aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes/2026-07-31-timeline-face-prototype-2.html
diff options
context:
space:
mode:
Diffstat (limited to 'docs/prototypes/2026-07-31-timeline-face-prototype-2.html')
-rw-r--r--docs/prototypes/2026-07-31-timeline-face-prototype-2.html198
1 files changed, 198 insertions, 0 deletions
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 &middot; prototype 2 &middot; <b>synthetic events</b></div>
+<div class="bar">
+ <span style="color:var(--steel)">diagonal</span>
+ <button data-dir="W" class="on">west at left &mdash; crossing 21.4%</button>
+ <button data-dir="E">east at left &mdash; 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 &mdash;
+ 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>