headreel 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,485 @@
1
+ import { applyCamera, cameraAt } from '../../core/camera.js';
2
+ import { altimeterLine, FRAMES, LAYOUT, markerAt, summitDates } from './trail.js';
3
+ import { CELL, GROUND_Y } from './profile.js';
4
+ import {} from './palette.js';
5
+ /**
6
+ * A printed trail profile sheet, walked by one camera.
7
+ *
8
+ * Everything that covers area is flat and pinned (paper, collar), and the
9
+ * sheet's line work is rasterized like a plotter: the profile and the ground
10
+ * line as pure 1px pixel runs, the strata as horizontal spans, the relief as
11
+ * engraved stipple dots. This is not only the printed look the style wants -
12
+ * measured on the top-down draft this style replaces, anti-aliased strokes
13
+ * re-derive their fringes on every camera move, which dithers across a
14
+ * hundred colors and re-shuffles every frame, bloating the GIF to 20 MB;
15
+ * rasterized onto whole pixels the loop is a few MB.
16
+ *
17
+ * The collar, text, graticule, grid, spot heights, summit, marker and
18
+ * neatline stay vector through the raw context (style set right before each
19
+ * draw; see Highlights Reel's note on p5's cached fill state). Small type
20
+ * that would read as dithered noise at the wide shot (grid and month labels,
21
+ * spot figures, the summit label) fades in with zoom, as Highlights Reel's
22
+ * busiest-week rule does for its day cells.
23
+ */
24
+ const SANS = '"Space Grotesk"';
25
+ const MONO = '"JetBrains Mono"';
26
+ const TAU = Math.PI * 2;
27
+ const { width: W, height: H, viewport, collar: COLLAR } = LAYOUT;
28
+ /** Camera dip tuning: moves longer than `travel` dip, scaled to `span`. */
29
+ const CAMERA = { travel: LAYOUT.travel, span: LAYOUT.span };
30
+ /** Hairline weights in screen px: the neatline's heavy rule. */
31
+ const LINE = { neatHeavy: 2.4, neatThin: 1 };
32
+ /** Small type appears as the camera closes in (zoom thresholds). */
33
+ const LABEL_Z = { sheet: [0.36, 0.42], summit: [1.15, 1.5] };
34
+ /** Screen size of the sheet's small type. */
35
+ const LABEL_SIZE = 10;
36
+ /** Stipple relief: engraving pitch, and the density under the profile line. */
37
+ const STIPPLE_PITCH = 10;
38
+ const STIPPLE_BASE = 0.05;
39
+ const STIPPLE_MAX = 0.3;
40
+ function viewOf(cam) {
41
+ return {
42
+ ox: Math.round(viewport.cx - cam.x * cam.z),
43
+ oy: Math.round(viewport.cy - cam.y * cam.z),
44
+ z: cam.z,
45
+ };
46
+ }
47
+ const clamp01 = (v) => Math.min(1, Math.max(0, v));
48
+ function smoothstep(e0, e1, x) {
49
+ const t = clamp01((x - e0) / (e1 - e0));
50
+ return t * t * (3 - 2 * t);
51
+ }
52
+ function rgba(color, alpha = 1) {
53
+ return `rgba(${color[0]},${color[1]},${color[2]},${alpha})`;
54
+ }
55
+ function setFill(ctx, color, alpha = 1) {
56
+ ctx.fillStyle = rgba(color, alpha);
57
+ }
58
+ function setStroke(ctx, color, alpha = 1) {
59
+ ctx.strokeStyle = rgba(color, alpha);
60
+ }
61
+ function setFont(ctx, o) {
62
+ ctx.font = `${o.bold ? 700 : 400} ${o.size}px ${o.mono ? MONO : SANS}`;
63
+ ctx.textAlign = o.align ?? 'left';
64
+ ctx.textBaseline = o.baseline ?? 'alphabetic';
65
+ ctx.letterSpacing = `${o.tracking ?? 0}px`;
66
+ }
67
+ function drawText(ctx, str, x, y, o) {
68
+ setFont(ctx, o);
69
+ setFill(ctx, o.color, o.alpha ?? 1);
70
+ ctx.fillText(str, x, y);
71
+ if (o.tracking)
72
+ ctx.letterSpacing = '0px';
73
+ }
74
+ /**
75
+ * The relief, engraved: one stipple dot per jittered grid cell where a seeded
76
+ * draw falls under the cell's density. Density is highest just below the
77
+ * profile line and falls off with depth, so the silhouette reads at the wide
78
+ * shot; flat ground takes no dots, because there is nothing under it. Dots
79
+ * are placed once, in world space, and plotted as single pixels every frame.
80
+ */
81
+ function stippleDots(model, next) {
82
+ const { mapArea, profileY } = model.profile;
83
+ const dots = [];
84
+ for (let y = STIPPLE_PITCH / 2; y < GROUND_Y; y += STIPPLE_PITCH) {
85
+ for (let x = STIPPLE_PITCH / 2; x < mapArea.x1; x += STIPPLE_PITCH) {
86
+ if (x < mapArea.x0)
87
+ continue;
88
+ const surface = profileY(x);
89
+ if (y <= surface)
90
+ continue;
91
+ const depth = (y - surface) / (GROUND_Y - surface);
92
+ const density = STIPPLE_BASE + STIPPLE_MAX * (1 - depth) ** 3;
93
+ if (next() >= density)
94
+ continue;
95
+ const jx = (next() - 0.5) * 0.8 * STIPPLE_PITCH;
96
+ const jy = (next() - 0.5) * 0.8 * STIPPLE_PITCH;
97
+ dots.push(x + jx, y + jy);
98
+ }
99
+ }
100
+ return dots;
101
+ }
102
+ /**
103
+ * A plotter hairline between two screen-integer points: Bresenham, batching
104
+ * pixels on the same row into one fillRect so a mostly-horizontal line is a
105
+ * few wide runs instead of one call per pixel. Segments that round to the
106
+ * same pixel are a single dot. `across` shifts the whole line one pixel
107
+ * sideways for the profile line's and the index strata's weight.
108
+ */
109
+ function plotHairline(ctx, ax, ay, bx, by, across) {
110
+ const ox = across?.[0] ?? 0;
111
+ const oy = across?.[1] ?? 0;
112
+ let x = Math.round(ax) + ox;
113
+ let y = Math.round(ay) + oy;
114
+ const endX = Math.round(bx) + ox;
115
+ const endY = Math.round(by) + oy;
116
+ const dx = Math.abs(endX - x);
117
+ const dy = -Math.abs(endY - y);
118
+ if (dx === 0 && dy === 0) {
119
+ ctx.fillRect(x, y, 1, 1);
120
+ return;
121
+ }
122
+ const sx = x < endX ? 1 : -1;
123
+ const sy = y < endY ? 1 : -1;
124
+ let err = dx + dy;
125
+ let runX = x;
126
+ let runY = y;
127
+ for (;;) {
128
+ const lastX = x;
129
+ const e2 = 2 * err;
130
+ if (e2 >= dy) {
131
+ err += dy;
132
+ x += sx;
133
+ }
134
+ if (e2 <= dx) {
135
+ // The row changed: flush the run and start the next.
136
+ ctx.fillRect(Math.min(runX, lastX), runY, Math.abs(lastX - runX) + 1, 1);
137
+ err += dx;
138
+ y += sy;
139
+ runY = y;
140
+ runX = x;
141
+ }
142
+ if (x === endX && y === endY)
143
+ break;
144
+ }
145
+ ctx.fillRect(Math.min(runX, endX), runY, Math.abs(endX - runX) + 1, 1);
146
+ }
147
+ /** A hairline polyline, plotted with an optional second pass one pixel over. */
148
+ function plotPolyline(ctx, view, line, across) {
149
+ for (let i = 1; i < line.length; i++) {
150
+ const ax = view.ox + line[i - 1][0] * view.z;
151
+ const ay = view.oy + line[i - 1][1] * view.z;
152
+ const bx = view.ox + line[i][0] * view.z;
153
+ const by = view.oy + line[i][1] * view.z;
154
+ plotHairline(ctx, ax, ay, bx, by, null);
155
+ if (across)
156
+ plotHairline(ctx, ax, ay, bx, by, across);
157
+ }
158
+ }
159
+ export function createTrailSketch(model, next) {
160
+ const P = model.palette;
161
+ const { profile } = model;
162
+ const { mapArea, world } = profile;
163
+ // Pure geometry, computed once.
164
+ let dots = [];
165
+ function setup(p) {
166
+ dots = stippleDots(model, next);
167
+ void p;
168
+ }
169
+ /** World point to screen integer, under the current view. */
170
+ const toScreen = (view, x, y) => [
171
+ Math.round(view.ox + x * view.z),
172
+ Math.round(view.oy + y * view.z),
173
+ ];
174
+ // --- world layers ---------------------------------------------------------
175
+ /**
176
+ * The elevation grid: a hairline at each index level across the sheet.
177
+ * Like the graticule, the lines are world-weight, so the resting wide sheet
178
+ * reads ruled but faint. Its labels are screen-space (`drawSheetLabels`).
179
+ */
180
+ function drawGrid(ctx) {
181
+ if (!profile.summit)
182
+ return;
183
+ const indexLevels = profile.levels.filter((l) => l.kind === 'index');
184
+ ctx.lineJoin = 'round';
185
+ setStroke(ctx, P.grid);
186
+ ctx.lineWidth = 1;
187
+ ctx.beginPath();
188
+ for (const level of indexLevels) {
189
+ ctx.moveTo(mapArea.x0, level.y);
190
+ ctx.lineTo(mapArea.x1, level.y);
191
+ }
192
+ ctx.stroke();
193
+ }
194
+ /** The graticule: a month line from the ground to the top neatline. */
195
+ function drawGraticule(ctx) {
196
+ ctx.lineJoin = 'round';
197
+ setStroke(ctx, P.graticule);
198
+ ctx.lineWidth = 1;
199
+ ctx.beginPath();
200
+ for (const mark of model.months) {
201
+ const x = mapArea.x0 + mark.week * CELL;
202
+ ctx.moveTo(x, GROUND_Y);
203
+ ctx.lineTo(x, mapArea.y0);
204
+ }
205
+ ctx.stroke();
206
+ }
207
+ /**
208
+ * The plotted sheet: strata, stipple, the ground line and the profile,
209
+ * rasterized in screen space and clipped to the map area, because printed
210
+ * sheets stop at the neatline. The dots and hairlines land on whole pixels
211
+ * at exact colors, so a still camera reproduces the sheet pixel for pixel
212
+ * and a moving one changes only the pixels a line actually crossed.
213
+ */
214
+ function drawRaster(ctx, cam, view) {
215
+ ctx.save();
216
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
217
+ const [mx0, my0] = toScreen(view, mapArea.x0, mapArea.y0);
218
+ const [mx1, my1] = toScreen(view, mapArea.x1, mapArea.y1);
219
+ ctx.beginPath();
220
+ ctx.rect(mx0, my0, mx1 - mx0, my1 - my0);
221
+ ctx.clip();
222
+ const hw = (W - viewport.left) / 2 / cam.z + 8;
223
+ const hh = H / 2 / cam.z + 8;
224
+ const bounds = { x0: cam.x - hw, x1: cam.x + hw, y0: cam.y - hh, y1: cam.y + hh };
225
+ // Strata: the x spans where the trail is above each level, index levels
226
+ // plotted twice for their weight.
227
+ setFill(ctx, P.strata);
228
+ for (const level of profile.levels) {
229
+ if (level.y < bounds.y0 || level.y > bounds.y1)
230
+ continue;
231
+ const across = level.kind === 'index' ? [0, 1] : null;
232
+ for (const [x0, x1] of level.spans) {
233
+ if (x1 < bounds.x0 || x0 > bounds.x1)
234
+ continue;
235
+ const ly = view.oy + level.y * view.z;
236
+ const ax = view.ox + x0 * view.z;
237
+ const bx = view.ox + x1 * view.z;
238
+ plotHairline(ctx, ax, ly, bx, ly, null);
239
+ if (across)
240
+ plotHairline(ctx, ax, ly, bx, ly, across);
241
+ }
242
+ }
243
+ // Stipple relief. Close in, the world pitch would thin to specks, so the
244
+ // dots double with the summit zoom.
245
+ const dot = cam.z > 1.15 ? 2 : 1;
246
+ setFill(ctx, P.stipple);
247
+ for (let d = 0; d < dots.length; d += 2) {
248
+ const x = dots[d];
249
+ const y = dots[d + 1];
250
+ if (x < mapArea.x0 || x > mapArea.x1 || y < mapArea.y0 || y > mapArea.y1)
251
+ continue;
252
+ if (x < bounds.x0 || x > bounds.x1 || y < bounds.y0 || y > bounds.y1)
253
+ continue;
254
+ const [sx, sy] = toScreen(view, x, y);
255
+ ctx.fillRect(sx, sy, dot, dot);
256
+ }
257
+ // The ground line and the profile, plotted like the rest of the line work.
258
+ setFill(ctx, P.ink);
259
+ const gy = view.oy + GROUND_Y * view.z;
260
+ plotHairline(ctx, view.ox + mapArea.x0 * view.z, gy, view.ox + mapArea.x1 * view.z, gy, null);
261
+ if (profile.summit) {
262
+ setFill(ctx, P.accent);
263
+ plotPolyline(ctx, view, profile.line, [0, 1]);
264
+ }
265
+ ctx.restore();
266
+ }
267
+ /** The summit: survey triangle on the profile, flag on a pole, its figure. */
268
+ function drawSummit(ctx, cam, t, labelAlpha) {
269
+ const summit = profile.summit;
270
+ if (!summit)
271
+ return;
272
+ const poleTop = summit.y - 88;
273
+ // Survey triangle at the highest point.
274
+ setFill(ctx, P.ink);
275
+ ctx.beginPath();
276
+ ctx.moveTo(summit.x, summit.y - 7);
277
+ ctx.lineTo(summit.x + 8, summit.y + 6);
278
+ ctx.lineTo(summit.x - 8, summit.y + 6);
279
+ ctx.closePath();
280
+ ctx.fill();
281
+ setStroke(ctx, P.ink);
282
+ ctx.lineWidth = 2 / cam.z;
283
+ ctx.beginPath();
284
+ ctx.moveTo(summit.x, summit.y);
285
+ ctx.lineTo(summit.x, poleTop);
286
+ ctx.stroke();
287
+ // The flag waves only while the camera holds the summit, about one cycle
288
+ // per 40 frames (at least two), amplitude eased to 0 at both ends of the
289
+ // hold, so the wide shots at the seam match (the flag is always planted).
290
+ const { start, end } = model.plan.summitHold;
291
+ const u = end > start ? clamp01((t * FRAMES - start) / (end - start)) : 0;
292
+ const envelope = Math.sin(Math.PI * u);
293
+ const cycles = Math.max(2, Math.round((end - start) / 40));
294
+ const phase = cycles * TAU * u;
295
+ const tipX = 34 + 4 * Math.sin(phase) * envelope;
296
+ const sag = 3 * Math.sin(phase + 1) * envelope;
297
+ setFill(ctx, P.flag);
298
+ ctx.beginPath();
299
+ ctx.moveTo(summit.x, poleTop);
300
+ ctx.quadraticCurveTo(summit.x + tipX * 0.6, poleTop + 8 + sag, summit.x + tipX, poleTop + 8);
301
+ ctx.lineTo(summit.x, poleTop + 16);
302
+ ctx.closePath();
303
+ ctx.fill();
304
+ if (labelAlpha <= 0.02)
305
+ return;
306
+ // Beside the pole's lower half: at the hold zoom the figure's ascender
307
+ // would cross the frame's top edge if it sat at the flag.
308
+ drawText(ctx, String(summit.total), summit.x + 46, summit.y - 40, {
309
+ size: 30,
310
+ bold: true,
311
+ color: P.ink,
312
+ alpha: labelAlpha,
313
+ });
314
+ drawText(ctx, summitDates(summit.from, summit.to), summit.x + 46, summit.y - 18, {
315
+ size: 13,
316
+ mono: true,
317
+ color: P.muted,
318
+ alpha: labelAlpha,
319
+ });
320
+ }
321
+ /** Double frame around the map: thin inner, heavy outer. */
322
+ function drawNeatline(ctx, cam) {
323
+ ctx.lineJoin = 'miter';
324
+ const off = 7;
325
+ setStroke(ctx, P.ink);
326
+ ctx.lineWidth = LINE.neatHeavy / cam.z;
327
+ ctx.strokeRect(mapArea.x0 - off, mapArea.y0 - off, mapArea.x1 - mapArea.x0 + 2 * off, mapArea.y1 - mapArea.y0 + 2 * off);
328
+ ctx.lineWidth = LINE.neatThin / cam.z;
329
+ ctx.strokeRect(mapArea.x0, mapArea.y0, mapArea.x1 - mapArea.x0, mapArea.y1 - mapArea.y0);
330
+ }
331
+ // --- screen layers --------------------------------------------------------
332
+ /**
333
+ * The sheet's small type, at a fixed screen size so it reads at the walk
334
+ * zoom without swelling at the summit: elevation labels on the west
335
+ * neatline (or the viewport's edge), month labels under the ground line, and the spot heights (`×`
336
+ * and figure). Anchored to world points through the snapped view, so it
337
+ * moves with the sheet pixel for pixel.
338
+ */
339
+ function drawSheetLabels(ctx, view, alpha) {
340
+ if (alpha <= 0.02 || !profile.summit)
341
+ return;
342
+ const label = { size: LABEL_SIZE, mono: true, color: P.muted, alpha };
343
+ // Elevation labels ride the west neatline, or the viewport's left edge
344
+ // once the walk has panned the neatline out of view.
345
+ const wx = Math.max(toScreen(view, mapArea.x0, 0)[0], viewport.left + 4);
346
+ for (const level of profile.levels) {
347
+ if (level.kind !== 'index')
348
+ continue;
349
+ const [, y] = toScreen(view, 0, level.y);
350
+ drawText(ctx, String(level.height), wx + 4, y + 4, label);
351
+ }
352
+ const [, gy] = toScreen(view, 0, GROUND_Y);
353
+ drawText(ctx, '0', wx + 4, gy - 3, label);
354
+ for (const mark of model.months) {
355
+ const [x] = toScreen(view, mapArea.x0 + mark.week * CELL, 0);
356
+ drawText(ctx, mark.label, x + 3, gy + 13, label);
357
+ }
358
+ const r = 3.5;
359
+ ctx.lineJoin = 'round';
360
+ ctx.lineWidth = 1.2;
361
+ setStroke(ctx, P.ink, alpha);
362
+ ctx.beginPath();
363
+ for (const spot of profile.spots) {
364
+ const [x, y] = toScreen(view, spot.x, spot.y);
365
+ ctx.moveTo(x - r, y - r - 6);
366
+ ctx.lineTo(x + r, y + r - 6);
367
+ ctx.moveTo(x + r, y - r - 6);
368
+ ctx.lineTo(x - r, y + r - 6);
369
+ }
370
+ ctx.stroke();
371
+ for (const spot of profile.spots) {
372
+ const [x, y] = toScreen(view, spot.x, spot.y);
373
+ drawText(ctx, String(spot.total), x + 7, y - 3, { ...label, color: P.ink });
374
+ }
375
+ }
376
+ /**
377
+ * The hiker: an ink bead with a paper ring, centred on the profile line.
378
+ * Drawn in screen space at a fixed size, so the walker does not balloon
379
+ * with the zoom; it fades in on the descend and out on the summit arrival.
380
+ */
381
+ function drawMarker(ctx, view, marker) {
382
+ if (!marker || marker.alpha <= 0.02)
383
+ return;
384
+ const [sx, sy] = toScreen(view, marker.x, marker.y);
385
+ ctx.globalAlpha = marker.alpha;
386
+ setFill(ctx, P.paper);
387
+ ctx.beginPath();
388
+ ctx.arc(sx, sy, 7, 0, TAU);
389
+ ctx.fill();
390
+ setFill(ctx, P.ink);
391
+ ctx.beginPath();
392
+ ctx.arc(sx, sy, 5, 0, TAU);
393
+ ctx.fill();
394
+ ctx.globalAlpha = 1;
395
+ }
396
+ // --- collar ---------------------------------------------------------------
397
+ function drawScaleBar(ctx) {
398
+ const { px, segments } = model.collar.scale;
399
+ const x = COLLAR.left;
400
+ const y = 296;
401
+ const h = 5;
402
+ setFill(ctx, P.ink);
403
+ ctx.fillRect(x, y, px, h);
404
+ ctx.fillRect(x + 2 * px, y, px, h);
405
+ setStroke(ctx, P.ink);
406
+ ctx.lineWidth = 1;
407
+ ctx.strokeRect(x + 0.5, y + 0.5, segments * px - 1, h - 1);
408
+ for (let k = 0; k <= segments; k++) {
409
+ drawText(ctx, String(k * 4), x + k * px, y - 5, {
410
+ size: 9,
411
+ mono: true,
412
+ align: 'center',
413
+ color: P.muted,
414
+ });
415
+ }
416
+ drawText(ctx, 'WEEKS', x + segments * px + 8, y + 5, { size: 9, mono: true, color: P.muted });
417
+ }
418
+ function drawCollar(ctx, altimeter) {
419
+ const c = model.collar;
420
+ setFill(ctx, P.paper);
421
+ ctx.fillRect(0, 0, viewport.left, H);
422
+ // The double rule on the collar's right edge.
423
+ setFill(ctx, P.ink, 0.45);
424
+ ctx.fillRect(391, 0, 1, H);
425
+ setFill(ctx, P.ink);
426
+ ctx.fillRect(398, 0, 2, H);
427
+ const x = COLLAR.left;
428
+ drawText(ctx, 'TRAIL PROFILE · LAST 12 MONTHS', x, 58, {
429
+ size: 11,
430
+ mono: true,
431
+ tracking: 2,
432
+ color: P.muted,
433
+ });
434
+ drawText(ctx, c.name.text, x, 122, {
435
+ size: c.name.size,
436
+ bold: true,
437
+ tracking: 3,
438
+ color: P.ink,
439
+ });
440
+ if (c.tagline) {
441
+ drawText(ctx, c.tagline.text, x, 152, { size: c.tagline.size, color: P.accent });
442
+ }
443
+ drawText(ctx, c.total, x, 216, { size: 34, bold: true, color: P.ink });
444
+ drawText(ctx, 'contributions', x, 238, { size: 11, mono: true, color: P.muted });
445
+ if (altimeter) {
446
+ drawText(ctx, altimeter, x, 266, { size: 11, mono: true, color: P.ink });
447
+ }
448
+ drawScaleBar(ctx);
449
+ if (c.intervalText) {
450
+ drawText(ctx, c.intervalText, x, 328, { size: 10, mono: true, color: P.muted });
451
+ }
452
+ if (c.website) {
453
+ drawText(ctx, c.website.text, x, 352, { size: c.website.size, mono: true, color: P.accent });
454
+ }
455
+ }
456
+ // --- frame ----------------------------------------------------------------
457
+ function draw(p, _frame, t) {
458
+ const ctx = p.drawingContext;
459
+ const frameFloat = t * FRAMES;
460
+ const cam = cameraAt(model.plan.keys, frameFloat, FRAMES, CAMERA);
461
+ const view = viewOf(cam);
462
+ const sheetAlpha = smoothstep(LABEL_Z.sheet[0], LABEL_Z.sheet[1], cam.z);
463
+ const summitAlpha = smoothstep(LABEL_Z.summit[0], LABEL_Z.summit[1], cam.z);
464
+ setFill(ctx, P.paper);
465
+ ctx.fillRect(0, 0, W, H);
466
+ ctx.save();
467
+ ctx.beginPath();
468
+ ctx.rect(viewport.left, 0, W - viewport.left, H);
469
+ ctx.clip();
470
+ applyCamera(ctx, cam, viewport.cx, viewport.cy);
471
+ setFill(ctx, P.paper);
472
+ ctx.fillRect(0, 0, world.w, world.h);
473
+ drawGrid(ctx);
474
+ drawGraticule(ctx);
475
+ drawRaster(ctx, cam, view);
476
+ drawSummit(ctx, cam, t, summitAlpha);
477
+ drawNeatline(ctx, cam);
478
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
479
+ drawSheetLabels(ctx, view, sheetAlpha);
480
+ drawMarker(ctx, view, markerAt(profile, model.plan, frameFloat));
481
+ ctx.restore();
482
+ drawCollar(ctx, altimeterLine(profile, model.plan, frameFloat));
483
+ }
484
+ return { setup, draw };
485
+ }