| (() => { |
| "use strict"; |
|
|
| const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; |
|
|
| |
| const nav = document.querySelector(".nav"); |
| if (nav) { |
| window.addEventListener("scroll", () => { |
| nav.classList.toggle("scrolled", window.scrollY > 8); |
| }, { passive: true }); |
| } |
|
|
| const navToggle = document.querySelector(".nav-toggle"); |
| const navLinks = document.querySelector(".nav-links"); |
| if (navToggle) { |
| navToggle.addEventListener("click", () => { |
| const open = navLinks.style.display === "flex"; |
| navLinks.style.display = open ? "none" : "flex"; |
| navLinks.style.cssText += open ? "" : "position:fixed;top:62px;left:0;right:0;background:#0b0d12;flex-direction:column;padding:18px 28px;border-bottom:1px solid var(--border);gap:16px;"; |
| }); |
| } |
|
|
| |
| const revealEls = document.querySelectorAll("[data-reveal]"); |
| if (reduceMotion) { |
| revealEls.forEach(el => el.classList.add("in-view")); |
| } else { |
| const io = new IntersectionObserver((entries) => { |
| entries.forEach(e => { |
| if (e.isIntersecting) { |
| e.target.classList.add("in-view"); |
| io.unobserve(e.target); |
| } |
| }); |
| }, { threshold: 0.15 }); |
| revealEls.forEach(el => io.observe(el)); |
| } |
|
|
| |
| const pipeStages = document.querySelectorAll(".pipe-stage"); |
| if (pipeStages.length) { |
| const pipeIO = new IntersectionObserver((entries) => { |
| entries.forEach(e => { |
| if (e.isIntersecting) { |
| pipeStages.forEach((stage, i) => { |
| setTimeout(() => stage.classList.add("active"), reduceMotion ? 0 : i * 260); |
| }); |
| pipeIO.disconnect(); |
| } |
| }); |
| }, { threshold: 0.4 }); |
| pipeIO.observe(document.querySelector(".pipeline")); |
| } |
|
|
| |
| function buildConsoleLines(diff) { |
| const lines = []; |
| diff.added_entities.forEach(e => lines.push({ type: "add", tag: "+", body: `entity ${e}` })); |
| diff.removed_entities.forEach(e => lines.push({ type: "rem", tag: "-", body: `entity ${e}` })); |
| diff.added_relations.forEach(([s, p, o]) => lines.push({ type: "add", tag: "+", body: `relation ${s} --${p}--> ${o}` })); |
| diff.removed_relations.forEach(([s, p, o]) => lines.push({ type: "rem", tag: "-", body: `relation ${s} --${p}--> ${o}` })); |
| lines.push({ |
| type: "info", tag: "\u2713", |
| body: `diff complete \u2014 ${diff.added_entities.length + diff.added_relations.length} added \u00b7 ${diff.removed_entities.length + diff.removed_relations.length} removed \u00b7 ${diff.unchanged_entities.length + diff.unchanged_relations.length} unchanged` |
| }); |
| return lines; |
| } |
|
|
| function playConsole(el, lines, { loop = false, lineDelay = 110, restartDelay = 4200 } = {}) { |
| let timers = []; |
| function clearTimers() { timers.forEach(t => clearTimeout(t)); timers = []; } |
| function run() { |
| clearTimers(); |
| el.innerHTML = ""; |
| lines.forEach((ln, i) => { |
| const t = setTimeout(() => { |
| const row = document.createElement("div"); |
| row.className = `console-line ${ln.type}`; |
| row.innerHTML = `<span class="tag">${ln.tag}</span><span class="body"></span>`; |
| row.querySelector(".body").textContent = ln.body; |
| el.appendChild(row); |
| }, reduceMotion ? 0 : i * lineDelay); |
| timers.push(t); |
| }); |
| const cursor = document.createElement("span"); |
| cursor.className = "cursor"; |
| const t2 = setTimeout(() => el.appendChild(cursor), reduceMotion ? 0 : lines.length * lineDelay + 100); |
| timers.push(t2); |
| if (loop && !reduceMotion) { |
| const t3 = setTimeout(run, lines.length * lineDelay + restartDelay); |
| timers.push(t3); |
| } |
| } |
| run(); |
| return clearTimers; |
| } |
|
|
| const heroConsole = document.getElementById("hero-console"); |
| if (heroConsole) { |
| playConsole(heroConsole, buildConsoleLines(DEMO_FIXTURE.diff), { loop: true, lineDelay: 95, restartDelay: 3600 }); |
| } |
|
|
| |
| function colorForNode(id, diff) { |
| if (diff.added_entities.includes(id)) return "var(--add)"; |
| if (diff.removed_entities.includes(id)) return "var(--rem)"; |
| return "var(--neu)"; |
| } |
| function tripleKey(s, p, o) { return `${s}\u0001${p}\u0001${o}`; } |
| function colorForEdge(link, diff) { |
| const k = tripleKey(link.source.id || link.source, link.label, link.target.id || link.target); |
| const addedSet = new Set(diff.added_relations.map(([s, p, o]) => tripleKey(s, p, o))); |
| const removedSet = new Set(diff.removed_relations.map(([s, p, o]) => tripleKey(s, p, o))); |
| if (addedSet.has(k)) return "var(--add)"; |
| if (removedSet.has(k)) return "var(--rem)"; |
| return "var(--edge)"; |
| } |
|
|
| function renderGraph(svgEl, graph, diff) { |
| const svg = d3.select(svgEl); |
| svg.selectAll("*").remove(); |
| const bbox = svgEl.getBoundingClientRect(); |
| const width = bbox.width || 460; |
| const height = bbox.height || 300; |
| svg.attr("viewBox", `0 0 ${width} ${height}`); |
|
|
| if (!graph.nodes.length) { |
| svg.append("text") |
| .attr("x", width / 2).attr("y", height / 2) |
| .attr("text-anchor", "middle") |
| .attr("fill", "var(--text-faint)") |
| .attr("font-family", "var(--mono)") |
| .attr("font-size", 12) |
| .text("no entities extracted"); |
| return; |
| } |
|
|
| const nodes = graph.nodes.map(d => ({ ...d })); |
| const links = graph.links.map(d => ({ ...d })); |
|
|
| const defs = svg.append("defs"); |
| ["add", "rem", "edge"].forEach(kind => { |
| defs.append("marker") |
| .attr("id", `arrow-${kind}-${svgEl.id}`) |
| .attr("viewBox", "0 -5 10 10") |
| .attr("refX", 19).attr("refY", 0) |
| .attr("markerWidth", 6).attr("markerHeight", 6) |
| .attr("orient", "auto") |
| .append("path") |
| .attr("d", "M0,-5L10,0L0,5") |
| .attr("fill", `var(--${kind === "edge" ? "edge" : kind})`); |
| }); |
|
|
| const sim = d3.forceSimulation(nodes) |
| .force("link", d3.forceLink(links).id(d => d.id).distance(78).strength(0.7)) |
| .force("charge", d3.forceManyBody().strength(-220)) |
| .force("center", d3.forceCenter(width / 2, height / 2)) |
| .force("collide", d3.forceCollide(26)); |
|
|
| const link = svg.append("g").selectAll("line") |
| .data(links).join("line") |
| .attr("stroke", d => colorForEdge(d, diff)) |
| .attr("stroke-width", d => { |
| const k = tripleKey(d.source.id || d.source, d.label, d.target.id || d.target); |
| const changed = diff.added_relations.some(([s, p, o]) => tripleKey(s, p, o) === k) || |
| diff.removed_relations.some(([s, p, o]) => tripleKey(s, p, o) === k); |
| return changed ? 2.2 : 1.1; |
| }) |
| .attr("marker-end", d => { |
| const k = tripleKey(d.source.id || d.source, d.label, d.target.id || d.target); |
| if (diff.added_relations.some(([s, p, o]) => tripleKey(s, p, o) === k)) return `url(#arrow-add-${svgEl.id})`; |
| if (diff.removed_relations.some(([s, p, o]) => tripleKey(s, p, o) === k)) return `url(#arrow-rem-${svgEl.id})`; |
| return `url(#arrow-edge-${svgEl.id})`; |
| }) |
| .attr("opacity", 0.85); |
|
|
| link.append("title").text(d => d.label); |
|
|
| const node = svg.append("g").selectAll("circle") |
| .data(nodes).join("circle") |
| .attr("r", d => (diff.added_entities.includes(d.id) || diff.removed_entities.includes(d.id)) ? 9 : 7) |
| .attr("fill", d => colorForNode(d.id, diff)) |
| .attr("stroke", "var(--bg-card)") |
| .attr("stroke-width", 2) |
| .style("cursor", "grab") |
| .call(d3.drag() |
| .on("start", (event, d) => { if (!event.active) sim.alphaTarget(0.25).restart(); d.fx = d.x; d.fy = d.y; }) |
| .on("drag", (event, d) => { d.fx = event.x; d.fy = event.y; }) |
| .on("end", (event, d) => { if (!event.active) sim.alphaTarget(0); d.fx = null; d.fy = null; })); |
|
|
| node.append("title").text(d => d.id); |
|
|
| const label = svg.append("g").selectAll("text") |
| .data(nodes).join("text") |
| .attr("class", "graph-node-label") |
| .attr("text-anchor", "middle") |
| .attr("dy", -12) |
| .text(d => d.id.length > 16 ? d.id.slice(0, 15) + "\u2026" : d.id); |
|
|
| sim.on("tick", () => { |
| link |
| .attr("x1", d => d.source.x).attr("y1", d => d.source.y) |
| .attr("x2", d => d.target.x).attr("y2", d => d.target.y); |
| node.attr("cx", d => d.x).attr("cy", d => d.y); |
| label.attr("x", d => d.x).attr("y", d => d.y); |
| }); |
|
|
| if (reduceMotion) { for (let i = 0; i < 200; i++) sim.tick(); sim.stop(); } |
| } |
|
|
| |
| function countUp(el, target, duration = 700) { |
| if (reduceMotion) { el.textContent = target; return; } |
| const start = performance.now(); |
| function frame(now) { |
| const p = Math.min(1, (now - start) / duration); |
| const eased = 1 - Math.pow(1 - p, 3); |
| el.textContent = Math.round(eased * target); |
| if (p < 1) requestAnimationFrame(frame); |
| } |
| requestAnimationFrame(frame); |
| } |
|
|
| |
| function typewrite(el, text, speed = 14) { |
| el.textContent = ""; |
| const cursor = document.createElement("span"); |
| cursor.className = "typecursor"; |
| if (reduceMotion) { el.textContent = text; return; } |
| let i = 0; |
| el.appendChild(cursor); |
| function step() { |
| if (i <= text.length) { |
| el.textContent = text.slice(0, i); |
| el.appendChild(cursor); |
| i += 2; |
| requestAnimationFrame(() => setTimeout(step, speed)); |
| } else { |
| cursor.remove(); |
| } |
| } |
| step(); |
| } |
|
|
| |
| function renderJson(diff) { |
| const pretty = JSON.stringify(diff, null, 2); |
| return pretty |
| .replace(/"(added_\w+|removed_\w+|unchanged_\w+)":/g, '<span class="k">"$1"</span>:'); |
| } |
|
|
| |
| const els = { |
| v1: document.getElementById("input-v1"), |
| v2: document.getElementById("input-v2"), |
| apiKey: document.getElementById("input-apikey"), |
| advToggle: document.getElementById("adv-toggle"), |
| advPanel: document.getElementById("adv-panel"), |
| runBtn: document.getElementById("run-diff-btn"), |
| resetBtn: document.getElementById("reset-sample-btn"), |
| status: document.getElementById("demo-status"), |
| results: document.getElementById("demo-results"), |
| console: document.getElementById("demo-console"), |
| g1: document.getElementById("graph-v1"), |
| g2: document.getElementById("graph-v2"), |
| statAdd: document.getElementById("stat-add"), |
| statRem: document.getElementById("stat-rem"), |
| statAddRel: document.getElementById("stat-add-rel"), |
| statRemRel: document.getElementById("stat-rem-rel"), |
| summary: document.getElementById("summary-text"), |
| json: document.getElementById("json-output"), |
| }; |
|
|
| if (els.v1) { |
| els.v1.value = DEMO_FIXTURE.doc_v1; |
| els.v2.value = DEMO_FIXTURE.doc_v2; |
| } |
|
|
| |
| |
| |
| if (els.apiKey) { |
| els.apiKey.value = ""; |
| window.addEventListener("pageshow", () => { els.apiKey.value = ""; }); |
| } |
|
|
| if (els.advToggle) { |
| els.advToggle.addEventListener("click", () => { |
| els.advPanel.classList.toggle("open"); |
| els.advToggle.textContent = els.advPanel.classList.contains("open") |
| ? "\u2212 use your own Groq API key" |
| : "+ use your own Groq API key"; |
| }); |
| } |
|
|
| if (els.resetBtn) { |
| els.resetBtn.addEventListener("click", () => { |
| els.v1.value = DEMO_FIXTURE.doc_v1; |
| els.v2.value = DEMO_FIXTURE.doc_v2; |
| }); |
| } |
|
|
| function setStatus(text, live) { |
| els.status.querySelector(".status-text").textContent = text; |
| els.status.querySelector(".status-dot").classList.toggle("live", !!live); |
| } |
|
|
| function renderResults(data) { |
| els.results.classList.remove("hidden"); |
|
|
| playConsole(els.console, buildConsoleLines(data.diff), { loop: false, lineDelay: 70 }); |
|
|
| renderGraph(els.g1, data.graph_json.v1, data.diff); |
| renderGraph(els.g2, data.graph_json.v2, data.diff); |
|
|
| countUp(els.statAdd, data.diff.added_entities.length); |
| countUp(els.statRem, data.diff.removed_entities.length); |
| countUp(els.statAddRel, data.diff.added_relations.length); |
| countUp(els.statRemRel, data.diff.removed_relations.length); |
|
|
| typewrite(els.summary, data.summary); |
| els.json.innerHTML = renderJson(data.diff); |
|
|
| els.results.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "nearest" }); |
| } |
|
|
| async function runDiff() { |
| const v1 = els.v1.value.trim(); |
| const v2 = els.v2.value.trim(); |
| const key = els.apiKey ? els.apiKey.value.trim() : ""; |
|
|
| els.runBtn.disabled = true; |
| const originalLabel = els.runBtn.textContent; |
| els.runBtn.textContent = "computing diff\u2026"; |
|
|
| if (!key) { |
| setStatus("bundled sample \u2014 add a Groq key above to run on your own text", false); |
| renderResults(DEMO_FIXTURE); |
| els.runBtn.disabled = false; |
| els.runBtn.textContent = originalLabel; |
| return; |
| } |
|
|
| try { |
| setStatus("calling live backend\u2026", true); |
| const res = await fetch("/api/diff", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ v1, v2, api_key: key }), |
| }); |
| const data = await res.json().catch(() => ({})); |
| if (!res.ok || data.error) throw new Error(data.error || `backend responded ${res.status}`); |
| setStatus("live result from your Flask backend + Groq", true); |
| renderResults(data); |
| } catch (err) { |
| setStatus(`couldn't reach the live backend (${err.message}) \u2014 showing bundled sample instead`, false); |
| renderResults(DEMO_FIXTURE); |
| } finally { |
| els.runBtn.disabled = false; |
| els.runBtn.textContent = originalLabel; |
| } |
| } |
|
|
| if (els.runBtn) els.runBtn.addEventListener("click", runDiff); |
|
|
| |
| const demoSection = document.getElementById("demo"); |
| if (demoSection) { |
| const demoIO = new IntersectionObserver((entries) => { |
| entries.forEach(e => { |
| if (e.isIntersecting) { |
| setStatus("bundled sample diff \u2014 computed from this repo's own data/doc_v1.txt & doc_v2.txt", false); |
| renderResults(DEMO_FIXTURE); |
| demoIO.disconnect(); |
| } |
| }); |
| }, { threshold: 0.3 }); |
| demoIO.observe(demoSection); |
| } |
|
|
| |
| let resizeT; |
| window.addEventListener("resize", () => { |
| clearTimeout(resizeT); |
| resizeT = setTimeout(() => { |
| if (els.results && !els.results.classList.contains("hidden")) { |
| renderGraph(els.g1, DEMO_FIXTURE.graph_json.v1, DEMO_FIXTURE.diff); |
| renderGraph(els.g2, DEMO_FIXTURE.graph_json.v2, DEMO_FIXTURE.diff); |
| } |
| }, 250); |
| }); |
|
|
| })(); |
|
|