diff options
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | Makefile | 1 | ||||
| -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 | ||||
| -rw-r--r-- | todo.org | 19 |
10 files changed, 1509 insertions, 0 deletions
@@ -33,3 +33,6 @@ __pycache__/ # Screenshot the gallery probes emit on every run /tests/gallery-probes/fams.png + +# Engine modules copied in to run an archived prototype; they live in dotfiles. +docs/prototypes/timeline-face-*.js @@ -54,6 +54,7 @@ deps: # because the dir names are hyphenated, so each suite is run explicitly. # Elisp suites (tests/*/test-*.el, ERT) run in the same pass so one green # `make test-unit` covers the whole fast tier. + test-unit: @fail=0; \ for t in tests/*/test_*.py; do \ 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. @@ -80,6 +80,25 @@ Launched by hand afterward it runs fine and survives, so gammastep is not broken Worth its own task — the fix is a readiness wait or a retry around that exec-once, not a persistence change. Filed here for now because it surfaced during this investigation. +** TODO [#C] Re-apply the active program at session start :refactor:dotfiles:hyprland: +:PROPERTIES: +:CREATED: [2026-07-30 Thu] +:LAST_REVIEWED: 2026-07-30 +:END: +Follow-up to the direction-aware persistence fix (dotfiles, 2026-07-30). I took the narrow repair first and deferred this deliberately. + +=settings restore= replays individual toggles from =store.DEFAULTS["toggles"]= — caffeine, DND, and now dim. But =active_program= is stored and read by =programs.py= and never re-applied as a unit. So the panel's claim that a program is active is only true to the extent that the individual toggle replays happen to reconstruct it. + +Right now the gap is small: dim, DND and caffeine are the fields that don't survive on their own, and all three now persist individually. Night light is deliberately excluded (time-of-day dependent) and the power profile is persisted by powerprofilesctl itself. So the observable behavior is close to correct today. + +What's wrong is the structure, not the current output. Two sources of truth describe the same intent — a program definition and a list of remembered toggles — and nothing keeps them in step. Add a field to a program and it silently won't be replayed; change what a program means and the replay still asserts the old fields. The drift is invisible until someone notices the desktop disagreeing with the panel, which is exactly how the dim bug surfaced. + +The change: have restore resolve the active program and apply it through =panel.apply_toggle=, with the remembered-toggle list becoming a fallback for a session with no active program. The =ASSERT_OFF= direction rule still applies per toggle and must survive the move — caffeine's ON-only assertion is a safety property, not an optimization (velox, 2026-07-22). + +Grading: Minor severity (no wrong behavior today; it's a latent drift that produces one on the next change to a program's fields) x some users sometimes (only bites when a program definition changes) = P3 = [#C]. + +:solo: — the design was settled when the fix was chosen, the surface is enumerable (=session.restore=, =programs.py=, =panel.apply_toggle=), and it is verifiable locally: the existing restore suite plus a test that a stored active program is reconstructed field by field after a simulated session start. + ** TODO [#A] Idle commits silently drop the screen-lock wrapper :bug:hyprland:dotfiles:security: DEADLINE: <2026-07-29 Wed> :PROPERTIES: |
