diff options
Diffstat (limited to 'docs/prototypes/2026-07-31-timeline-face-prototype-1.html')
| -rw-r--r-- | docs/prototypes/2026-07-31-timeline-face-prototype-1.html | 367 |
1 files changed, 367 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> |
