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,278 @@
1
+ import { canvasMeasure, fitLine } from '../../core/text.js';
2
+ import { buildProfile, CELL } from './profile.js';
3
+ /**
4
+ * The survey sheet as filmed: where the collar's type sits, and one walk from
5
+ * the year's first week to the summit and back (Highlights Reel's camera, in
6
+ * `core/camera.ts`). Everything here is a pure function of the data; the
7
+ * sketch only draws.
8
+ */
9
+ export const FRAMES = 480;
10
+ const W = 1280;
11
+ const H = 400;
12
+ /** The map's viewport: right of the collar. */
13
+ const VIEWPORT_LEFT = 400;
14
+ export const LAYOUT = {
15
+ width: W,
16
+ height: H,
17
+ viewport: { left: VIEWPORT_LEFT, cx: (VIEWPORT_LEFT + W) / 2, cy: H / 2 },
18
+ /** Margin around the sheet at the wide shot, screen px. */
19
+ wideMargin: 24,
20
+ /** The summit holds close. */
21
+ summitZ: 1.6,
22
+ /** Camera dip tuning: moves longer than `travel` dip, scaled to `span`. */
23
+ travel: 700,
24
+ span: 1600,
25
+ /** Collar text column. */
26
+ collar: { left: 48, right: 368 },
27
+ /**
28
+ * The walk. The camera frames the whole elevation band, from above the
29
+ * flag to the month labels, so it never chases the walker up and down: `y`
30
+ * and `z` are fixed and only `x` pans. The walker keeps one speed along the
31
+ * trail (screen px per frame, at `z`), eased in and out over `ramp` frames.
32
+ * A walk shorter than `max` frames gives its spare frames to the holds; a
33
+ * longer trail starts the walk that far before the summit.
34
+ */
35
+ walk: { z: 0.43, y: 660, speed: 7, ramp: 20, min: 60, max: 215 },
36
+ /** The walk's horizontal view keeps this margin past the map, world units. */
37
+ panMargin: 40,
38
+ /**
39
+ * Segment lengths, in frames, with the walk at `walk.max`; they add up to
40
+ * `FRAMES`. A shorter walk splits its spare frames between the two holds.
41
+ */
42
+ segments: { wideHold: 55, descend: 40, arrive: 40, summitHold: 80, pullBack: 50 },
43
+ };
44
+ const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
45
+ /** "2026-03-12" .. "2026-03-18" -> "MAR 12 TO 18" (crossing months spells both). */
46
+ export function summitDates(from, to) {
47
+ const [, fm, fd] = from.split('-').map(Number);
48
+ const [, tm, td] = to.split('-').map(Number);
49
+ return fm === tm
50
+ ? `${MONTHS[fm - 1]} ${fd} TO ${td}`
51
+ : `${MONTHS[fm - 1]} ${fd} TO ${MONTHS[tm - 1]} ${td}`;
52
+ }
53
+ /** The wide shot: the whole sheet in the viewport with `wideMargin` to spare. */
54
+ export function wideShot(profile) {
55
+ const z = Math.min((W - VIEWPORT_LEFT - LAYOUT.wideMargin) / profile.world.w, (H - LAYOUT.wideMargin) / profile.world.h);
56
+ return { x: profile.world.w / 2, y: profile.world.h / 2, z };
57
+ }
58
+ /** Weeks the months start in, for the graticule and the labels under the ground. */
59
+ export function monthMarks(contributions) {
60
+ const marks = [];
61
+ contributions.weeks.forEach((week, i) => {
62
+ for (const day of week) {
63
+ if (day.date < contributions.from || day.date > contributions.to)
64
+ continue;
65
+ const [, m, d] = day.date.split('-').map(Number);
66
+ if (d === 1) {
67
+ marks.push({ week: i, label: MONTHS[m - 1] });
68
+ break;
69
+ }
70
+ }
71
+ });
72
+ return marks;
73
+ }
74
+ const clamp01 = (v) => Math.min(1, Math.max(0, v));
75
+ /**
76
+ * The walker at a loop frame: standing at the walk's start while it fades in,
77
+ * walking the baked path to the summit, then fading out as the camera pushes
78
+ * in, so the survey triangle takes over and the wide holds match.
79
+ */
80
+ export function markerAt(profile, plan, frame) {
81
+ const { path, walk, fadeIn, fadeOut } = plan;
82
+ if (!profile.summit || path.length === 0)
83
+ return null;
84
+ const f = Math.floor(frame);
85
+ let x;
86
+ let alpha = 1;
87
+ let walking = false;
88
+ if (f < walk.start) {
89
+ x = path[0];
90
+ alpha = clamp01((frame - fadeIn.start) / (fadeIn.end - fadeIn.start));
91
+ }
92
+ else if (f < walk.end) {
93
+ x = path[f - walk.start];
94
+ walking = true;
95
+ }
96
+ else {
97
+ x = path.at(-1);
98
+ alpha = 1 - clamp01((frame - fadeOut.start) / (fadeOut.end - fadeOut.start));
99
+ }
100
+ return { x, y: profile.profileY(x), alpha, walking };
101
+ }
102
+ /**
103
+ * Distance along the trail at walk frame `f` of `n`, for a trail `length`
104
+ * long: a sine ramp up over `ramp` frames, a steady cruise, and the mirror
105
+ * ramp down, so velocity is continuous and the cruise is one speed.
106
+ */
107
+ export function walkDistance(f, n, ramp, length) {
108
+ const v = length / (n - ramp);
109
+ const up = (g) => v * (g / 2 - (ramp / (2 * Math.PI)) * Math.sin((Math.PI * g) / ramp));
110
+ if (f <= 0)
111
+ return 0;
112
+ if (f >= n)
113
+ return length;
114
+ if (f < ramp)
115
+ return up(f);
116
+ if (f <= n - ramp)
117
+ return v * (ramp / 2 + (f - ramp));
118
+ return length - up(n - f);
119
+ }
120
+ /**
121
+ * The walker's path: its x at each walk frame, at one speed along the trail
122
+ * (arc length, so it slows on the climbs instead of shooting up them). The
123
+ * walk takes as many frames as the trail needs at `walk.speed`, within
124
+ * `walk.min` and `walk.max`; a trail too long for `walk.max` starts that far
125
+ * before the summit.
126
+ */
127
+ function walkPath(profile) {
128
+ const { speed, ramp, min, max, z } = LAYOUT.walk;
129
+ const to = profile.summit.x;
130
+ // Cumulative arc length from the first week, sampled every world unit.
131
+ const xs = [];
132
+ const arc = [];
133
+ let s = 0;
134
+ let prevY = profile.profileY(profile.weekX(0));
135
+ for (let x = profile.weekX(0);; x = Math.min(x + 1, to)) {
136
+ const y = profile.profileY(x);
137
+ if (xs.length > 0)
138
+ s += Math.hypot(x - xs.at(-1), y - prevY);
139
+ xs.push(x);
140
+ arc.push(s);
141
+ prevY = y;
142
+ if (x >= to)
143
+ break;
144
+ }
145
+ const maxLength = ((max - ramp) * speed) / z;
146
+ const length = Math.min(s, maxLength);
147
+ const n = Math.min(max, Math.max(min, Math.round((length * z) / speed + ramp)));
148
+ const offset = s - length;
149
+ const path = [];
150
+ let i = 0;
151
+ for (let f = 0; f <= n; f++) {
152
+ const target = offset + walkDistance(f, n, ramp, length);
153
+ while (i < arc.length - 2 && arc[i + 1] < target)
154
+ i++;
155
+ const a = arc[i];
156
+ const b = arc[i + 1] ?? a;
157
+ const u = b > a ? clamp01((target - a) / (b - a)) : 0;
158
+ path.push(xs[i] + ((xs[i + 1] ?? xs[i]) - xs[i]) * u);
159
+ }
160
+ path[n] = to;
161
+ return path;
162
+ }
163
+ /**
164
+ * The walk's camera: fixed `y` and `z`, and an `x` that pans in step with
165
+ * the walker across the whole sheet, so the view's left edge sits at the
166
+ * map's west margin at the first week and its right edge at the east margin
167
+ * at the last. The pan is a straight line of the walker's x: no kinks.
168
+ */
169
+ function walkCam(profile, x) {
170
+ const { z, y } = LAYOUT.walk;
171
+ const half = (W - VIEWPORT_LEFT) / 2 / z;
172
+ const lo = profile.mapArea.x0 - LAYOUT.panMargin + half;
173
+ const hi = profile.mapArea.x1 + LAYOUT.panMargin - half;
174
+ const first = profile.weekX(0);
175
+ const last = profile.weekX(profile.cols - 1);
176
+ const u = last > first ? clamp01((x - first) / (last - first)) : 0.5;
177
+ return { x: hi > lo ? lo + (hi - lo) * u : profile.world.w / 2, y, z };
178
+ }
179
+ const EMPTY = { start: 0, end: 0 };
180
+ /**
181
+ * The camera plan. The walk is baked as one key per frame, so GIF frames
182
+ * sample it exactly and nothing re-eases.
183
+ */
184
+ function buildPlan(profile) {
185
+ const wide = wideShot(profile);
186
+ if (!profile.summit) {
187
+ // The empty state: the camera holds the wide shot for the whole loop.
188
+ return {
189
+ keys: [
190
+ { frame: 0, ...wide },
191
+ { frame: FRAMES, ...wide },
192
+ ],
193
+ summitHold: EMPTY,
194
+ walk: EMPTY,
195
+ fadeIn: EMPTY,
196
+ fadeOut: EMPTY,
197
+ path: [],
198
+ };
199
+ }
200
+ const seg = LAYOUT.segments;
201
+ const path = walkPath(profile);
202
+ const walkFrames = path.length - 1;
203
+ const spare = LAYOUT.walk.max - walkFrames;
204
+ const wideHold = seg.wideHold + Math.floor(spare / 2);
205
+ const summitHold = seg.summitHold + Math.ceil(spare / 2);
206
+ const walkStart = wideHold + seg.descend;
207
+ const walkEnd = walkStart + walkFrames;
208
+ const holdStart = walkEnd + seg.arrive;
209
+ const holdEnd = holdStart + summitHold;
210
+ const keys = [
211
+ { frame: 0, ...wide },
212
+ { frame: wideHold, ...wide },
213
+ ];
214
+ path.forEach((x, f) => keys.push({ frame: walkStart + f, ...walkCam(profile, x) }));
215
+ const summit = { x: profile.summit.x, y: profile.summit.y + 30, z: LAYOUT.summitZ };
216
+ keys.push({ frame: holdStart, ...summit });
217
+ keys.push({ frame: holdEnd, ...summit });
218
+ keys.push({ frame: FRAMES, ...wide });
219
+ return {
220
+ keys,
221
+ summitHold: { start: holdStart, end: holdEnd },
222
+ walk: { start: walkStart, end: walkEnd },
223
+ fadeIn: { start: wideHold, end: walkStart },
224
+ fadeOut: { start: walkEnd, end: holdStart },
225
+ path,
226
+ };
227
+ }
228
+ /**
229
+ * The collar's altimeter line: the summit at rest, and the week under the
230
+ * walker (its total and dates) while it walks, switching back at arrival.
231
+ */
232
+ export function altimeterLine(profile, plan, frame) {
233
+ const summit = profile.summit;
234
+ if (!summit)
235
+ return null;
236
+ const marker = markerAt(profile, plan, frame);
237
+ if (marker?.walking) {
238
+ const week = profile.weeks[profile.weekAt(marker.x)];
239
+ return `ALT ${week.total} · ${summitDates(week.from, week.to)}`;
240
+ }
241
+ return `SUMMIT ${summit.total} · ${summitDates(summit.from, summit.to)}`;
242
+ }
243
+ function buildCollar(profile, identity, zWide, total) {
244
+ const column = LAYOUT.collar.right - LAYOUT.collar.left;
245
+ return {
246
+ name: fitLine(identity.name.toUpperCase(), column, 44, 30, canvasMeasure('Space Grotesk', 700, 3)),
247
+ tagline: identity.tagline
248
+ ? fitLine(identity.tagline, column, 17, 17, canvasMeasure('Space Grotesk', 400))
249
+ : null,
250
+ website: identity.website
251
+ ? fitLine(`↗ ${displayUrl(identity.website)}`, column, 12, 12, canvasMeasure('JetBrains Mono', 400))
252
+ : null,
253
+ total: total.toLocaleString('en-US'),
254
+ ...(profile.summit
255
+ ? {
256
+ summitLine: `SUMMIT ${profile.summit.total} · ${summitDates(profile.summit.from, profile.summit.to)}`,
257
+ intervalText: `CONTOUR INTERVAL ${profile.interval} CONTRIBUTIONS`,
258
+ }
259
+ : { summitLine: null, intervalText: null }),
260
+ scale: { weeksPerSegment: 4, segments: 3, px: 4 * CELL * zWide },
261
+ };
262
+ }
263
+ /** "https://www.example.com/" -> "www.example.com" */
264
+ function displayUrl(url) {
265
+ return url.replace(/^[a-z]+:\/\//i, '').replace(/\/+$/, '');
266
+ }
267
+ export function buildTrail(data, identity, palette) {
268
+ const profile = buildProfile(data);
269
+ const zWide = wideShot(profile).z;
270
+ return {
271
+ profile,
272
+ collar: buildCollar(profile, identity, zWide, data.total),
273
+ months: monthMarks(data),
274
+ plan: buildPlan(profile),
275
+ palette,
276
+ zWide,
277
+ };
278
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "headreel",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Animated GitHub profile banner made from your GitHub activity, with a GitHub Action or CLI.",
5
5
  "keywords": [
6
6
  "github",