Spaces:
Configuration error
Configuration error
Download plotextract/static/app.js from AccuratePaperExtraction/MADTOOL: direct link, hf CLI and curl.
- Browser
- Download file 98 kB
-
https://huggingface.co/spaces/AccuratePaperExtraction/MADTOOL/resolve/main/plotextract/static/app.js
- Command line
-
hf download hf://spaces/AccuratePaperExtraction/MADTOOL/plotextract/static/app.js
-
curl -L -o app.js https://huggingface.co/spaces/AccuratePaperExtraction/MADTOOL/resolve/main/plotextract/static/app.js
98 kB
| 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 = `<strong>Processed:</strong> ${processed} images.`; | |
| if (zipDownload) { | |
| zipDownload.innerHTML = `<a href="${resultsUrl}" download>Download results ZIP</a>`; | |
| } | |
| 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 = "<p class=\"hint\">Preview will appear here.</p>"; | |
| 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 = `<p class="hint">${text}</p>`; | |
| }; | |
| const setZipPreview = (fileName) => { | |
| releasePreviewObjectUrl(); | |
| preview.innerHTML = ` | |
| <div class="zip-preview"> | |
| <svg viewBox="0 0 24 24" aria-hidden="true"> | |
| <path d="M7 3h7l5 5v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"></path> | |
| <path d="M14 3v6h6"></path> | |
| <path d="M10 11h4"></path> | |
| <path d="M10 14h4"></path> | |
| <path d="M10 17h4"></path> | |
| </svg> | |
| <div>${fileName}</div> | |
| <div>ZIP loaded</div> | |
| </div> | |
| `; | |
| }; | |
| 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, """) | |
| .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) => `<option value="${index}">${escapeHtml(getCurveName(curve, index))}</option>`) | |
| .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(`<line class="${shapeClass}" x1="${-half}" y1="0" x2="${half}" y2="0" stroke="${color}"></line>`); | |
| } | |
| if (plus || ["3", "4"].includes(marker)) { | |
| lines.push(`<line class="${shapeClass}" x1="0" y1="${-half}" x2="0" y2="${half}" stroke="${color}"></line>`); | |
| } | |
| if (diagonal || !lines.length) { | |
| lines.push( | |
| `<line class="${shapeClass}" x1="${-half}" y1="${-half}" x2="${half}" y2="${half}" stroke="${color}"></line>`, | |
| `<line class="${shapeClass}" x1="${-half}" y1="${half}" x2="${half}" y2="${-half}" stroke="${color}"></line>` | |
| ); | |
| } | |
| return `<g ${common}>${lines.join("")}</g>`; | |
| } | |
| if (["s", "p", "h", "H"].includes(marker)) { | |
| return `<g ${common}><rect class="${shapeClass}" x="${-half}" y="${-half}" width="${size}" height="${size}" fill="${color}" stroke="${color}"></rect></g>`; | |
| } | |
| if (["D", "d"].includes(marker)) { | |
| return `<g ${common}><path class="${shapeClass}" d="M 0 ${-half} L ${half} 0 L 0 ${half} L ${-half} 0 Z" fill="${color}" stroke="${color}"></path></g>`; | |
| } | |
| 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 `<g ${common}><polygon class="${shapeClass}" points="${points}" fill="${color}" stroke="${color}"></polygon></g>`; | |
| } | |
| 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 `<g ${common}><polygon class="${shapeClass}" points="${points}" fill="${color}" stroke="${color}"></polygon></g>`; | |
| } | |
| const radius = marker === "." ? Math.max(2.5, size * 0.42) : Math.max(3, size); | |
| return `<g ${common}><circle class="${shapeClass}" cx="0" cy="0" r="${radius}" fill="${color}" stroke="${color}"></circle></g>`; | |
| }; | |
| 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 = [ | |
| `<defs><clipPath id="${clipId}"><rect x="${area.x}" y="${area.y}" width="${area.width}" height="${area.height}"></rect></clipPath></defs>`, | |
| `<rect class="plot-bg" x="0" y="0" width="${PLOT_FRAME.width}" height="${PLOT_FRAME.height}" fill="${style.background || "#ffffff"}"></rect>`, | |
| `<rect class="plot-area" x="${area.x}" y="${area.y}" width="${area.width}" height="${area.height}" fill="${style.plotBackground || "#ffffff"}" stroke="${axisColor}"></rect>`, | |
| ]; | |
| if (style.title) { | |
| parts.push(`<text class="plot-title" x="${area.x + area.width / 2}" y="28" text-anchor="middle" fill="${textColor}">${escapeHtml(style.title)}</text>`); | |
| } | |
| 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(`<line class="plot-grid" x1="${view.x}" y1="${area.y}" x2="${view.x}" y2="${area.y + area.height}" stroke="${gridColor}"></line>`); | |
| } | |
| parts.push( | |
| `<text class="plot-tick" x="${view.x}" y="${area.y + area.height + 24}" text-anchor="middle" fill="${textColor}">${escapeHtml(formatTick(tick))}</text>` | |
| ); | |
| } | |
| 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(`<line class="plot-grid" x1="${area.x}" y1="${view.y}" x2="${area.x + area.width}" y2="${view.y}" stroke="${gridColor}"></line>`); | |
| } | |
| parts.push( | |
| `<text class="plot-tick" x="${area.x - 12}" y="${view.y + 4}" text-anchor="end" fill="${textColor}">${escapeHtml(formatTick(tick))}</text>` | |
| ); | |
| } | |
| parts.push( | |
| `<line class="plot-axis" x1="${area.x}" y1="${area.y + area.height}" x2="${area.x + area.width}" y2="${area.y + area.height}" stroke="${axisColor}"></line>`, | |
| `<line class="plot-axis" x1="${area.x}" y1="${area.y}" x2="${area.x}" y2="${area.y + area.height}" stroke="${axisColor}"></line>`, | |
| `<text class="plot-label" x="${area.x + area.width / 2}" y="${PLOT_FRAME.height - 24}" text-anchor="middle" fill="${textColor}">${escapeHtml(xLabel)}</text>`, | |
| `<text class="plot-label" transform="translate(24 ${area.y + area.height / 2}) rotate(-90)" text-anchor="middle" fill="${textColor}">${escapeHtml(yLabel)}</text>` | |
| ); | |
| parts.push(`<g clip-path="url(#${clipId})">`); | |
| 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(`<path class="plot-line" d="${path}" stroke="${color}" stroke-width="${curveStyle.lineWidth || 2}" opacity="${curveStyle.alpha ?? 1}"${dash}></path>`); | |
| } | |
| 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("</g>"); | |
| 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( | |
| `<circle cx="${area.x + area.width - 148}" cy="${y - 4}" r="5" fill="${color}"></circle>`, | |
| `<text class="plot-legend" x="${area.x + area.width - 136}" y="${y}" fill="${textColor}">${escapeHtml(getCurveName(curve, index))}</text>` | |
| ); | |
| }); | |
| 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 = [`<g class="manual-magnifier-replot-layer">`]; | |
| 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(`<path class="plot-line" d="${path}" stroke="${color}" stroke-width="${width}" opacity="1"${dash}></path>`); | |
| } | |
| 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("</g>"); | |
| 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 ` | |
| <g class="manual-calibration-marker"> | |
| <circle class="manual-calibration-point" cx="${view.x}" cy="${view.y}" r="7"></circle> | |
| <rect class="manual-calibration-label-bg" x="${labelBox.x}" y="${labelBox.y}" width="${labelBox.width}" height="${labelBox.height}" rx="8"></rect> | |
| <text class="manual-calibration-label" x="${labelBox.x + labelBox.width / 2}" y="${labelBox.y + 14}" text-anchor="middle">${escapeHtml(point.label)}</text> | |
| </g> | |
| `; | |
| }) | |
| .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( | |
| `<rect class="manual-image-bounds" x="${imageRect.x}" y="${imageRect.y}" width="${imageRect.width}" height="${imageRect.height}"></rect>` | |
| ); | |
| } | |
| if (nextLabel) { | |
| parts.push(`<text class="manual-step-label" x="18" y="28">Click ${escapeHtml(nextLabel)}</text>`); | |
| } | |
| 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( | |
| `<defs><clipPath id="${clipId}"><rect x="${imageRect.x}" y="${imageRect.y}" width="${imageRect.width}" height="${imageRect.height}"></rect></clipPath></defs>`, | |
| `<rect class="manual-original-dim" x="${imageRect.x}" y="${imageRect.y}" width="${imageRect.width}" height="${imageRect.height}"></rect>`, | |
| `<rect class="manual-image-bounds" x="${imageRect.x}" y="${imageRect.y}" width="${imageRect.width}" height="${imageRect.height}"></rect>` | |
| ); | |
| } | |
| parts.push(`<g class="manual-replot-layer"${imageRect ? ` clip-path="url(#${clipId})"` : ""}>`); | |
| 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(`<path class="plot-line" d="${path}" stroke="${color}" stroke-width="${curveStyle.lineWidth || 2}" opacity="1"${dash}></path>`); | |
| } | |
| 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("</g>", 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 = ` | |
| <div class="manual-modal" role="dialog" aria-modal="true" aria-labelledby="manualInstructionTitle"> | |
| <h3 id="manualInstructionTitle">Manual calibration</h3> | |
| <p>Click x1, x2, y1, and y2 on the original plot. After each click, enter that point's axis value.</p> | |
| <div class="manual-modal-actions"> | |
| <button class="ghost" data-manual-action="cancel" type="button">Cancel</button> | |
| <button class="primary" data-manual-action="start" type="button">Start</button> | |
| </div> | |
| </div> | |
| `; | |
| 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 = ` | |
| <h3 id="manualValuesTitle">${escapeHtml(label)} value</h3> | |
| <form class="calibration-form"> | |
| <label class="calibration-field"> | |
| <span>Axis value</span> | |
| <input name="value" type="number" step="any" value="${escapeHtml(formatPlotNumber(defaultValue))}" required autofocus /> | |
| </label> | |
| <p class="manual-modal-error" role="alert"></p> | |
| <div class="manual-modal-actions"> | |
| <button class="ghost" data-manual-action="cancel" type="button">Cancel</button> | |
| <button class="primary" type="submit">${isLastReference ? "Apply" : "Next"}</button> | |
| </div> | |
| </form> | |
| `; | |
| 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 class="plot-curve-select" title="Active curve"></select> | |
| <button class="ghost plot-tool text" data-plot-action="add" type="button" title="Click the plot to add a point">add point</button> | |
| <button class="ghost plot-tool text" data-plot-action="remove" type="button" title="Remove selected point">remove point</button> | |
| <span class="plot-remove-hint">select point to be removed</span> | |
| <button class="ghost plot-tool text" data-plot-action="reset" type="button" title="Reset edits">Reset</button> | |
| ${latestPlotOutput.replotUrl ? `<a class="ghost plot-tool text" href="${escapeHtml(latestPlotOutput.replotUrl)}" target="_blank" rel="noopener" title="Open generated static PNG">PNG</a>` : ""} | |
| `; | |
| 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 = ` | |
| <div class="plot-editor"> | |
| <svg class="plot-editor-svg" viewBox="0 0 ${PLOT_FRAME.width} ${PLOT_FRAME.height}" role="img" aria-label="Editable replotted data"></svg> | |
| <div class="plot-toolbar"> | |
| <select class="plot-curve-select" title="Active curve"></select> | |
| <button class="ghost plot-tool text" data-plot-action="add" type="button" title="Click the plot to add a point">add point</button> | |
| <button class="ghost plot-tool text" data-plot-action="remove" type="button" title="Remove selected point">remove point</button> | |
| <span class="plot-remove-hint">select point to be removed</span> | |
| <button class="ghost plot-tool text" data-plot-action="reset" type="button" title="Reset edits">Reset</button> | |
| ${replotUrl ? `<a class="ghost plot-tool text" href="${escapeHtml(replotUrl)}" target="_blank" rel="noopener" title="Open generated static PNG">PNG</a>` : ""} | |
| </div> | |
| </div> | |
| `; | |
| 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 = | |
| "<p class=\"hint\">Plot recreated using the extracted data will appear hear once the extraction process is finished.</p>"; | |
| return; | |
| } | |
| if (isPlotImageUrl(urlOrText)) { | |
| latestPlotOutput = { | |
| replotUrl: urlOrText, | |
| csvText, | |
| pythonCode, | |
| }; | |
| replotPreview.classList.add("static-replot"); | |
| replotPreview.innerHTML = `<img src="${escapeHtml(urlOrText)}" alt="Replotted extraction result" />`; | |
| setImproveManualVisible(Boolean(csvText || pythonCode)); | |
| return; | |
| } | |
| replotPreview.innerHTML = ""; | |
| replotPreview.innerHTML = `<p class="hint">${urlOrText}</p>`; | |
| }; | |
| 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 = | |
| "<strong>No data extracted.</strong> 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 = "<strong>ZIP uploaded.</strong> 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 = ` | |
| <strong>Validation:</strong> ${payload.validated || "unknown"}<br /> | |
| X-axis: ${answers.compare_x || "?"} | Y-axis: ${answers.compare_y || "?"} | | |
| Points: ${answers.compare_number || "?"} | Trend: ${answers.compare_trend || "?"} | |
| `; | |
| } else { | |
| validationEl.innerHTML = "<strong>Validation:</strong> 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; | |
| } | |
| }); | |