goframework-io-www

380 lines · 21.2 KiB Permalink Raw
1/* goframework.io: a recorded flow, replayed. Each recording is real modules
2 running a scenario (tools/flows in the workspace): every event, with the
3 state the modules reported after it. Nothing here simulates them: this
4 draws the recording, in chapters, and steps through it. */
5(function () {
6 "use strict";
7 const { el, svg, copy, codeBlock } = window.GF;
8 const reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
9 let tooltip = null;
10
11 function center(node) { return { x: node.x + node.w / 2, y: node.y + node.h / 2 }; }
12
13 // border is where the line from node's centre toward a point leaves it.
14 function border(node, toward) {
15 const c = center(node);
16 const dx = toward.x - c.x, dy = toward.y - c.y;
17 const sx = dx === 0 ? Infinity : (node.w / 2) / Math.abs(dx);
18 const sy = dy === 0 ? Infinity : (node.h / 2) / Math.abs(dy);
19 const s = Math.min(sx, sy);
20 return { x: c.x + dx * s, y: c.y + dy * s };
21 }
22
23 function free(rect, node, nodes) {
24 return Object.values(nodes).every((other) => other === node ||
25 rect.x + rect.w <= other.x || other.x + other.w <= rect.x || rect.y + rect.h <= other.y || other.y + other.h <= rect.y);
26 }
27
28 function routed(edge, from, to) {
29 const via = edge.via.map(([x, y]) => ({ x, y }));
30 const points = [border(from, via[0]), ...via, border(to, via[via.length - 1])];
31 const last = points.length - 1;
32 const before = points[last - 1], end = points[last];
33 const length = Math.hypot(end.x - before.x, end.y - before.y) || 1;
34 points[last] = { x: end.x - (end.x - before.x) * (7 / length), y: end.y - (end.y - before.y) * (7 / length) };
35 let d = `M ${points[0].x} ${points[0].y}`;
36 for (let at = 1; at < last; at++) {
37 const p = points[at], q = points[at - 1], r = points[at + 1];
38 const inLength = Math.hypot(p.x - q.x, p.y - q.y) || 1, outLength = Math.hypot(r.x - p.x, r.y - p.y) || 1;
39 const into = Math.min(16, inLength / 2), out = Math.min(16, outLength / 2);
40 d += ` L ${p.x - (p.x - q.x) * into / inLength} ${p.y - (p.y - q.y) * into / inLength}`;
41 d += ` Q ${p.x} ${p.y} ${p.x + (r.x - p.x) * out / outLength} ${p.y + (r.y - p.y) * out / outLength}`;
42 }
43 d += ` L ${points[last].x} ${points[last].y}`;
44 const middle = Math.floor(last / 2);
45 return { d, badge: { x: (points[middle].x + points[middle + 1].x) / 2, y: (points[middle].y + points[middle + 1].y) / 2 } };
46 }
47
48 function geometry(edge, nodes) {
49 const from = nodes[edge.from], to = nodes[edge.to];
50 if (from === to) {
51 if (free({ x: from.x, y: from.y + from.h, w: from.w, h: 66 }, from, nodes)) {
52 const y = from.y + from.h, x1 = from.x + from.w * 0.38, x2 = from.x + from.w * 0.62;
53 return { d: `M ${x1} ${y} C ${x1 - 26} ${y + 62}, ${x2 + 26} ${y + 62}, ${x2} ${y + 2}`, badge: { x: (x1 + x2) / 2, y: y + 47 } };
54 }
55 const x = from.x + from.w, y1 = from.y + from.h * 0.32, y2 = from.y + from.h * 0.68;
56 return { d: `M ${x} ${y1} C ${x + 62} ${y1 - 26}, ${x + 62} ${y2 + 26}, ${x + 2} ${y2}`, badge: { x: x + 47, y: (y1 + y2) / 2 } };
57 }
58 if (edge.via && edge.via.length) return routed(edge, from, to);
59 let a = border(from, center(to)), b = border(to, center(from));
60 const dx = b.x - a.x, dy = b.y - a.y, length = Math.hypot(dx, dy) || 1;
61 const n = { x: -dy / length, y: dx / length };
62 const bend = edge.bend || 0;
63 a = { x: a.x + n.x * bend * 0.7, y: a.y + n.y * bend * 0.7 };
64 b = { x: b.x + n.x * bend * 0.7, y: b.y + n.y * bend * 0.7 };
65 const c = { x: (a.x + b.x) / 2 + n.x * bend * 1.6, y: (a.y + b.y) / 2 + n.y * bend * 1.6 };
66 const back = Math.max(Math.hypot(b.x - c.x, b.y - c.y), 1);
67 const end = { x: b.x - (b.x - c.x) * (7 / back), y: b.y - (b.y - c.y) * (7 / back) };
68 return { d: `M ${a.x} ${a.y} Q ${c.x} ${c.y} ${end.x} ${end.y}`, badge: { x: 0.25 * a.x + 0.5 * c.x + 0.25 * b.x, y: 0.25 * a.y + 0.5 * c.y + 0.25 * b.y } };
69 }
70
71 function tip(event, title, detail) {
72 if (!tooltip) { tooltip = el("div", { class: "tooltip", hidden: true }); document.body.append(tooltip); }
73 tooltip.hidden = false;
74 tooltip.replaceChildren(el("b", {}, title), detail ? el("br") : null, detail || "");
75 tooltip.style.left = Math.max(8, Math.min(event.clientX + 14, window.innerWidth - 330)) + "px";
76 tooltip.style.top = event.clientY + 16 + "px";
77 }
78 function untip() { if (tooltip) tooltip.hidden = true; }
79
80 const stopWords = new Set("a an and are as at be by can do does for from how i if in is it its of on or so that the this to was what when where which who why will with you your not no".split(" "));
81 function words(text) {
82 return (text.toLowerCase().match(/[a-z0-9]+/g) || []).filter((word) => word.length > 1 && !stopWords.has(word)).map((word) => word.replace(/(ing|ed|es|s)$/, "") || word);
83 }
84
85 class FlowPlayer {
86 constructor(container, flow, catalogue) {
87 this.flow = flow;
88 this.catalogue = catalogue || { modules: {} };
89 this.index = 0;
90 this.timer = null;
91 this.tab = "why";
92 this.previous = {};
93 this.build(container);
94 this.go(0, true);
95 }
96
97 build(container) {
98 const flow = this.flow;
99 const nodes = Object.fromEntries(flow.nodes.map((node) => [node.id, node]));
100 this.chapterButtons = flow.chapters.map((title, at) => el("button", { type: "button", class: "chapter", onclick: () => this.go(flow.events.findIndex((e) => e.chapter === at)) },
101 el("span", { class: "chapter-number" }, String(at + 1)), el("span", {}, title)));
102 this.stage = el("div", { class: "stage", style: `width:${flow.width}px;height:${flow.height}px` });
103 this.sizer = el("div", { style: "position:relative" }, this.stage);
104 this.frame = el("div", { class: "stage-frame", tabindex: "0", "aria-label": `${flow.title}: the diagram. Use the arrow keys to step.` }, this.sizer);
105 this.frame.addEventListener("keydown", (event) => this.keys(event));
106
107 const layer = svg("svg", { width: flow.width, height: flow.height, "aria-hidden": "true" },
108 svg("defs", {},
109 svg("marker", { id: `arrow-${flow.id}`, viewBox: "0 0 10 10", refX: "6", refY: "5", markerWidth: "7", markerHeight: "7", orient: "auto-start-reverse" },
110 svg("path", { d: "M 0 0 L 10 5 L 0 10 z", style: "fill: var(--line-strong)" })),
111 svg("marker", { id: `arrow-hot-${flow.id}`, viewBox: "0 0 10 10", refX: "6", refY: "5", markerWidth: "6", markerHeight: "6", orient: "auto-start-reverse" },
112 svg("path", { d: "M 0 0 L 10 5 L 0 10 z", style: "fill: var(--hot)" }))));
113 const badges = svg("svg", { width: flow.width, height: flow.height, style: "pointer-events:none" });
114 this.edges = {};
115 for (const edge of flow.edges) {
116 const shape = geometry(edge, nodes);
117 const path = svg("path", { d: shape.d, class: "line", "marker-end": `url(#arrow-${flow.id})` });
118 const hit = svg("path", { d: shape.d, class: "hit" });
119 const badge = svg("g", { class: "badge", transform: `translate(${shape.badge.x} ${shape.badge.y})` }, svg("circle", { r: "11" }), svg("text", {}, String(edge.id)));
120 for (const target of [hit, badge]) {
121 target.addEventListener("mousemove", (event) => tip(event, `${edge.id}. ${edge.label}`, this.uses(edge)));
122 target.addEventListener("mouseleave", untip);
123 target.addEventListener("click", () => this.aboutEdge(edge));
124 }
125 const group = svg("g", { class: "edge" }, path, hit);
126 const badgeGroup = svg("g", { class: "edge" }, badge);
127 layer.append(group);
128 badges.append(badgeGroup);
129 this.edges[edge.id] = { group, badgeGroup, path, d: shape.d };
130 }
131 this.layer = layer;
132 this.boxes = {};
133 const boxes = flow.nodes.map((node) => {
134 const lines = el("div", { class: "lines" });
135 const cells = el("div", { class: "cells", hidden: true });
136 const box = el("div", { class: `node kind-${node.kind}`, style: `left:${node.x}px;top:${node.y}px;width:${node.w}px;height:${node.h}px` },
137 el("div", { class: "title" }, node.title),
138 node.module ? el("div", { class: "module" }, node.module) : el("div", { class: "module outside" }, "outside goframework"),
139 lines, cells);
140 box.addEventListener("mousemove", (event) => tip(event, node.title, node.module || "outside goframework: something it reacts to"));
141 box.addEventListener("mouseleave", untip);
142 box.addEventListener("click", () => this.aboutNode(node));
143 this.boxes[node.id] = { box, lines, cells };
144 return box;
145 });
146 this.stage.append(layer, ...boxes, badges);
147
148 this.playButton = el("button", { class: "ctl play", type: "button", onclick: () => (this.timer ? this.stop() : this.play()) }, "▶ Play");
149 this.range = el("input", { type: "range", min: "0", max: String(flow.events.length - 1), value: "0", "aria-label": "Step" });
150 this.range.addEventListener("input", () => this.go(Number(this.range.value)));
151 this.where = el("span", { class: "where" });
152 const ctl = (label, text, action) => el("button", { class: "ctl", type: "button", "aria-label": label, title: label, onclick: action }, text);
153 const controls = el("div", { class: "controls" },
154 ctl("Previous step (←)", "◀", () => this.go(this.index - 1)), this.playButton, ctl("Next step (→)", "▶", () => this.go(this.index + 1)),
155 this.range, this.where);
156
157 this.stepText = el("p", { class: "step-text", "aria-live": "polite" });
158 this.stepMeta = el("p", { class: "step-meta" });
159 this.tabButtons = {};
160 const tabs = el("div", { class: "tabs", role: "tablist" }, [["why", "Why"], ["how", "How the code does it"], ["ask", "Ask"], ["all", "Every event"]].map(([id, label]) => {
161 const button = el("button", { type: "button", role: "tab", onclick: () => { this.tab = id; this.drawTab(); } }, label);
162 this.tabButtons[id] = button;
163 return button;
164 }));
165 this.tabPanel = el("div", { class: "tab-panel" });
166 this.timeline = el("ol", { class: "timeline" }, flow.events.map((event, at) => el("li", { onclick: () => this.go(at) },
167 el("span", { class: "t" }, event.at.toFixed(1) + " s"), el("span", { class: "e" }, event.edge ? String(event.edge) : "·"), el("span", {}, event.text))));
168 this.askInput = el("input", { type: "search", placeholder: "Ask why or how…", "aria-label": "Ask about this flow" });
169 this.askForm = el("form", { class: "asker" }, this.askInput, el("button", { type: "submit" }, "Ask"));
170 this.askForm.addEventListener("submit", (event) => { event.preventDefault(); this.ask(this.askInput.value); });
171 this.results = el("div", { class: "results" });
172 this.suggestions = el("div", { class: "suggestions" }, flow.questions.slice(0, 6).map((question) =>
173 el("button", { type: "button", onclick: () => { this.askInput.value = question.q; this.ask(question.q); } }, question.q)));
174
175 const recorded = el("p", { class: "recorded" }, `Recorded ${flow.recorded.date} with ${flow.recorded.go}, the modules at ` +
176 Object.entries(flow.recorded.modules).map(([module, commit]) => `${module} ${commit}`).join(", ") +
177 ". Times are the program's own, on a fake clock (testing/synctest): no waiting.");
178 container.replaceChildren(
179 el("p", { class: "flow-summary" }, flow.summary),
180 el("details", { class: "scenario" }, el("summary", {}, "The code this recording ran"), codeBlock(flow.scenario)),
181 el("div", { class: "chapters" }, this.chapterButtons),
182 this.frame, controls,
183 el("div", { class: "step-box" }, this.stepText, this.stepMeta, tabs, this.tabPanel),
184 recorded);
185 const fit = () => {
186 const width = this.frame.clientWidth;
187 if (!width) return;
188 const scale = Math.max(0.6, Math.min(1, width / flow.width));
189 this.stage.style.transform = `scale(${scale})`;
190 this.sizer.style.width = flow.width * scale + "px";
191 this.sizer.style.height = flow.height * scale + "px";
192 this.frame.style.overflowX = flow.width * scale > width + 1 ? "auto" : "hidden";
193 };
194 new ResizeObserver(fit).observe(this.frame);
195 fit();
196 }
197
198 keys(event) {
199 const actions = { ArrowRight: () => this.go(this.index + 1), ArrowLeft: () => this.go(this.index - 1), Home: () => this.go(0),
200 End: () => this.go(this.flow.events.length - 1), " ": () => (this.timer ? this.stop() : this.play()) };
201 if (actions[event.key]) { event.preventDefault(); actions[event.key](); }
202 }
203
204 uses(edge) {
205 const count = this.flow.events.filter((event) => event.edge === edge.id).length;
206 return count === 0 ? "not taken in this recording" : count === 1 ? "taken once" : `taken ${count} times`;
207 }
208
209 go(at, first) {
210 const flow = this.flow;
211 this.index = Math.max(0, Math.min(flow.events.length - 1, at));
212 const event = flow.events[this.index];
213 for (const node of flow.nodes) this.drawNode(node, event.state[node.id] || { lines: [] }, node.id === event.node, first);
214 for (const edge of flow.edges) {
215 const taken = flow.events.slice(0, this.index + 1).some((each) => each.edge === edge.id);
216 const active = edge.id === event.edge;
217 for (const group of [this.edges[edge.id].group, this.edges[edge.id].badgeGroup]) {
218 group.classList.toggle("active", active);
219 group.classList.toggle("taken", taken && !active);
220 }
221 this.edges[edge.id].path.setAttribute("marker-end", active ? `url(#arrow-hot-${flow.id})` : `url(#arrow-${flow.id})`);
222 }
223 this.pulse(event.edge);
224 this.range.value = String(this.index);
225 this.where.textContent = `${this.index + 1} / ${flow.events.length} · ${event.at.toFixed(2)} s`;
226 this.chapterButtons.forEach((button, at) => button.classList.toggle("current", at === event.chapter));
227 const answer = flow.answers[event.kind] || {};
228 const edge = flow.edges.find((each) => each.id === event.edge);
229 const node = flow.nodes.find((each) => each.id === event.node);
230 this.stepText.textContent = event.text;
231 this.stepMeta.textContent = [answer.title, node && node.title, edge && `arrow ${edge.id}: ${edge.label}`].filter(Boolean).join(" · ");
232 for (const [at, item] of Array.from(this.timeline.children).entries()) {
233 item.classList.toggle("current", at === this.index);
234 item.classList.toggle("future", at > this.index);
235 }
236 this.drawTab();
237 }
238
239 drawNode(node, view, active, first) {
240 const part = this.boxes[node.id];
241 const before = this.previous[node.id] || {};
242 const now = {};
243 part.lines.replaceChildren(...(view.lines || []).map(([label, value]) => {
244 now[label] = value;
245 const changed = !first && label in before && before[label] !== value;
246 return el("div", {}, el("span", {}, label), el("b", { class: changed ? "changed" : "", title: value }, value === "" ? "—" : value));
247 }));
248 this.previous[node.id] = now;
249 part.box.className = `node kind-${node.kind}` + (view.status ? ` status-${view.status}` : "") + (active ? " active" : "");
250 if (view.cells) {
251 part.cells.hidden = false;
252 const items = view.cells.items || [];
253 const cells = [];
254 for (let at = 0; at < view.cells.size; at++) cells.push(el("span", { class: "cell" + (at < items.length ? " full" : "") }, at < items.length ? items[at] : ""));
255 part.cells.replaceChildren(el("span", {}, view.cells.label), ...cells);
256 } else {
257 part.cells.hidden = true;
258 }
259 }
260
261 pulse(edgeId) {
262 const old = this.layer.querySelector(".pulse");
263 if (old) old.remove();
264 if (!edgeId || reducedMotion) return;
265 const dot = svg("circle", { r: "5", class: "pulse" }, svg("animateMotion", { dur: "0.7s", repeatCount: "1", fill: "freeze", path: this.edges[edgeId].d }));
266 this.layer.append(dot);
267 try { dot.firstChild.beginElement(); } catch (error) { /* no SMIL: no dot */ }
268 }
269
270 drawTab() {
271 for (const [id, button] of Object.entries(this.tabButtons)) button.setAttribute("aria-selected", String(id === this.tab));
272 const event = this.flow.events[this.index];
273 const answer = this.flow.answers[event.kind] || {};
274 if (this.tab === "why") this.tabPanel.replaceChildren(el("p", {}, answer.why || ""));
275 if (this.tab === "how") this.tabPanel.replaceChildren(el("p", {}, answer.how || ""), ...(answer.code || []).map((code) => this.codeRef(code)));
276 if (this.tab === "ask") this.tabPanel.replaceChildren(this.askForm, this.suggestions, this.results);
277 if (this.tab === "all") {
278 this.tabPanel.replaceChildren(this.timeline);
279 const current = this.timeline.children[this.index];
280 if (current) this.timeline.scrollTop = current.offsetTop - this.timeline.clientHeight / 2;
281 }
282 }
283
284 codeRef(code) {
285 return el("div", { class: "code-ref" },
286 el("header", {}, el("span", {}, `${code.module}/${code.file}:${code.line}`), el("span", {}, code.symbol)), codeBlock(code.source));
287 }
288
289 play() {
290 if (this.index >= this.flow.events.length - 1) this.go(0);
291 this.playButton.textContent = "❚❚ Pause";
292 const tick = () => {
293 if (this.index >= this.flow.events.length - 1) { this.stop(); return; }
294 this.go(this.index + 1);
295 this.timer = setTimeout(tick, 1600);
296 };
297 this.timer = setTimeout(tick, 500);
298 }
299
300 stop() {
301 if (this.timer) clearTimeout(this.timer);
302 this.timer = null;
303 this.playButton.textContent = "▶ Play";
304 }
305
306 aboutNode(node) {
307 const module = node.module && this.catalogue.modules[node.module];
308 const count = this.flow.events.filter((event) => event.node === node.id).length;
309 this.tab = "ask";
310 this.drawTab();
311 this.show([{ title: node.title + (node.module ? ` (${node.module})` : ""),
312 paragraphs: [module ? module.summary : "Outside goframework: something your program or the world does.", `${count} of the ${this.flow.events.length} events happen here.`],
313 link: node.module ? `#/m/${node.module}` : null }], node.title);
314 }
315
316 aboutEdge(edge) {
317 const events = this.flow.events.map((event, at) => ({ event, at })).filter(({ event }) => event.edge === edge.id);
318 this.tab = "ask";
319 this.drawTab();
320 this.show([{ title: `Arrow ${edge.id}: ${edge.label}`, paragraphs: [this.uses(edge) + ".", ...events.slice(0, 3).map(({ event }) => `${event.at.toFixed(2)} s: ${event.text}`)],
321 jump: events.length ? events.find(({ at }) => at > this.index) || events[0] : null }], `arrow ${edge.id}`);
322 }
323
324 ask(question) {
325 question = question.trim();
326 if (!question) return;
327 const wanted = words(question);
328 const entries = [];
329 for (const [kind, answer] of Object.entries(this.flow.answers)) entries.push({ title: answer.title, kind, paragraphs: [`Why: ${answer.why}`, `How: ${answer.how}`], code: answer.code, text: `${answer.title} ${answer.why} ${answer.how}` });
330 for (const question of this.flow.questions) entries.push({ title: question.q, paragraphs: [question.a], text: `${question.q} ${question.q} ${question.a}` });
331 const scored = entries.map((entry) => {
332 const have = words(entry.text), title = words(entry.title);
333 let score = 0, matched = 0;
334 for (const word of wanted) {
335 const hits = have.filter((each) => each === word).length;
336 if (hits) matched++;
337 score += Math.min(hits, 3) + (title.includes(word) ? 3 : 0);
338 }
339 return { entry, score: score * (matched / Math.max(wanted.length, 1)) };
340 }).filter((each) => each.score > 0).sort((a, b) => b.score - a.score).slice(0, 3);
341 this.show(scored.map(({ entry }) => {
342 if (!entry.kind) return entry;
343 const found = this.flow.events.map((event, at) => ({ event, at })).filter(({ event }) => event.kind === entry.kind);
344 return Object.assign({}, entry, { jump: found.find(({ at }) => at >= this.index) || found[0] });
345 }), question);
346 }
347
348 show(results, question) {
349 const items = results.length ? results.map((result) => el("div", { class: "result" },
350 el("h4", {}, result.title), ...(result.paragraphs || []).map((text) => el("p", {}, text)),
351 ...(result.code || []).slice(0, 1).map((code) => this.codeRef(code)),
352 result.jump ? el("button", { type: "button", class: "copy", onclick: () => this.go(result.jump.at) }, `Go to step ${result.jump.at + 1}`) : null,
353 result.link ? el("a", { href: result.link, style: "margin-left:8px;font-size:13.5px" }, "The module") : null))
354 : [el("p", { class: "muted" }, "Nothing in this recording answers that. Ask an AI, with this step's context:")];
355 const prompt = this.prompt(question);
356 const copyPrompt = el("button", { type: "button", class: "copy" }, "Copy as a prompt");
357 copyPrompt.addEventListener("click", () => copy(prompt, copyPrompt));
358 this.results.replaceChildren(...items, el("div", { class: "ai-links" }, el("span", { class: "muted" }, "Not enough?"),
359 el("a", { href: "https://claude.ai/new?q=" + encodeURIComponent(prompt), target: "_blank", rel: "noopener" }, "Ask Claude"),
360 el("a", { href: "https://chatgpt.com/?q=" + encodeURIComponent(prompt), target: "_blank", rel: "noopener" }, "Ask ChatGPT"), copyPrompt));
361 }
362
363 prompt(question) {
364 const flow = this.flow, event = flow.events[this.index], answer = flow.answers[event.kind] || {};
365 const state = flow.nodes.map((node) => {
366 const view = event.state[node.id];
367 if (!view) return "";
368 const lines = (view.lines || []).map(([label, value]) => `${label} ${value}`).join(", ");
369 const cells = view.cells ? `; ${view.cells.label}: [${(view.cells.items || []).join(" ")}] of ${view.cells.size}` : "";
370 return `- ${node.title}${node.module ? ` (${node.module})` : ""}: ${lines}${cells}`;
371 }).filter(Boolean).join("\n");
372 const code = (answer.code || []).map((c) => `${c.module}/${c.file}:${c.line} (${c.symbol})`).join(", ");
373 return `I am reading goframework.io's recorded flow "${flow.title}" (goframework: a Go toolkit of small modules, https://goframework.io).\n${flow.summary}\n\n` +
374 `At step ${this.index + 1} of ${flow.events.length} (t = ${event.at.toFixed(2)} s): ${event.text}\nThe state after it:\n${state}\n\n` +
375 `The page's explanation. Why: ${answer.why} How: ${answer.how}\nThe code: ${code}\n\nMy question: ${question}`;
376 }
377 }
378
379 window.GF.FlowPlayer = FlowPlayer;
380})();