diff --git a/src-tauri/argyll/linux-x86_64/chartread.mock b/src-tauri/argyll/linux-x86_64/chartread.mock new file mode 100755 index 0000000..b05aada --- /dev/null +++ b/src-tauri/argyll/linux-x86_64/chartread.mock @@ -0,0 +1,30 @@ +#!/bin/bash +# Mock script for chartread -u +# This script simulates the behaviour of chartread for testing purposes. + +echo "Place instrument on calibration tile and hit [Space] to calibrate." + +# We don't really wait for input, just wait 1 second +sleep 1 +echo "Calibration successful." +echo "Hit [Space] to read strip A (or 's' to skip)." + +sleep 1 +echo "Reading strip A..." + +# Emit mock JSON for strip A +cat << 'EOF' +ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]} +EOF + +echo "Hit [Space] to read strip B (or 's' to skip)." +sleep 1 +echo "Reading strip B..." + +# Emit mock JSON for strip B +cat << 'EOF' +ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]} +EOF + +echo "Ready to read... done." +exit 0 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b397e59..635f18b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -171,6 +171,39 @@ pub async fn read_file_base64(path: String) -> Result { Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)) } +#[derive(Debug, Deserialize, Serialize)] +pub struct ChartreadConfig { + pub basename: String, + pub cwd: String, +} + +pub fn build_chartread_args(config: &ChartreadConfig) -> Vec { + vec![ + "-v".to_string(), + "-u".to_string(), + config.basename.clone(), + ] +} + +#[tauri::command] +pub async fn run_chartread( + app: AppHandle, + state: State<'_, ProcessManager>, + config: ChartreadConfig, +) -> Result<(), String> { + let binary = resolve_binary(app.clone(), "chartread".to_string()).await?; + let args = build_chartread_args(&config); + let id = format!("chartread_{}", config.basename); + + let cwd = if config.cwd.trim().is_empty() { + None + } else { + Some(config.cwd.clone()) + }; + + state.spawn(app, id, binary, args, cwd).await +} + #[cfg(test)] mod tests { use super::*; @@ -244,4 +277,14 @@ mod tests { let args = build_printtarg_args(&config); assert_eq!(args, vec!["-v", "-u", "-i", "SS", "-p", "200x400", "-t", "150", "custom_target"]); } + + #[test] + fn test_build_chartread_args() { + let config = ChartreadConfig { + basename: "my_profile".to_string(), + cwd: "/home/user".to_string(), + }; + let args = build_chartread_args(&config); + assert_eq!(args, vec!["-v", "-u", "my_profile"]); + } } diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs index cd1292f..9e665b7 100644 --- a/src-tauri/src/events.rs +++ b/src-tauri/src/events.rs @@ -45,3 +45,16 @@ pub fn emit_error(app: &AppHandle, id: &str, error: String) { error: Some(error), }); } + +#[derive(Clone, Serialize)] +pub struct JsonRowPayload { + pub id: String, + pub json: String, +} + +pub fn emit_json_row(app: &AppHandle, id: &str, json: String) { + let _ = app.emit("process:json_row", JsonRowPayload { + id: id.to_string(), + json, + }); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2c63096..161fe3e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,6 +17,7 @@ pub fn run() { commands::run_targen, commands::run_printtarg, commands::read_file_base64, + commands::run_chartread, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/process_manager.rs b/src-tauri/src/process_manager.rs index 2c8ae27..eb8d715 100644 --- a/src-tauri/src/process_manager.rs +++ b/src-tauri/src/process_manager.rs @@ -38,9 +38,15 @@ impl ProcessManager { let id_clone = id.clone(); let app_clone = app.clone(); tokio::spawn(async move { + const JSON_ROW_PREFIX: &str = "ROW_COLORS_JSON: "; let mut reader = BufReader::new(stdout).lines(); while let Ok(Some(line)) = reader.next_line().await { - emit_stdout(&app_clone, &id_clone, line); + if line.starts_with(JSON_ROW_PREFIX) { + let json_str = line[JSON_ROW_PREFIX.len()..].to_string(); + crate::events::emit_json_row(&app_clone, &id_clone, json_str); + } else { + emit_stdout(&app_clone, &id_clone, line); + } } }); diff --git a/src/index.html b/src/index.html index 3956978..410a8c8 100644 --- a/src/index.html +++ b/src/index.html @@ -177,9 +177,42 @@ diff --git a/src/js/app.js b/src/js/app.js index 82723e6..3641fd2 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -1,5 +1,6 @@ import { initTargen } from './targen.js'; import { initPrinttarg } from './printtarg.js'; +import { initChartread } from './chartread.js'; const { invoke } = window.__TAURI__.core; @@ -31,4 +32,7 @@ document.addEventListener('DOMContentLoaded', () => { // Initialize Stage 2 initPrinttarg(); + + // Initialize Stage 3 + initChartread(); }); diff --git a/src/js/chartread.js b/src/js/chartread.js new file mode 100644 index 0000000..8af8e1b --- /dev/null +++ b/src/js/chartread.js @@ -0,0 +1,233 @@ +const { invoke } = window.__TAURI__.core; +const { listen } = window.__TAURI__.event; +import { startSwatchListener, stopSwatchListener } from './swatch_grid.js'; + +// Module-level state: set by Stage 2 when it completes +let stage2Basename = ""; +let stage2Cwd = ""; + +/** + * Called by printtarg.js after Stage 2 completes. + */ +export function setStage2Result(basename, cwd) { + stage2Basename = basename; + stage2Cwd = cwd; +} + +// State machine states +const STATE = { + IDLE: "IDLE", + CALIBRATING: "CALIBRATING", + AWAITING_STRIP: "AWAITING_STRIP", + READING: "READING", + ERROR: "ERROR", + FINISHED: "FINISHED", +}; + +let currentState = STATE.IDLE; +let currentProcessId = ""; + +export function initChartread() { + const btnStartRead = document.getElementById("btnStartRead"); + const btnCalibrate = document.getElementById("btnCalibrate"); + const btnRetry = document.getElementById("btnRetry"); + const btnSkip = document.getElementById("btnSkip"); + const btnCancel = document.getElementById("btnCancel"); + const promptText = document.getElementById("chartreadPrompt"); + const stateLabel = document.getElementById("chartreadState"); + const logContainer = document.getElementById("chartreadLogContainer"); + const logPre = document.getElementById("chartreadLog"); + + function setState(newState) { + currentState = newState; + if (stateLabel) stateLabel.textContent = newState; + + // Show/hide buttons based on state + btnCalibrate.classList.add("hidden"); + btnRetry.classList.add("hidden"); + btnSkip.classList.add("hidden"); + btnCancel.classList.add("hidden"); + btnStartRead.classList.add("hidden"); + + switch (newState) { + case STATE.IDLE: + btnStartRead.classList.remove("hidden"); + break; + case STATE.CALIBRATING: + btnCalibrate.classList.remove("hidden"); + btnCancel.classList.remove("hidden"); + break; + case STATE.AWAITING_STRIP: + btnRetry.classList.remove("hidden"); + btnSkip.classList.remove("hidden"); + btnCancel.classList.remove("hidden"); + break; + case STATE.READING: + btnCancel.classList.remove("hidden"); + break; + case STATE.ERROR: + btnRetry.classList.remove("hidden"); + btnSkip.classList.remove("hidden"); + btnCancel.classList.remove("hidden"); + break; + case STATE.FINISHED: + btnStartRead.classList.remove("hidden"); + break; + } + } + + function setPrompt(text) { + if (promptText) promptText.textContent = text; + } + + // Start reading button + if (btnStartRead) { + btnStartRead.addEventListener("click", async () => { + if (!stage2Basename) { + setPrompt("Error: No .ti2 file available. Complete Stage 2 first."); + return; + } + + logPre.textContent = ""; + logContainer.classList.remove("hidden"); + setState(STATE.CALIBRATING); + setPrompt("Starting chartread... waiting for instrument calibration prompt."); + + const config = { + basename: stage2Basename, + cwd: stage2Cwd, + }; + + currentProcessId = `chartread_${stage2Basename}`; + + // Start swatch grid listener + await startSwatchListener(currentProcessId); + + try { + const unlistenStdout = await listen("process:stdout", (event) => { + if (event.payload.id !== currentProcessId || !event.payload.line) return; + const line = event.payload.line; + + logPre.textContent += line + "\n"; + logPre.scrollTop = logPre.scrollHeight; + + // Parse prompts for state transitions + const lineLower = line.toLowerCase(); + + if (lineLower.includes("calibrat") && lineLower.includes("place")) { + setState(STATE.CALIBRATING); + setPrompt(line); + } else if (lineLower.includes("hit") && lineLower.includes("read") && lineLower.includes("strip")) { + setState(STATE.AWAITING_STRIP); + setPrompt(line); + } else if (lineLower.includes("ready to read")) { + setState(STATE.AWAITING_STRIP); + setPrompt(line); + } else if (lineLower.includes("reading strip") || lineLower.includes("processing")) { + setState(STATE.READING); + setPrompt(line); + } else if (lineLower.includes("error") || lineLower.includes("too fast") || lineLower.includes("too slow") || lineLower.includes("misread")) { + setState(STATE.ERROR); + setPrompt("⚠️ " + line); + } + }); + + const unlistenStderr = await listen("process:stderr", (event) => { + if (event.payload.id === currentProcessId && event.payload.line) { + logPre.textContent += "ERR: " + event.payload.line + "\n"; + logPre.scrollTop = logPre.scrollHeight; + } + }); + + const unlistenExit = await listen("process:exit", (event) => { + if (event.payload.id !== currentProcessId) return; + unlistenStdout(); + unlistenStderr(); + unlistenExit(); + stopSwatchListener(); + + if (event.payload.code === 0) { + setState(STATE.FINISHED); + setPrompt("✅ Measurement complete! .ti3 file has been saved."); + logPre.textContent += "\n[SUCCESS] chartread completed. .ti3 file written.\n"; + advanceToStage4(); + } else { + setState(STATE.FINISHED); + setPrompt(`❌ chartread exited with code ${event.payload.code}.`); + logPre.textContent += `\n[ERROR] chartread exited with code ${event.payload.code}.\n`; + } + }); + + await invoke("run_chartread", { config }); + + } catch (err) { + setPrompt(`Invoke error: ${err}`); + setState(STATE.IDLE); + } + }); + } + + // Calibrate / confirm button — sends space + newline + if (btnCalibrate) { + btnCalibrate.addEventListener("click", async () => { + try { + await invoke("send_stdin", { id: currentProcessId, input: " \n" }); + } catch (e) { console.error("send_stdin error:", e); } + }); + } + + // Retry button — sends space + newline (same as confirm) + if (btnRetry) { + btnRetry.addEventListener("click", async () => { + try { + await invoke("send_stdin", { id: currentProcessId, input: " \n" }); + setState(STATE.READING); + setPrompt("Retrying strip read..."); + } catch (e) { console.error("send_stdin error:", e); } + }); + } + + // Skip button — sends "s\n" + if (btnSkip) { + btnSkip.addEventListener("click", async () => { + try { + await invoke("send_stdin", { id: currentProcessId, input: "s\n" }); + setState(STATE.AWAITING_STRIP); + setPrompt("Skipped current strip. Awaiting next..."); + } catch (e) { console.error("send_stdin error:", e); } + }); + } + + // Cancel button — kills the process + if (btnCancel) { + btnCancel.addEventListener("click", async () => { + try { + await invoke("kill_process", { id: currentProcessId }); + stopSwatchListener(); + setState(STATE.IDLE); + setPrompt("Measurement cancelled."); + } catch (e) { console.error("kill_process error:", e); } + }); + } + + // Start in IDLE state + setState(STATE.IDLE); + setPrompt("Press 'Start Measurement' to begin reading the printed target."); +} + +function advanceToStage4() { + const steps = document.querySelectorAll('.step'); + const stages = document.querySelectorAll('.stage'); + + steps.forEach(s => s.classList.remove('active')); + if (steps[3]) steps[3].classList.add('active'); + + stages.forEach(s => { + s.classList.remove('active'); + s.classList.add('hidden'); + }); + if (stages[3]) { + stages[3].classList.remove('hidden'); + stages[3].classList.add('active'); + } +} diff --git a/src/js/color_convert.js b/src/js/color_convert.js new file mode 100644 index 0000000..8428e78 --- /dev/null +++ b/src/js/color_convert.js @@ -0,0 +1,67 @@ +/** + * Convert CIE L*a*b* to sRGB [0–255] clamped values. + * Uses D50 illuminant reference white (standard for ICC profiles). + * @param {number} L - Lightness [0, 100] + * @param {number} a - Green-red axis [-128, 127] + * @param {number} b - Blue-yellow axis [-128, 127] + * @returns {number[]} [r, g, b] each in [0, 255] + */ +export function labToSrgb(L, a, b) { + // D50 reference white + const Xn = 0.9642; + const Yn = 1.0; + const Zn = 0.8249; + + // Lab → XYZ + const fy = (L + 16) / 116; + const fx = a / 500 + fy; + const fz = fy - b / 200; + + const delta = 6 / 29; + const delta3 = delta * delta * delta; + + const X = Xn * (fx > delta ? fx * fx * fx : (fx - 16 / 116) * 3 * delta * delta); + const Y = Yn * (fy > delta ? fy * fy * fy : (fy - 16 / 116) * 3 * delta * delta); + const Z = Zn * (fz > delta ? fz * fz * fz : (fz - 16 / 116) * 3 * delta * delta); + + // XYZ (D50) → linear sRGB via Bradford-adapted D50→D65 matrix + // Combined D50-adapted XYZ to sRGB matrix + const lr = 3.1338561 * X - 1.6168667 * Y - 0.4906146 * Z; + const lg = -0.9787684 * X + 1.9161415 * Y + 0.0334540 * Z; + const lb = 0.0719453 * X - 0.2289914 * Y + 1.4052427 * Z; + + // Linear sRGB → gamma-corrected sRGB + function gammaCorrect(c) { + return c <= 0.0031308 + ? 12.92 * c + : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; + } + + const r = Math.round(Math.max(0, Math.min(255, gammaCorrect(lr) * 255))); + const g = Math.round(Math.max(0, Math.min(255, gammaCorrect(lg) * 255))); + const bVal = Math.round(Math.max(0, Math.min(255, gammaCorrect(lb) * 255))); + + return [r, g, bVal]; +} + +/** + * Convert device RGB percentages [0–100] to CSS rgb string. + * @param {number[]} device - [R%, G%, B%] each in [0, 100] + * @returns {string} CSS rgb() string + */ +export function deviceRgbToCss(device) { + const r = Math.round((device[0] / 100) * 255); + const g = Math.round((device[1] / 100) * 255); + const b = Math.round((device[2] / 100) * 255); + return `rgb(${r}, ${g}, ${b})`; +} + +/** + * Convert Lab triplet to CSS rgb string. + * @param {number[]} lab - [L, a, b] + * @returns {string} CSS rgb() string + */ +export function labToCss(lab) { + const [r, g, b] = labToSrgb(lab[0], lab[1], lab[2]); + return `rgb(${r}, ${g}, ${b})`; +} diff --git a/src/js/delta_e.js b/src/js/delta_e.js new file mode 100644 index 0000000..aef3749 --- /dev/null +++ b/src/js/delta_e.js @@ -0,0 +1,86 @@ +/** + * Compute CIEDE2000 colour difference (ΔE₀₀) between two L*a*b* values. + * Standard parametric factors: kL=1, kC=1, kH=1. + * + * Reference: Sharma, Wu, Dalal (2005) "The CIEDE2000 Color-Difference Formula" + * + * @param {number[]} lab1 - [L1, a1, b1] + * @param {number[]} lab2 - [L2, a2, b2] + * @returns {number} ΔE₀₀ value + */ +export function computeDeltaE00(lab1, lab2) { + const [L1, a1, b1] = lab1; + const [L2, a2, b2] = lab2; + + const kL = 1, kC = 1, kH = 1; + + const C1ab = Math.sqrt(a1 * a1 + b1 * b1); + const C2ab = Math.sqrt(a2 * a2 + b2 * b2); + const Cab_avg = (C1ab + C2ab) / 2; + + const Cab_avg7 = Math.pow(Cab_avg, 7); + const G = 0.5 * (1 - Math.sqrt(Cab_avg7 / (Cab_avg7 + Math.pow(25, 7)))); + + const a1p = a1 * (1 + G); + const a2p = a2 * (1 + G); + + const C1p = Math.sqrt(a1p * a1p + b1 * b1); + const C2p = Math.sqrt(a2p * a2p + b2 * b2); + + let h1p = Math.atan2(b1, a1p) * (180 / Math.PI); + if (h1p < 0) h1p += 360; + let h2p = Math.atan2(b2, a2p) * (180 / Math.PI); + if (h2p < 0) h2p += 360; + + const dLp = L2 - L1; + const dCp = C2p - C1p; + + let dhp; + if (C1p * C2p === 0) { + dhp = 0; + } else if (Math.abs(h2p - h1p) <= 180) { + dhp = h2p - h1p; + } else if (h2p - h1p > 180) { + dhp = h2p - h1p - 360; + } else { + dhp = h2p - h1p + 360; + } + const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin((dhp * Math.PI / 180) / 2); + + const Lp_avg = (L1 + L2) / 2; + const Cp_avg = (C1p + C2p) / 2; + + let hp_avg; + if (C1p * C2p === 0) { + hp_avg = h1p + h2p; + } else if (Math.abs(h1p - h2p) <= 180) { + hp_avg = (h1p + h2p) / 2; + } else if (h1p + h2p < 360) { + hp_avg = (h1p + h2p + 360) / 2; + } else { + hp_avg = (h1p + h2p - 360) / 2; + } + + const T = 1 + - 0.17 * Math.cos((hp_avg - 30) * Math.PI / 180) + + 0.24 * Math.cos(2 * hp_avg * Math.PI / 180) + + 0.32 * Math.cos((3 * hp_avg + 6) * Math.PI / 180) + - 0.20 * Math.cos((4 * hp_avg - 63) * Math.PI / 180); + + const SL = 1 + (0.015 * Math.pow(Lp_avg - 50, 2)) / Math.sqrt(20 + Math.pow(Lp_avg - 50, 2)); + const SC = 1 + 0.045 * Cp_avg; + const SH = 1 + 0.015 * Cp_avg * T; + + const Cp_avg7 = Math.pow(Cp_avg, 7); + const RT_term = -2 * Math.sqrt(Cp_avg7 / (Cp_avg7 + Math.pow(25, 7))) + * Math.sin(60 * Math.exp(-Math.pow((hp_avg - 275) / 25, 2)) * Math.PI / 180); + + const dE = Math.sqrt( + Math.pow(dLp / (kL * SL), 2) + + Math.pow(dCp / (kC * SC), 2) + + Math.pow(dHp / (kH * SH), 2) + + RT_term * (dCp / (kC * SC)) * (dHp / (kH * SH)) + ); + + return dE; +} diff --git a/src/js/printtarg.js b/src/js/printtarg.js index da8d6db..b940dc8 100644 --- a/src/js/printtarg.js +++ b/src/js/printtarg.js @@ -1,5 +1,6 @@ const { invoke } = window.__TAURI__.core; const { listen } = window.__TAURI__.event; +import { setStage2Result } from './chartread.js'; // Module-level state: set by Stage 1 when it completes let stage1Basename = ""; @@ -118,6 +119,7 @@ export function initPrinttarg() { renderTiffGallery(manifest, config.cwd); } + setStage2Result(stage1Basename, stage1Cwd); advanceToStage3(); } else { logPre.textContent += `\n[ERROR] printtarg exited with code ${event.payload.code}.\n`; diff --git a/src/js/swatch_grid.js b/src/js/swatch_grid.js new file mode 100644 index 0000000..8856dfc --- /dev/null +++ b/src/js/swatch_grid.js @@ -0,0 +1,127 @@ +import { computeDeltaE00 } from './delta_e.js'; +import { labToCss, deviceRgbToCss } from './color_convert.js'; + +const { listen } = window.__TAURI__.event; + +let unlistenJsonRow = null; + +/** + * Start listening for row events from a chartread process. + * @param {string} processId - The process ID (e.g. "chartread_my_profile") + */ +export async function startSwatchListener(processId) { + const grid = document.getElementById("swatchGrid"); + const progressBar = document.getElementById("readProgress"); + const progressText = document.getElementById("readProgressText"); + const statsPanel = document.getElementById("readStats"); + + // Clear previous state + grid.innerHTML = ""; + let totalPatches = 0; + let totalDeltaE = 0; + let patchCount = 0; + let maxDeltaE = 0; + + unlistenJsonRow = await listen("process:json_row", (event) => { + if (event.payload.id !== processId) return; + + let data; + try { + data = JSON.parse(event.payload.json); + } catch (e) { + console.error("Failed to parse json_row:", e); + return; + } + + if (data.event !== "row_complete") return; + + // Update progress bar + const progress = ((data.row_index + 1) / data.total_rows) * 100; + if (progressBar) progressBar.style.width = `${progress}%`; + if (progressText) progressText.textContent = `Strip ${data.row_id} — ${data.row_index + 1} / ${data.total_rows}`; + + // Create row container + const rowEl = document.createElement("div"); + rowEl.className = "swatch-row"; + + const rowLabel = document.createElement("div"); + rowLabel.className = "swatch-row-label"; + rowLabel.textContent = data.row_id; + rowEl.appendChild(rowLabel); + + const rowPatches = document.createElement("div"); + rowPatches.className = "swatch-row-patches"; + + for (const patch of data.patches) { + if (patch.is_pad) continue; // Skip spacer patches + + const patchEl = document.createElement("div"); + patchEl.className = "swatch-patch"; + + // Determine the display colour + let bgColor; + if (patch.measured && patch.measured.Lab) { + bgColor = labToCss(patch.measured.Lab); + } else if (patch.device && patch.device.length === 3) { + bgColor = deviceRgbToCss(patch.device); + } else { + bgColor = "#888"; + } + + const swatch = document.createElement("div"); + swatch.className = "swatch-color"; + swatch.style.backgroundColor = bgColor; + patchEl.appendChild(swatch); + + // Compute and display ΔE₀₀ if both expected and measured Lab are present + if (patch.expected && patch.expected.Lab && patch.measured && patch.measured.Lab) { + const deltaE = computeDeltaE00(patch.expected.Lab, patch.measured.Lab); + + const deLabel = document.createElement("div"); + deLabel.className = "swatch-de"; + deLabel.textContent = deltaE.toFixed(1); + + // Traffic light classification + if (deltaE < 2) { + patchEl.classList.add("de-good"); // Green + } else if (deltaE < 5) { + patchEl.classList.add("de-warning"); // Amber + } else { + patchEl.classList.add("de-bad"); // Red + } + + patchEl.appendChild(deLabel); + + // Accumulate stats + totalDeltaE += deltaE; + patchCount++; + if (deltaE > maxDeltaE) maxDeltaE = deltaE; + } + + patchEl.title = `${patch.loc} (ID: ${patch.id})`; + rowPatches.appendChild(patchEl); + } + + rowEl.appendChild(rowPatches); + grid.appendChild(rowEl); + + // Scroll to bottom + grid.scrollTop = grid.scrollHeight; + + // Update stats + if (statsPanel && patchCount > 0) { + const avgDe = (totalDeltaE / patchCount).toFixed(2); + statsPanel.textContent = `Avg ΔE₀₀: ${avgDe} · Max ΔE₀₀: ${maxDeltaE.toFixed(2)} · Patches: ${patchCount}`; + } + }); +} + +/** + * Stop listening for row events. + */ +export function stopSwatchListener() { + if (unlistenJsonRow) { + unlistenJsonRow(); + unlistenJsonRow = null; + } +} diff --git a/src/styles/main.css b/src/styles/main.css index 82f9bde..e70398e 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -341,3 +341,161 @@ button:disabled { color: #666; } +/* ======================================== + Stage 3: chartread State Machine UI + ======================================== */ +.chartread-status { + background: var(--panel-color); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; +} + +.status-label { + font-size: 0.85em; + color: #888; + margin-bottom: 8px; +} + +.status-label span { + color: var(--accent-color); + font-weight: bold; + text-transform: uppercase; +} + +.status-prompt { + font-size: 1.1em; + color: #fff; + line-height: 1.4; + min-height: 2.8em; +} + +.chartread-actions { + display: flex; + gap: 10px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +button.danger { + background-color: #c62828; + color: white; + border: none; + padding: 10px 20px; + font-size: 1rem; + border-radius: 4px; + cursor: pointer; +} + +button.danger:hover { + background-color: #e53935; +} + +/* Progress Bar */ +.read-progress-container { + width: 100%; + height: 8px; + background: var(--bg-color); + border-radius: 4px; + overflow: hidden; + margin-bottom: 6px; +} + +.read-progress-bar { + height: 100%; + background: linear-gradient(90deg, var(--accent-color), #4fc3f7); + border-radius: 4px; + transition: width 0.3s ease; +} + +.read-progress-text { + font-size: 0.85em; + color: #888; + margin-bottom: 12px; +} + +.read-stats { + font-size: 0.9em; + color: #aaa; + margin-bottom: 16px; +} + +/* ======================================== + Stage 3: Swatch Grid + ======================================== */ +.swatch-grid { + max-height: 500px; + overflow-y: auto; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--panel-color); + padding: 12px; + margin-bottom: 16px; +} + +.swatch-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.swatch-row-label { + min-width: 30px; + font-weight: bold; + font-size: 0.85em; + color: #888; + text-align: center; +} + +.swatch-row-patches { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.swatch-patch { + width: 36px; + text-align: center; + border: 2px solid transparent; + border-radius: 4px; + overflow: hidden; + transition: border-color 0.2s; +} + +.swatch-color { + width: 100%; + height: 28px; +} + +.swatch-de { + font-size: 0.65em; + padding: 2px 0; + color: #ccc; + background: rgba(0,0,0,0.3); +} + +/* Traffic light ΔE indicators */ +.swatch-patch.de-good { + border-color: #4caf50; +} + +.swatch-patch.de-warning { + border-color: #ff9800; +} + +.swatch-patch.de-bad { + border-color: #f44336; +} + +/* Process log for chartread */ +#chartreadLog { + margin: 0; + font-family: monospace; + font-size: 0.9em; + color: #ccc; + max-height: 200px; + overflow-y: auto; + white-space: pre-wrap; +}