headreel 1.2.0 → 1.4.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 -19
- package/assets/fonts/Caveat.ttf +0 -0
- package/assets/fonts/OFL-Caveat.txt +93 -0
- package/dist/cli/run.js +2 -2
- package/dist/core/data/contributions.js +12 -9
- package/dist/core/data/highlights.js +141 -0
- package/dist/core/data/now-playing.js +282 -0
- package/dist/core/encode/gif.js +50 -7
- package/dist/core/fonts.js +3 -0
- package/dist/core/pipeline.js +5 -1
- package/dist/core/text.js +72 -0
- package/dist/styles/contribution-city/city.js +4 -2
- package/dist/styles/contribution-city/index.js +1 -1
- package/dist/styles/contribution-city/options.js +3 -2
- package/dist/styles/contribution-city/sketch.js +62 -32
- package/dist/styles/highlights-reel/index.js +16 -0
- package/dist/styles/highlights-reel/options.js +11 -0
- package/dist/styles/highlights-reel/palette.js +68 -0
- package/dist/styles/highlights-reel/reel.js +346 -0
- package/dist/styles/highlights-reel/sketch.js +555 -0
- package/dist/styles/index.js +4 -0
- package/dist/styles/now-playing/deck.js +105 -0
- package/dist/styles/now-playing/index.js +16 -0
- package/dist/styles/now-playing/options.js +19 -0
- package/dist/styles/now-playing/palette.js +69 -0
- package/dist/styles/now-playing/sketch.js +610 -0
- package/dist/styles/repo-galaxy/sketch.js +16 -8
- package/package.json +1 -1
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const THEMES = ['dark', 'light'];
|
|
3
|
+
export const ACCENTS = ['cyan', 'green', 'orange'];
|
|
4
|
+
export const options = z
|
|
5
|
+
.object({
|
|
6
|
+
/** The deck and the room: graphite on dark, or a silver deck on light. */
|
|
7
|
+
theme: z.enum(THEMES).default('dark'),
|
|
8
|
+
/** Display phosphor, level meter, play LED, prompt and links. Real VFD colors only. */
|
|
9
|
+
accent: z.enum(ACCENTS).default('cyan'),
|
|
10
|
+
/** Comma-separated repositories never picked, e.g. the profile repo itself. */
|
|
11
|
+
exclude: z
|
|
12
|
+
.string()
|
|
13
|
+
.default('')
|
|
14
|
+
.transform((v) => v
|
|
15
|
+
.split(',')
|
|
16
|
+
.map((n) => n.trim())
|
|
17
|
+
.filter(Boolean)),
|
|
18
|
+
})
|
|
19
|
+
.strict();
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accent presets: the three colors real VFDs came in. The phosphor value
|
|
3
|
+
* passes 4.5:1 on the display glass; the light theme's darker value passes
|
|
4
|
+
* 4.5:1 on its room (both asserted by a test).
|
|
5
|
+
*/
|
|
6
|
+
export const ACCENT_VALUES = {
|
|
7
|
+
cyan: [34, 211, 238],
|
|
8
|
+
green: [52, 211, 153],
|
|
9
|
+
orange: [251, 146, 60],
|
|
10
|
+
};
|
|
11
|
+
/** Tailwind 700 of each accent (orange 800: 700 misses 4.5:1), for type on the light room. */
|
|
12
|
+
const LIGHT_ACCENT_VALUES = {
|
|
13
|
+
cyan: [14, 116, 144],
|
|
14
|
+
green: [4, 120, 87],
|
|
15
|
+
orange: [154, 52, 18],
|
|
16
|
+
};
|
|
17
|
+
const CASSETTE = {
|
|
18
|
+
shell: [46, 44, 41],
|
|
19
|
+
tape: [24, 19, 17],
|
|
20
|
+
paper: [239, 230, 210],
|
|
21
|
+
ink: [31, 29, 26],
|
|
22
|
+
glass: [7, 16, 15],
|
|
23
|
+
amber: [255, 181, 71],
|
|
24
|
+
red: [255, 77, 61],
|
|
25
|
+
};
|
|
26
|
+
const BASE = {
|
|
27
|
+
dark: {
|
|
28
|
+
...CASSETTE,
|
|
29
|
+
bg: [16, 17, 20],
|
|
30
|
+
vignette: 0.3,
|
|
31
|
+
faceplate: [36, 38, 43],
|
|
32
|
+
bevel: [58, 61, 68],
|
|
33
|
+
keyTop: [58, 61, 68],
|
|
34
|
+
keyRim: [255, 255, 255],
|
|
35
|
+
screw: [22, 23, 26],
|
|
36
|
+
plateInk: [143, 147, 156],
|
|
37
|
+
identityInk: [242, 239, 232],
|
|
38
|
+
identityMuted: [143, 147, 156],
|
|
39
|
+
},
|
|
40
|
+
// A silver deck, as 1980s hi-fi came in. The window and display stay dark.
|
|
41
|
+
light: {
|
|
42
|
+
...CASSETTE,
|
|
43
|
+
bg: [236, 237, 241],
|
|
44
|
+
vignette: 0.06,
|
|
45
|
+
faceplate: [204, 207, 212],
|
|
46
|
+
bevel: [246, 247, 249],
|
|
47
|
+
keyTop: [226, 228, 232],
|
|
48
|
+
keyRim: [255, 255, 255],
|
|
49
|
+
screw: [120, 124, 132],
|
|
50
|
+
plateInk: [70, 74, 82],
|
|
51
|
+
identityInk: [17, 18, 22],
|
|
52
|
+
identityMuted: [92, 96, 106],
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
export function paletteOf(options) {
|
|
56
|
+
const accent = ACCENT_VALUES[options.accent];
|
|
57
|
+
return {
|
|
58
|
+
...BASE[options.theme],
|
|
59
|
+
accent,
|
|
60
|
+
identityAccent: options.theme === 'dark' ? accent : LIGHT_ACCENT_VALUES[options.accent],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Flat colors the encoder keeps exact and undithered. */
|
|
64
|
+
export function pinnedOf(palette) {
|
|
65
|
+
const colors = [palette.bg, palette.faceplate, palette.glass, palette.paper, palette.accent];
|
|
66
|
+
if (palette.identityAccent !== palette.accent)
|
|
67
|
+
colors.push(palette.identityAccent);
|
|
68
|
+
return colors;
|
|
69
|
+
}
|
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
import { canvasMeasure, fitIdentity, fitLine, } from '../../core/text.js';
|
|
2
|
+
import { HUB_R } from './deck.js';
|
|
3
|
+
/**
|
|
4
|
+
* A cassette deck, drawn as hardware: the cassette through a smoked window,
|
|
5
|
+
* a VFD showing the user's commit messages as lyrics, and a level meter of
|
|
6
|
+
* the month's days.
|
|
7
|
+
*
|
|
8
|
+
* Everything is drawn through the raw 2D context with the style set right
|
|
9
|
+
* before each draw (see Highlights Reel's note on p5's cached fill state).
|
|
10
|
+
* The chrome is one static layer built in setup; the reels, the display and
|
|
11
|
+
* the LEDs animate over it. Every animated value is periodic in `t`.
|
|
12
|
+
*/
|
|
13
|
+
const SANS = '"Space Grotesk"';
|
|
14
|
+
const MONO = '"JetBrains Mono"';
|
|
15
|
+
const HAND = '"Caveat"';
|
|
16
|
+
/**
|
|
17
|
+
* The prompt names what the tape is with made-up flags: the most active repo
|
|
18
|
+
* of the window, or the last pushed one when the window was empty.
|
|
19
|
+
*/
|
|
20
|
+
export function promptFor(status) {
|
|
21
|
+
if (status === 'playing')
|
|
22
|
+
return '~/now-playing --most-active-repo --30d';
|
|
23
|
+
if (status === 'last-played')
|
|
24
|
+
return '~/now-playing --last-pushed';
|
|
25
|
+
return '~/now-playing';
|
|
26
|
+
}
|
|
27
|
+
const W = 1280;
|
|
28
|
+
const H = 400;
|
|
29
|
+
const TAU = Math.PI * 2;
|
|
30
|
+
/** Width of the identity column: x 48 to 420. */
|
|
31
|
+
export const IDENTITY_COLUMN = 420 - 48;
|
|
32
|
+
const PLATE = { x: 452, y: 36, w: 784, h: 328, r: 12 };
|
|
33
|
+
/** The smoked window and its interior, where the cassette sits. */
|
|
34
|
+
const WELL = { x: 470, y: 56, w: 304, h: 190, r: 8 };
|
|
35
|
+
const INTERIOR = { x: WELL.x + 7, y: WELL.y + 7, w: WELL.w - 14, h: WELL.h - 14, r: 5 };
|
|
36
|
+
const CASSETTE = { x: 486, y: 72, w: 272, h: 160, r: 6 };
|
|
37
|
+
const LABEL = { x: 500, y: 82, w: 244, h: 58 };
|
|
38
|
+
const BAND_H = 20;
|
|
39
|
+
const HUBS = { y: 192, left: 548, right: 696 };
|
|
40
|
+
const KEYS = ['REW', 'PLAY', 'FF', 'STOP', 'REC'];
|
|
41
|
+
const KEY = { w: 52, h: 44, gap: 9, y: 264 };
|
|
42
|
+
const DISPLAY = { x: 792, y: 56, w: 428, h: 240, r: 8 };
|
|
43
|
+
const PAD = 18;
|
|
44
|
+
const UX = DISPLAY.x + PAD;
|
|
45
|
+
const UW = DISPLAY.w - PAD * 2;
|
|
46
|
+
const LYRIC_BASE = [128, 158, 188];
|
|
47
|
+
const LYRIC_ROW = 30;
|
|
48
|
+
const METER = { y: 210, h: 62 };
|
|
49
|
+
const BARS = 30;
|
|
50
|
+
const COUNTER = { x: 792, y: 308, w: 112, h: 40 };
|
|
51
|
+
const LEDS = { rec: 934, play: 988, y: 328 };
|
|
52
|
+
const clamp01 = (v) => Math.min(1, Math.max(0, v));
|
|
53
|
+
/** 0..1, periodic in `t`, so every animated value loops seamlessly. */
|
|
54
|
+
function wave(t, cycles, off) {
|
|
55
|
+
return 0.5 + 0.5 * Math.sin(TAU * (t * cycles + off));
|
|
56
|
+
}
|
|
57
|
+
/** The tape scroll: a short eased move between holds. */
|
|
58
|
+
function easeInOut(t) {
|
|
59
|
+
return t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2;
|
|
60
|
+
}
|
|
61
|
+
function rgba(color, alpha = 1) {
|
|
62
|
+
return `rgba(${color[0]},${color[1]},${color[2]},${alpha})`;
|
|
63
|
+
}
|
|
64
|
+
function setFill(ctx, color, alpha = 1) {
|
|
65
|
+
ctx.fillStyle = rgba(color, alpha);
|
|
66
|
+
}
|
|
67
|
+
function setStroke(ctx, color, alpha = 1) {
|
|
68
|
+
ctx.strokeStyle = rgba(color, alpha);
|
|
69
|
+
}
|
|
70
|
+
function setFont(ctx, o) {
|
|
71
|
+
ctx.font = `${o.bold ? 700 : 400} ${o.size}px ${o.mono ? MONO : (o.family ?? SANS)}`;
|
|
72
|
+
ctx.textAlign = o.align ?? 'left';
|
|
73
|
+
ctx.textBaseline = 'alphabetic';
|
|
74
|
+
ctx.letterSpacing = `${o.tracking ?? 0}px`;
|
|
75
|
+
}
|
|
76
|
+
function drawText(ctx, str, x, y, o) {
|
|
77
|
+
setFont(ctx, o);
|
|
78
|
+
setFill(ctx, o.color, o.alpha ?? 1);
|
|
79
|
+
ctx.fillText(str, x, y);
|
|
80
|
+
if (o.tracking)
|
|
81
|
+
ctx.letterSpacing = '0px';
|
|
82
|
+
}
|
|
83
|
+
/** Phosphor: the glyphs carry a faint glow, as a VFD does. */
|
|
84
|
+
function glowText(ctx, str, x, y, o) {
|
|
85
|
+
setFont(ctx, o);
|
|
86
|
+
setFill(ctx, o.color, o.alpha ?? 1);
|
|
87
|
+
ctx.shadowColor = rgba(o.color, 0.9);
|
|
88
|
+
ctx.shadowBlur = o.glow ?? 7;
|
|
89
|
+
ctx.fillText(str, x, y);
|
|
90
|
+
ctx.shadowBlur = 0;
|
|
91
|
+
ctx.shadowColor = 'transparent';
|
|
92
|
+
if (o.tracking)
|
|
93
|
+
ctx.letterSpacing = '0px';
|
|
94
|
+
}
|
|
95
|
+
function measure(ctx, str, o) {
|
|
96
|
+
setFont(ctx, o);
|
|
97
|
+
return ctx.measureText(str).width;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The lyric lines on the display at `t`: index into the lyrics, baseline and
|
|
101
|
+
* alpha. Lines scroll up one row between holds; the middle one is bright, its
|
|
102
|
+
* neighbours dim, and the loop wraps from the last line to the first. The row
|
|
103
|
+
* two below the middle is hidden during a hold and scrolls in, so no line
|
|
104
|
+
* appears in place. With two lines, the row above would repeat the row below,
|
|
105
|
+
* so it is left out and the leaving line fades out on its way up.
|
|
106
|
+
*/
|
|
107
|
+
export function lyricLayout(n, t) {
|
|
108
|
+
if (n === 0)
|
|
109
|
+
return [];
|
|
110
|
+
if (n === 1)
|
|
111
|
+
return [{ index: 0, base: LYRIC_BASE[1], alpha: 1 }];
|
|
112
|
+
const u = t * n;
|
|
113
|
+
const k = Math.floor(u) % n;
|
|
114
|
+
const offset = easeInOut(clamp01((u - Math.floor(u) - 0.8) / 0.2));
|
|
115
|
+
const rows = n === 2 ? [0, 1, 2] : [-1, 0, 1, 2];
|
|
116
|
+
const fadeAbove = n === 2 ? 1 : 0.68;
|
|
117
|
+
const lines = [];
|
|
118
|
+
for (const row of rows) {
|
|
119
|
+
const base = LYRIC_BASE[1] + (row - offset) * LYRIC_ROW;
|
|
120
|
+
const dist = Math.abs(base - LYRIC_BASE[1]) / LYRIC_ROW;
|
|
121
|
+
const alpha = clamp01(1 - dist * (base < LYRIC_BASE[1] ? fadeAbove : 0.68));
|
|
122
|
+
if (alpha > 0.02)
|
|
123
|
+
lines.push({ index: (k + row + n) % n, base, alpha });
|
|
124
|
+
}
|
|
125
|
+
return lines;
|
|
126
|
+
}
|
|
127
|
+
/** Width the hand-written name may take on the label. */
|
|
128
|
+
export const TAPE_NAME_WIDTH = LABEL.w - 24;
|
|
129
|
+
/**
|
|
130
|
+
* The hand-written `owner/name` on the label, with baselines relative to the
|
|
131
|
+
* label's writing area. One line when it fits at 20 px or more; otherwise the
|
|
132
|
+
* owner goes on a smaller first line and the name below it, each cut with an
|
|
133
|
+
* ellipsis if it is still too long.
|
|
134
|
+
*/
|
|
135
|
+
export function tapeLabel(owner, name, measure) {
|
|
136
|
+
const full = `${owner}/${name}`;
|
|
137
|
+
if (measure(full, 20) <= TAPE_NAME_WIDTH) {
|
|
138
|
+
return [{ ...fitLine(full, TAPE_NAME_WIDTH, 30, 20, measure), dy: 0 }];
|
|
139
|
+
}
|
|
140
|
+
return [
|
|
141
|
+
{ ...fitLine(`${owner}/`, TAPE_NAME_WIDTH, 16, 16, measure), dy: -16 },
|
|
142
|
+
{ ...fitLine(name, TAPE_NAME_WIDTH, 22, 18, measure), dy: 2 },
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
function roundRect(ctx, x, y, w, h, r) {
|
|
146
|
+
ctx.beginPath();
|
|
147
|
+
ctx.roundRect(x, y, w, h, r);
|
|
148
|
+
}
|
|
149
|
+
export function createDeckSketch(deck, identity) {
|
|
150
|
+
const P = deck.palette;
|
|
151
|
+
const prompt = promptFor(deck.status);
|
|
152
|
+
const fitted = fitIdentity({
|
|
153
|
+
name: identity.name.toUpperCase(),
|
|
154
|
+
tagline: identity.tagline,
|
|
155
|
+
website: identity.website && `↗ ${displayUrl(identity.website)}`,
|
|
156
|
+
}, IDENTITY_COLUMN);
|
|
157
|
+
const repo = deck.repo;
|
|
158
|
+
// Lines are cut with an ellipsis at the width they are drawn into.
|
|
159
|
+
const measureMono = canvasMeasure('JetBrains Mono', 400);
|
|
160
|
+
const measureHand = canvasMeasure('Caveat', 400);
|
|
161
|
+
const topLabel = deck.status === 'last-played' ? 'LAST PLAYED' : 'NOW PLAYING';
|
|
162
|
+
const topLabelW = canvasMeasure('JetBrains Mono', 400, 2)(topLabel, 12);
|
|
163
|
+
const topName =
|
|
164
|
+
// The label carries the owner; the display keeps the room for the name.
|
|
165
|
+
repo === null ? null : fitLine(repo.name, UW - topLabelW - 14, 13, 13, measureMono);
|
|
166
|
+
const lyrics = deck.lyrics.map((line) => fitLine(line, UW, 14, 14, measureMono));
|
|
167
|
+
const tapeName = repo === null ? [] : tapeLabel(repo.owner, repo.name, measureHand);
|
|
168
|
+
const languageName = deck.languageName === null
|
|
169
|
+
? null
|
|
170
|
+
: fitLine(deck.languageName, LABEL.w * 0.55, 8, 8, canvasMeasure('JetBrains Mono', 400, 1));
|
|
171
|
+
// --- static layer ---------------------------------------------------------
|
|
172
|
+
function drawVignette(ctx) {
|
|
173
|
+
const grad = ctx.createRadialGradient(W / 2, H / 2 - 20, 180, W / 2, H / 2, 860);
|
|
174
|
+
grad.addColorStop(0, 'rgba(0,0,0,0)');
|
|
175
|
+
grad.addColorStop(1, `rgba(0,0,0,${P.vignette})`);
|
|
176
|
+
ctx.fillStyle = grad;
|
|
177
|
+
ctx.fillRect(0, 0, W, H);
|
|
178
|
+
}
|
|
179
|
+
function drawPlate(ctx) {
|
|
180
|
+
setFill(ctx, P.faceplate);
|
|
181
|
+
roundRect(ctx, PLATE.x, PLATE.y, PLATE.w, PLATE.h, PLATE.r);
|
|
182
|
+
ctx.fill();
|
|
183
|
+
// Fine horizontal brushing, seeded.
|
|
184
|
+
for (const s of deck.brush) {
|
|
185
|
+
setStroke(ctx, [255, 255, 255], s.alpha);
|
|
186
|
+
ctx.lineWidth = 1;
|
|
187
|
+
ctx.beginPath();
|
|
188
|
+
ctx.moveTo(PLATE.x + 4 + s.x0 * (PLATE.w - 8), PLATE.y + 4 + s.y * (PLATE.h - 8));
|
|
189
|
+
ctx.lineTo(PLATE.x + 4 + s.x1 * (PLATE.w - 8), PLATE.y + 4 + s.y * (PLATE.h - 8));
|
|
190
|
+
ctx.stroke();
|
|
191
|
+
}
|
|
192
|
+
// Bevel: light on top, dark on the bottom edge.
|
|
193
|
+
setStroke(ctx, P.bevel, 0.65);
|
|
194
|
+
ctx.lineWidth = 1.5;
|
|
195
|
+
ctx.beginPath();
|
|
196
|
+
ctx.moveTo(PLATE.x + PLATE.r, PLATE.y + 1);
|
|
197
|
+
ctx.lineTo(PLATE.x + PLATE.w - PLATE.r, PLATE.y + 1);
|
|
198
|
+
ctx.stroke();
|
|
199
|
+
setStroke(ctx, [0, 0, 0], 0.4);
|
|
200
|
+
ctx.beginPath();
|
|
201
|
+
ctx.moveTo(PLATE.x + PLATE.r, PLATE.y + PLATE.h - 1);
|
|
202
|
+
ctx.lineTo(PLATE.x + PLATE.w - PLATE.r, PLATE.y + PLATE.h - 1);
|
|
203
|
+
ctx.stroke();
|
|
204
|
+
setStroke(ctx, [0, 0, 0], 0.35);
|
|
205
|
+
ctx.lineWidth = 1;
|
|
206
|
+
roundRect(ctx, PLATE.x + 0.5, PLATE.y + 0.5, PLATE.w - 1, PLATE.h - 1, PLATE.r);
|
|
207
|
+
ctx.stroke();
|
|
208
|
+
// Screws in the corners, slots at seeded angles.
|
|
209
|
+
const corners = [
|
|
210
|
+
[PLATE.x + 16, PLATE.y + 16],
|
|
211
|
+
[PLATE.x + PLATE.w - 16, PLATE.y + 16],
|
|
212
|
+
[PLATE.x + 16, PLATE.y + PLATE.h - 16],
|
|
213
|
+
[PLATE.x + PLATE.w - 16, PLATE.y + PLATE.h - 16],
|
|
214
|
+
];
|
|
215
|
+
corners.forEach(([cx, cy], i) => {
|
|
216
|
+
setFill(ctx, P.screw);
|
|
217
|
+
ctx.beginPath();
|
|
218
|
+
ctx.arc(cx, cy, 4.5, 0, TAU);
|
|
219
|
+
ctx.fill();
|
|
220
|
+
setStroke(ctx, P.bevel, 0.9);
|
|
221
|
+
ctx.lineWidth = 1.5;
|
|
222
|
+
const a = deck.screws[i];
|
|
223
|
+
ctx.beginPath();
|
|
224
|
+
ctx.moveTo(cx - Math.cos(a) * 3, cy - Math.sin(a) * 3);
|
|
225
|
+
ctx.lineTo(cx + Math.cos(a) * 3, cy + Math.sin(a) * 3);
|
|
226
|
+
ctx.stroke();
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
function drawWellChrome(ctx) {
|
|
230
|
+
// The well's interior: glass over the faceplate, the cassette sits inside.
|
|
231
|
+
setFill(ctx, P.glass, 0.85);
|
|
232
|
+
roundRect(ctx, INTERIOR.x, INTERIOR.y, INTERIOR.w, INTERIOR.h, INTERIOR.r);
|
|
233
|
+
ctx.fill();
|
|
234
|
+
setStroke(ctx, P.bevel, 0.9);
|
|
235
|
+
ctx.lineWidth = 1.5;
|
|
236
|
+
roundRect(ctx, WELL.x, WELL.y, WELL.w, WELL.h, WELL.r);
|
|
237
|
+
ctx.stroke();
|
|
238
|
+
setStroke(ctx, [0, 0, 0], 0.5);
|
|
239
|
+
ctx.lineWidth = 1;
|
|
240
|
+
roundRect(ctx, INTERIOR.x + 0.5, INTERIOR.y + 0.5, INTERIOR.w - 1, INTERIOR.h - 1, INTERIOR.r);
|
|
241
|
+
ctx.stroke();
|
|
242
|
+
}
|
|
243
|
+
function drawDisplayChrome(ctx) {
|
|
244
|
+
setFill(ctx, P.glass);
|
|
245
|
+
roundRect(ctx, DISPLAY.x, DISPLAY.y, DISPLAY.w, DISPLAY.h, DISPLAY.r);
|
|
246
|
+
ctx.fill();
|
|
247
|
+
setStroke(ctx, P.bevel, 0.9);
|
|
248
|
+
ctx.lineWidth = 1.5;
|
|
249
|
+
roundRect(ctx, DISPLAY.x, DISPLAY.y, DISPLAY.w, DISPLAY.h, DISPLAY.r);
|
|
250
|
+
ctx.stroke();
|
|
251
|
+
setStroke(ctx, [0, 0, 0], 0.5);
|
|
252
|
+
ctx.lineWidth = 1;
|
|
253
|
+
roundRect(ctx, DISPLAY.x + 1, DISPLAY.y + 1, DISPLAY.w - 2, DISPLAY.h - 2, DISPLAY.r - 1);
|
|
254
|
+
ctx.stroke();
|
|
255
|
+
// Unlit segment ghosts, as real VFDs show.
|
|
256
|
+
const ghost = rgba(P.accent, 0.05);
|
|
257
|
+
ctx.fillStyle = ghost;
|
|
258
|
+
for (const base of LYRIC_BASE) {
|
|
259
|
+
for (let x = UX; x < UX + UW - 3; x += 7) {
|
|
260
|
+
ctx.fillRect(x, base - 9, 3.5, 10);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function drawKeys(ctx) {
|
|
265
|
+
const total = KEYS.length * KEY.w + (KEYS.length - 1) * KEY.gap;
|
|
266
|
+
const x0 = WELL.x + (WELL.w - total) / 2;
|
|
267
|
+
KEYS.forEach((label, i) => {
|
|
268
|
+
const pressed = label === 'PLAY';
|
|
269
|
+
const x = x0 + i * (KEY.w + KEY.gap);
|
|
270
|
+
const y = KEY.y + (pressed ? 5 : 0);
|
|
271
|
+
const h = KEY.h - (pressed ? 5 : 0);
|
|
272
|
+
// Slot the raised keys sit out of.
|
|
273
|
+
if (!pressed) {
|
|
274
|
+
setFill(ctx, [0, 0, 0], 0.35);
|
|
275
|
+
roundRect(ctx, x, KEY.y - 3, KEY.w, KEY.h + 3, 5);
|
|
276
|
+
ctx.fill();
|
|
277
|
+
}
|
|
278
|
+
setFill(ctx, pressed ? P.faceplate : P.keyTop);
|
|
279
|
+
roundRect(ctx, x, y, KEY.w, h, 5);
|
|
280
|
+
ctx.fill();
|
|
281
|
+
setStroke(ctx, pressed ? [0, 0, 0] : P.keyRim, pressed ? 0.4 : 0.25);
|
|
282
|
+
ctx.lineWidth = 1;
|
|
283
|
+
roundRect(ctx, x + 0.5, y + 0.5, KEY.w - 1, h - 1, 5);
|
|
284
|
+
ctx.stroke();
|
|
285
|
+
if (pressed) {
|
|
286
|
+
setFill(ctx, P.identityAccent, 0.9);
|
|
287
|
+
ctx.fillRect(x + 8, y + 4, KEY.w - 16, 2.5);
|
|
288
|
+
}
|
|
289
|
+
drawText(ctx, label, x + KEY.w / 2, KEY.y + KEY.h + 18, {
|
|
290
|
+
size: 10,
|
|
291
|
+
align: 'center',
|
|
292
|
+
color: P.plateInk,
|
|
293
|
+
alpha: pressed ? 0.9 : 0.6,
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
function drawCounter(ctx) {
|
|
298
|
+
setFill(ctx, [10, 11, 14]);
|
|
299
|
+
roundRect(ctx, COUNTER.x, COUNTER.y, COUNTER.w, COUNTER.h, 4);
|
|
300
|
+
ctx.fill();
|
|
301
|
+
setStroke(ctx, P.bevel, 0.8);
|
|
302
|
+
ctx.lineWidth = 1;
|
|
303
|
+
roundRect(ctx, COUNTER.x + 0.5, COUNTER.y + 0.5, COUNTER.w - 1, COUNTER.h - 1, 4);
|
|
304
|
+
ctx.stroke();
|
|
305
|
+
const digits = String(deck.counter).padStart(3, '0');
|
|
306
|
+
for (let i = 0; i < 3; i++) {
|
|
307
|
+
const x = COUNTER.x + 10 + i * 33;
|
|
308
|
+
setFill(ctx, [4, 4, 6]);
|
|
309
|
+
roundRect(ctx, x, COUNTER.y + 7, 28, 26, 2);
|
|
310
|
+
ctx.fill();
|
|
311
|
+
drawText(ctx, digits[i], x + 14, COUNTER.y + 28, {
|
|
312
|
+
size: 19,
|
|
313
|
+
mono: true,
|
|
314
|
+
bold: true,
|
|
315
|
+
align: 'center',
|
|
316
|
+
color: P.amber,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
drawText(ctx, 'COUNTER', COUNTER.x + COUNTER.w / 2, COUNTER.y + COUNTER.h + 12, {
|
|
320
|
+
size: 7,
|
|
321
|
+
mono: true,
|
|
322
|
+
tracking: 2,
|
|
323
|
+
align: 'center',
|
|
324
|
+
color: P.plateInk,
|
|
325
|
+
alpha: 0.7,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
function drawLedLabels(ctx) {
|
|
329
|
+
drawText(ctx, 'REC', LEDS.rec + 9, LEDS.y + 3, {
|
|
330
|
+
size: 7,
|
|
331
|
+
mono: true,
|
|
332
|
+
color: P.plateInk,
|
|
333
|
+
alpha: 0.7,
|
|
334
|
+
});
|
|
335
|
+
drawText(ctx, 'PLAY', LEDS.play + 9, LEDS.y + 3, {
|
|
336
|
+
size: 7,
|
|
337
|
+
mono: true,
|
|
338
|
+
color: P.plateInk,
|
|
339
|
+
alpha: 0.7,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
function drawIdentity(ctx) {
|
|
343
|
+
const x = 48;
|
|
344
|
+
drawText(ctx, prompt, x, 58, { size: 13, mono: true, color: P.identityAccent });
|
|
345
|
+
drawText(ctx, fitted.name.text, x, 116, {
|
|
346
|
+
size: fitted.name.size,
|
|
347
|
+
bold: true,
|
|
348
|
+
color: P.identityInk,
|
|
349
|
+
tracking: 3,
|
|
350
|
+
});
|
|
351
|
+
if (fitted.tagline) {
|
|
352
|
+
drawText(ctx, fitted.tagline.text, x, 146, {
|
|
353
|
+
size: fitted.tagline.size,
|
|
354
|
+
color: P.identityAccent,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
if (fitted.website) {
|
|
358
|
+
drawText(ctx, fitted.website.text, x, 350, {
|
|
359
|
+
size: fitted.website.size,
|
|
360
|
+
mono: true,
|
|
361
|
+
color: P.identityAccent,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
let chrome;
|
|
366
|
+
function setup(p) {
|
|
367
|
+
chrome = p.createGraphics(W, H);
|
|
368
|
+
const ctx = chrome.drawingContext;
|
|
369
|
+
setFill(ctx, P.bg);
|
|
370
|
+
ctx.fillRect(0, 0, W, H);
|
|
371
|
+
drawVignette(ctx);
|
|
372
|
+
drawPlate(ctx);
|
|
373
|
+
drawWellChrome(ctx);
|
|
374
|
+
drawDisplayChrome(ctx);
|
|
375
|
+
drawKeys(ctx);
|
|
376
|
+
drawCounter(ctx);
|
|
377
|
+
drawLedLabels(ctx);
|
|
378
|
+
drawIdentity(ctx);
|
|
379
|
+
}
|
|
380
|
+
// --- cassette -------------------------------------------------------------
|
|
381
|
+
function drawReel(ctx, reel, cx, t) {
|
|
382
|
+
const angle = (reel.phase + reel.revs * t) * TAU;
|
|
383
|
+
setFill(ctx, P.tape);
|
|
384
|
+
ctx.beginPath();
|
|
385
|
+
ctx.arc(cx, HUBS.y, reel.radius, 0, TAU);
|
|
386
|
+
ctx.fill();
|
|
387
|
+
// Wound tape: a bright rim and faint rings.
|
|
388
|
+
if (reel.radius > HUB_R + 2) {
|
|
389
|
+
setStroke(ctx, [96, 84, 72], 0.55);
|
|
390
|
+
ctx.lineWidth = 1;
|
|
391
|
+
ctx.beginPath();
|
|
392
|
+
ctx.arc(cx, HUBS.y, reel.radius - 0.5, 0, TAU);
|
|
393
|
+
ctx.stroke();
|
|
394
|
+
setStroke(ctx, [255, 255, 255], 0.05);
|
|
395
|
+
for (let r = HUB_R + 5; r < reel.radius - 2; r += 4) {
|
|
396
|
+
ctx.beginPath();
|
|
397
|
+
ctx.arc(cx, HUBS.y, r, 0, TAU);
|
|
398
|
+
ctx.stroke();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// Six-spoke hub, turning a whole number of sixths per loop.
|
|
402
|
+
setStroke(ctx, [92, 86, 78], 0.95);
|
|
403
|
+
ctx.lineWidth = 5;
|
|
404
|
+
ctx.lineCap = 'round';
|
|
405
|
+
ctx.beginPath();
|
|
406
|
+
for (let i = 0; i < 3; i++) {
|
|
407
|
+
const a = angle + (i * TAU) / 6;
|
|
408
|
+
const dx = Math.cos(a) * (HUB_R - 3);
|
|
409
|
+
const dy = Math.sin(a) * (HUB_R - 3);
|
|
410
|
+
ctx.moveTo(cx - dx, HUBS.y - dy);
|
|
411
|
+
ctx.lineTo(cx + dx, HUBS.y + dy);
|
|
412
|
+
}
|
|
413
|
+
ctx.stroke();
|
|
414
|
+
ctx.lineCap = 'butt';
|
|
415
|
+
setFill(ctx, P.tape);
|
|
416
|
+
ctx.beginPath();
|
|
417
|
+
ctx.arc(cx, HUBS.y, 5, 0, TAU);
|
|
418
|
+
ctx.fill();
|
|
419
|
+
}
|
|
420
|
+
function drawCassette(ctx, t) {
|
|
421
|
+
ctx.save();
|
|
422
|
+
roundRect(ctx, INTERIOR.x, INTERIOR.y, INTERIOR.w, INTERIOR.h, INTERIOR.r);
|
|
423
|
+
ctx.clip();
|
|
424
|
+
if (deck.status !== 'empty') {
|
|
425
|
+
setFill(ctx, P.shell);
|
|
426
|
+
roundRect(ctx, CASSETTE.x, CASSETTE.y, CASSETTE.w, CASSETTE.h, CASSETTE.r);
|
|
427
|
+
ctx.fill();
|
|
428
|
+
setStroke(ctx, [255, 255, 255], 0.06);
|
|
429
|
+
ctx.lineWidth = 1;
|
|
430
|
+
ctx.beginPath();
|
|
431
|
+
ctx.moveTo(CASSETTE.x + CASSETTE.r, CASSETTE.y + 1);
|
|
432
|
+
ctx.lineTo(CASSETTE.x + CASSETTE.w - CASSETTE.r, CASSETTE.y + 1);
|
|
433
|
+
ctx.stroke();
|
|
434
|
+
setStroke(ctx, [20, 19, 17], 0.8);
|
|
435
|
+
roundRect(ctx, CASSETTE.x + 0.5, CASSETTE.y + 0.5, CASSETTE.w - 1, CASSETTE.h - 1, CASSETTE.r);
|
|
436
|
+
ctx.stroke();
|
|
437
|
+
// Label: cream paper, colored band, hand-written name.
|
|
438
|
+
setFill(ctx, P.paper);
|
|
439
|
+
ctx.fillRect(LABEL.x, LABEL.y, LABEL.w, LABEL.h);
|
|
440
|
+
setFill(ctx, deck.bandColor);
|
|
441
|
+
ctx.fillRect(LABEL.x, LABEL.y, LABEL.w, BAND_H);
|
|
442
|
+
setStroke(ctx, [0, 0, 0], 0.15);
|
|
443
|
+
ctx.lineWidth = 1;
|
|
444
|
+
ctx.strokeRect(LABEL.x + 0.5, LABEL.y + 0.5, LABEL.w - 1, LABEL.h - 1);
|
|
445
|
+
drawText(ctx, 'SIDE A', LABEL.x + 7, LABEL.y + 14, {
|
|
446
|
+
size: 8,
|
|
447
|
+
mono: true,
|
|
448
|
+
tracking: 1,
|
|
449
|
+
color: [255, 255, 255],
|
|
450
|
+
alpha: 0.92,
|
|
451
|
+
});
|
|
452
|
+
if (languageName) {
|
|
453
|
+
drawText(ctx, languageName.text, LABEL.x + LABEL.w - 7, LABEL.y + 14, {
|
|
454
|
+
size: languageName.size,
|
|
455
|
+
mono: true,
|
|
456
|
+
tracking: 1,
|
|
457
|
+
align: 'right',
|
|
458
|
+
color: [255, 255, 255],
|
|
459
|
+
alpha: 0.92,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
ctx.save();
|
|
463
|
+
ctx.translate(LABEL.x + LABEL.w / 2, LABEL.y + BAND_H + (LABEL.h - BAND_H) / 2 + 12);
|
|
464
|
+
ctx.rotate(deck.labelTilt);
|
|
465
|
+
for (const line of tapeName) {
|
|
466
|
+
drawText(ctx, line.text, 0, line.dy, {
|
|
467
|
+
size: line.size,
|
|
468
|
+
family: HAND,
|
|
469
|
+
align: 'center',
|
|
470
|
+
color: P.ink,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
ctx.restore();
|
|
474
|
+
// Tape threading along the bottom, then the reels.
|
|
475
|
+
setFill(ctx, P.tape);
|
|
476
|
+
ctx.fillRect(HUBS.left, 216, HUBS.right - HUBS.left, 3);
|
|
477
|
+
drawReel(ctx, deck.supply, HUBS.left, t);
|
|
478
|
+
drawReel(ctx, deck.takeUp, HUBS.right, t);
|
|
479
|
+
}
|
|
480
|
+
// Smoked glass over whatever is inside.
|
|
481
|
+
setFill(ctx, P.glass, 0.35);
|
|
482
|
+
ctx.fillRect(INTERIOR.x, INTERIOR.y, INTERIOR.w, INTERIOR.h);
|
|
483
|
+
ctx.restore();
|
|
484
|
+
}
|
|
485
|
+
// --- display --------------------------------------------------------------
|
|
486
|
+
function drawLyrics(ctx, t) {
|
|
487
|
+
ctx.save();
|
|
488
|
+
roundRect(ctx, DISPLAY.x + 4, DISPLAY.y + 4, DISPLAY.w - 8, DISPLAY.h - 8, DISPLAY.r - 2);
|
|
489
|
+
ctx.clip();
|
|
490
|
+
for (const { index, base, alpha } of lyricLayout(lyrics.length, t)) {
|
|
491
|
+
const line = lyrics[index];
|
|
492
|
+
const opts = { size: line.size, mono: true, color: P.accent, alpha };
|
|
493
|
+
if (alpha > 0.8) {
|
|
494
|
+
glowText(ctx, line.text, UX, base, opts);
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
drawText(ctx, line.text, UX, base, opts);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
ctx.restore();
|
|
501
|
+
}
|
|
502
|
+
function drawMeter(ctx, t) {
|
|
503
|
+
const pitch = UW / BARS;
|
|
504
|
+
const barW = pitch * 0.55;
|
|
505
|
+
const pos = (t * BARS) % BARS;
|
|
506
|
+
deck.bars.forEach((height, i) => {
|
|
507
|
+
const x = UX + i * pitch + (pitch - barW) / 2;
|
|
508
|
+
if (height <= 0) {
|
|
509
|
+
// Unlit ghost for a day without commits.
|
|
510
|
+
setFill(ctx, P.accent, 0.09);
|
|
511
|
+
ctx.fillRect(x, METER.y, barW, METER.h);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const d = (pos - i + BARS) % BARS;
|
|
515
|
+
const flash = Math.exp(-d * 0.4);
|
|
516
|
+
setFill(ctx, P.accent, Math.min(1, 0.55 + flash * 0.45));
|
|
517
|
+
const h = Math.max(3, height * METER.h);
|
|
518
|
+
ctx.fillRect(x, METER.y + METER.h - h, barW, h);
|
|
519
|
+
});
|
|
520
|
+
setStroke(ctx, P.accent, 0.25);
|
|
521
|
+
ctx.lineWidth = 1;
|
|
522
|
+
ctx.beginPath();
|
|
523
|
+
ctx.moveTo(UX, METER.y + METER.h + 2.5);
|
|
524
|
+
ctx.lineTo(UX + UW, METER.y + METER.h + 2.5);
|
|
525
|
+
ctx.stroke();
|
|
526
|
+
// The playhead sweeps once per loop.
|
|
527
|
+
const px = UX + pos * pitch + pitch / 2;
|
|
528
|
+
ctx.save();
|
|
529
|
+
setStroke(ctx, P.accent, 0.55);
|
|
530
|
+
ctx.lineWidth = 1.5;
|
|
531
|
+
ctx.shadowColor = rgba(P.accent, 0.9);
|
|
532
|
+
ctx.shadowBlur = 6;
|
|
533
|
+
ctx.beginPath();
|
|
534
|
+
ctx.moveTo(px, METER.y - 4);
|
|
535
|
+
ctx.lineTo(px, METER.y + METER.h + 4);
|
|
536
|
+
ctx.stroke();
|
|
537
|
+
ctx.restore();
|
|
538
|
+
ctx.shadowBlur = 0;
|
|
539
|
+
ctx.shadowColor = 'transparent';
|
|
540
|
+
}
|
|
541
|
+
function drawDisplay(ctx, t) {
|
|
542
|
+
if (deck.status === 'empty') {
|
|
543
|
+
glowText(ctx, 'NO TAPE', UX, 86, {
|
|
544
|
+
size: 13,
|
|
545
|
+
mono: true,
|
|
546
|
+
tracking: 2,
|
|
547
|
+
color: P.accent,
|
|
548
|
+
alpha: 0.85,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
drawText(ctx, topLabel, UX, 86, {
|
|
553
|
+
size: 12,
|
|
554
|
+
mono: true,
|
|
555
|
+
tracking: 2,
|
|
556
|
+
color: P.accent,
|
|
557
|
+
alpha: 0.55,
|
|
558
|
+
});
|
|
559
|
+
if (topName) {
|
|
560
|
+
glowText(ctx, topName.text, UX + topLabelW + 14, 86, {
|
|
561
|
+
size: topName.size,
|
|
562
|
+
mono: true,
|
|
563
|
+
color: P.accent,
|
|
564
|
+
alpha: 0.95,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
drawLyrics(ctx, t);
|
|
569
|
+
drawMeter(ctx, t);
|
|
570
|
+
}
|
|
571
|
+
function drawLeds(ctx, t) {
|
|
572
|
+
// Record-off: the red LED stays dim. Play pulses once per loop.
|
|
573
|
+
setFill(ctx, P.red, 0.22);
|
|
574
|
+
ctx.beginPath();
|
|
575
|
+
ctx.arc(LEDS.rec, LEDS.y, 4.5, 0, TAU);
|
|
576
|
+
ctx.fill();
|
|
577
|
+
const pulse = 0.45 + 0.55 * wave(t, 1, 0);
|
|
578
|
+
ctx.save();
|
|
579
|
+
setFill(ctx, P.accent, pulse);
|
|
580
|
+
ctx.shadowColor = rgba(P.accent, pulse);
|
|
581
|
+
ctx.shadowBlur = 8;
|
|
582
|
+
ctx.beginPath();
|
|
583
|
+
ctx.arc(LEDS.play, LEDS.y, 4.5, 0, TAU);
|
|
584
|
+
ctx.fill();
|
|
585
|
+
ctx.restore();
|
|
586
|
+
ctx.shadowBlur = 0;
|
|
587
|
+
ctx.shadowColor = 'transparent';
|
|
588
|
+
}
|
|
589
|
+
function drawCursor(ctx, t) {
|
|
590
|
+
// Once a second, a terminal's blink rate: 16 whole cycles per 16 s loop.
|
|
591
|
+
if (wave(t, 16, 0) <= 0.5)
|
|
592
|
+
return;
|
|
593
|
+
setFill(ctx, P.identityAccent);
|
|
594
|
+
ctx.fillRect(48 + measure(ctx, prompt, { size: 13, mono: true }) + 4, 47, 7, 13);
|
|
595
|
+
}
|
|
596
|
+
function draw(p, _frame, t) {
|
|
597
|
+
const ctx = p.drawingContext;
|
|
598
|
+
if (chrome)
|
|
599
|
+
p.image(chrome, 0, 0);
|
|
600
|
+
drawCassette(ctx, t);
|
|
601
|
+
drawDisplay(ctx, t);
|
|
602
|
+
drawLeds(ctx, t);
|
|
603
|
+
drawCursor(ctx, t);
|
|
604
|
+
}
|
|
605
|
+
return { setup, draw };
|
|
606
|
+
}
|
|
607
|
+
/** "https://www.example.com/" -> "www.example.com" */
|
|
608
|
+
function displayUrl(url) {
|
|
609
|
+
return url.replace(/^[a-z]+:\/\//i, '').replace(/\/+$/, '');
|
|
610
|
+
}
|