aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes/2026-07-31-timeline-face-prototype-1.html
blob: d3238540ffd4b0b82c0abb449ce06e7992371608 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
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 &middot; prototype 1 &middot; <b>synthetic data</b></div>
<div class="bar">
  <span style="color:var(--steel)">direction</span>
  <button data-dir="A" class="on">A &mdash; crossing 20%</button>
  <button data-dir="B">B &mdash; crossing 80%</button>
  <button data-dir="C">C &mdash; cosmos orbit</button>
  <button data-dir="D">D &mdash; 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&times;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 &mdash; 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 &mdash; 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>