headreel 1.2.0 → 1.3.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,555 @@
1
+ import { fitIdentity } from '../../core/text.js';
2
+ import { FRAMES, LAYOUT, plateAt, stationOf, } from './reel.js';
3
+ /**
4
+ * One camera over one desk.
5
+ *
6
+ * The cards and desk are drawn under the camera transform; the identity column
7
+ * is drawn after it, in screen space, and never moves. Held shots do not
8
+ * move at all. The dot
9
+ * field reads the camera speed and gets out of the way: hairline dots crossing
10
+ * the frame fast strobe, and cost GIF bytes. There is no screen-space grain:
11
+ * on a light desk it reads as dust on the lens once the cards move under it.
12
+ *
13
+ * Everything is drawn through the raw 2D context, with the style set right
14
+ * before each draw. p5's cached fill state desyncs from the context as soon as
15
+ * a gradient or shadow is written directly, and a stale fill paints cards
16
+ * white; explicit styles keep every frame identical.
17
+ */
18
+ const SANS = '"Space Grotesk"';
19
+ const MONO = '"JetBrains Mono"';
20
+ const PROMPT = '~/github';
21
+ const { width: W, height: H } = LAYOUT;
22
+ /** The busiest week's bar, opened: seven day cells with their counts. */
23
+ const COLUMN_W = 46;
24
+ const TAU = Math.PI * 2;
25
+ /** Width of the identity column: x 48 to 440, the solid part of the band. */
26
+ export const IDENTITY_COLUMN = 440 - 48;
27
+ const clamp01 = (v) => Math.min(1, Math.max(0, v));
28
+ const easeOutCubic = (t) => 1 - (1 - t) ** 3;
29
+ /** The one travel curve: almost all motion happens in the middle third. */
30
+ const travelEase = (t) => (t < 0.5 ? 8 * t ** 4 : 1 - (-2 * t + 2) ** 4 / 2);
31
+ function smoothstep(e0, e1, x) {
32
+ const t = clamp01((x - e0) / (e1 - e0));
33
+ return t * t * (3 - 2 * t);
34
+ }
35
+ /** 0..1, periodic in `t`, so every animated value loops seamlessly. */
36
+ function wave(t, cycles, off) {
37
+ return 0.5 + 0.5 * Math.sin(TAU * (t * cycles + off));
38
+ }
39
+ function truncate(text, max) {
40
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
41
+ }
42
+ /** "https://www.example.com/" -> "www.example.com" */
43
+ function displayUrl(url) {
44
+ return url.replace(/^[a-z]+:\/\//i, '').replace(/\/+$/, '');
45
+ }
46
+ function mix(a, b, t) {
47
+ return [
48
+ Math.round(a[0] + (b[0] - a[0]) * t),
49
+ Math.round(a[1] + (b[1] - a[1]) * t),
50
+ Math.round(a[2] + (b[2] - a[2]) * t),
51
+ ];
52
+ }
53
+ function setFill(ctx, color, alpha = 1) {
54
+ ctx.fillStyle =
55
+ typeof color === 'string' ? color : `rgba(${color[0]},${color[1]},${color[2]},${alpha})`;
56
+ }
57
+ function setStroke(ctx, color, alpha = 1) {
58
+ ctx.strokeStyle =
59
+ typeof color === 'string' ? color : `rgba(${color[0]},${color[1]},${color[2]},${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 = '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
+ function measure(ctx, str, o) {
75
+ setFont(ctx, o);
76
+ return ctx.measureText(str).width;
77
+ }
78
+ function roundRect(ctx, x, y, w, h, r) {
79
+ ctx.beginPath();
80
+ ctx.roundRect(x, y, w, h, r);
81
+ }
82
+ export function createReelSketch(reel, identity) {
83
+ const PALETTE = reel.palette;
84
+ // The identity column ends where the solid part of the identity band does.
85
+ const fitted = fitIdentity({
86
+ name: identity.name.toUpperCase(),
87
+ tagline: identity.tagline,
88
+ website: identity.website && `↗ ${displayUrl(identity.website)}`,
89
+ }, IDENTITY_COLUMN);
90
+ // --- camera ---------------------------------------------------------------
91
+ /** The camera between keys: one curve, with a zoom dip on long moves. */
92
+ function rawShot(frame) {
93
+ const keys = reel.plan.keys;
94
+ if (frame <= keys[0].frame) {
95
+ return { x: keys[0].x, y: keys[0].y, z: keys[0].z };
96
+ }
97
+ const last = keys[keys.length - 1];
98
+ if (frame >= last.frame) {
99
+ return { x: last.x, y: last.y, z: last.z };
100
+ }
101
+ let index = 0;
102
+ while (index < keys.length - 2 && keys[index + 1].frame <= frame) {
103
+ index++;
104
+ }
105
+ const from = keys[index];
106
+ const to = keys[index + 1];
107
+ const t = clamp01((frame - from.frame) / (to.frame - from.frame));
108
+ const travelled = travelEase(t);
109
+ const distance = Math.hypot(to.x - from.x, to.y - from.y);
110
+ // Zoom travels in log space, so leaving a deep push-in pulls back as fast
111
+ // as it pans. A long move also dips at its midpoint and pushes in to land.
112
+ const dip = distance > LAYOUT.travel
113
+ ? 1 - 0.22 * Math.sin(Math.PI * t) * Math.min(1, distance / 2400)
114
+ : 1;
115
+ return {
116
+ x: from.x + (to.x - from.x) * travelled,
117
+ y: from.y + (to.y - from.y) * travelled,
118
+ z: from.z * (to.z / from.z) ** travelled * dip,
119
+ };
120
+ }
121
+ /**
122
+ * The camera at a moment of the loop. Held shots are still: any drift, even
123
+ * in whole-pixel steps, reads as the cards stepping or the type vibrating.
124
+ */
125
+ function camera(frameFloat) {
126
+ // t = 1 snaps to 0, so the last frame is bit-identical to the first.
127
+ const t = frameFloat >= FRAMES ? 0 : (((frameFloat / FRAMES) % 1) + 1) % 1;
128
+ return rawShot(t * FRAMES);
129
+ }
130
+ /** How fast the frame is moving, in screen pixels, zoom included. */
131
+ function speedOf(frameFloat) {
132
+ const now = camera(frameFloat);
133
+ const before = camera(frameFloat - 1);
134
+ return ((Math.hypot(now.x - before.x, now.y - before.y) + Math.abs(now.z - before.z) * 700) * now.z);
135
+ }
136
+ /** A card builds while the camera arrives; an empty window means already finished. */
137
+ function buildAt(window, frameFloat) {
138
+ if (window.end <= window.start)
139
+ return 1;
140
+ return easeOutCubic(clamp01((frameFloat - window.start) / (window.end - window.start)));
141
+ }
142
+ // --- desk -----------------------------------------------------------------
143
+ /** Dot field, world space, culled to the visible rect, yielding to speed. */
144
+ function drawDots(ctx, cam, speed) {
145
+ const fade = 1 - clamp01((speed - 1) / 4);
146
+ if (fade < 0.03)
147
+ return;
148
+ const pitch = 56;
149
+ const hw = W / 2 / cam.z + pitch;
150
+ const hh = H / 2 / cam.z + pitch;
151
+ const k0 = Math.ceil(Math.max(-2800, cam.x - hw) / pitch);
152
+ const k1 = Math.floor(Math.min(3800, cam.x + hw) / pitch);
153
+ const j0 = Math.ceil(Math.max(-1000, cam.y - hh) / pitch);
154
+ const j1 = Math.floor(Math.min(1400, cam.y + hh) / pitch);
155
+ setFill(ctx, PALETTE.dots.rgb, PALETTE.dots.alpha * fade);
156
+ for (let k = k0; k <= k1; k++) {
157
+ for (let j = j0; j <= j1; j++) {
158
+ ctx.fillRect(k * pitch - 1.1, j * pitch - 1.1, 2.2, 2.2);
159
+ }
160
+ }
161
+ }
162
+ /** Print registration marks at the card corners: the desk as a light table. */
163
+ function drawMarks(ctx, index) {
164
+ const s = stationOf(index);
165
+ const { w, h } = LAYOUT.card;
166
+ const l = s.x - w / 2;
167
+ const t = s.y - h / 2;
168
+ const r = s.x + w / 2;
169
+ const b = s.y + h / 2;
170
+ const o = 12;
171
+ const a = 14;
172
+ setStroke(ctx, PALETTE.marks.rgb, PALETTE.marks.alpha);
173
+ ctx.lineWidth = 1.5;
174
+ ctx.beginPath();
175
+ ctx.moveTo(l - o - a, t);
176
+ ctx.lineTo(l - o, t);
177
+ ctx.moveTo(l, t - o - a);
178
+ ctx.lineTo(l, t - o);
179
+ ctx.moveTo(r + o, t);
180
+ ctx.lineTo(r + o + a, t);
181
+ ctx.moveTo(r, t - o - a);
182
+ ctx.lineTo(r, t - o);
183
+ ctx.moveTo(l - o - a, b);
184
+ ctx.lineTo(l - o, b);
185
+ ctx.moveTo(l, b + o);
186
+ ctx.lineTo(l, b + o + a);
187
+ ctx.moveTo(r + o, b);
188
+ ctx.lineTo(r + o + a, b);
189
+ ctx.moveTo(r, b + o);
190
+ ctx.lineTo(r, b + o + a);
191
+ ctx.stroke();
192
+ }
193
+ // --- card parts -----------------------------------------------------------
194
+ function eyebrow(ctx, text, x, y) {
195
+ drawText(ctx, text, x, y, { size: 15, mono: true, color: PALETTE.soft, tracking: 2.5 });
196
+ }
197
+ function unitLine(ctx, text, x, y) {
198
+ drawText(ctx, text, x, y, { size: 17, mono: true, color: PALETTE.soft });
199
+ }
200
+ /** The hero figure counts up in cobalt and settles to ink. */
201
+ function drawHero(ctx, value, x, y, size, prog) {
202
+ drawText(ctx, Math.round(value * prog).toLocaleString('en-US'), x, y, {
203
+ size,
204
+ bold: true,
205
+ color: mix(PALETTE.accent, PALETTE.ink, prog),
206
+ });
207
+ }
208
+ function starGlyph(ctx, x, y, r) {
209
+ ctx.beginPath();
210
+ for (let k = 0; k < 10; k++) {
211
+ const a = -Math.PI / 2 + (k * Math.PI) / 5;
212
+ const rr = k % 2 === 0 ? r : r * 0.45;
213
+ ctx.lineTo(x + Math.cos(a) * rr, y + Math.sin(a) * rr);
214
+ }
215
+ ctx.closePath();
216
+ ctx.fill();
217
+ }
218
+ function forkGlyph(ctx, x, y, alpha) {
219
+ setStroke(ctx, PALETTE.soft, alpha);
220
+ ctx.lineWidth = 2;
221
+ ctx.lineCap = 'round';
222
+ ctx.beginPath();
223
+ ctx.arc(x - 6, y - 9, 3, 0, TAU);
224
+ ctx.moveTo(x - 4, y - 9);
225
+ ctx.arc(x + 6, y - 9, 3, 0, TAU);
226
+ ctx.moveTo(x - 6, y - 6);
227
+ ctx.lineTo(x - 6, y);
228
+ ctx.quadraticCurveTo(x - 6, y + 5, x, y + 5);
229
+ ctx.moveTo(x + 6, y - 6);
230
+ ctx.lineTo(x + 6, y);
231
+ ctx.quadraticCurveTo(x + 6, y + 5, x, y + 5);
232
+ ctx.moveTo(x, y + 5);
233
+ ctx.lineTo(x, y + 12);
234
+ ctx.stroke();
235
+ }
236
+ // --- cards ----------------------------------------------------------------
237
+ function drawShell(ctx) {
238
+ const { w, h, r } = LAYOUT.card;
239
+ setFill(ctx, PALETTE.shadow.rgb, PALETTE.shadow.alpha);
240
+ roundRect(ctx, 0, 16, w, h, r);
241
+ ctx.fill();
242
+ setFill(ctx, PALETTE.card);
243
+ roundRect(ctx, 0, 0, w, h, r);
244
+ ctx.fill();
245
+ setStroke(ctx, PALETTE.border.rgb, PALETTE.border.alpha);
246
+ ctx.lineWidth = 1.5;
247
+ ctx.stroke();
248
+ }
249
+ function drawContributions(ctx, card, prog, zoom) {
250
+ eyebrow(ctx, 'CONTRIBUTIONS · LAST 12 MONTHS', 70, 86);
251
+ drawHero(ctx, card.total, 68, 212, 118, prog);
252
+ if (card.streak !== null) {
253
+ const a = clamp01((prog - 0.5) / 0.3);
254
+ if (a > 0.02) {
255
+ setFill(ctx, PALETTE.amber, a);
256
+ roundRect(ctx, 70, 236, 4, 18, 2);
257
+ ctx.fill();
258
+ const label = 'longest streak';
259
+ const value = ` ${card.streak} ${card.streak === 1 ? 'day' : 'days'}`;
260
+ const opts = { size: 16, mono: true, color: PALETTE.soft, alpha: a };
261
+ drawText(ctx, label, 86, 250, opts);
262
+ drawText(ctx, value, 86 + measure(ctx, label, opts), 250, {
263
+ ...opts,
264
+ color: PALETTE.ink,
265
+ });
266
+ }
267
+ }
268
+ drawStrip(ctx, card, prog, zoom);
269
+ }
270
+ function drawStrip(ctx, card, prog, zoom) {
271
+ const { x, baseline, height } = LAYOUT.strip;
272
+ const n = card.weeks.length;
273
+ const pitch = (LAYOUT.card.w - 2 * x) / n;
274
+ const barW = Math.max(6, pitch * 0.8);
275
+ const maxTotal = Math.max(1, ...card.weeks);
276
+ for (let i = 0; i < n; i++) {
277
+ if (i === card.busiest)
278
+ continue;
279
+ const gp = clamp01((prog - (i / n) * 0.55) / 0.45);
280
+ if (gp <= 0)
281
+ continue;
282
+ const total = card.weeks[i];
283
+ const bx = x + i * pitch + (pitch - barW) / 2;
284
+ if (total === 0) {
285
+ setFill(ctx, PALETTE.well);
286
+ ctx.fillRect(bx, baseline - 6 * gp, barW, 6 * gp);
287
+ }
288
+ else {
289
+ setFill(ctx, PALETTE.accent);
290
+ const h = (6 + (total / maxTotal) * (height - 6)) * easeOutCubic(gp);
291
+ ctx.fillRect(bx, baseline - h, barW, h);
292
+ }
293
+ }
294
+ if (card.busiest !== null)
295
+ drawOpenedColumn(ctx, card, pitch, prog, zoom);
296
+ }
297
+ /**
298
+ * The busiest week's bar, opened: a white plate over its neighbours, seven
299
+ * day cells inside it, counts and a date label that only read once the
300
+ * camera has pushed in close enough.
301
+ */
302
+ function drawOpenedColumn(ctx, card, pitch, prog, zoom) {
303
+ const { x, baseline, height } = LAYOUT.strip;
304
+ const detail = smoothstep(0.85, 1.05, zoom);
305
+ const cx = x + (card.busiest + 0.5) * pitch;
306
+ const left = cx - COLUMN_W / 2;
307
+ const days = card.busiestDays;
308
+ const cellH = height / days.length;
309
+ setFill(ctx, PALETTE.card);
310
+ ctx.fillRect(left - 3, baseline - height * prog, COLUMN_W + 6, height * prog + 3);
311
+ days.forEach((count, k) => {
312
+ const top = baseline - (k + 1) * cellH * prog;
313
+ setFill(ctx, count > 0 ? PALETTE.accent : PALETTE.well);
314
+ ctx.fillRect(left, top + 1, COLUMN_W, Math.max(0, cellH * prog - 2));
315
+ });
316
+ if (detail > 0.02) {
317
+ days.forEach((count, k) => {
318
+ const cy = baseline - (k + 0.5) * cellH * prog + 4.5;
319
+ drawText(ctx, String(count), cx, cy, {
320
+ size: 13,
321
+ mono: true,
322
+ align: 'center',
323
+ color: count > 0 ? PALETTE.onAccent : PALETTE.soft,
324
+ alpha: detail,
325
+ });
326
+ });
327
+ const total = days.reduce((s, d) => s + d, 0);
328
+ const label = `${card.busiestLabel}, ${total} ${total === 1 ? 'contribution' : 'contributions'}`;
329
+ const lw = measure(ctx, label, { size: 15, mono: true });
330
+ const lx = Math.min(Math.max(cx, 70 + lw / 2), LAYOUT.card.w - 70 - lw / 2);
331
+ drawText(ctx, label, lx, baseline + 30, {
332
+ size: 15,
333
+ mono: true,
334
+ align: 'center',
335
+ color: PALETTE.soft,
336
+ alpha: detail,
337
+ });
338
+ }
339
+ }
340
+ /** The top repo's own card: what the camera finds inside its pseudo card. */
341
+ function drawTopRepo(ctx, card, prog) {
342
+ eyebrow(ctx, 'TOP REPOSITORY', 70, 86);
343
+ const a = clamp01((prog - 0.4) / 0.3);
344
+ drawText(ctx, truncate(card.name, 26), 70, 154, {
345
+ size: 46,
346
+ bold: true,
347
+ color: PALETTE.ink,
348
+ alpha: a,
349
+ });
350
+ setFill(ctx, PALETTE.amber, a);
351
+ starGlyph(ctx, 92, 268, 17);
352
+ drawHero(ctx, card.stars, 120, 302, 112, prog);
353
+ if (a > 0.02) {
354
+ let mx = 70;
355
+ if (card.language) {
356
+ setFill(ctx, card.language.color);
357
+ ctx.beginPath();
358
+ ctx.arc(mx + 6, 386, 5.5, 0, TAU);
359
+ ctx.fill();
360
+ drawText(ctx, card.language.name, mx + 20, 390, {
361
+ size: 17,
362
+ mono: true,
363
+ color: PALETTE.soft,
364
+ alpha: a,
365
+ });
366
+ mx += 20 + measure(ctx, card.language.name, { size: 17, mono: true }) + 30;
367
+ }
368
+ forkGlyph(ctx, mx + 6, 384, a);
369
+ drawText(ctx, `${card.forks.toLocaleString('en-US')} ${card.forks === 1 ? 'fork' : 'forks'}`, mx + 20, 390, { size: 17, mono: true, color: PALETTE.soft, alpha: a });
370
+ }
371
+ }
372
+ /**
373
+ * A repo seen from across the desk: the shape of a card in its own colours,
374
+ * a name bar and a star bar sized to its share of the top repo's stars.
375
+ */
376
+ function drawPlate(ctx, plate, max) {
377
+ const { w, h, r } = LAYOUT.card;
378
+ setFill(ctx, PALETTE.plate);
379
+ roundRect(ctx, 0, 0, w, h, r * 2);
380
+ ctx.fill();
381
+ setFill(ctx, plate.color);
382
+ ctx.beginPath();
383
+ ctx.arc(100, 120, 24, 0, TAU);
384
+ ctx.fill();
385
+ setFill(ctx, PALETTE.ink, 0.72);
386
+ roundRect(ctx, 150, 98, Math.min(720, 120 + plate.name.length * 30), 44, 22);
387
+ ctx.fill();
388
+ setFill(ctx, PALETTE.amber);
389
+ starGlyph(ctx, 100, 340, 42);
390
+ setFill(ctx, PALETTE.accent, 0.85);
391
+ roundRect(ctx, 160, 316, 60 + 660 * (plate.stars / Math.max(1, max)), 48, 24);
392
+ ctx.fill();
393
+ }
394
+ /**
395
+ * Total stars, with the most starred repos laid out as pseudo cards. The
396
+ * camera then pushes into the first one, the others clear, and the pseudo
397
+ * card turns into the top repo's real card, drawn small in the world so the
398
+ * zoom finds real type.
399
+ */
400
+ function drawStars(ctx, card, prog, focusProg, zoom) {
401
+ eyebrow(ctx, 'TOTAL STARS', 70, 86);
402
+ const a = clamp01((prog - 0.3) / 0.3);
403
+ setFill(ctx, PALETTE.amber, a);
404
+ starGlyph(ctx, 92, 196, 17);
405
+ drawHero(ctx, card.totalStars, 120, 230, 104, prog);
406
+ if (prog > 0.9)
407
+ unitLine(ctx, 'across public repositories', 70, 280);
408
+ const { scale } = LAYOUT.mini;
409
+ const solo = smoothstep(0.9, 1.8, zoom);
410
+ const detail = smoothstep(1.6, 2.6, zoom);
411
+ const max = card.plates[0]?.stars ?? 1;
412
+ card.plates.forEach((plate, k) => {
413
+ const landed = easeOutCubic(clamp01((prog - (0.25 + k * 0.08)) / 0.3));
414
+ const shown = landed * (k === 0 ? 1 : 1 - solo);
415
+ if (shown <= 0.004)
416
+ return;
417
+ const at = plateAt(k);
418
+ ctx.save();
419
+ ctx.globalAlpha = shown;
420
+ ctx.translate(at.x, at.y + (1 - landed) * 24);
421
+ ctx.scale(scale, scale);
422
+ if (k > 0 || detail < 1)
423
+ drawPlate(ctx, plate, max);
424
+ if (k === 0 && detail > 0) {
425
+ ctx.globalAlpha = detail;
426
+ drawShell(ctx);
427
+ drawTopRepo(ctx, card, focusProg);
428
+ }
429
+ ctx.restore();
430
+ });
431
+ // The selection: the top repo is the one the camera is about to choose.
432
+ const sel = clamp01((prog - 0.8) / 0.2) * (1 - detail);
433
+ if (sel > 0.01 && card.plates.length > 0) {
434
+ const at = plateAt(0);
435
+ setStroke(ctx, PALETTE.accent, 0.85 * sel);
436
+ ctx.lineWidth = 3;
437
+ roundRect(ctx, at.x - 7, at.y - 7, LAYOUT.card.w * scale + 14, LAYOUT.card.h * scale + 14, LAYOUT.card.r * scale * 2 + 7);
438
+ ctx.stroke();
439
+ }
440
+ }
441
+ function drawPullRequests(ctx, card, prog) {
442
+ eyebrow(ctx, 'PULL REQUESTS', 70, 86);
443
+ drawHero(ctx, card.merged, 70, 296, 126, prog);
444
+ if (prog > 0.9)
445
+ unitLine(ctx, 'merged, all time', 70, 344);
446
+ }
447
+ function drawLanguages(ctx, card, prog) {
448
+ eyebrow(ctx, 'LANGUAGES', 70, 86);
449
+ card.languages.forEach((lang, j) => {
450
+ const a = clamp01((prog - (0.35 + j * 0.15)) / 0.25);
451
+ if (a <= 0.02)
452
+ return;
453
+ const y = 196 + j * 88;
454
+ setFill(ctx, lang.color);
455
+ ctx.beginPath();
456
+ ctx.arc(76, y - 13, 6, 0, TAU);
457
+ ctx.fill();
458
+ drawText(ctx, lang.name, 96, y, {
459
+ size: 40,
460
+ bold: true,
461
+ color: PALETTE.ink,
462
+ alpha: a,
463
+ });
464
+ drawText(ctx, `${lang.count} ${lang.count === 1 ? 'repository' : 'repositories'}`, 96, y + 30, { size: 16, mono: true, color: PALETTE.soft, alpha: a });
465
+ });
466
+ }
467
+ function drawCard(ctx, card, index, prog, focusProg, zoom) {
468
+ const s = stationOf(index);
469
+ ctx.save();
470
+ ctx.translate(s.x - LAYOUT.card.w / 2, s.y - LAYOUT.card.h / 2);
471
+ drawShell(ctx);
472
+ switch (card.kind) {
473
+ case 'contributions':
474
+ drawContributions(ctx, card, prog, zoom);
475
+ break;
476
+ case 'top_repo':
477
+ drawStars(ctx, card, prog, focusProg, zoom);
478
+ break;
479
+ case 'pull_requests':
480
+ drawPullRequests(ctx, card, prog);
481
+ break;
482
+ case 'languages':
483
+ drawLanguages(ctx, card, prog);
484
+ break;
485
+ }
486
+ ctx.restore();
487
+ }
488
+ // --- screen space ---------------------------------------------------------
489
+ /**
490
+ * The identity band: desk under type that never moves. Solid where a
491
+ * neighbouring card can sit at the hold, then a short fade into the shot.
492
+ */
493
+ function drawWash(ctx) {
494
+ const grad = ctx.createLinearGradient(0, 0, 480, 0);
495
+ const [r, g, b] = PALETTE.desk;
496
+ grad.addColorStop(0, `rgba(${r},${g},${b},1)`);
497
+ grad.addColorStop(440 / 480, `rgba(${r},${g},${b},1)`);
498
+ grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
499
+ ctx.fillStyle = grad;
500
+ ctx.fillRect(0, 0, 480, H);
501
+ }
502
+ function drawIdentity(ctx, t) {
503
+ const x = 48;
504
+ drawText(ctx, PROMPT, x, 58, { size: 13, mono: true, color: PALETTE.accent });
505
+ if (wave(t, 3, 0) > 0.5) {
506
+ setFill(ctx, PALETTE.accent);
507
+ ctx.fillRect(x + measure(ctx, PROMPT, { size: 13, mono: true }) + 4, 47, 7, 13);
508
+ }
509
+ drawText(ctx, fitted.name.text, x, 116, {
510
+ size: fitted.name.size,
511
+ bold: true,
512
+ color: PALETTE.ink,
513
+ tracking: 3,
514
+ });
515
+ if (fitted.tagline) {
516
+ drawText(ctx, fitted.tagline.text, x, 146, {
517
+ size: fitted.tagline.size,
518
+ color: PALETTE.accent,
519
+ });
520
+ }
521
+ if (fitted.website) {
522
+ drawText(ctx, fitted.website.text, x, 350, {
523
+ size: fitted.website.size,
524
+ mono: true,
525
+ color: PALETTE.accent,
526
+ });
527
+ }
528
+ }
529
+ function setup(_p) { }
530
+ function draw(p, _frame, t) {
531
+ const frameFloat = t * FRAMES;
532
+ const cam = camera(frameFloat);
533
+ const speed = speedOf(frameFloat);
534
+ const ctx = p.drawingContext;
535
+ setFill(ctx, PALETTE.desk);
536
+ ctx.fillRect(0, 0, W, H);
537
+ ctx.save();
538
+ // Snap the world to whole screen pixels. At a fixed zoom the drift then
539
+ // moves the cards in 1 px steps instead of resampling every glyph each
540
+ // frame at a new sub-pixel offset, which reads as vibrating type.
541
+ ctx.translate(Math.round(W / 2 - cam.x * cam.z), Math.round(H / 2 - cam.y * cam.z));
542
+ ctx.scale(cam.z, cam.z);
543
+ drawDots(ctx, cam, speed);
544
+ for (let i = 0; i < reel.cards.length; i++)
545
+ drawMarks(ctx, i);
546
+ reel.cards.forEach((card, i) => {
547
+ const prog = buildAt(reel.plan.builds[i], frameFloat);
548
+ drawCard(ctx, card, i, prog, buildAt(reel.plan.focus, frameFloat), cam.z);
549
+ });
550
+ ctx.restore();
551
+ drawWash(ctx);
552
+ drawIdentity(ctx, t);
553
+ }
554
+ return { setup, draw };
555
+ }
@@ -1,7 +1,9 @@
1
1
  import { contributionCity } from './contribution-city/index.js';
2
+ import { highlightsReel } from './highlights-reel/index.js';
2
3
  import { repoGalaxy } from './repo-galaxy/index.js';
3
4
  /** Built-in styles, keyed by id. */
4
5
  export const styles = {
5
6
  [contributionCity.id]: contributionCity,
7
+ [highlightsReel.id]: highlightsReel,
6
8
  [repoGalaxy.id]: repoGalaxy,
7
9
  };
@@ -1,3 +1,4 @@
1
+ import { fitIdentity } from '../../core/text.js';
1
2
  import { LAYOUT, RINGS } from './galaxy.js';
2
3
  const PALETTE = {
3
4
  skyTop: '#060914',
@@ -57,6 +58,13 @@ function truncate(text, max) {
57
58
  }
58
59
  export function createGalaxySketch(galaxy, identity, login, rng) {
59
60
  const grainSeed = Math.floor(rng() * 2 ** 31);
61
+ // Everything in the text column stops short of the outer orbit's dial.
62
+ const column = CX - RINGS[RINGS.length - 1].rx - 24 - TEXT_X;
63
+ const fitted = fitIdentity({
64
+ name: identity.name.toUpperCase(),
65
+ tagline: identity.tagline,
66
+ website: identity.website && `↗ ${displayUrl(identity.website)}`,
67
+ }, column);
60
68
  let sky;
61
69
  let grain;
62
70
  function renderSky(p) {
@@ -332,16 +340,16 @@ export function createGalaxySketch(galaxy, identity, login, rng) {
332
340
  }
333
341
  p.textFont(SANS);
334
342
  p.textStyle(p.BOLD);
335
- p.textSize(50);
343
+ p.textSize(fitted.name.size);
336
344
  ctx.letterSpacing = '3px';
337
345
  p.fill(PALETTE.ink);
338
- p.text(identity.name.toUpperCase(), x, 116);
346
+ p.text(fitted.name.text, x, 116);
339
347
  ctx.letterSpacing = '0px';
340
- if (identity.tagline) {
348
+ if (fitted.tagline) {
341
349
  p.textStyle(p.NORMAL);
342
- p.textSize(17);
350
+ p.textSize(fitted.tagline.size);
343
351
  p.fill(...brass);
344
- p.text(identity.tagline, x, 146);
352
+ p.text(fitted.tagline.text, x, 146);
345
353
  }
346
354
  const count = galaxy.planets.length;
347
355
  p.textFont(MONO);
@@ -371,10 +379,10 @@ export function createGalaxySketch(galaxy, identity, login, rng) {
371
379
  p.text(label, lx + 14, 296);
372
380
  lx += 14 + p.textWidth(label) + 18;
373
381
  }
374
- if (identity.website) {
375
- p.textSize(12);
382
+ if (fitted.website) {
383
+ p.textSize(fitted.website.size);
376
384
  p.fill(...brass);
377
- p.text(`↗ ${displayUrl(identity.website)}`, x, 350);
385
+ p.text(fitted.website.text, x, 350);
378
386
  }
379
387
  }
380
388
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "headreel",
3
- "version": "1.2.0",
3
+ "version": "1.3.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",