// ── Chart colours ────────────────────────────────────────── var IMPROVED_COLOR = "#22C55E"; var WORSENED_COLOR = "#EF4444"; var DIAG_COLOR = "#CBD5E1"; var BG_COLOR = "#FFFFFF"; var GRID_COLOR = "#F1F5F9"; var FONT_COLOR = "#0E1628"; // ── CTR tables (kept client-side for opportunity view) ───── var DERIVED_CTR = {}; var INDUSTRY_CTR = { 1: 0.28, 2: 0.15, 3: 0.11, 4: 0.08, 5: 0.06, 6: 0.05, 7: 0.04, 8: 0.03, 9: 0.025, 10: 0.02, 11: 0.015, 12: 0.012, 13: 0.01, 14: 0.009, 15: 0.008, 16: 0.007, 17: 0.006, 18: 0.005, 19: 0.0045, 20: 0.004, }; function getCTRatInt(pos) { if (DERIVED_CTR[pos] !== undefined) return DERIVED_CTR[pos]; if (INDUSTRY_CTR[pos] !== undefined) return INDUSTRY_CTR[pos]; return pos <= 50 ? 0.002 : 0.001; } function getCTR(p) { if (!p || p <= 0) return 0; var lo = Math.floor(p), frac = p - lo; if (frac === 0) return getCTRatInt(lo); return getCTRatInt(lo) * (1 - frac) + getCTRatInt(lo + 1) * frac; } // ── Instructions toggle ──────────────────────────────────── document.getElementById("instrHeader").onclick = function () { var body = document.getElementById("instrBody"); var toggle = document.getElementById("instrToggle"); var label = document.getElementById("instrToggleLabel"); var hidden = body.classList.toggle("hidden"); label.textContent = hidden ? "Show" : "Hide"; toggle.classList.toggle("collapsed", hidden); }; // ── Upload → API ─────────────────────────────────────────── function parseCSV(text) { var rows = [], row = [], cur = "", inQ = false, i = 0; while (i < text.length) { var c = text[i]; if (c === '"') { if (inQ && i + 1 < text.length && text[i + 1] === '"') { cur += '"'; i += 2; continue; // escaped quote "" } inQ = !inQ; } else if (c === "," && !inQ) { row.push(cur.trim()); cur = ""; } else if ((c === "\n" || c === "\r") && !inQ) { if (c === "\r" && i + 1 < text.length && text[i + 1] === "\n") i++; // skip \r\n row.push(cur.trim()); rows.push(row); row = []; cur = ""; } else { cur += c; } i++; } if (cur || row.length) { row.push(cur.trim()); if ( row.some(function (v) { return v; }) ) rows.push(row); } return rows; } function findCol(headers, groups) { var h = headers.map(function (s) { return s.trim().toLowerCase(); }); for (var g = 0; g < groups.length; g++) { var terms = groups[g]; for (var i = 0; i < h.length; i++) { if ( terms.every(function (t) { return h[i].indexOf(t) !== -1; }) ) return i; } } return -1; } function detectColumns(headers) { return { kw: findCol(headers, [ ["top quer"], ["top page"], ["keyword"], ["query"], ["page"], ["url"], ["landing"], ]), clkA: findCol(headers, [["last", "click"]]), clkB: findCol(headers, [ ["previous", "click"], ["prior", "click"], ]), impA: findCol(headers, [["last", "impression"]]), impB: findCol(headers, [ ["previous", "impression"], ["prior", "impression"], ]), posA: findCol(headers, [["last", "position"]]), posB: findCol(headers, [ ["previous", "position"], ["prior", "position"], ]), }; } function safeInt(row, i) { if (i < 0 || i >= row.length) return 0; var v = parseInt(row[i], 10); return isNaN(v) ? 0 : v; } function safeFloat(row, i) { if (i < 0 || i >= row.length) return null; var v = parseFloat(row[i].replace("%", "")); return isNaN(v) || v <= 0 ? null : v; } function buildCTRCurve(dataRows, idx) { var buckets = {}; var pairs = [ [idx.posA, idx.impA, idx.clkA], [idx.posB, idx.impB, idx.clkB], ]; dataRows.forEach(function (r) { pairs.forEach(function (p) { var posI = p[0], impI = p[1], clkI = p[2]; if (posI < 0 || impI < 0) return; var pos = parseFloat(r[posI]), imp = safeInt(r, impI), clk = safeInt(r, clkI); if (isNaN(pos) || pos <= 0 || imp <= 0) return; var key = Math.round(pos); if (!buckets[key]) buckets[key] = { clicks: 0, impressions: 0 }; buckets[key].clicks += clk; buckets[key].impressions += imp; }); }); var derived = {}; Object.keys(buckets).forEach(function (pos) { var b = buckets[pos]; if (b.impressions >= 100) derived[pos] = b.clicks / b.impressions; }); return derived; } function getCTRwithDerived(p, derived) { if (!p || p <= 0) return 0; var lo = Math.floor(p), frac = p - lo; function atInt(pos) { if (derived[String(pos)] !== undefined) return derived[String(pos)]; if (INDUSTRY_CTR[pos] !== undefined) return INDUSTRY_CTR[pos]; return pos <= 50 ? 0.002 : 0.001; } if (frac === 0) return atInt(lo); return atInt(lo) * (1 - frac) + atInt(lo + 1) * frac; } function processCSV(text) { if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); var rows = parseCSV(text); if (!rows.length) return { error: "Empty file." }; var headers = rows[0]; var idx = detectColumns(headers); var missing = []; if (idx.kw < 0) missing.push("Top queries / Query column"); if (idx.posA < 0) missing.push("Last [period] Position"); if (idx.posB < 0) missing.push("Previous [period] Position"); if (idx.impA < 0) missing.push("Last [period] Impressions"); if (missing.length) { return { error: "Could not find: " + missing.join(", ") + ". Make sure you exported with Compare mode enabled.", }; } var dataRows = rows.slice(1).filter(function (r) { return r.length > 2; }); var derivedCTR = buildCTRCurve(dataRows, idx); var processed = []; dataRows.forEach(function (r) { var posA = safeFloat(r, idx.posA), posB = safeFloat(r, idx.posB); if (!posA || !posB) return; var impA = safeInt(r, idx.impA); var impB = idx.impB >= 0 ? safeInt(r, idx.impB) : impA; var clkA = safeInt(r, idx.clkA), clkB = safeInt(r, idx.clkB); var ctrA = getCTRwithDerived(posA, derivedCTR); var ctrB = getCTRwithDerived(posB, derivedCTR); var impactA = impA * ctrA, impactB = impB * ctrB; processed.push({ kw: idx.kw >= 0 && idx.kw < r.length ? r[idx.kw] : "", posA: posA, posB: posB, impA: impA, impB: impB, clkA: clkA, clkB: clkB, ctrA: ctrA, ctrB: ctrB, impactA: impactA, impactB: impactB, delta: impactA - impactB, status: impactA >= impactB ? "Improved" : "Worsened", }); }); return { rows: processed, ctr_info: { derived_count: Object.keys(derivedCTR).length, derived_ctr: derivedCTR, }, }; } var allData = []; function uploadFile(file) { var loading = document.getElementById("loadingBox"); var error = document.getElementById("errorBox"); loading.classList.add("visible"); error.style.display = "none"; var reader = new FileReader(); reader.onload = function (e) { try { var result = processCSV(e.target.result); loading.classList.remove("visible"); if (result.error) { showError(result.error); return; } DERIVED_CTR = result.ctr_info.derived_ctr || {}; var derivedCount = result.ctr_info.derived_count || 0; allData = result.rows; document.getElementById("dbg").textContent = "CTR curve: " + derivedCount + " positions derived from your data" + (derivedCount < 10 ? " (sparse — industry defaults used for missing positions)" : "") + " | rows: " + allData.length; document.getElementById("controls").style.display = "block"; document.getElementById("chart").style.display = "block"; buildFilters(document.getElementById("viewMode").value); render(); } catch (err) { loading.classList.remove("visible"); showError("Processing error: " + err.message); } }; reader.onerror = function () { loading.classList.remove("visible"); showError("Could not read file — please export as UTF-8 CSV."); }; reader.readAsText(file); } // ── Filter definitions per view ──────────────────────────── var FILTER_DEFS = { estclicks: [ { id: "minImp", label: "Min impressions", def: 0, min: 0, step: 100, width: 110, }, { id: "posBeforeMin", label: "Before pos min", def: 1, min: 1, step: 1, width: 80, }, { id: "posBeforeMax", label: "Before pos max", def: 100, min: 1, step: 1, width: 80, }, { id: "minClickDelta", label: "Min est. click Δ", def: 0, min: 0, step: 1, width: 100, }, ], position: [ { id: "minImp", label: "Min impressions", def: 0, min: 0, step: 100, width: 110, }, { id: "posBeforeMin", label: "Before pos min", def: 1, min: 1, step: 1, width: 80, }, { id: "posBeforeMax", label: "Before pos max", def: 50, min: 1, step: 1, width: 80, }, { id: "minPosDelta", label: "Min pos change", def: 0, min: 0, step: 1, width: 80, }, ], realclicks: [ { id: "minClicks", label: "Min clicks (either period)", def: 1, min: 0, step: 1, width: 140, }, { id: "minClickDeltaReal", label: "Min click Δ", def: 0, min: 0, step: 1, width: 90, }, ], impressions: [ { id: "minImp", label: "Min impressions", def: 0, min: 0, step: 100, width: 110, }, { id: "posBeforeMin", label: "Before pos min", def: 1, min: 1, step: 1, width: 80, }, { id: "posBeforeMax", label: "Before pos max", def: 100, min: 1, step: 1, width: 80, }, { id: "minImpDelta", label: "Min impression Δ", def: 0, min: 0, step: 50, width: 110, }, ], ctrshift: [ { id: "minImp", label: "Min impressions", def: 100, min: 0, step: 50, width: 110, }, { id: "posBeforeMin", label: "Position min", def: 1, min: 1, step: 1, width: 80, }, { id: "posBeforeMax", label: "Position max", def: 50, min: 1, step: 1, width: 80, }, { id: "minCTRShift", label: "Min CTR shift (pp)", def: 0, min: 0, step: 0.1, width: 110, }, ], opportunity: [ { id: "minImp", label: "Min impressions", def: 100, min: 0, step: 50, width: 110, }, { id: "posCurrentMin", label: "Current pos min", def: 1, min: 1, step: 1, width: 90, }, { id: "posCurrentMax", label: "Current pos max", def: 50, min: 1, step: 1, width: 90, }, { id: "minPotential", label: "Min click potential", def: 0, min: 0, step: 10, width: 120, }, ], }; function buildFilters(view) { var container = document.getElementById("dynamicFilters"); container.innerHTML = ""; (FILTER_DEFS[view] || []).forEach(function (f) { var div = document.createElement("div"); div.className = "ctrl-group"; var lbl = document.createElement("label"); lbl.textContent = f.label; var inp = document.createElement("input"); inp.type = "number"; inp.id = f.id; inp.value = f.def; inp.min = f.min; inp.step = f.step; inp.style.width = (f.width || 100) + "px"; inp.onchange = render; div.appendChild(lbl); div.appendChild(inp); container.appendChild(div); }); } function fv(id, fallback) { var el = document.getElementById(id); if (!el) return fallback !== undefined ? fallback : 0; return parseFloat(el.value) || 0; } // ── Stats helpers ────────────────────────────────────────── function mean(arr, fn) { if (!arr.length) return 0; return ( arr.reduce(function (s, r) { return s + fn(r); }, 0) / arr.length ); } function zScore(arr, fn) { if (arr.length < 2) return null; var m = mean(arr, fn); var sd = Math.sqrt( arr.reduce(function (s, r) { var d = fn(r) - m; return s + d * d; }, 0) / (arr.length - 1), ); return sd === 0 ? null : m / (sd / Math.sqrt(arr.length)); } function fmtZ(z) { if (z === null) return "n/a"; return ( (z >= 0 ? "+" : "") + Math.abs(z).toFixed(2) + (Math.abs(z) >= 1.96 ? " ✓" : "") ); } function zColor(z) { if (z === null) return "#aaa"; if (Math.abs(z) >= 1.96) return z > 0 ? IMPROVED_COLOR : WORSENED_COLOR; return "#888"; } function pct(n, total) { return total ? ((n / total) * 100).toFixed(1) + "%" : "0%"; } // ── Main render ──────────────────────────────────────────── function render() { var view = document.getElementById("viewMode").value; var minImp = parseInt((document.getElementById("minImp") || {}).value || 0, 10) || 0; var d = allData.filter(function (r) { return r.impA >= minImp && r.impB >= minImp; }); document.getElementById("dbg").textContent = "view=" + view + " | minImp=" + minImp + " | showing " + d.length + " of " + allData.length + " keywords"; // ── stats per view ─────────────────────────────────────── var SI = IMPROVED_COLOR, SW = WORSENED_COLOR, SN = FONT_COLOR, SB = "#3B63FF"; var stats; if (view === "position") { var avgShift = mean(d, function (r) { return r.posB - r.posA; }); var z = zScore(d, function (r) { return r.posB - r.posA; }); var movers = d.filter(function (r) { return Math.abs(r.posA - r.posB) >= 1; }).length; stats = [ { l: "Keywords", v: d.length, c: SN }, { l: "Avg position shift", v: (avgShift >= 0 ? "+" : "") + avgShift.toFixed(2) + " pos", c: avgShift >= 0 ? SI : SW, }, { l: "Moved ≥1 position", v: movers + " (" + pct(movers, d.length) + ")", c: SN, }, { l: "t-statistic", v: fmtZ(z), c: zColor(z) }, ]; } else if (view === "estclicks") { var net = d.reduce(function (s, r) { return s + r.delta; }, 0); var avg = mean(d, function (r) { return r.delta; }); var z = zScore(d, function (r) { return r.delta; }); stats = [ { l: "Keywords", v: d.length, c: SN }, { l: "Net est. clicks Δ", v: (net >= 0 ? "+" : "") + net.toFixed(0), c: net >= 0 ? SI : SW, }, { l: "Avg Δ per keyword", v: (avg >= 0 ? "+" : "") + avg.toFixed(1) + " clicks", c: avg >= 0 ? SI : SW, }, { l: "t-statistic", v: fmtZ(z), c: zColor(z) }, ]; } else if (view === "realclicks") { var net = d.reduce(function (s, r) { return s + (r.clkA - r.clkB); }, 0); var avg = mean(d, function (r) { return r.clkA - r.clkB; }); var z = zScore(d, function (r) { return r.clkA - r.clkB; }); stats = [ { l: "Keywords", v: d.length, c: SN }, { l: "Net real clicks Δ", v: (net >= 0 ? "+" : "") + net, c: net >= 0 ? SI : SW, }, { l: "Avg Δ per keyword", v: (avg >= 0 ? "+" : "") + avg.toFixed(2) + " clicks", c: avg >= 0 ? SI : SW, }, { l: "t-statistic", v: fmtZ(z), c: zColor(z) }, ]; } else if (view === "impressions") { var net = d.reduce(function (s, r) { return s + (r.impA - r.impB); }, 0); var avg = mean(d, function (r) { return r.impA - r.impB; }); var z = zScore(d, function (r) { return r.impA - r.impB; }); stats = [ { l: "Keywords", v: d.length, c: SN }, { l: "Net impressions Δ", v: (net >= 0 ? "+" : "") + net.toLocaleString(), c: net >= 0 ? SI : SW, }, { l: "Avg Δ per keyword", v: (avg >= 0 ? "+" : "") + avg.toFixed(0) + " impr.", c: avg >= 0 ? SI : SW, }, { l: "t-statistic", v: fmtZ(z), c: zColor(z) }, ]; } else if (view === "ctrshift") { var net = mean(d, function (r) { return (r.ctrA - r.ctrB) * 100; }); var gainN = d.filter(function (r) { return r.ctrA > r.ctrB; }).length; var z = zScore(d, function (r) { return (r.ctrA - r.ctrB) * 100; }); stats = [ { l: "Keywords", v: d.length, c: SN }, { l: "Avg CTR shift", v: (net >= 0 ? "+" : "") + net.toFixed(3) + "pp", c: net >= 0 ? SI : SW, }, { l: "CTR gained", v: gainN + " (" + pct(gainN, d.length) + ")", c: SI, }, { l: "t-statistic", v: fmtZ(z), c: zColor(z) }, ]; } else if (view === "opportunity") { var top10 = d.filter(function (r) { return r.posA <= 10; }).length; var p1130 = d.filter(function (r) { return r.posA > 10 && r.posA <= 30; }).length; var topOpp = d.slice().sort(function (a, b) { return b.impA * (getCTR(1) - b.ctrA) - a.impA * (getCTR(1) - a.ctrA); })[0]; stats = [ { l: "Keywords", v: d.length, c: SN }, { l: "In positions 1–10", v: top10 + " (" + pct(top10, d.length) + ")", c: SI, }, { l: "In positions 11–30", v: p1130 + " (" + pct(p1130, d.length) + ")", c: SB, }, { l: "Top opportunity", v: topOpp ? topOpp.kw.substring(0, 28) + "..." : "—", c: SW, }, ]; } document.getElementById("statsBar").innerHTML = stats .map(function (s) { return ( '
' + s.l + "
" + '
' + s.v + "
" ); }) .join(""); // ── chart helpers ──────────────────────────────────────── function tip(r) { return ( "" + r.kw + "
" + "Pos: " + r.posB.toFixed(1) + " → " + r.posA.toFixed(1) + "
" + "Clicks: " + r.clkB + " → " + r.clkA + "
" + "Impressions: " + r.impB.toLocaleString() + " → " + r.impA.toLocaleString() + "
" + "Est. clicks: " + r.impactB.toFixed(0) + " → " + r.impactA.toFixed(0) ); } function splitBy(rows, bFn, aFn) { var imp = [], wor = []; rows.forEach(function (r) { (aFn(r) >= bFn(r) ? imp : wor).push(r); }); return { improved: imp, worsened: wor }; } function mkTrace(rows, name, color, xFn, yFn) { return { x: rows.map(xFn), y: rows.map(yFn), mode: "markers", type: "scatter", name: name, text: rows.map(tip), hovertemplate: "%{text}", marker: { color: color, size: 7, opacity: 0.8, line: { width: 0.5, color: "rgba(0,0,0,.08)" }, }, }; } function diagLine(mx) { return { x: [0, mx], y: [0, mx], mode: "lines", type: "scatter", name: "No change", line: { color: DIAG_COLOR, dash: "dash", width: 1.5 }, hoverinfo: "skip", }; } function maxOf(arr, f1, f2) { var m = 1; arr.forEach(function (r) { var a = f1(r), b = f2(r); if (a > m) m = a; if (b > m) m = b; }); return m * 1.05; } function stdTraces(bFn, aFn) { var s = splitBy(d, bFn, aFn); return [ mkTrace(s.improved, "Improved", IMPROVED_COLOR, bFn, aFn), mkTrace(s.worsened, "Worsened", WORSENED_COLOR, bFn, aFn), ]; } function axis(title, extra) { var base = { title: title, gridcolor: GRID_COLOR, zerolinecolor: GRID_COLOR, tickfont: { size: 11, color: FONT_COLOR }, }; for (var k in extra) base[k] = extra[k]; return base; } function ann(x, y, text, color) { return { x: x, y: y, text: text, showarrow: false, font: { color: color, size: 11 }, }; } var traces, xaxis, yaxis, annotations; if (view === "position") { var bFn = function (r) { return r.posB; }, aFn = function (r) { return r.posA; }; var s = splitBy( d, function (r) { return r.posA; }, function (r) { return r.posB; }, ); var mx = maxOf(d, bFn, aFn); traces = [ mkTrace(s.improved, "Improved", IMPROVED_COLOR, bFn, aFn), mkTrace(s.worsened, "Worsened", WORSENED_COLOR, bFn, aFn), diagLine(mx), ]; xaxis = axis("Position before", { autorange: "reversed" }); yaxis = axis("Position after", { autorange: "reversed" }); annotations = [ ann(mx * 0.15, mx * 0.05, "improved (below line)", IMPROVED_COLOR), ann(mx * 0.15, mx * 0.88, "worsened (above line)", WORSENED_COLOR), ]; } else if (view === "estclicks") { var bFn = function (r) { return r.impactB; }, aFn = function (r) { return r.impactA; }; var mx = maxOf(d, bFn, aFn); traces = stdTraces(bFn, aFn); traces.push(diagLine(mx)); xaxis = axis("Est. clicks before", { rangemode: "nonnegative" }); yaxis = axis("Est. clicks after", { rangemode: "nonnegative" }); annotations = [ ann(mx * 0.08, mx * 0.95, "improved (above line)", IMPROVED_COLOR), ann(mx * 0.08, mx * 0.05, "worsened (below line)", WORSENED_COLOR), ]; } else if (view === "realclicks") { var bFn = function (r) { return r.clkB; }, aFn = function (r) { return r.clkA; }; var s = splitBy(d, bFn, aFn); var mx = maxOf(d, bFn, aFn); function jitter(v) { return v + (Math.random() - 0.5) * 0.35; } function mkJ(rows, name, color) { return { x: rows.map(function (r) { return jitter(r.clkB); }), y: rows.map(function (r) { return jitter(r.clkA); }), mode: "markers", type: "scatter", name: name, text: rows.map(tip), hovertemplate: "%{text}", marker: { color: color, size: 5, opacity: 0.35, line: { width: 0 }, }, }; } traces = [ mkJ(s.improved, "Improved", IMPROVED_COLOR), mkJ(s.worsened, "Worsened", WORSENED_COLOR), diagLine(mx), ]; xaxis = axis("Real clicks before", { rangemode: "nonnegative" }); yaxis = axis("Real clicks after", { rangemode: "nonnegative" }); annotations = [ ann(mx * 0.08, mx * 0.95, "improved (above line)", IMPROVED_COLOR), ann(mx * 0.08, mx * 0.05, "worsened (below line)", WORSENED_COLOR), ]; } else if (view === "impressions") { var bFn = function (r) { return r.impB; }, aFn = function (r) { return r.impA; }; var mx = maxOf(d, bFn, aFn); traces = stdTraces(bFn, aFn); traces.push(diagLine(mx)); xaxis = axis("Impressions before", { rangemode: "nonnegative" }); yaxis = axis("Impressions after", { rangemode: "nonnegative" }); annotations = [ ann(mx * 0.08, mx * 0.95, "improved (above line)", IMPROVED_COLOR), ann(mx * 0.08, mx * 0.05, "worsened (below line)", WORSENED_COLOR), ]; } else if (view === "ctrshift") { var maxP = fv("posBeforeMax", 100); traces = [ { x: d.map(function (r) { return r.posB; }), y: d.map(function (r) { return (r.ctrA - r.ctrB) * 100; }), mode: "markers", type: "scatter", name: "CTR shift", text: d.map(tip), hovertemplate: "%{text}", marker: { color: d.map(function (r) { return r.ctrA >= r.ctrB ? IMPROVED_COLOR : WORSENED_COLOR; }), size: 7, opacity: 0.75, line: { width: 0.5, color: "rgba(0,0,0,.08)" }, }, }, { x: [0, maxP], y: [0, 0], mode: "lines", type: "scatter", name: "No change", line: { color: DIAG_COLOR, dash: "dash", width: 1.5 }, hoverinfo: "skip", }, ]; xaxis = axis("Position before", { rangemode: "nonnegative" }); yaxis = axis("CTR change (pp)", { zeroline: true, zerolinewidth: 2, zerolinecolor: DIAG_COLOR, }); annotations = [ ann(maxP * 0.5, 0.4, "CTR improved", IMPROVED_COLOR), ann(maxP * 0.5, -0.4, "CTR worsened", WORSENED_COLOR), ]; } else if (view === "opportunity") { var maxImp = d.reduce(function (m, r) { return r.impA > m ? r.impA : m; }, 1); traces = [ { x: d.map(function (r) { return r.posA; }), y: d.map(function (r) { return r.impA; }), mode: "markers", type: "scatter", name: "Keywords", text: d.map(function (r) { return ( tip(r) + "
Click potential to #1: +" + (r.impA * (getCTR(1) - r.ctrA)).toFixed(0) + "" ); }), hovertemplate: "%{text}", marker: { color: d.map(function (r) { return r.impA * (getCTR(1) - r.ctrA); }), colorscale: [ ["0", "#F1F4FF"], ["0.5", "#3B63FF"], ["1", "#0E1628"], ], showscale: true, colorbar: { title: { text: "Click potential", side: "right" }, tickfont: { color: FONT_COLOR, size: 10 }, bgcolor: "#fff", bordercolor: GRID_COLOR, }, size: d.map(function (r) { return 5 + (r.impA / maxImp) * 18; }), opacity: 0.8, line: { width: 0.5, color: "rgba(0,0,0,.08)" }, }, }, ]; xaxis = axis("Current position", { rangemode: "nonnegative" }); yaxis = axis("Impressions", { rangemode: "nonnegative" }); annotations = [ ann(5, maxImp * 0.92, "good rank, high impressions — defend", "#94A3B8"), ann( 55, maxImp * 0.92, "bad rank, high impressions — opportunity", WORSENED_COLOR, ), ]; } Plotly.newPlot( "chart", traces, { paper_bgcolor: BG_COLOR, plot_bgcolor: BG_COLOR, font: { color: FONT_COLOR, family: "'Inter','Helvetica Neue',Arial,sans-serif", }, height: 560, xaxis: xaxis, yaxis: yaxis, legend: { bgcolor: "#F7F8FA", bordercolor: GRID_COLOR, borderwidth: 1, font: { size: 12 }, }, margin: { t: 30, r: 20, b: 60, l: 70 }, hovermode: "closest", annotations: annotations || [], }, { displaylogo: false, responsive: true }, ); } // ── Error helpers ────────────────────────────────────────── function showError(msg) { var b = document.getElementById("errorBox"); b.textContent = msg; b.style.display = "block"; } function hideError() { document.getElementById("errorBox").style.display = "none"; } // ── Wire events ──────────────────────────────────────────── (function () { var dz = document.getElementById("dropZone"); var fi = document.getElementById("fileInput"); dz.onclick = function () { fi.click(); }; dz.ondragover = function (e) { e.preventDefault(); dz.classList.add("drag"); }; dz.ondragleave = function () { dz.classList.remove("drag"); }; dz.ondrop = function (e) { e.preventDefault(); dz.classList.remove("drag"); if (e.dataTransfer.files[0]) uploadFile(e.dataTransfer.files[0]); }; fi.onchange = function () { if (fi.files[0]) uploadFile(fi.files[0]); }; document.getElementById("viewMode").onchange = function () { buildFilters(this.value); render(); }; document.getElementById("updateBtn").onclick = render; })();