(() => {
"use strict";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/* ---------------- nav ---------------- */
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;";
});
}
/* ---------------- scroll reveal ---------------- */
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));
}
/* ---------------- pipeline sequential activation ---------------- */
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"));
}
/* ---------------- diff console (signature element) ---------------- */
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 = `${ln.tag}`;
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 });
}
/* ---------------- D3 force graph ---------------- */
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(); }
}
/* ---------------- stat count-up ---------------- */
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);
}
/* ---------------- summary typewriter ---------------- */
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();
}
/* ---------------- JSON pretty print with diff highlighting ---------------- */
function renderJson(diff) {
const pretty = JSON.stringify(diff, null, 2);
return pretty
.replace(/"(added_\w+|removed_\w+|unchanged_\w+)":/g, '"$1":');
}
/* ---------------- demo orchestration ---------------- */
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;
}
// Never let the browser silently restore a previously typed API key on
// reload or back/forward navigation — it's never stored, but make that
// visibly true too.
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);
// auto-run the bundled demo once, the first time the section scrolls into view
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);
}
// re-render graphs cleanly on resize (debounced)
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);
});
})();