PageSourceSearch

https://ash-hq.org/articles/temporal-resources/js/tour.js

js ash-hq.org collected 2026-10-03 22:16:23 UTC 11,768 bytes, 298 lines download raw bytes

1// A small canvas that three people have been painting for the last hour, and what each
2// part of the tour does to it. Everything shown is worked out from the paints, so the
3// canvas, the leaderboard and a pixel's history always agree. Times are minutes past
4// 09:00, and it's 09:45 now.
5
6const COLORS = {pink: "#FF99AA", yellow: "#FFD635", green: "#00A368", blue: "#2450A4"}
7const NOW = 45
8const HOUR = 60
9const SIZE = 6
10const PAINTERS = {alice: "Alice", bob: "Bob", carol: "Carol"}
11
12const paint = (id, x, y, color, painter, at) => ({id, cell: `${x},${y}`, color, painter, at})
13
14const PAINTS = [
15  paint(1, 1, 1, "blue", "alice", 5),
16  paint(2, 2, 1, "blue", "bob", 10),
17  paint(3, 1, 2, "yellow", "carol", 15),
18  paint(4, 2, 2, "blue", "alice", 20),
19  paint(5, 1, 1, "pink", "bob", 30),
20  paint(6, 3, 3, "green", "alice", 40),
21  paint(7, 4, 4, "blue", "carol", 42),
22]
23
24const NOW_PAINT = paint(8, 4, 1, "blue", "alice", NOW)
25const PAST_PAINT = paint(9, 0, 4, "yellow", "carol", 12)
26const FUTURE_PAINT = paint(10, 4, 1, "pink", "bob", 50)
27const ALL = [...PAINTS, NOW_PAINT, PAST_PAINT, FUTURE_PAINT]
28
29// What each step shows. A step of the article moves through a few of these in turn.
30export const STATES = {
31  start: {paints: PAINTS, marker: NOW},
32  written: {paints: [...PAINTS, NOW_PAINT], marker: NOW},
33  past: {paints: [...PAINTS, NOW_PAINT, PAST_PAINT], marker: NOW, ripple: [12, NOW]},
34  future: {paints: ALL, marker: NOW},
35  read: {paints: ALL, marker: 25},
36  count: {paints: ALL, marker: 25, count: "blue"},
37  painters: {paints: ALL, marker: 25, painters: true},
38  leadersPast: {paints: ALL, marker: 25, leaderboard: true},
39  leadersNow: {paints: ALL, marker: NOW, leaderboard: true},
40  activePast: {paints: ALL, marker: 25, leaderboard: true, active: 10},
41  activeNow: {paints: ALL, marker: NOW, leaderboard: true, active: 10},
42  historyNow: {paints: ALL, marker: NOW, history: "1,1"},
43  historyPast: {paints: ALL, marker: 25, history: "1,1"},
44}
45
46const clock = (minutes) =>
47  `${String(9 + Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`
48
49const position = (minutes) => `${(minutes * 100) / HOUR}%`
50const fill = (color) => (color ? COLORS[color] : "rgb(255 255 255 / 0.08)")
51
52// Each cell's latest paint up to a moment.
53function canvasAt(paints, moment) {
54  const canvas = new Map()
55  ;[...paints].filter((p) => p.at <= moment).sort((a, b) => a.at - b.at).forEach((p) => canvas.set(p.cell, p))
56  return canvas
57}
58
59function leaderboard(canvas, state) {
60  const owned = {}
61  for (const p of canvas.values()) owned[p.painter] = (owned[p.painter] || 0) + 1
62  const most = Math.max(1, ...Object.values(owned))
63
64  return Object.entries(PAINTERS).map(([painter, name]) => ({
65    painter,
66    name,
67    count: owned[painter] || 0,
68    width: ((owned[painter] || 0) * 100) / most,
69    active:
70      state.active !== undefined &&
71      [...canvas.values()].some((p) => p.painter === painter && p.at > state.marker - state.active),
72  }))
73}
74
75function versions(state) {
76  const painted = state.paints
77    .filter((p) => p.cell === state.history && p.at <= state.marker)
78    .sort((a, b) => a.at - b.at)
79    .map((p) => ({color: p.color, by: PAINTERS[p.painter], at: p.at}))
80
81  return [{color: null, by: "", at: 0}, ...painted]
82}
83
84const element = (tag, className, parent) => {
85  const el = document.createElement(tag)
86  if (className) el.className = className
87  if (parent) parent.appendChild(el)
88  return el
89}
90
91// Restarts a CSS animation on an element.
92function replay(el, className) {
93  el.classList.remove(className)
94  void el.offsetWidth
95  el.classList.add(className)
96}
97
98export class TourView {
99  constructor(root) {
100    this.root = root
101    root.classList.add("tour")
102
103    this.caption = element("p", "visual-caption", root)
104
105    const stage = element("div", "tour-stage", root)
106    const canvas = element("div", "tour-canvas", stage)
107    this.cells = new Map()
108
109    for (let y = 0; y < SIZE; y++) {
110      for (let x = 0; x < SIZE; x++) {
111        const cell = element("div", "tour-cell", canvas)
112        const swatch = element("span", "tour-swatch", cell)
113        const letter = element("span", "tour-letter", cell)
114        this.cells.set(`${x},${y}`, {cell, swatch, letter, color: undefined, painter: undefined})
115      }
116    }
117
118    const side = element("div", "tour-side", stage)
119
120    this.count = element("div", "tour-count", side)
121    this.countNumber = element("span", "tour-count-number", this.count)
122    element("span", "tour-count-label", this.count).textContent = "blue pixels at 09:25"
123
124    this.leaders = element("ol", "tour-leaders", side)
125    this.leaderRows = Object.entries(PAINTERS).map(([painter, name]) => {
126      const row = element("li", "", this.leaders)
127      element("span", "tour-leader-name", row).textContent = name
128      const track = element("span", "tour-leader-track", row)
129      const bar = element("span", "tour-leader-bar", track)
130      const count = element("span", "tour-leader-count", row)
131      const active = element("span", "tour-leader-active", row)
132      return {painter, bar, count, active}
133    })
134
135    this.history = element("ol", "tour-history", side)
136
137    this.relations = element("div", "tour-relations", side)
138    const header = element("div", "tour-relations-header", this.relations)
139    element("span", "", header).textContent = "pixel"
140    element("span", "", header).textContent = "painted_by"
141    this.relationRows = element("ol", "", this.relations)
142
143    const timeline = element("div", "timeline", root)
144    this.track = element("div", "timeline-track", timeline)
145    element("span", "timeline-future", this.track).style.left = position(NOW)
146    this.activeBand = element("span", "tour-active-band", this.track)
147    this.ripple = element("span", "tour-ripple", this.track)
148    this.dots = new Map()
149    this.marker = element("span", "timeline-marker", this.track)
150
151    const labels = element("div", "timeline-labels", timeline)
152    for (const [label, minutes] of [["09:00", 0], ["09:15", 15], ["09:30", 30], ["now", NOW], ["10:00", 60]]) {
153      const span = element("span", minutes === NOW ? "is-now" : "", labels)
154      span.textContent = label
155      span.style.left = position(minutes)
156    }
157
158    this.state = null
159  }
160
161  show(state, {animate = true} = {}) {
162    const previous = this.state
163    this.state = state
164    this.root.classList.toggle("is-instant", !animate)
165
166    const canvas = canvasAt(state.paints, state.marker)
167
168    this.caption.innerHTML =
169      state.marker === NOW
170        ? `The canvas <strong>now</strong>, at 09:45`
171        : `The canvas <strong>as of ${clock(state.marker)}</strong>`
172
173    // Cells change color in place. A cell that's just been painted pops, while one that
174    // changes because we're looking at another moment fades from one color to the other.
175    const picked = new Set()
176    for (const [key, p] of canvas) if (state.count && p.color === state.count) picked.add(key)
177    if (state.history) picked.add(state.history)
178
179    const painted = new Set(state.paints.filter((p) => previous && !previous.paints.includes(p)).map((p) => p.id))
180
181    for (const [key, view] of this.cells) {
182      const p = canvas.get(key)
183      const color = p ? p.color : null
184
185      if (view.color !== color) {
186        view.swatch.style.backgroundColor = fill(color)
187        if (animate && p && painted.has(p.id)) replay(view.swatch, "pop")
188        view.color = color
189      }
190
191      const painter = state.painters && p ? p.painter : null
192      if (view.painter !== painter) {
193        view.letter.textContent = painter ? PAINTERS[painter][0] : ""
194        if (animate && painter) replay(view.letter, "pop")
195        view.painter = painter
196      }
197
198      view.cell.classList.toggle("is-picked", picked.has(key))
199    }
200
201    // Alongside the canvas: a count, the leaderboard, or a pixel's history.
202    this.count.hidden = !state.count
203    if (state.count) {
204      this.countNumber.textContent = [...canvas.values()].filter((p) => p.color === state.count).length
205    }
206
207    this.leaders.hidden = !state.leaderboard
208    if (state.leaderboard) {
209      const rows = leaderboard(canvas, state)
210      this.leaderRows.forEach((view, index) => {
211        view.bar.style.width = `${rows[index].width}%`
212        view.count.textContent = rows[index].count
213        view.active.classList.toggle("is-active", rows[index].active)
214      })
215    }
216
217    this.history.hidden = !state.history
218    const historyKey = state.history ? `${state.history}@${state.marker}` : null
219    if (state.history && (!previous || historyKey !== this.historyKey)) {
220      this.history.replaceChildren(
221        ...versions(state).map((version, index) => {
222          const row = element("li", animate ? "rise" : "")
223          row.style.animationDelay = `${index * 100}ms`
224          element("span", "tour-history-swatch", row).style.backgroundColor = fill(version.color)
225          element("span", "tour-history-color", row).textContent = version.color || "white"
226          element("span", "tour-history-by", row).textContent = version.by
227          element("span", "tour-history-at", row).textContent = clock(version.at)
228          return row
229        })
230      )
231    }
232    this.historyKey = historyKey
233
234    // The relationship each pixel loaded, as of the moment: who had painted it then.
235    this.relations.hidden = !state.painters
236    const relationsKey = state.painters ? `${state.marker}` : null
237    if (state.painters && relationsKey !== this.relationsKey) {
238      const pixels = [...canvas.entries()].sort(([a], [b]) => {
239        const [ax, ay] = a.split(",").map(Number)
240        const [bx, by] = b.split(",").map(Number)
241        return ay - by || ax - bx
242      })
243
244      this.relationRows.replaceChildren(
245        ...pixels.map(([key, p], index) => {
246          const row = element("li", animate ? "rise" : "")
247          row.style.animationDelay = `${index * 80}ms`
248          const pixel = element("span", "tour-relation-pixel", row)
249          element("span", "tour-history-swatch", pixel).style.backgroundColor = fill(p.color)
250          element("span", "", pixel).textContent = `(${key.replace(",", ", ")})`
251          const painter = element("span", "tour-relation-painter", row)
252          element("span", "tour-relation-initial", painter).textContent = PAINTERS[p.painter][0]
253          element("span", "", painter).textContent = PAINTERS[p.painter]
254          return row
255        })
256      )
257    }
258    this.relationsKey = relationsKey
259
260    // The timeline: every paint as a dot, and the moment being looked at.
261    const ids = new Set(state.paints.map((p) => p.id))
262    for (const [id, dot] of this.dots) {
263      if (!ids.has(id)) {
264        dot.remove()
265        this.dots.delete(id)
266      }
267    }
268
269    for (const p of state.paints) {
270      let dot = this.dots.get(p.id)
271      if (!dot) {
272        dot = element("span", "tour-dot")
273        dot.style.left = position(p.at)
274        dot.style.backgroundColor = COLORS[p.color]
275        this.track.insertBefore(dot, this.marker)
276        this.dots.set(p.id, dot)
277        if (animate && previous) replay(dot, "pop")
278      }
279      dot.classList.toggle("is-dim", Boolean(state.history) && p.cell !== state.history)
280      dot.classList.toggle("is-focus", state.history === p.cell)
281    }
282
283    this.marker.style.left = position(state.marker)
284
285    this.activeBand.hidden = state.active === undefined
286    if (state.active !== undefined) {
287      this.activeBand.style.left = position(state.marker - state.active)
288      this.activeBand.style.width = position(state.active)
289    }
290
291    this.ripple.hidden = !state.ripple
292    if (state.ripple) {
293      this.ripple.style.left = position(state.ripple[0])
294      this.ripple.style.width = position(state.ripple[1] - state.ripple[0])
295      if (animate && !(previous && previous.ripple)) replay(this.ripple, "sweep")
296    }
297  }
298}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.