aboutsummaryrefslogtreecommitdiff
path: root/working/clock-display-references/2026-07-30-maeda-line-standalone.html
diff options
context:
space:
mode:
Diffstat (limited to 'working/clock-display-references/2026-07-30-maeda-line-standalone.html')
-rw-r--r--working/clock-display-references/2026-07-30-maeda-line-standalone.html457
1 files changed, 457 insertions, 0 deletions
diff --git a/working/clock-display-references/2026-07-30-maeda-line-standalone.html b/working/clock-display-references/2026-07-30-maeda-line-standalone.html
new file mode 100644
index 0000000..783939c
--- /dev/null
+++ b/working/clock-display-references/2026-07-30-maeda-line-standalone.html
@@ -0,0 +1,457 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Line (C2, 1997) — Maeda × Shiseido tribute</title>
+<style>
+:root{
+ --ground:#151311;
+ --panel:#100f0f;
+ --well:#0a0c0d;
+ --raise:#1a1917;
+ --silver:#bfc4d0;
+ --cream:#f3e7c5;
+ --steel:#969385;
+ --dim:#7c838a;
+ --wash:#2c2f32;
+ --gold:#e2a038;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground);font-size:112%}
+body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 4rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.wrap{max-width:820px;margin:0 auto}
+.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase}
+h1{font-size:1.5rem;color:var(--cream);font-weight:600;margin:.35rem 0 .2rem}
+.sub{color:var(--dim);font-size:.9rem;margin-bottom:1.6rem}
+.card{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;
+ border-radius:12px;padding:1.2rem;margin-bottom:1.4rem}
+.stage{background:var(--well);border:1px solid #22201d;border-radius:8px;padding:1rem;
+ display:flex;justify-content:center}
+.readout{margin-top:.8rem;color:var(--gold);font-size:.82rem;letter-spacing:.06em}
+.wnote{color:var(--dim);font-size:.85rem;margin-top:1rem}
+.wnote b{color:var(--steel);font-weight:600}
+.igrid{display:grid;grid-template-columns:11rem 1fr;gap:.35rem .9rem;margin-top:1rem;
+ font-size:.82rem}
+.ik{color:var(--steel)}
+.iv{color:var(--dim)}
+</style>
+</head>
+<body>
+<div class="wrap">
+ <p class="eyebrow">Waste Time Beautifully · C2</p>
+ <h1>Line — zoom through time</h1>
+ <p class="sub">John Maeda for Shiseido, 1997 (cal1.class) — rebuilt as a web component. Standalone copy, no build step.</p>
+
+ <div class="card">
+ <div class="stage"><maeda-line id="piece"></maeda-line></div>
+ <div class="readout" id="readout">scale — · counter —</div>
+ <p class="wnote"><b>Controls:</b> move the pointer along the diagonal to aim; press and
+ hold to zoom continuously into that moment; release and you float back out. Full zoom
+ is exactly 60 seconds, and the release is symmetric.</p>
+ <p class="wnote">Time as a single line — <b>diagonal</b>, bottom-left to top-right, the
+ detail every written description of this applet gets wrong. Maeda's note: it reflects
+ the relativity and comparability of time intervals. The zoom is the content.</p>
+ </div>
+
+ <div class="card">
+ <p class="eyebrow">Spec</p>
+ <div class="igrid">
+ <span class="ik">original</span><span class="iv">cal1.class, 12,339 bytes, single class, self-contained (Java 1.1)</span>
+ <span class="ik">dimensions</span><span class="iv">460×400, black ground, Helvetica throughout</span>
+ <span class="ik">collection</span><span class="iv">SFMOMA 99.554</span>
+ <span class="ik">zoom plateaus</span><span class="iv">72px per day / hour / minute / second — scn 1 → 72 → 1,728 → 103,680 → 6,220,800 px/day</span>
+ <span class="ik">easing</span><span class="iv">counter at 20 increments/sec over segments [150, 250, 350, 450]</span>
+ <span class="ik">labels</span><span class="iv">font = min(72, unit spacing); text under 5px degrades to a line; gray ramps 128→255 as a unit matures; the current unit is always red</span>
+ <span class="ik">period quirks</span><span class="iv">Mac JVM patch capped zoom at hour level; an unused HAPPY NEW YEAR string ships in the bytecode</span>
+ <span class="ik">tests</span><span class="iv">33 (Vitest + fast-check)</span>
+ </div>
+ </div>
+</div>
+
+<script>
+(() => {
+ // src/lib/calendar.js
+ function epochDayFromCivil(y, m, d) {
+ const yy = y - (m <= 2 ? 1 : 0);
+ const era = Math.floor(yy / 400);
+ const yoe = yy - era * 400;
+ const doy = Math.floor((153 * (m + (m > 2 ? -3 : 9)) + 2) / 5) + d - 1;
+ const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy;
+ return era * 146097 + doe - 719468;
+ }
+ function civilFromEpochDay(ed) {
+ const z = ed + 719468;
+ const era = Math.floor(z / 146097);
+ const doe = z - era * 146097;
+ const yoe = Math.floor(
+ (doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365
+ );
+ const y = yoe + era * 400;
+ const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
+ const mp = Math.floor((5 * doy + 2) / 153);
+ const d = doy - Math.floor((153 * mp + 2) / 5) + 1;
+ const m = mp + (mp < 10 ? 3 : -9);
+ return { y: y + (m <= 2 ? 1 : 0), m, d };
+ }
+
+ // src/pieces/line/model.js
+ var DAY = 86400;
+ var FAITHFUL_1997 = {
+ stops: [1, 72, 1728, 103680, 6220800],
+ segments: [150, 250, 350, 450],
+ incsPerSec: 20
+ };
+ function totalIncrements(cfg) {
+ return cfg.segments.reduce((a, b) => a + b, 0);
+ }
+ function scaleForCounter(cfg, counter) {
+ const total = totalIncrements(cfg);
+ const c = Math.max(0, Math.min(total, counter));
+ let acc = 0;
+ for (let i = 0; i < cfg.segments.length; i++) {
+ const seg = cfg.segments[i];
+ if (c <= acc + seg) {
+ const f = (c - acc) / seg;
+ return cfg.stops[i] + f * (cfg.stops[i + 1] - cfg.stops[i]);
+ }
+ acc += seg;
+ }
+ return cfg.stops[cfg.stops.length - 1];
+ }
+ function advanceCounter(cfg, counter, dtMs, held) {
+ const d = cfg.incsPerSec * dtMs / 1e3;
+ const next = held ? counter + d : counter - d;
+ return Math.max(0, Math.min(totalIncrements(cfg), next));
+ }
+ function pxOfTime(view, t) {
+ return (t - view.focus) * view.scale / DAY + view.anchorPx;
+ }
+ function timeOfPx(view, px) {
+ return (px - view.anchorPx) * DAY / view.scale + view.focus;
+ }
+ var MAX_FONT = 72;
+ var MIN_TEXT_PX = 5;
+ function fontSizeFor(spacingPx) {
+ return Math.min(MAX_FONT, spacingPx);
+ }
+ function textVisible(fontSize) {
+ return fontSize >= MIN_TEXT_PX;
+ }
+ function grayFor(fontSize) {
+ return Math.floor(Math.min(MAX_FONT, fontSize) * 127 / MAX_FONT) + 128;
+ }
+ var FIXED_UNITS = { second: 1, minute: 60, hour: 3600, day: DAY };
+ function* calendarTicks(unit, t0, t1) {
+ let { y, m } = civilFromEpochDay(Math.floor(t0 / DAY));
+ const stride = unit === "decade" ? 10 : unit === "century" ? 100 : 1;
+ if (unit === "month") {
+ for (; ; ) {
+ const t = epochDayFromCivil(y, m, 1) * DAY;
+ if (t >= t0) break;
+ m++;
+ if (m > 12) m = 1, y++;
+ }
+ for (; ; ) {
+ const t = epochDayFromCivil(y, m, 1) * DAY;
+ if (t > t1) return;
+ yield t;
+ m++;
+ if (m > 12) m = 1, y++;
+ }
+ } else {
+ let yy = Math.ceil(y / stride) * stride;
+ if (epochDayFromCivil(yy, 1, 1) * DAY < t0) yy += stride;
+ while (epochDayFromCivil(yy - stride, 1, 1) * DAY >= t0) yy -= stride;
+ for (; ; ) {
+ const t = epochDayFromCivil(yy, 1, 1) * DAY;
+ if (t > t1) return;
+ if (t >= t0) yield t;
+ yy += stride;
+ }
+ }
+ }
+ function ticksInRange(unit, t0, t1) {
+ if (unit in FIXED_UNITS) {
+ const w = FIXED_UNITS[unit];
+ const out = [];
+ for (let t = Math.ceil(t0 / w) * w; t <= t1; t += w) out.push(t);
+ return out;
+ }
+ return [...calendarTicks(unit, t0, t1)];
+ }
+ var MONTHS = [
+ "JANUARY",
+ "FEBRUARY",
+ "MARCH",
+ "APRIL",
+ "MAY",
+ "JUNE",
+ "JULY",
+ "AUGUST",
+ "SEPTEMBER",
+ "OCTOBER",
+ "NOVEMBER",
+ "DECEMBER"
+ ];
+ function formatMonth(m) {
+ return MONTHS[m - 1];
+ }
+ function formatDay(m, d) {
+ return `${m}/${d}`;
+ }
+ function formatHour(h) {
+ const twelve = h % 12 === 0 ? 12 : h % 12;
+ return { text: ` ${twelve}`, suffix: h >= 12 ? "pm" : "am" };
+ }
+ function formatMinute(mm) {
+ return `:${String(mm).padStart(2, "0")}`;
+ }
+
+ // src/pieces/line/step.js
+ function initState(cfg, range, lineLenPx) {
+ return {
+ counter: 0,
+ scale: scaleForCounter(cfg, 0),
+ focus: 0,
+ // epoch seconds under the anchor pixel
+ anchorPx: 0,
+ // pixel along the line where focus projects
+ range,
+ // {tMin, tMax} or null for the unbounded twist
+ lineLenPx
+ };
+ }
+ function step(cfg, s, dtMs, input) {
+ const cursorPx = Math.max(0, Math.min(s.lineLenPx, input.cursorPx));
+ let { focus, anchorPx } = s;
+ if (input.held || s.counter > 0) {
+ focus = timeOfPx({ focus: s.focus, scale: s.scale, anchorPx: s.anchorPx }, cursorPx);
+ anchorPx = cursorPx;
+ }
+ const counter = advanceCounter(cfg, s.counter, dtMs, input.held);
+ const scale = scaleForCounter(cfg, counter);
+ if (!input.held && counter < cfg.segments[0]) {
+ const target = cursorPx * DAY;
+ focus += (target - focus) / (counter + 1);
+ anchorPx = cursorPx;
+ }
+ if (s.range) {
+ focus = Math.max(s.range.tMin, Math.min(s.range.tMax, focus));
+ }
+ return { ...s, counter, scale, focus, anchorPx };
+ }
+
+ // src/pieces/line/render.js
+ var LEVELS = [
+ { unit: "month", secs: 30 * DAY },
+ { unit: "day", secs: DAY },
+ { unit: "hour", secs: 3600 },
+ { unit: "minute", secs: 60 },
+ { unit: "second", secs: 1 }
+ ];
+ var FACE = { w: 460, h: 400 };
+ function lineOrigin(state) {
+ const margin = (FACE.h - state.lineLenPx) / 2;
+ return { mx: FACE.w - FACE.h + margin, my: margin };
+ }
+ function labelFor(unit, t) {
+ const dayIdx = Math.floor(t / DAY);
+ const civ = civilFromEpochDay(dayIdx);
+ const rem = t - dayIdx * DAY;
+ const hh = Math.floor(rem / 3600);
+ const mm = Math.floor(rem % 3600 / 60);
+ const ss = Math.floor(rem % 60);
+ switch (unit) {
+ case "month":
+ return formatMonth(civ.m);
+ case "day":
+ return formatDay(civ.m, civ.d);
+ case "hour": {
+ const h = formatHour(hh);
+ return h.text + h.suffix;
+ }
+ case "minute":
+ return formatMinute(mm);
+ case "second":
+ return formatMinute(ss);
+ default:
+ return "";
+ }
+ }
+ function sameTick(unit, t, now) {
+ const w = { second: 1, minute: 60, hour: 3600, day: DAY }[unit];
+ if (w) return Math.floor(t / w) === Math.floor(now / w);
+ const a = civilFromEpochDay(Math.floor(t / DAY));
+ const b = civilFromEpochDay(Math.floor(now / DAY));
+ return a.y === b.y && a.m === b.m;
+ }
+ function draw(ctx, state, nowSec) {
+ const { mx, my } = lineOrigin(state);
+ ctx.fillStyle = "#000";
+ ctx.fillRect(0, 0, FACE.w, FACE.h);
+ const view = { focus: state.focus, scale: state.scale, anchorPx: state.anchorPx };
+ const tA = Math.max(
+ state.range ? state.range.tMin : -Infinity,
+ state.focus - state.anchorPx * DAY / state.scale
+ );
+ const tB = Math.min(
+ state.range ? state.range.tMax : Infinity,
+ state.focus + (state.lineLenPx - state.anchorPx) * DAY / state.scale
+ );
+ ctx.strokeStyle = "#666";
+ ctx.beginPath();
+ ctx.moveTo(mx, FACE.h - my);
+ ctx.lineTo(mx + state.lineLenPx, FACE.h - my - state.lineLenPx);
+ ctx.stroke();
+ const colWidth = {};
+ for (let i = LEVELS.length - 1; i >= 0; i--) {
+ const { unit, secs } = LEVELS[i];
+ const spacing = secs * state.scale / DAY;
+ if (spacing < 2 && unit !== "month") {
+ colWidth[unit] = 0;
+ continue;
+ }
+ const size = Math.floor(fontSizeFor(spacing));
+ colWidth[unit] = textVisible(size) ? size * 2.2 : 0;
+ }
+ for (let i = LEVELS.length - 1; i >= 0; i--) {
+ const { unit, secs } = LEVELS[i];
+ const spacing = secs * state.scale / DAY;
+ if (spacing < 2 && unit !== "month") continue;
+ const size = unit === "month" ? Math.floor(Math.min(72, 11 + state.scale)) : Math.floor(fontSizeFor(spacing));
+ const gray = unit === "month" ? 255 : grayFor(size);
+ let offset = 0;
+ for (let j = LEVELS.length - 1; j > i; j--) offset += colWidth[LEVELS[j].unit];
+ ctx.font = `${Math.max(size, 1)}px Helvetica, Arial, sans-serif`;
+ for (const t of ticksInRange(unit, tA, tB)) {
+ const n = pxOfTime(view, t);
+ if (n < 0 || n > state.lineLenPx) continue;
+ const x = mx + n;
+ const y = FACE.h - my - n;
+ const isNow = sameTick(unit, t, nowSec);
+ ctx.strokeStyle = ctx.fillStyle = isNow ? "#f00" : `rgb(${gray},${gray},${gray})`;
+ ctx.beginPath();
+ ctx.moveTo(x, y);
+ ctx.lineTo(x + 3, y);
+ ctx.stroke();
+ const text = labelFor(unit, t);
+ if (textVisible(size)) {
+ const w = ctx.measureText(text).width;
+ ctx.fillText(text, x - w - offset, y);
+ } else {
+ ctx.beginPath();
+ ctx.moveTo(x - 4 - offset, y);
+ ctx.lineTo(x - offset, y);
+ ctx.stroke();
+ }
+ }
+ }
+ ctx.fillStyle = "#ccc";
+ ctx.font = "15px Helvetica, Arial, sans-serif";
+ const label = `${Math.round(state.scale)}X${state.counter >= 1200 ? " (MAX)" : ""}`;
+ ctx.fillText(label, 3, 16);
+ }
+
+ // src/lib/loop.js
+ var STEP_MS = 30;
+ function startLoop({ stepFn, renderFn, stepMs = STEP_MS }) {
+ let acc = 0;
+ let last = null;
+ let raf = null;
+ let running = true;
+ function frame(ts) {
+ if (!running) return;
+ if (last === null) last = ts;
+ acc += Math.min(ts - last, 250);
+ last = ts;
+ while (acc >= stepMs) {
+ stepFn(stepMs);
+ acc -= stepMs;
+ }
+ renderFn();
+ raf = requestAnimationFrame(frame);
+ }
+ raf = requestAnimationFrame(frame);
+ return () => {
+ running = false;
+ if (raf) cancelAnimationFrame(raf);
+ };
+ }
+
+ // src/pieces/line/index.js
+ var MaedaLine = class extends HTMLElement {
+ connectedCallback() {
+ const canvas = document.createElement("canvas");
+ canvas.width = FACE.w;
+ canvas.height = FACE.h;
+ canvas.style.cssText = "display:block;width:100%;max-width:460px;cursor:crosshair;touch-action:none";
+ this.appendChild(canvas);
+ const ctx = canvas.getContext("2d");
+ const frozen = this.getAttribute("frozen-now");
+ const nowSec = () => frozen ? Number(frozen) : Date.now() / 1e3;
+ const { y } = (() => {
+ const d = new Date(nowSec() * 1e3);
+ return { y: d.getUTCFullYear() };
+ })();
+ const t0 = epochDayFromCivil(y, 1, 1) * DAY;
+ const t1 = epochDayFromCivil(y + 1, 1, 1) * DAY;
+ const lineLen = Math.round((t1 - t0) / DAY);
+ const cfg = FAITHFUL_1997;
+ let state = initState(cfg, { tMin: 0, tMax: t1 - t0 }, lineLen);
+ state.focus = nowSec() - t0;
+ state.anchorPx = state.focus / DAY;
+ const input = { cursorPx: state.anchorPx, held: false };
+ const toLinePx = (ev) => {
+ const r = canvas.getBoundingClientRect();
+ const sx = FACE.w / r.width;
+ const x = (ev.clientX - r.left) * sx;
+ const yy = (ev.clientY - r.top) * (FACE.h / r.height);
+ const margin = (FACE.h - lineLen) / 2;
+ const mx = FACE.w - FACE.h + margin;
+ return (x - mx + (FACE.h - margin - yy)) / 2;
+ };
+ canvas.addEventListener("pointermove", (ev) => {
+ input.cursorPx = toLinePx(ev);
+ });
+ canvas.addEventListener("pointerdown", (ev) => {
+ canvas.setPointerCapture(ev.pointerId);
+ input.cursorPx = toLinePx(ev);
+ input.held = true;
+ });
+ const release = () => {
+ input.held = false;
+ };
+ canvas.addEventListener("pointerup", release);
+ canvas.addEventListener("pointercancel", release);
+ this._stop = startLoop({
+ stepFn: (dtMs) => {
+ state = step(cfg, state, dtMs, input);
+ },
+ renderFn: () => {
+ draw(ctx, state, nowSec() - t0);
+ this.dispatchEvent(new CustomEvent("readout", {
+ detail: { scale: state.scale, counter: state.counter }
+ }));
+ }
+ });
+ }
+ disconnectedCallback() {
+ if (this._stop) this._stop();
+ }
+ };
+ customElements.define("maeda-line", MaedaLine);
+})();
+
+</script>
+<script>
+document.getElementById("piece").addEventListener("readout", (e) => {
+ const { scale, counter } = e.detail;
+ document.getElementById("readout").textContent =
+ "scale " + Number(scale).toFixed(3) + " · counter " + Math.round(counter);
+});
+</script>
+</body>
+</html>