headreel 1.0.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
@@ -1,53 +1,25 @@
1
1
  # headreel
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/headreel)](https://www.npmjs.com/package/headreel)
4
+ [![GitHub Marketplace](https://img.shields.io/badge/marketplace-headreel-blue?logo=github)](https://github.com/marketplace/actions/headreel)
4
5
  [![CI](https://github.com/arifszn/headreel/actions/workflows/ci.yml/badge.svg)](https://github.com/arifszn/headreel/actions/workflows/ci.yml)
5
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
6
7
 
7
- An animated banner for your GitHub profile, made from your own GitHub activity.
8
+ An animated banner for your GitHub profile, made from your GitHub activity.
8
9
 
9
10
  ![Contribution City banner](https://raw.githubusercontent.com/arifszn/headreel/main/docs/samples/contribution-city.gif)
10
11
 
11
- headreel turns your contributions into a looping GIF. You can use it in two ways:
12
+ Pick one way to make your banner:
12
13
 
13
- - **One command on your computer.** Run `npx headreel` and get your banner. You do not install or set up anything. It works on macOS, Windows, and Linux.
14
- - **A GitHub Action that keeps it current.** Add one workflow file, and your banner updates every day by itself.
14
+ - [GitHub Action](#github-action): updates the banner every day. No setup on your computer.
15
+ - [Command](#command): makes the banner once on your computer.
15
16
 
16
- Every style works on both light and dark GitHub themes.
17
+ ## GitHub Action
17
18
 
18
- ## Make your banner now
19
+ The Action is on the [GitHub Marketplace](https://github.com/marketplace/actions/headreel).
19
20
 
20
- Run this command. Replace `octocat` with your GitHub username.
21
-
22
- ```bash
23
- npx headreel --style contribution-city --user octocat
24
- ```
25
-
26
- The command saves your banner as `headreel.gif` in the current folder. The first run downloads headreel. After that, a banner takes a few seconds.
27
-
28
- Add your tagline and website:
29
-
30
- ```bash
31
- npx headreel --style contribution-city --user octocat \
32
- --tagline "Open source maintainer" \
33
- --website https://example.com
34
- ```
35
-
36
- You need [Node.js](https://nodejs.org) 22 (22.22.2 or later), 24 (24.15.0 or later), or 26 and later. You also need a GitHub token. If you use the [GitHub CLI](https://cli.github.com), log in with `gh auth login`, and headreel finds the token. For other ways, see [Command options](#command-options).
37
-
38
- To put the banner on your profile, add `headreel.gif` to your profile repository. Then add this line to its `README.md`:
39
-
40
- ```markdown
41
- ![My GitHub activity](headreel.gif)
42
- ```
43
-
44
- ## Keep it updated with the GitHub Action
45
-
46
- A banner that you make on your computer does not change. The Action makes a new banner every day.
47
-
48
- Your profile repository has the same name as your username, for example `octocat/octocat`. Do these steps in that repository.
49
-
50
- 1. Create the file `.github/workflows/headreel.yml` with this content:
21
+ 1. Open your profile repository. This is the public repository with the same name as your username, for example `octocat/octocat`.
22
+ 2. Add `.github/workflows/headreel.yml`:
51
23
 
52
24
  ```yaml
53
25
  name: headreel
@@ -55,7 +27,7 @@ Your profile repository has the same name as your username, for example `octocat
55
27
  on:
56
28
  schedule:
57
29
  - cron: '0 0 * * *' # every day at 00:00 UTC
58
- workflow_dispatch: # lets you run it from the Actions tab
30
+ workflow_dispatch:
59
31
 
60
32
  permissions:
61
33
  contents: write
@@ -68,107 +40,89 @@ Your profile repository has the same name as your username, for example `octocat
68
40
  - uses: arifszn/headreel@v1
69
41
  with:
70
42
  style: contribution-city
71
- tagline: Senior Software Engineer · Distributed Systems
43
+ publish_mode: branch
44
+ tagline: Open source maintainer
72
45
  website: https://example.com
73
46
  ```
74
47
 
75
- 2. Open the **Actions** tab of the repository. Select **headreel**, then select **Run workflow**.
76
- 3. Wait for the run to finish. The workflow adds `headreel.gif` to the repository.
77
- 4. Add this line to your `README.md`:
48
+ 3. Run the workflow once from the **Actions** tab. It adds `headreel.gif` to the `headreel` branch.
49
+ 4. Add the image line to your `README.md`. The workflow run page shows it, ready to copy. It looks like this:
78
50
 
79
51
  ```markdown
80
- ![My GitHub activity](headreel.gif)
52
+ ![My GitHub activity](https://raw.githubusercontent.com/octocat/octocat/headreel/headreel.gif)
81
53
  ```
82
54
 
83
- > [!NOTE]
84
- > The workflow makes a commit only when the banner changes. Your contribution count changes most days, so expect about one commit each day.
55
+ The workflow updates the banner only when it changes. After an update, the new banner can take up to 5 minutes to show. See [Settings](#settings) for all inputs.
85
56
 
86
- ## Action inputs
57
+ ### Keep your repository small
87
58
 
88
- | Input | Default | Description |
89
- | ---------------- | ------------------------------- | ------------------------------------------------------------------ |
90
- | `style` | (required) | The banner style. See [Styles](#styles). |
91
- | `username` | repository owner | The GitHub user to show. |
92
- | `tagline` | empty | One line under your name. Empty means no tagline. |
93
- | `website` | empty | Your website, for example `https://example.com`. Empty means none. |
94
- | `handle` | empty | A handle, for styles that show one. |
95
- | `options` | empty | Style settings, one `key: value` on each line. |
96
- | `output` | `headreel.gif` | The path of the banner in your repository. |
97
- | `commit_to` | the checked-out branch | The branch that gets the banner. |
98
- | `commit_message` | `chore: update headreel banner` | The commit message for each update. |
99
- | `token` | `github.token` | The token that reads your contribution data. |
59
+ The setup above uses `publish_mode: branch`. The banner lives on its own `headreel` branch, and each update replaces the old banner, so your repository does not grow.
100
60
 
101
- Your name comes from your GitHub profile. If your profile has no name, the banner shows your username.
61
+ If you remove `publish_mode: branch`, each update adds a new commit to your main branch. Old banners stay in your history, and each one is a few MB.
102
62
 
103
- ## Styles
63
+ ## Command
104
64
 
105
- ### Contribution City
65
+ Run this command. Replace `octocat` with your GitHub username.
66
+
67
+ ```bash
68
+ npx headreel --style contribution-city --user octocat --tagline "Open source maintainer" --website https://example.com
69
+ ```
70
+
71
+ The command saves `headreel.gif` in the current folder. Add the file to your profile repository, and add `![My GitHub activity](headreel.gif)` to its `README.md`.
106
72
 
107
- `style: contribution-city`
73
+ You need:
108
74
 
109
- Each day of the last 12 months is one building. A taller building means more contributions on that day. A light beam moves across the city, and beacons glow on your busiest days.
75
+ - A GitHub token. headreel uses `--token`, then `GITHUB_TOKEN`, then your [GitHub CLI](https://cli.github.com) login (`gh auth login`).
110
76
 
111
- The banner shows your name, your tagline, your total contributions, and your website.
77
+ ## Settings
78
+
79
+ The Action and the command use the same settings.
80
+
81
+ | Action input | Command flag | Default | Description |
82
+ | ---------------- | ---------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
83
+ | `style` | `--style` | (required) | The banner style. See [Styles](#styles). |
84
+ | `username` | `--user` | repository owner | The GitHub user to show. The command requires it. |
85
+ | `tagline` | `--tagline` | empty | One line under your name. |
86
+ | `website` | `--website` | empty | Your website. |
87
+ | `handle` | `--handle` | empty | A handle, for styles that show one. |
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
+ | `output` | `--out` | `headreel.gif` | The path of the banner. |
90
+ | `token` | `--token` | `github.token` | The token that reads your GitHub data. |
91
+ | `publish_mode` | - | `commit` | `commit` adds a commit on every update. `branch` keeps one commit on its own branch, replaced on every update. |
92
+ | `commit_to` | - | checked-out branch / `headreel` | The branch that gets the banner. The default is `headreel` in `branch` mode. |
93
+ | `commit_message` | - | `chore: update headreel banner` | The commit message. |
94
+ | - | `--config <file>` | - | A JSON file with these settings. Flags replace its values. |
95
+
96
+ Empty settings do not show on the banner. Your name comes from your GitHub profile. If your profile has no name, the banner shows your username.
97
+
98
+ ## Styles
99
+
100
+ ### Contribution City
101
+
102
+ `contribution-city`: each day of the last 12 months is one building. A taller building means more contributions. Beacons glow on your busiest days.
112
103
 
113
104
  | Option | Default | Description |
114
105
  | --------- | ------- | -------------------------------------------------- |
115
106
  | `beacons` | `8` | The number of busiest days with a beacon, 0 to 10. |
116
107
 
117
- Example:
108
+ ### Repo Galaxy
118
109
 
119
- ```yaml
120
- - uses: arifszn/headreel@v1
121
- with:
122
- style: contribution-city
123
- options: |
124
- beacons: 5
125
- ```
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.
126
111
 
127
- ## Command options
128
-
129
- | Option | Description |
130
- | ---------------------- | ----------------------------------------------------- |
131
- | `--style <id>` | The banner style (required). See [Styles](#styles). |
132
- | `--user <login>` | Your GitHub username (required). |
133
- | `--out <file>` | Where to save the GIF. The default is `headreel.gif`. |
134
- | `--tagline <text>` | One line under your name. |
135
- | `--website <url>` | Your website. |
136
- | `--handle <text>` | A handle, for styles that show one. |
137
- | `--option <key=value>` | A style setting. Use it again for each setting. |
138
- | `--config <file>` | A JSON file with your settings. |
139
- | `--token <token>` | A GitHub token. See the token order below. |
140
-
141
- headreel needs a GitHub token to read your contributions. It looks for a token in this order:
142
-
143
- 1. The `--token` option.
144
- 2. The `GITHUB_TOKEN` environment variable.
145
- 3. The [GitHub CLI](https://cli.github.com), if you are logged in with `gh auth login`.
146
-
147
- You can keep your settings in a JSON file:
148
-
149
- ```json
150
- {
151
- "style": "contribution-city",
152
- "user": "octocat",
153
- "tagline": "Open source maintainer",
154
- "website": "https://example.com",
155
- "options": { "beacons": 5 }
156
- }
157
- ```
112
+ ![Repo Galaxy banner](https://raw.githubusercontent.com/arifszn/headreel/main/docs/samples/repo-galaxy.gif)
158
113
 
159
- ```bash
160
- npx headreel --config headreel.json
161
- ```
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. |
162
119
 
163
- Options on the command line replace the values in the file. To see all options, run `npx headreel --help`.
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.
164
121
 
165
122
  ## Troubleshooting
166
123
 
167
- **The workflow fails with "Permission denied" or a 403 error.**
168
- Make sure that the workflow file contains `permissions: contents: write`.
169
-
170
- **The workflow fails with "Could not resolve to a User".**
171
- Check the `username` input. It must be a GitHub username.
172
-
173
- **The command fails with "No GitHub token found".**
174
- Log in with `gh auth login`, or set the `GITHUB_TOKEN` environment variable.
124
+ | Problem | Fix |
125
+ | -------------------------------- | -------------------------------------------------------- |
126
+ | "Permission denied" or 403 error | Add `permissions: contents: write` to the workflow file. |
127
+ | "Could not resolve to a User" | Set `username` to a valid GitHub username. |
128
+ | "No GitHub token found" | Run `gh auth login`, or set `GITHUB_TOKEN`. |
@@ -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,15 +1,24 @@
1
1
  {
2
2
  "name": "headreel",
3
- "version": "1.0.0",
4
- "description": "Animated GitHub profile banner, rendered from your GitHub data. GitHub Action and CLI.",
3
+ "version": "1.2.0",
4
+ "description": "Animated GitHub profile banner made from your GitHub activity, with a GitHub Action or CLI.",
5
5
  "keywords": [
6
6
  "github",
7
- "profile",
7
+ "github-profile",
8
+ "github-profile-readme",
9
+ "profile-readme",
8
10
  "readme",
9
11
  "banner",
10
12
  "gif",
13
+ "animation",
11
14
  "github-action",
12
- "contributions"
15
+ "github-actions",
16
+ "cli",
17
+ "npx",
18
+ "contributions",
19
+ "contribution-graph",
20
+ "generative-art",
21
+ "p5js"
13
22
  ],
14
23
  "homepage": "https://github.com/arifszn/headreel#readme",
15
24
  "bugs": {