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
|
<!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>
|