headreel 1.2.0 → 1.4.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 +52 -19
- package/assets/fonts/Caveat.ttf +0 -0
- package/assets/fonts/OFL-Caveat.txt +93 -0
- package/dist/cli/run.js +2 -2
- package/dist/core/data/contributions.js +12 -9
- package/dist/core/data/highlights.js +141 -0
- package/dist/core/data/now-playing.js +282 -0
- package/dist/core/encode/gif.js +50 -7
- package/dist/core/fonts.js +3 -0
- 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 +4 -0
- package/dist/styles/now-playing/deck.js +105 -0
- package/dist/styles/now-playing/index.js +16 -0
- package/dist/styles/now-playing/options.js +19 -0
- package/dist/styles/now-playing/palette.js +69 -0
- package/dist/styles/now-playing/sketch.js +610 -0
- package/dist/styles/repo-galaxy/sketch.js +16 -8
- package/package.json +1 -1
package/dist/core/encode/gif.js
CHANGED
|
@@ -4,7 +4,7 @@ import * as gifencModule from 'gifenc';
|
|
|
4
4
|
const gifenc = typeof gifencModule.quantize === 'function'
|
|
5
5
|
? gifencModule
|
|
6
6
|
: gifencModule.default;
|
|
7
|
-
const { GIFEncoder, quantize
|
|
7
|
+
const { GIFEncoder, quantize } = gifenc;
|
|
8
8
|
/** Number of frames sampled to build the shared palette. */
|
|
9
9
|
const PALETTE_SAMPLES = 6;
|
|
10
10
|
/** 255 real colors; the last global palette slot is the transparent index. */
|
|
@@ -33,12 +33,13 @@ const BAYER = (() => {
|
|
|
33
33
|
* that does not change between frames dithers identically, which keeps the
|
|
34
34
|
* frame deltas small and avoids shimmer.
|
|
35
35
|
*/
|
|
36
|
-
function dither(rgba, width) {
|
|
36
|
+
function dither(rgba, width, pinned) {
|
|
37
37
|
const out = new Uint8ClampedArray(rgba.length);
|
|
38
38
|
for (let i = 0, p = 0; i < rgba.length; i += 4, p++) {
|
|
39
39
|
const x = p % width;
|
|
40
40
|
const y = (p - x) / width;
|
|
41
|
-
const
|
|
41
|
+
const exact = pinned.size > 0 && pinned.has((rgba[i] << 16) | (rgba[i + 1] << 8) | rgba[i + 2]);
|
|
42
|
+
const offset = exact ? 0 : BAYER[(y & 7) * 8 + (x & 7)] * DITHER_SPREAD;
|
|
42
43
|
out[i] = rgba[i] + offset;
|
|
43
44
|
out[i + 1] = rgba[i + 1] + offset;
|
|
44
45
|
out[i + 2] = rgba[i + 2] + offset;
|
|
@@ -46,12 +47,48 @@ function dither(rgba, width) {
|
|
|
46
47
|
}
|
|
47
48
|
return out;
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Maps pixels to their nearest palette index, memoized per exact color.
|
|
52
|
+
* gifenc's `applyPalette` memoizes per rgb565 bucket instead, so the first pixel
|
|
53
|
+
* of a bucket in scan order picks the index for the whole bucket. Near-white
|
|
54
|
+
* pixels then land on white in one frame and on a pale grey in the next, and a
|
|
55
|
+
* flat card flickers. An exact memo is a pure function of the color.
|
|
56
|
+
*/
|
|
57
|
+
function createMapper(palette) {
|
|
58
|
+
const memo = new Int16Array(1 << 24).fill(-1);
|
|
59
|
+
const nearest = (r, g, b) => {
|
|
60
|
+
let best = 0;
|
|
61
|
+
let bestDist = Infinity;
|
|
62
|
+
for (let k = 0; k < palette.length; k++) {
|
|
63
|
+
const c = palette[k];
|
|
64
|
+
const d = (c[0] - r) ** 2 + (c[1] - g) ** 2 + (c[2] - b) ** 2;
|
|
65
|
+
if (d < bestDist) {
|
|
66
|
+
bestDist = d;
|
|
67
|
+
best = k;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return best;
|
|
71
|
+
};
|
|
72
|
+
return (rgba) => {
|
|
73
|
+
const out = new Uint8Array(rgba.length / 4);
|
|
74
|
+
for (let i = 0, p = 0; i < rgba.length; i += 4, p++) {
|
|
75
|
+
const key = (rgba[i] << 16) | (rgba[i + 1] << 8) | rgba[i + 2];
|
|
76
|
+
let index = memo[key];
|
|
77
|
+
if (index < 0) {
|
|
78
|
+
index = nearest(rgba[i], rgba[i + 1], rgba[i + 2]);
|
|
79
|
+
memo[key] = index;
|
|
80
|
+
}
|
|
81
|
+
out[p] = index;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function buildPalette(frames, frameBytes, colors) {
|
|
50
87
|
const step = Math.max(1, Math.floor(frames.length / PALETTE_SAMPLES));
|
|
51
88
|
const picks = frames.filter((_, i) => i % step === 0).slice(0, PALETTE_SAMPLES);
|
|
52
89
|
const sample = new Uint8ClampedArray(frameBytes * picks.length);
|
|
53
90
|
picks.forEach((data, i) => sample.set(data, i * frameBytes));
|
|
54
|
-
return quantize(sample,
|
|
91
|
+
return quantize(sample, colors);
|
|
55
92
|
}
|
|
56
93
|
/**
|
|
57
94
|
* Encodes RGBA frames into a looping GIF: one global palette, ordered
|
|
@@ -59,16 +96,22 @@ function buildPalette(frames, frameBytes) {
|
|
|
59
96
|
*/
|
|
60
97
|
export function encodeGif(frames, spec) {
|
|
61
98
|
const { width, height } = spec;
|
|
62
|
-
const
|
|
99
|
+
const pinnedColors = spec.pinned ?? [];
|
|
100
|
+
const palette = [
|
|
101
|
+
...buildPalette(frames, width * height * 4, COLORS - pinnedColors.length),
|
|
102
|
+
...pinnedColors.map((c) => [...c]),
|
|
103
|
+
];
|
|
104
|
+
const pinned = new Set(pinnedColors.map(([r, g, b]) => (r << 16) | (g << 8) | b));
|
|
63
105
|
const globalPalette = [...palette];
|
|
64
106
|
while (globalPalette.length < TRANSPARENT)
|
|
65
107
|
globalPalette.push([0, 0, 0]);
|
|
66
108
|
globalPalette.push([0, 0, 0]);
|
|
109
|
+
const toIndex = createMapper(palette);
|
|
67
110
|
const gif = GIFEncoder();
|
|
68
111
|
const delay = Math.round(1000 / spec.fps);
|
|
69
112
|
let previous;
|
|
70
113
|
for (const [i, data] of frames.entries()) {
|
|
71
|
-
const index =
|
|
114
|
+
const index = toIndex(dither(data, width, pinned));
|
|
72
115
|
let pixels = index;
|
|
73
116
|
if (previous) {
|
|
74
117
|
pixels = new Uint8Array(index);
|
package/dist/core/fonts.js
CHANGED
|
@@ -5,6 +5,9 @@ const FONT_FILES = [
|
|
|
5
5
|
'SpaceGrotesk-Medium.ttf',
|
|
6
6
|
'SpaceGrotesk-Bold.ttf',
|
|
7
7
|
'JetBrainsMono-Regular.ttf',
|
|
8
|
+
// Handwriting face for Now Playing's cassette label. Variable font: Skia
|
|
9
|
+
// registers the default 400 instance and ignores other weights.
|
|
10
|
+
'Caveat.ttf',
|
|
8
11
|
];
|
|
9
12
|
// Resolves from both src/core (tsx, vitest) and dist/core (published build).
|
|
10
13
|
const FONT_DIR = new URL('../../assets/fonts/', import.meta.url);
|
package/dist/core/pipeline.js
CHANGED
|
@@ -24,5 +24,9 @@ export async function renderBanner(style, input) {
|
|
|
24
24
|
rng,
|
|
25
25
|
});
|
|
26
26
|
const frames = await renderFrames(sketch, { ...CANVAS, frames: style.frames });
|
|
27
|
-
return encodeGif(frames, {
|
|
27
|
+
return encodeGif(frames, {
|
|
28
|
+
...CANVAS,
|
|
29
|
+
fps: style.fps,
|
|
30
|
+
...(style.pinned ? { pinned: style.pinned(options) } : {}),
|
|
31
|
+
});
|
|
28
32
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createCanvas } from '@napi-rs/canvas';
|
|
2
|
+
import { registerFonts } from './fonts.js';
|
|
3
|
+
const ELLIPSIS = '…';
|
|
4
|
+
/**
|
|
5
|
+
* Fits one line of text into `maxWidth`. It first shrinks from `size` toward
|
|
6
|
+
* `minSize` (pass `minSize === size` to keep the size), then cuts the text
|
|
7
|
+
* with an ellipsis. The result is never wider than `maxWidth`, so no line can
|
|
8
|
+
* leave the canvas or run into the scene.
|
|
9
|
+
*/
|
|
10
|
+
export function fitLine(text, maxWidth, size, minSize, measure) {
|
|
11
|
+
if (measure(text, size) <= maxWidth)
|
|
12
|
+
return { text, size };
|
|
13
|
+
// Width scales about linearly with size; step down until it fits.
|
|
14
|
+
let fitted = Math.max(minSize, Math.floor((size * maxWidth) / measure(text, size)));
|
|
15
|
+
while (fitted > minSize && measure(text, fitted) > maxWidth)
|
|
16
|
+
fitted--;
|
|
17
|
+
if (measure(text, fitted) <= maxWidth)
|
|
18
|
+
return { text, size: fitted };
|
|
19
|
+
// Longest prefix that fits with the ellipsis.
|
|
20
|
+
const chars = [...text];
|
|
21
|
+
let lo = 0;
|
|
22
|
+
let hi = chars.length;
|
|
23
|
+
while (lo < hi) {
|
|
24
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
25
|
+
const candidate = `${chars.slice(0, mid).join('').trimEnd()}${ELLIPSIS}`;
|
|
26
|
+
if (measure(candidate, minSize) <= maxWidth)
|
|
27
|
+
lo = mid;
|
|
28
|
+
else
|
|
29
|
+
hi = mid - 1;
|
|
30
|
+
}
|
|
31
|
+
const cut = chars.slice(0, lo).join('').trimEnd();
|
|
32
|
+
return { text: cut ? `${cut}${ELLIPSIS}` : '', size: minSize };
|
|
33
|
+
}
|
|
34
|
+
let scratch;
|
|
35
|
+
/**
|
|
36
|
+
* Measures with the bundled fonts on a private canvas, so fitting never
|
|
37
|
+
* touches a sketch's own drawing state (p5 caches font and fill state).
|
|
38
|
+
* `tracking` is letter spacing in px, as the sketch draws it.
|
|
39
|
+
*/
|
|
40
|
+
export function canvasMeasure(family, weight, tracking = 0) {
|
|
41
|
+
registerFonts();
|
|
42
|
+
scratch ??= createCanvas(1, 1).getContext('2d');
|
|
43
|
+
const ctx = scratch;
|
|
44
|
+
return (text, size) => {
|
|
45
|
+
ctx.font = `${weight} ${size}px "${family}"`;
|
|
46
|
+
ctx.letterSpacing = `${tracking}px`;
|
|
47
|
+
return ctx.measureText(text).width;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** The identity column's lines, shared by every style (same fonts and sizes). */
|
|
51
|
+
export const IDENTITY_TYPE = {
|
|
52
|
+
name: { family: 'Space Grotesk', weight: 700, size: 50, minSize: 30, tracking: 3 },
|
|
53
|
+
tagline: { family: 'Space Grotesk', weight: 400, size: 17 },
|
|
54
|
+
website: { family: 'JetBrains Mono', weight: 400, size: 12 },
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Fits the identity column: `name` shrinks then cuts, `tagline` and `website`
|
|
58
|
+
* cut. `column` bounds name and tagline; `websiteColumn` bounds the website,
|
|
59
|
+
* which some styles draw beside the scene rather than above it.
|
|
60
|
+
*/
|
|
61
|
+
export function fitIdentity(lines, column, websiteColumn = column) {
|
|
62
|
+
const { name, tagline, website } = IDENTITY_TYPE;
|
|
63
|
+
return {
|
|
64
|
+
name: fitLine(lines.name, column, name.size, name.minSize, canvasMeasure(name.family, name.weight, name.tracking)),
|
|
65
|
+
tagline: lines.tagline
|
|
66
|
+
? fitLine(lines.tagline, column, tagline.size, tagline.size, canvasMeasure(tagline.family, tagline.weight))
|
|
67
|
+
: null,
|
|
68
|
+
website: lines.website
|
|
69
|
+
? fitLine(lines.website, websiteColumn, website.size, website.size, canvasMeasure(website.family, website.weight))
|
|
70
|
+
: null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -31,7 +31,9 @@ export function tileAt(weeks, w, d) {
|
|
|
31
31
|
const depth = 6 - d;
|
|
32
32
|
return { x: o.x + w * LAYOUT.cell + depth * LAYOUT.depthX, y: o.y - depth * LAYOUT.depthY };
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
/** Busiest days marked with a beacon. */
|
|
35
|
+
export const BEACONS = 8;
|
|
36
|
+
export function buildCity(data, rng) {
|
|
35
37
|
const between = (min, max) => min + rng() * (max - min);
|
|
36
38
|
const weeks = data.weeks.length;
|
|
37
39
|
const maxCount = Math.max(0, ...data.weeks.flat().map((d) => d.count));
|
|
@@ -73,7 +75,7 @@ export function buildCity(data, rng, beacons) {
|
|
|
73
75
|
const ranked = buildings
|
|
74
76
|
.filter((b) => b.count > 0)
|
|
75
77
|
.sort((a, b) => b.count - a.count || a.w - b.w || a.d - b.d);
|
|
76
|
-
for (const b of ranked.slice(0,
|
|
78
|
+
for (const b of ranked.slice(0, BEACONS))
|
|
77
79
|
b.beacon = true;
|
|
78
80
|
// Back rows first, then left to right, so front towers occlude correctly.
|
|
79
81
|
buildings.sort((a, b) => a.d - b.d || a.w - b.w);
|
|
@@ -9,6 +9,6 @@ export const contributionCity = {
|
|
|
9
9
|
data: { name: 'contributions', schema: contributionsSchema, fetch: fetchContributions },
|
|
10
10
|
options,
|
|
11
11
|
createSketch({ data, options, identity, rng }) {
|
|
12
|
-
return createCitySketch(buildCity(data, rng
|
|
12
|
+
return createCitySketch(buildCity(data, rng), identity, rng, options.accent);
|
|
13
13
|
},
|
|
14
14
|
};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
export const ACCENTS = ['cyan', 'cobalt', 'green', 'violet', 'pink'];
|
|
2
3
|
export const options = z
|
|
3
4
|
.object({
|
|
4
|
-
/**
|
|
5
|
-
|
|
5
|
+
/** Color of the roofs, windows, glow, lines, scan beam and links. */
|
|
6
|
+
accent: z.enum(ACCENTS).default('cyan'),
|
|
6
7
|
})
|
|
7
8
|
.strict();
|
|
@@ -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
|
+
}
|