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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { contributionsSchema, fetchContributions, } from '../../core/data/contributions.js';
|
|
2
|
+
import { buildTrail, FRAMES } from './trail.js';
|
|
3
|
+
import { options } from './options.js';
|
|
4
|
+
import { paletteOf, pinnedOf } from './palette.js';
|
|
5
|
+
import { createTrailSketch } from './sketch.js';
|
|
6
|
+
export const trailProfile = {
|
|
7
|
+
id: 'trail-profile',
|
|
8
|
+
fps: 25,
|
|
9
|
+
frames: FRAMES,
|
|
10
|
+
pinned: (o) => pinnedOf(paletteOf(o)),
|
|
11
|
+
data: { name: 'contributions', schema: contributionsSchema, fetch: fetchContributions },
|
|
12
|
+
options,
|
|
13
|
+
createSketch({ data, identity, options, rng }) {
|
|
14
|
+
return createTrailSketch(buildTrail(data, identity, paletteOf(options)), rng);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** `over` at `alpha` over `base`, both opaque. */
|
|
2
|
+
export function blend(over, base, alpha) {
|
|
3
|
+
return [
|
|
4
|
+
Math.round(over[0] * alpha + base[0] * (1 - alpha)),
|
|
5
|
+
Math.round(over[1] * alpha + base[1] * (1 - alpha)),
|
|
6
|
+
Math.round(over[2] * alpha + base[2] * (1 - alpha)),
|
|
7
|
+
];
|
|
8
|
+
}
|
|
9
|
+
/** Survey-map contour brown, new to this style; the others keep shared names. */
|
|
10
|
+
const ACCENT_VALUES = {
|
|
11
|
+
sienna: [138, 75, 34], // #8A4B22
|
|
12
|
+
cobalt: [47, 85, 212], // #2F55D4
|
|
13
|
+
green: [31, 107, 58], // #1F6B3A
|
|
14
|
+
violet: [106, 63, 200], // #6A3FC8
|
|
15
|
+
};
|
|
16
|
+
const BASE = {
|
|
17
|
+
paper: [243, 239, 227], // #F3EFE3
|
|
18
|
+
ink: [43, 38, 32], // #2B2620
|
|
19
|
+
muted: [107, 99, 88], // #6B6358
|
|
20
|
+
graticule: [47, 111, 168], // #2F6FA8
|
|
21
|
+
flag: [200, 16, 46], // #C8102E
|
|
22
|
+
};
|
|
23
|
+
export function paletteOf(options) {
|
|
24
|
+
const accent = ACCENT_VALUES[options.accent];
|
|
25
|
+
const paper = BASE.paper;
|
|
26
|
+
return {
|
|
27
|
+
...BASE,
|
|
28
|
+
accent,
|
|
29
|
+
// Pre-blended solids, drawn opaque so the encoder can pin them: alpha
|
|
30
|
+
// strokes anti-alias into a ramp of near-identical colors that dithers
|
|
31
|
+
// and re-shuffles every frame the camera moves.
|
|
32
|
+
strata: blend(accent, paper, 0.6),
|
|
33
|
+
graticule: blend(BASE.graticule, paper, 0.35),
|
|
34
|
+
stipple: blend(BASE.ink, paper, 0.55),
|
|
35
|
+
grid: blend(BASE.ink, paper, 0.15),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Flat colors the encoder keeps exact and undithered. */
|
|
39
|
+
export function pinnedOf(palette) {
|
|
40
|
+
return [
|
|
41
|
+
palette.paper,
|
|
42
|
+
palette.ink,
|
|
43
|
+
palette.accent,
|
|
44
|
+
palette.flag,
|
|
45
|
+
palette.strata,
|
|
46
|
+
palette.graticule,
|
|
47
|
+
palette.stipple,
|
|
48
|
+
palette.grid,
|
|
49
|
+
];
|
|
50
|
+
}
|