const apiKeyInput = document.getElementById("apiKey");
const modelInput = document.getElementById("model");
const imageInput = document.getElementById("imageInput");
const dropzone = document.getElementById("dropzone");
const preview = document.getElementById("preview");
const replotPreview = document.getElementById("replotPreview");
const plotLayout = document.getElementById("plotLayout");
const originalPane = document.getElementById("originalPane");
const replotPane = document.getElementById("replotPane");
const improveManualBtn = document.getElementById("improveManualBtn");
const runBtn = document.getElementById("runBtn");
const statusEl = document.getElementById("status");
const errorDetails = document.getElementById("errorDetails");
const progressBar = document.getElementById("progressBar");
const progressFill = document.getElementById("progressFill");
const results = document.getElementById("results");
const csvOutput = document.getElementById("csvOutput");
const codeOutput = document.getElementById("codeOutput");
const validationEl = document.getElementById("validation");
const copyCsvBtn = document.getElementById("copyCsv");
const copyCodeBtn = document.getElementById("copyCode");
const toggleKeyBtn = document.getElementById("toggleKey");
const zipDownload = document.getElementById("zipDownload");
const resultCards = document.getElementById("resultCards");
const infoBtn = document.getElementById("infoBtn");
const infoModal = document.getElementById("infoModal");
const infoCloseBtn = document.getElementById("infoCloseBtn");
const APP_BASE = document.body?.dataset?.appBase || "";
const appUrl = (path) => `${APP_BASE}${path}`;
const MODEL_PLACEHOLDER_NO_KEY = "Enter API key to load models";
const MODEL_PLACEHOLDER_READY = "Press Tab/click out to load models";
const MODEL_PLACEHOLDER_LOADING = "Loading models...";
const MODEL_PLACEHOLDER_NONE = "No available vision models";
const MODEL_PLACEHOLDER_ERROR = "Could not load models";
let currentFile = null;
let isZipUpload = false;
let currentPreviewObjectUrl = "";
let pollTimer = null;
let loadedModelsKey = "";
let modelsLoaded = false;
let modelsLoading = false;
let latestPlotOutput = {
replotUrl: "",
csvText: "",
pythonCode: "",
};
const setStatus = (text, tone = "neutral") => {
statusEl.textContent = text;
statusEl.style.color =
tone === "error"
? "var(--error)"
: tone === "success"
? "var(--success)"
: "var(--muted)";
};
const setModelPlaceholder = (text) => {
if (!modelInput) return;
modelInput.innerHTML = "";
const option = document.createElement("option");
option.value = "";
option.textContent = text;
modelInput.appendChild(option);
modelInput.value = "";
modelInput.disabled = true;
};
const normalizeModels = (models) => {
const list = Array.isArray(models) ? models : [];
const unique = new Map();
for (const item of list) {
if (!item || !item.id) continue;
const id = String(item.id).trim();
if (!id || unique.has(id)) continue;
unique.set(id, {
id,
display_name: String(item.display_name || id).trim() || id,
});
}
return [...unique.values()];
};
const populateModelOptions = (models, defaultModel) => {
const normalized = normalizeModels(models);
if (!normalized.length) {
setModelPlaceholder(MODEL_PLACEHOLDER_NONE);
return false;
}
const previousValue = modelInput.value;
modelInput.disabled = false;
modelInput.innerHTML = "";
for (const model of normalized) {
const option = document.createElement("option");
option.value = model.id;
option.textContent =
model.display_name && model.display_name !== model.id
? `${model.display_name} (${model.id})`
: model.id;
modelInput.appendChild(option);
}
let nextValue = defaultModel || "";
if (modelsLoaded && normalized.some((item) => item.id === previousValue)) {
nextValue = previousValue;
}
if (!normalized.some((item) => item.id === nextValue)) {
nextValue = normalized[0]?.id || "";
}
modelInput.value = nextValue;
return Boolean(nextValue);
};
const loadAvailableModels = async ({ force = false, silent = false } = {}) => {
const apiKey = apiKeyInput.value.trim();
if (!apiKey) {
setModelPlaceholder(MODEL_PLACEHOLDER_NO_KEY);
loadedModelsKey = "";
modelsLoaded = false;
return false;
}
if (!force && loadedModelsKey === apiKey) {
return true;
}
if (modelsLoading) {
return false;
}
modelsLoading = true;
try {
setModelPlaceholder(MODEL_PLACEHOLDER_LOADING);
if (!silent) setStatus("Loading available models...", "neutral");
const response = await fetch(appUrl("/api/models"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: apiKey }),
});
const payload = await response.json();
if (!response.ok) {
const detail = payload.details ? ` (${payload.details})` : "";
throw new Error(`${payload.error || "Failed to load models."}${detail}`);
}
const ok = populateModelOptions(payload.models || [], payload.default_model || "");
loadedModelsKey = apiKey;
modelsLoaded = ok;
if (!silent) {
const providerName = payload.provider === "openai" ? "OpenAI" : payload.provider === "anthropic" ? "Anthropic" : payload.provider === "gemini" ? "Google Gemini" : "provider";
setStatus(
ok ? `Loaded ${providerName} models.` : "No vision-capable models available for this API key.",
ok ? "success" : "error"
);
}
return ok;
} catch (error) {
setModelPlaceholder(MODEL_PLACEHOLDER_ERROR);
loadedModelsKey = "";
modelsLoaded = false;
if (!silent) setStatus(error.message || "Failed to load models.", "error");
return false;
} finally {
modelsLoading = false;
}
};
let progressTimer = null;
let stepTimer = null;
const setProgress = (active, value = 0) => {
if (!progressBar || !progressFill) return;
progressBar.classList.toggle("active", Boolean(active));
progressFill.style.width = `${value}%`;
};
const startProgress = () => {
clearInterval(progressTimer);
setProgress(true, 10);
let current = 10;
progressTimer = setInterval(() => {
if (current < 45) {
current += 3;
setProgress(true, current);
}
}, 400);
};
const bumpProgress = (value) => {
clearInterval(progressTimer);
setProgress(true, value);
};
const finishProgress = () => {
clearInterval(progressTimer);
setProgress(true, 100);
setTimeout(() => setProgress(false, 0), 450);
};
const startZipPolling = (progressUrl, resultsUrl) => {
clearInterval(pollTimer);
const tick = async () => {
try {
const response = await fetch(progressUrl);
if (!response.ok) return;
const data = await response.json();
const total = data.total || 0;
const processed = data.processed || 0;
const current = data.current || "";
if (total > 0) {
const percent = Math.min(100, Math.round((processed / total) * 100));
setProgress(true, percent);
setStatus(
`Processing ${processed}/${total}${current ? `: ${current}` : ""}`,
"neutral"
);
}
if (data.status === "done") {
setProgress(true, 100);
setStatus("Finished.", "success");
validationEl.innerHTML = `Processed: ${processed} images.`;
if (zipDownload) {
zipDownload.innerHTML = `Download results ZIP`;
}
clearInterval(pollTimer);
finishProgress();
} else if (data.status === "error") {
setStatus("ZIP processing failed.", "error");
setErrorDetails(data.details || data.error || "ZIP processing failed.");
clearInterval(pollTimer);
finishProgress();
}
} catch (error) {
setErrorDetails(error.message || "Failed to fetch progress.");
}
};
tick();
pollTimer = setInterval(tick, 1200);
};
const startStepTimeline = () => {
clearInterval(stepTimer);
const steps = [
{ label: "Uploading image...", value: 12 },
{ label: "Extracting data...", value: 25 },
{ label: "Generating replot code...", value: 45 },
{ label: "Re-trying plot code (if needed)...", value: 60 },
{ label: "Rendering outputs...", value: 72 },
{ label: "Validating plots...", value: 85 },
];
let idx = 0;
setStatus(steps[0].label, "neutral");
setProgress(true, steps[0].value);
stepTimer = setInterval(() => {
idx = Math.min(idx + 1, steps.length - 1);
setStatus(steps[idx].label, "neutral");
if (progressFill) {
const current = parseFloat(progressFill.style.width || "0");
const next = Math.min(steps[idx].value, 92);
if (next > current) {
setProgress(true, next);
}
}
}, 2500);
};
const stopStepTimeline = () => {
clearInterval(stepTimer);
};
const setErrorDetails = (text) => {
if (!errorDetails) return;
if (!text) {
errorDetails.textContent = "";
errorDetails.style.display = "none";
return;
}
errorDetails.textContent = text;
errorDetails.style.display = "block";
};
const releasePreviewObjectUrl = () => {
if (!currentPreviewObjectUrl) return;
URL.revokeObjectURL(currentPreviewObjectUrl);
currentPreviewObjectUrl = "";
};
const setPreview = (file) => {
releasePreviewObjectUrl();
if (!file) {
preview.innerHTML = "
Preview will appear here.
";
return;
}
const url = URL.createObjectURL(file);
currentPreviewObjectUrl = url;
preview.innerHTML = "";
const img = document.createElement("img");
img.src = url;
preview.appendChild(img);
};
const setPreviewMessage = (text) => {
releasePreviewObjectUrl();
preview.innerHTML = `${text}
`;
};
const setZipPreview = (fileName) => {
releasePreviewObjectUrl();
preview.innerHTML = `
`;
};
const isPlotImageUrl = (value) => {
const text = String(value || "").trim();
return (
text.startsWith("http://") ||
text.startsWith("https://") ||
text.startsWith("/") ||
text.startsWith("data:image/")
);
};
const setImproveManualVisible = (visible) => {
if (!improveManualBtn) return;
improveManualBtn.classList.toggle("hidden", !visible);
improveManualBtn.disabled = !visible;
};
const PLOT_COLORS = [
"#1f77b4",
"#ff7f0e",
"#2ca02c",
"#d62728",
"#9467bd",
"#8c564b",
"#e377c2",
"#7f7f7f",
"#bcbd22",
"#17becf",
];
const PLOT_FRAME = {
width: 900,
height: 620,
left: 86,
right: 34,
top: 52,
bottom: 80,
};
const MATPLOTLIB_COLOR_NAMES = {
b: "#1f77b4",
blue: "#1f77b4",
"tab:blue": "#1f77b4",
g: "#2ca02c",
green: "#2ca02c",
"tab:green": "#2ca02c",
r: "#d62728",
red: "#d62728",
"tab:red": "#d62728",
c: "#17becf",
cyan: "#17becf",
"tab:cyan": "#17becf",
m: "#e377c2",
magenta: "#e377c2",
"tab:pink": "#e377c2",
y: "#bcbd22",
yellow: "#bcbd22",
"tab:olive": "#bcbd22",
k: "#111827",
black: "#111827",
w: "#ffffff",
white: "#ffffff",
orange: "#ff7f0e",
"tab:orange": "#ff7f0e",
purple: "#9467bd",
"tab:purple": "#9467bd",
brown: "#8c564b",
"tab:brown": "#8c564b",
gray: "#7f7f7f",
grey: "#7f7f7f",
"tab:gray": "#7f7f7f",
"tab:grey": "#7f7f7f",
};
const LINE_DASHES = {
"--": "10 7",
dashed: "10 7",
":": "2 6",
dotted: "2 6",
"-.": "10 5 2 5",
dashdot: "10 5 2 5",
};
const NO_LINE_STYLES = new Set(["", " ", "none", "None", "NoneType", "false", "False"]);
const NO_MARKERS = new Set(["", " ", "none", "None", "NoneType", ""]);
let plotEditorState = null;
let plotEditorElements = null;
let plotEditorDrag = null;
let manualImproveState = null;
const escapeHtml = (value) =>
String(value ?? "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
const stripMarkdownFence = (text) => {
const value = String(text || "").trim();
const fenced = value.match(/```(?:csv|text)?\s*([\s\S]*?)```/i);
return (fenced ? fenced[1] : value).trim();
};
const countDelimiterOutsideQuotes = (line, delimiter) => {
let count = 0;
let inQuotes = false;
const value = String(line || "");
for (let i = 0; i < value.length; i += 1) {
const char = value[i];
const next = value[i + 1];
if (char === "\"" && inQuotes && next === "\"") {
i += 1;
continue;
}
if (char === "\"") {
inQuotes = !inQuotes;
} else if (!inQuotes && char === delimiter) {
count += 1;
}
}
return count;
};
const detectCsvDelimiter = (text) => {
const lines = String(text || "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.slice(0, 8);
const candidates = [",", "\t", ";", "|"];
const scores = candidates.map((delimiter) => ({
delimiter,
score: lines.reduce((total, line) => total + countDelimiterOutsideQuotes(line, delimiter), 0),
}));
scores.sort((a, b) => b.score - a.score);
return scores[0]?.score ? scores[0].delimiter : ",";
};
const normalizeCsvText = (text) => {
const value = stripMarkdownFence(text);
const delimiter = detectCsvDelimiter(value);
const lines = value
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const tableLines = [];
let started = false;
for (const line of lines) {
const delimiterCount = countDelimiterOutsideQuotes(line, delimiter);
if (!started && delimiterCount === 0) {
continue;
}
if (delimiterCount === 0 && started) {
break;
}
started = true;
tableLines.push(line);
}
return {
delimiter,
text: tableLines.length ? tableLines.join("\n") : value,
};
};
const parseCsvRows = (text) => {
const normalized = normalizeCsvText(text);
const rows = [];
let row = [];
let cell = "";
let inQuotes = false;
const value = normalized.delimiter === "|"
? normalized.text
.split(/\r?\n/)
.map((line) => line.replace(/^\s*\|/, "").replace(/\|\s*$/, ""))
.join("\n")
: normalized.text;
const delimiter = normalized.delimiter;
for (let i = 0; i < value.length; i += 1) {
const char = value[i];
const next = value[i + 1];
if (inQuotes) {
if (char === "\"" && next === "\"") {
cell += "\"";
i += 1;
} else if (char === "\"") {
inQuotes = false;
} else {
cell += char;
}
continue;
}
if (char === "\"") {
inQuotes = true;
} else if (char === delimiter) {
row.push(cell);
cell = "";
} else if (char === "\n") {
row.push(cell);
rows.push(row);
row = [];
cell = "";
} else if (char !== "\r") {
cell += char;
}
}
if (cell || row.length) {
row.push(cell);
rows.push(row);
}
return rows.filter((item) => item.some((cellValue) => String(cellValue).trim()));
};
const parseNumericCell = (value) => {
const trimmed = String(value ?? "").trim().replace(/\u2212/g, "-");
if (!trimmed) return null;
const isPercent = trimmed.endsWith("%");
const cleaned = trimmed
.replace(/%$/, "")
.replace(/(\d),(?=\d{3}(?:\D|$))/g, "$1");
const numeric = Number(cleaned);
if (Number.isFinite(numeric) && isPercent) return numeric;
return Number.isFinite(numeric) ? numeric : null;
};
const splitTopLevelArgs = (text) => {
const parts = [];
let current = "";
let quote = "";
let depth = 0;
const value = String(text || "");
for (let i = 0; i < value.length; i += 1) {
const char = value[i];
const prev = value[i - 1];
if (quote) {
current += char;
if (char === quote && prev !== "\\") {
quote = "";
}
continue;
}
if (char === "\"" || char === "'") {
quote = char;
current += char;
} else if ("([{".includes(char)) {
depth += 1;
current += char;
} else if (")]".includes(char) || char === "}") {
depth = Math.max(0, depth - 1);
current += char;
} else if (char === "," && depth === 0) {
parts.push(current.trim());
current = "";
} else {
current += char;
}
}
if (current.trim()) {
parts.push(current.trim());
}
return parts;
};
const findTopLevelEquals = (text) => {
let quote = "";
let depth = 0;
const value = String(text || "");
for (let i = 0; i < value.length; i += 1) {
const char = value[i];
const prev = value[i - 1];
if (quote) {
if (char === quote && prev !== "\\") quote = "";
continue;
}
if (char === "\"" || char === "'") {
quote = char;
} else if ("([{".includes(char)) {
depth += 1;
} else if (")]".includes(char) || char === "}") {
depth = Math.max(0, depth - 1);
} else if (char === "=" && depth === 0) {
return i;
}
}
return -1;
};
const stripPythonString = (value) => {
const text = String(value ?? "").trim();
const match = text.match(/^[rubfRUBF]*(['"])([\s\S]*)\1$/);
if (!match) return "";
return match[2]
.replace(/\\n/g, "\n")
.replace(/\\t/g, "\t")
.replace(/\\"/g, "\"")
.replace(/\\'/g, "'")
.replace(/\\\\/g, "\\");
};
const extractFirstStringArg = (argsText) => {
for (const arg of splitTopLevelArgs(argsText)) {
const value = stripPythonString(arg);
if (value) return value;
}
return "";
};
const parsePythonKwargs = (argsText) => {
const kwargs = {};
const positional = [];
for (const arg of splitTopLevelArgs(argsText)) {
const equalsIndex = findTopLevelEquals(arg);
if (equalsIndex > 0) {
const key = arg.slice(0, equalsIndex).trim();
kwargs[key] = arg.slice(equalsIndex + 1).trim();
} else {
positional.push(arg);
}
}
return { kwargs, positional };
};
const getKwarg = (kwargs, names) => {
for (const name of names) {
if (Object.prototype.hasOwnProperty.call(kwargs, name)) {
return kwargs[name];
}
}
return "";
};
const parsePythonNumbers = (text) => {
const matches = String(text || "").match(/[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g) || [];
return matches.map(Number).filter(Number.isFinite);
};
const rangeFromStartStopStep = (start, stop, step = 1) => {
if (!Number.isFinite(start) || !Number.isFinite(stop) || !Number.isFinite(step) || step === 0) {
return [];
}
const values = [];
const forward = step > 0;
for (let value = start; forward ? value < stop : value > stop; value += step) {
values.push(Number(value.toFixed(12)));
if (values.length > 10000) break;
}
return values;
};
const linspaceFromStartStopCount = (start, stop, count = 50) => {
const size = Math.max(0, Math.floor(count));
if (!Number.isFinite(start) || !Number.isFinite(stop) || !size) return [];
if (size === 1) return [start];
return Array.from({ length: size }, (_, index) => (
start + ((stop - start) * index) / (size - 1)
));
};
const findMatchingParen = (text, openIndex) => {
let quote = "";
let depth = 0;
for (let i = openIndex; i < text.length; i += 1) {
const char = text[i];
const prev = text[i - 1];
if (quote) {
if (char === quote && prev !== "\\") quote = "";
continue;
}
if (char === "\"" || char === "'") {
quote = char;
} else if (char === "(") {
depth += 1;
} else if (char === ")") {
depth -= 1;
if (depth === 0) return i;
}
}
return -1;
};
const findMatchingBracket = (text, openIndex) => {
const openChar = text[openIndex];
const closeChar = openChar === "[" ? "]" : openChar === "(" ? ")" : "}";
let quote = "";
let depth = 0;
for (let i = openIndex; i < text.length; i += 1) {
const char = text[i];
const prev = text[i - 1];
if (quote) {
if (char === quote && prev !== "\\") quote = "";
continue;
}
if (char === "\"" || char === "'") {
quote = char;
} else if (char === openChar) {
depth += 1;
} else if (char === closeChar) {
depth -= 1;
if (depth === 0) return i;
}
}
return -1;
};
const extractPythonCalls = (code, names) => {
const source = String(code || "");
if (!source.trim()) return [];
const namePattern = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
const pattern = new RegExp(`(?:\\b\\w+\\.)?(${namePattern})\\s*\\(`, "g");
const calls = [];
let match = null;
while ((match = pattern.exec(source))) {
const openIndex = pattern.lastIndex - 1;
const closeIndex = findMatchingParen(source, openIndex);
if (closeIndex < 0) continue;
calls.push({
name: match[1],
args: source.slice(openIndex + 1, closeIndex),
index: match.index,
});
pattern.lastIndex = closeIndex + 1;
}
return calls;
};
const parsePythonNumericExpression = (expression, variableArrays = {}) => {
const expr = String(expression || "").trim();
if (!expr) return [];
if (variableArrays[expr]) return variableArrays[expr];
const linspaceMatch = expr.match(/(?:np|numpy)\.linspace\s*\(([\s\S]*)\)$/);
if (linspaceMatch) {
const args = splitTopLevelArgs(linspaceMatch[1]);
const start = parsePythonNumbers(args[0])[0];
const stop = parsePythonNumbers(args[1])[0];
const count = parsePythonNumbers(args[2])[0] || 50;
return linspaceFromStartStopCount(start, stop, count);
}
const arangeMatch = expr.match(/(?:np|numpy)\.arange\s*\(([\s\S]*)\)$/);
if (arangeMatch) {
const args = splitTopLevelArgs(arangeMatch[1]);
const nums = args.map((arg) => parsePythonNumbers(arg)[0]).filter(Number.isFinite);
if (nums.length === 1) return rangeFromStartStopStep(0, nums[0], 1);
if (nums.length === 2) return rangeFromStartStopStep(nums[0], nums[1], 1);
if (nums.length >= 3) return rangeFromStartStopStep(nums[0], nums[1], nums[2]);
}
const arrayMatch = expr.match(/(?:np|numpy)\.array\s*\(([\s\S]*)\)$/);
if (arrayMatch) return parsePythonNumbers(arrayMatch[1]);
if (/^[[(]/.test(expr)) return parsePythonNumbers(expr);
return parsePythonNumbers(expr);
};
const extractPythonVariableArrays = (code) => {
const source = String(code || "");
const arrays = {};
const assignmentPattern = /(?:^|\n)\s*([A-Za-z_]\w*)\s*=\s*/g;
let match = null;
while ((match = assignmentPattern.exec(source))) {
const name = match[1];
let start = assignmentPattern.lastIndex;
while (/\s/.test(source[start] || "")) start += 1;
let expression = "";
const callMatch = source.slice(start).match(/^(?:np|numpy)\.(?:array|linspace|arange)\s*\(/);
if (callMatch) {
const openIndex = start + callMatch[0].lastIndexOf("(");
const closeIndex = findMatchingParen(source, openIndex);
if (closeIndex > openIndex) expression = source.slice(start, closeIndex + 1);
} else if (source[start] === "[" || source[start] === "(") {
const closeIndex = findMatchingBracket(source, start);
if (closeIndex > start) expression = source.slice(start, closeIndex + 1);
} else {
const lineEnd = source.indexOf("\n", start);
expression = source.slice(start, lineEnd < 0 ? source.length : lineEnd);
}
const values = parsePythonNumericExpression(expression, arrays);
if (values.length) arrays[name] = values;
}
return arrays;
};
const buildCurvesFromPythonCode = (pythonCode) => {
const source = String(pythonCode || "");
const variableArrays = extractPythonVariableArrays(source);
const plotCalls = extractPythonCalls(source, ["plot", "scatter", "errorbar", "semilogx", "semilogy", "loglog"]);
const curves = [];
for (const call of plotCalls) {
const { positional, kwargs } = parsePythonKwargs(call.args);
const numericArgs = positional
.filter((arg) => !stripPythonString(arg))
.map((arg) => parsePythonNumericExpression(arg, variableArrays))
.filter((values) => values.length);
let xValues = [];
let yValues = [];
if (numericArgs.length >= 2) {
[xValues, yValues] = numericArgs;
} else if (numericArgs.length === 1) {
yValues = numericArgs[0];
xValues = yValues.map((_, index) => index);
}
const size = Math.min(xValues.length, yValues.length);
if (!size) continue;
const points = Array.from({ length: size }, (_, index) => ({
x: xValues[index],
y: yValues[index],
})).filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y));
if (!points.length) continue;
const label = stripPythonString(getKwarg(kwargs, ["label"]));
curves.push({
xHeader: "x",
yHeader: label || `Curve ${curves.length + 1}`,
points,
});
}
return curves;
};
const normalizePlotColor = (value, fallback) => {
const raw = String(value ?? "").trim();
if (!raw) return fallback;
const unquoted = stripPythonString(raw) || raw;
const lower = unquoted.trim().toLowerCase();
if (!lower || ["none", "null", "false"].includes(lower)) return fallback;
if (/^#[0-9a-f]{3,8}$/i.test(unquoted)) return unquoted;
if (/^c\d+$/i.test(lower)) {
return PLOT_COLORS[Number(lower.slice(1)) % PLOT_COLORS.length] || fallback;
}
if (MATPLOTLIB_COLOR_NAMES[lower]) return MATPLOTLIB_COLOR_NAMES[lower];
const tupleNumbers = parsePythonNumbers(unquoted);
if (tupleNumbers.length >= 3 && /^[([]/.test(unquoted)) {
const rgb = tupleNumbers.slice(0, 3).map((num) => {
const value255 = num <= 1 ? Math.round(num * 255) : Math.round(num);
return Math.min(255, Math.max(0, value255));
});
return `rgb(${rgb.join(", ")})`;
}
return fallback;
};
const normalizeLineStyle = (value, fallback = "-") => {
const raw = stripPythonString(value) || String(value ?? "").trim();
if (!raw) return fallback;
if (NO_LINE_STYLES.has(raw)) return "none";
return raw;
};
const normalizeMarker = (value, fallback = "") => {
const raw = stripPythonString(value) || String(value ?? "").trim();
if (!raw || NO_MARKERS.has(raw)) return "";
return raw;
};
const parsePlotFormatString = (value) => {
const fmt = stripPythonString(value);
if (!fmt) return {};
const style = {};
const colorChar = [...fmt].find((char) => "bgrcmykw".includes(char));
if (colorChar) {
style.color = normalizePlotColor(colorChar, "");
}
if (fmt.includes("--")) {
style.lineStyle = "--";
} else if (fmt.includes("-.")) {
style.lineStyle = "-.";
} else if (fmt.includes(":")) {
style.lineStyle = ":";
} else if (fmt.includes("-")) {
style.lineStyle = "-";
}
const markerChars = ["o", "s", "^", "v", "<", ">", "D", "d", "x", "+", "*", ".", "p", "h", "H", "1", "2", "3", "4"];
const marker = markerChars.find((char) => fmt.includes(char));
if (marker) {
style.marker = marker;
if (!style.lineStyle) style.lineStyle = "none";
}
return style;
};
const parsePlotLimit = (code, names) => {
const calls = extractPythonCalls(code, names);
if (!calls.length) return null;
const latest = calls[calls.length - 1];
const { kwargs } = parsePythonKwargs(latest.args);
const left = getKwarg(kwargs, ["left", "xmin", "bottom", "ymin"]);
const right = getKwarg(kwargs, ["right", "xmax", "top", "ymax"]);
if (left && right) {
const leftNum = parsePythonNumbers(left)[0];
const rightNum = parsePythonNumbers(right)[0];
if (Number.isFinite(leftNum) && Number.isFinite(rightNum) && leftNum !== rightNum) {
return [leftNum, rightNum];
}
}
const values = parsePythonNumbers(latest.args);
if (values.length >= 2 && values[0] !== values[1]) {
return [values[0], values[1]];
}
return null;
};
const parsePlotScale = (code, axis) => {
const calls = extractPythonCalls(code, axis === "x" ? ["xscale", "set_xscale"] : ["yscale", "set_yscale"]);
const latest = calls[calls.length - 1];
const text = latest ? extractFirstStringArg(latest.args).toLowerCase() : "";
return text === "log" || text === "symlog" ? "log" : "linear";
};
const parseBooleanish = (value, fallback = true) => {
const text = String(value ?? "").trim().toLowerCase();
if (!text) return fallback;
if (["false", "0", "none"].includes(text)) return false;
if (["true", "1"].includes(text)) return true;
return fallback;
};
const parseMatplotlibStyle = (code, curves) => {
const source = String(code || "");
const style = {
background: "#ffffff",
plotBackground: "#ffffff",
axisColor: "#111827",
textColor: "#111827",
gridColor: "#d1d5db",
grid: false,
title: "",
xLabel: "",
yLabel: "",
xScale: "linear",
yScale: "linear",
xlim: null,
ylim: null,
curves: [],
};
if (/dark_background/.test(source)) {
style.background = "#111827";
style.plotBackground = "#111827";
style.axisColor = "#f8fafc";
style.textColor = "#f8fafc";
style.gridColor = "#475569";
}
const labelCalls = {
xLabel: extractPythonCalls(source, ["xlabel", "set_xlabel"]),
yLabel: extractPythonCalls(source, ["ylabel", "set_ylabel"]),
title: extractPythonCalls(source, ["title", "set_title", "suptitle"]),
};
for (const [key, calls] of Object.entries(labelCalls)) {
const latest = calls[calls.length - 1];
if (latest) {
style[key] = extractFirstStringArg(latest.args);
}
}
style.xlim = parsePlotLimit(source, ["xlim", "set_xlim"]);
style.ylim = parsePlotLimit(source, ["ylim", "set_ylim"]);
style.xScale = parsePlotScale(source, "x");
style.yScale = parsePlotScale(source, "y");
const plotCalls = extractPythonCalls(source, ["plot", "scatter", "errorbar", "semilogx", "semilogy", "loglog"]);
if (plotCalls.some((call) => ["semilogx", "loglog"].includes(call.name))) style.xScale = "log";
if (plotCalls.some((call) => ["semilogy", "loglog"].includes(call.name))) style.yScale = "log";
const gridCalls = extractPythonCalls(source, ["grid"]);
if (gridCalls.length) {
const latest = gridCalls[gridCalls.length - 1];
const { positional, kwargs } = parsePythonKwargs(latest.args);
style.grid = parseBooleanish(positional[0] || getKwarg(kwargs, ["visible", "b"]), true);
}
style.curves = curves.map((curve, index) => {
const fallbackColor = PLOT_COLORS[index % PLOT_COLORS.length];
const call = plotCalls[index];
const curveStyle = {
color: fallbackColor,
lineStyle: "-",
lineWidth: 2,
marker: "o",
markerSize: 6,
label: getCurveName(curve, index),
connect: true,
alpha: 1,
};
if (!call) return curveStyle;
const { kwargs, positional } = parsePythonKwargs(call.args);
const formatArg = positional.find((arg) => stripPythonString(arg));
Object.assign(curveStyle, parsePlotFormatString(formatArg));
const colorValue = getKwarg(kwargs, ["color", "c", "markerfacecolor", "mfc"]);
curveStyle.color = normalizePlotColor(colorValue, curveStyle.color);
curveStyle.lineStyle = normalizeLineStyle(getKwarg(kwargs, ["linestyle", "ls"]), curveStyle.lineStyle);
curveStyle.marker = normalizeMarker(getKwarg(kwargs, ["marker"]), curveStyle.marker);
const labelValue = stripPythonString(getKwarg(kwargs, ["label"]));
if (labelValue && !labelValue.startsWith("_")) curveStyle.label = labelValue;
const lineWidth = parsePythonNumbers(getKwarg(kwargs, ["linewidth", "lw"]))[0];
if (Number.isFinite(lineWidth)) curveStyle.lineWidth = Math.max(0.5, lineWidth);
const markerSize = parsePythonNumbers(getKwarg(kwargs, ["markersize", "ms", "s"]))[0];
if (Number.isFinite(markerSize)) {
curveStyle.markerSize = call.name === "scatter"
? Math.max(4, Math.sqrt(markerSize))
: Math.max(3, markerSize);
}
const alpha = parsePythonNumbers(getKwarg(kwargs, ["alpha"]))[0];
if (Number.isFinite(alpha)) curveStyle.alpha = Math.min(1, Math.max(0.1, alpha));
if (call.name === "scatter") {
curveStyle.connect = false;
curveStyle.lineStyle = "none";
if (!curveStyle.marker) curveStyle.marker = "o";
} else {
curveStyle.connect = curveStyle.lineStyle !== "none";
}
return curveStyle;
});
return style;
};
const getCurveName = (curve, index) => {
if (curve?.displayLabel) return curve.displayLabel;
const yHeader = String(curve?.yHeader || "").trim();
return yHeader || `Curve ${index + 1}`;
};
const positiveValues = (values) => values.filter((value) => Number.isFinite(value) && value > 0);
const headerLooksLikeX = (header) => {
const value = String(header || "").trim().toLowerCase();
return (
value === "x" ||
value.startsWith("x ") ||
value.includes("x-axis") ||
value.includes("x axis") ||
value.includes("time") ||
value.includes("wavelength") ||
value.includes("frequency") ||
value.includes("temperature") ||
value.includes("concentration")
);
};
const buildPairedCurves = (headers, rows) => {
const curves = [];
for (let col = 0; col + 1 < headers.length; col += 2) {
const points = [];
for (const row of rows) {
const x = parseNumericCell(row[col]);
const y = parseNumericCell(row[col + 1]);
if (x === null || y === null) continue;
points.push({ x, y });
}
curves.push({
xHeader: headers[col] || "x",
yHeader: headers[col + 1] || `Curve ${curves.length + 1}`,
points,
});
}
return curves.filter((curve) => curve.points.length);
};
const compactThousandsCells = (row, targetLength) => {
const cells = [...row];
while (cells.length > targetLength) {
const mergeIndex = cells.findIndex((cell, index) => (
index < cells.length - 1 &&
/^[-+]?\d{1,3}$/.test(String(cell).trim()) &&
/^\d{3}(?:\.\d+)?%?$/.test(String(cells[index + 1]).trim())
));
if (mergeIndex < 0) break;
cells.splice(mergeIndex, 2, `${cells[mergeIndex]},${cells[mergeIndex + 1]}`);
}
return cells;
};
const buildSharedXCurves = (headers, rows) => {
if (headers.length < 3) return [];
const curves = [];
for (let col = 1; col < headers.length; col += 1) {
const points = [];
for (const row of rows) {
const x = parseNumericCell(row[0]);
const y = parseNumericCell(row[col]);
if (x === null || y === null) continue;
points.push({ x, y });
}
curves.push({
xHeader: headers[0] || "x",
yHeader: headers[col] || `Curve ${curves.length + 1}`,
points,
});
}
return curves.filter((curve) => curve.points.length);
};
const createPlotEditorState = (curves, csvText = "", pythonCode = "") => {
const drawableCurves = (curves || []).filter((curve) => curve.points?.length);
const editableCurves = drawableCurves.length
? drawableCurves
: [{
xHeader: "x",
yHeader: "y",
points: [],
}];
const style = parseMatplotlibStyle(pythonCode, editableCurves);
editableCurves.forEach((curve, index) => {
curve.displayLabel = style.curves[index]?.label || getCurveName(curve, index);
});
return {
originalCsv: csvText,
originalPython: pythonCode,
curves: editableCurves,
activeCurveIndex: 0,
selected: null,
mode: drawableCurves.length ? "select" : "add",
style,
domain: computePlotDomain(editableCurves, style),
};
};
const computePlotDomain = (curves, style = {}) => {
const points = curves.flatMap((curve) => curve.points);
const xValues = points.map((point) => point.x);
const yValues = points.map((point) => point.y);
const xScale = style.xScale === "log" && positiveValues(xValues).length ? "log" : "linear";
const yScale = style.yScale === "log" && positiveValues(yValues).length ? "log" : "linear";
const xDomainValues = xScale === "log" ? positiveValues(xValues) : xValues;
const yDomainValues = yScale === "log" ? positiveValues(yValues) : yValues;
let minX = Math.min(...xDomainValues);
let maxX = Math.max(...xDomainValues);
let minY = Math.min(...yDomainValues);
let maxY = Math.max(...yDomainValues);
if (!Number.isFinite(minX) || !Number.isFinite(maxX)) {
minX = 0;
maxX = 1;
}
if (!Number.isFinite(minY) || !Number.isFinite(maxY)) {
minY = 0;
maxY = 1;
}
if (style.xlim?.length === 2) {
const [left, right] = style.xlim;
if (Number.isFinite(left) && Number.isFinite(right) && left !== right) {
minX = xScale === "log" ? Math.max(Number.MIN_VALUE, Math.min(left, right)) : Math.min(left, right);
maxX = Math.max(left, right);
}
}
if (style.ylim?.length === 2) {
const [bottom, top] = style.ylim;
if (Number.isFinite(bottom) && Number.isFinite(top) && bottom !== top) {
minY = yScale === "log" ? Math.max(Number.MIN_VALUE, Math.min(bottom, top)) : Math.min(bottom, top);
maxY = Math.max(bottom, top);
}
}
const expand = (min, max, scale) => {
if (min === max) {
const delta = scale === "log" ? min * 0.5 : Math.abs(min || 1) * 0.1 || 1;
return [min - delta, max + delta];
}
if (scale === "log") {
const minLog = Math.log10(min);
const maxLog = Math.log10(max);
const pad = (maxLog - minLog) * 0.08;
return [10 ** (minLog - pad), 10 ** (maxLog + pad)];
}
const pad = (max - min) * 0.06;
return [min - pad, max + pad];
};
if (!style.xlim) [minX, maxX] = expand(minX, maxX, xScale);
if (!style.ylim) [minY, maxY] = expand(minY, maxY, yScale);
return { minX, maxX, minY, maxY, xScale, yScale };
};
const parsePlotCsv = (csvText, pythonCode = "") => {
const rows = parseCsvRows(csvText);
if (rows.length < 2) return null;
const headers = rows[0].map((header) => String(header || "").trim());
const dataRows = rows.slice(1).map((row) => compactThousandsCells(row, headers.length));
const pairedCurves = buildPairedCurves(headers, dataRows);
const repeatedXHeaders = headers
.slice(2)
.some((header, index) => index % 2 === 0 && headerLooksLikeX(header));
const shouldTrySharedX = headers.length >= 3 && (headers.length % 2 === 1 || !repeatedXHeaders);
const sharedXCurves = shouldTrySharedX ? buildSharedXCurves(headers, dataRows) : [];
const drawableCurves =
sharedXCurves.length > pairedCurves.length ? sharedXCurves : pairedCurves;
if (!drawableCurves.length) return null;
return createPlotEditorState(drawableCurves, csvText, pythonCode);
};
const parsePlotPython = (pythonCode, csvText = "") => {
const curves = buildCurvesFromPythonCode(pythonCode);
return curves.length ? createPlotEditorState(curves, csvText, pythonCode) : null;
};
const createBlankPlotEditorState = (csvText = "", pythonCode = "") =>
createPlotEditorState([], csvText, pythonCode);
const formatPlotNumber = (value) => {
if (!Number.isFinite(value)) return "";
const rounded = Number(value.toFixed(12));
return Object.is(rounded, -0) ? "0" : String(rounded);
};
const csvCell = (value) => {
const text = String(value ?? "");
if (/[",\n\r]/.test(text)) {
return `"${text.replace(/"/g, "\"\"")}"`;
}
return text;
};
const serializePlotCsv = (state) => {
if (!state?.curves?.length) return "";
const headers = [];
for (const curve of state.curves) {
headers.push(curve.xHeader || "x", curve.yHeader || "y");
}
const maxRows = Math.max(...state.curves.map((curve) => curve.points.length));
const rows = [headers];
for (let rowIndex = 0; rowIndex < maxRows; rowIndex += 1) {
const row = [];
for (const curve of state.curves) {
const point = curve.points[rowIndex];
row.push(
point ? formatPlotNumber(point.x) : "",
point ? formatPlotNumber(point.y) : ""
);
}
rows.push(row);
}
return rows.map((row) => row.map(csvCell).join(",")).join("\n");
};
const plotArea = () => ({
x: PLOT_FRAME.left,
y: PLOT_FRAME.top,
width: PLOT_FRAME.width - PLOT_FRAME.left - PLOT_FRAME.right,
height: PLOT_FRAME.height - PLOT_FRAME.top - PLOT_FRAME.bottom,
});
const scalePlotValue = (value, scale) => {
if (scale === "log") return value > 0 ? Math.log10(value) : NaN;
return value;
};
const unscalePlotValue = (value, scale) => {
if (scale === "log") return 10 ** value;
return value;
};
const dataToView = (point, domain) => {
const area = plotArea();
const minX = scalePlotValue(domain.minX, domain.xScale);
const maxX = scalePlotValue(domain.maxX, domain.xScale);
const minY = scalePlotValue(domain.minY, domain.yScale);
const maxY = scalePlotValue(domain.maxY, domain.yScale);
const pointX = scalePlotValue(point.x, domain.xScale);
const pointY = scalePlotValue(point.y, domain.yScale);
const x =
area.x + ((pointX - minX) / (maxX - minX)) * area.width;
const y =
area.y + area.height - ((pointY - minY) / (maxY - minY)) * area.height;
return { x, y };
};
const viewToData = (point, domain) => {
const area = plotArea();
const minX = scalePlotValue(domain.minX, domain.xScale);
const maxX = scalePlotValue(domain.maxX, domain.xScale);
const minY = scalePlotValue(domain.minY, domain.yScale);
const maxY = scalePlotValue(domain.maxY, domain.yScale);
const scaledX = minX + ((point.x - area.x) / area.width) * (maxX - minX);
const scaledY = minY + ((area.y + area.height - point.y) / area.height) * (maxY - minY);
return {
x: unscalePlotValue(scaledX, domain.xScale),
y: unscalePlotValue(scaledY, domain.yScale),
};
};
const clampViewPoint = (point) => {
const area = plotArea();
return {
x: Math.min(area.x + area.width, Math.max(area.x, point.x)),
y: Math.min(area.y + area.height, Math.max(area.y, point.y)),
};
};
const getSvgPoint = (event) => {
const svg = plotEditorElements?.svg;
if (!svg) return null;
if (svg.createSVGPoint && svg.getScreenCTM()) {
const point = svg.createSVGPoint();
point.x = event.clientX;
point.y = event.clientY;
return point.matrixTransform(svg.getScreenCTM().inverse());
}
const rect = svg.getBoundingClientRect();
return {
x: ((event.clientX - rect.left) / rect.width) * PLOT_FRAME.width,
y: ((event.clientY - rect.top) / rect.height) * PLOT_FRAME.height,
};
};
const makeTicks = (min, max, count = 5, scale = "linear") => {
if (!Number.isFinite(min) || !Number.isFinite(max) || min === max) return [min || 0];
if (scale === "log" && min > 0 && max > 0) {
const minLog = Math.log10(min);
const maxLog = Math.log10(max);
return Array.from({ length: count }, (_, index) => 10 ** (minLog + ((maxLog - minLog) * index) / (count - 1)));
}
return Array.from({ length: count }, (_, index) => min + ((max - min) * index) / (count - 1));
};
const formatTick = (value) => {
if (!Number.isFinite(value)) return "";
const abs = Math.abs(value);
if ((abs >= 10000 || (abs > 0 && abs < 0.001))) return value.toExponential(2);
return formatPlotNumber(value);
};
const updateInteractiveCsv = () => {
if (!plotEditorState || !csvOutput) return;
csvOutput.value = serializePlotCsv(plotEditorState);
};
const addPointToActiveCurve = (viewPoint) => {
const state = plotEditorState;
if (!state) return;
const curve = state.curves[state.activeCurveIndex];
if (!curve) return;
const point = viewToData(clampViewPoint(viewPoint), state.domain);
curve.points.push(point);
curve.points.sort((a, b) => a.x - b.x);
state.selected = {
curveIndex: state.activeCurveIndex,
pointIndex: curve.points.indexOf(point),
};
updateInteractiveCsv();
redrawPlotEditor();
};
const removeSelectedPoint = () => {
const state = plotEditorState;
const selected = state?.selected;
if (!selected) return;
const curve = state.curves[selected.curveIndex];
if (!curve || !curve.points[selected.pointIndex]) return;
curve.points.splice(selected.pointIndex, 1);
state.selected = null;
updateInteractiveCsv();
redrawPlotEditor();
};
const resetInteractivePlot = () => {
if (!plotEditorState?.originalCsv) return;
const resetState = parsePlotCsv(plotEditorState.originalCsv, plotEditorState.originalPython || "");
if (!resetState) return;
plotEditorState.curves = resetState.curves;
plotEditorState.activeCurveIndex = 0;
plotEditorState.selected = null;
plotEditorState.mode = "select";
plotEditorState.style = resetState.style;
plotEditorState.domain = resetState.domain;
updateInteractiveCsv();
setupCurveSelector();
redrawPlotEditor();
};
const setupCurveSelector = () => {
if (!plotEditorState || !plotEditorElements?.curveSelect) return;
plotEditorElements.curveSelect.innerHTML = plotEditorState.curves
.map((curve, index) => ``)
.join("");
plotEditorElements.curveSelect.value = String(plotEditorState.activeCurveIndex);
};
const updatePlotToolbarState = () => {
const hasSelection = Boolean(plotEditorState?.selected);
if (plotEditorElements?.removeButton) {
plotEditorElements.removeButton.disabled = !hasSelection;
}
if (plotEditorElements?.removeHint) {
plotEditorElements.removeHint.classList.toggle("hidden", hasSelection);
}
if (plotEditorElements?.addButton) {
plotEditorElements.addButton.classList.toggle("active", plotEditorState?.mode === "add");
}
};
const isViewPointFinite = (point) => Number.isFinite(point.x) && Number.isFinite(point.y);
const markerSvg = (view, curveStyle, curveIndex, pointIndex, isSelected) => {
const marker = curveStyle.marker || "";
const color = curveStyle.color;
const size = isSelected ? curveStyle.markerSize + 2 : curveStyle.markerSize;
const half = size / 2;
const common = `class="plot-point${isSelected ? " selected" : ""}" data-point="true" data-curve="${curveIndex}" data-index="${pointIndex}" transform="translate(${view.x} ${view.y})"`;
const shapeClass = marker ? "plot-marker-shape" : "plot-marker-shape plot-edit-handle";
if (["x", "+", "1", "2", "3", "4"].includes(marker)) {
const plus = marker === "+";
const diagonal = marker === "x";
const lines = [];
if (plus || ["1", "2"].includes(marker)) {
lines.push(``);
}
if (plus || ["3", "4"].includes(marker)) {
lines.push(``);
}
if (diagonal || !lines.length) {
lines.push(
``,
``
);
}
return `${lines.join("")}`;
}
if (["s", "p", "h", "H"].includes(marker)) {
return ``;
}
if (["D", "d"].includes(marker)) {
return ``;
}
if (["^", "v", "<", ">"].includes(marker)) {
const points = {
"^": `0,${-half} ${half},${half} ${-half},${half}`,
"v": `${-half},${-half} ${half},${-half} 0,${half}`,
"<": `${half},${-half} ${half},${half} ${-half},0`,
">": `${-half},${-half} ${-half},${half} ${half},0`,
}[marker];
return ``;
}
if (marker === "*") {
const outer = size;
const inner = size * 0.42;
const points = Array.from({ length: 10 }, (_, index) => {
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / 10;
const radius = index % 2 ? inner : outer;
return `${Math.cos(angle) * radius},${Math.sin(angle) * radius}`;
}).join(" ");
return ``;
}
const radius = marker === "." ? Math.max(2.5, size * 0.42) : Math.max(3, size);
return ``;
};
const redrawPlotEditor = () => {
if (manualImproveState) {
if (manualImproveState.calibration) {
drawManualOverlayPlot();
} else {
drawManualCalibrationPoints();
}
return;
}
drawInteractivePlot();
};
const drawInteractivePlot = () => {
const state = plotEditorState;
const svg = plotEditorElements?.svg;
if (!state || !svg) return;
const area = plotArea();
const style = state.style || {};
const xTicks = makeTicks(state.domain.minX, state.domain.maxX, 5, state.domain.xScale);
const yTicks = makeTicks(state.domain.minY, state.domain.maxY, 5, state.domain.yScale);
const activeCurve = state.curves[state.activeCurveIndex];
const xLabel = style.xLabel || activeCurve?.xHeader || state.curves[0]?.xHeader || "x";
const yLabel = style.yLabel || activeCurve?.yHeader || state.curves[0]?.yHeader || "y";
const selected = state.selected;
const clipId = "plot-editor-clip";
const textColor = style.textColor || "#111827";
const axisColor = style.axisColor || "#111827";
const gridColor = style.gridColor || "#d1d5db";
const parts = [
``,
``,
``,
];
if (style.title) {
parts.push(`${escapeHtml(style.title)}`);
}
for (const tick of xTicks) {
const view = dataToView({ x: tick, y: state.domain.minY }, state.domain);
if (!isViewPointFinite(view)) continue;
if (style.grid) {
parts.push(``);
}
parts.push(
`${escapeHtml(formatTick(tick))}`
);
}
for (const tick of yTicks) {
const view = dataToView({ x: state.domain.minX, y: tick }, state.domain);
if (!isViewPointFinite(view)) continue;
if (style.grid) {
parts.push(``);
}
parts.push(
`${escapeHtml(formatTick(tick))}`
);
}
parts.push(
``,
``,
`${escapeHtml(xLabel)}`,
`${escapeHtml(yLabel)}`
);
parts.push(``);
state.curves.forEach((curve, curveIndex) => {
const curveStyle = style.curves?.[curveIndex] || {};
const color = curveStyle.color || PLOT_COLORS[curveIndex % PLOT_COLORS.length];
const views = curve.points.map((point) => dataToView(point, state.domain));
const visibleViews = views.filter(isViewPointFinite);
let segmentOpen = false;
const path = views.map((point) => {
if (!isViewPointFinite(point)) {
segmentOpen = false;
return "";
}
const command = segmentOpen ? "L" : "M";
segmentOpen = true;
return `${command} ${point.x} ${point.y}`;
}).filter(Boolean).join(" ");
if (path && curveStyle.connect !== false && curveStyle.lineStyle !== "none") {
const dash = LINE_DASHES[curveStyle.lineStyle] ? ` stroke-dasharray="${LINE_DASHES[curveStyle.lineStyle]}"` : "";
parts.push(``);
}
curve.points.forEach((_point, pointIndex) => {
const view = views[pointIndex];
if (!isViewPointFinite(view)) return;
const isSelected =
selected?.curveIndex === curveIndex && selected?.pointIndex === pointIndex;
const markerStyle = {
color,
marker: curveStyle.marker || (curveStyle.connect === false ? "o" : ""),
markerSize: curveStyle.markerSize || 6,
};
parts.push(markerSvg(view, markerStyle, curveIndex, pointIndex, isSelected));
});
curve.visiblePointCount = visibleViews.length;
});
parts.push("");
const legendItems = state.curves.slice(0, 6);
legendItems.forEach((curve, index) => {
const y = area.y + 18 + index * 22;
const curveStyle = style.curves?.[index] || {};
const color = curveStyle.color || PLOT_COLORS[index % PLOT_COLORS.length];
parts.push(
``,
`${escapeHtml(getCurveName(curve, index))}`
);
});
svg.innerHTML = parts.join("");
svg.querySelectorAll("[data-point='true']").forEach((pointEl) => {
pointEl.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
const curveIndex = Number(pointEl.dataset.curve);
const pointIndex = Number(pointEl.dataset.index);
state.activeCurveIndex = curveIndex;
state.selected = { curveIndex, pointIndex };
state.mode = "select";
plotEditorDrag = { curveIndex, pointIndex };
if (plotEditorElements?.curveSelect) {
plotEditorElements.curveSelect.value = String(curveIndex);
}
svg.setPointerCapture?.(event.pointerId);
redrawPlotEditor();
});
});
updatePlotToolbarState();
};
const handlePlotPointerMove = (event) => {
if (!plotEditorDrag || !plotEditorState) return;
const curve = plotEditorState.curves[plotEditorDrag.curveIndex];
const point = curve?.points[plotEditorDrag.pointIndex];
const svgPoint = getSvgPoint(event);
if (!point || !svgPoint) return;
const next = viewToData(clampViewPoint(svgPoint), plotEditorState.domain);
point.x = next.x;
point.y = next.y;
updateInteractiveCsv();
redrawPlotEditor();
};
const handlePlotPointerUp = (event) => {
if (plotEditorElements?.svg && plotEditorDrag) {
plotEditorElements.svg.releasePointerCapture?.(event.pointerId);
}
plotEditorDrag = null;
};
const handlePlotKeyDown = (event) => {
if (!plotEditorState) return;
const tagName = event.target?.tagName;
if (["INPUT", "TEXTAREA", "SELECT"].includes(tagName)) return;
if (event.key === "Delete" || event.key === "Backspace") {
if (plotEditorState.selected) {
event.preventDefault();
removeSelectedPoint();
}
}
};
const MANUAL_POINT_LABELS = ["x1", "x2", "y1", "y2"];
const MANUAL_MAGNIFIER_SIZE = 150;
const MANUAL_MAGNIFIER_ZOOM = 3.2;
const resetPlotEditorRuntime = () => {
document.removeEventListener("keydown", handlePlotKeyDown);
plotEditorState = null;
plotEditorElements = null;
plotEditorDrag = null;
};
const closeManualModal = () => {
document
.querySelectorAll(".manual-modal-backdrop, .manual-value-popover")
.forEach((modal) => modal.remove());
};
const clearManualImprove = () => {
closeManualModal();
resetPlotEditorRuntime();
if (manualImproveState?.resizeObserver) {
manualImproveState.resizeObserver.disconnect();
}
manualImproveState = null;
plotLayout?.classList.remove("manual-mode");
originalPane?.classList.remove("manual-pane");
replotPane?.classList.remove("hidden");
preview?.classList.remove("manual-preview");
preview?.querySelector(".manual-overlay-svg")?.remove();
preview?.querySelector(".manual-magnifier")?.remove();
originalPane?.querySelector(".manual-plot-toolbar")?.remove();
};
const getPreviewImage = () => preview?.querySelector("img");
const getManualImageRect = () => {
const containerRect = preview?.getBoundingClientRect();
const img = getPreviewImage();
if (!containerRect || !img) return null;
const naturalWidth = img.naturalWidth || 0;
const naturalHeight = img.naturalHeight || 0;
if (!naturalWidth || !naturalHeight || !containerRect.width || !containerRect.height) {
return containerRect;
}
const imageRatio = naturalWidth / naturalHeight;
const containerRatio = containerRect.width / containerRect.height;
let width = containerRect.width;
let height = containerRect.height;
let left = containerRect.left;
let top = containerRect.top;
if (containerRatio > imageRatio) {
height = containerRect.height;
width = height * imageRatio;
left += (containerRect.width - width) / 2;
} else {
width = containerRect.width;
height = width / imageRatio;
top += (containerRect.height - height) / 2;
}
return { left, top, width, height, right: left + width, bottom: top + height };
};
const syncManualOverlayBox = () => {
const svg = manualImproveState?.overlaySvg;
const rect = preview?.getBoundingClientRect();
if (!svg || !rect) return;
svg.setAttribute("viewBox", `0 0 ${Math.max(1, rect.width)} ${Math.max(1, rect.height)}`);
};
const manualNormToView = (norm) => {
const imageRect = getManualImageRect();
const previewRect = preview?.getBoundingClientRect();
if (!imageRect || !previewRect) return null;
return {
x: imageRect.left - previewRect.left + norm.x * imageRect.width,
y: imageRect.top - previewRect.top + norm.y * imageRect.height,
};
};
const eventToManualNorm = (event, { clamp = false } = {}) => {
const imageRect = getManualImageRect();
if (!imageRect || !imageRect.width || !imageRect.height) return null;
let x = (event.clientX - imageRect.left) / imageRect.width;
let y = (event.clientY - imageRect.top) / imageRect.height;
if (!clamp && (x < 0 || x > 1 || y < 0 || y > 1)) return null;
if (clamp) {
x = Math.min(1, Math.max(0, x));
y = Math.min(1, Math.max(0, y));
}
return { x, y };
};
const hideManualMagnifier = () => {
manualImproveState?.magnifierEl?.classList.remove("visible");
manualImproveState?.magnifierEl?.classList.remove("editing");
manualImproveState?.overlaySvg?.classList.remove("magnifying");
if (manualImproveState?.magnifierPlotSvg) {
manualImproveState.magnifierPlotSvg.innerHTML = "";
}
};
const escapeCssUrl = (value) =>
String(value || "").replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
const drawManualMagnifierPlot = (centerNorm, imageRect) => {
const state = plotEditorState;
const svg = manualImproveState?.magnifierPlotSvg;
if (!state || !svg || manualImproveState?.phase !== "editing") {
if (svg) svg.innerHTML = "";
return;
}
const toMagnifierView = (point) => {
const norm = dataToManualNorm(point);
if (!norm || !Number.isFinite(norm.x) || !Number.isFinite(norm.y)) return null;
return {
x:
MANUAL_MAGNIFIER_SIZE / 2 +
(norm.x - centerNorm.x) * imageRect.width * MANUAL_MAGNIFIER_ZOOM,
y:
MANUAL_MAGNIFIER_SIZE / 2 +
(norm.y - centerNorm.y) * imageRect.height * MANUAL_MAGNIFIER_ZOOM,
};
};
const parts = [``];
state.curves.forEach((curve, curveIndex) => {
const curveStyle = state.style?.curves?.[curveIndex] || {};
const color = curveStyle.color || PLOT_COLORS[curveIndex % PLOT_COLORS.length];
const views = curve.points.map(toMagnifierView);
let segmentOpen = false;
const path = views
.map((point) => {
if (!point || !isViewPointFinite(point)) {
segmentOpen = false;
return "";
}
const command = segmentOpen ? "L" : "M";
segmentOpen = true;
return `${command} ${point.x} ${point.y}`;
})
.filter(Boolean)
.join(" ");
if (path && curveStyle.connect !== false && curveStyle.lineStyle !== "none") {
const dash = LINE_DASHES[curveStyle.lineStyle] ? ` stroke-dasharray="${LINE_DASHES[curveStyle.lineStyle]}"` : "";
const width = Math.max(2, Math.min(8, (curveStyle.lineWidth || 2) * 1.8));
parts.push(``);
}
curve.points.forEach((_point, pointIndex) => {
const view = views[pointIndex];
if (!view || !isViewPointFinite(view)) return;
const isSelected =
state.selected?.curveIndex === curveIndex && state.selected?.pointIndex === pointIndex;
const markerStyle = {
color,
marker: curveStyle.marker || (curveStyle.connect === false ? "o" : ""),
markerSize: Math.max(6, Math.min(14, (curveStyle.markerSize || 6) * 1.6)),
};
parts.push(markerSvg(view, markerStyle, curveIndex, pointIndex, isSelected));
});
});
parts.push("");
svg.setAttribute("viewBox", `0 0 ${MANUAL_MAGNIFIER_SIZE} ${MANUAL_MAGNIFIER_SIZE}`);
svg.innerHTML = parts.join("");
};
const updateManualMagnifier = (event) => {
const state = manualImproveState;
const magnifier = state?.magnifierEl;
const canMagnify = state?.phase === "calibrating" || state?.phase === "editing";
if (!state || !magnifier || !canMagnify) {
hideManualMagnifier();
return;
}
const norm = eventToManualNorm(event);
const img = getPreviewImage();
const imageRect = getManualImageRect();
const previewRect = preview?.getBoundingClientRect();
if (!norm || !img || !imageRect || !previewRect) {
hideManualMagnifier();
return;
}
const cursorX = event.clientX - previewRect.left;
const cursorY = event.clientY - previewRect.top;
const left = cursorX - MANUAL_MAGNIFIER_SIZE / 2;
const top = cursorY - MANUAL_MAGNIFIER_SIZE / 2;
const source = img.currentSrc || img.src;
const imageBackground = `url("${escapeCssUrl(source)}")`;
const imageSize = `${imageRect.width * MANUAL_MAGNIFIER_ZOOM}px ${imageRect.height * MANUAL_MAGNIFIER_ZOOM}px`;
const imagePosition =
`${MANUAL_MAGNIFIER_SIZE / 2 - norm.x * imageRect.width * MANUAL_MAGNIFIER_ZOOM}px ` +
`${MANUAL_MAGNIFIER_SIZE / 2 - norm.y * imageRect.height * MANUAL_MAGNIFIER_ZOOM}px`;
const isEditing = state.phase === "editing";
magnifier.style.left = `${left}px`;
magnifier.style.top = `${top}px`;
magnifier.style.backgroundImage = isEditing
? `linear-gradient(rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0.58)), ${imageBackground}`
: imageBackground;
magnifier.style.backgroundSize = isEditing
? `100% 100%, ${imageSize}`
: imageSize;
magnifier.style.backgroundPosition = isEditing
? `0 0, ${imagePosition}`
: imagePosition;
magnifier.classList.toggle("editing", isEditing);
state.overlaySvg?.classList.add("magnifying");
drawManualMagnifierPlot(norm, imageRect);
magnifier.classList.add("visible");
};
const dataToManualNorm = (point) => {
const calibration = manualImproveState?.calibration;
if (!calibration) return null;
const xScale = plotEditorState?.domain?.xScale || "linear";
const yScale = plotEditorState?.domain?.yScale || "linear";
const x1 = scalePlotValue(calibration.x1.value, xScale);
const x2 = scalePlotValue(calibration.x2.value, xScale);
const y1 = scalePlotValue(calibration.y1.value, yScale);
const y2 = scalePlotValue(calibration.y2.value, yScale);
const xValue = scalePlotValue(point.x, xScale);
const yValue = scalePlotValue(point.y, yScale);
const xDenominator = x2 - x1;
const yDenominator = y2 - y1;
if (
!Number.isFinite(xValue) ||
!Number.isFinite(yValue) ||
!Number.isFinite(xDenominator) ||
!Number.isFinite(yDenominator) ||
xDenominator === 0 ||
yDenominator === 0
) {
return null;
}
return {
x:
calibration.x1.norm.x +
((xValue - x1) / xDenominator) *
(calibration.x2.norm.x - calibration.x1.norm.x),
y:
calibration.y1.norm.y +
((yValue - y1) / yDenominator) *
(calibration.y2.norm.y - calibration.y1.norm.y),
};
};
const manualNormToData = (norm) => {
const calibration = manualImproveState?.calibration;
if (!calibration) return null;
const xScale = plotEditorState?.domain?.xScale || "linear";
const yScale = plotEditorState?.domain?.yScale || "linear";
const x1 = scalePlotValue(calibration.x1.value, xScale);
const x2 = scalePlotValue(calibration.x2.value, xScale);
const y1 = scalePlotValue(calibration.y1.value, yScale);
const y2 = scalePlotValue(calibration.y2.value, yScale);
const xPixelDenominator = calibration.x2.norm.x - calibration.x1.norm.x;
const yPixelDenominator = calibration.y2.norm.y - calibration.y1.norm.y;
if (
!Number.isFinite(xPixelDenominator) ||
!Number.isFinite(yPixelDenominator) ||
xPixelDenominator === 0 ||
yPixelDenominator === 0
) {
return null;
}
const scaledX =
x1 +
((norm.x - calibration.x1.norm.x) / xPixelDenominator) * (x2 - x1);
const scaledY =
y1 +
((norm.y - calibration.y1.norm.y) / yPixelDenominator) * (y2 - y1);
const x = unscalePlotValue(scaledX, xScale);
const y = unscalePlotValue(scaledY, yScale);
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
return { x, y };
};
const manualDataToView = (point) => {
const norm = dataToManualNorm(point);
if (!norm || !Number.isFinite(norm.x) || !Number.isFinite(norm.y)) return null;
return manualNormToView(norm);
};
const getManualImageViewRect = () => {
const imageRect = getManualImageRect();
const previewRect = preview?.getBoundingClientRect();
if (!imageRect || !previewRect) return null;
return {
x: imageRect.left - previewRect.left,
y: imageRect.top - previewRect.top,
width: imageRect.width,
height: imageRect.height,
};
};
const manualCalibrationMarkerMarkup = () => {
const state = manualImproveState;
if (!state) return "";
const previewRect = preview?.getBoundingClientRect();
const labelWidth = 34;
const labelHeight = 20;
const margin = 6;
const placedLabels = [];
const overlaps = (a, b) =>
a.x < b.x + b.width + margin &&
a.x + a.width + margin > b.x &&
a.y < b.y + b.height + margin &&
a.y + a.height + margin > b.y;
const clampLabelBox = (box) => {
if (!previewRect?.width || !previewRect?.height) return box;
return {
...box,
x: Math.min(previewRect.width - box.width - margin, Math.max(margin, box.x)),
y: Math.min(previewRect.height - box.height - margin, Math.max(margin, box.y)),
};
};
const candidateOffsets = [
{ x: 12, y: -30 },
{ x: 12, y: 14 },
{ x: -46, y: -30 },
{ x: -46, y: 14 },
{ x: -16, y: -48 },
{ x: -16, y: 30 },
{ x: 20, y: -8 },
{ x: -54, y: -8 },
];
return state.calibrationPoints
.map((point, pointIndex) => {
const view = manualNormToView(point.norm);
if (!view) return "";
const preferred = candidateOffsets
.slice(pointIndex)
.concat(candidateOffsets.slice(0, pointIndex));
const labelBox =
preferred
.map((offset) =>
clampLabelBox({
x: view.x + offset.x,
y: view.y + offset.y,
width: labelWidth,
height: labelHeight,
})
)
.find((box) => !placedLabels.some((placed) => overlaps(box, placed))) ||
clampLabelBox({
x: view.x + 12,
y: view.y + 14 + pointIndex * (labelHeight + 4),
width: labelWidth,
height: labelHeight,
});
placedLabels.push(labelBox);
return `
${escapeHtml(point.label)}
`;
})
.join("");
};
const drawManualCalibrationPoints = () => {
const svg = manualImproveState?.overlaySvg;
if (!svg) return;
syncManualOverlayBox();
const imageRect = getManualImageViewRect();
const nextLabel = MANUAL_POINT_LABELS[manualImproveState.calibrationPoints.length];
const parts = [];
if (imageRect) {
parts.push(
``
);
}
if (nextLabel) {
parts.push(`Click ${escapeHtml(nextLabel)}`);
}
parts.push(manualCalibrationMarkerMarkup());
svg.innerHTML = parts.join("");
};
const drawManualOverlayPlot = () => {
const state = plotEditorState;
const svg = manualImproveState?.overlaySvg;
if (!state || !svg || !manualImproveState?.calibration) return;
syncManualOverlayBox();
const imageRect = getManualImageViewRect();
const clipId = "manual-overlay-clip";
const selected = state.selected;
const parts = [];
if (imageRect) {
parts.push(
``,
``,
``
);
}
parts.push(``);
state.curves.forEach((curve, curveIndex) => {
const curveStyle = state.style?.curves?.[curveIndex] || {};
const color = curveStyle.color || PLOT_COLORS[curveIndex % PLOT_COLORS.length];
const views = curve.points.map(manualDataToView);
let segmentOpen = false;
const path = views
.map((point) => {
if (!point || !isViewPointFinite(point)) {
segmentOpen = false;
return "";
}
const command = segmentOpen ? "L" : "M";
segmentOpen = true;
return `${command} ${point.x} ${point.y}`;
})
.filter(Boolean)
.join(" ");
if (path && curveStyle.connect !== false && curveStyle.lineStyle !== "none") {
const dash = LINE_DASHES[curveStyle.lineStyle] ? ` stroke-dasharray="${LINE_DASHES[curveStyle.lineStyle]}"` : "";
parts.push(``);
}
curve.points.forEach((_point, pointIndex) => {
const view = views[pointIndex];
if (!view || !isViewPointFinite(view)) return;
const isSelected =
selected?.curveIndex === curveIndex && selected?.pointIndex === pointIndex;
const markerStyle = {
color,
marker: curveStyle.marker || (curveStyle.connect === false ? "o" : ""),
markerSize: curveStyle.markerSize || 6,
};
parts.push(markerSvg(view, markerStyle, curveIndex, pointIndex, isSelected));
});
});
parts.push("", manualCalibrationMarkerMarkup());
svg.innerHTML = parts.join("");
svg.querySelectorAll("[data-point='true']").forEach((pointEl) => {
pointEl.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
const curveIndex = Number(pointEl.dataset.curve);
const pointIndex = Number(pointEl.dataset.index);
state.activeCurveIndex = curveIndex;
state.selected = { curveIndex, pointIndex };
state.mode = "select";
plotEditorDrag = { curveIndex, pointIndex };
if (plotEditorElements?.curveSelect) {
plotEditorElements.curveSelect.value = String(curveIndex);
}
svg.setPointerCapture?.(event.pointerId);
redrawPlotEditor();
updateManualMagnifier(event);
});
});
updatePlotToolbarState();
};
const addManualPointFromNorm = (norm) => {
const state = plotEditorState;
const point = manualNormToData(norm);
const curve = state?.curves[state.activeCurveIndex];
if (!point || !curve) return;
curve.points.push(point);
curve.points.sort((a, b) => a.x - b.x);
state.selected = {
curveIndex: state.activeCurveIndex,
pointIndex: curve.points.indexOf(point),
};
updateInteractiveCsv();
redrawPlotEditor();
};
const getManualCalibrationPoint = (label) =>
manualImproveState?.calibrationPoints.find((point) => point.label === label);
const nextManualCalibrationLabel = () =>
MANUAL_POINT_LABELS.find((label) => !getManualCalibrationPoint(label));
const getManualDefaultValue = (label) => {
const domain = plotEditorState?.domain || {};
if (label === "x1") return domain.minX;
if (label === "x2") return domain.maxX;
if (label === "y1") return domain.minY;
if (label === "y2") return domain.maxY;
return "";
};
const validateManualReferenceClick = (label, norm) => {
if (label === "x2") {
const x1 = getManualCalibrationPoint("x1");
if (x1 && Math.abs(norm.x - x1.norm.x) < 0.0001) {
return "Click x2 farther away horizontally from x1.";
}
}
if (label === "y2") {
const y1 = getManualCalibrationPoint("y1");
if (y1 && Math.abs(norm.y - y1.norm.y) < 0.0001) {
return "Click y2 farther away vertically from y1.";
}
}
return "";
};
const validateManualReferenceValue = (label, value) => {
const isX = label.startsWith("x");
const scale = isX
? plotEditorState?.domain?.xScale || "linear"
: plotEditorState?.domain?.yScale || "linear";
const scaled = scalePlotValue(value, scale);
if (!Number.isFinite(value) || !Number.isFinite(scaled)) {
return scale === "log"
? "Use a positive finite value for a log axis."
: "Use a finite numeric value.";
}
if (label === "x2") {
const x1 = getManualCalibrationPoint("x1");
const x1Scaled = scalePlotValue(x1?.value, scale);
if (Number.isFinite(x1Scaled) && scaled === x1Scaled) {
return "x2 must be different from x1.";
}
}
if (label === "y2") {
const y1 = getManualCalibrationPoint("y1");
const y1Scaled = scalePlotValue(y1?.value, scale);
if (Number.isFinite(y1Scaled) && scaled === y1Scaled) {
return "y2 must be different from y1.";
}
}
return "";
};
const finalizeManualCalibration = () => {
const pointByLabel = Object.fromEntries(
(manualImproveState?.calibrationPoints || []).map((point) => [point.label, point])
);
if (!MANUAL_POINT_LABELS.every((label) => Number.isFinite(pointByLabel[label]?.value))) {
return false;
}
manualImproveState.calibration = {
x1: { norm: pointByLabel.x1.norm, value: pointByLabel.x1.value },
x2: { norm: pointByLabel.x2.norm, value: pointByLabel.x2.value },
y1: { norm: pointByLabel.y1.norm, value: pointByLabel.y1.value },
y2: { norm: pointByLabel.y2.norm, value: pointByLabel.y2.value },
};
manualImproveState.phase = "editing";
manualImproveState.toolbar?.classList.remove("hidden");
manualImproveState.overlaySvg?.classList.add("editing");
hideManualMagnifier();
closeManualModal();
updateInteractiveCsv();
redrawPlotEditor();
setStatus("Manual overlay ready.", "success");
return true;
};
const handleManualOverlayPointerDown = (event) => {
const state = manualImproveState;
if (!state) return;
if (state.phase === "calibrating") {
const norm = eventToManualNorm(event);
if (!norm) return;
event.preventDefault();
const label = nextManualCalibrationLabel();
if (!label) return;
const clickError = validateManualReferenceClick(label, norm);
if (clickError) {
setStatus(clickError, "error");
return;
}
hideManualMagnifier();
const point = { label, norm, value: null };
state.calibrationPoints.push(point);
state.phase = "value";
drawManualCalibrationPoints();
showManualValueModal(point);
return;
}
if (!state.calibration || event.target?.dataset?.point === "true") return;
if (plotEditorState?.mode !== "add") return;
const norm = eventToManualNorm(event);
if (!norm) return;
event.preventDefault();
addManualPointFromNorm(norm);
};
const handleManualPointerMove = (event) => {
if (!manualImproveState?.calibration || !plotEditorDrag || !plotEditorState) {
updateManualMagnifier(event);
return;
}
const curve = plotEditorState.curves[plotEditorDrag.curveIndex];
const point = curve?.points[plotEditorDrag.pointIndex];
const norm = eventToManualNorm(event, { clamp: true });
const next = norm ? manualNormToData(norm) : null;
if (!point || !next) {
updateManualMagnifier(event);
return;
}
point.x = next.x;
point.y = next.y;
updateInteractiveCsv();
redrawPlotEditor();
updateManualMagnifier(event);
};
const handleManualPointerUp = (event) => {
if (manualImproveState?.overlaySvg && plotEditorDrag) {
manualImproveState.overlaySvg.releasePointerCapture?.(event.pointerId);
}
plotEditorDrag = null;
};
const beginManualPointSelection = () => {
if (!manualImproveState) return;
manualImproveState.phase = "calibrating";
manualImproveState.calibrationPoints = [];
manualImproveState.calibration = null;
manualImproveState.toolbar?.classList.add("hidden");
manualImproveState.overlaySvg?.classList.remove("editing");
hideManualMagnifier();
drawManualCalibrationPoints();
setStatus("Click x1.", "neutral");
};
const showManualInstructionModal = () => {
closeManualModal();
const modal = document.createElement("div");
modal.className = "manual-modal-backdrop";
modal.innerHTML = `
Manual calibration
Click x1, x2, y1, and y2 on the original plot. After each click, enter that point's axis value.
`;
document.body.appendChild(modal);
modal.querySelector("[data-manual-action='cancel']")?.addEventListener("click", clearManualImprove);
modal.querySelector("[data-manual-action='start']")?.addEventListener("click", () => {
closeManualModal();
beginManualPointSelection();
});
};
const positionManualValuePopover = (popover, point) => {
const view = manualNormToView(point?.norm);
const previewRect = preview?.getBoundingClientRect();
if (!popover || !view || !previewRect) return;
const margin = 10;
const gap = 14;
const width = popover.offsetWidth || 220;
const height = popover.offsetHeight || 160;
let left = view.x + gap;
let top = view.y - height / 2;
if (left + width > previewRect.width - margin) {
left = view.x - width - gap;
}
if (top + height > previewRect.height - margin) {
top = previewRect.height - height - margin;
}
if (top < margin) {
top = margin;
}
if (left < margin) {
left = margin;
}
popover.style.left = `${left}px`;
popover.style.top = `${top}px`;
};
const showManualValueModal = (point) => {
closeManualModal();
const label = point?.label || "";
const defaultValue = getManualDefaultValue(label);
const isLastReference = label === MANUAL_POINT_LABELS[MANUAL_POINT_LABELS.length - 1];
const modal = document.createElement("div");
modal.className = "manual-value-popover";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "false");
modal.setAttribute("aria-labelledby", "manualValuesTitle");
modal.innerHTML = `
${escapeHtml(label)} value
`;
preview?.appendChild(modal);
positionManualValuePopover(modal, point);
const form = modal.querySelector(".calibration-form");
const errorEl = modal.querySelector(".manual-modal-error");
const input = modal.querySelector("input[name='value']");
modal.querySelector("[data-manual-action='cancel']")?.addEventListener("click", clearManualImprove);
input?.focus();
input?.select();
form?.addEventListener("submit", (event) => {
event.preventDefault();
const formData = new FormData(form);
const value = Number(formData.get("value"));
const valueError = validateManualReferenceValue(label, value);
if (valueError) {
errorEl.textContent = valueError;
return;
}
point.value = value;
if (MANUAL_POINT_LABELS.every((item) => Number.isFinite(getManualCalibrationPoint(item)?.value))) {
finalizeManualCalibration();
return;
}
const nextLabel = nextManualCalibrationLabel();
manualImproveState.phase = "calibrating";
closeManualModal();
drawManualCalibrationPoints();
setStatus(`Click ${nextLabel}.`, "neutral");
});
};
const createManualToolbar = () => {
originalPane?.querySelector(".manual-plot-toolbar")?.remove();
const toolbar = document.createElement("div");
toolbar.className = "plot-toolbar manual-plot-toolbar hidden";
toolbar.innerHTML = `
select point to be removed
${latestPlotOutput.replotUrl ? `PNG` : ""}
`;
originalPane?.appendChild(toolbar);
return toolbar;
};
const startManualImprove = () => {
if (!latestPlotOutput.csvText && !latestPlotOutput.pythonCode) {
setStatus("Run extraction before improving manually.", "error");
return;
}
if (!getPreviewImage()) {
setStatus("Load an image before improving manually.", "error");
return;
}
clearManualImprove();
runBtn?.classList.add("hidden");
const parsed =
parsePlotCsv(latestPlotOutput.csvText, latestPlotOutput.pythonCode) ||
parsePlotPython(latestPlotOutput.pythonCode, latestPlotOutput.csvText) ||
createBlankPlotEditorState(latestPlotOutput.csvText, latestPlotOutput.pythonCode);
plotEditorState = parsed;
plotEditorDrag = null;
plotLayout?.classList.add("manual-mode");
originalPane?.classList.add("manual-pane");
replotPane?.classList.add("hidden");
preview?.classList.add("manual-preview");
const overlaySvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
overlaySvg.classList.add("manual-overlay-svg");
overlaySvg.setAttribute("aria-label", "Manual plot calibration overlay");
overlaySvg.setAttribute("role", "img");
preview.appendChild(overlaySvg);
const magnifierEl = document.createElement("div");
magnifierEl.className = "manual-magnifier";
const magnifierPlotSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
magnifierPlotSvg.classList.add("manual-magnifier-plot");
magnifierEl.appendChild(magnifierPlotSvg);
preview.appendChild(magnifierEl);
const toolbar = createManualToolbar();
manualImproveState = {
phase: "idle",
calibrationPoints: [],
calibration: null,
overlaySvg,
magnifierEl,
magnifierPlotSvg,
toolbar,
resizeObserver: null,
};
plotEditorElements = {
curveSelect: toolbar.querySelector(".plot-curve-select"),
addButton: toolbar.querySelector("[data-plot-action='add']"),
removeButton: toolbar.querySelector("[data-plot-action='remove']"),
removeHint: toolbar.querySelector(".plot-remove-hint"),
resetButton: toolbar.querySelector("[data-plot-action='reset']"),
svg: overlaySvg,
};
setupCurveSelector();
plotEditorElements.curveSelect?.addEventListener("change", (event) => {
plotEditorState.activeCurveIndex = Number(event.target.value) || 0;
plotEditorState.selected = null;
redrawPlotEditor();
});
plotEditorElements.addButton?.addEventListener("click", () => {
plotEditorState.mode = plotEditorState.mode === "add" ? "select" : "add";
redrawPlotEditor();
});
plotEditorElements.removeButton?.addEventListener("click", removeSelectedPoint);
plotEditorElements.resetButton?.addEventListener("click", resetInteractivePlot);
overlaySvg.addEventListener("pointerdown", handleManualOverlayPointerDown);
overlaySvg.addEventListener("pointermove", handleManualPointerMove);
overlaySvg.addEventListener("pointerup", handleManualPointerUp);
overlaySvg.addEventListener("pointercancel", handleManualPointerUp);
overlaySvg.addEventListener("pointerleave", hideManualMagnifier);
document.addEventListener("keydown", handlePlotKeyDown);
if (window.ResizeObserver) {
manualImproveState.resizeObserver = new ResizeObserver(redrawPlotEditor);
manualImproveState.resizeObserver.observe(preview);
}
updateInteractiveCsv();
drawManualCalibrationPoints();
showManualInstructionModal();
};
const renderInteractivePlot = (csvText, replotUrl, pythonCode = "") => {
const parsed =
parsePlotCsv(csvText, pythonCode) ||
parsePlotPython(pythonCode, csvText) ||
createBlankPlotEditorState(csvText, pythonCode);
plotEditorState = parsed;
plotEditorDrag = null;
replotPreview.classList.add("interactive");
replotPreview.innerHTML = `
`;
plotEditorElements = {
curveSelect: replotPreview.querySelector(".plot-curve-select"),
addButton: replotPreview.querySelector("[data-plot-action='add']"),
removeButton: replotPreview.querySelector("[data-plot-action='remove']"),
removeHint: replotPreview.querySelector(".plot-remove-hint"),
resetButton: replotPreview.querySelector("[data-plot-action='reset']"),
svg: replotPreview.querySelector(".plot-editor-svg"),
};
setupCurveSelector();
plotEditorElements.curveSelect?.addEventListener("change", (event) => {
plotEditorState.activeCurveIndex = Number(event.target.value) || 0;
plotEditorState.selected = null;
redrawPlotEditor();
});
plotEditorElements.addButton?.addEventListener("click", () => {
plotEditorState.mode = plotEditorState.mode === "add" ? "select" : "add";
redrawPlotEditor();
});
plotEditorElements.removeButton?.addEventListener("click", removeSelectedPoint);
plotEditorElements.resetButton?.addEventListener("click", resetInteractivePlot);
plotEditorElements.svg?.addEventListener("pointerdown", (event) => {
if (event.target?.dataset?.point === "true" || plotEditorState.mode !== "add") return;
const svgPoint = getSvgPoint(event);
if (!svgPoint) return;
const area = plotArea();
if (
svgPoint.x < area.x ||
svgPoint.x > area.x + area.width ||
svgPoint.y < area.y ||
svgPoint.y > area.y + area.height
) {
return;
}
event.preventDefault();
addPointToActiveCurve(svgPoint);
});
plotEditorElements.svg?.addEventListener("pointermove", handlePlotPointerMove);
plotEditorElements.svg?.addEventListener("pointerup", handlePlotPointerUp);
plotEditorElements.svg?.addEventListener("pointercancel", handlePlotPointerUp);
document.addEventListener("keydown", handlePlotKeyDown);
updateInteractiveCsv();
redrawPlotEditor();
return true;
};
const setReplotPreview = (urlOrText, csvText = "", pythonCode = "") => {
if (!replotPreview) return;
clearManualImprove();
resetPlotEditorRuntime();
latestPlotOutput = {
replotUrl: "",
csvText: "",
pythonCode: "",
};
setImproveManualVisible(false);
replotPreview.classList.remove("interactive", "static-replot");
if (!urlOrText) {
replotPreview.innerHTML =
"Plot recreated using the extracted data will appear hear once the extraction process is finished.
";
return;
}
if (isPlotImageUrl(urlOrText)) {
latestPlotOutput = {
replotUrl: urlOrText,
csvText,
pythonCode,
};
replotPreview.classList.add("static-replot");
replotPreview.innerHTML = `
`;
setImproveManualVisible(Boolean(csvText || pythonCode));
return;
}
replotPreview.innerHTML = "";
replotPreview.innerHTML = `${urlOrText}
`;
};
const acceptFile = (file) => {
if (!file) return;
const lowerName = file.name.toLowerCase();
const isZip =
lowerName.endsWith(".zip") ||
file.type === "application/zip" ||
file.type === "application/x-zip-compressed";
const isImage =
file.type.startsWith("image/") || /\.(png|jpe?g)$/i.test(lowerName);
if (!isImage && !isZip) {
setStatus("Please provide an image file.", "error");
return;
}
currentFile = file;
isZipUpload = isZip;
imageInput.files = createFileList(file);
runBtn?.classList.remove("hidden");
if (isZip) {
setZipPreview(file.name);
setReplotPreview(
"ZIP uploaded, no replot will be shown. A zipped result will be available for download."
);
} else {
setPreview(file);
setReplotPreview("");
}
preview.classList.remove("hidden");
if (zipDownload) zipDownload.innerHTML = "";
results.classList.remove("visible");
if (resultCards) resultCards.classList.remove("hidden");
csvOutput.value = "";
codeOutput.value = "";
validationEl.innerHTML = "";
setStatus("Image loaded.", "success");
};
const createFileList = (file) => {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
return dataTransfer.files;
};
setModelPlaceholder(MODEL_PLACEHOLDER_NO_KEY);
improveManualBtn?.addEventListener("click", startManualImprove);
const openInfoModal = () => {
if (!infoModal) return;
infoModal.classList.remove("hidden");
infoCloseBtn?.focus();
};
const closeInfoModal = () => {
if (!infoModal) return;
infoModal.classList.add("hidden");
infoBtn?.focus();
};
infoBtn?.addEventListener("click", openInfoModal);
infoCloseBtn?.addEventListener("click", closeInfoModal);
infoModal?.addEventListener("click", (event) => {
if (event.target === infoModal) {
closeInfoModal();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && infoModal && !infoModal.classList.contains("hidden")) {
closeInfoModal();
}
});
imageInput.addEventListener("change", (event) => {
const file = event.target.files[0];
acceptFile(file);
});
dropzone.addEventListener("dragover", (event) => {
event.preventDefault();
dropzone.classList.add("dragging");
});
dropzone.addEventListener("dragleave", () => {
dropzone.classList.remove("dragging");
});
dropzone.addEventListener("drop", (event) => {
event.preventDefault();
dropzone.classList.remove("dragging");
const file = event.dataTransfer.files[0];
acceptFile(file);
});
document.addEventListener("paste", (event) => {
const items = event.clipboardData?.items || [];
for (const item of items) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
acceptFile(file);
break;
}
}
});
apiKeyInput.addEventListener("input", () => {
const key = apiKeyInput.value.trim();
if (loadedModelsKey !== key) {
loadedModelsKey = "";
modelsLoaded = false;
setModelPlaceholder(key ? MODEL_PLACEHOLDER_READY : MODEL_PLACEHOLDER_NO_KEY);
}
});
apiKeyInput.addEventListener("blur", () => {
loadAvailableModels();
});
toggleKeyBtn.addEventListener("click", () => {
const isPassword = apiKeyInput.type === "password";
apiKeyInput.type = isPassword ? "text" : "password";
toggleKeyBtn.textContent = isPassword ? "Hide" : "Show";
});
copyCsvBtn.addEventListener("click", async () => {
if (!csvOutput.value) return;
await navigator.clipboard.writeText(csvOutput.value);
setStatus("CSV copied to clipboard.", "success");
});
copyCodeBtn.addEventListener("click", async () => {
if (!codeOutput.value) return;
await navigator.clipboard.writeText(codeOutput.value);
setStatus("Code copied to clipboard.", "success");
});
runBtn.addEventListener("click", async () => {
const apiKey = apiKeyInput.value.trim();
if (!apiKey) {
setStatus("Please enter your API key.", "error");
return;
}
if (!currentFile) {
setStatus("Please upload or paste a plot image.", "error");
return;
}
await loadAvailableModels({ silent: true });
const selectedModel = modelInput.value.trim();
if (!selectedModel) {
setStatus("Unable to load models for this API key.", "error");
return;
}
const formData = new FormData();
formData.append("api_key", apiKey);
formData.append("model", selectedModel);
formData.append("image", currentFile, currentFile.name || "plot.png");
runBtn.disabled = true;
setErrorDetails("");
if (isZipUpload) {
clearInterval(progressTimer);
stopStepTimeline();
setProgress(true, 5);
setStatus("Processing ZIP...", "neutral");
} else {
startProgress();
startStepTimeline();
}
setReplotPreview("");
try {
const response = await fetch(appUrl("/api/run"), {
method: "POST",
body: formData,
});
const payload = await response.json();
if (!response.ok) {
const detail = payload.details ? `\n\n${payload.details}` : "";
throw new Error(`${payload.error || "Request failed"}${detail}`);
}
stopStepTimeline();
bumpProgress(70);
setStatus("Rendering outputs...", "neutral");
results.classList.add("visible");
csvOutput.value = payload.data_csv || "";
codeOutput.value = payload.code || "";
if (!isZipUpload) {
setReplotPreview(payload.replot_url || "", payload.data_csv || "", payload.code || "");
}
if (zipDownload) zipDownload.innerHTML = "";
if (payload.status === "none") {
validationEl.innerHTML =
"No data extracted. The model returned None.";
setStatus("No data extracted.", "error");
finishProgress();
return;
}
if (payload.status === "zip_pending") {
if (resultCards) resultCards.classList.add("hidden");
csvOutput.value = "";
codeOutput.value = "";
validationEl.innerHTML = "ZIP uploaded. Processing images...";
if (zipDownload) zipDownload.innerHTML = "";
startZipPolling(payload.progress_url, payload.results_zip_url);
return;
}
if (payload.validation) {
bumpProgress(88);
setStatus("Validating plots...", "neutral");
const answers = payload.validation.answers || {};
validationEl.innerHTML = `
Validation: ${payload.validated || "unknown"}
X-axis: ${answers.compare_x || "?"} | Y-axis: ${answers.compare_y || "?"} |
Points: ${answers.compare_number || "?"} | Trend: ${answers.compare_trend || "?"}
`;
} else {
validationEl.innerHTML = "Validation: not available.";
}
bumpProgress(95);
setStatus("Finished.", "success");
finishProgress();
} catch (error) {
stopStepTimeline();
setErrorDetails(error.message || "Failed to run extraction.");
setStatus(error.message || "Failed to run extraction.", "error");
} finally {
if (progressBar && progressBar.classList.contains("active")) {
finishProgress();
}
runBtn.disabled = false;
}
});