headreel 1.4.0 → 1.6.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 +75 -6
- package/dist/core/camera.js +68 -0
- package/dist/core/data/receipt.js +144 -0
- package/dist/core/streaks.js +38 -0
- package/dist/styles/highlights-reel/palette.js +2 -1
- package/dist/styles/highlights-reel/reel.js +2 -10
- package/dist/styles/highlights-reel/sketch.js +10 -51
- package/dist/styles/index.js +4 -0
- package/dist/styles/receipt/index.js +16 -0
- package/dist/styles/receipt/options.js +14 -0
- package/dist/styles/receipt/palette.js +48 -0
- package/dist/styles/receipt/receipt.js +229 -0
- package/dist/styles/receipt/sketch.js +296 -0
- package/dist/styles/trail-profile/index.js +16 -0
- package/dist/styles/trail-profile/options.js +8 -0
- package/dist/styles/trail-profile/palette.js +50 -0
- package/dist/styles/trail-profile/profile.js +231 -0
- package/dist/styles/trail-profile/sketch.js +485 -0
- package/dist/styles/trail-profile/trail.js +278 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://github.com/arifszn/headreel/actions/workflows/ci.yml)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Animated banners for your GitHub profile, made from your GitHub activity.
|
|
9
9
|
|
|
10
10
|

