headreel 1.4.0 → 1.5.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 +52 -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/index.js +2 -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/package.json +1 -1
package/README.md
CHANGED
|
@@ -99,6 +99,10 @@ Empty settings do not show on the banner. Your name comes from your GitHub profi
|
|
|
99
99
|
|
|
100
100
|
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
101
|
|
|
102
|
+
<table>
|
|
103
|
+
<tr>
|
|
104
|
+
<td>
|
|
105
|
+
|
|
102
106
|
### Contribution City
|
|
103
107
|
|
|
104
108
|
`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 +113,14 @@ Set the style with `style: <name>` in the Action, or `--style <name>` in the com
|
|
|
109
113
|
| -------- | ------- | --------------------------------------------------------------------------------------------- |
|
|
110
114
|
| `accent` | `cyan` | Color of the roofs, windows, lights and links: `cyan`, `cobalt`, `green`, `violet` or `pink`. |
|
|
111
115
|
|
|
116
|
+
</td>
|
|
117
|
+
</tr>
|
|
118
|
+
</table>
|
|
119
|
+
|
|
120
|
+
<table>
|
|
121
|
+
<tr>
|
|
122
|
+
<td>
|
|
123
|
+
|
|
112
124
|
### Highlights Reel
|
|
113
125
|
|
|
114
126
|
`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 +132,14 @@ Set the style with `style: <name>` in the Action, or `--style <name>` in the com
|
|
|
120
132
|
| `theme` | `light` | `light` or `dark`. Use `dark` if your profile is mostly viewed in GitHub dark mode. |
|
|
121
133
|
| `accent` | `cobalt` | Color of the bars, numbers and links: `cobalt`, `green`, `violet`, `orange` or `pink`. |
|
|
122
134
|
|
|
135
|
+
</td>
|
|
136
|
+
</tr>
|
|
137
|
+
</table>
|
|
138
|
+
|
|
139
|
+
<table>
|
|
140
|
+
<tr>
|
|
141
|
+
<td>
|
|
142
|
+
|
|
123
143
|
### Now Playing
|
|
124
144
|
|
|
125
145
|
`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 +152,34 @@ Set the style with `style: <name>` in the Action, or `--style <name>` in the com
|
|
|
132
152
|
| `accent` | `cyan` | Display color, meter and links: `cyan`, `green` or `orange`. |
|
|
133
153
|
| `exclude` | empty | Comma-separated repositories never picked, for example your profile repository: `exclude: octocat`. |
|
|
134
154
|
|
|
155
|
+
</td>
|
|
156
|
+
</tr>
|
|
157
|
+
</table>
|
|
158
|
+
|
|
159
|
+
<table>
|
|
160
|
+
<tr>
|
|
161
|
+
<td>
|
|
162
|
+
|
|
163
|
+
### Receipt
|
|
164
|
+
|
|
165
|
+
`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.
|
|
166
|
+
|
|
167
|
+

