Spaces:
Runtime error
Runtime error
| /* PlaigLab front-of-house: upload -> live investigation log -> dossier report */ | |
| ; | |
| const $ = (id) => document.getElementById(id); | |
| const views = { upload: $("view-upload"), progress: $("view-progress"), report: $("view-report") }; | |
| const caseNo = Math.random().toString(36).slice(2, 8).toUpperCase(); | |
| $("caseNo").textContent = `CASE Nº ${caseNo}`; | |
| function show(name) { | |
| Object.entries(views).forEach(([k, el]) => { el.hidden = k !== name; }); | |
| window.scrollTo({ top: 0, behavior: "smooth" }); | |
| } | |
| /* ---------------- upload wiring ---------------- */ | |
| const dz = $("dropzone"); | |
| const fileInput = $("fileInput"); | |
| dz.addEventListener("click", () => fileInput.click()); | |
| dz.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") fileInput.click(); }); | |
| ["dragover", "dragenter"].forEach(ev => dz.addEventListener(ev, (e) => { | |
| e.preventDefault(); dz.classList.add("drag"); | |
| })); | |
| ["dragleave", "drop"].forEach(ev => dz.addEventListener(ev, (e) => { | |
| e.preventDefault(); dz.classList.remove("drag"); | |
| })); | |
| dz.addEventListener("drop", (e) => { | |
| const f = e.dataTransfer.files[0]; | |
| if (f) submitFile(f); | |
| }); | |
| fileInput.addEventListener("change", () => { | |
| if (fileInput.files[0]) submitFile(fileInput.files[0]); | |
| }); | |
| const depthParam = () => | |
| ($("maxToggle") && $("maxToggle").checked ? "max" | |
| : $("deepToggle").checked ? "deep" : "standard"); | |
| $("sampleBtn").addEventListener("click", async () => { | |
| const r = await fetch(`/api/sample?depth=${depthParam()}`, { method: "POST" }); | |
| const j = await r.json(); | |
| beginInvestigation(j.job_id, "specimen_case.txt"); | |
| }); | |
| $("newCaseBtn").addEventListener("click", () => { | |
| fileInput.value = ""; | |
| show("upload"); | |
| }); | |
| /* ---------------- past-case history ---------------- */ | |
| $("historyBtn").addEventListener("click", async () => { | |
| const box = $("caseHistory"); | |
| if (!box.hidden) { box.hidden = true; return; } | |
| const r = await fetch("/api/cases"); | |
| const j = await r.json(); | |
| const cases = j.cases || []; | |
| box.hidden = false; | |
| box.innerHTML = cases.length | |
| ? "<h3 class='sec-head'>Past cases</h3>" + cases.map(c => ` | |
| <div class="hist-row" data-case="${esc(c.case_id)}"> | |
| <span class="hist-verdict v-${esc((c.verdict||'').toLowerCase())}">${esc(c.verdict||'?')}</span> | |
| <span class="hist-title">${esc(c.title || c.filename || c.case_id)}</span> | |
| <span class="hist-meta">${c.similarity_index ?? "?"}% sim · AI ${c.ai_score ?? "?"} · ${esc(c.created||"")}</span> | |
| </div>`).join("") | |
| : "<p class='cov-note'>no past cases yet</p>"; | |
| box.querySelectorAll(".hist-row").forEach(row => { | |
| row.addEventListener("click", async () => { | |
| const id = row.getAttribute("data-case"); | |
| const rep = await fetch(`/api/cases/${id}`); | |
| if (rep.ok) { currentJobId = id; renderReport(await rep.json()); } | |
| }); | |
| }); | |
| }); | |
| async function submitFile(file) { | |
| const ext = file.name.toLowerCase().split(".").pop(); | |
| if (!["pdf", "txt", "docx"].includes(ext)) { alert("Only PDF, DOCX or TXT evidence is admissible."); return; } | |
| const fd = new FormData(); | |
| fd.append("file", file); | |
| const r = await fetch(`/api/analyze?depth=${depthParam()}`, { method: "POST", body: fd }); | |
| if (!r.ok) { alert("Upload failed: " + (await r.text())); return; } | |
| const j = await r.json(); | |
| beginInvestigation(j.job_id, file.name); | |
| } | |
| /* ---------------- progress polling ---------------- */ | |
| const STAGE_KEYS = ["extract", "understood", "searching", "graph", "scraping", "comparison", "ai-writing", "report"]; | |
| const STAGE_MATCH = { | |
| extract: /extracting/, understood: /document understood/, | |
| searching: /searching|search wave|unique candidates/, | |
| graph: /citation graph|historical canon|literature space/, | |
| scraping: /deep-scraping|full text acquired/, | |
| comparison: /deep comparison/, "ai-writing": /ai-writing/, report: /report ready/, | |
| }; | |
| let pollTimer = null; | |
| let currentJobId = null; | |
| function beginInvestigation(jobId, filename) { | |
| currentJobId = jobId; | |
| $("investFile").textContent = `evidence: ${filename} · case ${caseNo}`; | |
| $("console").innerHTML = ""; | |
| document.querySelectorAll("#custody li").forEach(li => li.className = ""); | |
| show("progress"); | |
| let seen = 0; | |
| pollTimer = setInterval(async () => { | |
| const r = await fetch(`/api/jobs/${jobId}`); | |
| if (!r.ok) return; | |
| const j = await r.json(); | |
| const log = j.log || []; | |
| for (; seen < log.length; seen++) appendLog(log[seen]); | |
| markStages(log); | |
| if (j.status === "done") { | |
| clearInterval(pollTimer); | |
| const rep = await fetch(`/api/jobs/${jobId}/report`); | |
| renderReport(await rep.json()); | |
| } else if (j.status === "error") { | |
| clearInterval(pollTimer); | |
| appendLog("INVESTIGATION FAILED: " + j.error); | |
| } | |
| }, 900); | |
| } | |
| function appendLog(msg) { | |
| const div = document.createElement("div"); | |
| div.textContent = msg; | |
| const con = $("console"); | |
| con.appendChild(div); | |
| con.scrollTop = con.scrollHeight; | |
| } | |
| function markStages(log) { | |
| const joined = log.join("\n"); | |
| let lastHit = -1; | |
| STAGE_KEYS.forEach((k, i) => { if (STAGE_MATCH[k].test(joined)) lastHit = i; }); | |
| document.querySelectorAll("#custody li").forEach((li, i) => { | |
| li.className = i < lastHit ? "done" : i === lastHit ? "active" : ""; | |
| }); | |
| } | |
| /* ---------------- report rendering ---------------- */ | |
| const ARC_LEN = 251.3; | |
| function setDial(arcId, needleId, valId, value, dur = 1200) { | |
| const v = Math.max(0, Math.min(100, value)); | |
| $(arcId).style.strokeDashoffset = ARC_LEN * (1 - v / 100); | |
| $(needleId).style.transform = `rotate(${-90 + 1.8 * v}deg)`; | |
| const el = $(valId); | |
| const t0 = performance.now(); | |
| (function tick(t) { | |
| const p = Math.min(1, (t - t0) / dur); | |
| el.textContent = (v * (1 - Math.pow(1 - p, 3))).toFixed(1); | |
| if (p < 1) requestAnimationFrame(tick); | |
| })(t0); | |
| } | |
| function esc(s) { | |
| const d = document.createElement("div"); | |
| d.textContent = s; | |
| return d.innerHTML; | |
| } | |
| function renderReport(r) { | |
| show("report"); | |
| $("rptCase").textContent = `CASE ${caseNo}`; | |
| $("rptTitle").textContent = r.title; | |
| const yrs = r.literature_years ? ` · literature ${r.literature_years[0]}–${r.literature_years[1]}` : ""; | |
| $("rptMeta").textContent = | |
| `${r.filename} · ${r.words.toLocaleString()} words · ${r.sentences} sentences · ` + | |
| `${(r.depth || "standard").toUpperCase()} sweep · ${r.candidates_screened} works screened${yrs} · ${r.elapsed_seconds}s`; | |
| const stamp = $("stamp"); | |
| // verdict now comes from the recall-first engine (R1): borderline = REVIEW | |
| const verdictCls = { PLAGIARIZED: "", SUSPICIOUS: "warn", REVIEW: "warn", CLEAN: "ok" }; | |
| const stampText = r.verdict || "REVIEW"; | |
| $("stampText").textContent = stampText; | |
| stamp.className = "stamp " + (verdictCls[stampText] ?? "warn"); | |
| stamp.style.animation = "none"; | |
| void stamp.offsetWidth; // restart slam animation | |
| stamp.style.animation = ""; | |
| $("verdictBasis").innerHTML = (r.verdict_reasons || []).length | |
| ? "<b>VERDICT BASIS</b> — " + r.verdict_reasons.map(esc).join(" · ") | |
| : ""; | |
| /* obfuscation / evasion evidence */ | |
| const ob = r.obfuscation || {}; | |
| const evp = $("evasionPanel"); | |
| if ((ob.homoglyph_count || 0) > 0 || (ob.zero_width_count || 0) > 0) { | |
| evp.hidden = false; | |
| const ex = (ob.homoglyph_examples || []).slice(0, 6) | |
| .map(e => `<code>${esc(e.codepoint)} ${esc(e.name)} → ${esc(e.folded_to)}</code>`).join(" "); | |
| evp.innerHTML = | |
| `<b>${ob.spoof_suspected ? "⚠ OBFUSCATION EVIDENCE — evasion attempt suspected" | |
| : "obfuscation artifacts (minor)"}</b> | |
| <span>${ob.homoglyph_count} homoglyph(s) · ${ob.zero_width_count} hidden character(s) | |
| · ${ob.invisible_per_10k_chars}/10k chars invisible</span> ${ex}`; | |
| } else { | |
| evp.hidden = true; | |
| } | |
| setDial("simArc", "simNeedle", "simVal", r.similarity_index); | |
| setDial("aiArc", "aiNeedle", "aiVal", r.ai_score); | |
| $("simSub").textContent = `${r.plagiarism_risk} RISK`; | |
| $("aiSub").textContent = r.ai_band.replace(/_/g, " ").toUpperCase(); | |
| $("statStack").innerHTML = [ | |
| ["sources matched", r.sources.length], | |
| ["full PDFs read", r.full_texts_read ?? r.sources.filter(s => s.deep_scraped).length], | |
| ["works screened", r.candidates_screened], | |
| ["via citation graph", r.graph_expanded ?? 0], | |
| ["via topic canon", r.topic_canon ?? 0], | |
| ["matched sentences", r.heatmap.filter(h => h.sim >= 0.55).length + " / " + r.sentences], | |
| ["semantic engine", r.semantic_enabled ? "ON (MiniLM)" : "OFF"], | |
| ["vault passages", (r.coverage && r.coverage.vault_passages) || 0], | |
| ].map(([k, v]) => `<div class="stat-row"><span>${k}</span><b>${v}</b></div>`).join(""); | |
| /* exhibits */ | |
| const list = $("sourceList"); | |
| list.innerHTML = r.sources.length ? "" : | |
| "<p style='font-family:var(--mono);font-size:14px'>No matching sources surfaced from the open indexes.</p>"; | |
| r.sources.forEach((s, i) => { | |
| const card = document.createElement("div"); | |
| card.className = "source-card"; | |
| card.style.animationDelay = `${0.08 * i}s`; | |
| const authors = (s.authors || []).filter(Boolean).join(", "); | |
| const pairs = (s.top_pairs || []).map(p => | |
| `<div class="pair-row"><div class="who">SUBMITTED — cosine ${p.similarity}</div>${esc(p.submitted)} | |
| <div class="who" style="margin-top:6px">SOURCE</div>${esc(p.source)}</div>`).join(""); | |
| card.innerHTML = ` | |
| <div class="exhibit-tag">${String.fromCharCode(65 + (i % 26))}</div> | |
| <div> | |
| <div class="src-title"><a href="${esc(s.url)}" target="_blank" rel="noopener">${esc(s.title)}</a></div> | |
| <div class="src-meta"> | |
| <span class="badge">${esc(s.provider)}</span> | |
| ${s.deep_scraped ? '<span class="badge deep">FULL TEXT SCRAPED</span>' : ""} | |
| ${s.ai_score != null ? `<span class="badge ai-src ${s.ai_score >= 70 ? "hot" : s.ai_score >= 45 ? "warm" : "ok"}">SOURCE AI ${s.ai_score}%</span>` : ""} | |
| ${esc(authors)}${authors ? " · " : ""}${esc(s.venue || "")} ${s.year || ""} | |
| · exact fingerprint ${s.exact_fingerprint_overlap}% | |
| </div> | |
| </div> | |
| <div class="src-pct">${s.match_percent}<small>%</small></div> | |
| <div class="src-bar"><i></i></div> | |
| ${pairs ? `<details class="src-pairs"><summary>matched passages (${s.top_pairs.length})</summary>${pairs}</details>` : ""}`; | |
| list.appendChild(card); | |
| requestAnimationFrame(() => | |
| requestAnimationFrame(() => { | |
| card.querySelector(".src-bar i").style.width = Math.min(100, s.match_percent) + "%"; | |
| })); | |
| }); | |
| /* near misses (R1: nothing hidden below the threshold) */ | |
| const nm = r.near_misses || []; | |
| $("nearBlock").hidden = nm.length === 0; | |
| $("nearMisses").innerHTML = nm.map(n => ` | |
| <div class="near-row"> | |
| <span class="badge">${esc(n.provider)}</span> | |
| ${n.deep_scraped ? '<span class="badge deep">FULL TEXT</span>' : ""} | |
| <a href="${esc(n.url)}" target="_blank" rel="noopener">${esc(n.title)}</a> | |
| <i>${n.year || ""}</i> | |
| <b>${n.match_percent}% · best sentence ${Math.round((n.best_sentence_sim || 0) * 100)}%</b> | |
| </div>`).join(""); | |
| /* manuscript heatmap */ | |
| const ms = $("manuscript"); | |
| ms.innerHTML = ""; | |
| r.heatmap.forEach(h => { | |
| const sp = document.createElement("span"); | |
| sp.textContent = h.text + " "; | |
| if ((h.exact || 0) >= 0.5) sp.className = "m-exact"; | |
| else if (h.sim >= 0.72) sp.className = "m-high"; | |
| else if (h.sim >= 0.55) sp.className = "m-mid"; | |
| if (h.sem && sp.className) sp.className += " m-sem"; | |
| if (h.source >= 0 && r.sources[h.source]) { | |
| sp.title = `${Math.round(h.sim * 100)}% similar${h.sem ? " (semantic)" : ""} — ${r.sources[h.source].title}`; | |
| } | |
| ms.appendChild(sp); | |
| }); | |
| /* AI strip */ | |
| const strip = $("aiStrip"); | |
| strip.innerHTML = ""; | |
| const bandColor = { likely_ai: "rgba(164,36,59,.75)", mixed_or_uncertain: "rgba(217,164,59,.8)", likely_human: "rgba(79,111,82,.65)" }; | |
| (r.ai_chunks.length ? r.ai_chunks : [{ score: r.ai_score, band: r.ai_band, preview: "" }]).forEach(c => { | |
| const seg = document.createElement("i"); | |
| seg.style.background = bandColor[c.band] || "#ccc"; | |
| seg.title = `chunk ${c.index ?? 0}: ${c.score}% AI signal (${c.band.replace(/_/g, " ")})`; | |
| strip.appendChild(seg); | |
| }); | |
| $("aiStripLabels").innerHTML = "<span>document start</span><span>document end</span>"; | |
| /* AI detector ensemble breakdown */ | |
| const dets = r.ai_detectors || []; | |
| $("aiDetectors").innerHTML = dets.length ? dets.map(d => ` | |
| <div class="det-row"> | |
| <span class="det-name">${esc(d.name)} <i>${esc(d.kind || "")}</i></span> | |
| <div class="det-bar"><i style="width:${Math.min(100, d.score)}%" | |
| class="${d.score >= 70 ? "hot" : d.score >= 45 ? "warm" : ""}"></i></div> | |
| <b>${d.score}</b> | |
| </div>`).join("") | |
| : "<p class='cov-note'>language-model detectors unavailable — stylometry only</p>"; | |
| const lm = r.ai_lm_metrics; | |
| $("lmMetrics").textContent = lm | |
| ? `binoculars ratio ${lm.binoculars} · perplexity ${lm.ppl} · ` + | |
| `top-10 token rate ${lm.gltr_top10} · ${lm.tokens_scored} tokens scored` | |
| : ""; | |
| const cf = r.ai_conformal; | |
| const fusionLabel = { meta_hc3: "HC3-calibrated meta-classifier", | |
| hand_lm: "hand-weighted (uncalibrated)", stylometry_only: "stylometry only" }; | |
| $("fusionNote").innerHTML = | |
| `fusion: <b>${esc(fusionLabel[r.ai_fusion] || r.ai_fusion || "—")}</b>` + | |
| (cf ? ` · P(AI)=${cf.p_ai} · conformal band [${cf.abstain_lo}, ${cf.abstain_hi}]` + | |
| (cf.inconclusive ? ` · <b class="warn-text">INCONCLUSIVE — abstaining</b>` : "") | |
| : ""); | |
| /* optional LLM executive summary */ | |
| const ls = $("llmSummary"); | |
| if (r.llm_summary) { | |
| ls.hidden = false; | |
| ls.innerHTML = `<b>LLM REVIEWER SUMMARY</b> ${esc(r.llm_summary)}`; | |
| } else { ls.hidden = true; } | |
| /* data fraud panel */ | |
| const df = r.data_fraud; | |
| const fp = $("fraudPanel"); | |
| if (df && (df.suspicious || df.benford)) { | |
| fp.hidden = false; | |
| const b = df.benford; | |
| const bLine = b ? `Benford: ${b.n_numbers} numbers, conformity <b>${esc(b.conformity)}</b> ` + | |
| `(MAD ${b.mad}, χ² ${b.chi_square}/${b.chi2_critical_p05})` : ""; | |
| const gLine = df.grim ? ` · GRIM: ${df.grim.count} impossible mean(s)` : ""; | |
| fp.className = "fraud-panel" + (df.suspicious ? " hot" : ""); | |
| fp.innerHTML = `<b>${df.suspicious ? "⚠ DATA FABRICATION SIGNALS" : "data integrity check"}</b>` + | |
| `<span>${bLine}${gLine}</span>` + | |
| (df.flags && df.flags.length ? df.flags.map(f => `<div>• ${esc(f)}</div>`).join("") : ""); | |
| } else { fp.hidden = true; } | |
| /* figure plagiarism panel */ | |
| const fig = r.figure_analysis; | |
| const gp = $("figurePanel"); | |
| if (fig && fig.submitted_figures) { | |
| gp.hidden = false; | |
| gp.className = "figure-panel" + (fig.reused ? " hot" : ""); | |
| gp.innerHTML = `<b>${fig.reused ? "⚠ FIGURE REUSE DETECTED" : "figure check"}</b>` + | |
| `<span>${fig.submitted_figures} figure(s) in submission; ${fig.matches.length} match a source image</span>` + | |
| fig.matches.map(m => `<div>• figure matches "${esc(m.source_title)}" (Hamming ${m.hamming}/64)</div>`).join(""); | |
| } else { gp.hidden = true; } | |
| /* style seam map */ | |
| const sm = r.style_seams; | |
| $("seamBlock").hidden = !(sm && sm.segments && sm.segments.length); | |
| if (sm && sm.segments && sm.segments.length) { | |
| const total = sm.segments[sm.segments.length - 1].end || 1; | |
| const segColor = (s) => s == null ? "rgba(120,120,120,.4)" | |
| : s >= 70 ? "rgba(164,36,59,.75)" : s >= 45 ? "rgba(217,164,59,.8)" : "rgba(79,111,82,.6)"; | |
| $("seamStrip").innerHTML = sm.segments.map(s => { | |
| const w = 100 * (s.end - s.start) / total; | |
| return `<i style="width:${w}%;background:${segColor(s.style_ai_score)}" | |
| title="sentences ${s.start}-${s.end}: style-AI ${s.style_ai_score ?? "?"}"></i>`; | |
| }).join(""); | |
| $("seamLegend").innerHTML = | |
| (sm.mixed_style_suspected | |
| ? "<b class='warn-text'>⚠ MIXED authorship suspected</b> — " | |
| : "") + | |
| `${sm.seams.length} stylistic seam(s); each block is a segment, colour = its AI-style score ` + | |
| `(green human · amber mixed · red AI). Segments: ` + | |
| sm.segments.map(s => `[${s.start}–${s.end}: ${s.style_ai_score ?? "?"}]`).join(" "); | |
| } | |
| /* COVERAGE — the full scrape manifest (R2) */ | |
| const cov = r.coverage || {}; | |
| $("covChips").innerHTML = | |
| Object.entries(cov.providers || {}).map(([p, n]) => `<span class="chip">${esc(p)} <b>${n}</b></span>`).join("") + | |
| `<span class="chip total">total considered <b>${cov.total_works_considered ?? 0}</b></span>` + | |
| `<span class="chip">deep-read PDFs <b>${(cov.deep_read_pdfs || []).length}</b></span>` + | |
| (cov.year_range ? `<span class="chip">literature ${cov.year_range[0]}–${cov.year_range[1]}</span>` : ""); | |
| $("covPdfs").innerHTML = (cov.deep_read_pdfs || []).length | |
| ? "<b>PDFs actually downloaded & read in full:</b>" + | |
| cov.deep_read_pdfs.map(p => ` | |
| <div class="near-row"><span class="badge deep">${esc(p.provider)}</span> | |
| <a href="${esc(p.url)}" target="_blank" rel="noopener">${esc(p.title)}</a> | |
| <b>${p.words_read.toLocaleString()} words read</b></div>`).join("") | |
| : "<p class='cov-note'>no open-access full texts were retrievable for this case</p>"; | |
| const cands = cov.candidates || []; | |
| const ACCESS = { | |
| read_full_text: ["✓ full text read", "acc-read"], | |
| oa_available: ["open access (not read)", "acc-oa"], | |
| shadow_text: ["shadow text", "acc-oa"], | |
| metadata_only: ["metadata only · paywalled?", "acc-meta"], | |
| }; | |
| $("covTable").innerHTML = | |
| "<tr><th>candidate work</th><th>year</th><th>provider</th>" + | |
| "<th>screen sim</th><th>access</th><th>match %</th></tr>" + | |
| cands.slice(0, 500).map(c => { | |
| const [lbl, cls] = ACCESS[c.access] || ["metadata only", "acc-meta"]; | |
| const doi = c.doi | |
| ? `<div class="cand-doi">DOI: <a href="https://doi.org/${esc(c.doi)}" target="_blank" rel="noopener">${esc(c.doi)}</a></div>` | |
| : ""; | |
| const ven = c.venue ? `<div class="cand-venue">${esc(c.venue)}</div>` : ""; | |
| return ` | |
| <tr class="${c.deep_read ? "deep-row" : ""}"> | |
| <td><a href="${esc(c.url)}" target="_blank" rel="noopener">${esc(c.title || "(untitled)")}</a>${ven}${doi}</td> | |
| <td>${c.year || ""}</td><td>${esc(c.provider)}</td> | |
| <td>${(c.rank_sim ?? 0).toFixed(3)}</td> | |
| <td><span class="acc ${cls}">${lbl}</span></td> | |
| <td>${c.match_percent == null ? "—" : c.match_percent + "%"}</td> | |
| </tr>`; | |
| }).join(""); | |
| $("manifestBtn").href = `/api/jobs/${currentJobId}/manifest`; | |
| $("pdfBtn").href = `/api/jobs/${currentJobId}/pdf`; | |
| $("queryList").innerHTML = r.queries_used.map(q => `<span>${esc(q)}</span>`).join(""); | |
| } | |
| /* ---------------- sticky report navigator (additive) ---------------- */ | |
| (function reportNav() { | |
| const nav = $("reportNav"); | |
| if (!nav) return; | |
| // mirror the verdict stamp into the sticky bar | |
| const stampText = $("stampText"); | |
| const rnVerdict = $("rnVerdict"); | |
| const syncVerdict = () => { | |
| const v = (stampText.textContent || "").trim(); | |
| rnVerdict.textContent = v || "—"; | |
| rnVerdict.className = "rn-verdict v-" + v.toLowerCase(); | |
| }; | |
| new MutationObserver(syncVerdict).observe(stampText, | |
| { childList: true, characterData: true, subtree: true }); | |
| // sticky-bar PDF button triggers the real download link | |
| $("rnPdf").addEventListener("click", (e) => { e.preventDefault(); $("pdfBtn").click(); }); | |
| // scroll-spy: highlight the section currently in view | |
| const links = [...nav.querySelectorAll(".rn-links a")]; | |
| const byId = (id) => links.find(a => a.getAttribute("href") === "#" + id); | |
| const targets = links | |
| .map(a => document.getElementById(a.getAttribute("href").slice(1))) | |
| .filter(Boolean); | |
| const spy = new IntersectionObserver((entries) => { | |
| entries.forEach(en => { | |
| if (!en.isIntersecting) return; | |
| links.forEach(a => a.classList.remove("active")); | |
| const link = byId(en.target.id); | |
| if (link) link.classList.add("active"); | |
| }); | |
| }, { rootMargin: "-72px 0px -65% 0px", threshold: 0 }); | |
| targets.forEach(t => spy.observe(t)); | |
| })(); | |