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 = `
${fileName}
ZIP loaded
`; }; 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 = ` `; 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 = `
select point to be removed ${replotUrl ? `PNG` : ""}
`; 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 = `Replotted extraction result`; 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; } });