diff options
Diffstat (limited to 'docs/prototypes')
| -rw-r--r-- | docs/prototypes/2026-07-31-timeline-face-prototype-1.html | 367 | ||||
| -rw-r--r-- | docs/prototypes/2026-07-31-timeline-face-prototype-2.html | 198 | ||||
| -rw-r--r-- | docs/prototypes/2026-07-31-timeline-face-prototype-3.html | 224 | ||||
| -rw-r--r-- | docs/prototypes/2026-07-31-timeline-face-prototype-4.html | 242 | ||||
| -rw-r--r-- | docs/prototypes/2026-07-31-timeline-face-prototype-5.html | 263 | ||||
| -rw-r--r-- | docs/prototypes/2026-07-31-timeline-face-prototype-6.html | 151 | ||||
| -rw-r--r-- | docs/prototypes/README.md | 41 |
7 files changed, 1486 insertions, 0 deletions
diff --git a/docs/prototypes/2026-07-31-timeline-face-prototype-1.html b/docs/prototypes/2026-07-31-timeline-face-prototype-1.html new file mode 100644 index 0000000..d323854 --- /dev/null +++ b/docs/prototypes/2026-07-31-timeline-face-prototype-1.html @@ -0,0 +1,367 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>Timeline face — prototype 1</title> +<style> + :root{ + --ground:#050505; --cream:#f3e7c5; --silver:#bfc4d0; --steel:#969385; + --dim:#7c838a; --gold:#dab53d; --gold-hi:#ffd75f; --slate:#54677d; + --night:#39435a; --line:#2c2823; --now:#c8452f; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",ui-monospace,monospace; + } + *{box-sizing:border-box} + html,body{margin:0;height:100%;background:#111;color:var(--silver); + font-family:var(--mono)} + .chrome{padding:14px 18px 0;font-size:13px;letter-spacing:.16em; + text-transform:uppercase;color:var(--steel)} + .chrome b{color:var(--cream);font-weight:700} + .bar{padding:10px 18px 14px;display:flex;gap:10px;flex-wrap:wrap; + align-items:center;font-size:13px} + button{font-family:var(--mono);font-size:12px;letter-spacing:.1em; + text-transform:uppercase;background:#191919;color:var(--silver); + border:1px solid #2e2e2e;padding:6px 11px;cursor:pointer} + button.on{background:var(--gold);color:#111;border-color:var(--gold)} + .note{color:var(--dim);font-size:12px;letter-spacing:.04em; + text-transform:none;padding:0 18px 12px;max-width:1100px;line-height:1.5} + .stage{margin:0 18px 18px;border:1px solid #262626;background:var(--ground); + /* the real surface is 3440x1440; scaled to fit the page */ + width:calc(100% - 36px);aspect-ratio:3440/1440;position:relative} + svg{position:absolute;inset:0;width:100%;height:100%} +</style> +</head> +<body> + +<div class="chrome">Timeline face · prototype 1 · <b>synthetic data</b></div> +<div class="bar"> + <span style="color:var(--steel)">direction</span> + <button data-dir="A" class="on">A — crossing 20%</button> + <button data-dir="B">B — crossing 80%</button> + <button data-dir="C">C — cosmos orbit</button> + <button data-dir="D">D — typographic</button> + <span style="width:18px"></span> + <button id="tnow" class="on">live clock</button> + <button id="tscrub">scrub day</button> +</div> +<div class="note"> + Surface is 3440×1440, drawn to scale. Cities are Craig's live waybar + worldclock roster (15 zones, home New Orleans). Events are a synthetic + fixture, never his real agenda — this repo is still served anonymously + over cgit, so real calendar titles cannot live in a tracked file. + Brightness runs dark in the past to bright in the future. The next event is + gold. Type size follows tick spacing, capped at 72px, and grey level is + derived from that same size — Maeda's law from Line, one rule driving + scale, weight and colour. +</div> + +<div class="stage"><svg id="face" viewBox="0 0 3440 1440" preserveAspectRatio="none"></svg></div> + +<script> +// --------------------------------------------------------------------------- +// Data. Cities are real (the roster is not sensitive); events are invented. +// --------------------------------------------------------------------------- +const CITIES = [ + {tz:"Pacific/Honolulu", label:"Honolulu"}, + {tz:"America/Anchorage", label:"Anchorage"}, + {tz:"America/Los_Angeles",label:"Berkeley"}, + {tz:"America/Chicago", label:"New Orleans", home:true}, + {tz:"America/New_York", label:"New York"}, + {tz:"Europe/London", label:"London"}, + {tz:"Europe/Paris", label:"Paris"}, + {tz:"Europe/Athens", label:"Athens"}, + {tz:"Europe/Istanbul", label:"Istanbul"}, + {tz:"Asia/Yerevan", label:"Yerevan"}, + {tz:"Asia/Kolkata", label:"Delhi"}, + {tz:"Asia/Shanghai", label:"Shanghai"}, + {tz:"Asia/Tokyo", label:"Tokyo"}, + {tz:"Australia/Sydney", label:"Sydney"}, + {tz:"Pacific/Auckland", label:"Wellington"}, +]; + +// Offsets in hours from "now", so the fixture is always relative and never +// goes stale. Durations in minutes. Titles are invented. +const FIXTURE = [ + {dh:-3.4, dur: 25, title:"Morning prep"}, + {dh:-1.9, dur: 15, title:"Daily standup"}, + {dh:-0.6, dur: 30, title:"Review inbox"}, + {dh: 0.8, dur: 25, title:"Weekly 1:1"}, + {dh: 2.3, dur: 60, title:"Design review"}, + {dh: 4.1, dur: 30, title:"Vendor call"}, + {dh: 6.5, dur: 45, title:"Planning"}, + {dh: 9.2, dur: 20, title:"Wind down"}, + {dh:12.0, dur: 30, title:"Goodnight"}, +]; + +const W = 3440, H = 1440, SPAN_H = 24, MAX_FONT = 72; +const svg = document.getElementById("face"); +const NS = "http://www.w3.org/2000/svg"; + +let direction = "A"; +let scrub = null; // null = live clock, else epoch ms + +// Maeda's law: size follows spacing, grey follows size. +const fontFor = sp => Math.min(MAX_FONT, sp); +const greyFor = sz => Math.floor(Math.min(MAX_FONT, sz) * 127 / MAX_FONT) + 128; + +function el(name, attrs, text){ + const n = document.createElementNS(NS, name); + for (const k in attrs) n.setAttribute(k, attrs[k]); + if (text != null) n.textContent = text; + return n; +} + +function tzParts(date, tz){ + const f = new Intl.DateTimeFormat("en-GB", {timeZone:tz, hour12:false, + hour:"2-digit", minute:"2-digit", weekday:"short"}); + const p = {}; + for (const {type, value} of f.formatToParts(date)) p[type] = value; + return p; +} + +// Sun-independent day/night proxy: local hour between 06 and 19. +const isDay = h => h >= 6 && h < 19; + +function now(){ return scrub != null ? new Date(scrub) : new Date(); } + +// --------------------------------------------------------------------------- +// Shared geometry +// --------------------------------------------------------------------------- +function layout(){ + const crossFrac = direction === "B" ? 0.80 : 0.20; + const M = {l:150, r:150, t:110, b:130}; + return { + crossFrac, + x0: M.l, x1: W - M.r, y0: M.t, y1: H - M.b, + crossX: M.l + (W - M.l - M.r) * crossFrac, + }; +} + +// Horizontal axis: 24 hours wide, `now` sits at crossX. +function timeToX(g, t, ref){ + const hours = (t - ref) / 3600000; + const pxPerHour = (g.x1 - g.x0) / SPAN_H; + return g.crossX + hours * pxPerHour; +} + +// --------------------------------------------------------------------------- +// Directions +// --------------------------------------------------------------------------- +function drawDiagonalCities(g, ref, homeY){ + const n = CITIES.length; + // Cities ride a diagonal: west/behind at bottom-left, east/ahead at top-right. + CITIES.forEach((c, i) => { + const f = i / (n - 1); + const y = g.y1 - f * (g.y1 - g.y0); + const x = g.x0 + f * (g.crossX - g.x0) * 0.55; + const p = tzParts(ref, c.tz); + const hr = parseInt(p.hour, 10); + const day = isDay(hr); + const sp = (g.y1 - g.y0) / (n - 1); + const size = Math.max(11, Math.min(fontFor(sp) * 0.34, 30)); + + const fill = c.home ? "var(--gold)" : (day ? "var(--cream)" : "var(--slate)"); + const t = el("text", {x, y, "text-anchor":"end", "font-size":size, + fill, "letter-spacing":"0.18em", + "font-weight": c.home ? 700 : 400}); + t.textContent = c.label.toUpperCase() + (day ? "" : " ☾"); + svg.appendChild(t); + + svg.appendChild(el("text", {x: x + 14, y, "font-size": size * 0.92, + fill: c.home ? "var(--gold-hi)" : (day ? "var(--steel)" : "var(--night)"), + "letter-spacing":"0.06em"}, `${p.hour}:${p.minute}`)); + + // the wire out to the axis + svg.appendChild(el("line", {x1: x + 110, y1: y - 5, x2: g.x1, y2: y - 5, + stroke: c.home ? "var(--gold)" : "var(--line)", + "stroke-width": c.home ? 1.4 : 0.6, + "stroke-opacity": c.home ? 0.5 : 0.5})); + }); +} + +function drawHourAxis(g, ref, y){ + const pxPerHour = (g.x1 - g.x0) / SPAN_H; + const size = Math.max(12, Math.min(fontFor(pxPerHour) * 0.30, 26)); + const start = new Date(ref.getTime() - (g.crossX - g.x0) / pxPerHour * 3600000); + const h0 = new Date(start); h0.setMinutes(0, 0, 0); + for (let k = 0; k <= SPAN_H + 1; k++){ + const t = new Date(h0.getTime() + k * 3600000); + const x = timeToX(g, t.getTime(), ref.getTime()); + if (x < g.x0 - 1 || x > g.x1 + 1) continue; + const future = t.getTime() >= ref.getTime(); + // brightness encodes tense: past dark, future bright + const lvl = future ? greyFor(size) : Math.round(greyFor(size) * 0.42); + const col = `rgb(${lvl},${lvl},${lvl})`; + svg.appendChild(el("line", {x1:x, y1:y - 9, x2:x, y2:y + 9, + stroke:col, "stroke-width":1, "stroke-opacity":0.55})); + const hh = String(t.getHours()).padStart(2, "0"); + svg.appendChild(el("text", {x, y: y + 34, "text-anchor":"middle", + "font-size": size * 0.74, fill: col, "letter-spacing":"0.12em"}, hh)); + } +} + +function drawEvents(g, ref, y){ + const refMs = ref.getTime(); + const pxPerHour = (g.x1 - g.x0) / SPAN_H; + const evs = FIXTURE.map(e => ({ + ...e, start: refMs + e.dh * 3600000, + end: refMs + e.dh * 3600000 + e.dur * 60000, + })).sort((a, b) => a.start - b.start); + const next = evs.find(e => e.start >= refMs); + + // Perpendicular axis: vertical offset is collision avoidance only. It + // carries no data. Duration is a quantity of time, so it reads on the time + // axis as bar length, not as height. + const lanes = []; + evs.forEach(e => { + const x = timeToX(g, e.start, refMs); + if (x < g.x0 - 40 || x > g.x1 + 40) return; + const wpx = Math.max(3, (e.dur / 60) * pxPerHour); + const future = e.start >= refMs; + const isNext = next && e.start === next.start; + const estWidth = e.title.length * 9 + 60; + + let lane = 0; + while (lanes[lane] != null && lanes[lane] > x - estWidth) lane++; + lanes[lane] = x + estWidth; + const dir = lane % 2 === 0 ? -1 : 1; + const ly = y + dir * (46 + Math.floor(lane / 2) * 40); + + const lvl = future ? 232 : 96; + const col = isNext ? "var(--gold-hi)" : `rgb(${lvl},${lvl},${lvl})`; + + svg.appendChild(el("rect", {x, y: y - 5, width: wpx, height: 10, + fill: col, "fill-opacity": isNext ? 0.95 : (future ? 0.5 : 0.28)})); + svg.appendChild(el("line", {x1:x, y1:y, x2:x, y2:ly + (dir < 0 ? 12 : -22), + stroke: col, "stroke-width": isNext ? 1.6 : 0.8, + "stroke-opacity": isNext ? 0.8 : 0.35})); + + const hh = new Date(e.start); + const label = `${String(hh.getHours()).padStart(2,"0")}:${String(hh.getMinutes()).padStart(2,"0")}`; + svg.appendChild(el("text", {x, y: ly, "text-anchor":"middle", + "font-size": isNext ? 30 : 20, fill: col, + "font-weight": isNext ? 700 : 400, "letter-spacing":"0.06em"}, + isNext ? `${label} ${e.title}` : e.title)); + if (!isNext) + svg.appendChild(el("text", {x, y: ly + (dir < 0 ? -20 : 20), + "text-anchor":"middle", "font-size":15, + fill:`rgb(${Math.round(lvl*0.62)},${Math.round(lvl*0.62)},${Math.round(lvl*0.66)})`, + "letter-spacing":"0.1em"}, label)); + }); +} + +function drawCrossing(g, ref, y){ + svg.appendChild(el("line", {x1:g.crossX, y1:g.y0 - 30, x2:g.crossX, y2:g.y1 + 40, + stroke:"var(--now)", "stroke-width":1.2, "stroke-opacity":0.65})); + svg.appendChild(el("circle", {cx:g.crossX, cy:y, r:7, fill:"var(--now)"})); + const p = tzParts(ref, "America/Chicago"); + svg.appendChild(el("text", {x:g.crossX + 18, y:g.y0 - 10, "font-size":26, + fill:"var(--now)", "letter-spacing":"0.14em"}, + `NOW ${p.hour}:${p.minute}`)); +} + +function drawOdometer(g, ref){ + // Cosmos's linear strip living inside the frame, at a different scale. + const s = ref.getSeconds(); + for (let k = -6; k <= 6; k++){ + const v = (s + k + 60) % 60; + const yy = H / 2 + k * 26; + const d = 1 - Math.abs(k) / 7; + const lvl = Math.round(80 + d * 150); + svg.appendChild(el("text", {x: W - 46, y: yy, "text-anchor":"end", + "font-size": 13 + d * 5, fill:`rgb(${lvl},${lvl},${lvl})`, + "letter-spacing":"0.1em"}, String(v).padStart(2,"0"))); + } +} + +function drawOrbit(g, ref){ + // Direction C: the 24-hour cycle as a rotated ellipse, cities as bodies, + // now on the sweep hand. Cosmos's construction with timezones as the system. + const cx = W * 0.42, cy = H * 0.52, rx = W * 0.30, ry = H * 0.34, tilt = -14; + const rad = tilt * Math.PI / 180; + const place = (frac) => { + const a = frac * Math.PI * 2 - Math.PI / 2; + const px = Math.cos(a) * rx, py = Math.sin(a) * ry; + return [cx + px * Math.cos(rad) - py * Math.sin(rad), + cy + px * Math.sin(rad) + py * Math.cos(rad)]; + }; + svg.appendChild(el("ellipse", {cx, cy, rx, ry, + transform:`rotate(${tilt} ${cx} ${cy})`, fill:"none", + stroke:"var(--line)", "stroke-width":1, "stroke-opacity":0.7})); + + CITIES.forEach(c => { + const p = tzParts(ref, c.tz); + const hr = parseInt(p.hour,10) + parseInt(p.minute,10)/60; + const [x, y] = place(hr / 24); + const day = isDay(Math.floor(hr)); + svg.appendChild(el("circle", {cx:x, cy:y, r: c.home ? 6 : 3, + fill: c.home ? "var(--gold)" : (day ? "var(--cream)" : "var(--night)")})); + svg.appendChild(el("text", {x: x + 12, y: y + 5, "font-size": c.home ? 22 : 17, + fill: c.home ? "var(--gold-hi)" : (day ? "var(--steel)" : "var(--night)"), + "letter-spacing":"0.12em"}, `${c.label.toUpperCase()} ${p.hour}:${p.minute}`)); + }); + + const hNow = ref.getHours() + ref.getMinutes()/60; + const [hx, hy] = place(hNow / 24); + svg.appendChild(el("line", {x1:cx, y1:cy, x2:hx, y2:hy, + stroke:"var(--now)", "stroke-width":1.4, "stroke-opacity":0.8})); +} + +// --------------------------------------------------------------------------- +function render(){ + while (svg.firstChild) svg.removeChild(svg.firstChild); + const ref = now(); + const g = layout(); + const homeIdx = CITIES.findIndex(c => c.home); + const homeY = g.y1 - (homeIdx / (CITIES.length - 1)) * (g.y1 - g.y0); + + if (direction === "C"){ + drawOrbit(g, ref); + drawEvents(g, ref, H - 210); + drawHourAxis(g, ref, H - 150); + drawCrossing(g, ref, H - 150); + } else if (direction === "D"){ + // Typographic: no diagonal. The day axis carries everything, cities stack + // as a quiet column so the type law is the only structure. + CITIES.forEach((c, i) => { + const p = tzParts(ref, c.tz); + const day = isDay(parseInt(p.hour,10)); + const y = g.y0 + 26 + i * 26; + svg.appendChild(el("text", {x: g.x0 - 40, y, "text-anchor":"end", + "font-size":16, fill: c.home ? "var(--gold)" : (day ? "var(--steel)" : "var(--night)"), + "letter-spacing":"0.14em"}, `${c.label.toUpperCase()} ${p.hour}:${p.minute}`)); + }); + drawEvents(g, ref, H * 0.56); + drawHourAxis(g, ref, H * 0.56); + drawCrossing(g, ref, H * 0.56); + } else { + drawDiagonalCities(g, ref, homeY); + drawEvents(g, ref, homeY); + drawHourAxis(g, ref, homeY); + drawCrossing(g, ref, homeY); + } + drawOdometer(g, ref); +} + +document.querySelectorAll("button[data-dir]").forEach(b => { + b.addEventListener("click", () => { + direction = b.dataset.dir; + document.querySelectorAll("button[data-dir]").forEach(o => o.classList.toggle("on", o === b)); + render(); + }); +}); +document.getElementById("tnow").addEventListener("click", e => { + scrub = null; + e.target.classList.add("on"); + document.getElementById("tscrub").classList.remove("on"); +}); +document.getElementById("tscrub").addEventListener("click", e => { + scrub = Date.now(); + e.target.classList.add("on"); + document.getElementById("tnow").classList.remove("on"); +}); +// Scrubbing advances 4 minutes per tick so a full day passes in ~90s. +setInterval(() => { if (scrub != null) scrub += 4 * 60000; render(); }, 250); +render(); +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-31-timeline-face-prototype-2.html b/docs/prototypes/2026-07-31-timeline-face-prototype-2.html new file mode 100644 index 0000000..2a7bdc4 --- /dev/null +++ b/docs/prototypes/2026-07-31-timeline-face-prototype-2.html @@ -0,0 +1,198 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>Timeline face — prototype 2</title> +<style> + :root{ + --ground:#050505; --cream:#f3e7c5; --silver:#bfc4d0; --steel:#969385; + --dim:#7c838a; --gold:#dab53d; --gold-hi:#ffd75f; --slate:#54677d; + --night:#39435a; --line:#2c2823; --now:#c8452f; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",ui-monospace,monospace; + } + *{box-sizing:border-box} + html,body{margin:0;height:100%;background:#111;color:var(--silver);font-family:var(--mono)} + .chrome{padding:14px 18px 0;font-size:13px;letter-spacing:.16em; + text-transform:uppercase;color:var(--steel)} + .chrome b{color:var(--cream);font-weight:700} + .bar{padding:10px 18px 12px;display:flex;gap:10px;align-items:center;font-size:13px} + button{font-family:var(--mono);font-size:12px;letter-spacing:.1em;text-transform:uppercase; + background:#191919;color:var(--silver);border:1px solid #2e2e2e;padding:6px 11px;cursor:pointer} + button.on{background:var(--gold);color:#111;border-color:var(--gold)} + .note{color:var(--dim);font-size:12px;padding:0 18px 12px;max-width:1180px;line-height:1.55} + .stage{margin:0 18px 18px;border:1px solid #262626;background:var(--ground); + width:calc(100% - 36px);aspect-ratio:3440/1440;position:relative} + svg{position:absolute;inset:0;width:100%;height:100%} +</style> +</head> +<body> + +<div class="chrome">Timeline face · prototype 2 · <b>synthetic events</b></div> +<div class="bar"> + <span style="color:var(--steel)">diagonal</span> + <button data-dir="W" class="on">west at left — crossing 21.4%</button> + <button data-dir="E">east at left — crossing 78.6%</button> + <span style="width:20px"></span> + <button id="tscrub">scrub the day</button> +</div> +<div class="note"> + The crossing is not a free parameter. The horizontal meets the diagonal at the + home city, and New Orleans is 4th of 15 west-to-east, so it lands at 21.4%. + Reversing the diagonal puts the same intersection at 78.6%. Both numbers + describe one point. Events are invented; the city roster is real. + Vertical offset on an event is collision avoidance and carries no data — + duration is a quantity of time, so it reads as bar length on the time axis. +</div> + +<div class="stage"><svg id="face" viewBox="0 0 3440 1440"></svg></div> + +<script> +const CITIES = [ + {tz:"Pacific/Honolulu",label:"Honolulu"}, {tz:"America/Anchorage",label:"Anchorage"}, + {tz:"America/Los_Angeles",label:"Berkeley"},{tz:"America/Chicago",label:"New Orleans",home:true}, + {tz:"America/New_York",label:"New York"}, {tz:"Europe/London",label:"London"}, + {tz:"Europe/Paris",label:"Paris"}, {tz:"Europe/Athens",label:"Athens"}, + {tz:"Europe/Istanbul",label:"Istanbul"}, {tz:"Asia/Yerevan",label:"Yerevan"}, + {tz:"Asia/Kolkata",label:"Delhi"}, {tz:"Asia/Shanghai",label:"Shanghai"}, + {tz:"Asia/Tokyo",label:"Tokyo"}, {tz:"Australia/Sydney",label:"Sydney"}, + {tz:"Pacific/Auckland",label:"Wellington"}, +]; +const FIXTURE = [ + {dh:-3.4,dur:25,title:"Morning prep"}, {dh:-1.9,dur:15,title:"Daily standup"}, + {dh:-0.6,dur:30,title:"Review inbox"}, {dh:0.8,dur:25,title:"Weekly 1:1"}, + {dh:2.3,dur:60,title:"Design review"}, {dh:4.1,dur:30,title:"Vendor call"}, + {dh:6.5,dur:45,title:"Planning"}, {dh:9.2,dur:20,title:"Wind down"}, + {dh:12.0,dur:30,title:"Goodnight"}, +]; + +const W=3440, H=1440, SPAN_H=24, MAX_FONT=72, NS="http://www.w3.org/2000/svg"; +const svg=document.getElementById("face"); +let orient="W", scrub=null; + +const greyFor = sz => Math.floor(Math.min(MAX_FONT,sz)*127/MAX_FONT)+128; +const rgb = l => `rgb(${l},${l},${l})`; +function el(n,a,t){const e=document.createElementNS(NS,n);for(const k in a)e.setAttribute(k,a[k]); + if(t!=null)e.textContent=t;return e;} +function tzParts(d,tz){const f=new Intl.DateTimeFormat("en-GB",{timeZone:tz,hour12:false, + hour:"2-digit",minute:"2-digit"});const p={};for(const{type,value}of f.formatToParts(d))p[type]=value;return p;} +const isDay = h => h>=6 && h<19; +const now = () => scrub!=null ? new Date(scrub) : new Date(); + +// The diagonal spans the full plot. Home's rank fixes the crossing. +function geom(){ + const M={l:210,r:210,t:150,b:190}; + const n=CITIES.length, hi=CITIES.findIndex(c=>c.home); + const frac = orient==="W" ? hi/(n-1) : 1-hi/(n-1); + return {x0:M.l, x1:W-M.r, y0:M.t, y1:H-M.b, n, hi, frac, + crossX: M.l+(W-M.l-M.r)*frac, + homeY: (H-M.b)-(hi/(n-1))*((H-M.b)-M.t)}; +} +const cityPos = (g,i) => { + const f=i/(g.n-1); + return [g.x0+(orient==="W"?f:1-f)*(g.x1-g.x0), g.y1-f*(g.y1-g.y0)]; +}; +const pxPerHour = g => (g.x1-g.x0)/SPAN_H; +const timeToX = (g,t,ref) => g.crossX + (t-ref)/3600000*pxPerHour(g); + +function drawDiagonal(g,ref){ + const [ax,ay]=cityPos(g,0), [bx,by]=cityPos(g,g.n-1); + svg.appendChild(el("line",{x1:ax,y1:ay,x2:bx,y2:by,stroke:"var(--line)", + "stroke-width":1,"stroke-opacity":0.85})); + CITIES.forEach((c,i)=>{ + const [x,y]=cityPos(g,i); + const p=tzParts(ref,c.tz), hr=parseInt(p.hour,10), day=isDay(hr); + const size = c.home?27:20; + const col = c.home?"var(--gold)":(day?"var(--cream)":"var(--slate)"); + svg.appendChild(el("circle",{cx:x,cy:y,r:c.home?6:2.5, + fill:c.home?"var(--gold)":(day?"var(--cream)":"var(--night)")})); + // label sits above-left of its node so it never leaves the frame + svg.appendChild(el("text",{x:x-16,y:y-16,"text-anchor":"end","font-size":size,fill:col, + "letter-spacing":"0.18em","font-weight":c.home?700:400}, + c.label.toUpperCase()+(day?"":" ☾"))); + svg.appendChild(el("text",{x:x+16,y:y-16,"font-size":size*0.95, + fill:c.home?"var(--gold-hi)":(day?"var(--steel)":"var(--night)"), + "letter-spacing":"0.06em"},`${p.hour}:${p.minute}`)); + }); +} + +function drawHourAxis(g,ref){ + const pph=pxPerHour(g), y=g.homeY, size=22; + svg.appendChild(el("line",{x1:g.x0,y1:y,x2:g.x1,y2:y,stroke:"var(--line)","stroke-width":1})); + const h0=new Date(ref.getTime()-(g.crossX-g.x0)/pph*3600000); h0.setMinutes(0,0,0); + for(let k=0;k<=SPAN_H+1;k++){ + const t=new Date(h0.getTime()+k*3600000), x=timeToX(g,t.getTime(),ref.getTime()); + if(x<g.x0-1||x>g.x1+1) continue; + const future=t.getTime()>=ref.getTime(); + const lvl=future?greyFor(size):Math.round(greyFor(size)*0.40); + svg.appendChild(el("line",{x1:x,y1:y,x2:x,y2:y+11,stroke:rgb(lvl), + "stroke-width":1,"stroke-opacity":0.6})); + svg.appendChild(el("text",{x,y:y+40,"text-anchor":"middle","font-size":size*0.8, + fill:rgb(lvl),"letter-spacing":"0.12em"},String(t.getHours()).padStart(2,"0"))); + } +} + +function drawEvents(g,ref){ + const refMs=ref.getTime(), pph=pxPerHour(g), y=g.homeY; + const evs=FIXTURE.map(e=>({...e,start:refMs+e.dh*3600000})).sort((a,b)=>a.start-b.start); + const next=evs.find(e=>e.start>=refMs); + const lanes=[]; + evs.forEach(e=>{ + const x=timeToX(g,e.start,refMs); + if(x<g.x0-30||x>g.x1+30) return; + const wpx=Math.max(4,(e.dur/60)*pph), future=e.start>=refMs; + const isNext = next && e.start===next.start; + const est=e.title.length*11+70; + let lane=0; while(lanes[lane]!=null&&lanes[lane]>x-est) lane++; + lanes[lane]=x+est; + const ly = y - 46 - lane*46; // events live above the axis only + const lvl = future?236:100; + const col = isNext?"var(--gold-hi)":rgb(lvl); + svg.appendChild(el("rect",{x,y:y-5,width:wpx,height:10,fill:col, + "fill-opacity":isNext?0.95:(future?0.5:0.25)})); + svg.appendChild(el("line",{x1:x,y1:y-6,x2:x,y2:ly+10,stroke:col, + "stroke-width":isNext?1.6:0.8,"stroke-opacity":isNext?0.85:0.3})); + const d=new Date(e.start); + const hhmm=`${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}`; + svg.appendChild(el("text",{x,y:ly,"text-anchor":"middle","font-size":isNext?32:21, + fill:col,"font-weight":isNext?700:400,"letter-spacing":"0.05em"}, + isNext?`${hhmm} ${e.title}`:e.title)); + if(!isNext) svg.appendChild(el("text",{x,y:ly-21,"text-anchor":"middle","font-size":15, + fill:rgb(Math.round(lvl*0.6)),"letter-spacing":"0.1em"},hhmm)); + }); +} + +function drawCrossing(g,ref){ + svg.appendChild(el("line",{x1:g.crossX,y1:g.y0-60,x2:g.crossX,y2:g.y1+60, + stroke:"var(--now)","stroke-width":1.2,"stroke-opacity":0.55})); + svg.appendChild(el("circle",{cx:g.crossX,cy:g.homeY,r:8,fill:"var(--now)"})); + const p=tzParts(ref,"America/Chicago"); + svg.appendChild(el("text",{x:g.crossX,y:g.y1+108,"text-anchor":"middle","font-size":30, + fill:"var(--now)","letter-spacing":"0.16em"},`NOW ${p.hour}:${p.minute}`)); + svg.appendChild(el("text",{x:g.crossX,y:g.y1+140,"text-anchor":"middle","font-size":15, + fill:"var(--dim)","letter-spacing":"0.14em"}, + `CROSSING ${(g.frac*100).toFixed(1)}%`)); +} + +function render(){ + while(svg.firstChild) svg.removeChild(svg.firstChild); + const ref=now(), g=geom(); + drawDiagonal(g,ref); + drawHourAxis(g,ref); + drawEvents(g,ref); + drawCrossing(g,ref); +} + +document.querySelectorAll("button[data-dir]").forEach(b=>b.addEventListener("click",()=>{ + orient=b.dataset.dir; + document.querySelectorAll("button[data-dir]").forEach(o=>o.classList.toggle("on",o===b)); + render(); +})); +document.getElementById("tscrub").addEventListener("click",e=>{ + scrub = scrub==null ? Date.now() : null; + e.target.classList.toggle("on", scrub!=null); +}); +setInterval(()=>{ if(scrub!=null) scrub+=4*60000; render(); },250); +render(); +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-31-timeline-face-prototype-3.html b/docs/prototypes/2026-07-31-timeline-face-prototype-3.html new file mode 100644 index 0000000..1fcc506 --- /dev/null +++ b/docs/prototypes/2026-07-31-timeline-face-prototype-3.html @@ -0,0 +1,224 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>Timeline face — prototype 3</title> +<style> + :root{ + --ground:#050505; --cream:#f3e7c5; --silver:#bfc4d0; --steel:#969385; + --dim:#7c838a; --gold:#dab53d; --gold-hi:#ffd75f; --slate:#54677d; + --night:#39435a; --line:#2c2823; --now:#c8452f; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",ui-monospace,monospace; + } + *{box-sizing:border-box} + html,body{margin:0;height:100%;background:#111;color:var(--silver);font-family:var(--mono)} + .chrome{padding:14px 18px 0;font-size:13px;letter-spacing:.16em; + text-transform:uppercase;color:var(--steel)} + .chrome b{color:var(--cream);font-weight:700} + .bar{padding:10px 18px 12px;display:flex;gap:10px;align-items:center;font-size:13px} + button{font-family:var(--mono);font-size:12px;letter-spacing:.1em;text-transform:uppercase; + background:#191919;color:var(--silver);border:1px solid #2e2e2e;padding:6px 11px;cursor:pointer} + button.on{background:var(--gold);color:#111;border-color:var(--gold)} + .note{color:var(--dim);font-size:12px;padding:0 18px 12px;max-width:1180px;line-height:1.55} + .stage{margin:0 18px 18px;border:1px solid #262626;background:var(--ground); + width:calc(100% - 36px);aspect-ratio:3440/1440;position:relative} + svg{position:absolute;inset:0;width:100%;height:100%} +</style> +</head> +<body> + +<div class="chrome">Timeline face · prototype 3 · <b>synthetic events</b></div> +<div class="bar"> + <span style="color:var(--steel)">latitude</span> + <button data-y="rank" class="on">ranked — evenly spaced</button> + <button data-y="true">true — honest degrees</button> + <span style="width:20px"></span> + <button id="tscrub">scrub the day</button> +</div> +<div class="note"> + North is up. x is real longitude and y is latitude, so the crossing still falls + out of where home sits — longitude is timezone, and New Orleans lands at + 20.4%, within a point of the rank-derived 21.4%. Two latitude scales: ranked + spaces the cities evenly and keeps the north-to-south order, true uses honest + degrees and shows what that costs, since nine of fifteen cities sit between + 28 and 41 degrees north. Events are invented; the roster is real. +</div> + +<div class="stage"><svg id="face" viewBox="0 0 3440 1440"></svg></div> + +<script> +// lat/lon are the roster's own — worldclock.conf already carries them. +const CITIES = [ + {tz:"Pacific/Honolulu", label:"Honolulu", lat: 21.31, lon:-157.86}, + {tz:"America/Anchorage", label:"Anchorage", lat: 61.22, lon:-149.90}, + {tz:"America/Los_Angeles",label:"Berkeley", lat: 37.87, lon:-122.27}, + {tz:"America/Chicago", label:"New Orleans",lat: 29.95, lon: -90.07, home:true}, + {tz:"America/New_York", label:"New York", lat: 40.71, lon: -74.01}, + {tz:"Europe/London", label:"London", lat: 51.51, lon: -0.13}, + {tz:"Europe/Paris", label:"Paris", lat: 48.86, lon: 2.35}, + {tz:"Europe/Athens", label:"Athens", lat: 37.98, lon: 23.73}, + {tz:"Europe/Istanbul", label:"Istanbul", lat: 41.01, lon: 28.98}, + {tz:"Asia/Yerevan", label:"Yerevan", lat: 40.18, lon: 44.51}, + {tz:"Asia/Kolkata", label:"Delhi", lat: 28.61, lon: 77.21}, + {tz:"Asia/Shanghai", label:"Shanghai", lat: 31.23, lon: 121.47}, + {tz:"Asia/Tokyo", label:"Tokyo", lat: 35.68, lon: 139.69}, + {tz:"Australia/Sydney", label:"Sydney", lat:-33.87, lon: 151.21}, + {tz:"Pacific/Auckland", label:"Wellington", lat:-41.29, lon: 174.78}, +]; +const LON = CITIES.map(c=>c.lon), LAT = CITIES.map(c=>c.lat); +const LON0=Math.min(...LON), LON1=Math.max(...LON); +const LAT0=Math.min(...LAT), LAT1=Math.max(...LAT); +// north-to-south rank, for the evenly-spaced variant +const RANK = CITIES.slice().sort((a,b)=>b.lat-a.lat).map(c=>c.label); +const FIXTURE = [ + {dh:-3.4,dur:25,title:"Morning prep"}, {dh:-1.9,dur:15,title:"Daily standup"}, + {dh:-0.6,dur:30,title:"Review inbox"}, {dh:0.8,dur:25,title:"Weekly 1:1"}, + {dh:2.3,dur:60,title:"Design review"}, {dh:4.1,dur:30,title:"Vendor call"}, + {dh:6.5,dur:45,title:"Planning"}, {dh:9.2,dur:20,title:"Wind down"}, + {dh:12.0,dur:30,title:"Goodnight"}, +]; + +const W=3440, H=1440, SPAN_H=24, MAX_FONT=72, NS="http://www.w3.org/2000/svg"; +const svg=document.getElementById("face"); +let yMode="rank", scrub=null; + +const greyFor = sz => Math.floor(Math.min(MAX_FONT,sz)*127/MAX_FONT)+128; +const rgb = l => `rgb(${l},${l},${l})`; +function el(n,a,t){const e=document.createElementNS(NS,n);for(const k in a)e.setAttribute(k,a[k]); + if(t!=null)e.textContent=t;return e;} +function tzParts(d,tz){const f=new Intl.DateTimeFormat("en-GB",{timeZone:tz,hour12:false, + hour:"2-digit",minute:"2-digit"});const p={};for(const{type,value}of f.formatToParts(d))p[type]=value;return p;} +const isDay = h => h>=6 && h<19; +const now = () => scrub!=null ? new Date(scrub) : new Date(); + +// Geography, not rank. x is real longitude, so the crossing still falls out of +// where home sits — longitude IS timezone. y is latitude, north up, which is +// the whole point of this iteration. +function geom(){ + const M={l:230,r:230,t:150,b:200}; + const hi=CITIES.findIndex(c=>c.home); + const [hx,hy]=cityXY(CITIES[hi],{x0:M.l,x1:W-M.r,y0:M.t,y1:H-M.b}); + return {x0:M.l, x1:W-M.r, y0:M.t, y1:H-M.b, n:CITIES.length, hi, + frac:(hx-M.l)/(W-M.l-M.r), crossX:hx, homeY:hy}; +} +function cityXY(c,g){ + const fx=(c.lon-LON0)/(LON1-LON0); + const fy = yMode==="true" + ? (c.lat-LAT0)/(LAT1-LAT0) // honest latitude + : 1-RANK.indexOf(c.label)/(CITIES.length-1); // even spacing, order kept + return [g.x0+fx*(g.x1-g.x0), g.y1-fy*(g.y1-g.y0)]; +} +const cityPos = (g,i) => cityXY(CITIES[i],g); +const pxPerHour = g => (g.x1-g.x0)/SPAN_H; +const timeToX = (g,t,ref) => g.crossX + (t-ref)/3600000*pxPerHour(g); + +function drawDiagonal(g,ref){ + // No connecting wire. Once y is latitude the cities are a scatter, not a + // sequence, and a polyline through them in longitude order draws a zigzag + // that asserts an adjacency the data does not have. The equator earns a rule + // instead — it is the one latitude that means something here. + if (yMode === "true") { + const eq = g.y1 - ((0-LAT0)/(LAT1-LAT0))*(g.y1-g.y0); + svg.appendChild(el("line",{x1:g.x0,y1:eq,x2:g.x1,y2:eq,stroke:"var(--line)", + "stroke-width":1,"stroke-dasharray":"6 10","stroke-opacity":0.5})); + svg.appendChild(el("text",{x:g.x0-14,y:eq+5,"text-anchor":"end","font-size":15, + fill:"var(--night)","letter-spacing":"0.14em"},"EQUATOR")); + } + CITIES.forEach((c,i)=>{ + const [x,y]=cityPos(g,i); + const p=tzParts(ref,c.tz), hr=parseInt(p.hour,10), day=isDay(hr); + const size = c.home?27:20; + const col = c.home?"var(--gold)":(day?"var(--cream)":"var(--slate)"); + svg.appendChild(el("circle",{cx:x,cy:y,r:c.home?6:2.5, + fill:c.home?"var(--gold)":(day?"var(--cream)":"var(--night)")})); + // label sits above-left of its node so it never leaves the frame + svg.appendChild(el("text",{x:x-16,y:y-16,"text-anchor":"end","font-size":size,fill:col, + "letter-spacing":"0.18em","font-weight":c.home?700:400}, + c.label.toUpperCase()+(day?"":" ☾"))); + svg.appendChild(el("text",{x:x+16,y:y-16,"font-size":size*0.95, + fill:c.home?"var(--gold-hi)":(day?"var(--steel)":"var(--night)"), + "letter-spacing":"0.06em"},`${p.hour}:${p.minute}`)); + }); +} + +function drawHourAxis(g,ref){ + const pph=pxPerHour(g), y=g.homeY, size=22; + svg.appendChild(el("line",{x1:g.x0,y1:y,x2:g.x1,y2:y,stroke:"var(--line)","stroke-width":1})); + const h0=new Date(ref.getTime()-(g.crossX-g.x0)/pph*3600000); h0.setMinutes(0,0,0); + for(let k=0;k<=SPAN_H+1;k++){ + const t=new Date(h0.getTime()+k*3600000), x=timeToX(g,t.getTime(),ref.getTime()); + if(x<g.x0-1||x>g.x1+1) continue; + const future=t.getTime()>=ref.getTime(); + const lvl=future?greyFor(size):Math.round(greyFor(size)*0.40); + svg.appendChild(el("line",{x1:x,y1:y,x2:x,y2:y+11,stroke:rgb(lvl), + "stroke-width":1,"stroke-opacity":0.6})); + svg.appendChild(el("text",{x,y:y+40,"text-anchor":"middle","font-size":size*0.8, + fill:rgb(lvl),"letter-spacing":"0.12em"},String(t.getHours()).padStart(2,"0"))); + } +} + +function drawEvents(g,ref){ + const refMs=ref.getTime(), pph=pxPerHour(g), y=g.homeY; + const evs=FIXTURE.map(e=>({...e,start:refMs+e.dh*3600000})).sort((a,b)=>a.start-b.start); + const next=evs.find(e=>e.start>=refMs); + const lanes=[]; + evs.forEach(e=>{ + const x=timeToX(g,e.start,refMs); + if(x<g.x0-30||x>g.x1+30) return; + const wpx=Math.max(4,(e.dur/60)*pph), future=e.start>=refMs; + const isNext = next && e.start===next.start; + const est=e.title.length*11+70; + let lane=0; while(lanes[lane]!=null&&lanes[lane]>x-est) lane++; + lanes[lane]=x+est; + const ly = y - 46 - lane*46; // events live above the axis only + const lvl = future?236:100; + const col = isNext?"var(--gold-hi)":rgb(lvl); + svg.appendChild(el("rect",{x,y:y-5,width:wpx,height:10,fill:col, + "fill-opacity":isNext?0.95:(future?0.5:0.25)})); + svg.appendChild(el("line",{x1:x,y1:y-6,x2:x,y2:ly+10,stroke:col, + "stroke-width":isNext?1.6:0.8,"stroke-opacity":isNext?0.85:0.3})); + const d=new Date(e.start); + const hhmm=`${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}`; + svg.appendChild(el("text",{x,y:ly,"text-anchor":"middle","font-size":isNext?32:21, + fill:col,"font-weight":isNext?700:400,"letter-spacing":"0.05em"}, + isNext?`${hhmm} ${e.title}`:e.title)); + if(!isNext) svg.appendChild(el("text",{x,y:ly-21,"text-anchor":"middle","font-size":15, + fill:rgb(Math.round(lvl*0.6)),"letter-spacing":"0.1em"},hhmm)); + }); +} + +function drawCrossing(g,ref){ + svg.appendChild(el("line",{x1:g.crossX,y1:g.y0-60,x2:g.crossX,y2:g.y1+60, + stroke:"var(--now)","stroke-width":1.2,"stroke-opacity":0.55})); + svg.appendChild(el("circle",{cx:g.crossX,cy:g.homeY,r:8,fill:"var(--now)"})); + const p=tzParts(ref,"America/Chicago"); + svg.appendChild(el("text",{x:g.crossX,y:g.y1+108,"text-anchor":"middle","font-size":30, + fill:"var(--now)","letter-spacing":"0.16em"},`NOW ${p.hour}:${p.minute}`)); + svg.appendChild(el("text",{x:g.crossX,y:g.y1+140,"text-anchor":"middle","font-size":15, + fill:"var(--dim)","letter-spacing":"0.14em"}, + `CROSSING ${(g.frac*100).toFixed(1)}%`)); +} + +function render(){ + while(svg.firstChild) svg.removeChild(svg.firstChild); + const ref=now(), g=geom(); + drawDiagonal(g,ref); + drawHourAxis(g,ref); + drawEvents(g,ref); + drawCrossing(g,ref); +} + +document.querySelectorAll("button[data-y]").forEach(b=>b.addEventListener("click",()=>{ + yMode=b.dataset.y; + document.querySelectorAll("button[data-y]").forEach(o=>o.classList.toggle("on",o===b)); + render(); +})); +document.getElementById("tscrub").addEventListener("click",e=>{ + scrub = scrub==null ? Date.now() : null; + e.target.classList.toggle("on", scrub!=null); +}); +setInterval(()=>{ if(scrub!=null) scrub+=4*60000; render(); },250); +render(); +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-31-timeline-face-prototype-4.html b/docs/prototypes/2026-07-31-timeline-face-prototype-4.html new file mode 100644 index 0000000..88648bf --- /dev/null +++ b/docs/prototypes/2026-07-31-timeline-face-prototype-4.html @@ -0,0 +1,242 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>Timeline face — prototype 4</title> +<style> + :root{ + --ground:#050505; --cream:#f3e7c5; --silver:#bfc4d0; --steel:#969385; + --dim:#7c838a; --gold:#dab53d; --gold-hi:#ffd75f; --slate:#54677d; + --night:#39435a; --line:#2c2823; --now:#c8452f; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",ui-monospace,monospace; + } + *{box-sizing:border-box} + html,body{margin:0;height:100%;background:#111;color:var(--silver);font-family:var(--mono)} + .chrome{padding:14px 18px 0;font-size:13px;letter-spacing:.16em; + text-transform:uppercase;color:var(--steel)} + .chrome b{color:var(--cream);font-weight:700} + .bar{padding:10px 18px 12px;display:flex;gap:10px;align-items:center;font-size:13px} + button{font-family:var(--mono);font-size:12px;letter-spacing:.1em;text-transform:uppercase; + background:#191919;color:var(--silver);border:1px solid #2e2e2e;padding:6px 11px;cursor:pointer} + button.on{background:var(--gold);color:#111;border-color:var(--gold)} + .note{color:var(--dim);font-size:12px;padding:0 18px 12px;max-width:1180px;line-height:1.55} + .stage{margin:0 18px 18px;border:1px solid #262626;background:var(--ground); + width:calc(100% - 36px);aspect-ratio:3440/1440;position:relative} + svg{position:absolute;inset:0;width:100%;height:100%} +</style> +</head> +<body> + +<div class="chrome">Timeline face · prototype 4 · <b>synthetic events</b></div> +<div class="bar"> + <span style="color:var(--steel)">latitude</span> + <button data-y="rank" class="on">ranked — evenly spaced</button> + <button data-y="true">true — honest degrees</button> + <span style="width:20px"></span> + <button id="tscrub">scrub the day</button> +</div> +<div class="note"> + One axis, one scale. The width is a full 360° of longitude and a full 24 + hours, which are the same quantity — so an event N hours ahead lands on + the city that is living that hour right now. Wind down at +9.2h falls on + Yerevan, 14 minutes off. Prototype 3 had the roster's 332.64° stretched to + the full width, putting the city scale 7.6% off the time scale. Home sits at + 20%, which is nearly forced: Honolulu is 67.8° west, so anything under + 18.8% drops it off the frame. Events are invented; the roster is real. +</div> + +<div class="stage"><svg id="face" viewBox="0 0 3440 1440"></svg></div> + +<script> +// lat/lon are the roster's own — worldclock.conf already carries them. +const CITIES = [ + {tz:"Pacific/Honolulu", label:"Honolulu", lat: 21.31, lon:-157.86}, + {tz:"America/Anchorage", label:"Anchorage", lat: 61.22, lon:-149.90}, + {tz:"America/Los_Angeles",label:"Berkeley", lat: 37.87, lon:-122.27}, + {tz:"America/Chicago", label:"New Orleans",lat: 29.95, lon: -90.07, home:true}, + {tz:"America/New_York", label:"New York", lat: 40.71, lon: -74.01}, + {tz:"Europe/London", label:"London", lat: 51.51, lon: -0.13}, + {tz:"Europe/Paris", label:"Paris", lat: 48.86, lon: 2.35}, + {tz:"Europe/Athens", label:"Athens", lat: 37.98, lon: 23.73}, + {tz:"Europe/Istanbul", label:"Istanbul", lat: 41.01, lon: 28.98}, + {tz:"Asia/Yerevan", label:"Yerevan", lat: 40.18, lon: 44.51}, + {tz:"Asia/Kolkata", label:"Delhi", lat: 28.61, lon: 77.21}, + {tz:"Asia/Shanghai", label:"Shanghai", lat: 31.23, lon: 121.47}, + {tz:"Asia/Tokyo", label:"Tokyo", lat: 35.68, lon: 139.69}, + {tz:"Australia/Sydney", label:"Sydney", lat:-33.87, lon: 151.21}, + {tz:"Pacific/Auckland", label:"Wellington", lat:-41.29, lon: 174.78}, +]; +const LON = CITIES.map(c=>c.lon), LAT = CITIES.map(c=>c.lat); +const LON0=Math.min(...LON), LON1=Math.max(...LON); +const LAT0=Math.min(...LAT), LAT1=Math.max(...LAT); +// north-to-south rank, for the evenly-spaced variant +const RANK = CITIES.slice().sort((a,b)=>b.lat-a.lat).map(c=>c.label); +const FIXTURE = [ + {dh:-3.4,dur:25,title:"Morning prep"}, {dh:-1.9,dur:15,title:"Daily standup"}, + {dh:-0.6,dur:30,title:"Review inbox"}, {dh:0.8,dur:25,title:"Weekly 1:1"}, + {dh:2.3,dur:60,title:"Design review"}, {dh:4.1,dur:30,title:"Vendor call"}, + {dh:6.5,dur:45,title:"Planning"}, {dh:9.2,dur:20,title:"Wind down"}, + {dh:12.0,dur:30,title:"Goodnight"}, +]; + +const W=3440, H=1440, SPAN_H=24, MAX_FONT=72, NS="http://www.w3.org/2000/svg"; +const svg=document.getElementById("face"); +let yMode="rank", scrub=null; + +const greyFor = sz => Math.floor(Math.min(MAX_FONT,sz)*127/MAX_FONT)+128; +const rgb = l => `rgb(${l},${l},${l})`; +function el(n,a,t){const e=document.createElementNS(NS,n);for(const k in a)e.setAttribute(k,a[k]); + if(t!=null)e.textContent=t;return e;} +function tzParts(d,tz){const f=new Intl.DateTimeFormat("en-GB",{timeZone:tz,hour12:false, + hour:"2-digit",minute:"2-digit"});const p={};for(const{type,value}of f.formatToParts(d))p[type]=value;return p;} +const isDay = h => h>=6 && h<19; +const now = () => scrub!=null ? new Date(scrub) : new Date(); + +// Geography, not rank. x is real longitude, so the crossing still falls out of +// where home sits — longitude IS timezone. y is latitude, north up, which is +// the whole point of this iteration. +// Home sits at 20% -- and that is very nearly forced. The westernmost city, +// Honolulu, is 67.8 degrees west of home, so home must be at least 18.8% from +// the left or Honolulu falls off the frame. 20% is the tightest round number +// that keeps the whole roster on screen. +const CROSS_FRAC = 0.20; +function geom(){ + const M={l:230,r:230,t:150,b:200}; + const hi=CITIES.findIndex(c=>c.home); + const [hx,hy]=cityXY(CITIES[hi],{x0:M.l,x1:W-M.r,y0:M.t,y1:H-M.b}); + return {x0:M.l, x1:W-M.r, y0:M.t, y1:H-M.b, n:CITIES.length, hi, + frac:(hx-M.l)/(W-M.l-M.r), crossX:hx, homeY:hy}; +} +// A full turn of longitude is a full day, so the width must be 360 degrees -- +// not the roster's 332.64 stretched to fit. Getting that wrong put the city +// scale 7.6% off the time scale, and the two are physically the same axis. +// With them aligned, an event N hours ahead lands on the city that is living +// that hour right now. +const HOME_LON = CITIES.find(c=>c.home).lon; +// Normalise into the window the time axis actually shows -- 72 deg west of home +// through 288 deg east -- not the shortest way round. Shortest-path (-180..180) +// sends Tokyo, Shanghai, Sydney and Wellington westward off the left edge, +// because from New Orleans they are more than half a turn east. +const WEST = CROSS_FRAC * 360; +const relLon = lon => ((lon - HOME_LON + WEST) % 360 + 360) % 360 - WEST; +function cityXY(c,g){ + const fx = CROSS_FRAC + relLon(c.lon)/360; + const fy = yMode==="true" + ? (c.lat-LAT0)/(LAT1-LAT0) // honest latitude + : 1-RANK.indexOf(c.label)/(CITIES.length-1); // even spacing, order kept + return [g.x0+fx*(g.x1-g.x0), g.y1-fy*(g.y1-g.y0)]; +} +const cityPos = (g,i) => cityXY(CITIES[i],g); +const pxPerHour = g => (g.x1-g.x0)/SPAN_H; +const timeToX = (g,t,ref) => g.crossX + (t-ref)/3600000*pxPerHour(g); + +function drawDiagonal(g,ref){ + // No connecting wire. Once y is latitude the cities are a scatter, not a + // sequence, and a polyline through them in longitude order draws a zigzag + // that asserts an adjacency the data does not have. The equator earns a rule + // instead — it is the one latitude that means something here. + if (yMode === "true") { + const eq = g.y1 - ((0-LAT0)/(LAT1-LAT0))*(g.y1-g.y0); + svg.appendChild(el("line",{x1:g.x0,y1:eq,x2:g.x1,y2:eq,stroke:"var(--line)", + "stroke-width":1,"stroke-dasharray":"6 10","stroke-opacity":0.5})); + svg.appendChild(el("text",{x:g.x0-14,y:eq+5,"text-anchor":"end","font-size":15, + fill:"var(--night)","letter-spacing":"0.14em"},"EQUATOR")); + } + CITIES.forEach((c,i)=>{ + const [x,y]=cityPos(g,i); + const p=tzParts(ref,c.tz), hr=parseInt(p.hour,10), day=isDay(hr); + const size = c.home?27:20; + const col = c.home?"var(--gold)":(day?"var(--cream)":"var(--slate)"); + svg.appendChild(el("circle",{cx:x,cy:y,r:c.home?6:2.5, + fill:c.home?"var(--gold)":(day?"var(--cream)":"var(--night)")})); + // label sits above-left of its node so it never leaves the frame + svg.appendChild(el("text",{x:x-16,y:y-16,"text-anchor":"end","font-size":size,fill:col, + "letter-spacing":"0.18em","font-weight":c.home?700:400}, + c.label.toUpperCase()+(day?"":" ☾"))); + svg.appendChild(el("text",{x:x+16,y:y-16,"font-size":size*0.95, + fill:c.home?"var(--gold-hi)":(day?"var(--steel)":"var(--night)"), + "letter-spacing":"0.06em"},`${p.hour}:${p.minute}`)); + }); +} + +function drawHourAxis(g,ref){ + const pph=pxPerHour(g), y=g.homeY, size=22; + svg.appendChild(el("line",{x1:g.x0,y1:y,x2:g.x1,y2:y,stroke:"var(--line)","stroke-width":1})); + const h0=new Date(ref.getTime()-(g.crossX-g.x0)/pph*3600000); h0.setMinutes(0,0,0); + for(let k=0;k<=SPAN_H+1;k++){ + const t=new Date(h0.getTime()+k*3600000), x=timeToX(g,t.getTime(),ref.getTime()); + if(x<g.x0-1||x>g.x1+1) continue; + const future=t.getTime()>=ref.getTime(); + const lvl=future?greyFor(size):Math.round(greyFor(size)*0.40); + svg.appendChild(el("line",{x1:x,y1:y,x2:x,y2:y+11,stroke:rgb(lvl), + "stroke-width":1,"stroke-opacity":0.6})); + svg.appendChild(el("text",{x,y:y+40,"text-anchor":"middle","font-size":size*0.8, + fill:rgb(lvl),"letter-spacing":"0.12em"},String(t.getHours()).padStart(2,"0"))); + } +} + +function drawEvents(g,ref){ + const refMs=ref.getTime(), pph=pxPerHour(g), y=g.homeY; + const evs=FIXTURE.map(e=>({...e,start:refMs+e.dh*3600000})).sort((a,b)=>a.start-b.start); + const next=evs.find(e=>e.start>=refMs); + const lanes=[]; + evs.forEach(e=>{ + const x=timeToX(g,e.start,refMs); + if(x<g.x0-30||x>g.x1+30) return; + const wpx=Math.max(4,(e.dur/60)*pph), future=e.start>=refMs; + const isNext = next && e.start===next.start; + const est=e.title.length*11+70; + let lane=0; while(lanes[lane]!=null&&lanes[lane]>x-est) lane++; + lanes[lane]=x+est; + const ly = y - 46 - lane*46; // events live above the axis only + const lvl = future?236:100; + const col = isNext?"var(--gold-hi)":rgb(lvl); + svg.appendChild(el("rect",{x,y:y-5,width:wpx,height:10,fill:col, + "fill-opacity":isNext?0.95:(future?0.5:0.25)})); + svg.appendChild(el("line",{x1:x,y1:y-6,x2:x,y2:ly+10,stroke:col, + "stroke-width":isNext?1.6:0.8,"stroke-opacity":isNext?0.85:0.3})); + const d=new Date(e.start); + const hhmm=`${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}`; + svg.appendChild(el("text",{x,y:ly,"text-anchor":"middle","font-size":isNext?32:21, + fill:col,"font-weight":isNext?700:400,"letter-spacing":"0.05em"}, + isNext?`${hhmm} ${e.title}`:e.title)); + if(!isNext) svg.appendChild(el("text",{x,y:ly-21,"text-anchor":"middle","font-size":15, + fill:rgb(Math.round(lvl*0.6)),"letter-spacing":"0.1em"},hhmm)); + }); +} + +function drawCrossing(g,ref){ + svg.appendChild(el("line",{x1:g.crossX,y1:g.y0-60,x2:g.crossX,y2:g.y1+60, + stroke:"var(--now)","stroke-width":1.2,"stroke-opacity":0.55})); + svg.appendChild(el("circle",{cx:g.crossX,cy:g.homeY,r:8,fill:"var(--now)"})); + const p=tzParts(ref,"America/Chicago"); + svg.appendChild(el("text",{x:g.crossX,y:g.y1+108,"text-anchor":"middle","font-size":30, + fill:"var(--now)","letter-spacing":"0.16em"},`NOW ${p.hour}:${p.minute}`)); + svg.appendChild(el("text",{x:g.crossX,y:g.y1+140,"text-anchor":"middle","font-size":15, + fill:"var(--dim)","letter-spacing":"0.14em"}, + `CROSSING ${(g.frac*100).toFixed(1)}%`)); +} + +function render(){ + while(svg.firstChild) svg.removeChild(svg.firstChild); + const ref=now(), g=geom(); + drawDiagonal(g,ref); + drawHourAxis(g,ref); + drawEvents(g,ref); + drawCrossing(g,ref); +} + +document.querySelectorAll("button[data-y]").forEach(b=>b.addEventListener("click",()=>{ + yMode=b.dataset.y; + document.querySelectorAll("button[data-y]").forEach(o=>o.classList.toggle("on",o===b)); + render(); +})); +document.getElementById("tscrub").addEventListener("click",e=>{ + scrub = scrub==null ? Date.now() : null; + e.target.classList.toggle("on", scrub!=null); +}); +setInterval(()=>{ if(scrub!=null) scrub+=4*60000; render(); },250); +render(); +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-31-timeline-face-prototype-5.html b/docs/prototypes/2026-07-31-timeline-face-prototype-5.html new file mode 100644 index 0000000..b77fd35 --- /dev/null +++ b/docs/prototypes/2026-07-31-timeline-face-prototype-5.html @@ -0,0 +1,263 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>Timeline face — prototype 5</title> +<style> + :root{ + --ground:#050505; --cream:#f3e7c5; --silver:#bfc4d0; --steel:#969385; + --dim:#7c838a; --gold:#dab53d; --gold-hi:#ffd75f; --slate:#54677d; + --night:#39435a; --line:#2c2823; --now:#c8452f; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",ui-monospace,monospace; + } + *{box-sizing:border-box} + html,body{margin:0;height:100%;background:#111;color:var(--silver);font-family:var(--mono)} + .chrome{padding:14px 18px 0;font-size:13px;letter-spacing:.16em; + text-transform:uppercase;color:var(--steel)} + .chrome b{color:var(--cream);font-weight:700} + .bar{padding:10px 18px 12px;display:flex;gap:10px;align-items:center;font-size:13px} + button{font-family:var(--mono);font-size:12px;letter-spacing:.1em;text-transform:uppercase; + background:#191919;color:var(--silver);border:1px solid #2e2e2e;padding:6px 11px;cursor:pointer} + button.on{background:var(--gold);color:#111;border-color:var(--gold)} + .note{color:var(--dim);font-size:12px;padding:0 18px 12px;max-width:1180px;line-height:1.55} + .stage{margin:0 18px 18px;border:1px solid #262626;background:var(--ground); + width:calc(100% - 36px);aspect-ratio:3440/1440;position:relative} + svg{position:absolute;inset:0;width:100%;height:100%} +</style> +</head> +<body> + +<div class="chrome">Timeline face · prototype 5 · <b>synthetic events</b></div> +<div class="bar"> + <span style="color:var(--steel)">latitude</span> + <button data-y="rank" class="on">ranked — evenly spaced</button> + <button data-y="true">true — honest degrees</button> + <span style="width:20px"></span> + <button id="tscrub">scrub the day</button> +</div> +<div class="note"> + x is now each city's <em>clock</em> offset from home, not its longitude. + Longitude only approximates time — zones are political, and against this + roster it is off by up to 1.32 h (Tokyo), partly because New Orleans is on + daylight time. So a city now sits exactly under its own local hour and an + event N hours ahead lands on the living-that-hour city with no error at all. + Latitude still sets y, and Tokyo really is south of Athens and Yerevan + — 35.7°N against 38.0° and 40.2°. + Events are invented; the roster is real. +</div> + +<div class="stage"><svg id="face" viewBox="0 0 3440 1440"></svg></div> + +<script> +// lat/lon are the roster's own — worldclock.conf already carries them. +const CITIES = [ + {tz:"Pacific/Honolulu", label:"Honolulu", lat: 21.31, lon:-157.86}, + {tz:"America/Anchorage", label:"Anchorage", lat: 61.22, lon:-149.90}, + {tz:"America/Los_Angeles",label:"Berkeley", lat: 37.87, lon:-122.27}, + {tz:"America/Chicago", label:"New Orleans",lat: 29.95, lon: -90.07, home:true}, + {tz:"America/New_York", label:"New York", lat: 40.71, lon: -74.01}, + {tz:"Europe/London", label:"London", lat: 51.51, lon: -0.13}, + {tz:"Europe/Paris", label:"Paris", lat: 48.86, lon: 2.35}, + {tz:"Europe/Athens", label:"Athens", lat: 37.98, lon: 23.73}, + {tz:"Europe/Istanbul", label:"Istanbul", lat: 41.01, lon: 28.98}, + {tz:"Asia/Yerevan", label:"Yerevan", lat: 40.18, lon: 44.51}, + {tz:"Asia/Kolkata", label:"Delhi", lat: 28.61, lon: 77.21}, + {tz:"Asia/Shanghai", label:"Shanghai", lat: 31.23, lon: 121.47}, + {tz:"Asia/Tokyo", label:"Tokyo", lat: 35.68, lon: 139.69}, + {tz:"Australia/Sydney", label:"Sydney", lat:-33.87, lon: 151.21}, + {tz:"Pacific/Auckland", label:"Wellington", lat:-41.29, lon: 174.78}, +]; +const LON = CITIES.map(c=>c.lon), LAT = CITIES.map(c=>c.lat); +const LON0=Math.min(...LON), LON1=Math.max(...LON); +const LAT0=Math.min(...LAT), LAT1=Math.max(...LAT); +// north-to-south rank, for the evenly-spaced variant +const RANK = CITIES.slice().sort((a,b)=>b.lat-a.lat).map(c=>c.label); +// One fixed instant the whole fixture hangs off. Captured once at load so the +// events keep absolute times and drift leftward as the day advances. +const BASE = Date.now(); +const FIXTURE = [ + {dh:-3.4,dur:25,title:"Morning prep"}, {dh:-1.9,dur:15,title:"Daily standup"}, + {dh:-0.6,dur:30,title:"Review inbox"}, {dh:0.8,dur:25,title:"Weekly 1:1"}, + {dh:2.3,dur:60,title:"Design review"}, {dh:4.1,dur:30,title:"Vendor call"}, + {dh:6.5,dur:45,title:"Planning"}, {dh:9.2,dur:20,title:"Wind down"}, + {dh:12.0,dur:30,title:"Goodnight"}, +]; + +const W=3440, H=1440, SPAN_H=24, MAX_FONT=72, NS="http://www.w3.org/2000/svg"; +const svg=document.getElementById("face"); +let yMode="rank", scrub=null; + +const greyFor = sz => Math.floor(Math.min(MAX_FONT,sz)*127/MAX_FONT)+128; +const rgb = l => `rgb(${l},${l},${l})`; +function el(n,a,t){const e=document.createElementNS(NS,n);for(const k in a)e.setAttribute(k,a[k]); + if(t!=null)e.textContent=t;return e;} +function tzParts(d,tz){const f=new Intl.DateTimeFormat("en-GB",{timeZone:tz,hour12:false, + hour:"2-digit",minute:"2-digit"});const p={};for(const{type,value}of f.formatToParts(d))p[type]=value;return p;} +const isDay = h => h>=6 && h<19; +const now = () => scrub!=null ? new Date(scrub) : new Date(); + +// Geography, not rank. x is real longitude, so the crossing still falls out of +// where home sits — longitude IS timezone. y is latitude, north up, which is +// the whole point of this iteration. +// The roster spans Honolulu at -5 h to Wellington at +17 h, so 22 of the 24 +// hours are spoken for. That pins the crossing between 20.8% (or Honolulu drops +// off the left) and 29.2% (or Wellington drops off the right). 21% is the +// tightest value that holds, so Craig's 20% was short by a hair -- and the +// window is genuinely almost forced. +const CROSS_FRAC = 0.21; +function geom(){ + const M={l:230,r:230,t:150,b:200}; + const hi=CITIES.findIndex(c=>c.home); + const [hx,hy]=cityXY(CITIES[hi],{x0:M.l,x1:W-M.r,y0:M.t,y1:H-M.b},now()); + return {x0:M.l, x1:W-M.r, y0:M.t, y1:H-M.b, n:CITIES.length, hi, + frac:(hx-M.l)/(W-M.l-M.r), crossX:hx, homeY:hy}; +} +// x is the city's CLOCK offset from home, not its longitude. +// +// Longitude is only an approximation of time, because time zones are political. +// Measured against this roster it is off by up to 1.32 h -- Tokyo's zone sits +// east of its meridian, Shanghai spans a huge span on one zone, and New Orleans +// is on daylight time (solar UTC-6, clock UTC-5), which shifts every comparison. +// Placing by longitude put Tokyo 1.3 h right of the hour it actually reads. +// Placing by clock offset makes a city sit exactly under its own local hour, so +// an event N hours ahead lands on the living-that-hour city with no error. +function tzOffsetHours(date, tz){ + const f = new Intl.DateTimeFormat("en-US",{timeZone:tz,hour12:false, + year:"numeric",month:"2-digit",day:"2-digit", + hour:"2-digit",minute:"2-digit",second:"2-digit"}); + const p={}; for(const {type,value} of f.formatToParts(date)) p[type]=value; + const asUTC = Date.UTC(+p.year, p.month-1, +p.day, + p.hour==="24"?0:+p.hour, +p.minute, +p.second); + return (asUTC - Math.floor(date.getTime()/1000)*1000)/3600000; +} +const WEST_H = CROSS_FRAC * 24; +function relHours(c, ref){ + const h = tzOffsetHours(ref, c.tz) - tzOffsetHours(ref, HOME_TZ); + return ((h + WEST_H) % 24 + 24) % 24 - WEST_H; +} +const HOME_TZ = CITIES.find(c=>c.home).tz; +function cityXY(c,g,ref){ + const fx = CROSS_FRAC + relHours(c, ref||new Date())/24; + const fy = yMode==="true" + ? (c.lat-LAT0)/(LAT1-LAT0) // honest latitude + : 1-RANK.indexOf(c.label)/(CITIES.length-1); // even spacing, order kept + return [g.x0+fx*(g.x1-g.x0), g.y1-fy*(g.y1-g.y0)]; +} +const cityPos = (g,i,ref) => cityXY(CITIES[i],g,ref); +const pxPerHour = g => (g.x1-g.x0)/SPAN_H; +const timeToX = (g,t,ref) => g.crossX + (t-ref)/3600000*pxPerHour(g); + +function drawDiagonal(g,ref){ + // No connecting wire. Once y is latitude the cities are a scatter, not a + // sequence, and a polyline through them in longitude order draws a zigzag + // that asserts an adjacency the data does not have. The equator earns a rule + // instead — it is the one latitude that means something here. + if (yMode === "true") { + const eq = g.y1 - ((0-LAT0)/(LAT1-LAT0))*(g.y1-g.y0); + svg.appendChild(el("line",{x1:g.x0,y1:eq,x2:g.x1,y2:eq,stroke:"var(--line)", + "stroke-width":1,"stroke-dasharray":"6 10","stroke-opacity":0.5})); + svg.appendChild(el("text",{x:g.x0-14,y:eq+5,"text-anchor":"end","font-size":15, + fill:"var(--night)","letter-spacing":"0.14em"},"EQUATOR")); + } + CITIES.forEach((c,i)=>{ + const [x,y]=cityPos(g,i,ref); + const p=tzParts(ref,c.tz), hr=parseInt(p.hour,10), day=isDay(hr); + const size = c.home?27:20; + const col = c.home?"var(--gold)":(day?"var(--cream)":"var(--slate)"); + svg.appendChild(el("circle",{cx:x,cy:y,r:c.home?6:2.5, + fill:c.home?"var(--gold)":(day?"var(--cream)":"var(--night)")})); + // label sits above-left of its node so it never leaves the frame + svg.appendChild(el("text",{x:x-16,y:y-16,"text-anchor":"end","font-size":size,fill:col, + "letter-spacing":"0.18em","font-weight":c.home?700:400}, + c.label.toUpperCase()+(day?"":" ☾"))); + svg.appendChild(el("text",{x:x+16,y:y-16,"font-size":size*0.95, + fill:c.home?"var(--gold-hi)":(day?"var(--steel)":"var(--night)"), + "letter-spacing":"0.06em"},`${p.hour}:${p.minute}`)); + }); +} + +function drawHourAxis(g,ref){ + const pph=pxPerHour(g), y=g.homeY, size=22; + svg.appendChild(el("line",{x1:g.x0,y1:y,x2:g.x1,y2:y,stroke:"var(--line)","stroke-width":1})); + const h0=new Date(ref.getTime()-(g.crossX-g.x0)/pph*3600000); h0.setMinutes(0,0,0); + for(let k=0;k<=SPAN_H+1;k++){ + const t=new Date(h0.getTime()+k*3600000), x=timeToX(g,t.getTime(),ref.getTime()); + if(x<g.x0-1||x>g.x1+1) continue; + const future=t.getTime()>=ref.getTime(); + const lvl=future?greyFor(size):Math.round(greyFor(size)*0.40); + svg.appendChild(el("line",{x1:x,y1:y,x2:x,y2:y+11,stroke:rgb(lvl), + "stroke-width":1,"stroke-opacity":0.6})); + svg.appendChild(el("text",{x,y:y+40,"text-anchor":"middle","font-size":size*0.8, + fill:rgb(lvl),"letter-spacing":"0.12em"},String(t.getHours()).padStart(2,"0"))); + } +} + +function drawEvents(g,ref){ + const refMs=ref.getTime(), pph=pxPerHour(g), y=g.homeY; + // Anchored to a fixed instant, NOT to refMs. Recomputing start from the + // current moment pinned every event to the crossing, so the clock labels + // advanced while nothing moved. Real events sit at fixed instants and the + // day flows past them right to left. + const evs=FIXTURE.map(e=>({...e,start:BASE+e.dh*3600000})).sort((a,b)=>a.start-b.start); + const next=evs.find(e=>e.start>=refMs); + const lanes=[]; + evs.forEach(e=>{ + const x=timeToX(g,e.start,refMs); + if(x<g.x0-30||x>g.x1+30) return; + const wpx=Math.max(4,(e.dur/60)*pph), future=e.start>=refMs; + const isNext = next && e.start===next.start; + const est=e.title.length*11+70; + let lane=0; while(lanes[lane]!=null&&lanes[lane]>x-est) lane++; + lanes[lane]=x+est; + const ly = y - 46 - lane*46; // events live above the axis only + const lvl = future?236:100; + const col = isNext?"var(--gold-hi)":rgb(lvl); + svg.appendChild(el("rect",{x,y:y-5,width:wpx,height:10,fill:col, + "fill-opacity":isNext?0.95:(future?0.5:0.25)})); + svg.appendChild(el("line",{x1:x,y1:y-6,x2:x,y2:ly+10,stroke:col, + "stroke-width":isNext?1.6:0.8,"stroke-opacity":isNext?0.85:0.3})); + const d=new Date(e.start); + const hhmm=`${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}`; + svg.appendChild(el("text",{x,y:ly,"text-anchor":"middle","font-size":isNext?32:21, + fill:col,"font-weight":isNext?700:400,"letter-spacing":"0.05em"}, + isNext?`${hhmm} ${e.title}`:e.title)); + if(!isNext) svg.appendChild(el("text",{x,y:ly-21,"text-anchor":"middle","font-size":15, + fill:rgb(Math.round(lvl*0.6)),"letter-spacing":"0.1em"},hhmm)); + }); +} + +function drawCrossing(g,ref){ + svg.appendChild(el("line",{x1:g.crossX,y1:g.y0-60,x2:g.crossX,y2:g.y1+60, + stroke:"var(--now)","stroke-width":1.2,"stroke-opacity":0.55})); + svg.appendChild(el("circle",{cx:g.crossX,cy:g.homeY,r:8,fill:"var(--now)"})); + const p=tzParts(ref,"America/Chicago"); + svg.appendChild(el("text",{x:g.crossX,y:g.y1+108,"text-anchor":"middle","font-size":30, + fill:"var(--now)","letter-spacing":"0.16em"},`NOW ${p.hour}:${p.minute}`)); + svg.appendChild(el("text",{x:g.crossX,y:g.y1+140,"text-anchor":"middle","font-size":15, + fill:"var(--dim)","letter-spacing":"0.14em"}, + `CROSSING ${(g.frac*100).toFixed(1)}%`)); +} + +function render(){ + while(svg.firstChild) svg.removeChild(svg.firstChild); + const ref=now(), g=geom(); + drawDiagonal(g,ref); + drawHourAxis(g,ref); + drawEvents(g,ref); + drawCrossing(g,ref); +} + +document.querySelectorAll("button[data-y]").forEach(b=>b.addEventListener("click",()=>{ + yMode=b.dataset.y; + document.querySelectorAll("button[data-y]").forEach(o=>o.classList.toggle("on",o===b)); + render(); +})); +document.getElementById("tscrub").addEventListener("click",e=>{ + scrub = scrub==null ? Date.now() : null; + e.target.classList.toggle("on", scrub!=null); +}); +setInterval(()=>{ if(scrub!=null) scrub+=4*60000; render(); },250); +render(); +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-31-timeline-face-prototype-6.html b/docs/prototypes/2026-07-31-timeline-face-prototype-6.html new file mode 100644 index 0000000..d3f1427 --- /dev/null +++ b/docs/prototypes/2026-07-31-timeline-face-prototype-6.html @@ -0,0 +1,151 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>Timeline face — prototype 6</title> +<style> + :root{ + --ground:#050505; --cream:#f3e7c5; --silver:#bfc4d0; --steel:#969385; + --dim:#7c838a; --gold:#dab53d; --gold-hi:#ffd75f; --slate:#54677d; + --night:#39435a; --grid:#332f28; --grid-hi:#4a443a; --now:#c8452f; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",ui-monospace,monospace; + } + *{box-sizing:border-box} + html,body{margin:0;height:100%;background:var(--ground);overflow:hidden; + font-family:var(--mono)} + #face{position:fixed;inset:0;width:100vw;height:100vh;display:block} + #hud{position:fixed;left:10px;top:8px;z-index:5;display:flex;gap:8px; + align-items:center;font-size:11px} + button{font-family:var(--mono);font-size:11px;letter-spacing:.1em; + text-transform:uppercase;background:#16161699;color:var(--silver); + border:1px solid #2e2e2e;padding:5px 9px;cursor:pointer} + button.on{background:var(--gold);color:#111;border-color:var(--gold)} + select{font-family:var(--mono);font-size:11px;letter-spacing:.08em; + background:#161616;color:var(--silver);border:1px solid #2e2e2e; + padding:4px 6px} + + /* One rule per primitive class the engine emits. All appearance lives here; + the engine decides what to draw and never how it looks. */ + .meridian,.parallel{stroke:var(--grid);stroke-width:1;stroke-dasharray:7 7} + .meridian-anchor,.equator{stroke:var(--grid-hi);stroke-width:1} + .day-axis{stroke:var(--grid-hi);stroke-width:1.4} + line.now{stroke:var(--now);stroke-width:1.2;stroke-opacity:.6} + circle.now{fill:var(--now)} + text.now{fill:var(--now)} + .grid-label{fill:var(--steel)} + rect.event-next{fill:var(--gold-hi);fill-opacity:.95} + rect.event-future{fill:#ececec;fill-opacity:.5} + rect.event-past{fill:#686868;fill-opacity:.25} + text.event-next,text.event-next-above{fill:var(--gold-hi);font-weight:700} + text.event-future,text.event-future-above{fill:#ececec} + text.event-past,text.event-past-above{fill:#686868} + .leader{stroke:var(--grid-hi);stroke-width:1;stroke-opacity:.8} + .dot-home{fill:var(--gold)} + /* temporary: every city marked in red so their true positions are obvious */ + .dot-city{fill:#e0392b} + .city-home{fill:var(--gold);font-weight:700} + .city-day{fill:var(--cream)} + .city-night{fill:var(--slate)} +</style> +</head> +<body> +<div id="hud"> + <button id="bTrue" class="on">true latitude</button> + <button id="bRank">ranked</button> + <button id="bScrub">scrub</button> + <span style="width:14px"></span> + <span style="color:var(--steel)">home</span> + <select id="homes"></select> +</div> +<svg id="face"></svg> + +<script src="timeline-face-engine.js"></script> +<script> +// The renderer. It measures the font, asks the engine for a scene, and turns +// primitives into SVG nodes. It holds no layout, no time and no geography -- +// all of that lives in the engine, where it is tested. +"use strict"; +const NS = "http://www.w3.org/2000/svg"; +const svg = document.getElementById("face"); +let mode = "true", scrub = null, charRatio = 0.6, home = TF.HOME; + +const now = () => (scrub != null ? new Date(scrub) : new Date()); + +/** + * Advance width of one glyph as a fraction of font size, measured against the + * font actually in use. Every width downstream is arithmetic from this, which + * is what lets the engine derive margins without touching the DOM. + */ +function measureChar() { + const probe = document.createElementNS(NS, "text"); + probe.setAttribute("font-size", "100"); + probe.setAttribute("x", "-9999"); + probe.setAttribute("y", "-9999"); + probe.textContent = "MMMMMMMMMM"; + svg.appendChild(probe); + try { + const w = probe.getComputedTextLength(); + if (w > 0) charRatio = w / 10 / 100; + } catch (e) { /* keep the default */ } + probe.remove(); +} + +const ATTRS = { + line: (p) => ({ x1: p.x1, y1: p.y1, x2: p.x2, y2: p.y2 }), + circle: (p) => ({ cx: p.cx, cy: p.cy, r: p.r }), + rect: (p) => ({ x: p.x, y: p.y, width: p.width, height: p.height }), + text: (p) => ({ + x: p.x, y: p.y, "text-anchor": p.anchor, + "font-size": p.size, "letter-spacing": p.ls + "em", + }), +}; + +function toNode(p) { + const node = document.createElementNS(NS, p.kind); + const attrs = ATTRS[p.kind](p); + for (const k in attrs) if (attrs[k] != null) node.setAttribute(k, attrs[k]); + node.setAttribute("class", p.cls); + if (p.kind === "text") node.textContent = p.text; + return node; +} + +function render() { + const width = svg.clientWidth || window.innerWidth; + const height = svg.clientHeight || window.innerHeight; + svg.setAttribute("viewBox", "0 0 " + width + " " + height); + const items = TF.scene({ width, height, ref: now(), mode, charRatio, home }); + svg.replaceChildren(...items.map(toNode)); +} + +function pick(id, value) { + mode = value; + for (const b of ["bTrue", "bRank"]) { + document.getElementById(b).classList.toggle("on", b === id); + } + render(); +} +document.getElementById("bTrue").onclick = () => pick("bTrue", "true"); +document.getElementById("bRank").onclick = () => pick("bRank", "rank"); +document.getElementById("bScrub").onclick = (e) => { + scrub = scrub == null ? Date.now() : null; + e.target.classList.toggle("on", scrub != null); +}; + +// One button per candidate home. Moving home moves the crossing with it, since +// the roster reaches a fixed distance either side of wherever you are. +const homeSel = document.getElementById("homes"); +TF.HOMES.forEach((h, i) => { + const o = document.createElement("option"); + o.value = String(i); + o.textContent = h.label; + homeSel.appendChild(o); +}); +homeSel.onchange = () => { home = TF.HOMES[+homeSel.value]; render(); }; + +new ResizeObserver(render).observe(document.documentElement); +setInterval(function () { if (scrub != null) scrub += 4 * 60000; render(); }, 250); +measureChar(); +render(); +</script> +</body> +</html> diff --git a/docs/prototypes/README.md b/docs/prototypes/README.md new file mode 100644 index 0000000..fd73b2e --- /dev/null +++ b/docs/prototypes/README.md @@ -0,0 +1,41 @@ +# Prototypes + +Frozen design history. Every file here is a snapshot of what a design looked +like on the date in its name, kept so the evolution is walkable. Nothing here +is maintained, and nothing here is loaded by anything that runs. + +Most pages are self-contained: open one and it renders. + +## The world face + +The world wallpaper's engine used to live here and was hand-copied into +`~/.dotfiles/settings/faces/` on every change. Both copies were byte-identical +and both test suites ran the same tests, so the duplication bought nothing and +risked drift — the two pages' palettes had already diverged enough to break a +scripted edit. + +The live engine, its tests and the lab page now live only with the shipped +face, in the dotfiles `settings/faces/` directory: + +- `timeline-face-engine.js`, `timeline-face-spine.js` — the engine +- `world.html` — the projected wallpaper +- `lab.html` — the same engine with a movable home and a fast clock, for + trying an idea without touching the wallpaper + +`make test-faces` in dotfiles runs their tests. + +`2026-07-31-timeline-face-prototype-6.html` and the option-A pages load the +engine, so they are readable here but not runnable here. The engine is +deliberately **not** kept in this repo: it carries a list of the places I +travel to, with coordinates, and this repo is published. A design record is +not worth publishing that. + +To run prototype 6, copy the two engine modules in beside it: + +```sh +cp ~/.dotfiles/settings/faces/timeline-face-*.js docs/prototypes/ +``` + +They are gitignored here, so a copy stays local. Note that the live engine +keeps moving: prototype 6 is a photograph of one day's design and a current +engine may not render it the way it looked. |
