headreel 1.1.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,149 @@
1
+ import { compareRepos } from '../../core/data/repos.js';
2
+ export const LAYOUT = {
3
+ width: 1280,
4
+ height: 400,
5
+ /** Sun, center of every orbit. */
6
+ cx: 872,
7
+ cy: 206,
8
+ /** Tilt of the orbital plane on screen, radians. */
9
+ tilt: -0.1,
10
+ /** Orbit ellipse height / width: how far the plane leans away from the viewer. */
11
+ aspect: 0.3,
12
+ /** Planet radius range, px, before depth scaling. */
13
+ minR: 3,
14
+ maxR: 13,
15
+ stars: 220,
16
+ dust: 2400,
17
+ grain: 2600,
18
+ };
19
+ /**
20
+ * Orbits by time since the last push: recent work circles close to the sun.
21
+ * `revs` are whole revolutions per loop, so every orbit closes seamlessly.
22
+ */
23
+ export const RINGS = [
24
+ { label: '30 DAYS', maxDays: 30, rx: 132, revs: 3 },
25
+ { label: '6 MONTHS', maxDays: 183, rx: 206, revs: 2 },
26
+ { label: '2 YEARS', maxDays: 730, rx: 282, revs: 1 },
27
+ { label: 'OLDER', maxDays: Infinity, rx: 360, revs: 1 },
28
+ ];
29
+ /** Planets without a language, and languages without a linguist color. */
30
+ const NEUTRAL = [154, 163, 181];
31
+ const DAY_MS = 86_400_000;
32
+ /** Linguist colors include near-black ones; lift them so every planet reads on the night sky. */
33
+ export function planetColor(hex) {
34
+ if (!hex)
35
+ return NEUTRAL;
36
+ const n = Number.parseInt(hex.slice(1), 16);
37
+ const rgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
38
+ const [h, s, l] = rgbToHsl(rgb);
39
+ return hslToRgb(h, Math.min(s, 0.85), Math.max(l, 0.52));
40
+ }
41
+ export function ringFor(pushedAt, asOf) {
42
+ const days = (Date.parse(`${asOf}T00:00:00Z`) - Date.parse(`${pushedAt}T00:00:00Z`)) / DAY_MS;
43
+ return RINGS.findIndex((r) => days <= r.maxDays);
44
+ }
45
+ export function selectRepos(data, options) {
46
+ return data.repos
47
+ .filter((r) => options.include_forks || !r.fork)
48
+ .sort(compareRepos)
49
+ .slice(0, options.max_repos);
50
+ }
51
+ export function buildGalaxy(data, rng, options) {
52
+ const repos = selectRepos(data, options);
53
+ const maxStars = Math.max(0, ...repos.map((r) => r.stars));
54
+ const scale = Math.log1p(maxStars);
55
+ const planets = repos.map((repo, rank) => ({
56
+ name: repo.name,
57
+ stars: repo.stars,
58
+ rank,
59
+ ring: ringFor(repo.pushedAt, data.asOf),
60
+ r: scale > 0
61
+ ? LAYOUT.minR + (LAYOUT.maxR - LAYOUT.minR) * (Math.log1p(repo.stars) / scale)
62
+ : LAYOUT.minR + 0.5,
63
+ color: planetColor(repo.language?.color),
64
+ angle: 0,
65
+ // A repo without stars is not among the most starred, whatever its rank.
66
+ label: options.labels === 'top3' && rank < 3 && repo.stars > 0,
67
+ }));
68
+ // Spread each ring's planets evenly from a seeded start, with a little jitter.
69
+ RINGS.forEach((_, ring) => {
70
+ const members = planets.filter((p) => p.ring === ring);
71
+ const start = rng() * Math.PI * 2;
72
+ const step = (Math.PI * 2) / Math.max(1, members.length);
73
+ members.forEach((p, i) => {
74
+ p.angle = start + i * step + (rng() - 0.5) * step * 0.3;
75
+ });
76
+ });
77
+ const languages = new Map();
78
+ for (const repo of repos) {
79
+ if (!repo.language)
80
+ continue;
81
+ const entry = languages.get(repo.language.name) ?? {
82
+ name: repo.language.name,
83
+ color: planetColor(repo.language.color),
84
+ count: 0,
85
+ };
86
+ entry.count++;
87
+ languages.set(entry.name, entry);
88
+ }
89
+ const stars = Array.from({ length: LAYOUT.stars }, () => ({
90
+ x: rng() * LAYOUT.width,
91
+ y: rng() * LAYOUT.height,
92
+ s: rng() < 0.07 ? 2 : 1,
93
+ a: 30 + rng() * 140,
94
+ cycles: 1 + Math.floor(rng() * 3),
95
+ off: rng(),
96
+ }));
97
+ // A faint band of dust crossing behind the orbits, bottom left to top right.
98
+ const dust = Array.from({ length: LAYOUT.dust }, () => {
99
+ const t = rng();
100
+ const spread = (rng() + rng() + rng() - 1.5) * 70;
101
+ return {
102
+ x: 380 + t * 960 + spread * 0.4,
103
+ y: 430 - t * 470 + spread,
104
+ a: 6 + rng() * 26,
105
+ warm: rng() < 0.35,
106
+ };
107
+ });
108
+ return {
109
+ planets,
110
+ totalStars: repos.reduce((sum, r) => sum + r.stars, 0),
111
+ languages: [...languages.values()]
112
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
113
+ .slice(0, 3),
114
+ stars,
115
+ dust,
116
+ };
117
+ }
118
+ function rgbToHsl([r, g, b]) {
119
+ const [rn, gn, bn] = [r / 255, g / 255, b / 255];
120
+ const max = Math.max(rn, gn, bn);
121
+ const min = Math.min(rn, gn, bn);
122
+ const l = (max + min) / 2;
123
+ if (max === min)
124
+ return [0, 0, l];
125
+ const d = max - min;
126
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
127
+ const h = max === rn
128
+ ? (gn - bn) / d + (gn < bn ? 6 : 0)
129
+ : max === gn
130
+ ? (bn - rn) / d + 2
131
+ : (rn - gn) / d + 4;
132
+ return [h / 6, s, l];
133
+ }
134
+ function hslToRgb(h, s, l) {
135
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
136
+ const p = 2 * l - q;
137
+ const channel = (t) => {
138
+ const k = (t + 1) % 1;
139
+ const v = k < 1 / 6
140
+ ? p + (q - p) * 6 * k
141
+ : k < 1 / 2
142
+ ? q
143
+ : k < 2 / 3
144
+ ? p + (q - p) * (2 / 3 - k) * 6
145
+ : p;
146
+ return Math.round(v * 255);
147
+ };
148
+ return [channel(h + 1 / 3), channel(h), channel(h - 1 / 3)];
149
+ }
@@ -0,0 +1,14 @@
1
+ import { fetchRepos, reposSchema } from '../../core/data/repos.js';
2
+ import { buildGalaxy } from './galaxy.js';
3
+ import { options } from './options.js';
4
+ import { createGalaxySketch } from './sketch.js';
5
+ export const repoGalaxy = {
6
+ id: 'repo-galaxy',
7
+ fps: 25,
8
+ frames: 300,
9
+ data: { name: 'repos', schema: reposSchema, fetch: fetchRepos },
10
+ options,
11
+ createSketch({ login, data, options, identity, rng }) {
12
+ return createGalaxySketch(buildGalaxy(data, rng, options), identity, login, rng);
13
+ },
14
+ };
@@ -0,0 +1,12 @@
1
+ import { z } from 'zod';
2
+ import { MAX_REPOS } from '../../core/data/repos.js';
3
+ export const options = z
4
+ .object({
5
+ /** Number of repositories shown, most starred first. */
6
+ max_repos: z.coerce.number().int().min(5).max(MAX_REPOS).default(20),
7
+ /** Include forked repositories. */
8
+ include_forks: z.stringbool().default(false),
9
+ /** Name the three most starred repositories. */
10
+ labels: z.enum(['top3', 'none']).default('top3'),
11
+ })
12
+ .strict();
@@ -0,0 +1,415 @@
1
+ import { fitIdentity } from '../../core/text.js';
2
+ import { LAYOUT, RINGS } from './galaxy.js';
3
+ const PALETTE = {
4
+ skyTop: '#060914',
5
+ skyMid: '#0d1228',
6
+ skyFloor: '#0a0d1c',
7
+ /** Shadow side of planets. */
8
+ night: [6, 9, 20],
9
+ brass: [217, 178, 111],
10
+ sun: [255, 243, 214],
11
+ ink: '#f2ede3',
12
+ muted: '#8e93a8',
13
+ };
14
+ const SANS = 'Space Grotesk';
15
+ const MONO = 'JetBrains Mono';
16
+ const TEXT_X = 48;
17
+ const PROMPT = '~/repos';
18
+ /**
19
+ * Rays around the sun, long and short in turn. They rotate by one long-short
20
+ * pair (two spacings) per loop, so the loop closes.
21
+ */
22
+ const RAYS = 12;
23
+ /** Trail length along the orbit, px. */
24
+ const TRAIL = 110;
25
+ /** Where orbit names sit: the far side, left of the sun's crown. */
26
+ const RING_LABEL_ANGLE = -Math.PI / 2 - 0.42;
27
+ const { width: W, height: H, cx: CX, cy: CY, tilt: TILT, aspect: ASPECT } = LAYOUT;
28
+ const COS_T = Math.cos(TILT);
29
+ const SIN_T = Math.sin(TILT);
30
+ /** 0..1, periodic in `phase`, so every animated value loops seamlessly. */
31
+ function wave(phase, cycles, off) {
32
+ return 0.5 + 0.5 * Math.sin(Math.PI * 2 * (phase * cycles + off));
33
+ }
34
+ /** Screen position of angle `a` on an orbit of half-width `rx`. */
35
+ function project(rx, a) {
36
+ const x = rx * Math.cos(a);
37
+ const y = rx * ASPECT * Math.sin(a);
38
+ return { x: CX + x * COS_T - y * SIN_T, y: CY + x * SIN_T + y * COS_T };
39
+ }
40
+ /** -1 at the far side of the plane, 1 at the near side. */
41
+ const depthOf = (a) => Math.sin(a);
42
+ function rgba([r, g, b], a) {
43
+ return `rgba(${r},${g},${b},${a.toFixed(3)})`;
44
+ }
45
+ /** 1234 -> "1.2k" */
46
+ function compact(n) {
47
+ if (n < 1000)
48
+ return String(n);
49
+ const k = n / 1000;
50
+ return `${k >= 100 ? Math.round(k) : Number(k.toFixed(1))}k`;
51
+ }
52
+ /** "https://www.example.com/" -> "www.example.com" */
53
+ function displayUrl(url) {
54
+ return url.replace(/^[a-z]+:\/\//i, '').replace(/\/+$/, '');
55
+ }
56
+ function truncate(text, max) {
57
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
58
+ }
59
+ export function createGalaxySketch(galaxy, identity, login, rng) {
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);
68
+ let sky;
69
+ let grain;
70
+ function renderSky(p) {
71
+ const g = p.createGraphics(W, H);
72
+ g.pixelDensity(1);
73
+ const ctx = g.drawingContext;
74
+ const grad = ctx.createLinearGradient(0, 0, 0, H);
75
+ grad.addColorStop(0, PALETTE.skyTop);
76
+ grad.addColorStop(0.7, PALETTE.skyMid);
77
+ grad.addColorStop(1, PALETTE.skyFloor);
78
+ ctx.fillStyle = grad;
79
+ ctx.fillRect(0, 0, W, H);
80
+ // Cool haze on the far right, warm light around the sun.
81
+ const haze = ctx.createRadialGradient(1180, 60, 0, 1180, 60, 520);
82
+ haze.addColorStop(0, 'rgba(111,120,220,0.10)');
83
+ haze.addColorStop(1, 'rgba(111,120,220,0)');
84
+ ctx.fillStyle = haze;
85
+ ctx.fillRect(0, 0, W, H);
86
+ const warm = ctx.createRadialGradient(CX, CY, 0, CX, CY, 420);
87
+ warm.addColorStop(0, 'rgba(217,178,111,0.20)');
88
+ warm.addColorStop(0.35, 'rgba(217,178,111,0.06)');
89
+ warm.addColorStop(1, 'rgba(217,178,111,0)');
90
+ ctx.fillStyle = warm;
91
+ ctx.fillRect(0, 0, W, H);
92
+ for (const d of galaxy.dust) {
93
+ ctx.fillStyle = d.warm ? `rgba(240,214,170,${d.a / 255})` : `rgba(190,200,255,${d.a / 255})`;
94
+ ctx.fillRect(d.x, d.y, 1, 1);
95
+ }
96
+ return g;
97
+ }
98
+ function renderGrain(p) {
99
+ const g = p.createGraphics(W, H);
100
+ g.pixelDensity(1);
101
+ g.noStroke();
102
+ let s = grainSeed;
103
+ const next = () => {
104
+ s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
105
+ return s / 4294967296;
106
+ };
107
+ for (let i = 0; i < LAYOUT.grain; i++) {
108
+ g.fill(255, 4 + next() * 8);
109
+ g.rect(next() * W, next() * H, 1, 1);
110
+ }
111
+ return g;
112
+ }
113
+ function drawStars(p, phase) {
114
+ p.noStroke();
115
+ for (const s of galaxy.stars) {
116
+ p.fill(226, 232, 250, s.a * (0.3 + 0.7 * wave(phase, s.cycles, s.off)));
117
+ p.rect(s.x, s.y, s.s, s.s);
118
+ }
119
+ }
120
+ /** One half of every orbit: the far half (behind the sun) or the near half. */
121
+ function drawOrbits(ctx, near) {
122
+ const [start, end] = near ? [0, Math.PI] : [Math.PI, Math.PI * 2];
123
+ ctx.lineWidth = 0.8;
124
+ RINGS.forEach((ring, i) => {
125
+ const outer = i === RINGS.length - 1;
126
+ ctx.strokeStyle = rgba(PALETTE.brass, (near ? 0.42 : 0.2) + (outer ? 0.08 : 0));
127
+ ctx.setLineDash(outer ? [] : [1, 4]);
128
+ ctx.beginPath();
129
+ ctx.ellipse(CX, CY, ring.rx, ring.rx * ASPECT, TILT, start, end);
130
+ ctx.stroke();
131
+ });
132
+ ctx.setLineDash([]);
133
+ // Dial on the outer orbit: a tick every 6 degrees, a long one every 30.
134
+ const rx = RINGS[RINGS.length - 1].rx;
135
+ ctx.strokeStyle = rgba(PALETTE.brass, near ? 0.5 : 0.24);
136
+ ctx.lineWidth = 0.7;
137
+ ctx.beginPath();
138
+ for (let k = 0; k < 60; k++) {
139
+ const a = (k / 60) * Math.PI * 2;
140
+ if (depthOf(a) >= 0 !== near)
141
+ continue;
142
+ const inner = project(rx + 3, a);
143
+ const outerPt = project(rx + (k % 5 === 0 ? 11 : 6), a);
144
+ ctx.moveTo(inner.x, inner.y);
145
+ ctx.lineTo(outerPt.x, outerPt.y);
146
+ }
147
+ ctx.stroke();
148
+ }
149
+ function drawRingLabels(p) {
150
+ const ctx = p.drawingContext;
151
+ p.textFont(MONO);
152
+ p.textStyle(p.NORMAL);
153
+ p.textSize(9);
154
+ p.noStroke();
155
+ ctx.letterSpacing = '1.5px';
156
+ p.textAlign(p.CENTER, p.BASELINE);
157
+ for (const ring of RINGS) {
158
+ const at = project(ring.rx, RING_LABEL_ANGLE);
159
+ p.fill(217, 178, 111, 130);
160
+ p.text(ring.label, at.x, at.y - 5);
161
+ }
162
+ ctx.letterSpacing = '0px';
163
+ }
164
+ function place(planet, phase) {
165
+ const ring = RINGS[planet.ring];
166
+ const a = planet.angle + Math.PI * 2 * ring.revs * phase;
167
+ const { x, y } = project(ring.rx, a);
168
+ const depth = depthOf(a);
169
+ return { planet, a, x, y, depth, r: planet.r * (1 + 0.16 * depth) };
170
+ }
171
+ function drawTrail(ctx, pl, light) {
172
+ const rx = RINGS[pl.planet.ring].rx;
173
+ const span = TRAIL / rx;
174
+ const steps = 24;
175
+ ctx.lineCap = 'round';
176
+ let prev = project(rx, pl.a);
177
+ for (let k = 1; k <= steps; k++) {
178
+ const pt = project(rx, pl.a - (span * k) / steps);
179
+ const fade = 1 - k / steps;
180
+ ctx.strokeStyle = rgba(pl.planet.color, 0.45 * fade * fade * light);
181
+ ctx.lineWidth = Math.max(0.6, pl.r * 0.55 * fade);
182
+ ctx.beginPath();
183
+ ctx.moveTo(prev.x, prev.y);
184
+ ctx.lineTo(pt.x, pt.y);
185
+ ctx.stroke();
186
+ prev = pt;
187
+ }
188
+ }
189
+ /** A lit sphere: bright toward the sun, shadowed on the far side. */
190
+ function drawPlanet(ctx, pl) {
191
+ const { x, y, r, planet } = pl;
192
+ const light = 0.62 + 0.38 * ((pl.depth + 1) / 2);
193
+ drawTrail(ctx, pl, light);
194
+ // A soft glow on the larger planets only; small ones stay crisp.
195
+ if (r >= 7) {
196
+ const halo = ctx.createRadialGradient(x, y, r * 0.8, x, y, r * 2);
197
+ halo.addColorStop(0, rgba(planet.color, 0.2 * light));
198
+ halo.addColorStop(1, rgba(planet.color, 0));
199
+ ctx.fillStyle = halo;
200
+ ctx.fillRect(x - r * 2, y - r * 2, r * 4, r * 4);
201
+ }
202
+ const ring = planet.rank === 0 && planet.stars > 0;
203
+ if (ring)
204
+ drawPlanetRing(ctx, pl, false, light);
205
+ // Direction from the sun, for the shadow side.
206
+ const dx = x - CX;
207
+ const dy = y - CY;
208
+ const len = Math.hypot(dx, dy) || 1;
209
+ const ux = dx / len;
210
+ const uy = dy / len;
211
+ ctx.save();
212
+ ctx.beginPath();
213
+ ctx.arc(x, y, r, 0, Math.PI * 2);
214
+ ctx.fillStyle = rgba(planet.color, light);
215
+ ctx.fill();
216
+ ctx.clip();
217
+ ctx.fillStyle = rgba(PALETTE.night, 0.62);
218
+ ctx.beginPath();
219
+ ctx.arc(x + ux * r * 0.95, y + uy * r * 0.95, r * 1.08, 0, Math.PI * 2);
220
+ ctx.fill();
221
+ ctx.fillStyle = `rgba(255,255,255,${(0.35 * light).toFixed(3)})`;
222
+ ctx.beginPath();
223
+ ctx.arc(x - ux * r * 0.45, y - uy * r * 0.45, r * 0.32, 0, Math.PI * 2);
224
+ ctx.fill();
225
+ ctx.restore();
226
+ if (ring)
227
+ drawPlanetRing(ctx, pl, true, light);
228
+ }
229
+ /** The most starred repo wears a ring, split so the planet sits inside it. */
230
+ function drawPlanetRing(ctx, pl, near, light) {
231
+ const [start, end] = near ? [0, Math.PI] : [Math.PI, Math.PI * 2];
232
+ ctx.lineCap = 'butt';
233
+ for (const [scale, width, alpha] of [
234
+ [2.05, 1.6, 0.75],
235
+ [1.6, 0.8, 0.45],
236
+ ]) {
237
+ ctx.strokeStyle = rgba(PALETTE.brass, alpha * light);
238
+ ctx.lineWidth = width;
239
+ ctx.beginPath();
240
+ ctx.ellipse(pl.x, pl.y, pl.r * scale, pl.r * scale * 0.32, -0.32, start, end);
241
+ ctx.stroke();
242
+ }
243
+ }
244
+ function drawSun(p, phase) {
245
+ const ctx = p.drawingContext;
246
+ const pulse = wave(phase, 1, 0);
247
+ const corona = ctx.createRadialGradient(CX, CY, 8, CX, CY, 64 + pulse * 10);
248
+ corona.addColorStop(0, rgba(PALETTE.sun, 0.55));
249
+ corona.addColorStop(0.3, rgba(PALETTE.brass, 0.22));
250
+ corona.addColorStop(1, rgba(PALETTE.brass, 0));
251
+ ctx.fillStyle = corona;
252
+ ctx.fillRect(CX - 80, CY - 80, 160, 160);
253
+ ctx.strokeStyle = rgba(PALETTE.brass, 0.4);
254
+ ctx.lineWidth = 0.8;
255
+ ctx.beginPath();
256
+ const turn = (phase * Math.PI * 4) / RAYS;
257
+ for (let k = 0; k < RAYS; k++) {
258
+ const a = turn + (k / RAYS) * Math.PI * 2;
259
+ const len = k % 2 === 0 ? 20 : 11;
260
+ ctx.moveTo(CX + Math.cos(a) * 22, CY + Math.sin(a) * 22);
261
+ ctx.lineTo(CX + Math.cos(a) * (22 + len), CY + Math.sin(a) * (22 + len));
262
+ }
263
+ ctx.stroke();
264
+ const core = ctx.createRadialGradient(CX - 4, CY - 4, 1, CX, CY, 15);
265
+ core.addColorStop(0, '#ffffff');
266
+ core.addColorStop(0.6, rgba(PALETTE.sun, 1));
267
+ core.addColorStop(1, rgba([246, 206, 140], 1));
268
+ ctx.fillStyle = core;
269
+ ctx.beginPath();
270
+ ctx.arc(CX, CY, 15, 0, Math.PI * 2);
271
+ ctx.fill();
272
+ p.textFont(MONO);
273
+ p.textStyle(p.NORMAL);
274
+ p.textSize(10);
275
+ p.textAlign(p.CENTER, p.BASELINE);
276
+ p.noStroke();
277
+ p.fill(217, 178, 111, 200);
278
+ p.text(`@${login}`, CX, CY + 34);
279
+ }
280
+ function drawStarGlyph(ctx, x, y, r) {
281
+ ctx.beginPath();
282
+ for (let k = 0; k < 10; k++) {
283
+ const a = -Math.PI / 2 + (k * Math.PI) / 5;
284
+ const rr = k % 2 === 0 ? r : r * 0.45;
285
+ ctx.lineTo(x + Math.cos(a) * rr, y + Math.sin(a) * rr);
286
+ }
287
+ ctx.closePath();
288
+ ctx.fill();
289
+ }
290
+ /**
291
+ * Callout centered above the planet, clamped to the canvas. Its position is
292
+ * a continuous function of the planet's, so it never jumps between frames.
293
+ */
294
+ function drawLabel(p, pl) {
295
+ const ctx = p.drawingContext;
296
+ const light = 0.55 + 0.45 * ((pl.depth + 1) / 2);
297
+ const name = truncate(pl.planet.name, 22);
298
+ const count = compact(pl.planet.stars);
299
+ p.textFont(MONO);
300
+ p.textStyle(p.NORMAL);
301
+ p.textSize(11);
302
+ const nameW = p.textWidth(name);
303
+ const width = nameW + 17 + p.textWidth(count);
304
+ const ey = pl.y - pl.r - 16;
305
+ const tx = Math.min(Math.max(pl.x - width / 2, 20), W - 20 - width);
306
+ ctx.strokeStyle = rgba(PALETTE.brass, 0.6 * light);
307
+ ctx.lineWidth = 0.8;
308
+ ctx.beginPath();
309
+ ctx.moveTo(pl.x, pl.y - pl.r - 2);
310
+ ctx.lineTo(pl.x, ey + 8);
311
+ ctx.stroke();
312
+ // A dark tag behind the text keeps it legible when it crosses the sun.
313
+ ctx.fillStyle = rgba(PALETTE.night, 0.7 * light);
314
+ ctx.beginPath();
315
+ ctx.roundRect(tx - 4, ey - 8, width + 8, 16, 3);
316
+ ctx.fill();
317
+ p.textAlign(p.LEFT, p.BASELINE);
318
+ p.noStroke();
319
+ p.fill(242, 237, 227, 235 * light);
320
+ p.text(name, tx, ey + 4);
321
+ const sx = tx + nameW + 10;
322
+ ctx.fillStyle = rgba(PALETTE.brass, light);
323
+ drawStarGlyph(ctx, sx, ey + 0.5, 4.2);
324
+ p.fill(217, 178, 111, 255 * light);
325
+ p.text(count, sx + 7, ey + 4);
326
+ }
327
+ function drawText(p, phase) {
328
+ const x = TEXT_X;
329
+ const ctx = p.drawingContext;
330
+ const brass = PALETTE.brass;
331
+ p.noStroke();
332
+ p.textAlign(p.LEFT, p.BASELINE);
333
+ p.textFont(MONO);
334
+ p.textStyle(p.NORMAL);
335
+ p.textSize(13);
336
+ p.fill(...brass);
337
+ p.text(PROMPT, x, 58);
338
+ if (wave(phase, 3, 0) > 0.5) {
339
+ p.rect(x + p.textWidth(PROMPT) + 4, 47, 7, 13);
340
+ }
341
+ p.textFont(SANS);
342
+ p.textStyle(p.BOLD);
343
+ p.textSize(fitted.name.size);
344
+ ctx.letterSpacing = '3px';
345
+ p.fill(PALETTE.ink);
346
+ p.text(fitted.name.text, x, 116);
347
+ ctx.letterSpacing = '0px';
348
+ if (fitted.tagline) {
349
+ p.textStyle(p.NORMAL);
350
+ p.textSize(fitted.tagline.size);
351
+ p.fill(...brass);
352
+ p.text(fitted.tagline.text, x, 146);
353
+ }
354
+ const count = galaxy.planets.length;
355
+ p.textFont(MONO);
356
+ p.textStyle(p.NORMAL);
357
+ p.textSize(11);
358
+ p.fill(PALETTE.muted);
359
+ p.text(count === 1 ? '1 REPOSITORY' : `${count} REPOSITORIES`, x, 206);
360
+ p.textFont(SANS);
361
+ p.textStyle(p.BOLD);
362
+ p.textSize(34);
363
+ p.fill(PALETTE.ink);
364
+ p.text(galaxy.totalStars.toLocaleString('en-US'), x, 244);
365
+ p.textFont(MONO);
366
+ p.textStyle(p.NORMAL);
367
+ p.textSize(11);
368
+ p.fill(PALETTE.muted);
369
+ p.text(galaxy.totalStars === 1 ? 'star' : 'stars', x, 262);
370
+ if (count === 0) {
371
+ p.text('no public repositories yet', x, 296);
372
+ }
373
+ let lx = x;
374
+ for (const lang of galaxy.languages) {
375
+ const label = truncate(lang.name, 14);
376
+ p.fill(...lang.color);
377
+ p.circle(lx + 4, 292, 7);
378
+ p.fill(PALETTE.muted);
379
+ p.text(label, lx + 14, 296);
380
+ lx += 14 + p.textWidth(label) + 18;
381
+ }
382
+ if (fitted.website) {
383
+ p.textSize(fitted.website.size);
384
+ p.fill(...brass);
385
+ p.text(fitted.website.text, x, 350);
386
+ }
387
+ }
388
+ return {
389
+ setup(p) {
390
+ sky = renderSky(p);
391
+ grain = renderGrain(p);
392
+ },
393
+ draw(p, _frame, phase) {
394
+ const ctx = p.drawingContext;
395
+ const placed = galaxy.planets.map((pl) => place(pl, phase)).sort((a, b) => a.depth - b.depth);
396
+ p.image(sky, 0, 0);
397
+ drawStars(p, phase);
398
+ drawOrbits(ctx, false);
399
+ drawRingLabels(p);
400
+ for (const pl of placed)
401
+ if (pl.depth < 0)
402
+ drawPlanet(ctx, pl);
403
+ drawSun(p, phase);
404
+ drawOrbits(ctx, true);
405
+ for (const pl of placed)
406
+ if (pl.depth >= 0)
407
+ drawPlanet(ctx, pl);
408
+ for (const pl of placed)
409
+ if (pl.planet.label)
410
+ drawLabel(p, pl);
411
+ drawText(p, phase);
412
+ p.image(grain, 0, 0);
413
+ },
414
+ };
415
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "headreel",
3
- "version": "1.1.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",