| 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 | })(); |