$sloprun.dev

CSV → Instant Chart

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 6 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

Paste or drop a CSV and it charts itself: every column is sniffed for its type — number, date, category — and the chart form follows, line for a date axis, grouped bars for categories, scatter when two numbers are the real story. It opens already plotted on 36 rows of 2024 climate data for Reykjavik, Cairo and Singapore, coloured by city; switch the Y axis to rainfall_mm to see which of the three is genuinely wet, and watch the chart type re-pick itself when you move X onto a category. There is no upload and no charting library — the parser handles quoted commas, $/%/thousands separators, tabs and semicolons, and names the lines it had to skip instead of dropping them silently. Try cars, where it settles on weight against mpg by itself, then messy, which is deliberately broken input handled without a stack trace.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/csv-chart" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="CSV → Instant Chart — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/csv-chart

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1941 lines 82.5 KB index.html
demos/csv-chart/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CSV → Instant Chart</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
   Identity: instrument-panel. Machine facts in mono; human words in sans.
   Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }

/* native controls follow the theme too (selects, scrollbars) */
:root { color-scheme: light; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { color-scheme: dark; } }
:root[data-theme="dark"] { color-scheme: dark; }

/* ---- categorical series palette (validated: adjacent CVD dE 9.1 light / 8.4 dark,
   normal-vision 19.6 / 19.3, against surfaces #FFFFFF and #1C1F26).
   Three light slots sit under 3:1 on white — relief is shipped: legend, direct
   labels and a table view, so hue never carries meaning alone. ---- */
:root {
  --s1:#2a78d6; --s2:#eb6834; --s3:#1baf7a; --s4:#eda100; --s5:#e87ba4; --s6:#008300;
  --s-other:#7C838C;
  --plot-ink: var(--ink); --plot-muted: var(--muted); --plot-grid: var(--line);
  --field: #FBFBF9;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --s1:#3987e5; --s2:#d95926; --s3:#199e70; --s4:#c98500; --s5:#d55181; --s6:#008300;
    --s-other:#8B929B; --field:#171A20;
  }
}
:root[data-theme="dark"] {
  --s1:#3987e5; --s2:#d95926; --s3:#199e70; --s4:#c98500; --s5:#d55181; --s6:#008300;
  --s-other:#8B929B; --field:#171A20;
}

*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
  margin: 0; padding: 18px 16px 28px; line-height: 1.5;
  font-size: 15px; overflow-x: hidden;
}
.wrap { max-width: 1120px; margin: 0 auto; }

/* ---- header ---- */
header { margin-bottom: 16px; }
.kicker {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em;
  text-transform: uppercase; color: var(--muted); margin: 0 0 6px;
}
h1 { font-size: clamp(21px, 4.6vw, 27px); line-height: 1.2; margin: 0 0 6px; letter-spacing: -.01em; }
.lede { margin: 0; color: var(--muted); max-width: 62ch; font-size: clamp(13.5px, 3.4vw, 15px); }

/* ---- layout ---- */
.grid { display: grid; gap: 14px; grid-template-columns: minmax(0,1fr); align-items: start; }
@media (min-width: 900px) { .grid { grid-template-columns: 330px minmax(0,1fr); } }
.panel {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 14px; min-width: 0;
}
.panel + .panel { margin-top: 0; }
.panel-head {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 10px; margin-bottom: 10px; flex-wrap: wrap;
}
h2 {
  font-size: 12px; font-family: var(--font-mono); font-weight: 600; margin: 0;
  letter-spacing: .06em; text-transform: uppercase; color: var(--muted);
}

