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.
- package/README.md +40 -19
- package/dist/core/data/contributions.js +12 -9
- package/dist/core/data/highlights.js +141 -0
- package/dist/core/encode/gif.js +50 -7
- package/dist/core/pipeline.js +5 -1
- package/dist/core/text.js +72 -0
- package/dist/styles/contribution-city/city.js +4 -2
- package/dist/styles/contribution-city/index.js +1 -1
- package/dist/styles/contribution-city/options.js +3 -2
- package/dist/styles/contribution-city/sketch.js +62 -32
- package/dist/styles/highlights-reel/index.js +16 -0
- package/dist/styles/highlights-reel/options.js +11 -0
- package/dist/styles/highlights-reel/palette.js +68 -0
- package/dist/styles/highlights-reel/reel.js +346 -0
- package/dist/styles/highlights-reel/sketch.js +555 -0
- package/dist/styles/index.js +2 -0
- package/dist/styles/repo-galaxy/sketch.js +16 -8
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fitIdentity } from '../../core/text.js';
|
|
1
2
|
import { cityOrigin, LAYOUT, tileAt } from './city.js';
|
|
2
3
|
const PALETTE = {
|
|
3
4
|
skyTop: '#07090c',
|
|
@@ -5,12 +6,32 @@ const PALETTE = {
|
|
|
5
6
|
skyFloor: '#0c0a09',
|
|
6
7
|
front: [20, 28, 34],
|
|
7
8
|
side: [12, 18, 23],
|
|
8
|
-
top: [8, 145, 178],
|
|
9
|
-
window: [165, 243, 252],
|
|
10
|
-
cyan: '#22d3ee',
|
|
11
9
|
ink: '#f5f5f4',
|
|
12
10
|
muted: '#a8a29e',
|
|
13
11
|
};
|
|
12
|
+
const ACCENTS = {
|
|
13
|
+
cyan: { deep: [8, 145, 178], bright: [34, 211, 238], pale: [165, 243, 252], scan: [12, 40, 50] },
|
|
14
|
+
cobalt: {
|
|
15
|
+
deep: [37, 99, 235],
|
|
16
|
+
bright: [96, 165, 250],
|
|
17
|
+
pale: [191, 219, 254],
|
|
18
|
+
scan: [19, 33, 50],
|
|
19
|
+
},
|
|
20
|
+
green: { deep: [5, 150, 105], bright: [52, 211, 153], pale: [167, 243, 208], scan: [10, 42, 31] },
|
|
21
|
+
violet: {
|
|
22
|
+
deep: [124, 58, 237],
|
|
23
|
+
bright: [167, 139, 250],
|
|
24
|
+
pale: [221, 214, 254],
|
|
25
|
+
scan: [33, 28, 50],
|
|
26
|
+
},
|
|
27
|
+
pink: {
|
|
28
|
+
deep: [219, 39, 119],
|
|
29
|
+
bright: [244, 114, 182],
|
|
30
|
+
pale: [251, 207, 232],
|
|
31
|
+
scan: [49, 23, 36],
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
const rgba = ([r, g, b], a) => `rgba(${r},${g},${b},${a})`;
|
|
14
35
|
const SANS = 'Space Grotesk';
|
|
15
36
|
const MONO = 'JetBrains Mono';
|
|
16
37
|
const TEXT_X = 48;
|
|
@@ -27,7 +48,16 @@ function easeInOut(t) {
|
|
|
27
48
|
function displayUrl(url) {
|
|
28
49
|
return url.replace(/^[a-z]+:\/\//i, '').replace(/\/+$/, '');
|
|
29
50
|
}
|
|
30
|
-
export function createCitySketch(city, identity, rng) {
|
|
51
|
+
export function createCitySketch(city, identity, rng, accent = 'cyan') {
|
|
52
|
+
const { deep, bright, pale, scan } = ACCENTS[accent];
|
|
53
|
+
const [br, bg, bb] = bright;
|
|
54
|
+
const [pr, pg, pb] = pale;
|
|
55
|
+
// The name and tagline run above the city; the website sits beside it.
|
|
56
|
+
const fitted = fitIdentity({
|
|
57
|
+
name: identity.name.toUpperCase(),
|
|
58
|
+
tagline: identity.tagline,
|
|
59
|
+
website: identity.website && `↗ ${displayUrl(identity.website)}`,
|
|
60
|
+
}, W - TEXT_X - LAYOUT.rightMargin, cityOrigin(city.weeks).x - 16 - TEXT_X);
|
|
31
61
|
const origin = cityOrigin(city.weeks);
|
|
32
62
|
const noiseSeed = Math.floor(rng() * 2 ** 31);
|
|
33
63
|
const grainSeed = Math.floor(rng() * 2 ** 31);
|
|
@@ -51,9 +81,9 @@ export function createCitySketch(city, identity, rng) {
|
|
|
51
81
|
const gx = origin.x + 520;
|
|
52
82
|
const gy = baseY - 40;
|
|
53
83
|
const glow = ctx.createRadialGradient(gx, gy, 10, gx, gy, 620);
|
|
54
|
-
glow.addColorStop(0,
|
|
55
|
-
glow.addColorStop(0.5,
|
|
56
|
-
glow.addColorStop(1,
|
|
84
|
+
glow.addColorStop(0, rgba(deep, 0.22));
|
|
85
|
+
glow.addColorStop(0.5, rgba(deep, 0.06));
|
|
86
|
+
glow.addColorStop(1, rgba(deep, 0));
|
|
57
87
|
ctx.fillStyle = glow;
|
|
58
88
|
ctx.fillRect(0, 0, W, H);
|
|
59
89
|
// Faint contour lines from warped noise.
|
|
@@ -61,7 +91,7 @@ export function createCitySketch(city, identity, rng) {
|
|
|
61
91
|
g.noFill();
|
|
62
92
|
g.strokeWeight(0.6);
|
|
63
93
|
for (let k = 0; k < 7; k++) {
|
|
64
|
-
g.stroke(
|
|
94
|
+
g.stroke(br, bg, bb, 10 + k * 1.5);
|
|
65
95
|
g.beginShape();
|
|
66
96
|
for (let x = 0; x <= W; x += 8) {
|
|
67
97
|
const n = p.noise(x * 0.0025, k * 0.4);
|
|
@@ -100,13 +130,13 @@ export function createCitySketch(city, identity, rng) {
|
|
|
100
130
|
p.fill(14, 18, 22);
|
|
101
131
|
p.quad(origin.x - 14, origin.y + 6, frontRight.x + 14, origin.y + 6, frontRight.x + 14 + 7 * depthX, back.y - depthY - 4, back.x - 14 + depthX, back.y - depthY - 4);
|
|
102
132
|
// Street grid, one line per weekday row.
|
|
103
|
-
p.stroke(
|
|
133
|
+
p.stroke(br, bg, bb, 18);
|
|
104
134
|
p.strokeWeight(0.6);
|
|
105
135
|
for (let d = 0; d <= 7; d++) {
|
|
106
136
|
const y = origin.y - d * depthY + 2;
|
|
107
137
|
p.line(origin.x + d * depthX - 8, y, frontRight.x + d * depthX + 8, y);
|
|
108
138
|
}
|
|
109
|
-
p.stroke(
|
|
139
|
+
p.stroke(br, bg, bb, 60);
|
|
110
140
|
p.strokeWeight(1);
|
|
111
141
|
p.line(origin.x - 14, origin.y + 6, frontRight.x + 14, origin.y + 6);
|
|
112
142
|
}
|
|
@@ -117,26 +147,26 @@ export function createCitySketch(city, identity, rng) {
|
|
|
117
147
|
const hit = Math.max(0, 1 - Math.abs(x + w / 2 - scanX) / 70);
|
|
118
148
|
if (h === 0) {
|
|
119
149
|
p.noStroke();
|
|
120
|
-
p.fill(
|
|
150
|
+
p.fill(br, bg, bb, 10 + hit * 40);
|
|
121
151
|
p.quad(x, y, x + w, y, x + w + dx, y - dy, x + dx, y - dy);
|
|
122
152
|
return;
|
|
123
153
|
}
|
|
124
154
|
const lum = 0.55 + b.t * 0.45;
|
|
125
155
|
const [fr, fg, fb] = PALETTE.front;
|
|
126
156
|
const [sr, sg, sb] = PALETTE.side;
|
|
127
|
-
const [tr, tg, tb] =
|
|
157
|
+
const [tr, tg, tb] = deep;
|
|
128
158
|
p.noStroke();
|
|
129
159
|
p.fill(sr + hit * 10, sg + hit * 30, sb + hit * 38);
|
|
130
160
|
p.quad(x + w, y, x + w + dx, y - dy, x + w + dx, y - dy - h, x + w, y - h);
|
|
131
|
-
p.fill(fr * lum + hit *
|
|
161
|
+
p.fill(fr * lum + hit * scan[0], fg * lum + hit * scan[1], fb * lum + hit * scan[2]);
|
|
132
162
|
p.rect(x, y - h, w, h);
|
|
133
163
|
p.fill(tr + hit * 60, tg + hit * 80, tb + hit * 60, Math.min(255, 90 + b.t * 165 + hit * 80));
|
|
134
164
|
p.quad(x, y - h, x + w, y - h, x + w + dx, y - h - dy, x + dx, y - h - dy);
|
|
135
|
-
p.stroke(
|
|
165
|
+
p.stroke(pr, pg, pb, 40 + b.t * 120 + hit * 90);
|
|
136
166
|
p.strokeWeight(0.8);
|
|
137
167
|
p.line(x, y - h, x + w, y - h);
|
|
138
168
|
p.noStroke();
|
|
139
|
-
const [wr, wg, wb] =
|
|
169
|
+
const [wr, wg, wb] = pale;
|
|
140
170
|
for (const win of b.windows) {
|
|
141
171
|
const on = win.cycles ? wave(phase, win.cycles, win.off) > 0.5 : win.lit;
|
|
142
172
|
const a = on ? 150 + b.t * 80 : 18;
|
|
@@ -167,7 +197,7 @@ export function createCitySketch(city, identity, rng) {
|
|
|
167
197
|
const c1x = p0.x + (p3.x - p0.x) * 0.25;
|
|
168
198
|
const c2x = p0.x + (p3.x - p0.x) * 0.75;
|
|
169
199
|
p.noFill();
|
|
170
|
-
p.stroke(
|
|
200
|
+
p.stroke(br, bg, bb, 34);
|
|
171
201
|
p.strokeWeight(0.8);
|
|
172
202
|
p.bezier(p0.x, p0.y, c1x, top, c2x, top, p3.x, p3.y);
|
|
173
203
|
// A packet travels the arc once per loop, trailed by fading dots.
|
|
@@ -179,7 +209,7 @@ export function createCitySketch(city, identity, rng) {
|
|
|
179
209
|
const s = Math.max(0, eased - k * 0.012);
|
|
180
210
|
const px = p.bezierPoint(p0.x, c1x, c2x, p3.x, s);
|
|
181
211
|
const py = p.bezierPoint(p0.y, top, top, p3.y, s);
|
|
182
|
-
p.fill(
|
|
212
|
+
p.fill(pr, pg, pb, (k === 0 ? 255 : 110 - k * 14) * fade);
|
|
183
213
|
p.circle(px, py, k === 0 ? 4 : 3 - k * 0.3);
|
|
184
214
|
}
|
|
185
215
|
}
|
|
@@ -189,12 +219,12 @@ export function createCitySketch(city, identity, rng) {
|
|
|
189
219
|
const ctx = p.drawingContext;
|
|
190
220
|
ctx.globalAlpha = k;
|
|
191
221
|
const g = ctx.createLinearGradient(scanX - 60, 0, scanX + 60, 0);
|
|
192
|
-
g.addColorStop(0,
|
|
193
|
-
g.addColorStop(0.5,
|
|
194
|
-
g.addColorStop(1,
|
|
222
|
+
g.addColorStop(0, rgba(bright, 0));
|
|
223
|
+
g.addColorStop(0.5, rgba(bright, 0.07));
|
|
224
|
+
g.addColorStop(1, rgba(bright, 0));
|
|
195
225
|
ctx.fillStyle = g;
|
|
196
226
|
ctx.fillRect(scanX - 60, 150, 120, baseY - 140);
|
|
197
|
-
p.stroke(
|
|
227
|
+
p.stroke(pr, pg, pb, 70);
|
|
198
228
|
p.strokeWeight(1);
|
|
199
229
|
p.line(scanX, baseY + 8, scanX, baseY + 16);
|
|
200
230
|
ctx.globalAlpha = 1;
|
|
@@ -221,23 +251,23 @@ export function createCitySketch(city, identity, rng) {
|
|
|
221
251
|
p.textFont(MONO);
|
|
222
252
|
p.textStyle(p.NORMAL);
|
|
223
253
|
p.textSize(13);
|
|
224
|
-
p.fill(
|
|
254
|
+
p.fill(br, bg, bb);
|
|
225
255
|
p.text(PROMPT, x, 58);
|
|
226
256
|
if (wave(phase, 3, 0) > 0.5) {
|
|
227
257
|
p.rect(x + p.textWidth(PROMPT) + 4, 47, 7, 13);
|
|
228
258
|
}
|
|
229
259
|
p.textFont(SANS);
|
|
230
260
|
p.textStyle(p.BOLD);
|
|
231
|
-
p.textSize(
|
|
261
|
+
p.textSize(fitted.name.size);
|
|
232
262
|
ctx.letterSpacing = '3px';
|
|
233
263
|
p.fill(PALETTE.ink);
|
|
234
|
-
p.text(
|
|
264
|
+
p.text(fitted.name.text, x, 116);
|
|
235
265
|
ctx.letterSpacing = '0px';
|
|
236
|
-
if (
|
|
266
|
+
if (fitted.tagline) {
|
|
237
267
|
p.textStyle(p.NORMAL);
|
|
238
|
-
p.textSize(
|
|
239
|
-
p.fill(
|
|
240
|
-
p.text(
|
|
268
|
+
p.textSize(fitted.tagline.size);
|
|
269
|
+
p.fill(br, bg, bb);
|
|
270
|
+
p.text(fitted.tagline.text, x, 146);
|
|
241
271
|
}
|
|
242
272
|
p.textFont(MONO);
|
|
243
273
|
p.textStyle(p.NORMAL);
|
|
@@ -260,10 +290,10 @@ export function createCitySketch(city, identity, rng) {
|
|
|
260
290
|
p.fill(PALETTE.muted);
|
|
261
291
|
p.text('busiest days', x + 14, 296);
|
|
262
292
|
}
|
|
263
|
-
if (
|
|
264
|
-
p.textSize(
|
|
265
|
-
p.fill(
|
|
266
|
-
p.text(
|
|
293
|
+
if (fitted.website) {
|
|
294
|
+
p.textSize(fitted.website.size);
|
|
295
|
+
p.fill(br, bg, bb);
|
|
296
|
+
p.text(fitted.website.text, x, 350);
|
|
267
297
|
}
|
|
268
298
|
}
|
|
269
299
|
return {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { fetchHighlights, highlightsSchema } from '../../core/data/highlights.js';
|
|
2
|
+
import { options } from './options.js';
|
|
3
|
+
import { buildReel, FRAMES } from './reel.js';
|
|
4
|
+
import { paletteOf, pinnedOf } from './palette.js';
|
|
5
|
+
import { createReelSketch } from './sketch.js';
|
|
6
|
+
export const highlightsReel = {
|
|
7
|
+
id: 'highlights-reel',
|
|
8
|
+
fps: 25,
|
|
9
|
+
frames: FRAMES,
|
|
10
|
+
pinned: (options) => pinnedOf(paletteOf(options)),
|
|
11
|
+
data: { name: 'highlights', schema: highlightsSchema, fetch: fetchHighlights },
|
|
12
|
+
options,
|
|
13
|
+
createSketch({ data, options, identity, rng }) {
|
|
14
|
+
return createReelSketch(buildReel(data, rng, options), identity);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const THEMES = ['light', 'dark'];
|
|
3
|
+
export const ACCENTS = ['cobalt', 'green', 'violet', 'orange', 'pink'];
|
|
4
|
+
export const options = z
|
|
5
|
+
.object({
|
|
6
|
+
/** Desk, cards and type. `dark` suits profiles viewed in GitHub dark mode. */
|
|
7
|
+
theme: z.enum(THEMES).default('light'),
|
|
8
|
+
/** Bars, counting digits, prompt and links. Presets only, each contrast-checked. */
|
|
9
|
+
accent: z.enum(ACCENTS).default('cobalt'),
|
|
10
|
+
})
|
|
11
|
+
.strict();
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const AMBER = [232, 163, 61];
|
|
2
|
+
/**
|
|
3
|
+
* Accent presets, [light, dark]. Each passes 4.5:1 against its theme's desk,
|
|
4
|
+
* the lowest background its small type sits on (see SPEC, Highlights Reel).
|
|
5
|
+
*/
|
|
6
|
+
const ACCENT_VALUES = {
|
|
7
|
+
cobalt: [
|
|
8
|
+
[53, 88, 232],
|
|
9
|
+
[123, 147, 255],
|
|
10
|
+
],
|
|
11
|
+
green: [
|
|
12
|
+
[18, 122, 66],
|
|
13
|
+
[61, 203, 127],
|
|
14
|
+
],
|
|
15
|
+
violet: [
|
|
16
|
+
[109, 63, 214],
|
|
17
|
+
[169, 139, 255],
|
|
18
|
+
],
|
|
19
|
+
orange: [
|
|
20
|
+
[176, 71, 22],
|
|
21
|
+
[255, 143, 87],
|
|
22
|
+
],
|
|
23
|
+
pink: [
|
|
24
|
+
[196, 40, 94],
|
|
25
|
+
[255, 119, 168],
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
const LIGHT_INK = [11, 12, 20];
|
|
29
|
+
const DARK_INK = [238, 240, 246];
|
|
30
|
+
const BASE = {
|
|
31
|
+
light: {
|
|
32
|
+
desk: [236, 237, 241],
|
|
33
|
+
card: [255, 255, 255],
|
|
34
|
+
plate: [242, 243, 247],
|
|
35
|
+
well: [228, 230, 236],
|
|
36
|
+
ink: LIGHT_INK,
|
|
37
|
+
soft: [102, 106, 124],
|
|
38
|
+
amber: AMBER,
|
|
39
|
+
dots: { rgb: LIGHT_INK, alpha: 0.16 },
|
|
40
|
+
marks: { rgb: LIGHT_INK, alpha: 0.15 },
|
|
41
|
+
shadow: { rgb: LIGHT_INK, alpha: 0.07 },
|
|
42
|
+
border: { rgb: LIGHT_INK, alpha: 0.09 },
|
|
43
|
+
},
|
|
44
|
+
dark: {
|
|
45
|
+
desk: [13, 15, 23],
|
|
46
|
+
card: [23, 26, 37],
|
|
47
|
+
plate: [31, 35, 49],
|
|
48
|
+
well: [38, 43, 58],
|
|
49
|
+
ink: DARK_INK,
|
|
50
|
+
soft: [140, 146, 168],
|
|
51
|
+
amber: AMBER,
|
|
52
|
+
dots: { rgb: DARK_INK, alpha: 0.1 },
|
|
53
|
+
marks: { rgb: DARK_INK, alpha: 0.14 },
|
|
54
|
+
shadow: { rgb: [0, 0, 0], alpha: 0.35 },
|
|
55
|
+
border: { rgb: DARK_INK, alpha: 0.08 },
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
export function paletteOf(options) {
|
|
59
|
+
const base = BASE[options.theme];
|
|
60
|
+
const [light, dark] = ACCENT_VALUES[options.accent];
|
|
61
|
+
return options.theme === 'light'
|
|
62
|
+
? { ...base, accent: light, onAccent: [255, 255, 255] }
|
|
63
|
+
: { ...base, accent: dark, onAccent: base.desk };
|
|
64
|
+
}
|
|
65
|
+
/** Flat colors the encoder keeps exact and undithered. */
|
|
66
|
+
export function pinnedOf(palette) {
|
|
67
|
+
return [palette.desk, palette.card, palette.plate, palette.well, palette.accent, palette.ink];
|
|
68
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { paletteOf } from './palette.js';
|
|
2
|
+
/**
|
|
3
|
+
* Where the reel lives: four cards on one desk, filmed by one camera.
|
|
4
|
+
*
|
|
5
|
+
* The cards sit in a serpentine (row one left to right, row two right to left),
|
|
6
|
+
* just far enough apart that a neighbour is off frame (or under the identity
|
|
7
|
+
* band) at the holding zoom, so the pull-back at the end reads as one tight,
|
|
8
|
+
* laid-out sheet rather than a queue. Everything here is a pure function of the
|
|
9
|
+
* data; the sketch only draws.
|
|
10
|
+
*/
|
|
11
|
+
export const FRAMES = 425;
|
|
12
|
+
export const LAYOUT = {
|
|
13
|
+
width: 1280,
|
|
14
|
+
height: 400,
|
|
15
|
+
/** Holding zoom. */
|
|
16
|
+
z: 0.62,
|
|
17
|
+
card: { w: 1000, h: 500, r: 18 },
|
|
18
|
+
/** Station pitch: a neighbour clears the frame, or sits under the identity band. */
|
|
19
|
+
spacing: { x: 1240, y: 680 },
|
|
20
|
+
/** At the hold the card sits right of centre, clearing the identity band. */
|
|
21
|
+
offX: 374,
|
|
22
|
+
/** The one push-in: twice the holding zoom, landing right of centre on the busiest week. */
|
|
23
|
+
pushZ: 1.24,
|
|
24
|
+
pushOffX: 169,
|
|
25
|
+
pushUpY: 40,
|
|
26
|
+
/**
|
|
27
|
+
* The top-repo card holds a grid of pseudo repo cards, drawn at `scale`, the
|
|
28
|
+
* top repo first. The second push-in lands on it at `z / scale`, so the pseudo
|
|
29
|
+
* card fills the frame exactly as a whole card does at the hold.
|
|
30
|
+
*/
|
|
31
|
+
mini: { scale: 0.2, x: 520, y: 84, gap: 16, cols: 2 },
|
|
32
|
+
/** Moves longer than this world distance dip their zoom; shorter ones stay flat. */
|
|
33
|
+
travel: 1200,
|
|
34
|
+
/** Weekly strip, card-local. */
|
|
35
|
+
strip: { x: 70, baseline: 440, height: 165 },
|
|
36
|
+
/** Wide-shot frame band, screen space: right of the identity text. */
|
|
37
|
+
band: { left: 490, right: 1262, top: 14, bottom: 386 },
|
|
38
|
+
};
|
|
39
|
+
/** Segment weights, in frames at four cards; the plan normalizes them to `FRAMES`. */
|
|
40
|
+
const WEIGHTS = {
|
|
41
|
+
hold: 52,
|
|
42
|
+
travel: 20,
|
|
43
|
+
pushTravel: 20,
|
|
44
|
+
pushHold: 46,
|
|
45
|
+
focusTravel: 20,
|
|
46
|
+
focusHold: 46,
|
|
47
|
+
wideHold: 34,
|
|
48
|
+
return: 24,
|
|
49
|
+
};
|
|
50
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
51
|
+
/** Languages without a linguist color: the theme's soft grey. */
|
|
52
|
+
const NEUTRAL = { light: [102, 106, 124], dark: [140, 146, 168] };
|
|
53
|
+
/** Cards in play order. */
|
|
54
|
+
const CARD_ORDER = ['contributions', 'top_repo', 'pull_requests', 'languages'];
|
|
55
|
+
/** Longest run of consecutive days with at least one contribution. */
|
|
56
|
+
export function longestStreak(contributions) {
|
|
57
|
+
let best = 0;
|
|
58
|
+
let run = 0;
|
|
59
|
+
for (const week of contributions.weeks) {
|
|
60
|
+
for (const day of week) {
|
|
61
|
+
run = day.count > 0 ? run + 1 : 0;
|
|
62
|
+
if (run > best)
|
|
63
|
+
best = run;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return best;
|
|
67
|
+
}
|
|
68
|
+
/** "2026-03-28" .. "2026-04-03" -> "Mar 28 to Apr 3". */
|
|
69
|
+
function dateRange(from, to) {
|
|
70
|
+
const [, fm, fd] = from.split('-').map(Number);
|
|
71
|
+
const [, tm, td] = to.split('-').map(Number);
|
|
72
|
+
const start = `${MONTHS[fm - 1]} ${fd}`;
|
|
73
|
+
return fm === tm ? `${start} to ${td}` : `${start} to ${MONTHS[tm - 1]} ${td}`;
|
|
74
|
+
}
|
|
75
|
+
function busiestWeek(contributions) {
|
|
76
|
+
let index = -1;
|
|
77
|
+
let total = 0;
|
|
78
|
+
contributions.weeks.forEach((week, i) => {
|
|
79
|
+
const sum = week.reduce((s, d) => s + d.count, 0);
|
|
80
|
+
if (sum > total) {
|
|
81
|
+
total = sum;
|
|
82
|
+
index = i;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
if (index < 0)
|
|
86
|
+
return null;
|
|
87
|
+
const week = contributions.weeks[index];
|
|
88
|
+
return {
|
|
89
|
+
index,
|
|
90
|
+
total,
|
|
91
|
+
days: week.map((d) => d.count),
|
|
92
|
+
label: dateRange(week[0].date, week[week.length - 1].date),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Linguist colors include pale and dark ones: darken for the white card, lighten
|
|
97
|
+
* for the dark one, so every dot reads on its card.
|
|
98
|
+
*/
|
|
99
|
+
export function languageColor(hex, theme = 'light') {
|
|
100
|
+
if (!hex)
|
|
101
|
+
return NEUTRAL[theme];
|
|
102
|
+
const n = Number.parseInt(hex.slice(1), 16);
|
|
103
|
+
const [h, s, l] = rgbToHsl([(n >> 16) & 255, (n >> 8) & 255, n & 255]);
|
|
104
|
+
const lightness = theme === 'light' ? Math.min(l, 0.55) : Math.max(l, 0.52);
|
|
105
|
+
return hslToRgb(h, Math.min(s, 0.85), lightness);
|
|
106
|
+
}
|
|
107
|
+
/** Serpentine desk: row one left to right, row two right to left. */
|
|
108
|
+
export function stationOf(index) {
|
|
109
|
+
const row = Math.floor(index / 2);
|
|
110
|
+
const col = row % 2 === 0 ? index % 2 : 1 - (index % 2);
|
|
111
|
+
return { x: col * LAYOUT.spacing.x, y: row * LAYOUT.spacing.y };
|
|
112
|
+
}
|
|
113
|
+
function holdPose(index) {
|
|
114
|
+
const s = stationOf(index);
|
|
115
|
+
return { x: s.x - LAYOUT.offX, y: s.y, z: LAYOUT.z };
|
|
116
|
+
}
|
|
117
|
+
/** Pulls back far enough to hold every card at once, inside the identity band. */
|
|
118
|
+
function widePose(count) {
|
|
119
|
+
const { w, h } = LAYOUT.card;
|
|
120
|
+
let left = Infinity;
|
|
121
|
+
let right = -Infinity;
|
|
122
|
+
let top = Infinity;
|
|
123
|
+
let bottom = -Infinity;
|
|
124
|
+
for (let i = 0; i < count; i++) {
|
|
125
|
+
const s = stationOf(i);
|
|
126
|
+
left = Math.min(left, s.x - w / 2);
|
|
127
|
+
right = Math.max(right, s.x + w / 2);
|
|
128
|
+
top = Math.min(top, s.y - h / 2);
|
|
129
|
+
bottom = Math.max(bottom, s.y + h / 2);
|
|
130
|
+
}
|
|
131
|
+
const pad = 40;
|
|
132
|
+
left -= pad;
|
|
133
|
+
right += pad;
|
|
134
|
+
top -= pad;
|
|
135
|
+
bottom += pad;
|
|
136
|
+
const z = Math.min((LAYOUT.band.right - LAYOUT.band.left) / (right - left), (LAYOUT.band.bottom - LAYOUT.band.top) / (bottom - top));
|
|
137
|
+
const bandCx = (LAYOUT.band.left + LAYOUT.band.right) / 2;
|
|
138
|
+
return {
|
|
139
|
+
x: (left + right) / 2 - (bandCx - LAYOUT.width / 2) / z,
|
|
140
|
+
y: (top + bottom) / 2,
|
|
141
|
+
z,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/** Card-local top-left corner of pseudo card `k` on the top-repo card. */
|
|
145
|
+
export function plateAt(k) {
|
|
146
|
+
const { scale, x, y, gap, cols } = LAYOUT.mini;
|
|
147
|
+
const w = LAYOUT.card.w * scale;
|
|
148
|
+
const h = LAYOUT.card.h * scale;
|
|
149
|
+
return { x: x + (k % cols) * (w + gap), y: y + Math.floor(k / cols) * (h + gap) };
|
|
150
|
+
}
|
|
151
|
+
/** Where the second push-in lands: the top repo's pseudo card, framed like a hold. */
|
|
152
|
+
function focusPoseOf(index) {
|
|
153
|
+
const s = stationOf(index);
|
|
154
|
+
const { scale } = LAYOUT.mini;
|
|
155
|
+
const plate = plateAt(0);
|
|
156
|
+
const z = LAYOUT.z / scale;
|
|
157
|
+
return {
|
|
158
|
+
x: s.x -
|
|
159
|
+
LAYOUT.card.w / 2 +
|
|
160
|
+
plate.x +
|
|
161
|
+
(LAYOUT.card.w * scale) / 2 -
|
|
162
|
+
(LAYOUT.offX * LAYOUT.z) / z,
|
|
163
|
+
y: s.y - LAYOUT.card.h / 2 + plate.y + (LAYOUT.card.h * scale) / 2,
|
|
164
|
+
z,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Where the push-in lands: the busiest week's opened column, framed right of
|
|
169
|
+
* centre. Null when there is no busiest week (an empty year), so no push.
|
|
170
|
+
*/
|
|
171
|
+
function pushPoseOf(card, weekCount) {
|
|
172
|
+
if (card.busiest === null)
|
|
173
|
+
return null;
|
|
174
|
+
const pitch = (LAYOUT.card.w - 2 * LAYOUT.strip.x) / weekCount;
|
|
175
|
+
const s = stationOf(0);
|
|
176
|
+
const localX = LAYOUT.strip.x + (card.busiest + 0.5) * pitch;
|
|
177
|
+
const localY = LAYOUT.strip.baseline - LAYOUT.strip.height / 2;
|
|
178
|
+
return {
|
|
179
|
+
x: s.x - LAYOUT.card.w / 2 + localX - LAYOUT.pushOffX,
|
|
180
|
+
y: s.y - LAYOUT.card.h / 2 + localY - LAYOUT.pushUpY,
|
|
181
|
+
z: LAYOUT.pushZ,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
export function buildPlan(cards, push) {
|
|
185
|
+
const hold = (i) => holdPose(i);
|
|
186
|
+
if (cards.length === 1 && !push) {
|
|
187
|
+
// The empty state: one card, drift only.
|
|
188
|
+
return {
|
|
189
|
+
keys: [
|
|
190
|
+
{ frame: 0, ...hold(0) },
|
|
191
|
+
{ frame: FRAMES, ...hold(0) },
|
|
192
|
+
],
|
|
193
|
+
builds: [{ start: 0, end: 0 }],
|
|
194
|
+
focus: { start: 0, end: 0 },
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const segs = [{ weight: WEIGHTS.hold, pose: hold(0) }];
|
|
198
|
+
if (push) {
|
|
199
|
+
segs.push({ weight: WEIGHTS.pushTravel, pose: push });
|
|
200
|
+
segs.push({ weight: WEIGHTS.pushHold, pose: push });
|
|
201
|
+
}
|
|
202
|
+
for (let i = 1; i < cards.length; i++) {
|
|
203
|
+
segs.push({ weight: WEIGHTS.travel, pose: hold(i), build: i });
|
|
204
|
+
segs.push({ weight: WEIGHTS.hold, pose: hold(i) });
|
|
205
|
+
if (cards[i].kind === 'top_repo') {
|
|
206
|
+
// Total stars first, then into the top repo's pseudo card.
|
|
207
|
+
const focus = focusPoseOf(i);
|
|
208
|
+
segs.push({ weight: WEIGHTS.focusTravel, pose: focus, focus: true });
|
|
209
|
+
segs.push({ weight: WEIGHTS.focusHold, pose: focus });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (cards.length >= 2) {
|
|
213
|
+
const wide = widePose(cards.length);
|
|
214
|
+
segs.push({ weight: WEIGHTS.travel, pose: wide });
|
|
215
|
+
segs.push({ weight: WEIGHTS.wideHold, pose: wide });
|
|
216
|
+
}
|
|
217
|
+
// Back into card 0: its pose is the loop's first key, so the seam closes.
|
|
218
|
+
segs.push({ weight: WEIGHTS.return, pose: hold(0) });
|
|
219
|
+
const total = segs.reduce((s, g) => s + g.weight, 0);
|
|
220
|
+
const scale = FRAMES / total;
|
|
221
|
+
const keys = [{ frame: 0, ...hold(0) }];
|
|
222
|
+
const builds = cards.map(() => ({ start: 0, end: 0 }));
|
|
223
|
+
let focus = { start: 0, end: 0 };
|
|
224
|
+
let frame = 0;
|
|
225
|
+
for (const seg of segs) {
|
|
226
|
+
const start = frame;
|
|
227
|
+
frame += seg.weight * scale;
|
|
228
|
+
const end = Math.min(FRAMES, Math.round(frame));
|
|
229
|
+
keys.push({ frame: end, ...seg.pose });
|
|
230
|
+
if (seg.build !== undefined) {
|
|
231
|
+
// A card builds while the camera arrives, and settles shortly after.
|
|
232
|
+
builds[seg.build] = {
|
|
233
|
+
start,
|
|
234
|
+
end: Math.min(FRAMES, end + Math.round(WEIGHTS.hold * scale * 0.4)),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (seg.focus) {
|
|
238
|
+
// The top repo's details build as the camera lands in its pseudo card.
|
|
239
|
+
focus = {
|
|
240
|
+
start: Math.round(start + (end - start) * 0.5),
|
|
241
|
+
end: Math.min(FRAMES, end + Math.round(WEIGHTS.focusHold * scale * 0.4)),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
keys[keys.length - 1].frame = FRAMES;
|
|
246
|
+
return { keys, builds, focus };
|
|
247
|
+
}
|
|
248
|
+
export function buildReel(data, _rng, options) {
|
|
249
|
+
const contributions = data.contributions;
|
|
250
|
+
const busiest = busiestWeek(contributions);
|
|
251
|
+
const streak = longestStreak(contributions);
|
|
252
|
+
const cards = [];
|
|
253
|
+
let push = null;
|
|
254
|
+
const theme = options.theme;
|
|
255
|
+
for (const id of CARD_ORDER) {
|
|
256
|
+
switch (id) {
|
|
257
|
+
case 'contributions': {
|
|
258
|
+
// The one card that is always filmed, even at zero.
|
|
259
|
+
const card = {
|
|
260
|
+
kind: 'contributions',
|
|
261
|
+
total: contributions.total,
|
|
262
|
+
streak: streak > 0 ? streak : null,
|
|
263
|
+
weeks: contributions.weeks.map((w) => w.reduce((s, d) => s + d.count, 0)),
|
|
264
|
+
busiest: busiest?.index ?? null,
|
|
265
|
+
busiestDays: busiest?.days ?? [],
|
|
266
|
+
busiestLabel: busiest?.label ?? '',
|
|
267
|
+
};
|
|
268
|
+
push = pushPoseOf(card, card.weeks.length);
|
|
269
|
+
cards.push(card);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
case 'top_repo':
|
|
273
|
+
if (data.topRepo) {
|
|
274
|
+
cards.push({
|
|
275
|
+
kind: 'top_repo',
|
|
276
|
+
totalStars: data.totalStars,
|
|
277
|
+
plates: data.starredRepos.map((r) => ({
|
|
278
|
+
name: r.name,
|
|
279
|
+
stars: r.stars,
|
|
280
|
+
color: languageColor(r.color, theme),
|
|
281
|
+
})),
|
|
282
|
+
name: data.topRepo.name,
|
|
283
|
+
stars: data.topRepo.stars,
|
|
284
|
+
forks: data.topRepo.forks,
|
|
285
|
+
language: data.topRepo.language
|
|
286
|
+
? {
|
|
287
|
+
name: data.topRepo.language.name,
|
|
288
|
+
color: languageColor(data.topRepo.language.color, theme),
|
|
289
|
+
}
|
|
290
|
+
: null,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
break;
|
|
294
|
+
case 'pull_requests':
|
|
295
|
+
if (data.mergedPullRequests > 0) {
|
|
296
|
+
cards.push({ kind: 'pull_requests', merged: data.mergedPullRequests });
|
|
297
|
+
}
|
|
298
|
+
break;
|
|
299
|
+
case 'languages':
|
|
300
|
+
if (data.languages.length > 0) {
|
|
301
|
+
cards.push({
|
|
302
|
+
kind: 'languages',
|
|
303
|
+
languages: data.languages.map((l) => ({
|
|
304
|
+
name: l.name,
|
|
305
|
+
color: languageColor(l.color, theme),
|
|
306
|
+
count: l.count,
|
|
307
|
+
})),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return { cards, plan: buildPlan(cards, push), palette: paletteOf(options) };
|
|
314
|
+
}
|
|
315
|
+
function rgbToHsl([r, g, b]) {
|
|
316
|
+
const [rn, gn, bn] = [r / 255, g / 255, b / 255];
|
|
317
|
+
const max = Math.max(rn, gn, bn);
|
|
318
|
+
const min = Math.min(rn, gn, bn);
|
|
319
|
+
const l = (max + min) / 2;
|
|
320
|
+
if (max === min)
|
|
321
|
+
return [0, 0, l];
|
|
322
|
+
const d = max - min;
|
|
323
|
+
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
324
|
+
const h = max === rn
|
|
325
|
+
? (gn - bn) / d + (gn < bn ? 6 : 0)
|
|
326
|
+
: max === gn
|
|
327
|
+
? (bn - rn) / d + 2
|
|
328
|
+
: (rn - gn) / d + 4;
|
|
329
|
+
return [h / 6, s, l];
|
|
330
|
+
}
|
|
331
|
+
function hslToRgb(h, s, l) {
|
|
332
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
333
|
+
const p = 2 * l - q;
|
|
334
|
+
const channel = (t) => {
|
|
335
|
+
const k = (t + 1) % 1;
|
|
336
|
+
const v = k < 1 / 6
|
|
337
|
+
? p + (q - p) * 6 * k
|
|
338
|
+
: k < 1 / 2
|
|
339
|
+
? q
|
|
340
|
+
: k < 2 / 3
|
|
341
|
+
? p + (q - p) * (2 / 3 - k) * 6
|
|
342
|
+
: p;
|
|
343
|
+
return Math.round(v * 255);
|
|
344
|
+
};
|
|
345
|
+
return [channel(h + 1 / 3), channel(h), channel(h - 1 / 3)];
|
|
346
|
+
}
|