diff options
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.html | 457 |
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> |