/* ---- controls ---- */
button, select, textarea, input { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn {
  background: var(--surface); border: 1px solid var(--line); color: var(--ink);
  border-radius: 8px; padding: 5px 10px; cursor: pointer;
  font-size: 12.5px; font-family: var(--font-mono); line-height: 1.4;
}
.btn:hover { border-color: var(--accent); background: var(--accent-soft); }
.btn[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }

.field { display: grid; gap: 4px; min-width: 0; }
.field > span {
  font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .07em;
  text-transform: uppercase; color: var(--muted);
}
select {
  background: var(--field); border: 1px solid var(--line); border-radius: 8px;
  padding: 6px 8px; font-size: 13px; width: 100%; max-width: 100%;
  font-family: var(--font-mono); cursor: pointer;
}
select:hover { border-color: var(--accent); }
select:disabled { opacity: .55; cursor: not-allowed; }

/* segmented radio group */
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: var(--field); }
.seg input { position: absolute; opacity: 0; pointer-events: none; }
.seg label {
  padding: 6px 11px; font-family: var(--font-mono); font-size: 12.5px;
  cursor: pointer; color: var(--muted); border-right: 1px solid var(--line);
  user-select: none; white-space: nowrap;
}
.seg label:last-of-type { border-right: 0; }
.seg label:hover { color: var(--ink); background: var(--accent-soft); }
.seg input:checked + label { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.seg input:focus-visible + label { outline: 2px solid var(--accent); outline-offset: -2px; }

/* ---- data panel ---- */
.drop { position: relative; border-radius: 8px; }
.drop.over::after {
  content: "drop CSV to load"; position: absolute; inset: 0;
  display: grid; place-items: center; border-radius: 8px;
  border: 2px solid var(--accent); background: var(--accent-soft);
  font-family: var(--font-mono); font-size: 13px; color: var(--accent);
}
textarea {
  width: 100%; background: var(--field); border: 1px solid var(--line);
  border-radius: 8px; padding: 9px 10px; font-family: var(--font-mono);
  font-size: 11.5px; line-height: 1.55; resize: vertical; min-height: 132px;
  height: 156px; white-space: pre; overflow: auto; tab-size: 4;
}
@media (min-width: 900px) { textarea { height: 210px; } }
textarea:hover { border-color: var(--accent); }

.status {
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px;
  margin-top: 10px; font-family: var(--font-mono); font-size: 11.5px;
}
.ok {
  color: var(--run); background: var(--run-soft); border: 1px solid transparent;
  border-radius: 999px; padding: 1px 8px; font-weight: 600;
}
.warnpill {
  color: var(--accent); background: var(--accent-soft);
  border-radius: 999px; padding: 1px 8px;
}
.status .facts { color: var(--muted); }
.status .facts b { color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; }
.status .facts > span { white-space: nowrap; }

.issues { margin: 10px 0 0; padding: 0; list-style: none; display: grid; gap: 4px; }
.issues li {
  font-family: var(--font-mono); font-size: 11px; color: var(--muted);
  display: flex; gap: 7px; align-items: baseline;
}
.issues li .row { color: var(--accent); flex: none; }
.issues li.bad .row { color: var(--danger); }

.cols { margin-top: 12px; display: grid; gap: 5px; }
.colbtn {
  display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;
  background: none; border: 1px solid transparent; border-radius: 7px;
  padding: 4px 6px; cursor: pointer; min-width: 0;
}
.colbtn:hover { border-color: var(--line); background: var(--field); }
.colbtn .nm {
  font-family: var(--font-mono); font-size: 12px; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap; flex: 1 1 auto; min-width: 0;
}
.colbtn .ty {
  font-family: var(--font-mono); font-size: 10px; letter-spacing: .06em;
  text-transform: uppercase; border: 1px solid var(--line); border-radius: 5px;
  padding: 0 5px; color: var(--muted); flex: none;
}
.colbtn .ty.number { color: var(--accent); border-color: var(--accent); }
.colbtn .rng {
  font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
  flex: none; font-variant-numeric: tabular-nums;
}
.colbtn[data-active="x"], .colbtn[data-active="y"], .colbtn[data-active="g"] {
  background: var(--accent-soft); border-color: var(--accent);
}
.colbtn .role {
  font-family: var(--font-mono); font-size: 10px; letter-spacing: .07em;
  color: var(--accent); flex: none; font-weight: 600;
}

/* ---- chart panel ---- */
.controls {
  display: flex; flex-wrap: wrap; gap: 10px; align-items: end;
  padding-bottom: 12px; border-bottom: 1px solid var(--line); margin-bottom: 12px;
}
.controls .field { flex: 1 1 130px; max-width: 210px; }
.autoflag {
  font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
  display: inline-flex; align-items: center; gap: 6px;
}
.linkbtn {
  background: none; border: 0; padding: 2px 4px; color: var(--accent);
  font-family: var(--font-mono); font-size: 11px; cursor: pointer; text-decoration: underline;
}
.chart-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; margin-bottom: 8px; }
.chart-title { font-weight: 600; font-size: 15px; }
.chart-note { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
.plotwrap { position: relative; width: 100%; }
canvas { display: block; width: 100%; height: 300px; border-radius: 6px; touch-action: pan-y; }
@media (min-width: 640px) { canvas { height: 340px; } }
canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.tip {
  position: absolute; z-index: 3; pointer-events: none; opacity: 0;
  transform: translate(-50%, -100%); transition: opacity .12s ease;
  background: var(--surface); border: 1px solid var(--line); border-radius: 8px;
  padding: 7px 9px; min-width: 118px; box-shadow: 0 6px 20px rgba(0,0,0,.14);
}
.tip.on { opacity: 1; }
.tip .tx { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); margin-bottom: 4px; letter-spacing: .04em; }
.tip .row { display: flex; align-items: center; gap: 7px; margin-top: 2px; }
.tip .key { width: 12px; height: 2px; border-radius: 2px; flex: none; }
.tip .nm { font-size: 11.5px; color: var(--muted); flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tip .v { font-family: var(--font-mono); font-size: 12.5px; font-weight: 600; font-variant-numeric: tabular-nums; }

.legend { display: flex; flex-wrap: wrap; gap: 4px 6px; margin-top: 10px; }
.legend button {
  display: inline-flex; align-items: center; gap: 6px; background: none;
  border: 1px solid transparent; border-radius: 999px; padding: 3px 9px 3px 5px;
  cursor: pointer; font-size: 12px; color: var(--ink);
}
.legend button:hover { border-color: var(--line); background: var(--field); }
.legend button[aria-pressed="false"] { color: var(--muted); text-decoration: line-through; }
.legend button[aria-pressed="false"] svg { opacity: .4; }
.legend svg { flex: none; display: block; }

.chartfoot { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; justify-content: space-between; margin-top: 12px; }
.hint { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); }
.tablebox { margin-top: 10px; max-height: 260px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; }
table { border-collapse: collapse; width: 100%; font-size: 12px; }
th, td {
  text-align: right; padding: 5px 9px; border-bottom: 1px solid var(--line);
  font-family: var(--font-mono); font-variant-numeric: tabular-nums; white-space: nowrap;
}
th { position: sticky; top: 0; background: var(--surface); color: var(--muted); font-weight: 600; font-size: 10.5px; letter-spacing: .05em; text-transform: uppercase; }
th:first-child, td:first-child { text-align: left; }
tbody tr:last-child td { border-bottom: 0; }
.err {
  border: 1px solid var(--danger); border-radius: 8px; padding: 12px 13px;
  background: var(--surface); display: grid; gap: 5px;
}
.err strong { color: var(--danger); font-size: 13.5px; }
.err span { color: var(--muted); font-size: 13px; }
.err code { font-family: var(--font-mono); font-size: 12px; color: var(--ink); }
[hidden] { display: none !important; }
.sr {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
}
footer {
  margin-top: 22px; padding-top: 12px; border-top: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 11px; color: var(--muted);
}
@media (prefers-reduced-motion: reduce) {
  * { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">
  <header>
    <p class="kicker">sloprun · dataviz</p>
    <h1>CSV → Instant Chart</h1>
    <p class="lede">Paste or drop any CSV. It sniffs each column's type, picks the chart that fits the data, and draws it — entirely in this tab, with nothing uploaded anywhere.</p>
  </header>

  <main class="grid">
    <!-- ---------- data panel ---------- -->
    <section class="panel" aria-labelledby="h-data">
      <div class="panel-head">
        <h2 id="h-data">CSV in</h2>
        <div class="chips" id="samples" role="group" aria-label="Load a sample dataset">
          <button class="btn" type="button" data-sample="weather">weather</button>
          <button class="btn" type="button" data-sample="cars">cars</button>
          <button class="btn" type="button" data-sample="bugs">bugs</button>
          <button class="btn" type="button" data-sample="messy">messy</button>
        </div>
      </div>

      <div class="drop" id="drop">
        <label class="sr" for="csv">CSV text</label>
        <textarea id="csv" spellcheck="false" autocomplete="off" aria-describedby="status"></textarea>
      </div>

      <div class="chips" style="margin-top:8px">
        <button class="btn" type="button" id="pickFile">choose file…</button>
        <input type="file" id="file" accept=".csv,.tsv,.txt,text/csv,text/plain" class="sr" tabindex="-1" aria-label="Choose a CSV file">
        <span class="hint" style="align-self:center">…or drag one onto the box</span>
      </div>

      <div class="status" id="status" role="status">
        <span class="ok" id="okPill" hidden>✓ parsed</span>
        <span class="warnpill" id="warnPill" hidden></span>
        <span class="facts" id="facts"></span>
      </div>

      <ul class="issues" id="issues"></ul>

      <div class="cols" id="cols" role="group" aria-label="Detected columns — click one to plot it"></div>
    </section>

    <!-- ---------- chart panel ---------- -->
    <section class="panel" aria-labelledby="h-chart">
      <div class="panel-head">
        <h2 id="h-chart">Chart</h2>
        <span class="autoflag" id="autoflag"></span>
      </div>

      <div class="controls" id="controls">
        <div class="field" style="flex:0 0 auto;max-width:none">
          <span id="lbl-type">Type</span>
          <div class="seg" role="radiogroup" aria-labelledby="lbl-type">
            <input type="radio" name="ctype" id="t-line" value="line"><label for="t-line">line</label>
            <input type="radio" name="ctype" id="t-bar" value="bar"><label for="t-bar">bar</label>
            <input type="radio" name="ctype" id="t-scatter" value="scatter"><label for="t-scatter">scatter</label>
          </div>
        </div>
        <label class="field"><span>X axis</span><select id="selX"></select></label>
        <label class="field"><span>Y axis</span><select id="selY"></select></label>
        <label class="field"><span>Colour by</span><select id="selG"></select></label>
      </div>

      <div id="chartArea">
        <div class="chart-head">
          <span class="chart-title" id="chartTitle"></span>
          <span class="chart-note" id="chartNote"></span>
        </div>
        <div class="plotwrap" id="plotwrap">
          <canvas id="cv" tabindex="0" role="img" aria-describedby="hint"></canvas>
          <div class="tip" id="tip" aria-hidden="true"></div>
        </div>
        <div class="legend" id="legend" role="group" aria-label="Series — toggle visibility"></div>
        <div class="chartfoot">
          <span class="hint" id="hint">hover or focus the chart, then ← → to step through points</span>
          <button class="btn" type="button" id="tableBtn" aria-expanded="false" aria-controls="tablebox">show data table</button>
        </div>
        <div class="tablebox" id="tablebox" hidden></div>
        <p class="sr" id="live" role="status" aria-live="polite"></p>
      </div>

      <div class="err" id="chartErr" hidden>
        <strong id="errTitle"></strong>
        <span id="errBody"></span>
      </div>
    </section>
  </main>

  <footer>demo · sloprun.dev</footer>
</div>
<script>
(function () {
"use strict";

/* ============================ sample data ============================ */
var SAMPLES = {
weather: 'month,city,avg_temp_c,rainfall_mm,sunshine_hours\n' +
'2024-01,Reykjavik,0.5,76,20\n2024-02,Reykjavik,1.0,72,55\n2024-03,Reykjavik,1.5,82,110\n' +
'2024-04,Reykjavik,3.9,58,141\n2024-05,Reykjavik,6.9,44,177\n2024-06,Reykjavik,9.6,50,168\n' +
'2024-07,Reykjavik,11.2,52,175\n2024-08,Reykjavik,10.9,62,153\n2024-09,Reykjavik,8.2,67,120\n' +
'2024-10,Reykjavik,4.8,86,90\n2024-11,Reykjavik,2.3,73,41\n2024-12,Reykjavik,0.9,79,17\n' +
'2024-01,Cairo,14.0,5,213\n2024-02,Cairo,15.5,4,234\n2024-03,Cairo,18.2,4,269\n' +
'2024-04,Cairo,22.5,1,285\n2024-05,Cairo,26.3,0,324\n2024-06,Cairo,28.6,0,357\n' +
'2024-07,Cairo,29.2,0,363\n2024-08,Cairo,29.0,0,351\n2024-09,Cairo,27.4,0,311\n' +
'2024-10,Cairo,24.5,1,292\n2024-11,Cairo,19.8,3,248\n2024-12,Cairo,15.6,6,203\n' +
'2024-01,Singapore,26.6,242,174\n2024-02,Singapore,27.2,161,183\n2024-03,Singapore,27.6,186,190\n' +
'2024-04,Singapore,28.0,179,175\n2024-05,Singapore,28.4,171,180\n2024-06,Singapore,28.4,161,177\n' +
'2024-07,Singapore,28.0,159,183\n2024-08,Singapore,27.9,177,180\n2024-09,Singapore,27.7,169,156\n' +
'2024-10,Singapore,27.5,194,156\n2024-11,Singapore,27.0,257,132\n2024-12,Singapore,26.6,315,134\n',

cars: 'model,origin,weight_kg,mpg,horsepower\n' +
'Chevy Impala,USA,1980,14,165\nFord Torino,USA,1650,17,140\nBuick Skylark,USA,1560,20,150\n' +
'Dodge Challenger,USA,1730,15,190\nPontiac Catalina,USA,1990,14,175\nChevy Nova,USA,1450,22,100\n' +
'Ford Maverick,USA,1400,21,88\nAMC Gremlin,USA,1310,21,90\nPlymouth Fury,USA,1900,14,180\n' +
'Ford Pinto,USA,1150,25,75\nChevy Vega,USA,1080,28,72\nMercury Monarch,USA,1620,18,120\n' +
'VW Rabbit,Europe,880,41,48\nAudi 100LS,Europe,1120,24,90\nVolvo 145E,Europe,1330,18,112\n' +
'Peugeot 504,Europe,1300,23,87\nBMW 2002,Europe,1060,26,113\nSaab 99E,Europe,1150,25,95\n' +
'Fiat 128,Europe,900,29,75\nVW Dasher,Europe,940,36,67\n' +
'Toyota Corolla,Japan,970,32,75\nHonda Civic,Japan,830,38,53\nDatsun 510,Japan,1010,27,97\n' +
'Mazda GLC,Japan,860,34,65\nSubaru DL,Japan,940,30,67\nToyota Corona,Japan,1130,24,95\n',

bugs: 'team,severity,open_issues,median_age_days\n' +
'Platform,critical,3,4\nPlatform,major,11,12\nPlatform,minor,24,38\n' +
'Payments,critical,5,2\nPayments,major,14,7\nPayments,minor,9,21\n' +
'Mobile,critical,1,9\nMobile,major,8,17\nMobile,minor,31,52\n' +
'Growth,critical,2,6\nGrowth,major,6,14\nGrowth,minor,18,44\n' +
'Data,critical,4,3\nData,major,17,9\nData,minor,12,27\n',

messy: 'date,city,temp_c\n' +
'2024-01-01,Oslo,-3.2\n' +
'2024-01-02,Oslo,not measured\n' +
'2024-01-03,Oslo,-1.8,sensor B\n' +
'2024-01-04;Oslo;0.4\n' +
'\n' +
'2024-01-05,"Oslo, Norway",2.1\n' +
'2024-01-06,Oslo,\n' +
'2024-01-07,Oslo,3.9\n' +
'2024-01-08,"Oslo, Norway",1.2\n' +
'2024-01-09,Oslo,-0.6\n' +
'2024-01-10,Oslo,-2.4\n' +
'2024-01-11,"Oslo, Norway",0.8\n' +
'2024-01-12,Oslo,2.7\n' +
'2024-01-13,Oslo,4.1\n' +
'2024-01-14,Oslo,3.3\n'
};

/* ============================ tiny helpers ============================ */
function $(id) { return document.getElementById(id); }
function el(tag, cls, text) {
  var n = document.createElement(tag);
  if (cls) n.className = cls;
  if (text != null) n.textContent = text;
  return n;
}
function clear(n) { while (n.firstChild) n.removeChild(n.firstChild); }
var MON = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
var MONIDX = {};
(function () {
  var full = ['january','february','march','april','may','june','july','august','september','october','november','december'];
  for (var i = 0; i < 12; i++) { MONIDX[full[i]] = i; MONIDX[full[i].slice(0, 3)] = i; }
  MONIDX.sept = 8;
})();

/* ---- number parsing: tolerant of $ € £ ¥, %, thousands commas, (negatives),
   and of decimal commas when the file is not comma-separated ---- */
var DEC_COMMA = false;
function parseNum(raw) {
  if (raw == null) return NaN;
  var s = String(raw).trim();
  if (!s) return NaN;
  var mul = 1;
  if (s.charAt(0) === '(' && s.charAt(s.length - 1) === ')') { s = s.slice(1, -1).trim(); mul = -1; }
  s = s.replace(/^[$€£¥]\s*/, '').replace(/\s*[$€£¥]$/, '');
  if (s.charAt(s.length - 1) === '%') s = s.slice(0, -1).trim();
  if (/^[-+]?\d{1,3}(,\d{3})+(\.\d+)?$/.test(s)) s = s.replace(/,/g, '');
  else if (DEC_COMMA && /^[-+]?\d+,\d+$/.test(s)) s = s.replace(',', '.');
  s = s.replace(/\s/g, '');
  if (!/^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/.test(s)) return NaN;
  var v = parseFloat(s);
  return isFinite(v) ? v * mul : NaN;
}
function isPct(raw) { var s = String(raw == null ? '' : raw).trim(); return s.charAt(s.length - 1) === '%'; }

/* ---- date parsing: ISO, ISO month, slashed, month-name forms ---- */
/* Date.UTC silently rolls 2024-13-01 forward into 2025 and 2024-02-30 into
   March. A column that says month 13 is not a date column — refuse the value
   so it lands in the "not a date" count instead of inventing a point. */
function utcDay(y, mo, dy, prec) {
  if (!(mo >= 1 && mo <= 12) || !(dy >= 1 && dy <= 31)) return null;
  var t = Date.UTC(y, mo - 1, dy), d = new Date(t);
  if (d.getUTCFullYear() !== y || d.getUTCMonth() !== mo - 1 || d.getUTCDate() !== dy) return null;
  return { t: t, prec: prec };
}
function parseDate(raw, dayFirst) {
  var s = String(raw == null ? '' : raw).trim();
  if (!s) return null;
  var m;
  if ((m = /^(\d{4})[-\/](\d{1,2})[-\/](\d{1,2})(?:[T ].*)?$/.exec(s)))
    return utcDay(+m[1], +m[2], +m[3], 'd');
  if ((m = /^(\d{4})[-\/](\d{1,2})$/.exec(s)))
    return utcDay(+m[1], +m[2], 1, 'm');
  if ((m = /^(\d{1,2})[-\/](\d{1,2})[-\/](\d{4})$/.exec(s))) {
    var a = +m[1], b = +m[2];
    return utcDay(+m[3], dayFirst ? b : a, dayFirst ? a : b, 'd');
  }
  if ((m = /^([A-Za-z]{3,9})\.?\s+(\d{4})$/.exec(s))) {
    var i1 = MONIDX[m[1].toLowerCase()];
    if (i1 == null) return null;
    return utcDay(+m[2], i1 + 1, 1, 'm');
  }
  if ((m = /^([A-Za-z]{3,9})\.?\s+(\d{1,2}),?\s+(\d{4})$/.exec(s))) {
    var i2 = MONIDX[m[1].toLowerCase()];
    if (i2 == null) return null;
    return utcDay(+m[3], i2 + 1, +m[2], 'd');
  }
  if ((m = /^(\d{1,2})\s+([A-Za-z]{3,9})\.?\s+(\d{4})$/.exec(s))) {
    var i3 = MONIDX[m[2].toLowerCase()];
    if (i3 == null) return null;
    return utcDay(+m[3], i3 + 1, +m[1], 'd');
  }
  return null;
}
function fmtDate(t, sameYear, prec) {
  var d = new Date(t);
  if (prec === 'y') return String(d.getUTCFullYear());
  if (prec === 'm') return MON[d.getUTCMonth()] + (sameYear ? '' : " '" + String(d.getUTCFullYear()).slice(2));
  return MON[d.getUTCMonth()] + ' ' + d.getUTCDate() + (sameYear ? '' : ' ' + d.getUTCFullYear());
}
function fmtDateFull(t, prec) {
  var d = new Date(t);
  if (prec === 'm') return MON[d.getUTCMonth()] + ' ' + d.getUTCFullYear();
  return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0');
}
function fmtVal(v) {
  if (!isFinite(v)) return '—';
  var a = Math.abs(v);
  if (a >= 1e9) return (v / 1e9).toFixed(2).replace(/\.?0+$/, '') + 'B';
  if (a >= 1e6) return (v / 1e6).toFixed(2).replace(/\.?0+$/, '') + 'M';
  if (Number.isInteger(v)) return v.toLocaleString('en-US');
  if (a >= 100) return v.toFixed(1);
  if (a >= 1) return String(+v.toFixed(2));
  return String(+v.toPrecision(3));
}
function fmtY(v, dec) {
  if (!isFinite(v)) return '—';
  if (Math.abs(v) >= 1e6) return fmtVal(v);
  return v.toLocaleString('en-US', { minimumFractionDigits: dec || 0, maximumFractionDigits: dec || 0 });
}
function decimalsOf(col) {
  var dec = 0, nonInt = false, i, sv, dot;
  for (i = 0; i < col.values.length && i < 800; i++) {
    sv = String(col.values[i]);
    dot = sv.indexOf('.');
    if (dot >= 0) dec = Math.max(dec, Math.min(3, sv.length - dot - 1));
    if (col.nums && isFinite(col.nums[i]) && !Number.isInteger(col.nums[i])) nonInt = true;
  }
  if (!dec && nonInt) dec = 2;
  return dec;
}
function decOf(step) {
  if (!isFinite(step) || step <= 0) return 0;
  var s = String(step);
  /* the cap has to clear the smallest step nice ticks can hand back, or a
     near-constant axis prints the same label on every gridline */
  if (s.indexOf('e') >= 0 || s.indexOf('E') >= 0) return Math.min(12, Math.max(0, Math.ceil(-Math.log10(step))));
  var dot = s.indexOf('.');
  return dot < 0 ? 0 : Math.min(12, s.length - dot - 1);
}
function fmtTick(v, step) {
  var dec = decOf(step);
  var a = Math.abs(v);
  if (a >= 1e6) return +(v / 1e6).toFixed(1) + 'M';
  if (a >= 1e4) return +(v / 1e3).toFixed(a >= 1e5 ? 0 : 1) + 'k';
  return v.toLocaleString('en-US', { minimumFractionDigits: dec, maximumFractionDigits: dec });
}

/* ============================ CSV parsing ============================ */
function countOutside(line, ch) {
  var q = false, n = 0;
  for (var i = 0; i < line.length; i++) {
    var c = line.charAt(i);
    if (c === '"') q = !q;
    else if (!q && c === ch) n++;
  }
  return n;
}
function sniffDelim(text) {
  var lines = text.split(/\r?\n/), probe = '', i;
  for (i = 0; i < lines.length; i++) { if (lines[i].trim()) { probe = lines[i]; break; } }
  var best = ',', bestN = 0, cands = [',', ';', '\t', '|'];
  for (i = 0; i < cands.length; i++) {
    var n = countOutside(probe, cands[i]);
    if (n > bestN) { bestN = n; best = cands[i]; }
  }
  return best;
}
function splitRows(text, d) {
  var rows = [], row = [], cell = '', q = false, line = 1, start = 1, atFieldStart = true;
  function pushCell() { row.push(cell); cell = ''; atFieldStart = true; }
  function pushRow() { rows.push({ cells: row, line: start }); row = []; }
  for (var i = 0; i < text.length; i++) {
    var c = text.charAt(i);
    if (q) {
      if (c === '"') { if (text.charAt(i + 1) === '"') { cell += '"'; i++; } else q = false; }
      else { if (c === '\n') line++; cell += c; }
    } else if (c === '"' && atFieldStart) { q = true; atFieldStart = false; }
    else if (c === d) pushCell();
    else if (c === '\r') { /* ignore */ }
    else if (c === '\n') { pushCell(); pushRow(); line++; start = line; }
    else { cell += c; atFieldStart = false; }
  }
  if (cell !== '' || row.length) { pushCell(); pushRow(); }
  return rows;
}

var MAX_ROWS = 20000;

function parseCSV(text) {
  var out = { ok: false, issues: [], cols: [], rows: [], delim: ',', truncated: 0 };
  if (!text || !text.trim()) {
    out.fatal = { title: 'Nothing to chart yet', body: 'Paste some CSV into the box, drop a file on it, or press one of the sample buttons above.' };
    return out;
  }
  var d = out.delim = sniffDelim(text);
  DEC_COMMA = d !== ',';
  var raw = splitRows(text, d).filter(function (r) {
    return r.cells.some(function (c) { return String(c).trim() !== ''; });
  });
  if (!raw.length) {
    out.fatal = { title: 'No rows found', body: 'Every line in that input was blank.' };
    return out;
  }
  var head = raw[0].cells.map(function (c) { return String(c).trim(); });
  var allNumericHeader = head.every(function (h) { return h !== '' && !isNaN(parseNum(h)); });
  var body;
  if (allNumericHeader) {
    head = head.map(function (_, i) { return 'column_' + (i + 1); });
    body = raw;
    out.issues.push({ kind: 'note', row: '', text: 'first line looked numeric — treated as data, columns auto-named' });
  } else {
    body = raw.slice(1);
  }
  var seen = Object.create(null);
  head = head.map(function (h, i) {
    var base = h || ('column_' + (i + 1)), name = base, k = 1;
    while (seen[name]) { k++; name = base + '_' + k; }
    seen[name] = 1;
    return name;
  });
  if (head.length < 2) {
    out.fatal = {
      title: 'Only one column found',
      body: 'A chart needs at least two columns. Detected separator: "' + (d === '\t' ? '\\t' : d) + '" — if your file uses a different one, that is probably the problem.'
    };
    return out;
  }

  var rows = [], skipped = [];
  for (var i = 0; i < body.length; i++) {
    var r = body[i];
    if (rows.length >= MAX_ROWS) { out.truncated = body.length - i; break; }
    if (r.cells.length !== head.length) { skipped.push({ line: r.line, got: r.cells.length }); continue; }
    rows.push(r.cells.map(function (c) { return String(c).trim(); }));
  }
  if (!rows.length) {
    out.fatal = body.length ? {
      title: 'No usable rows',
      body: 'Every data row had a different number of fields than the header (' + head.length + '). Check for stray separators or unclosed quotes.'
    } : {
      title: 'Header row only',
      body: 'Found ' + head.length + ' column names but no data rows underneath them. Add at least one row of values.'
    };
    return out;
  }
  if (skipped.length) {
    var shown = skipped.slice(0, 3);
    for (var s = 0; s < shown.length; s++) {
      out.issues.push({ kind: 'bad', row: 'line ' + shown[s].line, text: 'has ' + shown[s].got + (shown[s].got === 1 ? ' field' : ' fields') + ', expected ' + head.length + ' — skipped' });
    }
    if (skipped.length > shown.length) {
      out.issues.push({ kind: 'bad', row: '+' + (skipped.length - shown.length), text: 'more rows skipped for the same reason' });
    }
  }
  if (out.truncated) out.issues.push({ kind: 'note', row: 'limit', text: out.truncated.toLocaleString('en-US') + ' rows past ' + MAX_ROWS.toLocaleString('en-US') + ' ignored' });

  out.cols = head.map(function (name, ci) { return profileColumn(name, ci, rows); });

  /* Cells that survived the row check but not their column's type still vanish
     from the plot — name them here rather than letting the count quietly shrink. */
  var noisy = 0;
  for (var c = 0; c < out.cols.length; c++) {
    var col = out.cols[c], parts = [];
    if (col.bad) parts.push(col.bad + (col.bad === 1 ? ' value that is not a ' : ' values that are not ') + (col.type === 'date' ? 'date' : 'number') + (col.bad === 1 ? '' : 's'));
    if (col.blanks) parts.push(col.blanks + ' blank');
    if (!parts.length) continue;
    noisy++;
    if (noisy <= 3) out.issues.push({ kind: 'note', row: col.name, text: parts.join(' · ') + ' — left out of the plot' });
  }
  if (noisy > 3) out.issues.push({ kind: 'note', row: '+' + (noisy - 3), text: 'more columns with gaps' });

  out.rows = rows;
  out.skipped = skipped.length;
  out.ok = true;
  return out;
}

function profileColumn(name, ci, rows) {
  var col = { name: name, index: ci, blanks: 0, distinctCount: 0, values: [], unit: '' };
  var vals = [], i, v;
  for (i = 0; i < rows.length; i++) {
    v = rows[i][ci];
    vals.push(v);
    if (v === '') col.blanks++;
  }
  col.values = vals;
  var nonEmpty = rows.length - col.blanks;

  /* date? */
  var dayFirst = false;
  for (i = 0; i < vals.length; i++) {
    var mm = /^(\d{1,2})[-\/](\d{1,2})[-\/]\d{4}$/.exec(vals[i]);
    if (mm && +mm[1] > 12) { dayFirst = true; break; }
  }
  var times = new Array(vals.length), dOK = 0, prec = 'm';
  for (i = 0; i < vals.length; i++) {
    var p = vals[i] === '' ? null : parseDate(vals[i], dayFirst);
    if (p) { times[i] = p.t; dOK++; if (p.prec === 'd') prec = 'd'; } else times[i] = NaN;
  }
  if (nonEmpty > 0 && dOK / nonEmpty >= 0.8) {
    col.type = 'date'; col.times = times; col.prec = prec; col.dayFirst = dayFirst;
    var tmin = Infinity, tmax = -Infinity;
    for (i = 0; i < times.length; i++) { if (isFinite(times[i])) { if (times[i] < tmin) tmin = times[i]; if (times[i] > tmax) tmax = times[i]; } }
    col.min = tmin; col.max = tmax;
    col.bad = nonEmpty - dOK;
    countDistinct(col, vals);
    return col;
  }

  /* number? */
  var nums = new Array(vals.length), nOK = 0, pct = 0;
  for (i = 0; i < vals.length; i++) {
    var n = vals[i] === '' ? NaN : parseNum(vals[i]);
    nums[i] = n;
    if (isFinite(n)) { nOK++; if (isPct(vals[i])) pct++; }
  }
  if (nonEmpty > 0 && nOK / nonEmpty >= 0.8) {
    col.type = 'number'; col.nums = nums;
    var mn = Infinity, mx = -Infinity, uniq = {}, uniqN = 0, allInt = true;
    for (i = 0; i < nums.length; i++) {
      if (!isFinite(nums[i])) continue;
      if (nums[i] < mn) mn = nums[i];
      if (nums[i] > mx) mx = nums[i];
      if (!Number.isInteger(nums[i])) allInt = false;
      if (!uniq[nums[i]]) { uniq[nums[i]] = 1; uniqN++; }
    }
    col.min = mn; col.max = mx; col.distinctCount = uniqN;
    col.unit = pct === nOK && nOK > 0 ? '%' : '';
    col.idLike = allInt && uniqN === nOK && nOK > 3 && (mx - mn + 1 === nOK || /^(id|index|idx|no|num|rank|row)$/i.test(name));
    col.bad = nonEmpty - nOK;
    return col;
  }

  col.type = 'text';
  countDistinct(col, vals);
  col.bad = 0;
  return col;
}
function countDistinct(col, vals) {
  var map = new Map(), i;
  for (i = 0; i < vals.length; i++) {
    if (vals[i] === '') continue;
    map.set(vals[i], (map.get(vals[i]) || 0) + 1);
  }
  col.counts = map;
  col.distinctCount = map.size;
  col.order = Array.from(map.keys());
}

/* ============================ chart model ============================ */
var PALETTE = ['--s1', '--s2', '--s3', '--s4', '--s5', '--s6'];
var MAX_SERIES = 6;

function autoPick(parsed) {
  var cols = parsed.cols, rows = parsed.rows, n = rows.length;
  var nums = cols.filter(function (c) { return c.type === 'number'; });
  var plainNums = nums.filter(function (c) { return !c.idLike; });
  var dates = cols.filter(function (c) { return c.type === 'date'; });
  var texts = cols.filter(function (c) { return c.type === 'text'; });
  if (!nums.length) return null;
  var yPool = plainNums.length ? plainNums : nums;
  /* a column that never changes makes a useless axis — skip it if we have a choice */
  var varied = yPool.filter(function (c) { return isFinite(c.min) && c.min !== c.max; });
  if (varied.length) yPool = varied;
  var y = yPool[0], x = null, type = 'bar';

  var groupCands = texts.concat(dates).filter(function (c) {
    return c.distinctCount >= 2 && c.distinctCount <= 12 && c.distinctCount < n;
  });
  var idLikeText = texts.filter(function (c) { return c.distinctCount >= n * 0.9 && n > 3; })[0];

  if (dates.length) {
    x = dates[0]; type = 'line';
  } else if (idLikeText && yPool.length >= 2) {
    x = yPool[0]; y = yPool[1]; type = 'scatter';
  } else if (groupCands.length) {
    x = groupCands[0]; type = 'bar';
  } else if (yPool.length >= 2) {
    x = yPool[0]; y = yPool[1]; type = 'scatter';
  } else {
    /* last resort: any other column that actually has values in it */
    var rest = cols.filter(function (c) { return c !== y; });
    x = rest.filter(function (c) {
      return c.type === 'number' ? isFinite(c.min) : c.distinctCount > 0;
    })[0] || rest[0] || cols[0];
    /* a numeric X is a continuous axis — 50 ids side by side as bars is not a chart */
    type = x.type === 'date' ? 'line' : x.type !== 'number' ? 'bar' : x.idLike ? 'line' : 'scatter';
  }
  var g = groupCands.filter(function (c) {
    return c !== x && c !== y && c.distinctCount >= 2 && c.distinctCount <= MAX_SERIES;
  })[0] || null;
  return { x: x.name, y: y.name, g: g ? g.name : '', type: type };
}

function colByName(parsed, name) {
  if (!name) return null;
  for (var i = 0; i < parsed.cols.length; i++) if (parsed.cols[i].name === name) return parsed.cols[i];
  return null;
}
function rawX(col, i) {
  if (col.type === 'date') return col.times[i];
  if (col.type === 'number') return col.nums[i];
  return col.values[i];
}
function xLabelOf(col, v, sameYear) {
  if (col.type === 'date') return fmtDate(v, sameYear, col.prec);
  if (col.type === 'number') return fmtVal(v);
  return String(v);
}

function buildModel(parsed, xName, yName, gName, type) {
  var xCol = colByName(parsed, xName), yCol = colByName(parsed, yName), gCol = colByName(parsed, gName);
  if (!xCol || !yCol) return null;
  var rows = parsed.rows, i;

  /* series keys, in stable first-seen order — colour follows the entity */
  var keys = [], keyIdx = new Map();
  if (gCol) {
    var counts = [];
    gCol.counts.forEach(function (c, kk) { counts.push([kk, c]); });
    counts.sort(function (a, b) { return b[1] - a[1]; });
    var keep = new Set(counts.slice(0, MAX_SERIES).map(function (p) { return p[0]; }));
    var order = gCol.type === 'date'
      ? gCol.order.slice().sort(function (a, b) {
          var pa = parseDate(a, gCol.dayFirst), pb = parseDate(b, gCol.dayFirst);
          return ((pa && pa.t) || 0) - ((pb && pb.t) || 0);
        })
      : gCol.order;
    for (i = 0; i < order.length; i++) if (keep.has(order[i])) keys.push(order[i]);
    if (counts.length > MAX_SERIES && keys.indexOf('Other') < 0) keys.push('Other');
  } else {
    keys.push(yCol.name);
  }
  for (i = 0; i < keys.length; i++) keyIdx.set(keys[i], i);

  var folded = gCol ? Math.max(0, gCol.distinctCount - MAX_SERIES) : 0;
  var xKind = type === 'bar' ? 'ordinal'
    : xCol.type === 'date' ? 'time'
      : xCol.type === 'number' ? 'linear' : 'ordinal';

  /* collect usable rows */
  var pts = [], dropped = 0, xvalsSeen = new Map();
  for (i = 0; i < rows.length; i++) {
    var yv = yCol.type === 'number' ? yCol.nums[i] : parseNum(yCol.values[i]);
    var xv = rawX(xCol, i);
    var xOK = xCol.type === 'text' ? String(xv) !== '' : isFinite(xv);
    if (!isFinite(yv) || !xOK) { dropped++; continue; }
    var gk = yCol.name;
    if (gCol) {
      var gv = gCol.values[i];
      if (gv === '') { dropped++; continue; }
      gk = keyIdx.has(gv) ? gv : 'Other';
    }
    if (!xvalsSeen.has(String(xv))) xvalsSeen.set(String(xv), xv);
    pts.push({ x: xv, y: yv, k: gk });
  }
  if (!pts.length) return { empty: true, xCol: xCol, yCol: yCol, gCol: gCol, type: type, dropped: dropped };

  /* ordinal categories */
  var cats = null, catIdx = null, sameYear = true;
  if (xCol.type === 'date') {
    var yrs = new Set();
    for (i = 0; i < pts.length; i++) yrs.add(new Date(pts[i].x).getUTCFullYear());
    sameYear = yrs.size === 1;
  }
  if (xKind === 'ordinal') {
    cats = Array.from(xvalsSeen.values());
    if (xCol.type === 'date' || xCol.type === 'number') cats.sort(function (a, b) { return a - b; });
    catIdx = new Map();
    for (i = 0; i < cats.length; i++) catIdx.set(String(cats[i]), i);
  }

  /* series buckets */
  var series = keys.map(function (kk, idx) {
    return {
      name: String(kk), key: String(kk),
      colorVar: (kk === 'Other' && folded) ? '--s-other' : PALETTE[idx % PALETTE.length],
      shape: idx % 6, points: [], visible: true
    };
  });
  var sIdx = new Map();
  for (i = 0; i < series.length; i++) sIdx.set(series[i].key, i);

  var aggregated = false;
  if (type === 'scatter') {
    for (i = 0; i < pts.length; i++) {
      var s0 = series[sIdx.get(pts[i].k)];
      var px = xKind === 'ordinal' ? catIdx.get(String(pts[i].x)) : pts[i].x;
      s0.points.push({ x: px, y: pts[i].y, xr: pts[i].x });
    }
  } else {
    var acc = new Map();
    for (i = 0; i < pts.length; i++) {
      var key = pts[i].k + '\u0000' + String(pts[i].x);
      var a = acc.get(key);
      if (a) { a.sum += pts[i].y; a.n++; aggregated = true; }
      else acc.set(key, { sum: pts[i].y, n: 1, k: pts[i].k, x: pts[i].x });
    }
    acc.forEach(function (a) {
      var s1 = series[sIdx.get(a.k)];
      var px2 = xKind === 'ordinal' ? catIdx.get(String(a.x)) : a.x;
      s1.points.push({ x: px2, y: a.sum / a.n, xr: a.x });
    });
    for (i = 0; i < series.length; i++) series[i].points.sort(function (a, b) { return a.x - b.x; });
  }

  return {
    type: type, xCol: xCol, yCol: yCol, gCol: gCol, xKind: xKind, cats: cats,
    sameYear: sameYear, series: series, aggregated: aggregated, folded: folded,
    dropped: dropped, plotted: pts.length, yDec: decimalsOf(yCol)
  };
}

function niceTicks(min, max, target) {
  if (!isFinite(min) || !isFinite(max)) return { ticks: [0], step: 1 };
  if (min === max) { var pad = Math.abs(min) * 0.2 || 1; min -= pad; max += pad; }
  var span = max - min;
  var raw = span / Math.max(1, target);
  var mag = Math.pow(10, Math.floor(Math.log10(raw)));
  var norm = raw / mag, step;
  if (norm <= 1) step = 1; else if (norm <= 2) step = 2; else if (norm <= 2.5) step = 2.5;
  else if (norm <= 5) step = 5; else step = 10;
  step = +(step * mag).toPrecision(12);
  if (!isFinite(step) || step <= 0) return { ticks: [min, max], step: (max - min) || 1 };
  function build(st) {
    var a = [], start = Math.ceil(min / st - 1e-9) * st;
    for (var v = start; v <= max + st * 1e-9 && a.length < 64; v += st) a.push(Math.abs(v) < st * 1e-9 ? 0 : +v.toFixed(10));
    return a;
  }
  var out = build(step);
  /* a two-line grid reads as no grid — step down once when the ladder lands too coarse */
  if (out.length < 3) {
    var finer = +(step / 2).toPrecision(12);
    if (finer > 0) { var o2 = build(finer); if (o2.length >= 3) { out = o2; step = finer; } }
  }
  return { ticks: out, step: step };
}

/* ============================ canvas rendering ============================ */
var cv = $('cv'), ctx = cv.getContext('2d');
var W = 0, H = 0, geo = null, anim = { raf: 0, p: 1 };
var reduce = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : { matches: false };
var MONO = 'ui-monospace, "SF Mono", Menlo, Consolas, monospace';
var SANS = 'system-ui, -apple-system, "Segoe UI", sans-serif';

function cssVar(name) {
  return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || '#888888';
}
function themeColors() {
  var c = { ink: cssVar('--ink'), muted: cssVar('--muted'), grid: cssVar('--line'), surface: cssVar('--surface'), accent: cssVar('--accent') };
  c.series = {};
  for (var i = 0; i < PALETTE.length; i++) c.series[PALETTE[i]] = cssVar(PALETTE[i]);
  c.series['--s-other'] = cssVar('--s-other');
  return c;
}
function sizeCanvas() {
  var r = cv.getBoundingClientRect();
  W = Math.max(200, r.width); H = Math.max(160, r.height);
  var dpr = Math.min(3, window.devicePixelRatio || 1);
  cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function n2(v) { return Math.round(v * 100) / 100; }
function shapePath(i, cx, cy, r) {
  cx = n2(cx); cy = n2(cy); r = n2(r);
  var s, a, b;
  switch (i % 6) {
    case 1: s = n2(r * 0.92); return 'M ' + (cx - s) + ' ' + (cy - s) + ' H ' + (cx + s) + ' V ' + (cy + s) + ' H ' + (cx - s) + ' Z';
    case 2: return 'M ' + cx + ' ' + n2(cy - r * 1.18) + ' L ' + n2(cx + r * 1.08) + ' ' + n2(cy + r * 0.78) + ' L ' + n2(cx - r * 1.08) + ' ' + n2(cy + r * 0.78) + ' Z';
    case 3: return 'M ' + cx + ' ' + n2(cy - r * 1.25) + ' L ' + n2(cx + r * 1.25) + ' ' + cy + ' L ' + cx + ' ' + n2(cy + r * 1.25) + ' L ' + n2(cx - r * 1.25) + ' ' + cy + ' Z';
    case 4: return 'M ' + cx + ' ' + n2(cy + r * 1.18) + ' L ' + n2(cx + r * 1.08) + ' ' + n2(cy - r * 0.78) + ' L ' + n2(cx - r * 1.08) + ' ' + n2(cy - r * 0.78) + ' Z';
    case 5:
      a = n2(r * 0.44); b = n2(r * 1.2);
      return 'M ' + (cx - a) + ' ' + (cy - b) + ' H ' + (cx + a) + ' V ' + (cy - a) + ' H ' + (cx + b) + ' V ' + (cy + a) +
        ' H ' + (cx + a) + ' V ' + (cy + b) + ' H ' + (cx - a) + ' V ' + (cy + a) + ' H ' + (cx - b) + ' V ' + (cy - a) + ' H ' + (cx - a) + ' Z';
    default: return 'M ' + (cx - r) + ' ' + cy + ' a ' + r + ' ' + r + ' 0 1 0 ' + n2(2 * r) + ' 0 a ' + r + ' ' + r + ' 0 1 0 ' + n2(-2 * r) + ' 0 Z';
  }
}
function drawShape(i, cx, cy, r, fill, ring, ringW) {
  var p = new Path2D(shapePath(i, cx, cy, r));
  if (ring) { ctx.lineWidth = ringW || 2; ctx.strokeStyle = ring; ctx.stroke(p); }
  ctx.fillStyle = fill; ctx.fill(p);
}
function roundedTop(x, y, w, h, r) {
  r = Math.max(0, Math.min(r, w / 2, Math.abs(h)));
  ctx.beginPath();
  if (h >= 0) {
    var yt2 = y - h;
    ctx.moveTo(x, y); ctx.lineTo(x, yt2 + r);
    ctx.quadraticCurveTo(x, yt2, x + r, yt2); ctx.lineTo(x + w - r, yt2);
    ctx.quadraticCurveTo(x + w, yt2, x + w, yt2 + r); ctx.lineTo(x + w, y); ctx.closePath();
  } else {
    var yb = y - h;
    ctx.moveTo(x, y); ctx.lineTo(x, yb - r);
    ctx.quadraticCurveTo(x, yb, x + r, yb); ctx.lineTo(x + w - r, yb);
    ctx.quadraticCurveTo(x + w, yb, x + w, yb - r); ctx.lineTo(x + w, y); ctx.closePath();
  }
  ctx.fill();
}

function render(model, hover, p) {
  ctx.clearRect(0, 0, W, H);
  geo = null;
  if (!model || model.empty) return;
  var C = themeColors();
  var vis = model.series.filter(function (s) { return s.visible && s.points.length; });
  if (!vis.length) {
    ctx.font = '13px ' + SANS; ctx.fillStyle = C.muted; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText('every series is hidden — switch one back on below', W / 2, H / 2);
    return;
  }

  /* ---- domains ---- */
  var ymin = Infinity, ymax = -Infinity, xmin = Infinity, xmax = -Infinity, i, j, s, pt;
  for (i = 0; i < vis.length; i++) for (j = 0; j < vis[i].points.length; j++) {
    pt = vis[i].points[j];
    if (pt.y < ymin) ymin = pt.y;
    if (pt.y > ymax) ymax = pt.y;
    if (pt.x < xmin) xmin = pt.x;
    if (pt.x > xmax) xmax = pt.x;
  }
  var bar = model.type === 'bar';
  if (bar) { if (ymin > 0) ymin = 0; if (ymax < 0) ymax = 0; }
  if (ymin === ymax) { var e = Math.abs(ymin) * 0.15 || 1; ymin -= e; ymax += e; }
  else if (!bar) { var padY = (ymax - ymin) * 0.08; ymin -= padY; ymax += padY; }
  var yt = niceTicks(ymin, ymax, H < 260 ? 4 : 5);
  if (yt.ticks.length) {
    ymin = Math.min(ymin, yt.ticks[0]);
    ymax = Math.max(ymax, yt.ticks[yt.ticks.length - 1]);
  }

  /* ---- paddings ---- */
  ctx.font = '11px ' + MONO;
  var labW = 0;
  for (i = 0; i < yt.ticks.length; i++) labW = Math.max(labW, ctx.measureText(fmtTick(yt.ticks[i], yt.step)).width);
  var padL = Math.ceil(labW) + 12, padT = 12, padB = 42, padR = 14;

  var endLabels = model.type === 'line' && vis.length <= 4 && W >= 440;
  if (endLabels) {
    ctx.font = '11px ' + SANS;
    var endW = 0;
    for (i = 0; i < vis.length; i++) endW = Math.max(endW, ctx.measureText(vis[i].name).width);
    padR += Math.min(endW + 18, 110);
  }
  var plotW = W - padL - padR, plotH = H - padT - padB;
  if (plotW < 90) { endLabels = false; padR = 14; plotW = W - padL - padR; }

  var ordinal = model.xKind === 'ordinal';
  var nCats = ordinal ? model.cats.length : 0;
  var band = ordinal ? plotW / Math.max(1, nCats) : 0;
  function X(v) {
    if (ordinal) return padL + band * (v + 0.5);
    if (xmax === xmin) return padL + plotW / 2;
    return padL + (v - xmin) / (xmax - xmin) * plotW;
  }
  function Y(v) { return padT + plotH - (v - ymin) / (ymax - ymin) * plotH; }

  /* ---- gridlines + y ticks ---- */
  ctx.strokeStyle = C.grid; ctx.lineWidth = 1;
  ctx.font = '11px ' + MONO; ctx.fillStyle = C.muted; ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
  for (i = 0; i < yt.ticks.length; i++) {
    var gy = Math.round(Y(yt.ticks[i])) + 0.5;
    ctx.beginPath(); ctx.moveTo(padL, gy); ctx.lineTo(padL + plotW, gy); ctx.stroke();
    ctx.fillText(fmtTick(yt.ticks[i], yt.step), padL - 8, gy);
  }

  /* ---- x ticks ---- */
  var xticks = [], lb;
  if (ordinal) {
    ctx.font = '11px ' + MONO;
    /* sample the widths — measuring every category costs a frame on wide files */
    var maxW = 0, sampleStep = Math.max(1, Math.ceil(nCats / 120));
    for (i = 0; i < nCats; i += sampleStep) {
      lb = xLabelOf(model.xCol, model.cats[i], model.sameYear);
      maxW = Math.max(maxW, ctx.measureText(lb).width);
    }
    var fit = Math.max(1, Math.floor(plotW / (maxW + 14)));
    var stepC = Math.ceil(nCats / fit);
    for (i = 0; i < nCats; i += stepC) xticks.push({ px: X(i), label: xLabelOf(model.xCol, model.cats[i], model.sameYear) });
  } else if (model.xKind === 'linear') {
    var want1 = Math.max(2, Math.min(7, Math.floor(plotW / 74)));
    var xt = niceTicks(xmin, xmax, want1);
    for (i = 0; i < xt.ticks.length; i++) {
      if (xt.ticks[i] < xmin - 1e-9 || xt.ticks[i] > xmax + 1e-9) continue;
      xticks.push({ px: X(xt.ticks[i]), label: fmtTick(xt.ticks[i], xt.step) });
    }
  } else {
    var ded = xValueList(model);   /* cached + deduped per visibility signature */
    var want2 = Math.max(2, Math.min(7, Math.floor(plotW / 74)));
    var stepT = Math.max(1, Math.ceil(ded.length / want2));
    for (i = 0; i < ded.length; i += stepT) xticks.push({ px: X(ded[i].x), label: xLabelOf(model.xCol, ded[i].x, model.sameYear) });
  }
  ctx.fillStyle = C.muted; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.font = '11px ' + MONO;
  for (i = 0; i < xticks.length; i++) {
    var halfW = ctx.measureText(xticks[i].label).width / 2;
    ctx.fillText(xticks[i].label, Math.min(W - 3 - halfW, Math.max(3 + halfW, xticks[i].px)), padT + plotH + 8);
  }
  ctx.strokeStyle = C.grid; ctx.beginPath();
  ctx.moveTo(padL, Math.round(padT + plotH) + 0.5); ctx.lineTo(padL + plotW, Math.round(padT + plotH) + 0.5); ctx.stroke();
  ctx.fillStyle = C.muted; ctx.font = '10.5px ' + MONO; ctx.textBaseline = 'alphabetic';
  ctx.fillText(model.xCol.name, padL + plotW / 2, H - 6);

  /* ---- marks ---- */
  var hoverIdx = (hover && hover.kind === 'x' && hover.cat != null) ? hover.cat : -1;
  if (bar) {
    var slots = vis.length, gap = 2;
    var inner = Math.min(band * 0.8, 26 * slots + gap * (slots - 1));
    var bw = Math.max(1.5, (inner - gap * (slots - 1)) / slots);
    if (hoverIdx >= 0) {
      ctx.globalAlpha = 0.5; ctx.fillStyle = C.grid;
      ctx.fillRect(padL + band * hoverIdx, padT, band, plotH); ctx.globalAlpha = 1;
    }
    for (i = 0; i < vis.length; i++) {
      s = vis[i]; ctx.fillStyle = C.series[s.colorVar];
      for (j = 0; j < s.points.length; j++) {
        pt = s.points[j];
        var bx = padL + band * (pt.x + 0.5) - inner / 2 + i * (bw + gap);
        var y0 = Y(0);
        roundedTop(bx, y0, bw, (y0 - Y(pt.y)) * p, 4);
      }
    }
  } else if (model.type === 'line') {
    ctx.save();
    ctx.beginPath(); ctx.rect(padL - 8, 0, plotW * p + 16, H); ctx.clip();
    ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.lineWidth = 2;
    var total = 0;
    for (i = 0; i < vis.length; i++) {
      s = vis[i]; total += s.points.length;
      ctx.strokeStyle = C.series[s.colorVar];
      ctx.beginPath();
      for (j = 0; j < s.points.length; j++) {
        pt = s.points[j];
        if (j === 0) ctx.moveTo(X(pt.x), Y(pt.y)); else ctx.lineTo(X(pt.x), Y(pt.y));
      }
      ctx.stroke();
    }
    if (total <= 300) for (i = 0; i < vis.length; i++) {
      s = vis[i];
      for (j = 0; j < s.points.length; j++) drawShape(s.shape, X(s.points[j].x), Y(s.points[j].y), 4, C.series[s.colorVar], C.surface, 2);
    }
    ctx.restore();
  } else {
    ctx.globalAlpha = p;
    for (i = 0; i < vis.length; i++) {
      s = vis[i];
      var many = s.points.length > 400;
      for (j = 0; j < s.points.length; j++) {
        pt = s.points[j];
        drawShape(s.shape, X(pt.x), Y(pt.y), 4.5 * (0.4 + 0.6 * p), C.series[s.colorVar], many ? null : C.surface, 2);
      }
    }
    ctx.globalAlpha = 1;
  }

  /* ---- direct labels ---- */
  if (p >= 1 && endLabels) {
    var lab = [];
    for (i = 0; i < vis.length; i++) {
      s = vis[i];
      if (!s.points.length) continue;
      var last = s.points[s.points.length - 1];
      lab.push({ y: Y(last.y), x: X(last.x), name: s.name, cvar: C.series[s.colorVar] });
    }
    lab.sort(function (a, b) { return a.y - b.y; });
    var collide = false;
    for (i = 1; i < lab.length; i++) if (lab[i].y - lab[i - 1].y < 14) collide = true;
    if (!collide) {
      ctx.font = '11px ' + SANS; ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
      for (i = 0; i < lab.length; i++) {
        var lx = Math.min(lab[i].x + 8, padL + plotW + 8);
        ctx.strokeStyle = lab[i].cvar; ctx.lineWidth = 2;
        ctx.beginPath(); ctx.moveTo(lx, lab[i].y); ctx.lineTo(lx + 10, lab[i].y); ctx.stroke();
        ctx.fillStyle = C.ink;
        ctx.fillText(lab[i].name, lx + 15, lab[i].y);
      }
    }
  }
  if (p >= 1 && bar && vis.length === 1) {
    ctx.font = '11px ' + MONO; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillStyle = C.ink;
    s = vis[0];
    var fits = true;
    for (j = 0; j < s.points.length; j++) if (ctx.measureText(fmtY(s.points[j].y, model.yDec)).width + 8 > band) fits = false;
    if (fits) for (j = 0; j < s.points.length; j++) {
      pt = s.points[j];
      ctx.fillText(fmtY(pt.y, model.yDec), padL + band * (pt.x + 0.5), Y(pt.y) + (pt.y < 0 ? 15 : -6));
    }
  }

  /* ---- hover overlay ---- */
  if (hover && p >= 1) {
    if (hover.kind === 'x') {
      if (!bar && hover.x != null) {
        var hx = Math.round(X(hover.x)) + 0.5;
        ctx.save();
        ctx.globalAlpha = 0.5; ctx.strokeStyle = C.muted; ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.moveTo(hx, padT); ctx.lineTo(hx, padT + plotH);
        ctx.stroke(); ctx.restore();
      }
      for (i = 0; i < hover.marks.length; i++) {
        var mk = hover.marks[i];
        if (bar) {
          var hbx = padL + band * (mk.x + 0.5) - inner / 2 + mk.si * (bw + gap);
          ctx.lineWidth = 2; ctx.strokeStyle = C.ink;
          ctx.strokeRect(hbx - 1.5, Math.min(Y(0), Y(mk.y)) - 1.5, bw + 3, Math.abs(Y(mk.y) - Y(0)) + 3);
        } else {
          drawShape(mk.shape, X(mk.x), Y(mk.y), 5.5, C.series[mk.colorVar], C.surface, 2.5);
        }
      }
    } else if (hover.kind === 'pt' && hover.mark) {
      drawShape(hover.mark.shape, X(hover.mark.x), Y(hover.mark.y), 6.5, C.series[hover.mark.colorVar], C.surface, 2.5);
    }
  }

  geo = {
    padL: padL, padT: padT, padR: padR, plotW: plotW, plotH: plotH, band: band,
    X: X, Y: Y, ordinal: ordinal, vis: vis, bar: bar, nCats: nCats
  };
}

function animateIn() {
  if (anim.raf) { cancelAnimationFrame(anim.raf); anim.raf = 0; }
  if (reduce.matches) { anim.p = 1; draw(); return; }
  var t0 = 0, dur = 340;
  anim.p = 0;
  anim.raf = requestAnimationFrame(function step(now) {
    if (!t0) t0 = now;
    var k = Math.min(1, (now - t0) / dur);
    anim.p = 1 - Math.pow(1 - k, 3);
    draw();
    anim.raf = k < 1 ? requestAnimationFrame(step) : 0;
  });
}
function draw() { render(state.model, state.hover, anim.p); }

/* ============================ state + UI ============================ */
var state = {
  parsed: null, model: null, x: '', y: '', g: '', type: 'line',
  manual: { x: false, y: false, g: false, type: false },
  hidden: Object.create(null), hover: null, showTable: false, keyIdx: -1
};

var elCsv = $('csv'), elIssues = $('issues'), elCols = $('cols'), elFacts = $('facts');
var okPill = $('okPill'), warnPill = $('warnPill'), elLegend = $('legend'), elTip = $('tip');
var selX = $('selX'), selY = $('selY'), selG = $('selG'), autoflag = $('autoflag');
var chartArea = $('chartArea'), chartErr = $('chartErr'), errTitle = $('errTitle'), errBody = $('errBody');
var chartTitle = $('chartTitle'), chartNote = $('chartNote'), live = $('live');
var tableBtn = $('tableBtn'), tablebox = $('tablebox');

function colDetail(col) {
  if (col.type === 'number') {
    if (!isFinite(col.min)) return 'no values';
    return fmtVal(col.min) + '–' + fmtVal(col.max) + (col.unit || '');
  }
  if (col.type === 'date') return fmtDateFull(col.min, col.prec) + ' → ' + fmtDateFull(col.max, col.prec);
  return col.distinctCount + ' distinct';
}

function renderStatus(parsed, ms) {
  clear(elIssues);
  if (!parsed.ok) {
    okPill.hidden = true; warnPill.hidden = true;
    elFacts.textContent = '';
    return;
  }
  okPill.hidden = false;
  var facts = document.createDocumentFragment();
  function fact(v, unit) {
    var sp = el('span');
    sp.appendChild(el('b', null, v));
    sp.appendChild(document.createTextNode(' ' + unit));
    facts.appendChild(sp);
    facts.appendChild(document.createTextNode(' · '));
  }
  fact(parsed.rows.length.toLocaleString('en-US'), parsed.rows.length === 1 ? 'row' : 'rows');
  fact(String(parsed.cols.length), 'columns');
  var dn = parsed.delim === '\t' ? 'tab' : parsed.delim === ',' ? 'comma' : parsed.delim === ';' ? 'semicolon' : 'pipe';
  facts.appendChild(el('span', null, dn + '-separated'));
  facts.appendChild(document.createTextNode(' · '));
  fact(ms.toFixed(ms < 10 ? 1 : 0), 'ms');
  facts.removeChild(facts.lastChild);
  clear(elFacts);
  elFacts.appendChild(facts);

  if (parsed.skipped) {
    warnPill.hidden = false;
    warnPill.textContent = parsed.skipped + (parsed.skipped === 1 ? ' row skipped' : ' rows skipped');
  } else warnPill.hidden = true;

  for (var i = 0; i < parsed.issues.length; i++) {
    var it = parsed.issues[i];
    var li = el('li', it.kind === 'bad' ? 'bad' : null);
    li.appendChild(el('span', 'row', it.row));
    li.appendChild(el('span', null, it.text));
    elIssues.appendChild(li);
  }
}

function renderColumns(parsed) {
  clear(elCols);
  parsed.cols.forEach(function (col) {
    var b = el('button', 'colbtn');
    b.type = 'button';
    var nm = el('span', 'nm', col.name);
    var ty = el('span', 'ty ' + col.type, col.type);
    var rg = el('span', 'rng', colDetail(col));
    b.appendChild(nm); b.appendChild(ty); b.appendChild(rg);
    /* One button, one honest action. A column already carrying a role says so
       instead of offering a role it cannot take (colour-by is never the X). */
    var numCount = parsed.cols.filter(function (c) { return c.type === 'number'; }).length;
    var groupable = col.type !== 'number' && col.name !== state.x &&
      col.distinctCount >= 2 && col.distinctCount <= 12;
    var action, verb;
    if (col.name === state.g) { action = 'ungroup'; verb = 'Stop colouring by'; }
    else if (col.type === 'number') {
      if (col.name !== state.y) { action = 'y'; verb = 'Plot on the Y axis'; }
      else if (numCount > 1) { action = 'x'; verb = 'Move to the X axis'; }
      else { action = 'y'; verb = 'Already the Y axis'; }
    } else if (col.name === state.x) { action = 'x'; verb = 'Already the X axis'; }
    else if (groupable) { action = 'g'; verb = 'Colour the chart by'; }
    else { action = 'x'; verb = 'Plot on the X axis'; }
    b.title = verb + ': ' + col.name;
    b.setAttribute('aria-label', verb + ': ' + col.name + ' (' + col.type + ', ' + colDetail(col) + ')');
    var role = col.name === state.x ? 'x' : col.name === state.y ? 'y' : col.name === state.g ? 'g' : '';
    if (role) {
      b.setAttribute('data-active', role);
      b.setAttribute('aria-pressed', 'true');
      b.insertBefore(el('span', 'role', role === 'g' ? 'colour' : role.toUpperCase()), rg);
    }
    b.addEventListener('click', function () {
      if (action === 'y') setY(col.name);
      else if (action === 'x') setX(col.name);
      else if (action === 'ungroup') { state.g = ''; state.manual.g = true; state.hidden = Object.create(null); }
      else { state.g = col.name; state.manual.g = true; state.hidden = Object.create(null); }
      syncSelects(); rebuild(true);
    });
    elCols.appendChild(b);
  });
}

function fillSelect(sel, items, value) {
  clear(sel);
  items.forEach(function (it) {
    var o = document.createElement('option');
    o.value = it.value; o.textContent = it.label;
    sel.appendChild(o);
  });
  sel.value = value;
  if (sel.selectedIndex < 0 && sel.options.length) sel.selectedIndex = 0;
}
function groupCandidates(parsed) {
  return parsed.cols.filter(function (c) {
    return c.type !== 'number' && c.distinctCount >= 2 && c.distinctCount <= 12;
  });
}
function syncSelects() {
  var p = state.parsed;
  if (!p || !p.ok) return;
  fillSelect(selX, p.cols.map(function (c) { return { value: c.name, label: c.name + '  (' + c.type + ')' }; }), state.x);
  if (selX.options.length) state.x = selX.value;
  var nums = p.cols.filter(function (c) { return c.type === 'number'; });
  fillSelect(selY, nums.map(function (c) { return { value: c.name, label: c.name }; }), state.y);
  if (selY.options.length) state.y = selY.value;
  selY.disabled = nums.length === 0;
  var gs = groupCandidates(p).filter(function (c) { return c.name !== state.x; });
  fillSelect(selG, [{ value: '', label: '(nothing — one series)' }].concat(
    gs.map(function (c) { return { value: c.name, label: c.name + '  (' + c.distinctCount + ')' }; })
  ), gs.some(function (c) { return c.name === state.g; }) ? state.g : '');
  state.g = selG.value;
  selG.disabled = gs.length === 0;
  var r = document.querySelector('input[name="ctype"][value="' + state.type + '"]');
  if (r) r.checked = true;
  renderColumns(p);

  clear(autoflag);
  var manual = state.manual.x || state.manual.y || state.manual.g || state.manual.type;
  if (manual) {
    var btn = el('button', 'linkbtn', 'reset to auto-pick');
    btn.type = 'button';
    btn.addEventListener('click', function () {
      state.manual = { x: false, y: false, g: false, type: false };
      state.hidden = Object.create(null);
      applyAuto(); syncSelects(); rebuild(true);
      selX.focus();
    });
    autoflag.appendChild(el('span', null, 'manual · '));
    autoflag.appendChild(btn);
  } else {
    autoflag.textContent = 'auto-picked from the data';
  }
}

function applyAuto() {
  var p = state.parsed, a = p ? autoPick(p) : null;
  if (!a) return false;
  if (!state.manual.x || !colByName(p, state.x)) state.x = a.x;
  if (!state.manual.y || !colByName(p, state.y)) state.y = a.y;
  if (!state.manual.g || (state.g && !colByName(p, state.g))) state.g = a.g;
  if (!state.manual.type) state.type = a.type;
  return true;
}

/* When the visitor swaps the X column but has not pinned a chart type, re-derive
   the type from the new X — a line drawn across unordered categories implies an
   order that is not in the data. */
function typeForX(xCol) {
  if (!xCol) return state.type;
  if (xCol.type === 'date') return 'line';
  if (xCol.type === 'number' && !xCol.idLike) return 'scatter';
  return 'bar';
}
/* The colour-by column is dropped whenever it becomes the X column. If the
   visitor never chose it by hand, offer it back once it is legal again rather
   than silently collapsing every series into one averaged line. */
function autoGroupFor(xName, yName) {
  var p = state.parsed;
  if (!p || !p.ok) return '';
  var n = p.rows.length;
  var c = p.cols.filter(function (col) {
    return col.name !== xName && col.name !== yName && col.type !== 'number' &&
      col.distinctCount >= 2 && col.distinctCount <= MAX_SERIES && col.distinctCount < n;
  })[0];
  return c ? c.name : '';
}
/* A column plotted against itself is a 45° line that says nothing, and both
   selects can reach that state. Whichever axis the visitor just moved keeps the
   column; the other axis steps aside to the next sensible one. */
function fixAxisClash(moved) {
  var p = state.parsed;
  if (!p || !p.ok || state.x !== state.y) return;
  var i, alt;
  if (moved === 'x') {
    alt = p.cols.filter(function (c) { return c.type === 'number' && c.name !== state.x; })[0];
    if (alt) { state.y = alt.name; return; }
    alt = p.cols.filter(function (c) { return c.name !== state.y; })[0];
    if (alt) state.x = alt.name;
  } else {
    var rest = p.cols.filter(function (c) { return c.name !== state.y; });
    alt = rest.filter(function (c) { return c.type === 'date'; })[0] ||
      rest.filter(function (c) { return c.type === 'text'; })[0] || rest[0];
    if (alt) {
      state.x = alt.name;
      if (!state.manual.type) state.type = typeForX(alt);
    }
  }
}
function setX(name) {
  state.x = name;
  state.manual.x = true;
  fixAxisClash('x');
  if (!state.manual.type) state.type = typeForX(colByName(state.parsed, state.x));
  if (!state.manual.g) state.g = autoGroupFor(state.x, state.y);
}
function setY(name) {
  state.y = name;
  state.manual.y = true;
  fixAxisClash('y');
  if (!state.manual.g) state.g = autoGroupFor(state.x, state.y);
}

/* ---------------- legend ---------------- */
function swatch(series, type) {
  var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  svg.setAttribute('width', '16'); svg.setAttribute('height', '14'); svg.setAttribute('viewBox', '0 0 16 14');
  svg.setAttribute('aria-hidden', 'true');
  var col = cssVar(series.colorVar);
  if (type === 'bar') {
    var r = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
    r.setAttribute('x', '2'); r.setAttribute('y', '2'); r.setAttribute('width', '12'); r.setAttribute('height', '10');
    r.setAttribute('rx', '2'); r.setAttribute('fill', col);
    svg.appendChild(r);
  } else {
    if (type === 'line') {
      var l = document.createElementNS('http://www.w3.org/2000/svg', 'line');
      l.setAttribute('x1', '1'); l.setAttribute('y1', '7'); l.setAttribute('x2', '15'); l.setAttribute('y2', '7');
      l.setAttribute('stroke', col); l.setAttribute('stroke-width', '2'); l.setAttribute('stroke-linecap', 'round');
      svg.appendChild(l);
    }
    var p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
    p.setAttribute('d', shapePath(series.shape, 8, 7, 4));
    p.setAttribute('fill', col);
    p.setAttribute('stroke', cssVar('--surface'));
    p.setAttribute('stroke-width', '2');
    p.setAttribute('paint-order', 'stroke');
    svg.appendChild(p);
  }
  return svg;
}
function renderLegend(model) {
  clear(elLegend);
  if (!model || model.empty || model.series.length < 2) { elLegend.hidden = true; return; }
  elLegend.hidden = false;
  model.series.forEach(function (s) {
    var b = el('button');
    b.type = 'button';
    b.setAttribute('aria-pressed', s.visible ? 'true' : 'false');
    b.appendChild(swatch(s, model.type));
    b.appendChild(el('span', null, s.name));
    b.title = (s.visible ? 'Hide ' : 'Show ') + s.name;
    b.addEventListener('click', function () {
      s.visible = !s.visible;
      if (s.visible) delete state.hidden[s.key]; else state.hidden[s.key] = true;
      b.setAttribute('aria-pressed', s.visible ? 'true' : 'false');
      b.title = (s.visible ? 'Hide ' : 'Show ') + s.name;
      state.hover = null; hideTip();
      draw(); renderTable(model); updateNote();
    });
    elLegend.appendChild(b);
  });
}

/* ---------------- table view ---------------- */
function renderTable(model) {
  if (!state.showTable) { tablebox.hidden = true; return; }
  tablebox.hidden = false;
  clear(tablebox);
  if (!model || model.empty) { tablebox.appendChild(el('p', 'hint', 'nothing to tabulate')); return; }
  var vis = model.series.filter(function (s) { return s.visible && s.points.length; });
  var t = document.createElement('table'), thead = document.createElement('thead'),
    tb = document.createElement('tbody'), hr = document.createElement('tr');
  var i, j, tr, td;
  var MAXR = 250, count = 0, truncated = 0;

  if (model.type === 'scatter') {
    [model.xCol.name, model.yCol.name].concat(model.gCol ? [model.gCol.name] : []).forEach(function (h) {
      hr.appendChild(el('th', null, h));
    });
    var flat = [];
    for (i = 0; i < vis.length; i++) for (j = 0; j < vis[i].points.length; j++) flat.push({ s: vis[i], p: vis[i].points[j] });
    for (i = 0; i < flat.length; i++) {
      if (count >= MAXR) { truncated = flat.length - count; break; }
      tr = document.createElement('tr');
      tr.appendChild(el('td', null, xLabelOf(model.xCol, model.xKind === 'ordinal' ? model.cats[flat[i].p.x] : flat[i].p.xr, model.sameYear)));
      tr.appendChild(el('td', null, fmtY(flat[i].p.y, model.yDec)));
      if (model.gCol) tr.appendChild(el('td', null, flat[i].s.name));
      tb.appendChild(tr); count++;
    }
  } else {
    hr.appendChild(el('th', null, model.xCol.name));
    for (i = 0; i < vis.length; i++) hr.appendChild(el('th', null, vis[i].name));
    var xs = [];
    if (model.xKind === 'ordinal') { for (i = 0; i < model.cats.length; i++) xs.push({ k: i, raw: model.cats[i] }); }
    else {
      var seen = new Map();
      for (i = 0; i < vis.length; i++) for (j = 0; j < vis[i].points.length; j++) seen.set(vis[i].points[j].x, vis[i].points[j].xr);
      var keys = Array.from(seen.keys()).sort(function (a, b) { return a - b; });
      for (i = 0; i < keys.length; i++) xs.push({ k: keys[i], raw: seen.get(keys[i]) });
    }
    for (i = 0; i < xs.length; i++) {
      if (count >= MAXR) { truncated = xs.length - count; break; }
      tr = document.createElement('tr');
      tr.appendChild(el('td', null, xLabelOf(model.xCol, model.xKind === 'ordinal' ? xs[i].raw : xs[i].k, model.sameYear)));
      for (j = 0; j < vis.length; j++) {
        var found = null, pts = vis[j].points;
        for (var q = 0; q < pts.length; q++) if (pts[q].x === xs[i].k) { found = pts[q]; break; }
        td = el('td', null, found ? fmtY(found.y, model.yDec) : '—');
        if (!found) td.style.color = 'var(--muted)';
        tr.appendChild(td);
      }
      tb.appendChild(tr); count++;
    }
  }
  thead.appendChild(hr); t.appendChild(thead); t.appendChild(tb);
  tablebox.appendChild(t);
  if (truncated) tablebox.appendChild(el('p', 'hint', ' ' + truncated.toLocaleString('en-US') + ' more rows not shown'));
}

/* ---------------- rebuild ---------------- */
function rebuild(animate) {
  var p = state.parsed;
  state.hover = null; hideTip(); state.keyIdx = -1;
  if (!p || !p.ok) return;
  var nums = p.cols.filter(function (c) { return c.type === 'number'; });
  if (!nums.length) {
    showError('No numeric column found',
      'Every column parsed as text or dates, so there is nothing to measure. A chart needs at least one column of numbers — check for stray units, currency symbols or a mis-detected separator.');
    return;
  }
  hideError();
  var model = buildModel(p, state.x, state.y, state.g, state.type);
  if (!model) { showError('Pick two columns', 'Choose an X and a Y column to plot.'); return; }
  for (var i = 0; i < (model.series || []).length; i++) {
    if (state.hidden[model.series[i].key]) model.series[i].visible = false;
  }
  state.model = model;

  var yName = model.yCol.name + (model.yCol.unit || '');
  chartTitle.textContent = model.empty ? 'Nothing to plot' : yName + ' by ' + model.xCol.name;
  updateNote();

  if (model.empty) {
    showError('No rows survived',
      'Every row was missing a usable value in "' + model.xCol.name + '" or "' + model.yCol.name + '". Try different columns.');
    return;
  }
  cv.setAttribute('aria-label',
    model.type + ' chart of ' + yName + ' by ' + model.xCol.name +
    (model.gCol ? ', one series per ' + model.gCol.name : '') +
    '. ' + model.plotted + ' points. Use the data table below for exact values.');

  renderLegend(model);
  renderTable(model);
  sizeCanvas();
  if (animate) animateIn(); else { anim.p = 1; draw(); }
}
function updateNote() {
  var model = state.model, p = state.parsed;
  if (!model || !p) { chartNote.textContent = ''; return; }
  var bits = [model.type];
  if (!model.empty) {
    var visN = model.series.filter(function (s) { return s.visible; }).length;
    var hidden = model.series.length - visN;
    bits.push(visN + ' series' + (hidden ? ' of ' + model.series.length : ''));
    bits.push(model.plotted.toLocaleString('en-US') + ' of ' + p.rows.length.toLocaleString('en-US') + ' rows plotted');
    if (model.aggregated) bits.push('mean per point');
    if (model.folded) bits.push(model.folded + ' small groups folded into "Other"');
  }
  chartNote.textContent = bits.join(' · ');
}
function showError(title, body) {
  errTitle.textContent = title; errBody.textContent = body;
  chartErr.hidden = false; chartArea.hidden = true;
  state.model = null;
}
function hideError() { chartErr.hidden = true; chartArea.hidden = false; }

/* ---------------- parse pipeline ---------------- */
function runParse(text, resetManual, animate) {
  var t0 = performance.now();
  var p = parseCSV(text);
  var ms = performance.now() - t0;
  state.parsed = p;
  if (resetManual) { state.manual = { x: false, y: false, g: false, type: false }; state.hidden = Object.create(null); }
  renderStatus(p, ms);
  if (!p.ok) {
    clear(elCols);
    [selX, selY, selG].forEach(function (s) { clear(s); s.disabled = true; });
    clear(autoflag);
    showError(p.fatal.title, p.fatal.body);
    return;
  }
  [selX, selY].forEach(function (s) { s.disabled = false; });
  applyAuto();
  syncSelects();
  rebuild(animate !== false);
}

/* ---------------- tooltip + hover ---------------- */
function hideTip() { elTip.classList.remove('on'); elTip.setAttribute('aria-hidden', 'true'); }
function showTip(px, py, headline, rows) {
  clear(elTip);
  elTip.appendChild(el('div', 'tx', headline));
  rows.forEach(function (r) {
    var row = el('div', 'row');
    var k = el('span', 'key');
    k.style.background = cssVar(r.colorVar);
    row.appendChild(k);
    row.appendChild(el('span', 'nm', r.name));
    row.appendChild(el('span', 'v', r.value));
    elTip.appendChild(row);
  });
  elTip.classList.add('on');
  elTip.setAttribute('aria-hidden', 'false');
  var tw = elTip.offsetWidth, th = elTip.offsetHeight;
  var left = Math.max(tw / 2 + 4, Math.min(W - tw / 2 - 4, px));
  var top = Math.max(th + 6, py - 12);
  elTip.style.left = left + 'px';
  elTip.style.top = top + 'px';
}

function visSig(model) {
  return model.series.map(function (s) { return s.visible ? 1 : 0; }).join('');
}
function xValueList(model) {
  var sig = visSig(model);
  if (model._xl && model._xlSig === sig) return model._xl;
  var vis = model.series.filter(function (s) { return s.visible && s.points.length; });
  var set = new Map(), i, j;
  for (i = 0; i < vis.length; i++) for (j = 0; j < vis[i].points.length; j++) set.set(vis[i].points[j].x, vis[i].points[j].xr);
  var keys = Array.from(set.keys()).sort(function (a, b) { return a - b; });
  model._xl = keys.map(function (k) { return { x: k, raw: set.get(k) }; });
  model._xlSig = sig;
  return model._xl;
}
function flatPoints(model) {
  var sig = visSig(model);
  if (model._fp && model._fpSig === sig) return model._fp;
  var out = [], vis = model.series.filter(function (s) { return s.visible && s.points.length; });
  for (var i = 0; i < vis.length; i++) for (var j = 0; j < vis[i].points.length; j++) out.push({ s: vis[i], p: vis[i].points[j] });
  out.sort(function (a, b) { return a.p.x - b.p.x; });
  model._fp = out; model._fpSig = sig;
  return out;
}
function hoverAtX(model, xv, raw) {
  var vis = model.series.filter(function (s) { return s.visible && s.points.length; });
  var marks = [], i, j;
  for (i = 0; i < vis.length; i++) {
    for (j = 0; j < vis[i].points.length; j++) {
      if (vis[i].points[j].x === xv) {
        marks.push({ x: xv, y: vis[i].points[j].y, colorVar: vis[i].colorVar, shape: vis[i].shape, name: vis[i].name, si: i });
        break;
      }
    }
  }
  if (!marks.length) return null;
  var label = xLabelOf(model.xCol, model.xKind === 'ordinal' ? (raw != null ? raw : model.cats[xv]) : xv, false);
  if (model.xCol.type === 'date') {
    var t = model.xKind === 'ordinal' ? (raw != null ? raw : model.cats[xv]) : xv;
    label = fmtDateFull(t, model.xCol.prec);
  }
  return { kind: 'x', cat: model.xKind === 'ordinal' ? xv : null, x: xv, marks: marks, label: label, px: geo ? geo.X(xv) : null };
}
function paintHover(h, announce) {
  state.hover = h;
  draw();
  if (!h || !geo) { hideTip(); return; }
  if (h.kind === 'x') {
    var minY = Infinity, i;
    for (i = 0; i < h.marks.length; i++) minY = Math.min(minY, geo.Y(h.marks[i].y));
    var rows = h.marks.map(function (m) {
      return { colorVar: m.colorVar, name: state.model.gCol ? m.name : state.model.yCol.name, value: fmtY(m.y, state.model.yDec) + (state.model.yCol.unit || '') };
    });
    showTip(geo.X(h.x), minY, h.label, rows);
    if (announce) live.textContent = h.label + ': ' + rows.map(function (r) { return r.name + ' ' + r.value; }).join(', ');
  } else if (h.kind === 'pt') {
    var m = h.mark;
    var head = state.model.xCol.name + ' ' + xLabelOf(state.model.xCol, state.model.xKind === 'ordinal' ? state.model.cats[m.x] : m.x, false);
    showTip(geo.X(m.x), geo.Y(m.y), head, [{
      colorVar: m.colorVar,
      name: state.model.gCol ? m.name : state.model.yCol.name,
      value: fmtY(m.y, state.model.yDec) + (state.model.yCol.unit || '')
    }]);
    if (announce) live.textContent = head + ', ' + m.name + ' ' + fmtY(m.y, state.model.yDec);
  }
}

cv.addEventListener('pointermove', function (ev) {
  var model = state.model;
  if (!model || model.empty || !geo || anim.p < 1) return;
  var r = cv.getBoundingClientRect();
  var mx = ev.clientX - r.left, my = ev.clientY - r.top;
  if (mx < geo.padL - 12 || mx > geo.padL + geo.plotW + 12 || my > geo.padT + geo.plotH + 8) { state.hover = null; hideTip(); draw(); return; }
  if (model.type === 'scatter') {
    var best = null, bd = 44 * 44;
    var fp = flatPoints(model);
    for (var i = 0; i < fp.length; i++) {
      var dx = geo.X(fp[i].p.x) - mx, dy = geo.Y(fp[i].p.y) - my, d = dx * dx + dy * dy;
      if (d < bd) { bd = d; best = fp[i]; }
    }
    if (!best) { state.hover = null; hideTip(); draw(); return; }
    paintHover({ kind: 'pt', mark: { x: best.p.x, y: best.p.y, colorVar: best.s.colorVar, shape: best.s.shape, name: best.s.name } }, false);
  } else {
    var xs = xValueList(model);
    if (!xs.length) return;
    var bi = 0, bdist = Infinity;
    for (var k = 0; k < xs.length; k++) {
      var dd = Math.abs(geo.X(xs[k].x) - mx);
      if (dd < bdist) { bdist = dd; bi = k; }
    }
    state.keyIdx = bi;
    var h = hoverAtX(model, xs[bi].x, xs[bi].raw);
    paintHover(h, false);
  }
});
cv.addEventListener('pointerleave', function () { state.hover = null; hideTip(); draw(); });
cv.addEventListener('keydown', function (ev) {
  var model = state.model;
  if (!model || model.empty) return;
  var list = model.type === 'scatter' ? flatPoints(model) : xValueList(model);
  if (!list.length) return;
  var k = ev.key, handled = true;
  if (k === 'ArrowRight' || k === 'ArrowDown') state.keyIdx = Math.min(list.length - 1, state.keyIdx + 1);
  else if (k === 'ArrowLeft' || k === 'ArrowUp') state.keyIdx = state.keyIdx <= 0 ? 0 : state.keyIdx - 1;
  else if (k === 'Home') state.keyIdx = 0;
  else if (k === 'End') state.keyIdx = list.length - 1;
  else if (k === 'Escape') { state.keyIdx = -1; state.hover = null; hideTip(); draw(); live.textContent = ''; }
  else handled = false;
  if (!handled) return;
  ev.preventDefault();
  if (state.keyIdx < 0) return;
  if (model.type === 'scatter') {
    var it = list[state.keyIdx];
    paintHover({ kind: 'pt', mark: { x: it.p.x, y: it.p.y, colorVar: it.s.colorVar, shape: it.s.shape, name: it.s.name } }, true);
  } else {
    paintHover(hoverAtX(model, list[state.keyIdx].x, list[state.keyIdx].raw), true);
  }
});
cv.addEventListener('blur', function () { state.hover = null; hideTip(); draw(); });

/* ---------------- controls ---------------- */
selX.addEventListener('change', function () { setX(selX.value); syncSelects(); rebuild(true); });
selY.addEventListener('change', function () { setY(selY.value); syncSelects(); rebuild(true); });
selG.addEventListener('change', function () {
  state.g = selG.value; state.manual.g = true; state.hidden = Object.create(null);
  syncSelects(); rebuild(true);
});
Array.prototype.forEach.call(document.querySelectorAll('input[name="ctype"]'), function (r) {
  r.addEventListener('change', function () {
    if (!r.checked) return;
    state.type = r.value; state.manual.type = true;
    syncSelects(); rebuild(true);
  });
});
tableBtn.addEventListener('click', function () {
  state.showTable = !state.showTable;
  tableBtn.setAttribute('aria-expanded', state.showTable ? 'true' : 'false');
  tableBtn.textContent = state.showTable ? 'hide data table' : 'show data table';
  renderTable(state.model);
});

/* ---------------- input: typing, samples, files, drop ---------------- */
var sampleBtns = document.querySelectorAll('[data-sample]');
function markSample(name) {
  Array.prototype.forEach.call(sampleBtns, function (o) {
    o.setAttribute('aria-pressed', o.getAttribute('data-sample') === name ? 'true' : 'false');
  });
}
var typeTimer = 0;
elCsv.addEventListener('input', function () {
  markSample('');
  clearTimeout(typeTimer);
  typeTimer = setTimeout(function () { runParse(elCsv.value, true, false); }, 220);
});
Array.prototype.forEach.call(sampleBtns, function (b) {
  b.addEventListener('click', function () {
    var name = b.getAttribute('data-sample');
    elCsv.value = SAMPLES[name];
    markSample(name);
    runParse(elCsv.value, true, true);
  });
});
var drop = $('drop'), fileInput = $('file');
$('pickFile').addEventListener('click', function () { fileInput.click(); });
fileInput.addEventListener('change', function () {
  if (fileInput.files && fileInput.files[0]) readFile(fileInput.files[0]);
  fileInput.value = '';
});
function fileStatus(msg) {
  okPill.hidden = true; warnPill.hidden = true;
  clear(elIssues);
  elFacts.textContent = msg;
}
function readFile(f) {
  markSample('');
  if (f.size > 5e6) {
    elCsv.value = '';
    fileStatus('file too big — not read');
    showError('That file is too big', 'This demo reads files up to 5 MB in the browser. "' + f.name + '" is ' + (f.size / 1e6).toFixed(1) + ' MB.');
    return;
  }
  fileStatus('reading ' + f.name + '…');
  var fr = new FileReader();
  fr.onload = function () { elCsv.value = String(fr.result || ''); runParse(elCsv.value, true, true); };
  fr.onerror = function () {
    fileStatus('could not read ' + f.name);
    showError('Could not read that file', 'The browser refused to read "' + f.name + '".');
  };
  fr.readAsText(f);
}
['dragenter', 'dragover'].forEach(function (t) {
  drop.addEventListener(t, function (ev) { ev.preventDefault(); drop.classList.add('over'); });
});
['dragleave', 'dragend'].forEach(function (t) {
  drop.addEventListener(t, function () { drop.classList.remove('over'); });
});
drop.addEventListener('drop', function (ev) {
  ev.preventDefault();
  drop.classList.remove('over');
  var dt = ev.dataTransfer;
  if (!dt) return;
  if (dt.files && dt.files[0]) readFile(dt.files[0]);
  else {
    var txt = dt.getData('text');
    if (txt) { elCsv.value = txt; markSample(''); runParse(txt, true, true); }
  }
});
document.addEventListener('dragover', function (ev) { ev.preventDefault(); });
document.addEventListener('drop', function (ev) { ev.preventDefault(); });

/* ---------------- theme + resize ---------------- */
window.addEventListener('message', function (ev) {
  var d = ev.data;
  if (d && d.type === 'sloprun:theme' && (d.theme === 'light' || d.theme === 'dark')) {
    document.documentElement.setAttribute('data-theme', d.theme);
    repaintTheme();
  }
});
function repaintTheme() {
  draw();
  if (state.model) renderLegend(state.model);
}
/* the canvas is not CSS — it has to be told when the palette moves under it,
   including when the host flips data-theme on <html> without a message */
if (window.MutationObserver) {
  new MutationObserver(repaintTheme).observe(document.documentElement, {
    attributes: true, attributeFilter: ['data-theme']
  });
}
if (window.matchMedia) {
  var mq = window.matchMedia('(prefers-color-scheme: dark)');
  if (mq.addEventListener) mq.addEventListener('change', repaintTheme);
  else if (mq.addListener) mq.addListener(repaintTheme);
  if (reduce.addEventListener) reduce.addEventListener('change', function () { anim.p = 1; draw(); });
}
var rzTimer = 0;
function onResize() {
  cancelAnimationFrame(rzTimer);
  rzTimer = requestAnimationFrame(function () {
    state.hover = null; hideTip();
    sizeCanvas();
    if (!anim.raf) anim.p = 1;   /* don't cut the entrance animation short */
    draw();
  });
}
if (window.ResizeObserver) new ResizeObserver(onResize).observe($('plotwrap'));
else window.addEventListener('resize', onResize);

/* ---------------- boot ---------------- */
elCsv.value = SAMPLES.weather;
markSample('weather');
runParse(elCsv.value, true, true);
})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong