headreel 1.1.0 → 1.2.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 CHANGED
@@ -87,7 +87,7 @@ The Action and the command use the same settings.
87
87
  | `handle` | `--handle` | empty | A handle, for styles that show one. |
88
88
  | `options` | `--option <key=value>` | empty | Style options. Action: one `key: value` on each line. Command: repeat the flag, for example `--option beacons=5`. |
89
89
  | `output` | `--out` | `headreel.gif` | The path of the banner. |
90
- | `token` | `--token` | `github.token` | The token that reads your contribution data. |
90
+ | `token` | `--token` | `github.token` | The token that reads your GitHub data. |
91
91
  | `publish_mode` | - | `commit` | `commit` adds a commit on every update. `branch` keeps one commit on its own branch, replaced on every update. |
92
92
  | `commit_to` | - | checked-out branch / `headreel` | The branch that gets the banner. The default is `headreel` in `branch` mode. |
93
93
  | `commit_message` | - | `chore: update headreel banner` | The commit message. |
@@ -105,7 +105,19 @@ Empty settings do not show on the banner. Your name comes from your GitHub profi
105
105
  | --------- | ------- | -------------------------------------------------- |
106
106
  | `beacons` | `8` | The number of busiest days with a beacon, 0 to 10. |
107
107
 
108
- Set an option with `options: 'beacons: 5'` in the Action, or `--option beacons=5` in the command.
108
+ ### Repo Galaxy
109
+
110
+ `repo-galaxy`: your most starred repositories orbit a sun. A bigger planet has more stars, and its color is the repository's main language. Repositories you pushed to recently orbit closer to the sun.
111
+
112
+ ![Repo Galaxy banner](https://raw.githubusercontent.com/arifszn/headreel/main/docs/samples/repo-galaxy.gif)
113
+
114
+ | Option | Default | Description |
115
+ | --------------- | ------- | --------------------------------------------------------------------- |
116
+ | `max_repos` | `20` | The number of repositories to show, 5 to 30. |
117
+ | `include_forks` | `false` | Show forked repositories too. |
118
+ | `labels` | `top3` | `top3` names the three most starred repositories. `none` hides names. |
119
+
120
+ Set an option with `options: 'beacons: 5'` in the Action, or `--option beacons=5` in the command. For more than one option in the Action, put each on its own line.
109
121
 
110
122
  ## Troubleshooting
111
123
 
@@ -0,0 +1,87 @@
1
+ import { z } from 'zod';
2
+ /** Most repos any style shows; each list below fetches this many. */
3
+ export const MAX_REPOS = 30;
4
+ const REPO_FIELDS = /* GraphQL */ `
5
+ nodes {
6
+ name
7
+ isFork
8
+ stargazerCount
9
+ pushedAt
10
+ primaryLanguage {
11
+ name
12
+ color
13
+ }
14
+ }
15
+ `;
16
+ // Two lists in one query: top own repos, and top repos including forks. Their
17
+ // union holds the top MAX_REPOS for either `include_forks` setting.
18
+ const QUERY = /* GraphQL */ `
19
+ query Repos($login: String!, $first: Int!) {
20
+ user(login: $login) {
21
+ own: repositories(
22
+ first: $first
23
+ ownerAffiliations: OWNER
24
+ privacy: PUBLIC
25
+ isFork: false
26
+ orderBy: { field: STARGAZERS, direction: DESC }
27
+ ) {
28
+ ${REPO_FIELDS}
29
+ }
30
+ all: repositories(
31
+ first: $first
32
+ ownerAffiliations: OWNER
33
+ privacy: PUBLIC
34
+ orderBy: { field: STARGAZERS, direction: DESC }
35
+ ) {
36
+ ${REPO_FIELDS}
37
+ }
38
+ }
39
+ }
40
+ `;
41
+ const repoSchema = z.object({
42
+ name: z.string().min(1),
43
+ fork: z.boolean(),
44
+ stars: z.number().int().min(0),
45
+ /** Primary language; `color` is the linguist color, null when it has none. */
46
+ language: z
47
+ .object({
48
+ name: z.string().min(1),
49
+ color: z
50
+ .string()
51
+ .regex(/^#[0-9a-fA-F]{6}$/)
52
+ .nullable(),
53
+ })
54
+ .nullable(),
55
+ /** Last push, YYYY-MM-DD. */
56
+ pushedAt: z.iso.date(),
57
+ });
58
+ export const reposSchema = z.object({
59
+ /** Fetch date, YYYY-MM-DD (UTC). Repo ages are measured from here, so renders stay stable. */
60
+ asOf: z.iso.date(),
61
+ /** Most starred first, ties by name. */
62
+ repos: z.array(repoSchema),
63
+ });
64
+ /** Stars descending, then name, so ties never depend on API order. */
65
+ export function compareRepos(a, b) {
66
+ return b.stars - a.stars || a.name.localeCompare(b.name);
67
+ }
68
+ export async function fetchRepos(client, login, now) {
69
+ const { user } = await client(QUERY, { login, first: MAX_REPOS });
70
+ const byName = new Map();
71
+ for (const n of [...user.own.nodes, ...user.all.nodes]) {
72
+ byName.set(n.name, {
73
+ name: n.name,
74
+ fork: n.isFork,
75
+ stars: n.stargazerCount,
76
+ language: n.primaryLanguage
77
+ ? { name: n.primaryLanguage.name, color: n.primaryLanguage.color }
78
+ : null,
79
+ // A repo never pushed to has no pushedAt; it counts as the oldest.
80
+ pushedAt: (n.pushedAt ?? '1970-01-01').slice(0, 10),
81
+ });
82
+ }
83
+ return {
84
+ asOf: now.toISOString().slice(0, 10),
85
+ repos: [...byName.values()].sort(compareRepos),
86
+ };
87
+ }
@@ -16,7 +16,13 @@ export async function renderBanner(style, input) {
16
16
  const options = parseOptions(style, input.options);
17
17
  registerFonts();
18
18
  const rng = createRng(hashSeed(`${input.login}:${style.id}`));
19
- const sketch = style.createSketch({ data: input.data, options, identity: input.identity, rng });
19
+ const sketch = style.createSketch({
20
+ login: input.login,
21
+ data: input.data,
22
+ options,
23
+ identity: input.identity,
24
+ rng,
25
+ });
20
26
  const frames = await renderFrames(sketch, { ...CANVAS, frames: style.frames });
21
27
  return encodeGif(frames, { ...CANVAS, fps: style.fps });
22
28
  }
@@ -6,7 +6,7 @@ export const contributionCity = {
6
6
  id: 'contribution-city',
7
7
  fps: 25,
8
8
  frames: 150,
9
- data: { schema: contributionsSchema, fetch: fetchContributions },
9
+ data: { name: 'contributions', schema: contributionsSchema, fetch: fetchContributions },
10
10
  options,
11
11
  createSketch({ data, options, identity, rng }) {
12
12
  return createCitySketch(buildCity(data, rng, options.beacons), identity, rng);
@@ -1,5 +1,7 @@
1
1
  import { contributionCity } from './contribution-city/index.js';
2
+ import { repoGalaxy } from './repo-galaxy/index.js';
2
3
  /** Built-in styles, keyed by id. */
3
4
  export const styles = {
4
5
  [contributionCity.id]: contributionCity,
6
+ [repoGalaxy.id]: repoGalaxy,
5
7
  };
@@ -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,407 @@
1
+ import { LAYOUT, RINGS } from './galaxy.js';
2
+ const PALETTE = {
3
+ skyTop: '#060914',
4
+ skyMid: '#0d1228',
5
+ skyFloor: '#0a0d1c',
6
+ /** Shadow side of planets. */
7
+ night: [6, 9, 20],
8
+ brass: [217, 178, 111],
9
+ sun: [255, 243, 214],
10
+ ink: '#f2ede3',
11
+ muted: '#8e93a8',
12
+ };
13
+ const SANS = 'Space Grotesk';
14
+ const MONO = 'JetBrains Mono';
15
+ const TEXT_X = 48;
16
+ const PROMPT = '~/repos';
17
+ /**
18
+ * Rays around the sun, long and short in turn. They rotate by one long-short
19
+ * pair (two spacings) per loop, so the loop closes.
20
+ */
21
+ const RAYS = 12;
22
+ /** Trail length along the orbit, px. */
23
+ const TRAIL = 110;
24
+ /** Where orbit names sit: the far side, left of the sun's crown. */
25
+ const RING_LABEL_ANGLE = -Math.PI / 2 - 0.42;
26
+ const { width: W, height: H, cx: CX, cy: CY, tilt: TILT, aspect: ASPECT } = LAYOUT;
27
+ const COS_T = Math.cos(TILT);
28
+ const SIN_T = Math.sin(TILT);
29
+ /** 0..1, periodic in `phase`, so every animated value loops seamlessly. */
30
+ function wave(phase, cycles, off) {
31
+ return 0.5 + 0.5 * Math.sin(Math.PI * 2 * (phase * cycles + off));
32
+ }
33
+ /** Screen position of angle `a` on an orbit of half-width `rx`. */
34
+ function project(rx, a) {
35
+ const x = rx * Math.cos(a);
36
+ const y = rx * ASPECT * Math.sin(a);
37
+ return { x: CX + x * COS_T - y * SIN_T, y: CY + x * SIN_T + y * COS_T };
38
+ }
39
+ /** -1 at the far side of the plane, 1 at the near side. */
40
+ const depthOf = (a) => Math.sin(a);
41
+ function rgba([r, g, b], a) {
42
+ return `rgba(${r},${g},${b},${a.toFixed(3)})`;
43
+ }
44
+ /** 1234 -> "1.2k" */
45
+ function compact(n) {
46
+ if (n < 1000)
47
+ return String(n);
48
+ const k = n / 1000;
49
+ return `${k >= 100 ? Math.round(k) : Number(k.toFixed(1))}k`;
50
+ }
51
+ /** "https://www.example.com/" -> "www.example.com" */
52
+ function displayUrl(url) {
53
+ return url.replace(/^[a-z]+:\/\//i, '').replace(/\/+$/, '');
54
+ }
55
+ function truncate(text, max) {
56
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
57
+ }
58
+ export function createGalaxySketch(galaxy, identity, login, rng) {
59
+ const grainSeed = Math.floor(rng() * 2 ** 31);
60
+ let sky;
61
+ let grain;
62
+ function renderSky(p) {
63
+ const g = p.createGraphics(W, H);
64
+ g.pixelDensity(1);
65
+ const ctx = g.drawingContext;
66
+ const grad = ctx.createLinearGradient(0, 0, 0, H);
67
+ grad.addColorStop(0, PALETTE.skyTop);
68
+ grad.addColorStop(0.7, PALETTE.skyMid);
69
+ grad.addColorStop(1, PALETTE.skyFloor);
70
+ ctx.fillStyle = grad;
71
+ ctx.fillRect(0, 0, W, H);
72
+ // Cool haze on the far right, warm light around the sun.
73
+ const haze = ctx.createRadialGradient(1180, 60, 0, 1180, 60, 520);
74
+ haze.addColorStop(0, 'rgba(111,120,220,0.10)');
75
+ haze.addColorStop(1, 'rgba(111,120,220,0)');
76
+ ctx.fillStyle = haze;
77
+ ctx.fillRect(0, 0, W, H);
78
+ const warm = ctx.createRadialGradient(CX, CY, 0, CX, CY, 420);
79
+ warm.addColorStop(0, 'rgba(217,178,111,0.20)');
80
+ warm.addColorStop(0.35, 'rgba(217,178,111,0.06)');
81
+ warm.addColorStop(1, 'rgba(217,178,111,0)');
82
+ ctx.fillStyle = warm;
83
+ ctx.fillRect(0, 0, W, H);
84
+ for (const d of galaxy.dust) {
85
+ ctx.fillStyle = d.warm ? `rgba(240,214,170,${d.a / 255})` : `rgba(190,200,255,${d.a / 255})`;
86
+ ctx.fillRect(d.x, d.y, 1, 1);
87
+ }
88
+ return g;
89
+ }
90
+ function renderGrain(p) {
91
+ const g = p.createGraphics(W, H);
92
+ g.pixelDensity(1);
93
+ g.noStroke();
94
+ let s = grainSeed;
95
+ const next = () => {
96
+ s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
97
+ return s / 4294967296;
98
+ };
99
+ for (let i = 0; i < LAYOUT.grain; i++) {
100
+ g.fill(255, 4 + next() * 8);
101
+ g.rect(next() * W, next() * H, 1, 1);
102
+ }
103
+ return g;
104
+ }
105
+ function drawStars(p, phase) {
106
+ p.noStroke();
107
+ for (const s of galaxy.stars) {
108
+ p.fill(226, 232, 250, s.a * (0.3 + 0.7 * wave(phase, s.cycles, s.off)));
109
+ p.rect(s.x, s.y, s.s, s.s);
110
+ }
111
+ }
112
+ /** One half of every orbit: the far half (behind the sun) or the near half. */
113
+ function drawOrbits(ctx, near) {
114
+ const [start, end] = near ? [0, Math.PI] : [Math.PI, Math.PI * 2];
115
+ ctx.lineWidth = 0.8;
116
+ RINGS.forEach((ring, i) => {
117
+ const outer = i === RINGS.length - 1;
118
+ ctx.strokeStyle = rgba(PALETTE.brass, (near ? 0.42 : 0.2) + (outer ? 0.08 : 0));
119
+ ctx.setLineDash(outer ? [] : [1, 4]);
120
+ ctx.beginPath();
121
+ ctx.ellipse(CX, CY, ring.rx, ring.rx * ASPECT, TILT, start, end);
122
+ ctx.stroke();
123
+ });
124
+ ctx.setLineDash([]);
125
+ // Dial on the outer orbit: a tick every 6 degrees, a long one every 30.
126
+ const rx = RINGS[RINGS.length - 1].rx;
127
+ ctx.strokeStyle = rgba(PALETTE.brass, near ? 0.5 : 0.24);
128
+ ctx.lineWidth = 0.7;
129
+ ctx.beginPath();
130
+ for (let k = 0; k < 60; k++) {
131
+ const a = (k / 60) * Math.PI * 2;
132
+ if (depthOf(a) >= 0 !== near)
133
+ continue;
134
+ const inner = project(rx + 3, a);
135
+ const outerPt = project(rx + (k % 5 === 0 ? 11 : 6), a);
136
+ ctx.moveTo(inner.x, inner.y);
137
+ ctx.lineTo(outerPt.x, outerPt.y);
138
+ }
139
+ ctx.stroke();
140
+ }
141
+ function drawRingLabels(p) {
142
+ const ctx = p.drawingContext;
143
+ p.textFont(MONO);
144
+ p.textStyle(p.NORMAL);
145
+ p.textSize(9);
146
+ p.noStroke();
147
+ ctx.letterSpacing = '1.5px';
148
+ p.textAlign(p.CENTER, p.BASELINE);
149
+ for (const ring of RINGS) {
150
+ const at = project(ring.rx, RING_LABEL_ANGLE);
151
+ p.fill(217, 178, 111, 130);
152
+ p.text(ring.label, at.x, at.y - 5);
153
+ }
154
+ ctx.letterSpacing = '0px';
155
+ }
156
+ function place(planet, phase) {
157
+ const ring = RINGS[planet.ring];
158
+ const a = planet.angle + Math.PI * 2 * ring.revs * phase;
159
+ const { x, y } = project(ring.rx, a);
160
+ const depth = depthOf(a);
161
+ return { planet, a, x, y, depth, r: planet.r * (1 + 0.16 * depth) };
162
+ }
163
+ function drawTrail(ctx, pl, light) {
164
+ const rx = RINGS[pl.planet.ring].rx;
165
+ const span = TRAIL / rx;
166
+ const steps = 24;
167
+ ctx.lineCap = 'round';
168
+ let prev = project(rx, pl.a);
169
+ for (let k = 1; k <= steps; k++) {
170
+ const pt = project(rx, pl.a - (span * k) / steps);
171
+ const fade = 1 - k / steps;
172
+ ctx.strokeStyle = rgba(pl.planet.color, 0.45 * fade * fade * light);
173
+ ctx.lineWidth = Math.max(0.6, pl.r * 0.55 * fade);
174
+ ctx.beginPath();
175
+ ctx.moveTo(prev.x, prev.y);
176
+ ctx.lineTo(pt.x, pt.y);
177
+ ctx.stroke();
178
+ prev = pt;
179
+ }
180
+ }
181
+ /** A lit sphere: bright toward the sun, shadowed on the far side. */
182
+ function drawPlanet(ctx, pl) {
183
+ const { x, y, r, planet } = pl;
184
+ const light = 0.62 + 0.38 * ((pl.depth + 1) / 2);
185
+ drawTrail(ctx, pl, light);
186
+ // A soft glow on the larger planets only; small ones stay crisp.
187
+ if (r >= 7) {
188
+ const halo = ctx.createRadialGradient(x, y, r * 0.8, x, y, r * 2);
189
+ halo.addColorStop(0, rgba(planet.color, 0.2 * light));
190
+ halo.addColorStop(1, rgba(planet.color, 0));
191
+ ctx.fillStyle = halo;
192
+ ctx.fillRect(x - r * 2, y - r * 2, r * 4, r * 4);
193
+ }
194
+ const ring = planet.rank === 0 && planet.stars > 0;
195
+ if (ring)
196
+ drawPlanetRing(ctx, pl, false, light);
197
+ // Direction from the sun, for the shadow side.
198
+ const dx = x - CX;
199
+ const dy = y - CY;
200
+ const len = Math.hypot(dx, dy) || 1;
201
+ const ux = dx / len;
202
+ const uy = dy / len;
203
+ ctx.save();
204
+ ctx.beginPath();
205
+ ctx.arc(x, y, r, 0, Math.PI * 2);
206
+ ctx.fillStyle = rgba(planet.color, light);
207
+ ctx.fill();
208
+ ctx.clip();
209
+ ctx.fillStyle = rgba(PALETTE.night, 0.62);
210
+ ctx.beginPath();
211
+ ctx.arc(x + ux * r * 0.95, y + uy * r * 0.95, r * 1.08, 0, Math.PI * 2);
212
+ ctx.fill();
213
+ ctx.fillStyle = `rgba(255,255,255,${(0.35 * light).toFixed(3)})`;
214
+ ctx.beginPath();
215
+ ctx.arc(x - ux * r * 0.45, y - uy * r * 0.45, r * 0.32, 0, Math.PI * 2);
216
+ ctx.fill();
217
+ ctx.restore();
218
+ if (ring)
219
+ drawPlanetRing(ctx, pl, true, light);
220
+ }
221
+ /** The most starred repo wears a ring, split so the planet sits inside it. */
222
+ function drawPlanetRing(ctx, pl, near, light) {
223
+ const [start, end] = near ? [0, Math.PI] : [Math.PI, Math.PI * 2];
224
+ ctx.lineCap = 'butt';
225
+ for (const [scale, width, alpha] of [
226
+ [2.05, 1.6, 0.75],
227
+ [1.6, 0.8, 0.45],
228
+ ]) {
229
+ ctx.strokeStyle = rgba(PALETTE.brass, alpha * light);
230
+ ctx.lineWidth = width;
231
+ ctx.beginPath();
232
+ ctx.ellipse(pl.x, pl.y, pl.r * scale, pl.r * scale * 0.32, -0.32, start, end);
233
+ ctx.stroke();
234
+ }
235
+ }
236
+ function drawSun(p, phase) {
237
+ const ctx = p.drawingContext;
238
+ const pulse = wave(phase, 1, 0);
239
+ const corona = ctx.createRadialGradient(CX, CY, 8, CX, CY, 64 + pulse * 10);
240
+ corona.addColorStop(0, rgba(PALETTE.sun, 0.55));
241
+ corona.addColorStop(0.3, rgba(PALETTE.brass, 0.22));
242
+ corona.addColorStop(1, rgba(PALETTE.brass, 0));
243
+ ctx.fillStyle = corona;
244
+ ctx.fillRect(CX - 80, CY - 80, 160, 160);
245
+ ctx.strokeStyle = rgba(PALETTE.brass, 0.4);
246
+ ctx.lineWidth = 0.8;
247
+ ctx.beginPath();
248
+ const turn = (phase * Math.PI * 4) / RAYS;
249
+ for (let k = 0; k < RAYS; k++) {
250
+ const a = turn + (k / RAYS) * Math.PI * 2;
251
+ const len = k % 2 === 0 ? 20 : 11;
252
+ ctx.moveTo(CX + Math.cos(a) * 22, CY + Math.sin(a) * 22);
253
+ ctx.lineTo(CX + Math.cos(a) * (22 + len), CY + Math.sin(a) * (22 + len));
254
+ }
255
+ ctx.stroke();
256
+ const core = ctx.createRadialGradient(CX - 4, CY - 4, 1, CX, CY, 15);
257
+ core.addColorStop(0, '#ffffff');
258
+ core.addColorStop(0.6, rgba(PALETTE.sun, 1));
259
+ core.addColorStop(1, rgba([246, 206, 140], 1));
260
+ ctx.fillStyle = core;
261
+ ctx.beginPath();
262
+ ctx.arc(CX, CY, 15, 0, Math.PI * 2);
263
+ ctx.fill();
264
+ p.textFont(MONO);
265
+ p.textStyle(p.NORMAL);
266
+ p.textSize(10);
267
+ p.textAlign(p.CENTER, p.BASELINE);
268
+ p.noStroke();
269
+ p.fill(217, 178, 111, 200);
270
+ p.text(`@${login}`, CX, CY + 34);
271
+ }
272
+ function drawStarGlyph(ctx, x, y, r) {
273
+ ctx.beginPath();
274
+ for (let k = 0; k < 10; k++) {
275
+ const a = -Math.PI / 2 + (k * Math.PI) / 5;
276
+ const rr = k % 2 === 0 ? r : r * 0.45;
277
+ ctx.lineTo(x + Math.cos(a) * rr, y + Math.sin(a) * rr);
278
+ }
279
+ ctx.closePath();
280
+ ctx.fill();
281
+ }
282
+ /**
283
+ * Callout centered above the planet, clamped to the canvas. Its position is
284
+ * a continuous function of the planet's, so it never jumps between frames.
285
+ */
286
+ function drawLabel(p, pl) {
287
+ const ctx = p.drawingContext;
288
+ const light = 0.55 + 0.45 * ((pl.depth + 1) / 2);
289
+ const name = truncate(pl.planet.name, 22);
290
+ const count = compact(pl.planet.stars);
291
+ p.textFont(MONO);
292
+ p.textStyle(p.NORMAL);
293
+ p.textSize(11);
294
+ const nameW = p.textWidth(name);
295
+ const width = nameW + 17 + p.textWidth(count);
296
+ const ey = pl.y - pl.r - 16;
297
+ const tx = Math.min(Math.max(pl.x - width / 2, 20), W - 20 - width);
298
+ ctx.strokeStyle = rgba(PALETTE.brass, 0.6 * light);
299
+ ctx.lineWidth = 0.8;
300
+ ctx.beginPath();
301
+ ctx.moveTo(pl.x, pl.y - pl.r - 2);
302
+ ctx.lineTo(pl.x, ey + 8);
303
+ ctx.stroke();
304
+ // A dark tag behind the text keeps it legible when it crosses the sun.
305
+ ctx.fillStyle = rgba(PALETTE.night, 0.7 * light);
306
+ ctx.beginPath();
307
+ ctx.roundRect(tx - 4, ey - 8, width + 8, 16, 3);
308
+ ctx.fill();
309
+ p.textAlign(p.LEFT, p.BASELINE);
310
+ p.noStroke();
311
+ p.fill(242, 237, 227, 235 * light);
312
+ p.text(name, tx, ey + 4);
313
+ const sx = tx + nameW + 10;
314
+ ctx.fillStyle = rgba(PALETTE.brass, light);
315
+ drawStarGlyph(ctx, sx, ey + 0.5, 4.2);
316
+ p.fill(217, 178, 111, 255 * light);
317
+ p.text(count, sx + 7, ey + 4);
318
+ }
319
+ function drawText(p, phase) {
320
+ const x = TEXT_X;
321
+ const ctx = p.drawingContext;
322
+ const brass = PALETTE.brass;
323
+ p.noStroke();
324
+ p.textAlign(p.LEFT, p.BASELINE);
325
+ p.textFont(MONO);
326
+ p.textStyle(p.NORMAL);
327
+ p.textSize(13);
328
+ p.fill(...brass);
329
+ p.text(PROMPT, x, 58);
330
+ if (wave(phase, 3, 0) > 0.5) {
331
+ p.rect(x + p.textWidth(PROMPT) + 4, 47, 7, 13);
332
+ }
333
+ p.textFont(SANS);
334
+ p.textStyle(p.BOLD);
335
+ p.textSize(50);
336
+ ctx.letterSpacing = '3px';
337
+ p.fill(PALETTE.ink);
338
+ p.text(identity.name.toUpperCase(), x, 116);
339
+ ctx.letterSpacing = '0px';
340
+ if (identity.tagline) {
341
+ p.textStyle(p.NORMAL);
342
+ p.textSize(17);
343
+ p.fill(...brass);
344
+ p.text(identity.tagline, x, 146);
345
+ }
346
+ const count = galaxy.planets.length;
347
+ p.textFont(MONO);
348
+ p.textStyle(p.NORMAL);
349
+ p.textSize(11);
350
+ p.fill(PALETTE.muted);
351
+ p.text(count === 1 ? '1 REPOSITORY' : `${count} REPOSITORIES`, x, 206);
352
+ p.textFont(SANS);
353
+ p.textStyle(p.BOLD);
354
+ p.textSize(34);
355
+ p.fill(PALETTE.ink);
356
+ p.text(galaxy.totalStars.toLocaleString('en-US'), x, 244);
357
+ p.textFont(MONO);
358
+ p.textStyle(p.NORMAL);
359
+ p.textSize(11);
360
+ p.fill(PALETTE.muted);
361
+ p.text(galaxy.totalStars === 1 ? 'star' : 'stars', x, 262);
362
+ if (count === 0) {
363
+ p.text('no public repositories yet', x, 296);
364
+ }
365
+ let lx = x;
366
+ for (const lang of galaxy.languages) {
367
+ const label = truncate(lang.name, 14);
368
+ p.fill(...lang.color);
369
+ p.circle(lx + 4, 292, 7);
370
+ p.fill(PALETTE.muted);
371
+ p.text(label, lx + 14, 296);
372
+ lx += 14 + p.textWidth(label) + 18;
373
+ }
374
+ if (identity.website) {
375
+ p.textSize(12);
376
+ p.fill(...brass);
377
+ p.text(`↗ ${displayUrl(identity.website)}`, x, 350);
378
+ }
379
+ }
380
+ return {
381
+ setup(p) {
382
+ sky = renderSky(p);
383
+ grain = renderGrain(p);
384
+ },
385
+ draw(p, _frame, phase) {
386
+ const ctx = p.drawingContext;
387
+ const placed = galaxy.planets.map((pl) => place(pl, phase)).sort((a, b) => a.depth - b.depth);
388
+ p.image(sky, 0, 0);
389
+ drawStars(p, phase);
390
+ drawOrbits(ctx, false);
391
+ drawRingLabels(p);
392
+ for (const pl of placed)
393
+ if (pl.depth < 0)
394
+ drawPlanet(ctx, pl);
395
+ drawSun(p, phase);
396
+ drawOrbits(ctx, true);
397
+ for (const pl of placed)
398
+ if (pl.depth >= 0)
399
+ drawPlanet(ctx, pl);
400
+ for (const pl of placed)
401
+ if (pl.planet.label)
402
+ drawLabel(p, pl);
403
+ drawText(p, phase);
404
+ p.image(grain, 0, 0);
405
+ },
406
+ };
407
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "headreel",
3
- "version": "1.1.0",
3
+ "version": "1.2.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",