diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-31 22:45:08 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-31 22:45:08 -0500 |
| commit | f487561cfa340b87b6595180a4acb8872fb538a3 (patch) | |
| tree | a32fbf6aa23a6c7618f3b0f6dd196b348e7d536a /docs/prototypes/2026-07-31-timeline-face-prototype-6.html | |
| parent | 5fe4b7d28ab78c46b9ca850e6f7ef0bca14702c3 (diff) | |
| download | archsetup-f487561cfa340b87b6595180a4acb8872fb538a3.tar.gz archsetup-f487561cfa340b87b6595180a4acb8872fb538a3.zip | |
Six prototypes from the world-face work, kept as the record of how the design got to where it is. The first five are self-contained; prototype 6 and the option-A pages load the engine, so they read here but do not run here.
The engine stays out on purpose. It carries the list of places I travel to, with coordinates, and this repo is published — a design record is not worth publishing that for. The README says how to copy the modules in locally to run prototype 6, and gitignore keeps such a copy from being committed by accident.
Dropping the prototype test target with it: the engine and its tests now live in one place, so the suite runs once rather than twice over identical files.
Diffstat (limited to 'docs/prototypes/2026-07-31-timeline-face-prototype-6.html')
| -rw-r--r-- | docs/prototypes/2026-07-31-timeline-face-prototype-6.html | 151 |
1 files changed, 151 insertions, 0 deletions
diff --git a/docs/prototypes/2026-07-31-timeline-face-prototype-6.html b/docs/prototypes/2026-07-31-timeline-face-prototype-6.html new file mode 100644 index 0000000..d3f1427 --- /dev/null +++ b/docs/prototypes/2026-07-31-timeline-face-prototype-6.html @@ -0,0 +1,151 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>Timeline face — prototype 6</title> +<style> + :root{ + --ground:#050505; --cream:#f3e7c5; --silver:#bfc4d0; --steel:#969385; + --dim:#7c838a; --gold:#dab53d; --gold-hi:#ffd75f; --slate:#54677d; + --night:#39435a; --grid:#332f28; --grid-hi:#4a443a; --now:#c8452f; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",ui-monospace,monospace; + } + *{box-sizing:border-box} + html,body{margin:0;height:100%;background:var(--ground);overflow:hidden; + font-family:var(--mono)} + #face{position:fixed;inset:0;width:100vw;height:100vh;display:block} + #hud{position:fixed;left:10px;top:8px;z-index:5;display:flex;gap:8px; + align-items:center;font-size:11px} + button{font-family:var(--mono);font-size:11px;letter-spacing:.1em; + text-transform:uppercase;background:#16161699;color:var(--silver); + border:1px solid #2e2e2e;padding:5px 9px;cursor:pointer} + button.on{background:var(--gold);color:#111;border-color:var(--gold)} + select{font-family:var(--mono);font-size:11px;letter-spacing:.08em; + background:#161616;color:var(--silver);border:1px solid #2e2e2e; + padding:4px 6px} + + /* One rule per primitive class the engine emits. All appearance lives here; + the engine decides what to draw and never how it looks. */ + .meridian,.parallel{stroke:var(--grid);stroke-width:1;stroke-dasharray:7 7} + .meridian-anchor,.equator{stroke:var(--grid-hi);stroke-width:1} + .day-axis{stroke:var(--grid-hi);stroke-width:1.4} + line.now{stroke:var(--now);stroke-width:1.2;stroke-opacity:.6} + circle.now{fill:var(--now)} + text.now{fill:var(--now)} + .grid-label{fill:var(--steel)} + rect.event-next{fill:var(--gold-hi);fill-opacity:.95} + rect.event-future{fill:#ececec;fill-opacity:.5} + rect.event-past{fill:#686868;fill-opacity:.25} + text.event-next,text.event-next-above{fill:var(--gold-hi);font-weight:700} + text.event-future,text.event-future-above{fill:#ececec} + text.event-past,text.event-past-above{fill:#686868} + .leader{stroke:var(--grid-hi);stroke-width:1;stroke-opacity:.8} + .dot-home{fill:var(--gold)} + /* temporary: every city marked in red so their true positions are obvious */ + .dot-city{fill:#e0392b} + .city-home{fill:var(--gold);font-weight:700} + .city-day{fill:var(--cream)} + .city-night{fill:var(--slate)} +</style> +</head> +<body> +<div id="hud"> + <button id="bTrue" class="on">true latitude</button> + <button id="bRank">ranked</button> + <button id="bScrub">scrub</button> + <span style="width:14px"></span> + <span style="color:var(--steel)">home</span> + <select id="homes"></select> +</div> +<svg id="face"></svg> + +<script src="timeline-face-engine.js"></script> +<script> +// The renderer. It measures the font, asks the engine for a scene, and turns +// primitives into SVG nodes. It holds no layout, no time and no geography -- +// all of that lives in the engine, where it is tested. +"use strict"; +const NS = "http://www.w3.org/2000/svg"; +const svg = document.getElementById("face"); +let mode = "true", scrub = null, charRatio = 0.6, home = TF.HOME; + +const now = () => (scrub != null ? new Date(scrub) : new Date()); + +/** + * Advance width of one glyph as a fraction of font size, measured against the + * font actually in use. Every width downstream is arithmetic from this, which + * is what lets the engine derive margins without touching the DOM. + */ +function measureChar() { + const probe = document.createElementNS(NS, "text"); + probe.setAttribute("font-size", "100"); + probe.setAttribute("x", "-9999"); + probe.setAttribute("y", "-9999"); + probe.textContent = "MMMMMMMMMM"; + svg.appendChild(probe); + try { + const w = probe.getComputedTextLength(); + if (w > 0) charRatio = w / 10 / 100; + } catch (e) { /* keep the default */ } + probe.remove(); +} + +const ATTRS = { + line: (p) => ({ x1: p.x1, y1: p.y1, x2: p.x2, y2: p.y2 }), + circle: (p) => ({ cx: p.cx, cy: p.cy, r: p.r }), + rect: (p) => ({ x: p.x, y: p.y, width: p.width, height: p.height }), + text: (p) => ({ + x: p.x, y: p.y, "text-anchor": p.anchor, + "font-size": p.size, "letter-spacing": p.ls + "em", + }), +}; + +function toNode(p) { + const node = document.createElementNS(NS, p.kind); + const attrs = ATTRS[p.kind](p); + for (const k in attrs) if (attrs[k] != null) node.setAttribute(k, attrs[k]); + node.setAttribute("class", p.cls); + if (p.kind === "text") node.textContent = p.text; + return node; +} + +function render() { + const width = svg.clientWidth || window.innerWidth; + const height = svg.clientHeight || window.innerHeight; + svg.setAttribute("viewBox", "0 0 " + width + " " + height); + const items = TF.scene({ width, height, ref: now(), mode, charRatio, home }); + svg.replaceChildren(...items.map(toNode)); +} + +function pick(id, value) { + mode = value; + for (const b of ["bTrue", "bRank"]) { + document.getElementById(b).classList.toggle("on", b === id); + } + render(); +} +document.getElementById("bTrue").onclick = () => pick("bTrue", "true"); +document.getElementById("bRank").onclick = () => pick("bRank", "rank"); +document.getElementById("bScrub").onclick = (e) => { + scrub = scrub == null ? Date.now() : null; + e.target.classList.toggle("on", scrub != null); +}; + +// One button per candidate home. Moving home moves the crossing with it, since +// the roster reaches a fixed distance either side of wherever you are. +const homeSel = document.getElementById("homes"); +TF.HOMES.forEach((h, i) => { + const o = document.createElement("option"); + o.value = String(i); + o.textContent = h.label; + homeSel.appendChild(o); +}); +homeSel.onchange = () => { home = TF.HOMES[+homeSel.value]; render(); }; + +new ResizeObserver(render).observe(document.documentElement); +setInterval(function () { if (scrub != null) scrub += 4 * 60000; render(); }, 250); +measureChar(); +render(); +</script> +</body> +</html> |