|
|
11
11
|
|
|
@@ -18,8 +18,7 @@ Pick one way to make your banner:
|
|
|
18
18
|
|
|
19
19
|
The Action is on the [GitHub Marketplace](https://github.com/marketplace/actions/headreel).
|
|
20
20
|
|
|
21
|
-
1.
|
|
22
|
-
2. Add `.github/workflows/headreel.yml`:
|
|
21
|
+
1. Add `.github/workflows/headreel.yml` to your repository:
|
|
23
22
|
|
|
24
23
|
```yaml
|
|
25
24
|
name: headreel
|
|
@@ -45,8 +44,8 @@ The Action is on the [GitHub Marketplace](https://github.com/marketplace/actions
|
|
|
45
44
|
website: https://example.com
|
|
46
45
|
```
|
|
47
46
|
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
2. Run the workflow once from the **Actions** tab. It adds `headreel.gif` to the `headreel` branch.
|
|
48
|
+
3. Add the image line to your `README.md`. The workflow run page shows it, ready to copy. It looks like this:
|
|
50
49
|
|
|
51
50
|
```markdown
|
|
52
51
|

|
|
@@ -68,7 +67,7 @@ Run this command. Replace `octocat` with your GitHub username.
|
|
|
68
67
|
npx headreel --style contribution-city --user octocat --tagline "Open source maintainer" --website https://example.com
|
|
69
68
|
```
|
|
70
69
|
|
|
71
|
-
The command saves `headreel.gif` in the current folder. Add the file to your
|
|
70
|
+
The command saves `headreel.gif` in the current folder. Add the file to your repository, and add `` to its `README.md`.
|
|
72
71
|
|
|
73
72
|
You need:
|
|
74
73
|
|
|
@@ -99,6 +98,10 @@ Empty settings do not show on the banner. Your name comes from your GitHub profi
|
|
|
99
98
|
|
|
100
99
|
Set the style with `style: <name>` in the Action, or `--style <name>` in the command. Set an option with `options: 'accent: green'` in the Action, or `--option accent=green` in the command. For more than one option in the Action, put each on its own line.
|
|
101
100
|
|
|
101
|
+
<table>
|
|
102
|
+
<tr>
|
|
103
|
+
<td>
|
|
104
|
+
|
|
102
105
|
### Contribution City
|
|
103
106
|
|
|
104
107
|
`contribution-city`: each day of the last 12 months is one building. A taller building means more contributions. Beacons glow on your busiest days.
|
|
@@ -109,6 +112,14 @@ Set the style with `style: <name>` in the Action, or `--style <name>` in the com
|
|
|
109
112
|
| -------- | ------- | --------------------------------------------------------------------------------------------- |
|
|
110
113
|
| `accent` | `cyan` | Color of the roofs, windows, lights and links: `cyan`, `cobalt`, `green`, `violet` or `pink`. |
|
|
111
114
|
|
|
115
|
+
</td>
|
|
116
|
+
</tr>
|
|
117
|
+
</table>
|
|
118
|
+
|
|
119
|
+
<table>
|
|
120
|
+
<tr>
|
|
121
|
+
<td>
|
|
122
|
+
|
|
112
123
|
### Highlights Reel
|
|
113
124
|
|
|
114
125
|
`highlights-reel`: a few profile facts, each on its own card, filmed by one camera. The camera travels between the cards, pushes into your busiest week, and ends on a wide shot of the whole reel.
|
|
@@ -120,6 +131,14 @@ Set the style with `style: <name>` in the Action, or `--style <name>` in the com
|
|
|
120
131
|
| `theme` | `light` | `light` or `dark`. Use `dark` if your profile is mostly viewed in GitHub dark mode. |
|
|
121
132
|
| `accent` | `cobalt` | Color of the bars, numbers and links: `cobalt`, `green`, `violet`, `orange` or `pink`. |
|
|
122
133
|
|
|
134
|
+
</td>
|
|
135
|
+
</tr>
|
|
136
|
+
</table>
|
|
137
|
+
|
|
138
|
+
<table>
|
|
139
|
+
<tr>
|
|
140
|
+
<td>
|
|
141
|
+
|
|
123
142
|
### Now Playing
|
|
124
143
|
|
|
125
144
|
`now-playing`: a cassette deck playing the repository you committed to most in the last 30 days. The label names its language, your commit messages on it scroll across the deck's display as lyrics, and the level meter shows the month day by day. When nothing was committed in the last 30 days, the deck shows the repository you pushed to last as `LAST PLAYED`.
|
|
@@ -132,6 +151,52 @@ Set the style with `style: <name>` in the Action, or `--style <name>` in the com
|
|
|
132
151
|
| `accent` | `cyan` | Display color, meter and links: `cyan`, `green` or `orange`. |
|
|
133
152
|
| `exclude` | empty | Comma-separated repositories never picked, for example your profile repository: `exclude: octocat`. |
|
|
134
153
|
|
|
154
|
+
</td>
|
|
155
|
+
</tr>
|
|
156
|
+
</table>
|
|
157
|
+
|
|
158
|
+
<table>
|
|
159
|
+
<tr>
|
|
160
|
+
<td>
|
|
161
|
+
|
|
162
|
+
### Receipt
|
|
163
|
+
|
|
164
|
+
`receipt`: a thermal printer feeds out your last 12 months as a till receipt: repositories as items, contribution counts as the totals, streaks and your best day printed below, and a barcode encoding your activity week by week. The paper prints line by line, tears off, and the next receipt starts.
|
|
165
|
+
|
|
166
|
+

|
|
167
|
+
|
|
168
|
+
| Option | Default | Description |
|
|
169
|
+
| -------- | -------- | ------------------------------------------------------------------------------- |
|
|
170
|
+
| `theme` | `dark` | `dark` or `light`. Use `light` if your profile is mostly viewed in light mode. |
|
|
171
|
+
| `accent` | `cobalt` | Prompt, cursor, tagline and LED: `cobalt`, `green`, `violet`, `orange`, `pink`. |
|
|
172
|
+
| `items` | `5` | Repositories printed as line items, `0` to `5`. |
|
|
173
|
+
|
|
174
|
+
</td>
|
|
175
|
+
</tr>
|
|
176
|
+
</table>
|
|
177
|
+
|
|
178
|
+
<table>
|
|
179
|
+
<tr>
|
|
180
|
+
<td>
|
|
181
|
+
|
|
182
|
+
### Trail Profile
|
|
183
|
+
|
|
184
|
+
`trail-profile`: your last 12 months as a hiking trail's elevation profile on printed survey paper. Each week's contributions are the trail's elevation, drawn as a profile line with contour strata and engraved relief below it. A hiker marker walks the trail to your busiest week, the summit, and holds on its flag.
|
|
185
|
+
|
|
186
|
+

|
|
187
|
+
|
|
188
|
+
| Option | Default | Description |
|
|
189
|
+
| -------- | -------- | ---------------------------------------------------------------------------------------------- |
|
|
190
|
+
| `accent` | `sienna` | Profile line, strata and links: `sienna`, `cobalt`, `green` or `violet`. The paper stays buff. |
|
|
191
|
+
|
|
192
|
+
</td>
|
|
193
|
+
</tr>
|
|
194
|
+
</table>
|
|
195
|
+
|
|
196
|
+
<table>
|
|
197
|
+
<tr>
|
|
198
|
+
<td>
|
|
199
|
+
|
|
135
200
|
### Repo Galaxy
|
|
136
201
|
|
|
137
202
|
`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.
|
|
@@ -144,6 +209,10 @@ Set the style with `style: <name>` in the Action, or `--style <name>` in the com
|
|
|
144
209
|
| `include_forks` | `false` | Show forked repositories too. |
|
|
145
210
|
| `labels` | `top3` | `top3` names the three most starred repositories. `none` hides names. |
|
|
146
211
|
|
|
212
|
+
</td>
|
|
213
|
+
</tr>
|
|
214
|
+
</table>
|
|
215
|
+
|
|
147
216
|
## Troubleshooting
|
|
148
217
|
|
|
149
218
|
| Problem | Fix |
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One camera over one world: a key list `{ frame, x, y, z }` interpolated with
|
|
3
|
+
* a single travel curve, so almost all motion happens in the middle third of a
|
|
4
|
+
* move (Highlights Reel's camera, shared with Trail Profile).
|
|
5
|
+
*
|
|
6
|
+
* Zoom travels in log space, so leaving a deep push-in pulls back as fast as it
|
|
7
|
+
* pans. A long move dips its zoom at the midpoint and pushes in to land; short
|
|
8
|
+
* hops stay flat. Held shots are still, and `frameFloat` wraps so `t = 1` snaps
|
|
9
|
+
* bitwise to `t = 0`, closing the loop to the pixel.
|
|
10
|
+
*/
|
|
11
|
+
/** The default travel curve: almost all motion happens in the middle third. */
|
|
12
|
+
export const easeQuartic = (t) => t < 0.5 ? 8 * t ** 4 : 1 - (-2 * t + 2) ** 4 / 2;
|
|
13
|
+
/** A gentler cruise, used for long flights: cubic in-out. */
|
|
14
|
+
export const easeCubic = (t) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);
|
|
15
|
+
const clamp01 = (v) => Math.min(1, Math.max(0, v));
|
|
16
|
+
/** The camera between keys: one curve, with a zoom dip on long moves. */
|
|
17
|
+
export function shotAt(keys, frame, opts) {
|
|
18
|
+
const ease = opts.ease ?? easeQuartic;
|
|
19
|
+
if (frame <= keys[0].frame) {
|
|
20
|
+
return { x: keys[0].x, y: keys[0].y, z: keys[0].z };
|
|
21
|
+
}
|
|
22
|
+
const last = keys[keys.length - 1];
|
|
23
|
+
if (frame >= last.frame) {
|
|
24
|
+
return { x: last.x, y: last.y, z: last.z };
|
|
25
|
+
}
|
|
26
|
+
let index = 0;
|
|
27
|
+
while (index < keys.length - 2 && keys[index + 1].frame <= frame) {
|
|
28
|
+
index++;
|
|
29
|
+
}
|
|
30
|
+
const from = keys[index];
|
|
31
|
+
const to = keys[index + 1];
|
|
32
|
+
const t = clamp01((frame - from.frame) / (to.frame - from.frame));
|
|
33
|
+
const travelled = ease(t);
|
|
34
|
+
const distance = Math.hypot(to.x - from.x, to.y - from.y);
|
|
35
|
+
const dip = distance > opts.travel
|
|
36
|
+
? 1 - 0.22 * Math.sin(Math.PI * t) * Math.min(1, distance / opts.span)
|
|
37
|
+
: 1;
|
|
38
|
+
return {
|
|
39
|
+
x: from.x + (to.x - from.x) * travelled,
|
|
40
|
+
y: from.y + (to.y - from.y) * travelled,
|
|
41
|
+
z: from.z * (to.z / from.z) ** travelled * dip,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The camera at a moment of the loop. Held shots are still: any drift, even in
|
|
46
|
+
* whole-pixel steps, reads as the scene stepping or the type vibrating.
|
|
47
|
+
*/
|
|
48
|
+
export function cameraAt(keys, frameFloat, frames, opts) {
|
|
49
|
+
// t = 1 snaps to 0, so the last frame is bit-identical to the first.
|
|
50
|
+
const t = frameFloat >= frames ? 0 : (((frameFloat / frames) % 1) + 1) % 1;
|
|
51
|
+
return shotAt(keys, t * frames, opts);
|
|
52
|
+
}
|
|
53
|
+
/** How fast the frame is moving, in screen pixels, zoom included. */
|
|
54
|
+
export function speedAt(keys, frameFloat, frames, opts) {
|
|
55
|
+
const now = cameraAt(keys, frameFloat, frames, opts);
|
|
56
|
+
const before = cameraAt(keys, frameFloat - 1, frames, opts);
|
|
57
|
+
return ((Math.hypot(now.x - before.x, now.y - before.y) + Math.abs(now.z - before.z) * 700) * now.z);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Applies the world transform, snapped to whole screen pixels. At a fixed zoom
|
|
61
|
+
* the drift then moves the scene in 1 px steps instead of resampling every
|
|
62
|
+
* glyph each frame at a new sub-pixel offset, which reads as vibrating type.
|
|
63
|
+
* `(cx, cy)` is the viewport's centre in screen space.
|
|
64
|
+
*/
|
|
65
|
+
export function applyCamera(ctx, cam, cx, cy) {
|
|
66
|
+
ctx.translate(Math.round(cx - cam.x * cam.z), Math.round(cy - cam.y * cam.z));
|
|
67
|
+
ctx.scale(cam.z, cam.z);
|
|
68
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { contributionWindow } from './contributions.js';
|
|
3
|
+
// One query for the whole receipt: the 365-day calendar (TOTAL, the streaks,
|
|
4
|
+
// the best day and the barcode), the per-type contribution counts, and the
|
|
5
|
+
// repositories the user committed to (the line items). Field names checked
|
|
6
|
+
// against the live GraphQL schema (2026-09-19).
|
|
7
|
+
const QUERY = /* GraphQL */ `
|
|
8
|
+
query Receipt($login: String!, $from: DateTime!, $to: DateTime!) {
|
|
9
|
+
user(login: $login) {
|
|
10
|
+
contributionsCollection(from: $from, to: $to) {
|
|
11
|
+
contributionCalendar {
|
|
12
|
+
totalContributions
|
|
13
|
+
weeks {
|
|
14
|
+
contributionDays {
|
|
15
|
+
date
|
|
16
|
+
contributionCount
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
totalCommitContributions
|
|
21
|
+
totalPullRequestContributions
|
|
22
|
+
totalPullRequestReviewContributions
|
|
23
|
+
totalIssueContributions
|
|
24
|
+
totalRepositoryContributions
|
|
25
|
+
restrictedContributionsCount
|
|
26
|
+
commitContributionsByRepository(maxRepositories: 25) {
|
|
27
|
+
repository {
|
|
28
|
+
name
|
|
29
|
+
owner {
|
|
30
|
+
login
|
|
31
|
+
}
|
|
32
|
+
isPrivate
|
|
33
|
+
primaryLanguage {
|
|
34
|
+
name
|
|
35
|
+
color
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
contributions {
|
|
39
|
+
totalCount
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
`;
|
|
46
|
+
/** Line items printed, at most. */
|
|
47
|
+
export const MAX_ITEMS = 5;
|
|
48
|
+
/** Days in the contribution window: 364 back plus the run date. */
|
|
49
|
+
export const WINDOW_DAYS = 365;
|
|
50
|
+
const languageSchema = z
|
|
51
|
+
.object({
|
|
52
|
+
name: z.string().min(1),
|
|
53
|
+
/** Linguist color; null when the repo has no language or color. */
|
|
54
|
+
color: z
|
|
55
|
+
.string()
|
|
56
|
+
.regex(/^#[0-9a-fA-F]{6}$/)
|
|
57
|
+
.nullable(),
|
|
58
|
+
})
|
|
59
|
+
.nullable();
|
|
60
|
+
export const receiptSchema = z.object({
|
|
61
|
+
/** Fetch date (= window end), YYYY-MM-DD (UTC). */
|
|
62
|
+
asOf: z.iso.date(),
|
|
63
|
+
from: z.iso.date(),
|
|
64
|
+
to: z.iso.date(),
|
|
65
|
+
/** The calendar's total, the number the profile shows. */
|
|
66
|
+
total: z.number().int().min(0),
|
|
67
|
+
commits: z.number().int().min(0),
|
|
68
|
+
pullRequests: z.number().int().min(0),
|
|
69
|
+
reviews: z.number().int().min(0),
|
|
70
|
+
issues: z.number().int().min(0),
|
|
71
|
+
newRepos: z.number().int().min(0),
|
|
72
|
+
/** Private contributions the token could see, without any repository named. */
|
|
73
|
+
restricted: z.number().int().min(0),
|
|
74
|
+
/** Public repositories committed to, most commits first. */
|
|
75
|
+
items: z
|
|
76
|
+
.array(z.object({
|
|
77
|
+
/** A repository the user does not own prints as `owner/name`. */
|
|
78
|
+
name: z.string().min(1),
|
|
79
|
+
language: languageSchema,
|
|
80
|
+
commits: z.number().int().min(1),
|
|
81
|
+
}))
|
|
82
|
+
.max(MAX_ITEMS),
|
|
83
|
+
/** Commits summed over the private repositories, never named. */
|
|
84
|
+
privateCommits: z.number().int().min(0),
|
|
85
|
+
/** Daily counts over the window, oldest first. */
|
|
86
|
+
days: z.array(z.number().int().min(0)).length(WINDOW_DAYS),
|
|
87
|
+
});
|
|
88
|
+
/**
|
|
89
|
+
* The line items: public repositories by commits in the window, ties by name.
|
|
90
|
+
* Private repositories are dropped here, before ranking; their commits are
|
|
91
|
+
* summed into `privateCommits` and never named.
|
|
92
|
+
*/
|
|
93
|
+
export function selectItems(entries, login, cap = MAX_ITEMS) {
|
|
94
|
+
const items = [];
|
|
95
|
+
let privateCommits = 0;
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
const { repository } = entry;
|
|
98
|
+
if (repository.isPrivate) {
|
|
99
|
+
privateCommits += entry.contributions.totalCount;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
items.push({
|
|
103
|
+
name: repository.owner.login === login
|
|
104
|
+
? repository.name
|
|
105
|
+
: `${repository.owner.login}/${repository.name}`,
|
|
106
|
+
language: repository.primaryLanguage
|
|
107
|
+
? { name: repository.primaryLanguage.name, color: repository.primaryLanguage.color }
|
|
108
|
+
: null,
|
|
109
|
+
commits: entry.contributions.totalCount,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
items.sort((a, b) => b.commits - a.commits || a.name.localeCompare(b.name));
|
|
113
|
+
return { items: items.slice(0, cap), privateCommits };
|
|
114
|
+
}
|
|
115
|
+
export async function fetchReceipt(client, login, now) {
|
|
116
|
+
const { from, to } = contributionWindow(now);
|
|
117
|
+
const { user } = await client(QUERY, {
|
|
118
|
+
login,
|
|
119
|
+
from: `${from}T00:00:00Z`,
|
|
120
|
+
to: `${to}T23:59:59Z`,
|
|
121
|
+
});
|
|
122
|
+
const cc = user.contributionsCollection;
|
|
123
|
+
// Calendar weeks are padded to full weeks; keep only the window's days.
|
|
124
|
+
const days = cc.contributionCalendar.weeks
|
|
125
|
+
.flatMap((week) => week.contributionDays)
|
|
126
|
+
.filter((day) => day.date >= from && day.date <= to)
|
|
127
|
+
.map((day) => day.contributionCount);
|
|
128
|
+
const { items, privateCommits } = selectItems(cc.commitContributionsByRepository, login);
|
|
129
|
+
return {
|
|
130
|
+
asOf: to,
|
|
131
|
+
from,
|
|
132
|
+
to,
|
|
133
|
+
total: cc.contributionCalendar.totalContributions,
|
|
134
|
+
commits: cc.totalCommitContributions,
|
|
135
|
+
pullRequests: cc.totalPullRequestContributions,
|
|
136
|
+
reviews: cc.totalPullRequestReviewContributions,
|
|
137
|
+
issues: cc.totalIssueContributions,
|
|
138
|
+
newRepos: cc.totalRepositoryContributions,
|
|
139
|
+
restricted: cc.restrictedContributionsCount,
|
|
140
|
+
items,
|
|
141
|
+
privateCommits,
|
|
142
|
+
days,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaks and peaks over a flat array of daily contribution counts, oldest
|
|
3
|
+
* first. Shared by Highlights Reel and Receipt.
|
|
4
|
+
*/
|
|
5
|
+
/** Longest run of consecutive days with at least one contribution. */
|
|
6
|
+
export function longestStreak(days) {
|
|
7
|
+
let best = 0;
|
|
8
|
+
let run = 0;
|
|
9
|
+
for (const count of days) {
|
|
10
|
+
run = count > 0 ? run + 1 : 0;
|
|
11
|
+
if (run > best)
|
|
12
|
+
best = run;
|
|
13
|
+
}
|
|
14
|
+
return best;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Consecutive active days ending on the last day, or on the day before it
|
|
18
|
+
* when the last day is still empty: a scheduled run often happens before the
|
|
19
|
+
* day's work.
|
|
20
|
+
*/
|
|
21
|
+
export function currentStreak(days) {
|
|
22
|
+
let end = days.length - 1;
|
|
23
|
+
if (days[end] <= 0)
|
|
24
|
+
end--;
|
|
25
|
+
let run = 0;
|
|
26
|
+
for (let i = end; i >= 0 && days[i] > 0; i--)
|
|
27
|
+
run++;
|
|
28
|
+
return run;
|
|
29
|
+
}
|
|
30
|
+
/** The day with the highest count, or null when every day is empty. */
|
|
31
|
+
export function bestDay(days) {
|
|
32
|
+
let best = null;
|
|
33
|
+
days.forEach((count, index) => {
|
|
34
|
+
if (count > 0 && (best === null || count >= best.count))
|
|
35
|
+
best = { index, count };
|
|
36
|
+
});
|
|
37
|
+
return best;
|
|
38
|
+
}
|
|
@@ -2,8 +2,9 @@ const AMBER = [232, 163, 61];
|
|
|
2
2
|
/**
|
|
3
3
|
* Accent presets, [light, dark]. Each passes 4.5:1 against its theme's desk,
|
|
4
4
|
* the lowest background its small type sits on (see SPEC, Highlights Reel).
|
|
5
|
+
* Receipt shares them: its counter is this desk.
|
|
5
6
|
*/
|
|
6
|
-
const ACCENT_VALUES = {
|
|
7
|
+
export const ACCENT_VALUES = {
|
|
7
8
|
cobalt: [
|
|
8
9
|
[53, 88, 232],
|
|
9
10
|
[123, 147, 255],
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { longestStreak as coreLongestStreak } from '../../core/streaks.js';
|
|
1
2
|
import { paletteOf } from './palette.js';
|
|
2
3
|
/**
|
|
3
4
|
* Where the reel lives: four cards on one desk, filmed by one camera.
|
|
@@ -54,16 +55,7 @@ const NEUTRAL = { light: [102, 106, 124], dark: [140, 146, 168] };
|
|
|
54
55
|
const CARD_ORDER = ['contributions', 'top_repo', 'pull_requests', 'languages'];
|
|
55
56
|
/** Longest run of consecutive days with at least one contribution. */
|
|
56
57
|
export function longestStreak(contributions) {
|
|
57
|
-
|
|
58
|
-
let run = 0;
|
|
59
|
-
for (const week of contributions.weeks) {
|
|
60
|
-
for (const day of week) {
|
|
61
|
-
run = day.count > 0 ? run + 1 : 0;
|
|
62
|
-
if (run > best)
|
|
63
|
-
best = run;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return best;
|
|
58
|
+
return coreLongestStreak(contributions.weeks.flat().map((day) => day.count));
|
|
67
59
|
}
|
|
68
60
|
/** "2026-03-28" .. "2026-04-03" -> "Mar 28 to Apr 3". */
|
|
69
61
|
function dateRange(from, to) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { applyCamera, cameraAt, speedAt } from '../../core/camera.js';
|
|
1
2
|
import { fitIdentity } from '../../core/text.js';
|
|
2
3
|
import { FRAMES, LAYOUT, plateAt, stationOf, } from './reel.js';
|
|
3
4
|
/**
|
|
@@ -26,8 +27,8 @@ const TAU = Math.PI * 2;
|
|
|
26
27
|
export const IDENTITY_COLUMN = 440 - 48;
|
|
27
28
|
const clamp01 = (v) => Math.min(1, Math.max(0, v));
|
|
28
29
|
const easeOutCubic = (t) => 1 - (1 - t) ** 3;
|
|
29
|
-
/**
|
|
30
|
-
const
|
|
30
|
+
/** Camera tuning: moves longer than `travel` dip their zoom, scaled to `span`. */
|
|
31
|
+
const CAMERA = { travel: LAYOUT.travel, span: 2400 };
|
|
31
32
|
function smoothstep(e0, e1, x) {
|
|
32
33
|
const t = clamp01((x - e0) / (e1 - e0));
|
|
33
34
|
return t * t * (3 - 2 * t);
|
|
@@ -88,51 +89,10 @@ export function createReelSketch(reel, identity) {
|
|
|
88
89
|
website: identity.website && `↗ ${displayUrl(identity.website)}`,
|
|
89
90
|
}, IDENTITY_COLUMN);
|
|
90
91
|
// --- camera ---------------------------------------------------------------
|
|
91
|
-
/** The camera
|
|
92
|
-
|
|
93
|
-
const keys = reel.plan.keys;
|
|
94
|
-
if (frame <= keys[0].frame) {
|
|
95
|
-
return { x: keys[0].x, y: keys[0].y, z: keys[0].z };
|
|
96
|
-
}
|
|
97
|
-
const last = keys[keys.length - 1];
|
|
98
|
-
if (frame >= last.frame) {
|
|
99
|
-
return { x: last.x, y: last.y, z: last.z };
|
|
100
|
-
}
|
|
101
|
-
let index = 0;
|
|
102
|
-
while (index < keys.length - 2 && keys[index + 1].frame <= frame) {
|
|
103
|
-
index++;
|
|
104
|
-
}
|
|
105
|
-
const from = keys[index];
|
|
106
|
-
const to = keys[index + 1];
|
|
107
|
-
const t = clamp01((frame - from.frame) / (to.frame - from.frame));
|
|
108
|
-
const travelled = travelEase(t);
|
|
109
|
-
const distance = Math.hypot(to.x - from.x, to.y - from.y);
|
|
110
|
-
// Zoom travels in log space, so leaving a deep push-in pulls back as fast
|
|
111
|
-
// as it pans. A long move also dips at its midpoint and pushes in to land.
|
|
112
|
-
const dip = distance > LAYOUT.travel
|
|
113
|
-
? 1 - 0.22 * Math.sin(Math.PI * t) * Math.min(1, distance / 2400)
|
|
114
|
-
: 1;
|
|
115
|
-
return {
|
|
116
|
-
x: from.x + (to.x - from.x) * travelled,
|
|
117
|
-
y: from.y + (to.y - from.y) * travelled,
|
|
118
|
-
z: from.z * (to.z / from.z) ** travelled * dip,
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* The camera at a moment of the loop. Held shots are still: any drift, even
|
|
123
|
-
* in whole-pixel steps, reads as the cards stepping or the type vibrating.
|
|
124
|
-
*/
|
|
125
|
-
function camera(frameFloat) {
|
|
126
|
-
// t = 1 snaps to 0, so the last frame is bit-identical to the first.
|
|
127
|
-
const t = frameFloat >= FRAMES ? 0 : (((frameFloat / FRAMES) % 1) + 1) % 1;
|
|
128
|
-
return rawShot(t * FRAMES);
|
|
129
|
-
}
|
|
92
|
+
/** The camera at a moment of the loop (see `core/camera.ts`). */
|
|
93
|
+
const camera = (frameFloat) => cameraAt(reel.plan.keys, frameFloat, FRAMES, CAMERA);
|
|
130
94
|
/** How fast the frame is moving, in screen pixels, zoom included. */
|
|
131
|
-
|
|
132
|
-
const now = camera(frameFloat);
|
|
133
|
-
const before = camera(frameFloat - 1);
|
|
134
|
-
return ((Math.hypot(now.x - before.x, now.y - before.y) + Math.abs(now.z - before.z) * 700) * now.z);
|
|
135
|
-
}
|
|
95
|
+
const speedOf = (frameFloat) => speedAt(reel.plan.keys, frameFloat, FRAMES, CAMERA);
|
|
136
96
|
/** A card builds while the camera arrives; an empty window means already finished. */
|
|
137
97
|
function buildAt(window, frameFloat) {
|
|
138
98
|
if (window.end <= window.start)
|
|
@@ -535,11 +495,10 @@ export function createReelSketch(reel, identity) {
|
|
|
535
495
|
setFill(ctx, PALETTE.desk);
|
|
536
496
|
ctx.fillRect(0, 0, W, H);
|
|
537
497
|
ctx.save();
|
|
538
|
-
// Snap the world to whole screen pixels
|
|
539
|
-
// moves the cards in 1 px steps instead of resampling
|
|
540
|
-
// frame at a new sub-pixel offset
|
|
541
|
-
ctx
|
|
542
|
-
ctx.scale(cam.z, cam.z);
|
|
498
|
+
// Snap the world to whole screen pixels (see `applyCamera`): at a fixed
|
|
499
|
+
// zoom the drift then moves the cards in 1 px steps instead of resampling
|
|
500
|
+
// every glyph each frame at a new sub-pixel offset.
|
|
501
|
+
applyCamera(ctx, cam, W / 2, H / 2);
|
|
543
502
|
drawDots(ctx, cam, speed);
|
|
544
503
|
for (let i = 0; i < reel.cards.length; i++)
|
|
545
504
|
drawMarks(ctx, i);
|
package/dist/styles/index.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { contributionCity } from './contribution-city/index.js';
|
|
2
2
|
import { highlightsReel } from './highlights-reel/index.js';
|
|
3
3
|
import { nowPlaying } from './now-playing/index.js';
|
|
4
|
+
import { receipt } from './receipt/index.js';
|
|
4
5
|
import { repoGalaxy } from './repo-galaxy/index.js';
|
|
6
|
+
import { trailProfile } from './trail-profile/index.js';
|
|
5
7
|
/** Built-in styles, keyed by id. */
|
|
6
8
|
export const styles = {
|
|
7
9
|
[contributionCity.id]: contributionCity,
|
|
8
10
|
[highlightsReel.id]: highlightsReel,
|
|
9
11
|
[nowPlaying.id]: nowPlaying,
|
|
12
|
+
[receipt.id]: receipt,
|
|
10
13
|
[repoGalaxy.id]: repoGalaxy,
|
|
14
|
+
[trailProfile.id]: trailProfile,
|
|
11
15
|
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { fetchReceipt, receiptSchema } from '../../core/data/receipt.js';
|
|
2
|
+
import { buildReceipt, FRAMES } from './receipt.js';
|
|
3
|
+
import { options } from './options.js';
|
|
4
|
+
import { paletteOf, pinnedOf } from './palette.js';
|
|
5
|
+
import { createReceiptSketch } from './sketch.js';
|
|
6
|
+
export const receipt = {
|
|
7
|
+
id: 'receipt',
|
|
8
|
+
fps: 25,
|
|
9
|
+
frames: FRAMES,
|
|
10
|
+
pinned: (options) => pinnedOf(paletteOf(options)),
|
|
11
|
+
data: { name: 'receipt', schema: receiptSchema, fetch: fetchReceipt },
|
|
12
|
+
options,
|
|
13
|
+
createSketch({ data, login, options, identity, rng }) {
|
|
14
|
+
return createReceiptSketch(buildReceipt(data, login, identity, options, paletteOf(options), rng), identity);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { MAX_ITEMS } from '../../core/data/receipt.js';
|
|
3
|
+
export const THEMES = ['dark', 'light'];
|
|
4
|
+
export const ACCENTS = ['cobalt', 'green', 'violet', 'orange', 'pink'];
|
|
5
|
+
export const options = z
|
|
6
|
+
.object({
|
|
7
|
+
/** The counter and the printer: dark slate, or the light desk. */
|
|
8
|
+
theme: z.enum(THEMES).default('dark'),
|
|
9
|
+
/** Prompt, cursor, tagline, website and the printer LED, as Highlights Reel. */
|
|
10
|
+
accent: z.enum(ACCENTS).default('cobalt'),
|
|
11
|
+
/** Line items printed, most commits first. 0 prints the totals only. */
|
|
12
|
+
items: z.coerce.number().int().min(0).max(MAX_ITEMS).default(MAX_ITEMS),
|
|
13
|
+
})
|
|
14
|
+
.strict();
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ACCENT_VALUES } from '../highlights-reel/palette.js';
|
|
2
|
+
const PAPER = {
|
|
3
|
+
paper: [250, 249, 245],
|
|
4
|
+
ink: [30, 30, 34],
|
|
5
|
+
fadedInk: [106, 106, 112],
|
|
6
|
+
};
|
|
7
|
+
const BASE = {
|
|
8
|
+
dark: {
|
|
9
|
+
...PAPER,
|
|
10
|
+
counter: [13, 15, 23],
|
|
11
|
+
body: [28, 31, 41],
|
|
12
|
+
slot: [5, 6, 10],
|
|
13
|
+
bevel: [58, 61, 74],
|
|
14
|
+
identityInk: [238, 240, 246],
|
|
15
|
+
identityMuted: [140, 146, 168],
|
|
16
|
+
paperBorder: null,
|
|
17
|
+
shadow: 0,
|
|
18
|
+
},
|
|
19
|
+
light: {
|
|
20
|
+
...PAPER,
|
|
21
|
+
counter: [236, 237, 241],
|
|
22
|
+
body: [201, 204, 211],
|
|
23
|
+
slot: [42, 45, 53],
|
|
24
|
+
bevel: [246, 247, 249],
|
|
25
|
+
identityInk: [11, 12, 20],
|
|
26
|
+
identityMuted: [102, 106, 124],
|
|
27
|
+
paperBorder: [11, 12, 20],
|
|
28
|
+
shadow: 0.09,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
export function paletteOf(options) {
|
|
32
|
+
const [light, dark] = ACCENT_VALUES[options.accent];
|
|
33
|
+
return {
|
|
34
|
+
...BASE[options.theme],
|
|
35
|
+
accent: options.theme === 'light' ? light : dark,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Flat colors the encoder keeps exact and undithered. */
|
|
39
|
+
export function pinnedOf(palette) {
|
|
40
|
+
return [
|
|
41
|
+
palette.counter,
|
|
42
|
+
palette.paper,
|
|
43
|
+
palette.ink,
|
|
44
|
+
palette.fadedInk,
|
|
45
|
+
palette.body,
|
|
46
|
+
palette.accent,
|
|
47
|
+
];
|
|
48
|
+
}
|