Notifications
Clear all
Topic starter
Quite often I find graphs published in articles but I can not find the data that the graph is based on. It can be very frustrating. So I made this app to allow the graphic to be dropped into it and after a few clicks on the scales and such it gives you a CSV file of the data in the graph. Here are the steps to follow:
- Copy the code below into your browser (or save and click to load).
- Load the image by browsing to it.
- Calibrate the x axis (usually time) by clicking each of an early and late date/time and specifying the value at that point.
- If the y axis is on a log scale click that button.
- Calibrate the y axis by clicking each of a low vale and a high value on the scale and typing in those values.
- Give the series a name.
- Select the colour with the cross hairs. Can be delicate for thin lines but it shows what you selected. If you miss you can try again.
- Give it the lower left and upper right corners that the graph stays inside. Make sure that there are no other things of that colour in that area or they will get digitized too.
- Press extract curve.
- Copy the resulting CSV file which has the co-ordinates of all the x,y points along the graph.
- Go paste it somewhere useful.
Thanks Claude for your help.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Graph Digitizer</title>
<style>
:root{
--bg: #14181c;
--panel: #1c2126;
--panel-2: #232a30;
--line: #313a41;
--text: #e7ecf0;
--muted: #8b98a3;
--accent: #5fd3c4;
--accent-dim: #3a7d74;
--warn: #e8a33d;
--danger: #e0665f;
--mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace;
--sans: -apple-system, "Segoe UI", ui-sans-serif, system-ui, sans-serif;
}
*{box-sizing:border-box;}
html,body{margin:0;padding:0;background:var(--bg);color:var(--text);font-family:var(--sans);}
body{padding:20px;}
h1{font-size:15px;letter-spacing:.08em;text-transform:uppercase;color:var(--accent);margin:0 0 2px;font-weight:600;}
.sub{color:var(--muted);font-size:12.5px;margin:0 0 18px;line-height:1.5;}
.layout{display:grid;grid-template-columns:300px 1fr 320px;gap:16px;align-items:start;}
@media (max-width:1100px){.layout{grid-template-columns:1fr;}}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px;}
.panel + .panel{margin-top:14px;}
.step{border-left:2px solid var(--line);padding:2px 0 14px 14px;position:relative;}
.step:last-child{padding-bottom:2px;}
.step::before{content:attr(data-n);position:absolute;left:-11px;top:0;width:20px;height:20px;border-radius:50%;background:var(--panel-2);border:1px solid var(--line);color:var(--muted);font-family:var(--mono);font-size:10.5px;display:flex;align-items:center;justify-content:center;}
.step.active::before{border-color:var(--accent);color:var(--accent);background:#132523;}
.step.done::before{content:"?";border-color:var(--accent-dim);color:var(--accent);}
.step h3{margin:0 0 6px;font-size:12.5px;color:var(--text);font-weight:600;}
.step p.hint{margin:0 0 8px;font-size:11.5px;color:var(--muted);line-height:1.5;}
button{font-family:var(--sans);font-size:12.5px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);padding:7px 11px;border-radius:6px;cursor:pointer;}
button:hover{border-color:var(--accent-dim);}
button:focus-visible{outline:2px solid var(--accent);outline-offset:1px;}
button.primary{background:var(--accent);color:#0c1214;border-color:var(--accent);font-weight:600;}
button.primary:hover{background:#7ee0d3;}
button:disabled{opacity:.35;cursor:not-allowed;}
button.small{padding:4px 8px;font-size:11px;}
input[type=text],input[type=number]{font-family:var(--mono);font-size:12.5px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:5px;padding:6px 8px;width:100%;}
input:focus-visible{outline:2px solid var(--accent);outline-offset:1px;}
label{font-size:11px;color:var(--muted);display:block;margin-bottom:3px;}
.row{display:flex;gap:8px;margin-bottom:8px;}
.row > div{flex:1;}
.canvas-wrap{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px;position:relative;overflow:auto;max-height:78vh;}
.canvas-inner{position:relative;display:inline-block;}
canvas{display:block;max-width:100%;cursor:crosshair;border-radius:2px;}
.readout{position:absolute;pointer-events:none;font-family:var(--mono);font-size:10.5px;background:rgba(12,18,20,.88);color:var(--accent);border:1px solid var(--accent-dim);padding:3px 6px;border-radius:4px;white-space:nowrap;transform:translate(10px,-28px);}
.drop{border:1.5px dashed var(--line);border-radius:8px;padding:28px 10px;text-align:center;color:var(--muted);font-size:12px;cursor:pointer;}
.drop:hover{border-color:var(--accent-dim);color:var(--text);}
.series-item{border:1px solid var(--line);border-radius:6px;padding:8px;margin-bottom:8px;background:var(--panel-2);}
.series-item.active{border-color:var(--accent);}
.swatch{width:13px;height:13px;border-radius:3px;display:inline-block;vertical-align:-2px;border:1px solid rgba(255,255,255,.2);margin-right:6px;}
.series-name{font-size:12.5px;font-weight:600;}
.series-meta{font-size:10.5px;color:var(--muted);font-family:var(--mono);margin-top:3px;}
.tabs{display:flex;gap:4px;margin-bottom:10px;}
.tabs button{flex:1;}
.tabs button.active{background:var(--panel-2);border-color:var(--accent);color:var(--accent);}
table{width:100%;border-collapse:collapse;font-family:var(--mono);font-size:11px;}
th,td{text-align:left;padding:3px 6px;border-bottom:1px solid var(--line);}
th{color:var(--muted);font-weight:500;position:sticky;top:0;background:var(--panel);}
.tablewrap{max-height:220px;overflow:auto;border:1px solid var(--line);border-radius:6px;margin-top:6px;}
textarea{width:100%;height:140px;font-family:var(--mono);font-size:11px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:6px;padding:8px;resize:vertical;}
.flexgap{display:flex;gap:8px;margin-top:8px;}
.flexgap button{flex:1;}
.empty{color:var(--muted);font-size:12px;text-align:center;padding:20px 6px;}
.checkrow{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--muted);margin-bottom:8px;}
.checkrow input{width:auto;}
.legend-dot{width:8px;height:8px;border-radius:50%;display:inline-block;}
.toolbar-note{font-size:10.5px;color:var(--warn);margin-top:6px;line-height:1.4;}
::-webkit-scrollbar{width:9px;height:9px;}
::-webkit-scrollbar-thumb{background:var(--line);border-radius:5px;}
</style>
</head>
<body>
<h1>Graph Digitizer</h1>
<p class="sub">Load a chart image, calibrate its axes against two known reference points on each, sample the curve's color, then extract a dense table of coordinate pairs. Everything runs locally in this page.</p>
<div class="layout">
<!-- LEFT: workflow -->
<div>
<div class="panel">
<div class="step" id="step-load" data-n="1">
<h3>Load image</h3>
<p class="hint">Drop a chart image, or browse for one.</p>
<div class="drop" id="dropZone">Drop image here, or click to choose a file</div>
<input type="file" id="fileInput" accept="image/*" style="display:none;">
</div>
<div class="step" id="step-x" data-n="2">
<h3>Calibrate X axis</h3>
<p class="hint">Click two points on the X axis whose values you know (e.g. two gridline ticks), entering the value after each click.</p>
<div class="checkrow"><input type="checkbox" id="xLog"><label for="xLog" style="margin:0;">Log scale on X</label></div>
<button id="btnCalibX" disabled>Set X reference points</button>
<div id="xStatus" class="series-meta" style="margin-top:6px;"></div>
</div>
<div class="step" id="step-y" data-n="3">
<h3>Calibrate Y axis</h3>
<p class="hint">Same idea, on the Y axis.</p>
<div class="checkrow"><input type="checkbox" id="yLog"><label for="yLog" style="margin:0;">Log scale on Y</label></div>
<button id="btnCalibY" disabled>Set Y reference points</button>
<div id="yStatus" class="series-meta" style="margin-top:6px;"></div>
</div>
<div class="step" id="step-series" data-n="4">
<h3>Trace a curve</h3>
<p class="hint">Name the series, then click directly on the curve in the image to sample its color. Adjust tolerance if it misses spots or catches noise.</p>
<div class="row">
<div><label>Series name</label><input type="text" id="seriesName" placeholder="e.g. Exxon curve"></div>
</div>
<button id="btnPickColor" disabled>Pick color from curve</button>
<div id="colorPreview" class="series-meta" style="margin-top:6px;"></div>
<div style="margin-top:10px;">
<label>Color match tolerance: <span id="tolVal">45</span></label>
<input type="range" id="tolerance" min="2" max="160" value="45" style="width:100%;">
</div>
<div style="margin-top:10px;">
<button id="btnSetRegion" disabled style="width:100%;">Restrict to plot area (optional)</button>
<div id="regionStatus" class="series-meta" style="margin-top:4px;">Not set — will scan the whole image. Set this if legend text, axis labels, or anything else shares the curve's color.</div>
<button id="btnClearRegion" class="small" style="margin-top:6px;">Clear region (use whole image)</button>
</div>
<button id="btnExtract" class="primary" disabled style="margin-top:10px;width:100%;">Extract curve</button>
<p class="toolbar-note" id="extractNote"></p>
</div>
</div>
<div class="panel">
<h3 style="margin:0 0 8px;font-size:12.5px;">Series extracted</h3>
<div id="seriesList"><div class="empty">None yet</div></div>
</div>
</div>
<!-- CENTER: canvas -->
<div class="canvas-wrap">
<div class="canvas-inner" id="canvasInner">
<canvas id="canvas" width="600" height="400"></canvas>
<div class="readout" id="readout" style="display:none;"></div>
</div>
</div>
<!-- RIGHT: results -->
<div class="panel">
<h3 style="margin:0 0 10px;font-size:12.5px;">Output</h3>
<div class="row">
<div>
<label>X axis label</label>
<input type="text" id="xLabel" value="X" placeholder="e.g. Millions of years ago">
</div>
</div>
<div class="checkrow" style="margin-top:2px;">
<input type="checkbox" id="resampleOn">
<label for="resampleOn" style="margin:0;">Resample to even X steps</label>
</div>
<div class="row" id="resampleRow" style="display:none;">
<div><label>Number of points</label><input type="number" id="resampleN" value="200" min="2" max="5000"></div>
</div>
<div class="tabs">
<button id="tabTable" class="active">Table</button>
<button id="tabCSV">CSV text</button>
</div>
<div id="viewTable"><div class="tablewrap"><table><thead><tr><th>x</th><th id="thY">y</th></tr></thead><tbody id="tableBody"></tbody></table></div></div>
<div id="viewCSV" style="display:none;"><textarea id="csvText" readonly placeholder="Extract a curve to see data here."></textarea></div>
<div class="flexgap">
<button id="btnCopy">Copy</button>
<button id="btnDownload" class="primary">Download CSV</button>
</div>
<label style="margin-top:12px;">Combine series into one file</label>
<div class="flexgap" style="margin-top:0;">
<button id="btnDownloadAll">Download all (shared X grid)</button>
</div>
</div>
</div>
<script>
(() => {
const $ = id => document.getElementById(id);
const canvas = $('canvas'), ctx = canvas.getContext('2d', {willReadFrequently:true});
const readout = $('readout');
const canvasInner = $('canvasInner');
let img = null, imgData = null;
let calib = { x: [], y: [], xLog:false, yLog:false };
let mode = null; // 'calibX' | 'calibY' | 'pickColor' | 'setRegion'
let pendingCalibAxis = null;
let series = []; // {name, color:[r,g,b], tolerance, points:[{px,dataX,dataY}]}
let activeSeries = null;
let region = null; // {x0,x1,y0,y1} in pixel coords, or null = whole image
let regionClickBuffer = [];
// ---------- image loading ----------
const dropZone = $('dropZone'), fileInput = $('fileInput');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.style.borderColor = 'var(--accent)'; });
dropZone.addEventListener('dragleave', () => dropZone.style.borderColor = 'var(--line)');
dropZone.addEventListener('drop', e => {
e.preventDefault(); dropZone.style.borderColor = 'var(--line)';
if (e.dataTransfer.files.length) loadFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', e => { if (e.target.files.length) loadFile(e.target.files[0]); });
function loadFile(file) {
const reader = new FileReader();
reader.onload = ev => {
const im = new Image();
im.onload = () => {
const MAXW = 1800;
let w = im.naturalWidth, h = im.naturalHeight;
if (w > MAXW) { h = Math.round(h * MAXW / w); w = MAXW; }
canvas.width = w; canvas.height = h;
ctx.drawImage(im, 0, 0, w, h);
img = im;
imgData = ctx.getImageData(0, 0, w, h);
dropZone.textContent = file.name + ' — loaded (' + w + '×' + h + 'px). Drop another to replace.';
$('btnCalibX').disabled = false;
$('btnCalibY').disabled = false;
updateStepStates();
};
im.src = ev.target.result;
};
reader.readAsDataURL(file);
}
// ---------- coordinate helpers ----------
function canvasCoords(evt) {
const r = canvas.getBoundingClientRect();
const scaleX = canvas.width / r.width, scaleY = canvas.height / r.height;
return {
px: (evt.clientX - r.left) * scaleX,
py: (evt.clientY - r.top) * scaleY
};
}
function mapAxis(pVal, p1, p2, log) {
// linear/log interpolation given two calibration points {px, val}
if (log) {
const l1 = Math.log10(p1.val), l2 = Math.log10(p2.val);
const l = l1 + (pVal - p1.px) * (l2 - l1) / (p2.px - p1.px);
return Math.pow(10, l);
}
return p1.val + (pVal - p1.px) * (p2.val - p1.val) / (p2.px - p1.px);
}
function pixelToData(px, py) {
if (calib.x.length < 2 || calib.y.length < 2) return null;
const dataX = mapAxis(px, calib.x[0], calib.x[1], xLogOn());
const dataY = mapAxis(py, calib.y[0], calib.y[1], yLogOn());
return { dataX, dataY };
}
function xLogOn(){ return $('xLog').checked; }
function yLogOn(){ return $('yLog').checked; }
// ---------- mouse readout ----------
canvas.addEventListener('mousemove', evt => {
if (!img) return;
const { px, py } = canvasCoords(evt);
const r = canvas.getBoundingClientRect();
readout.style.left = (evt.clientX - r.left) + 'px';
readout.style.top = (evt.clientY - r.top) + 'px';
readout.style.display = 'block';
let text = 'px ' + Math.round(px) + ', ' + Math.round(py);
if (calib.x.length === 2 && calib.y.length === 2) {
const d = pixelToData(px, py);
if (d) text += ' ? x=' + fmt(d.dataX) + ', y=' + fmt(d.dataY);
}
readout.textContent = text;
});
canvas.addEventListener('mouseleave', () => readout.style.display = 'none');
function fmt(n){ return Math.abs(n) >= 1000 ? n.toFixed(0) : n.toPrecision(4).replace(/\.?0+$/,''); }
// ---------- calibration flow ----------
$('btnCalibX').addEventListener('click', () => startCalib('x'));
$('btnCalibY').addEventListener('click', () => startCalib('y'));
function startCalib(axis) {
calib[axis] = [];
mode = 'calib' + axis.toUpperCase();
pendingCalibAxis = axis;
statusFor(axis).textContent = 'Click point 1 on the ' + axis.toUpperCase() + ' axis…';
setButtonsBusy(true);
}
function statusFor(axis){ return axis === 'x' ? $('xStatus') : $('yStatus'); }
canvas.addEventListener('click', evt => {
if (!img) return;
const { px, py } = canvasCoords(evt);
if (mode === 'calibX' || mode === 'calibY') {
const axis = pendingCalibAxis;
askValue(axis === 'x' ? px : py, pt => {
calib[axis].push({ px: axis === 'x' ? px : py, val: pt });
drawMarker(px, py, '#e8a33d');
if (calib[axis].length === 1) {
statusFor(axis).textContent = 'Point 1 set. Click point 2 on the ' + axis.toUpperCase() + ' axis…';
} else {
statusFor(axis).textContent = axis.toUpperCase() + ' calibrated: ' +
calib[axis][0].val + ' ? ' + calib[axis][1].val;
mode = null; setButtonsBusy(false);
updateStepStates();
}
});
return;
}
if (mode === 'setRegion') {
regionClickBuffer.push({ px, py });
drawMarker(px, py, '#5fd3c4');
if (regionClickBuffer.length === 1) {
$('regionStatus').textContent = 'Top-left set. Click the bottom-right corner of the plot area…';
} else {
const [a, b] = regionClickBuffer;
region = {
x0: Math.min(a.px, b.px), x1: Math.max(a.px, b.px),
y0: Math.min(a.py, b.py), y1: Math.max(a.py, b.py)
};
$('regionStatus').textContent = 'Region set: x ' + Math.round(region.x0) + '–' + Math.round(region.x1) +
', y ' + Math.round(region.y0) + '–' + Math.round(region.y1) + '. Extraction will ignore everything outside this box.';
mode = null;
}
return;
}
if (mode === 'pickColor') {
const idx = (Math.floor(py) * canvas.width + Math.floor(px)) * 4;
const rC = imgData.data[idx], g = imgData.data[idx+1], b = imgData.data[idx+2];
activeSeries.color = [rC, g, b];
$('colorPreview').innerHTML = '<span class="swatch" style="background:rgb(' + rC + ',' + g + ',' + b + ')"></span>sampled rgb(' + rC + ', ' + g + ', ' + b + ')';
mode = null;
$('btnExtract').disabled = false;
redrawBase();
}
});
// inline value-entry (no blocking dialogs — sandboxed iframes can block those)
function askValue(pixelPos, onDone) {
const panel = document.createElement('div');
panel.style.cssText = 'margin-top:6px;display:flex;gap:6px;';
panel.innerHTML = '<input type="text" id="__valInput" placeholder="value at this point" style="flex:1;"><button id="__valOk" class="small primary">Set</button>';
const host = mode === 'calibX' ? $('xStatus') : $('yStatus');
host.after(panel);
const input = panel.querySelector('#__valInput');
input.focus();
function commit() {
const v = parseFloat(input.value);
panel.remove();
if (!isNaN(v)) onDone(v);
}
panel.querySelector('#__valOk').addEventListener('click', commit);
input.addEventListener('keydown', e => { if (e.key === 'Enter') commit(); });
}
function setButtonsBusy(busy) {
$('btnCalibX').disabled = busy;
$('btnCalibY').disabled = busy;
}
function drawMarker(px, py, color) {
ctx.save();
ctx.strokeStyle = color; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI*2); ctx.stroke();
ctx.restore();
}
function redrawBase() {
if (!img) return;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
}
// ---------- series ----------
$('btnPickColor').addEventListener('click', () => {
const name = $('seriesName').value.trim() || ('Series ' + (series.length + 1));
activeSeries = { name, color: null, tolerance: parseInt($('tolerance').value,10), points: [] };
mode = 'pickColor';
$('colorPreview').textContent = 'Click on the curve in the image…';
});
$('btnSetRegion').addEventListener('click', () => {
regionClickBuffer = [];
mode = 'setRegion';
$('regionStatus').textContent = 'Click the top-left corner of the plot area…';
});
$('btnClearRegion').addEventListener('click', () => {
region = null;
regionClickBuffer = [];
mode = null;
$('regionStatus').textContent = 'Not set — will scan the whole image. Set this if legend text, axis labels, or anything else shares the curve\'s color.';
});
$('tolerance').addEventListener('input', e => { $('tolVal').textContent = e.target.value; if (activeSeries) activeSeries.tolerance = parseInt(e.target.value,10); });
$('btnExtract').addEventListener('click', () => {
if (!activeSeries || !activeSeries.color || calib.x.length < 2 || calib.y.length < 2) return;
const [cr, cg, cb] = activeSeries.color;
const baseTol = activeSeries.tolerance;
const w = canvas.width, h = canvas.height;
const data = imgData.data;
// hard-clamp to the pixel range you calibrated — those ARE the graph's real limits,
// so we never scan (or report data from) outside them. A region box can only narrow this further.
const calibXMin = Math.min(calib.x[0].px, calib.x[1].px), calibXMax = Math.max(calib.x[0].px, calib.x[1].px);
const calibYMin = Math.min(calib.y[0].px, calib.y[1].px), calibYMax = Math.max(calib.y[0].px, calib.y[1].px);
let xStart = Math.round(calibXMin), xEnd = Math.round(calibXMax);
let yStart = Math.round(calibYMin), yEnd = Math.round(calibYMax);
if (region) {
xStart = Math.max(xStart, Math.floor(region.x0)); xEnd = Math.min(xEnd, Math.ceil(region.x1));
yStart = Math.max(yStart, Math.floor(region.y0)); yEnd = Math.min(yEnd, Math.ceil(region.y1));
}
xStart = Math.max(0, xStart); xEnd = Math.min(w - 1, xEnd);
yStart = Math.max(0, yStart); yEnd = Math.min(h - 1, yEnd);
function matchesAt(x, tol) {
const out = [];
for (let y = yStart; y <= yEnd; y++) {
const idx = (y * w + x) * 4;
const dr = data[idx]-cr, dg = data[idx+1]-cg, db = data[idx+2]-cb;
if (Math.sqrt(dr*dr+dg*dg+db*db) <= tol) out.push(y);
}
return out;
}
function runsOf(matches) {
const runs = [];
if (!matches.length) return runs;
let runStart = matches[0], prev = matches[0];
for (let i = 1; i <= matches.length; i++) {
const y = matches[i];
if (y === undefined || y - prev > 2) {
runs.push({ centroid: (runStart + prev) / 2, size: prev - runStart + 1 });
if (y !== undefined) runStart = y;
}
prev = y;
}
return runs;
}
const pts = [];
let lastPy = null;
let stretchedColumns = 0, deadColumns = [];
const TOL_STEPS = [1, 1.6, 2.4, 3.5, 5, 7, 10]; // multipliers tried in order until a match is found
for (let x = xStart; x <= xEnd; x++) {
let matches = [], usedTol = baseTol;
for (const mult of TOL_STEPS) {
usedTol = baseTol * mult;
matches = matchesAt(x, usedTol);
if (matches.length) { if (mult > 1) stretchedColumns++; break; }
}
if (!matches.length) { deadColumns.push(x); continue; }
const runs = runsOf(matches);
let chosen;
if (lastPy === null) {
chosen = runs.reduce((a, b) => (b.size > a.size ? b : a), runs[0]);
} else {
chosen = runs.reduce((a, b) =>
Math.abs(b.centroid - lastPy) < Math.abs(a.centroid - lastPy) ? b : a, runs[0]);
}
lastPy = chosen.centroid;
const d = pixelToData(x, chosen.centroid);
pts.push({ px: x, dataX: d.dataX, dataY: d.dataY });
}
activeSeries.points = pts.sort((a,b) => a.dataX - b.dataX);
series.push(activeSeries);
// auto-fill short gaps (a handful of pixels — typically where another curve/gridline
// crosses this one and briefly steals the match) by interpolating; leave long gaps alone
const FILL_MAX_PX = 8;
const ptsByPx = activeSeries.points.slice().sort((a,b) => a.px - b.px);
const filled = [];
let filledGapCount = 0;
for (let i = 0; i < ptsByPx.length; i++) {
filled.push(ptsByPx[i]);
if (i < ptsByPx.length - 1) {
const gap = ptsByPx[i+1].px - ptsByPx[i].px;
if (gap > 1 && gap <= FILL_MAX_PX + 1) {
filledGapCount++;
for (let px = ptsByPx[i].px + 1; px < ptsByPx[i+1].px; px++) {
const t = (px - ptsByPx[i].px) / gap;
filled.push({
px,
dataX: ptsByPx[i].dataX + t * (ptsByPx[i+1].dataX - ptsByPx[i].dataX),
dataY: ptsByPx[i].dataY + t * (ptsByPx[i+1].dataY - ptsByPx[i].dataY),
interpolated: true
});
}
}
}
}
activeSeries.points = filled.sort((a,b) => a.dataX - b.dataX);
// report exactly what was and wasn't covered, in data units, so gaps are diagnosable
const scannedCols = xEnd - xStart + 1;
const fullRangeD = pixelToData(xStart, yStart), fullRangeD2 = pixelToData(xEnd, yStart);
let note = pts.length + ' of ' + scannedCols + ' scanned columns matched directly (' + stretchedColumns + ' needed relaxed tolerance), plus ' +
filledGapCount + ' short gap(s) auto-filled by interpolation (likely line crossings).';
if (pts.length) {
const xs = pts.map(p => p.dataX);
note += ' Data spans x=' + fmt(Math.min(...xs)) + ' to x=' + fmt(Math.max(...xs)) +
'. Scan was clamped to your calibrated range, x=' + fmt(Math.min(fullRangeD.dataX, fullRangeD2.dataX)) + ' to x=' + fmt(Math.max(fullRangeD.dataX, fullRangeD2.dataX)) + '.';
}
// only report gaps too wide to have been auto-filled above
const longDeadColumns = [];
{
let gs = null, gp = null;
const flushIfLong = () => { if (gs !== null && (gp - gs) > FILL_MAX_PX) longDeadColumns.push([gs, gp]); };
for (const c of deadColumns) {
if (gs === null) { gs = c; gp = c; }
else if (c - gp <= 1) { gp = c; }
else { flushIfLong(); gs = c; gp = c; }
}
flushIfLong();
}
if (longDeadColumns.length) {
const gapText = longDeadColumns.slice(0, 6).map(([a,b]) => {
const da = pixelToData(a, yStart).dataX, db = pixelToData(b, yStart).dataX;
return fmt(Math.min(da,db)) + '–' + fmt(Math.max(da,db));
}).join(', ');
note += ' No match (even relaxed) over a stretch wider than a crossing, in: ' + gapText +
(longDeadColumns.length > 6 ? ', +' + (longDeadColumns.length-6) + ' more' : '') +
' — likely the line is genuinely a different color there, or absent from the source.';
}
$('extractNote').textContent = note;
renderSeriesList();
renderOutput();
activeSeries = null;
$('btnExtract').disabled = true;
$('seriesName').value = '';
$('colorPreview').textContent = '';
});
function renderSeriesList() {
const host = $('seriesList');
if (!series.length) { host.innerHTML = '<div class="empty">None yet</div>'; return; }
host.innerHTML = '';
series.forEach((s, i) => {
const div = document.createElement('div');
div.className = 'series-item';
div.innerHTML = '<span class="swatch" style="background:rgb(' + s.color.join(',') + ')"></span>' +
'<span class="series-name">' + s.name + '</span>' +
'<div class="series-meta">' + s.points.length + ' points · tolerance ' + s.tolerance + '</div>' +
'<div class="flexgap"><button class="small" data-act="view">View</button><button class="small" data-act="remove">Remove</button></div>';
div.querySelector('[data-act=view]').addEventListener('click', () => { renderOutput(i); });
div.querySelector('[data-act=remove]').addEventListener('click', () => { series.splice(i,1); renderSeriesList(); renderOutput(); });
host.appendChild(div);
});
}
// ---------- output ----------
$('tabTable').addEventListener('click', () => switchTab('table'));
$('tabCSV').addEventListener('click', () => switchTab('csv'));
function switchTab(which) {
$('tabTable').classList.toggle('active', which==='table');
$('tabCSV').classList.toggle('active', which==='csv');
$('viewTable').style.display = which==='table' ? '' : 'none';
$('viewCSV').style.display = which==='csv' ? '' : 'none';
}
$('resampleOn').addEventListener('change', e => { $('resampleRow').style.display = e.target.checked ? '' : 'none'; renderOutput(currentViewIdx); });
$('resampleN').addEventListener('change', () => renderOutput(currentViewIdx));
$('xLabel').addEventListener('input', () => renderOutput(currentViewIdx));
let currentViewIdx = 0;
function renderOutput(idx) {
if (idx !== undefined) currentViewIdx = idx;
if (!series.length) {
$('tableBody').innerHTML = '';
$('csvText').value = '';
$('thY').textContent = 'y';
return;
}
const s = series[Math.min(currentViewIdx, series.length-1)];
$('thY').textContent = s.name;
let pts = s.points;
if ($('resampleOn').checked) pts = resample(pts, parseInt($('resampleN').value,10) || 200);
const xLabel = $('xLabel').value || 'x';
let csv = xLabel + ',' + s.name + '\n';
let rows = '';
pts.forEach(p => {
csv += p.dataX.toPrecision(6) + ',' + p.dataY.toPrecision(6) + '\n';
rows += '<tr><td>' + fmt(p.dataX) + '</td><td>' + fmt(p.dataY) + '</td></tr>';
});
$('tableBody').innerHTML = rows;
$('csvText').value = csv;
}
function resample(pts, n) {
if (pts.length < 2) return pts;
const xs = pts.map(p => p.dataX);
const xMin = Math.min(...xs), xMax = Math.max(...xs);
const out = [];
for (let i = 0; i < n; i++) {
const xt = xMin + (xMax - xMin) * i / (n - 1);
out.push({ dataX: xt, dataY: interpAt(pts, xt) });
}
return out;
}
function interpAt(pts, xt) {
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i], b = pts[i+1];
if ((xt >= a.dataX && xt <= b.dataX) || (xt <= a.dataX && xt >= b.dataX)) {
const t = (xt - a.dataX) / (b.dataX - a.dataX || 1);
return a.dataY + t * (b.dataY - a.dataY);
}
}
return pts[pts.length-1].dataY;
}
$('btnCopy').addEventListener('click', () => {
$('csvText').select();
navigator.clipboard.writeText($('csvText').value).catch(()=>{ document.execCommand('copy'); });
});
$('btnDownload').addEventListener('click', () => {
if (!series.length) return;
const s = series[currentViewIdx];
downloadText($('csvText').value, (s.name || 'series').replace(/\s+/g,'_') + '.csv');
});
$('btnDownloadAll').addEventListener('click', () => {
if (!series.length) return;
const useResample = $('resampleOn').checked;
const n = parseInt($('resampleN').value,10) || 200;
const allX = series.flatMap(s => s.points.map(p => p.dataX));
const xMin = Math.min(...allX), xMax = Math.max(...allX);
const count = useResample ? n : 200;
const xLabel = $('xLabel').value || 'x';
let header = xLabel + ',' + series.map(s => s.name).join(',');
let csv = header + '\n';
for (let i = 0; i < count; i++) {
const xt = xMin + (xMax - xMin) * i / (count - 1);
const row = [xt.toPrecision(6)];
series.forEach(s => row.push(interpAt(s.points, xt).toPrecision(6)));
csv += row.join(',') + '\n';
}
downloadText(csv, 'combined_series.csv');
});
function downloadText(text, filename) {
const blob = new Blob([text], {type:'text/csv'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
function updateStepStates() {
$('step-load').classList.toggle('done', !!img);
$('step-x').classList.toggle('active', !!img && calib.x.length < 2);
$('step-x').classList.toggle('done', calib.x.length === 2);
$('step-y').classList.toggle('active', calib.x.length === 2 && calib.y.length < 2);
$('step-y').classList.toggle('done', calib.y.length === 2);
$('step-series').classList.toggle('active', calib.x.length === 2 && calib.y.length === 2);
$('btnPickColor').disabled = !(calib.x.length === 2 && calib.y.length === 2);
$('btnSetRegion').disabled = !(calib.x.length === 2 && calib.y.length === 2);
}
})();
</script>
</body>
</html>
Posted : 30/08/2026 7:52 pm
