@cratis/cast 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/badges/RegistryMember.ts +18 -0
- package/badges/ResolvedTier.ts +17 -0
- package/badges/Tier.ts +14 -0
- package/badges/TierSource.ts +14 -0
- package/badges/badgeSvg.ts +137 -0
- package/badges/constants.ts +17 -0
- package/badges/cratisMark.ts +15 -0
- package/badges/index.ts +12 -0
- package/badges/registry.ts +68 -0
- package/badges/resolveTier.ts +24 -0
- package/cast-json.schema.json +1496 -0
- package/cast.schema.json +1113 -0
- package/config/CastConfig.ts +17 -0
- package/config/CastConfigError.ts +10 -0
- package/config/LiveStat.ts +17 -0
- package/config/ResolvedSection.ts +13 -0
- package/config/SectionId.ts +21 -0
- package/config/ThemeMode.ts +9 -0
- package/config/calendar.ts +34 -0
- package/config/castSchema.ts +263 -0
- package/config/index.ts +12 -0
- package/config/parseCast.ts +58 -0
- package/config/resolveSections.ts +48 -0
- package/github/GitHubSnapshot.ts +66 -0
- package/github/fetchGitHub.ts +235 -0
- package/github/gitHubSnapshotSchema.ts +58 -0
- package/github/parse.ts +63 -0
- package/index.ts +88 -0
- package/integration/CastData.ts +20 -0
- package/integration/CastOptions.ts +17 -0
- package/integration/Snapshot.ts +12 -0
- package/integration/loadRegistry.ts +37 -0
- package/integration/resolveData.ts +138 -0
- package/integration/snapshotFile.ts +23 -0
- package/integration/virtualModule.ts +43 -0
- package/machine/castJson.ts +77 -0
- package/machine/castJsonSchema.ts +35 -0
- package/machine/index.ts +6 -0
- package/machine/llmsText.ts +69 -0
- package/machine/ogImageSvg.ts +79 -0
- package/machine/profileFromCastJson.ts +214 -0
- package/machine/resumeJson.ts +87 -0
- package/package.json +80 -8
- package/registry/members.yaml +16 -0
- package/routes/404.astro +42 -0
- package/routes/apple-touch-icon.png.ts +17 -0
- package/routes/badge-card.svg.ts +13 -0
- package/routes/badge.svg.ts +11 -0
- package/routes/cast.json.ts +17 -0
- package/routes/cv.astro +9 -0
- package/routes/favicon.svg.ts +16 -0
- package/routes/index.astro +9 -0
- package/routes/llms.txt.ts +10 -0
- package/routes/og.png.ts +23 -0
- package/routes/resume.json.ts +13 -0
- package/routes/robots.txt.ts +10 -0
- package/site/components/Activity.astro +196 -0
- package/site/components/Agentic.astro +144 -0
- package/site/components/CareerReplay.astro +415 -0
- package/site/components/CastBadge.astro +132 -0
- package/site/components/CommandPalette.astro +122 -0
- package/site/components/Contact.astro +166 -0
- package/site/components/Cratis.astro +235 -0
- package/site/components/CratisMark.astro +18 -0
- package/site/components/EventModel.astro +265 -0
- package/site/components/Footer.astro +33 -0
- package/site/components/Hero.astro +211 -0
- package/site/components/Inline.astro +45 -0
- package/site/components/Monogram.astro +28 -0
- package/site/components/Nav.astro +127 -0
- package/site/components/Now.astro +116 -0
- package/site/components/Projects.astro +185 -0
- package/site/components/PullRequestFeed.astro +88 -0
- package/site/components/SectionHeader.astro +30 -0
- package/site/components/Skills.astro +44 -0
- package/site/components/Stats.astro +106 -0
- package/site/components/Talks.astro +76 -0
- package/site/components/Terminal.astro +164 -0
- package/site/components/Writing.astro +128 -0
- package/site/data.ts +11 -0
- package/site/layouts/Base.astro +156 -0
- package/site/lib/accent.ts +52 -0
- package/site/lib/blog.ts +58 -0
- package/site/lib/career.ts +148 -0
- package/site/lib/clipboard.ts +12 -0
- package/site/lib/contributions.ts +63 -0
- package/site/lib/github.ts +72 -0
- package/site/lib/inline.ts +79 -0
- package/site/lib/lifecycle.ts +44 -0
- package/site/lib/motion.ts +43 -0
- package/site/lib/nav.ts +49 -0
- package/site/lib/reltime.ts +33 -0
- package/site/lib/safeJson.ts +15 -0
- package/site/lib/sections.ts +41 -0
- package/site/lib/theme.ts +28 -0
- package/site/pages/CvPage.astro +379 -0
- package/site/pages/ProfilePage.astro +78 -0
- package/site/profile/CastHost.ts +32 -0
- package/site/profile/CastView.ts +96 -0
- package/site/profile/assertRenderable.ts +151 -0
- package/site/profile/clientCast.ts +117 -0
- package/site/profile/index.ts +8 -0
- package/site/scripts/ambient.ts +93 -0
- package/site/scripts/event-board.ts +403 -0
- package/site/scripts/event-model.ts +155 -0
- package/site/scripts/heatmap.ts +94 -0
- package/site/scripts/live.ts +155 -0
- package/site/scripts/palette.ts +313 -0
- package/site/scripts/replay.ts +252 -0
- package/site/scripts/terminal.ts +559 -0
- package/site/styles/global.css +508 -0
- package/virtual.d.ts +15 -0
- package/writing/WritingSnapshot.ts +20 -0
- package/writing/fetchWriting.ts +69 -0
- package/writing/parse.ts +72 -0
- package/writing/writingSnapshotSchema.ts +27 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
// Generative Event Modeling board for the hero.
|
|
5
|
+
// Blue commands fire as they cross the "now" line, append orange events, and the
|
|
6
|
+
// events feed green read models. Canvas 2D, DPR aware, paused when not visible.
|
|
7
|
+
import { motionReduced, onMotionChange } from '../lib/motion';
|
|
8
|
+
|
|
9
|
+
type Kind = 'command' | 'event' | 'view';
|
|
10
|
+
type Card = {
|
|
11
|
+
id: number;
|
|
12
|
+
kind: Kind;
|
|
13
|
+
label: string;
|
|
14
|
+
band: number;
|
|
15
|
+
x: number; // world x of the left edge
|
|
16
|
+
row: 0 | 1; // 0 = command/view row, 1 = event row
|
|
17
|
+
/** Time (ms) the card became active; Infinity = not yet. */
|
|
18
|
+
activeAt: number;
|
|
19
|
+
inputs: Card[];
|
|
20
|
+
};
|
|
21
|
+
type Edge = { from: Card; to: Card };
|
|
22
|
+
|
|
23
|
+
const COLORS: Record<Kind, { base: string; soft: string; rgb: string }> = {
|
|
24
|
+
command: { base: '#5b86f2', soft: '#a3bbf8', rgb: '91,134,242' },
|
|
25
|
+
event: { base: '#f2994a', soft: '#f8bd85', rgb: '242,153,74' },
|
|
26
|
+
view: { base: '#2dd4bf', soft: '#8eeadc', rgb: '45,212,191' },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Real names for the cards: [command, event] pairs and read models, passed in from the page. */
|
|
30
|
+
export type BoardLabels = { flows: [string, string][]; views: string[] };
|
|
31
|
+
const FALLBACK: BoardLabels = { flows: [['MergePullRequest', 'PullRequestMerged']], views: ['Profile.Now'] };
|
|
32
|
+
|
|
33
|
+
const SPEED = 16; // px per second of world drift
|
|
34
|
+
const PULSE_MS = 700;
|
|
35
|
+
const NOW_X = 0.86; // fraction of width
|
|
36
|
+
|
|
37
|
+
export function mountEventBoard(canvas: HTMLCanvasElement, host: HTMLElement, labels: BoardLabels = FALLBACK): () => void {
|
|
38
|
+
const ctx = canvas.getContext('2d');
|
|
39
|
+
if (!ctx) return () => undefined;
|
|
40
|
+
const FLOWS = labels.flows.length ? labels.flows : FALLBACK.flows;
|
|
41
|
+
const VIEWS = labels.views.length ? labels.views : FALLBACK.views;
|
|
42
|
+
|
|
43
|
+
let dpr = 1;
|
|
44
|
+
let W = 0;
|
|
45
|
+
let H = 0;
|
|
46
|
+
let cardW = 96;
|
|
47
|
+
let cardH = 56;
|
|
48
|
+
let bands: { top: number; depth: number }[] = [];
|
|
49
|
+
|
|
50
|
+
let cards: Card[] = [];
|
|
51
|
+
let edges: Edge[] = [];
|
|
52
|
+
let nextId = 0;
|
|
53
|
+
let worldEnd = 0; // world x where generation continues
|
|
54
|
+
let lastEventsByBand: Card[][] = [];
|
|
55
|
+
|
|
56
|
+
let t = 0; // world scroll offset (px)
|
|
57
|
+
let now = 0; // ms clock that only advances while running
|
|
58
|
+
let raf = 0;
|
|
59
|
+
let running = false;
|
|
60
|
+
let visible = true;
|
|
61
|
+
let last = 0;
|
|
62
|
+
|
|
63
|
+
const pointer = { x: -9999, y: -9999, px: 0, py: 0, tx: 0, ty: 0 };
|
|
64
|
+
let seed = 1337;
|
|
65
|
+
const rand = () => {
|
|
66
|
+
seed = (seed * 16807) % 2147483647;
|
|
67
|
+
return (seed - 1) / 2147483646;
|
|
68
|
+
};
|
|
69
|
+
const pick = <T,>(xs: readonly T[]): T => xs[Math.floor(rand() * xs.length)] as T;
|
|
70
|
+
|
|
71
|
+
function layout() {
|
|
72
|
+
const rect = canvas.getBoundingClientRect();
|
|
73
|
+
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
74
|
+
W = Math.max(1, Math.round(rect.width));
|
|
75
|
+
H = Math.max(1, Math.round(rect.height));
|
|
76
|
+
canvas.width = Math.round(W * dpr);
|
|
77
|
+
canvas.height = Math.round(H * dpr);
|
|
78
|
+
cardW = Math.round(Math.min(118, Math.max(78, H * 0.115)));
|
|
79
|
+
cardH = Math.round(cardW * 0.6);
|
|
80
|
+
const rowGap = cardH * 0.95;
|
|
81
|
+
const bandH = cardH * 2 + rowGap;
|
|
82
|
+
const count = H > bandH * 2.6 ? 2 : 1;
|
|
83
|
+
const usable = H - bandH * count;
|
|
84
|
+
const gap = usable / (count + 1);
|
|
85
|
+
bands = Array.from({ length: count }, (_, i) => ({ top: gap * (i + 1) + bandH * i, depth: i === 0 ? 0.6 : 1 }));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function rowY(card: Card) {
|
|
89
|
+
const band = bands[card.band] ?? bands[0]!;
|
|
90
|
+
const rowGap = cardH * 0.95;
|
|
91
|
+
return band.top + (card.row === 1 ? cardH + rowGap : 0);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function addCard(kind: Kind, label: string, band: number, x: number, row: 0 | 1, inputs: Card[] = []): Card {
|
|
95
|
+
const card: Card = { id: nextId++, kind, label, band, x, row, activeAt: Number.POSITIVE_INFINITY, inputs };
|
|
96
|
+
cards.push(card);
|
|
97
|
+
for (const from of inputs) edges.push({ from, to: card });
|
|
98
|
+
return card;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function generateUntil(limit: number) {
|
|
102
|
+
while (worldEnd < limit) {
|
|
103
|
+
for (let b = 0; b < bands.length; b++) {
|
|
104
|
+
const jitter = (rand() - 0.5) * cardW * 0.5;
|
|
105
|
+
const x = worldEnd + jitter + b * cardW * 0.55;
|
|
106
|
+
const recent = lastEventsByBand[b] ?? (lastEventsByBand[b] = []);
|
|
107
|
+
if (recent.length >= 2 && rand() < 0.42) {
|
|
108
|
+
const inputs = recent.slice(-1 - Math.floor(rand() * 2));
|
|
109
|
+
addCard('view', pick(VIEWS), b, x, 0, inputs);
|
|
110
|
+
recent.length = 0;
|
|
111
|
+
} else {
|
|
112
|
+
const [command, event] = pick(FLOWS);
|
|
113
|
+
const cmd = addCard('command', command, b, x, 0);
|
|
114
|
+
const ev = addCard('event', event, b, x + cardW * 0.42, 1, [cmd]);
|
|
115
|
+
recent.push(ev);
|
|
116
|
+
if (recent.length > 4) recent.shift();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
worldEnd += cardW * (1.32 + rand() * 0.5);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function reset() {
|
|
124
|
+
cards = [];
|
|
125
|
+
edges = [];
|
|
126
|
+
lastEventsByBand = [];
|
|
127
|
+
nextId = 0;
|
|
128
|
+
seed = 1337;
|
|
129
|
+
worldEnd = -cardW * 2;
|
|
130
|
+
t = 0;
|
|
131
|
+
generateUntil(W + cardW * 4);
|
|
132
|
+
// Everything already left of "now" is history: active from the start.
|
|
133
|
+
settle(Number.NEGATIVE_INFINITY);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const screenX = (c: Card, parallax: number) => c.x - t + parallax;
|
|
137
|
+
|
|
138
|
+
/** Propagates activation: crossing "now" fires commands, which drive events, which drive views. */
|
|
139
|
+
function settle(at: number) {
|
|
140
|
+
const nowLine = W * NOW_X;
|
|
141
|
+
for (const c of cards) {
|
|
142
|
+
if (c.activeAt !== Number.POSITIVE_INFINITY) continue;
|
|
143
|
+
if (c.kind === 'command' && c.x - t + cardW * 0.5 < nowLine) c.activeAt = at;
|
|
144
|
+
}
|
|
145
|
+
// Two passes reach views fed by events fed by commands.
|
|
146
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
147
|
+
for (const c of cards) {
|
|
148
|
+
if (c.kind === 'command' || c.activeAt !== Number.POSITIVE_INFINITY || !c.inputs.length) continue;
|
|
149
|
+
const ready = Math.max(...c.inputs.map((i) => i.activeAt));
|
|
150
|
+
if (ready === Number.POSITIVE_INFINITY) continue;
|
|
151
|
+
if (c.kind === 'view' && c.x - t + cardW * 0.5 > nowLine) continue;
|
|
152
|
+
c.activeAt = ready === Number.NEGATIVE_INFINITY ? ready : Math.max(ready, at) + PULSE_MS;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function roundRect(x: number, y: number, w: number, h: number, r: number) {
|
|
158
|
+
ctx!.beginPath();
|
|
159
|
+
ctx!.roundRect(x, y, w, h, r);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function edgePath(e: Edge, pA: number, pB: number) {
|
|
163
|
+
const ax = screenX(e.from, pA) + cardW / 2;
|
|
164
|
+
const bx = screenX(e.to, pB) + cardW / 2;
|
|
165
|
+
const fromBelow = e.from.row === 0; // command -> event goes down; event -> view goes up
|
|
166
|
+
const ay = rowY(e.from) + (fromBelow ? cardH : 0);
|
|
167
|
+
const by = rowY(e.to) + (fromBelow ? 0 : cardH);
|
|
168
|
+
const my = (ay + by) / 2;
|
|
169
|
+
return { ax, ay, bx, by, c1x: ax, c1y: my, c2x: bx, c2y: my };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const bez = (p0: number, p1: number, p2: number, p3: number, s: number) => {
|
|
173
|
+
const u = 1 - s;
|
|
174
|
+
return u * u * u * p0 + 3 * u * u * s * p1 + 3 * u * s * s * p2 + s * s * s * p3;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
function draw(static_ = false) {
|
|
178
|
+
// Hairlines follow the theme: white on ink, ink on paper.
|
|
179
|
+
const ink = document.documentElement.dataset['theme'] === 'light' ? '20,22,30' : '255,255,255';
|
|
180
|
+
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
181
|
+
ctx!.clearRect(0, 0, W, H);
|
|
182
|
+
const nowLine = W * NOW_X;
|
|
183
|
+
|
|
184
|
+
// Timeline ticks drifting with the world.
|
|
185
|
+
const tick = cardW * 0.75;
|
|
186
|
+
ctx!.lineWidth = 1;
|
|
187
|
+
for (let x = -((t % tick) + tick); x < W + tick; x += tick) {
|
|
188
|
+
ctx!.strokeStyle = `rgba(${ink},0.035)`;
|
|
189
|
+
ctx!.beginPath();
|
|
190
|
+
ctx!.moveTo(Math.round(x) + 0.5, 0);
|
|
191
|
+
ctx!.lineTo(Math.round(x) + 0.5, H);
|
|
192
|
+
ctx!.stroke();
|
|
193
|
+
}
|
|
194
|
+
// Swimlane guides.
|
|
195
|
+
for (const band of bands) {
|
|
196
|
+
for (const row of [0, 1]) {
|
|
197
|
+
const y = band.top + (row ? cardH * 1.95 : 0) + cardH / 2;
|
|
198
|
+
ctx!.strokeStyle = `rgba(${ink},0.045)`;
|
|
199
|
+
ctx!.setLineDash([2, 6]);
|
|
200
|
+
ctx!.beginPath();
|
|
201
|
+
ctx!.moveTo(0, Math.round(y) + 0.5);
|
|
202
|
+
ctx!.lineTo(W, Math.round(y) + 0.5);
|
|
203
|
+
ctx!.stroke();
|
|
204
|
+
ctx!.setLineDash([]);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// "now" line.
|
|
209
|
+
const grad = ctx!.createLinearGradient(0, 0, 0, H);
|
|
210
|
+
grad.addColorStop(0, 'rgba(242,153,74,0)');
|
|
211
|
+
grad.addColorStop(0.5, 'rgba(242,153,74,0.35)');
|
|
212
|
+
grad.addColorStop(1, 'rgba(242,153,74,0)');
|
|
213
|
+
ctx!.strokeStyle = grad;
|
|
214
|
+
ctx!.beginPath();
|
|
215
|
+
ctx!.moveTo(Math.round(nowLine) + 0.5, 0);
|
|
216
|
+
ctx!.lineTo(Math.round(nowLine) + 0.5, H);
|
|
217
|
+
ctx!.stroke();
|
|
218
|
+
ctx!.font = '500 10px "JetBrains Mono Variable", ui-monospace, monospace';
|
|
219
|
+
ctx!.fillStyle = 'rgba(248,189,133,0.55)';
|
|
220
|
+
ctx!.fillText('now ▸ append', nowLine + 8, (bands[0]?.top ?? 40) - 14);
|
|
221
|
+
|
|
222
|
+
const par = (band: number) => {
|
|
223
|
+
const depth = bands[band]?.depth ?? 1;
|
|
224
|
+
return { x: pointer.px * depth, y: pointer.py * depth };
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// Edges.
|
|
228
|
+
for (const e of edges) {
|
|
229
|
+
const pa = par(e.from.band).x;
|
|
230
|
+
const pb = par(e.to.band).x;
|
|
231
|
+
const p = edgePath(e, pa, pb);
|
|
232
|
+
if (Math.max(p.ax, p.bx) < -cardW || Math.min(p.ax, p.bx) > W + cardW) continue;
|
|
233
|
+
const py = par(e.to.band).y;
|
|
234
|
+
const active = e.to.activeAt <= now;
|
|
235
|
+
ctx!.strokeStyle = active ? `rgba(${COLORS[e.to.kind].rgb},0.28)` : `rgba(${ink},0.07)`;
|
|
236
|
+
ctx!.lineWidth = 1;
|
|
237
|
+
ctx!.beginPath();
|
|
238
|
+
ctx!.moveTo(p.ax, p.ay + py);
|
|
239
|
+
ctx!.bezierCurveTo(p.c1x, p.c1y + py, p.c2x, p.c2y + py, p.bx, p.by + py);
|
|
240
|
+
ctx!.stroke();
|
|
241
|
+
|
|
242
|
+
// Travelling pulse while the target is being activated.
|
|
243
|
+
const start = e.to.activeAt - PULSE_MS;
|
|
244
|
+
if (!static_ && Number.isFinite(e.to.activeAt) && now >= start && now < e.to.activeAt) {
|
|
245
|
+
const s = (now - start) / PULSE_MS;
|
|
246
|
+
const x = bez(p.ax, p.c1x, p.c2x, p.bx, s);
|
|
247
|
+
const y = bez(p.ay, p.c1y, p.c2y, p.by, s) + py;
|
|
248
|
+
const g = ctx!.createRadialGradient(x, y, 0, x, y, 10);
|
|
249
|
+
g.addColorStop(0, `rgba(${COLORS[e.to.kind].rgb},0.95)`);
|
|
250
|
+
g.addColorStop(1, `rgba(${COLORS[e.to.kind].rgb},0)`);
|
|
251
|
+
ctx!.fillStyle = g;
|
|
252
|
+
ctx!.beginPath();
|
|
253
|
+
ctx!.arc(x, y, 10, 0, Math.PI * 2);
|
|
254
|
+
ctx!.fill();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Cards.
|
|
259
|
+
for (const c of cards) {
|
|
260
|
+
const p = par(c.band);
|
|
261
|
+
const x = screenX(c, p.x);
|
|
262
|
+
if (x > W + cardW || x < -cardW * 1.5) continue;
|
|
263
|
+
const y = rowY(c) + p.y;
|
|
264
|
+
const col = COLORS[c.kind];
|
|
265
|
+
const active = c.activeAt <= now;
|
|
266
|
+
const age = active ? now - c.activeAt : 0;
|
|
267
|
+
const flash = active && Number.isFinite(c.activeAt) ? Math.max(0, 1 - age / 1400) : 0;
|
|
268
|
+
const dx = pointer.x - (x + cardW / 2);
|
|
269
|
+
const dy = pointer.y - (y + cardH / 2);
|
|
270
|
+
const near = Math.max(0, 1 - Math.hypot(dx, dy) / 240);
|
|
271
|
+
|
|
272
|
+
ctx!.save();
|
|
273
|
+
if (flash > 0 || near > 0.2) {
|
|
274
|
+
ctx!.shadowColor = `rgba(${col.rgb},${0.55 * Math.max(flash, near * 0.6)})`;
|
|
275
|
+
ctx!.shadowBlur = 24;
|
|
276
|
+
}
|
|
277
|
+
roundRect(x, y, cardW, cardH, 7);
|
|
278
|
+
if (active) {
|
|
279
|
+
ctx!.fillStyle = `rgba(${col.rgb},${0.1 + 0.16 * flash + 0.08 * near})`;
|
|
280
|
+
ctx!.fill();
|
|
281
|
+
ctx!.restore();
|
|
282
|
+
ctx!.strokeStyle = `rgba(${col.rgb},${0.42 + 0.4 * flash + 0.3 * near})`;
|
|
283
|
+
ctx!.lineWidth = 1;
|
|
284
|
+
roundRect(x + 0.5, y + 0.5, cardW - 1, cardH - 1, 7);
|
|
285
|
+
ctx!.stroke();
|
|
286
|
+
// Sticky-note tab.
|
|
287
|
+
ctx!.fillStyle = `rgba(${col.rgb},${0.75 + 0.25 * flash})`;
|
|
288
|
+
ctx!.fillRect(x + 9, y + 9, 10, 3);
|
|
289
|
+
} else {
|
|
290
|
+
ctx!.restore();
|
|
291
|
+
ctx!.setLineDash([3, 4]);
|
|
292
|
+
ctx!.strokeStyle = `rgba(${col.rgb},${0.16 + 0.25 * near})`;
|
|
293
|
+
roundRect(x + 0.5, y + 0.5, cardW - 1, cardH - 1, 7);
|
|
294
|
+
ctx!.stroke();
|
|
295
|
+
ctx!.setLineDash([]);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Label, clipped to the card.
|
|
299
|
+
const labelAlpha = (active ? 0.5 : 0.18) + 0.45 * Math.max(near, flash);
|
|
300
|
+
ctx!.save();
|
|
301
|
+
ctx!.beginPath();
|
|
302
|
+
ctx!.rect(x + 6, y, cardW - 12, cardH);
|
|
303
|
+
ctx!.clip();
|
|
304
|
+
ctx!.fillStyle = `rgba(${col.rgb},${labelAlpha})`;
|
|
305
|
+
const size = Math.max(7.5, Math.min(cardW / 10.5, (cardW - 18) / (c.label.length * 0.6)));
|
|
306
|
+
ctx!.font = `500 ${size.toFixed(1)}px "JetBrains Mono Variable", ui-monospace, monospace`;
|
|
307
|
+
ctx!.fillText(c.label, x + 9, y + cardH - 12);
|
|
308
|
+
ctx!.restore();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function frame(ts: number) {
|
|
313
|
+
const dt = Math.min(50, last ? ts - last : 16);
|
|
314
|
+
last = ts;
|
|
315
|
+
now += dt;
|
|
316
|
+
t += (SPEED * dt) / 1000;
|
|
317
|
+
pointer.px += (pointer.tx - pointer.px) * 0.06;
|
|
318
|
+
pointer.py += (pointer.ty - pointer.py) * 0.06;
|
|
319
|
+
|
|
320
|
+
generateUntil(t + W + cardW * 4);
|
|
321
|
+
settle(now);
|
|
322
|
+
// Cull cards that drifted off the left edge (keeping any still feeding visible cards).
|
|
323
|
+
if (cards.length > 160) {
|
|
324
|
+
const keep = new Set<Card>();
|
|
325
|
+
for (const c of cards) if (c.x - t > -cardW * 6) keep.add(c);
|
|
326
|
+
for (const c of [...keep]) c.inputs.forEach((i) => keep.add(i));
|
|
327
|
+
cards = cards.filter((c) => keep.has(c));
|
|
328
|
+
edges = edges.filter((e) => keep.has(e.from) && keep.has(e.to));
|
|
329
|
+
}
|
|
330
|
+
draw();
|
|
331
|
+
raf = requestAnimationFrame(frame);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function start() {
|
|
335
|
+
if (running || motionReduced() || !visible || document.hidden) return;
|
|
336
|
+
running = true;
|
|
337
|
+
last = 0;
|
|
338
|
+
raf = requestAnimationFrame(frame);
|
|
339
|
+
}
|
|
340
|
+
function stop() {
|
|
341
|
+
running = false;
|
|
342
|
+
cancelAnimationFrame(raf);
|
|
343
|
+
}
|
|
344
|
+
function renderStatic() {
|
|
345
|
+
stop();
|
|
346
|
+
draw(true);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
layout();
|
|
350
|
+
reset();
|
|
351
|
+
if (motionReduced()) renderStatic();
|
|
352
|
+
else start();
|
|
353
|
+
host.dataset['boardReady'] = 'true';
|
|
354
|
+
|
|
355
|
+
const ro = new ResizeObserver(() => {
|
|
356
|
+
const prevW = W;
|
|
357
|
+
const prevH = H;
|
|
358
|
+
layout();
|
|
359
|
+
if (Math.abs(prevH - H) > 40 || Math.abs(prevW - W) > 40) reset();
|
|
360
|
+
if (!running) draw(true);
|
|
361
|
+
});
|
|
362
|
+
ro.observe(canvas);
|
|
363
|
+
|
|
364
|
+
const io = new IntersectionObserver(([entry]) => {
|
|
365
|
+
visible = !!entry?.isIntersecting;
|
|
366
|
+
if (visible) start();
|
|
367
|
+
else stop();
|
|
368
|
+
});
|
|
369
|
+
io.observe(canvas);
|
|
370
|
+
|
|
371
|
+
const onVisibility = () => (document.hidden ? stop() : start());
|
|
372
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
373
|
+
const offMotion = onMotionChange((reduced) => (reduced ? renderStatic() : start()));
|
|
374
|
+
const onTheme = () => !running && draw(true);
|
|
375
|
+
document.addEventListener('theme:change', onTheme);
|
|
376
|
+
|
|
377
|
+
host.addEventListener(
|
|
378
|
+
'pointermove',
|
|
379
|
+
(e: PointerEvent) => {
|
|
380
|
+
const r = canvas.getBoundingClientRect();
|
|
381
|
+
pointer.x = e.clientX - r.left;
|
|
382
|
+
pointer.y = e.clientY - r.top;
|
|
383
|
+
pointer.tx = ((e.clientX - r.left) / r.width - 0.5) * -18;
|
|
384
|
+
pointer.ty = ((e.clientY - r.top) / r.height - 0.5) * -10;
|
|
385
|
+
if (!running) draw(true);
|
|
386
|
+
},
|
|
387
|
+
{ passive: true },
|
|
388
|
+
);
|
|
389
|
+
host.addEventListener('pointerleave', () => {
|
|
390
|
+
pointer.x = pointer.y = -9999;
|
|
391
|
+
pointer.tx = pointer.ty = 0;
|
|
392
|
+
if (!running) draw(true);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
return () => {
|
|
396
|
+
stop();
|
|
397
|
+
ro.disconnect();
|
|
398
|
+
io.disconnect();
|
|
399
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
400
|
+
offMotion();
|
|
401
|
+
document.removeEventListener('theme:change', onTheme);
|
|
402
|
+
};
|
|
403
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
// Event Modeling board: highlight a card's flow on hover/focus/click, step through, or play.
|
|
5
|
+
import { disposer } from '../lib/lifecycle';
|
|
6
|
+
|
|
7
|
+
type CardData = { id: string; kind: string; name: string; source: string; note: string };
|
|
8
|
+
type StepData = { title: string; text: string; cards: string[] };
|
|
9
|
+
const KIND = { ui: 'Screen', command: 'Command', event: 'Event', view: 'Read model' } as Record<string, string>;
|
|
10
|
+
const STEP_MS = 2600;
|
|
11
|
+
|
|
12
|
+
export function initEventModel(): (() => void) | void {
|
|
13
|
+
const root = document.querySelector<HTMLElement>('[data-model]');
|
|
14
|
+
const json = document.querySelector<HTMLScriptElement>('[data-model-json]');
|
|
15
|
+
if (!root || !json) return;
|
|
16
|
+
const data = JSON.parse(json.textContent ?? '{}') as { cards: CardData[]; steps: StepData[]; root: string };
|
|
17
|
+
const cards = new Map(data.cards.map((c) => [c.id, c]));
|
|
18
|
+
const cardEls = [...root.querySelectorAll<HTMLButtonElement>('[data-card]')];
|
|
19
|
+
const edgeEls = [...root.querySelectorAll<SVGPathElement>('.edge')];
|
|
20
|
+
const stepEls = [...root.querySelectorAll<HTMLButtonElement>('[data-step]')];
|
|
21
|
+
const meta = root.querySelector<HTMLElement>('[data-model-detail-meta]');
|
|
22
|
+
const text = root.querySelector<HTMLElement>('[data-model-detail-text]');
|
|
23
|
+
const play = root.querySelector<HTMLButtonElement>('[data-model-play]');
|
|
24
|
+
const playLabel = root.querySelector<HTMLElement>('[data-model-play-label]');
|
|
25
|
+
const reset = root.querySelector<HTMLButtonElement>('[data-model-reset]');
|
|
26
|
+
const scroller = root.querySelector<HTMLElement>('[data-model-scroll]');
|
|
27
|
+
const initial = { meta: meta?.textContent ?? '', text: text?.textContent ?? '' };
|
|
28
|
+
const d = disposer();
|
|
29
|
+
|
|
30
|
+
let pinned: { type: 'card' | 'step'; key: string | number } | null = null;
|
|
31
|
+
let timer = 0;
|
|
32
|
+
let playing = false;
|
|
33
|
+
|
|
34
|
+
const light = (ids: Set<string>) => {
|
|
35
|
+
root.toggleAttribute('data-focus', ids.size > 0);
|
|
36
|
+
cardEls.forEach((el) => el.toggleAttribute('data-lit', ids.has(el.dataset['card'] ?? '')));
|
|
37
|
+
edgeEls.forEach((el) => el.toggleAttribute('data-lit', ids.has(el.dataset['from'] ?? '') && ids.has(el.dataset['to'] ?? '')));
|
|
38
|
+
if (reset) reset.hidden = ids.size === 0;
|
|
39
|
+
};
|
|
40
|
+
const detail = (metaText: string, body: string, href?: string) => {
|
|
41
|
+
if (meta) {
|
|
42
|
+
meta.replaceChildren(metaText);
|
|
43
|
+
if (href) {
|
|
44
|
+
const a = document.createElement('a');
|
|
45
|
+
a.href = href;
|
|
46
|
+
a.rel = 'noopener';
|
|
47
|
+
a.className = 'ml-2 underline decoration-line-strong hover:text-fg';
|
|
48
|
+
a.textContent = href.slice(data.root.length + 1);
|
|
49
|
+
meta.append(a);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (text) text.textContent = body;
|
|
53
|
+
};
|
|
54
|
+
const markStep = (i: number | null) => stepEls.forEach((el, j) => el.setAttribute('aria-pressed', String(i === j)));
|
|
55
|
+
|
|
56
|
+
const showCard = (id: string) => {
|
|
57
|
+
const card = cards.get(id);
|
|
58
|
+
if (!card) return;
|
|
59
|
+
const ids = new Set<string>([id]);
|
|
60
|
+
for (const s of data.steps) if (s.cards.includes(id)) s.cards.forEach((c) => ids.add(c));
|
|
61
|
+
light(ids);
|
|
62
|
+
markStep(null);
|
|
63
|
+
detail(`${KIND[card.kind] ?? card.kind} · ${card.name} ·`, card.note, `${data.root}/${card.source}`);
|
|
64
|
+
};
|
|
65
|
+
const showStep = (i: number) => {
|
|
66
|
+
const step = data.steps[i];
|
|
67
|
+
if (!step) return;
|
|
68
|
+
light(new Set(step.cards));
|
|
69
|
+
markStep(i);
|
|
70
|
+
detail(`Step ${i + 1} of ${data.steps.length} · ${step.title}`, step.text);
|
|
71
|
+
// Keep the lit cards in view when the board scrolls.
|
|
72
|
+
const first = cardEls.find((el) => el.dataset['card'] === step.cards[0]);
|
|
73
|
+
const last = cardEls.find((el) => el.dataset['card'] === step.cards[step.cards.length - 1]);
|
|
74
|
+
if (scroller && first && last && scroller.scrollWidth > scroller.clientWidth) {
|
|
75
|
+
const mid = (first.offsetLeft + last.offsetLeft + last.offsetWidth) / 2;
|
|
76
|
+
scroller.scrollTo({ left: mid - scroller.clientWidth / 2, behavior: 'smooth' });
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const clear = () => {
|
|
80
|
+
light(new Set());
|
|
81
|
+
markStep(null);
|
|
82
|
+
detail(initial.meta, initial.text);
|
|
83
|
+
};
|
|
84
|
+
const restore = () => {
|
|
85
|
+
if (playing) return;
|
|
86
|
+
if (!pinned) clear();
|
|
87
|
+
else if (pinned.type === 'card') showCard(String(pinned.key));
|
|
88
|
+
else showStep(Number(pinned.key));
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const stop = () => {
|
|
92
|
+
playing = false;
|
|
93
|
+
window.clearTimeout(timer);
|
|
94
|
+
play?.setAttribute('aria-pressed', 'false');
|
|
95
|
+
root.querySelector('[data-icon-play]')?.classList.remove('hidden');
|
|
96
|
+
root.querySelector('[data-icon-pause]')?.classList.add('hidden');
|
|
97
|
+
if (playLabel) playLabel.textContent = 'Play the flow';
|
|
98
|
+
};
|
|
99
|
+
const run = (i: number) => {
|
|
100
|
+
if (i >= data.steps.length) {
|
|
101
|
+
stop();
|
|
102
|
+
pinned = { type: 'step', key: data.steps.length - 1 };
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
showStep(i);
|
|
106
|
+
timer = window.setTimeout(() => run(i + 1), STEP_MS);
|
|
107
|
+
};
|
|
108
|
+
const start = () => {
|
|
109
|
+
playing = true;
|
|
110
|
+
play?.setAttribute('aria-pressed', 'true');
|
|
111
|
+
root.querySelector('[data-icon-play]')?.classList.add('hidden');
|
|
112
|
+
root.querySelector('[data-icon-pause]')?.classList.remove('hidden');
|
|
113
|
+
if (playLabel) playLabel.textContent = 'Pause';
|
|
114
|
+
run(0);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
for (const el of cardEls) {
|
|
118
|
+
const id = el.dataset['card'] ?? '';
|
|
119
|
+
d.on(el, 'pointerenter', () => !playing && showCard(id));
|
|
120
|
+
d.on(el, 'pointerleave', restore);
|
|
121
|
+
d.on(el, 'focus', () => !playing && showCard(id));
|
|
122
|
+
d.on(el, 'blur', restore);
|
|
123
|
+
d.on(el, 'click', () => {
|
|
124
|
+
stop();
|
|
125
|
+
pinned = pinned?.type === 'card' && pinned.key === id ? null : { type: 'card', key: id };
|
|
126
|
+
restore();
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
stepEls.forEach((el, i) =>
|
|
130
|
+
d.on(el, 'click', () => {
|
|
131
|
+
stop();
|
|
132
|
+
pinned = pinned?.type === 'step' && pinned.key === i ? null : { type: 'step', key: i };
|
|
133
|
+
restore();
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
if (play) d.on(play, 'click', () => (playing ? stop() : start()));
|
|
137
|
+
if (reset)
|
|
138
|
+
d.on(reset, 'click', () => {
|
|
139
|
+
stop();
|
|
140
|
+
pinned = null;
|
|
141
|
+
clear();
|
|
142
|
+
});
|
|
143
|
+
d.on<KeyboardEvent>(root, 'keydown', (e) => {
|
|
144
|
+
if (e.key === 'Escape' && (pinned || playing)) {
|
|
145
|
+
stop();
|
|
146
|
+
pinned = null;
|
|
147
|
+
clear();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return () => {
|
|
152
|
+
stop();
|
|
153
|
+
d.dispose();
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
// Contribution heatmap: fill-in on first view, pointer/keyboard tooltip, scroll to "now".
|
|
5
|
+
import { motionReduced } from '../lib/motion';
|
|
6
|
+
import { disposer } from '../lib/lifecycle';
|
|
7
|
+
|
|
8
|
+
const fmt = new Intl.NumberFormat('en-US');
|
|
9
|
+
const dayLabel = (date: string) =>
|
|
10
|
+
new Date(`${date}T00:00:00Z`).toLocaleDateString('en-US', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
|
|
11
|
+
const describe = (cell: HTMLElement) => {
|
|
12
|
+
const n = Number(cell.dataset['n']);
|
|
13
|
+
return `${n === 0 ? 'No' : fmt.format(n)} event${n === 1 ? '' : 's'} · ${dayLabel(cell.dataset['d'] ?? '')}`;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function initHeatmap(): (() => void) | void {
|
|
17
|
+
const root = document.querySelector<HTMLElement>('[data-heatmap]');
|
|
18
|
+
const grid = root?.querySelector<HTMLElement>('[data-heatmap-grid]');
|
|
19
|
+
const tip = document.querySelector<HTMLElement>('[data-heatmap-tip]');
|
|
20
|
+
const keys = root?.querySelector<HTMLElement>('[data-heatmap-keys]');
|
|
21
|
+
const live = root?.querySelector<HTMLElement>('[data-heatmap-live]');
|
|
22
|
+
const scroller = root?.querySelector<HTMLElement>('[data-heatmap-scroll]');
|
|
23
|
+
if (!root || !grid || !tip) return;
|
|
24
|
+
const d = disposer();
|
|
25
|
+
const cells = [...grid.querySelectorAll<HTMLElement>('.c')];
|
|
26
|
+
|
|
27
|
+
// Most recent weeks are on the right; start there on narrow screens.
|
|
28
|
+
if (scroller) scroller.scrollLeft = scroller.scrollWidth;
|
|
29
|
+
|
|
30
|
+
if (motionReduced() || !('IntersectionObserver' in window)) {
|
|
31
|
+
root.classList.add('filled');
|
|
32
|
+
} else {
|
|
33
|
+
const io = new IntersectionObserver(
|
|
34
|
+
([entry]) => {
|
|
35
|
+
if (!entry?.isIntersecting) return;
|
|
36
|
+
root.classList.add('filled');
|
|
37
|
+
io.disconnect();
|
|
38
|
+
},
|
|
39
|
+
{ threshold: 0.25 },
|
|
40
|
+
);
|
|
41
|
+
io.observe(grid);
|
|
42
|
+
d.add(() => io.disconnect());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let current: HTMLElement | null = null;
|
|
46
|
+
const show = (cell: HTMLElement | null) => {
|
|
47
|
+
current?.removeAttribute('data-on');
|
|
48
|
+
current = cell;
|
|
49
|
+
if (!cell) {
|
|
50
|
+
tip.hidden = true;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
cell.setAttribute('data-on', '');
|
|
54
|
+
const r = cell.getBoundingClientRect();
|
|
55
|
+
tip.textContent = describe(cell);
|
|
56
|
+
tip.hidden = false;
|
|
57
|
+
const half = tip.offsetWidth / 2 + 8;
|
|
58
|
+
tip.style.left = `${Math.min(window.innerWidth - half, Math.max(half, r.left + r.width / 2))}px`;
|
|
59
|
+
tip.style.top = `${r.top}px`;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
d.on<PointerEvent>(grid, 'pointerover', (e) => {
|
|
63
|
+
const cell = (e.target as Element).closest<HTMLElement>('.c');
|
|
64
|
+
if (cell) show(cell);
|
|
65
|
+
});
|
|
66
|
+
d.on(grid, 'pointerleave', () => show(null));
|
|
67
|
+
d.on(window, 'scroll', () => current && document.activeElement !== keys && show(null), { passive: true });
|
|
68
|
+
|
|
69
|
+
if (keys) {
|
|
70
|
+
let index = cells.length - 1;
|
|
71
|
+
const announce = () => {
|
|
72
|
+
const cell = cells[index];
|
|
73
|
+
if (!cell) return;
|
|
74
|
+
cell.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
75
|
+
show(cell);
|
|
76
|
+
if (live) live.textContent = describe(cell);
|
|
77
|
+
};
|
|
78
|
+
d.on(keys, 'focus', announce);
|
|
79
|
+
d.on(keys, 'blur', () => show(null));
|
|
80
|
+
d.on<KeyboardEvent>(keys, 'keydown', (e) => {
|
|
81
|
+
const step: Record<string, number> = { ArrowLeft: -7, ArrowRight: 7, ArrowUp: -1, ArrowDown: 1, Home: -Infinity, End: Infinity };
|
|
82
|
+
const s = step[e.key];
|
|
83
|
+
if (s === undefined) return;
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
index = Number.isFinite(s) ? Math.min(cells.length - 1, Math.max(0, index + s)) : s < 0 ? 0 : cells.length - 1;
|
|
86
|
+
announce();
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return () => {
|
|
91
|
+
show(null);
|
|
92
|
+
d.dispose();
|
|
93
|
+
};
|
|
94
|
+
}
|