|
|
168
|
+
|
|
169
|
+
| Option | Default | Description |
|
|
170
|
+
| -------- | -------- | ------------------------------------------------------------------------------- |
|
|
171
|
+
| `theme` | `dark` | `dark` or `light`. Use `light` if your profile is mostly viewed in light mode. |
|
|
172
|
+
| `accent` | `cobalt` | Prompt, cursor, tagline and LED: `cobalt`, `green`, `violet`, `orange`, `pink`. |
|
|
173
|
+
| `items` | `5` | Repositories printed as line items, `0` to `5`. |
|
|
174
|
+
|
|
175
|
+
</td>
|
|
176
|
+
</tr>
|
|
177
|
+
</table>
|
|
178
|
+
|
|
179
|
+
<table>
|
|
180
|
+
<tr>
|
|
181
|
+
<td>
|
|
182
|
+
|
|
135
183
|
### Repo Galaxy
|
|
136
184
|
|
|
137
185
|
`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 +192,10 @@ Set the style with `style: <name>` in the Action, or `--style <name>` in the com
|
|
|
144
192
|
| `include_forks` | `false` | Show forked repositories too. |
|
|
145
193
|
| `labels` | `top3` | `top3` names the three most starred repositories. `none` hides names. |
|
|
146
194
|
|
|
195
|
+
</td>
|
|
196
|
+
</tr>
|
|
197
|
+
</table>
|
|
198
|
+
|
|
147
199
|
## Troubleshooting
|
|
148
200
|
|
|
149
201
|
| Problem | Fix |
|
|
@@ -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) {
|
package/dist/styles/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
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';
|
|
5
6
|
/** Built-in styles, keyed by id. */
|
|
6
7
|
export const styles = {
|
|
7
8
|
[contributionCity.id]: contributionCity,
|
|
8
9
|
[highlightsReel.id]: highlightsReel,
|
|
9
10
|
[nowPlaying.id]: nowPlaying,
|
|
11
|
+
[receipt.id]: receipt,
|
|
10
12
|
[repoGalaxy.id]: repoGalaxy,
|
|
11
13
|
};
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { bestDay, currentStreak, longestStreak } from '../../core/streaks.js';
|
|
2
|
+
import { canvasMeasure, fitLine } from '../../core/text.js';
|
|
3
|
+
/**
|
|
4
|
+
* What the printer prints. The whole receipt is one list of text lines plus a
|
|
5
|
+
* tail (barcode, footer, tear line); the sketch rasterizes them into one
|
|
6
|
+
* offscreen strip and feeds it out of the slot. Everything here is a pure
|
|
7
|
+
* function of the data.
|
|
8
|
+
*/
|
|
9
|
+
export const FRAMES = 400;
|
|
10
|
+
/** Paper geometry, px: a 58 mm thermal printer prints 384 dots per line. */
|
|
11
|
+
export const PAPER = {
|
|
12
|
+
width: 384,
|
|
13
|
+
/** Side margins; the 32-column text grid lives between them. */
|
|
14
|
+
margin: 16,
|
|
15
|
+
text: 352,
|
|
16
|
+
size: 18,
|
|
17
|
+
line: 26,
|
|
18
|
+
/** Barcode bars, full height. */
|
|
19
|
+
bars: 48,
|
|
20
|
+
};
|
|
21
|
+
/** The tail: everything after the last text line, to the next header. */
|
|
22
|
+
export const TAIL = {
|
|
23
|
+
gapAboveBars: 10,
|
|
24
|
+
gapBelowBars: 12,
|
|
25
|
+
footer: PAPER.line,
|
|
26
|
+
gapBelowFooter: 8,
|
|
27
|
+
tear: PAPER.line,
|
|
28
|
+
/** Paper between the tear line and the next receipt's header. */
|
|
29
|
+
leader: 18,
|
|
30
|
+
};
|
|
31
|
+
/** Step, pause and hold weights, in frames, normalized to `FRAMES` below. */
|
|
32
|
+
const WEIGHTS = { hold: 40, step: 3, pause: 5 };
|
|
33
|
+
/** Constant feed speed of the barcode tail, px per frame. */
|
|
34
|
+
const FEED_SPEED = 2;
|
|
35
|
+
const easeInOut = (t) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);
|
|
36
|
+
/**
|
|
37
|
+
* The feed timeline: a hold, then one eased step per text line with a pause
|
|
38
|
+
* after each, then the tail at constant speed. Weights are normalized to the
|
|
39
|
+
* loop, so a short receipt feeds more slowly over the same 16 s.
|
|
40
|
+
*/
|
|
41
|
+
export function buildFeedPlan(textHeights, tail) {
|
|
42
|
+
const raw = [
|
|
43
|
+
{ w: WEIGHTS.hold, dist: 0, kind: 'hold' },
|
|
44
|
+
];
|
|
45
|
+
for (const h of textHeights) {
|
|
46
|
+
raw.push({ w: WEIGHTS.step, dist: h, kind: 'step' });
|
|
47
|
+
raw.push({ w: WEIGHTS.pause, dist: 0, kind: 'pause' });
|
|
48
|
+
}
|
|
49
|
+
raw.push({ w: tail / FEED_SPEED, dist: tail, kind: 'feed' });
|
|
50
|
+
const scale = FRAMES / raw.reduce((sum, g) => sum + g.w, 0);
|
|
51
|
+
const segments = [];
|
|
52
|
+
let at = 0;
|
|
53
|
+
let length = 0;
|
|
54
|
+
for (const g of raw) {
|
|
55
|
+
const end = at + g.w * scale;
|
|
56
|
+
segments.push({ start: at, end, dist: g.dist, kind: g.kind });
|
|
57
|
+
length += g.dist;
|
|
58
|
+
at = end;
|
|
59
|
+
}
|
|
60
|
+
return { length, segments };
|
|
61
|
+
}
|
|
62
|
+
/** Where the paper is at `t`, and whether the printer's LED is on. */
|
|
63
|
+
export function feedAt(plan, t) {
|
|
64
|
+
const pos = Math.min(Math.max(t, 0), 1) * FRAMES;
|
|
65
|
+
let dist = 0;
|
|
66
|
+
for (const seg of plan.segments) {
|
|
67
|
+
const last = seg === plan.segments.at(-1);
|
|
68
|
+
if (pos < seg.end || last) {
|
|
69
|
+
const u = Math.min(1, Math.max(0, (pos - seg.start) / (seg.end - seg.start)));
|
|
70
|
+
dist += seg.kind === 'step' ? seg.dist * easeInOut(u) : seg.dist * u;
|
|
71
|
+
const led = seg.kind === 'pause' ? false : seg.kind === 'step' ? u < 0.6 : true;
|
|
72
|
+
return { offset: Math.round(dist) % plan.length, led };
|
|
73
|
+
}
|
|
74
|
+
dist += seg.dist;
|
|
75
|
+
}
|
|
76
|
+
return { offset: 0, led: true };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Barcode bar widths, one per calendar week: 0 for a week without
|
|
80
|
+
* contributions, otherwise 1 to 4 px by quartile of the non-zero weeks, so
|
|
81
|
+
* the busiest weeks print as the widest bars.
|
|
82
|
+
*/
|
|
83
|
+
export function barcodeWidths(days, from) {
|
|
84
|
+
// Calendar columns start on Sunday; the window's first week may be partial.
|
|
85
|
+
const lead = new Date(`${from}T00:00:00Z`).getUTCDay();
|
|
86
|
+
const weeks = [];
|
|
87
|
+
days.forEach((count, i) => {
|
|
88
|
+
const w = Math.floor((lead + i) / 7);
|
|
89
|
+
weeks[w] = (weeks[w] ?? 0) + count;
|
|
90
|
+
});
|
|
91
|
+
const active = weeks.filter((t) => t > 0).sort((a, b) => a - b);
|
|
92
|
+
if (active.length === 0)
|
|
93
|
+
return weeks.map(() => 0);
|
|
94
|
+
const at = (p) => active[Math.min(active.length - 1, Math.floor(p * (active.length - 1)))];
|
|
95
|
+
const [q1, q2, q3] = [at(0.25), at(0.5), at(0.75)];
|
|
96
|
+
return weeks.map((total) => total <= 0 ? 0 : total >= q3 ? 4 : total >= q2 ? 3 : total >= q1 ? 2 : 1);
|
|
97
|
+
}
|
|
98
|
+
const MONTHS = [
|
|
99
|
+
'JAN',
|
|
100
|
+
'FEB',
|
|
101
|
+
'MAR',
|
|
102
|
+
'APR',
|
|
103
|
+
'MAY',
|
|
104
|
+
'JUN',
|
|
105
|
+
'JUL',
|
|
106
|
+
'AUG',
|
|
107
|
+
'SEP',
|
|
108
|
+
'OCT',
|
|
109
|
+
'NOV',
|
|
110
|
+
'DEC',
|
|
111
|
+
];
|
|
112
|
+
/** "2026-03-12" -> "12 MAR 2026". */
|
|
113
|
+
export function printDate(date) {
|
|
114
|
+
const [, m, d] = date.split('-');
|
|
115
|
+
return `${d} ${MONTHS[Number(m) - 1]} ${date.slice(0, 4)}`;
|
|
116
|
+
}
|
|
117
|
+
const measure = canvasMeasure('JetBrains Mono', PAPER.size);
|
|
118
|
+
/** Right-aligned value with room to breathe: the till-receipt column gap. */
|
|
119
|
+
const GAP = 12;
|
|
120
|
+
export function buildReceipt(data, login, identity, options, palette, rng) {
|
|
121
|
+
// Thermal texture: per-line density, with a lighter band every few lines.
|
|
122
|
+
const texture = () => {
|
|
123
|
+
const d = 0.88 + rng() * 0.12;
|
|
124
|
+
return rng() < 0.15 ? d * 0.8 : d;
|
|
125
|
+
};
|
|
126
|
+
const line = (h, fields) => ({
|
|
127
|
+
h,
|
|
128
|
+
...fields,
|
|
129
|
+
density: texture(),
|
|
130
|
+
});
|
|
131
|
+
const text = (fields) => line(PAPER.line, fields);
|
|
132
|
+
const rule = () => text({ left: '-'.repeat(32) });
|
|
133
|
+
const doubleRule = () => text({ left: '='.repeat(32) });
|
|
134
|
+
const count = (label, value) => text({ left: label, right: String(value) });
|
|
135
|
+
const lines = [];
|
|
136
|
+
// Header: the store is the user.
|
|
137
|
+
const name = fitLine(identity.name.toUpperCase(), PAPER.text, PAPER.size, PAPER.size, measure);
|
|
138
|
+
lines.push(line(PAPER.line * 2, { center: name.text, double: true }));
|
|
139
|
+
lines.push(text({ center: `github.com/${login}`, faded: true }));
|
|
140
|
+
const txn = `${Math.floor(rng() * 0x10000)
|
|
141
|
+
.toString(16)
|
|
142
|
+
.toUpperCase()
|
|
143
|
+
.padStart(4, '0')}-${String(Math.floor(rng() * 99) + 1).padStart(2, '0')}`;
|
|
144
|
+
lines.push(text({ left: `TXN ${txn}`, right: 'REG 01' }));
|
|
145
|
+
lines.push(text({ center: `${printDate(data.from)} - ${printDate(data.to)}`, faded: true }));
|
|
146
|
+
lines.push(rule());
|
|
147
|
+
// Items: the repositories the user committed to.
|
|
148
|
+
const empty = data.total === 0;
|
|
149
|
+
const items = data.items.slice(0, options.items);
|
|
150
|
+
if (empty) {
|
|
151
|
+
lines.push(text({ center: 'NO ITEMS' }));
|
|
152
|
+
lines.push(rule());
|
|
153
|
+
}
|
|
154
|
+
else if (items.length > 0 || data.privateCommits > 0) {
|
|
155
|
+
lines.push(text({ left: 'ITEM', right: 'QTY' }));
|
|
156
|
+
for (const item of items) {
|
|
157
|
+
const qty = String(item.commits);
|
|
158
|
+
const fitted = fitLine(item.name, PAPER.text - measure(qty, PAPER.size) - GAP, PAPER.size, PAPER.size, measure);
|
|
159
|
+
lines.push(text({ left: fitted.text, right: qty }));
|
|
160
|
+
if (item.language) {
|
|
161
|
+
lines.push(text({ left: ` @ ${item.language.name}`, faded: true }));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (data.privateCommits > 0) {
|
|
165
|
+
lines.push(count('private repos', data.privateCommits));
|
|
166
|
+
}
|
|
167
|
+
lines.push(rule());
|
|
168
|
+
}
|
|
169
|
+
// Totals; a line whose count is 0 is left out. OTHER is what the per-type
|
|
170
|
+
// counts do not add up to, so the receipt always adds up.
|
|
171
|
+
const totals = [
|
|
172
|
+
count('COMMITS', data.commits),
|
|
173
|
+
count('PULL REQUESTS', data.pullRequests),
|
|
174
|
+
count('REVIEWS', data.reviews),
|
|
175
|
+
count('ISSUES', data.issues),
|
|
176
|
+
count('NEW REPOS', data.newRepos),
|
|
177
|
+
count('PRIVATE', data.restricted),
|
|
178
|
+
].filter((l) => l.right !== '0');
|
|
179
|
+
const other = data.total -
|
|
180
|
+
(data.commits +
|
|
181
|
+
data.pullRequests +
|
|
182
|
+
data.reviews +
|
|
183
|
+
data.issues +
|
|
184
|
+
data.newRepos +
|
|
185
|
+
data.restricted);
|
|
186
|
+
if (other > 0)
|
|
187
|
+
totals.push(count('OTHER', other));
|
|
188
|
+
else if (other < 0)
|
|
189
|
+
console.warn('receipt: per-type counts exceed the calendar total; OTHER left out');
|
|
190
|
+
lines.push(...totals);
|
|
191
|
+
lines.push(doubleRule());
|
|
192
|
+
lines.push(count('TOTAL', data.total));
|
|
193
|
+
lines.push(doubleRule());
|
|
194
|
+
// Streaks and the best day; a streak of 0 is left out.
|
|
195
|
+
const longest = longestStreak(data.days);
|
|
196
|
+
if (longest > 0)
|
|
197
|
+
lines.push(text({ left: 'LONGEST STREAK', right: `${longest} DAYS` }));
|
|
198
|
+
const current = currentStreak(data.days);
|
|
199
|
+
if (current > 0)
|
|
200
|
+
lines.push(text({ left: 'CURRENT STREAK', right: `${current} DAYS` }));
|
|
201
|
+
const best = bestDay(data.days);
|
|
202
|
+
if (best) {
|
|
203
|
+
const date = new Date(Date.parse(`${data.from}T00:00:00Z`) + best.index * 86_400_000)
|
|
204
|
+
.toISOString()
|
|
205
|
+
.slice(0, 10);
|
|
206
|
+
const qty = String(best.count);
|
|
207
|
+
const label = fitLine(`BEST DAY ${printDate(date)}`, PAPER.text - measure(qty, PAPER.size) - GAP, PAPER.size, PAPER.size, measure);
|
|
208
|
+
lines.push(text({ left: label.text, right: qty }));
|
|
209
|
+
}
|
|
210
|
+
const barcode = barcodeWidths(data.days, data.from);
|
|
211
|
+
const tail = TAIL.gapAboveBars +
|
|
212
|
+
PAPER.bars +
|
|
213
|
+
TAIL.gapBelowBars +
|
|
214
|
+
TAIL.footer +
|
|
215
|
+
TAIL.gapBelowFooter +
|
|
216
|
+
TAIL.tear +
|
|
217
|
+
TAIL.leader;
|
|
218
|
+
const feed = buildFeedPlan(lines.map((l) => l.h), tail);
|
|
219
|
+
return {
|
|
220
|
+
palette,
|
|
221
|
+
total: data.total,
|
|
222
|
+
lines,
|
|
223
|
+
barcode,
|
|
224
|
+
footer: `${login.toUpperCase()}-${data.asOf.slice(0, 4)}`,
|
|
225
|
+
feed,
|
|
226
|
+
length: feed.length,
|
|
227
|
+
txn,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { fitIdentity } from '../../core/text.js';
|
|
2
|
+
import { feedAt, PAPER, TAIL } from './receipt.js';
|
|
3
|
+
/**
|
|
4
|
+
* A thermal printer feeding the user's year as a till receipt.
|
|
5
|
+
*
|
|
6
|
+
* The whole receipt, tear line included, is rasterized once in setup into one
|
|
7
|
+
* offscreen strip (the paper). Each frame draws that strip at a whole-pixel
|
|
8
|
+
* offset, clipped at the printer's slot, tiling around at the strip's length:
|
|
9
|
+
* the roll prints the same receipt back to back, so the loop closes. Nothing
|
|
10
|
+
* on the paper is re-rasterized per frame, so held frames are byte-identical.
|
|
11
|
+
*
|
|
12
|
+
* Everything is drawn through the raw 2D context with the style set right
|
|
13
|
+
* before each draw (see Highlights Reel's note on p5's cached fill state).
|
|
14
|
+
*/
|
|
15
|
+
const SANS = '"Space Grotesk"';
|
|
16
|
+
const MONO = '"JetBrains Mono"';
|
|
17
|
+
const PROMPT = '~/receipt --last-12-months';
|
|
18
|
+
const W = 1280;
|
|
19
|
+
const H = 400;
|
|
20
|
+
const TAU = Math.PI * 2;
|
|
21
|
+
/** Width of the identity column: x 48 to 640, the printer body's left edge minus 20. */
|
|
22
|
+
export const IDENTITY_COLUMN = 640 - 48;
|
|
23
|
+
/** Paper and printer geometry, screen space. */
|
|
24
|
+
const PAPER_X = 708;
|
|
25
|
+
const SLOT_Y = 336;
|
|
26
|
+
const PRINTER = { left: 660, right: 1140, bottom: 400 };
|
|
27
|
+
const SLOT = { left: 700, right: 1100, bottom: 350 };
|
|
28
|
+
const LED = { x: 1104, y: 369 };
|
|
29
|
+
function rgba(color, alpha = 1) {
|
|
30
|
+
return `rgba(${color[0]},${color[1]},${color[2]},${alpha})`;
|
|
31
|
+
}
|
|
32
|
+
function setFill(ctx, color, alpha = 1) {
|
|
33
|
+
ctx.fillStyle = rgba(color, alpha);
|
|
34
|
+
}
|
|
35
|
+
function setFont(ctx, o) {
|
|
36
|
+
ctx.font = `${o.bold ? 700 : 400} ${o.size}px ${o.mono ? MONO : SANS}`;
|
|
37
|
+
ctx.textAlign = o.align ?? 'left';
|
|
38
|
+
ctx.textBaseline = 'alphabetic';
|
|
39
|
+
ctx.letterSpacing = `${o.tracking ?? 0}px`;
|
|
40
|
+
}
|
|
41
|
+
function drawText(ctx, str, x, y, o) {
|
|
42
|
+
setFont(ctx, o);
|
|
43
|
+
setFill(ctx, o.color, o.alpha ?? 1);
|
|
44
|
+
ctx.fillText(str, x, y);
|
|
45
|
+
if (o.tracking)
|
|
46
|
+
ctx.letterSpacing = '0px';
|
|
47
|
+
}
|
|
48
|
+
/** 0..1, periodic in `t`, so every animated value loops seamlessly. */
|
|
49
|
+
function wave(t, cycles, off) {
|
|
50
|
+
return 0.5 + 0.5 * Math.sin(TAU * (t * cycles + off));
|
|
51
|
+
}
|
|
52
|
+
/** One text row of the paper, in strip coordinates. */
|
|
53
|
+
function drawLine(ctx, line, top, p) {
|
|
54
|
+
if (!line.left && !line.right && !line.center)
|
|
55
|
+
return;
|
|
56
|
+
const color = line.faded ? p.fadedInk : p.ink;
|
|
57
|
+
ctx.font = `400 ${PAPER.size}px ${MONO}`;
|
|
58
|
+
ctx.textBaseline = 'middle';
|
|
59
|
+
ctx.letterSpacing = '0px';
|
|
60
|
+
if (line.double) {
|
|
61
|
+
// The thermal double-height mode: the same glyphs, vertically scaled.
|
|
62
|
+
ctx.save();
|
|
63
|
+
ctx.translate(0, top);
|
|
64
|
+
ctx.scale(1, 2);
|
|
65
|
+
setFill(ctx, color, line.density);
|
|
66
|
+
ctx.textAlign = 'center';
|
|
67
|
+
ctx.fillText(line.center ?? '', PAPER.width / 2, line.h / 4);
|
|
68
|
+
ctx.restore();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
setFill(ctx, color, line.density);
|
|
72
|
+
if (line.left) {
|
|
73
|
+
ctx.textAlign = 'left';
|
|
74
|
+
ctx.fillText(line.left, PAPER.margin, top + line.h / 2);
|
|
75
|
+
}
|
|
76
|
+
if (line.right) {
|
|
77
|
+
ctx.textAlign = 'right';
|
|
78
|
+
ctx.fillText(line.right, PAPER.width - PAPER.margin, top + line.h / 2);
|
|
79
|
+
}
|
|
80
|
+
if (line.center) {
|
|
81
|
+
ctx.textAlign = 'center';
|
|
82
|
+
ctx.fillText(line.center, PAPER.width / 2, top + line.h / 2);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** The paper: everything the printer prints, one receipt per period. */
|
|
86
|
+
function renderStrip(p, model) {
|
|
87
|
+
const P = model.palette;
|
|
88
|
+
const strip = p.createGraphics(PAPER.width, model.length);
|
|
89
|
+
const ctx = strip.drawingContext;
|
|
90
|
+
setFill(ctx, P.paper);
|
|
91
|
+
ctx.fillRect(0, 0, PAPER.width, model.length);
|
|
92
|
+
let y = 0;
|
|
93
|
+
for (const line of model.lines) {
|
|
94
|
+
drawLine(ctx, line, y, P);
|
|
95
|
+
y += line.h;
|
|
96
|
+
}
|
|
97
|
+
// Barcode: one bar per week, centered, 2 px between bars.
|
|
98
|
+
const bars = model.barcode.filter((w) => w > 0);
|
|
99
|
+
const barsTop = y + TAIL.gapAboveBars;
|
|
100
|
+
if (bars.length > 0) {
|
|
101
|
+
const total = bars.reduce((s, w) => s + w, 0) + (bars.length - 1) * 2;
|
|
102
|
+
let x = (PAPER.width - total) / 2;
|
|
103
|
+
ctx.fillStyle = rgba(P.ink, 0.95);
|
|
104
|
+
for (const w of model.barcode) {
|
|
105
|
+
if (w > 0) {
|
|
106
|
+
ctx.fillRect(Math.round(x), barsTop, w, PAPER.bars);
|
|
107
|
+
x += w + 2;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Human-readable text under the barcode.
|
|
112
|
+
const footerTop = barsTop + PAPER.bars + TAIL.gapBelowBars;
|
|
113
|
+
ctx.font = `400 14px ${MONO}`;
|
|
114
|
+
ctx.textAlign = 'center';
|
|
115
|
+
ctx.textBaseline = 'middle';
|
|
116
|
+
ctx.letterSpacing = '2px';
|
|
117
|
+
ctx.fillStyle = rgba(P.ink, 0.9);
|
|
118
|
+
ctx.fillText(model.footer, PAPER.width / 2, footerTop + TAIL.footer / 2);
|
|
119
|
+
ctx.letterSpacing = '0px';
|
|
120
|
+
// Tear line: a perforation between receipts, with edge notches.
|
|
121
|
+
const tearY = footerTop + TAIL.footer + TAIL.gapBelowFooter + TAIL.tear / 2;
|
|
122
|
+
ctx.strokeStyle = rgba(P.fadedInk, 0.85);
|
|
123
|
+
ctx.lineWidth = 1.5;
|
|
124
|
+
ctx.setLineDash([5, 4]);
|
|
125
|
+
ctx.beginPath();
|
|
126
|
+
ctx.moveTo(8, tearY);
|
|
127
|
+
ctx.lineTo(PAPER.width - 8, tearY);
|
|
128
|
+
ctx.stroke();
|
|
129
|
+
ctx.setLineDash([]);
|
|
130
|
+
ctx.globalCompositeOperation = 'destination-out';
|
|
131
|
+
for (const cx of [0, PAPER.width]) {
|
|
132
|
+
ctx.beginPath();
|
|
133
|
+
ctx.arc(cx, tearY, 6, 0, TAU);
|
|
134
|
+
ctx.fill();
|
|
135
|
+
}
|
|
136
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
137
|
+
if (P.paperBorder) {
|
|
138
|
+
ctx.strokeStyle = rgba(P.paperBorder, 0.09);
|
|
139
|
+
ctx.lineWidth = 1;
|
|
140
|
+
ctx.strokeRect(0.5, 0.5, PAPER.width - 1, model.length - 1);
|
|
141
|
+
}
|
|
142
|
+
return strip;
|
|
143
|
+
}
|
|
144
|
+
export function createReceiptSketch(model, identity) {
|
|
145
|
+
const P = model.palette;
|
|
146
|
+
const fitted = fitIdentity({
|
|
147
|
+
name: identity.name.toUpperCase(),
|
|
148
|
+
tagline: identity.tagline,
|
|
149
|
+
website: identity.website && `↗ ${displayUrl(identity.website)}`,
|
|
150
|
+
}, IDENTITY_COLUMN);
|
|
151
|
+
// --- static layers --------------------------------------------------------
|
|
152
|
+
/** The counter and the identity column, beneath the paper. */
|
|
153
|
+
function renderChrome(p) {
|
|
154
|
+
const chrome = p.createGraphics(W, H);
|
|
155
|
+
const ctx = chrome.drawingContext;
|
|
156
|
+
setFill(ctx, P.counter);
|
|
157
|
+
ctx.fillRect(0, 0, W, H);
|
|
158
|
+
// Soft shadow around the paper, light theme only.
|
|
159
|
+
if (P.shadow > 0) {
|
|
160
|
+
for (const [x0, x1, peak] of [
|
|
161
|
+
[PAPER_X + PAPER.width, PAPER_X + PAPER.width + 10, P.shadow],
|
|
162
|
+
[PAPER_X - 10, PAPER_X, P.shadow * 0.6],
|
|
163
|
+
]) {
|
|
164
|
+
const grad = ctx.createLinearGradient(x0, 0, x1, 0);
|
|
165
|
+
grad.addColorStop(0, rgba(P.paperBorder, peak));
|
|
166
|
+
grad.addColorStop(1, rgba(P.paperBorder, 0));
|
|
167
|
+
ctx.fillStyle = grad;
|
|
168
|
+
ctx.fillRect(x0, 0, x1 - x0, SLOT_Y);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const x = 48;
|
|
172
|
+
drawText(ctx, PROMPT, x, 58, { size: 13, mono: true, color: P.accent });
|
|
173
|
+
drawText(ctx, fitted.name.text, x, 116, {
|
|
174
|
+
size: fitted.name.size,
|
|
175
|
+
bold: true,
|
|
176
|
+
color: P.identityInk,
|
|
177
|
+
tracking: 3,
|
|
178
|
+
});
|
|
179
|
+
if (fitted.tagline) {
|
|
180
|
+
drawText(ctx, fitted.tagline.text, x, 146, {
|
|
181
|
+
size: fitted.tagline.size,
|
|
182
|
+
color: P.accent,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
drawText(ctx, model.total.toLocaleString('en-US'), x, 218, {
|
|
186
|
+
size: 34,
|
|
187
|
+
bold: true,
|
|
188
|
+
color: P.identityInk,
|
|
189
|
+
});
|
|
190
|
+
drawText(ctx, 'contributions · last 12 months', x, 240, {
|
|
191
|
+
size: 11,
|
|
192
|
+
mono: true,
|
|
193
|
+
color: P.identityMuted,
|
|
194
|
+
});
|
|
195
|
+
if (fitted.website) {
|
|
196
|
+
drawText(ctx, fitted.website.text, x, 350, {
|
|
197
|
+
size: fitted.website.size,
|
|
198
|
+
mono: true,
|
|
199
|
+
color: P.accent,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return chrome;
|
|
203
|
+
}
|
|
204
|
+
/** The printer, drawn over the paper's lower edge. */
|
|
205
|
+
function renderPrinter(p) {
|
|
206
|
+
const printer = p.createGraphics(W, H);
|
|
207
|
+
const ctx = printer.drawingContext;
|
|
208
|
+
setFill(ctx, P.body);
|
|
209
|
+
ctx.fillRect(PRINTER.left, SLOT_Y, PRINTER.right - PRINTER.left, PRINTER.bottom - SLOT_Y);
|
|
210
|
+
// Bevel on the body's top edge.
|
|
211
|
+
setStroke(ctx, P.bevel, 0.7);
|
|
212
|
+
ctx.lineWidth = 1.5;
|
|
213
|
+
ctx.beginPath();
|
|
214
|
+
ctx.moveTo(PRINTER.left + 8, SLOT_Y + 1);
|
|
215
|
+
ctx.lineTo(PRINTER.right - 8, SLOT_Y + 1);
|
|
216
|
+
ctx.stroke();
|
|
217
|
+
// The slot: a dark mouth with a serrated tear bar biting the paper.
|
|
218
|
+
setFill(ctx, P.slot);
|
|
219
|
+
ctx.fillRect(SLOT.left, SLOT_Y, SLOT.right - SLOT.left, SLOT.bottom - SLOT_Y);
|
|
220
|
+
setFill(ctx, P.slot);
|
|
221
|
+
ctx.beginPath();
|
|
222
|
+
for (let x = SLOT.left + 4; x < SLOT.right - 2; x += 12) {
|
|
223
|
+
ctx.moveTo(x, SLOT_Y + 1);
|
|
224
|
+
ctx.lineTo(x + 6, SLOT_Y - 5);
|
|
225
|
+
ctx.lineTo(x + 12, SLOT_Y + 1);
|
|
226
|
+
ctx.closePath();
|
|
227
|
+
}
|
|
228
|
+
ctx.fill();
|
|
229
|
+
return printer;
|
|
230
|
+
}
|
|
231
|
+
function setStroke(ctx, color, alpha = 1) {
|
|
232
|
+
ctx.strokeStyle = rgba(color, alpha);
|
|
233
|
+
}
|
|
234
|
+
let chrome;
|
|
235
|
+
let printer;
|
|
236
|
+
let strip;
|
|
237
|
+
function setup(p) {
|
|
238
|
+
chrome = renderChrome(p);
|
|
239
|
+
printer = renderPrinter(p);
|
|
240
|
+
strip = renderStrip(p, model);
|
|
241
|
+
}
|
|
242
|
+
function draw(p, _frame, t) {
|
|
243
|
+
const ctx = p.drawingContext;
|
|
244
|
+
if (chrome)
|
|
245
|
+
p.image(chrome, 0, 0);
|
|
246
|
+
// The paper, one whole receipt per period, wrapped at the strip's length.
|
|
247
|
+
const { offset, led } = feedAt(model.feed, t);
|
|
248
|
+
const L = model.length;
|
|
249
|
+
ctx.save();
|
|
250
|
+
ctx.beginPath();
|
|
251
|
+
ctx.rect(PAPER_X, 0, PAPER.width, SLOT_Y);
|
|
252
|
+
ctx.clip();
|
|
253
|
+
if (strip) {
|
|
254
|
+
p.image(strip, PAPER_X, SLOT_Y - offset);
|
|
255
|
+
p.image(strip, PAPER_X, SLOT_Y - offset - L);
|
|
256
|
+
}
|
|
257
|
+
ctx.restore();
|
|
258
|
+
if (P.paperBorder) {
|
|
259
|
+
setStroke(ctx, P.paperBorder, 0.09);
|
|
260
|
+
ctx.lineWidth = 1;
|
|
261
|
+
ctx.strokeRect(PAPER_X + 0.5, 0.5, PAPER.width - 1, SLOT_Y - 1);
|
|
262
|
+
}
|
|
263
|
+
if (printer)
|
|
264
|
+
p.image(printer, 0, 0);
|
|
265
|
+
// Status LED: blinks once per text step, lit while feeding and holding.
|
|
266
|
+
if (led) {
|
|
267
|
+
ctx.save();
|
|
268
|
+
setFill(ctx, P.accent);
|
|
269
|
+
ctx.shadowColor = rgba(P.accent, 0.8);
|
|
270
|
+
ctx.shadowBlur = 7;
|
|
271
|
+
ctx.beginPath();
|
|
272
|
+
ctx.arc(LED.x, LED.y, 4.5, 0, TAU);
|
|
273
|
+
ctx.fill();
|
|
274
|
+
ctx.restore();
|
|
275
|
+
ctx.shadowBlur = 0;
|
|
276
|
+
ctx.shadowColor = 'transparent';
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
setFill(ctx, P.accent, 0.16);
|
|
280
|
+
ctx.beginPath();
|
|
281
|
+
ctx.arc(LED.x, LED.y, 4.5, 0, TAU);
|
|
282
|
+
ctx.fill();
|
|
283
|
+
}
|
|
284
|
+
// Prompt cursor, blinking 3 times per loop.
|
|
285
|
+
if (wave(t, 3, 0) > 0.5) {
|
|
286
|
+
setFont(ctx, { size: 13, mono: true });
|
|
287
|
+
setFill(ctx, P.accent);
|
|
288
|
+
ctx.fillRect(48 + ctx.measureText(PROMPT).width + 4, 47, 7, 13);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return { setup, draw };
|
|
292
|
+
}
|
|
293
|
+
/** "https://www.example.com/" -> "www.example.com" */
|
|
294
|
+
function displayUrl(url) {
|
|
295
|
+
return url.replace(/^[a-z]+:\/\//i, '').replace(/\/+$/, '');
|
|
296
|
+
}
|