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