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 +69 -115
- package/dist/core/data/repos.js +87 -0
- package/dist/core/pipeline.js +7 -1
- package/dist/styles/contribution-city/index.js +1 -1
- package/dist/styles/index.js +2 -0
- package/dist/styles/repo-galaxy/galaxy.js +149 -0
- package/dist/styles/repo-galaxy/index.js +14 -0
- package/dist/styles/repo-galaxy/options.js +12 -0
- package/dist/styles/repo-galaxy/sketch.js +407 -0
- package/package.json +13 -4
package/README.md
CHANGED
|
@@ -1,53 +1,25 @@
|
|
|
1
1
|
# headreel
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/headreel)
|
|
4
|
+
[](https://github.com/marketplace/actions/headreel)
|
|
4
5
|
[](https://github.com/arifszn/headreel/actions/workflows/ci.yml)
|
|
5
6
|
[](LICENSE)
|
|
6
7
|
|
|
7
|
-
An animated banner for your GitHub profile, made from your
|
|
8
|
+
An animated banner for your GitHub profile, made from your GitHub activity.
|
|
8
9
|
|
|
9
10
|

|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
Pick one way to make your banner:
|
|
12
13
|
|
|
13
|
-
-
|
|
14
|
-
-
|
|
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
|
-
|
|
17
|
+
## GitHub Action
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
The Action is on the [GitHub Marketplace](https://github.com/marketplace/actions/headreel).
|
|
19
20
|
|
|
20
|
-
|
|
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
|
-

|
|
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:
|
|
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
|
-
|
|
43
|
+
publish_mode: branch
|
|
44
|
+
tagline: Open source maintainer
|
|
72
45
|
website: https://example.com
|
|
73
46
|
```
|
|
74
47
|
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-

|
|
52
|
+

|
|
81
53
|
```
|
|
82
54
|
|
|
83
|
-
|
|
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
|
-
|
|
57
|
+
### Keep your repository small
|
|
87
58
|
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
63
|
+
## Command
|
|
104
64
|
|
|
105
|
-
|
|
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 `` to its `README.md`.
|
|
106
72
|
|
|
107
|
-
|
|
73
|
+
You need:
|
|
108
74
|
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
+
### Repo Galaxy
|
|
118
109
|
|
|
119
|
-
|
|
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
|
-
|
|
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
|
+

|
|
158
113
|
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
+
}
|
package/dist/core/pipeline.js
CHANGED
|
@@ -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({
|
|
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);
|
package/dist/styles/index.js
CHANGED
|
@@ -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.
|
|
4
|
-
"description": "Animated GitHub profile banner
|
|
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
|
-
"
|
|
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": {
|