feat: Stage 3 - Interactive chartread Subprocess State Machine & Swatch Grid #13

Merged
gronod merged 1 commits from feature/stage-3-chartread into feature/stage-2-printtarg 2026-08-22 18:27:19 +01:00
13 changed files with 806 additions and 3 deletions
+30
View File
@@ -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
+43
View File
@@ -171,6 +171,39 @@ pub async fn read_file_base64(path: String) -> Result<String, String> {
Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)) 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<String> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -244,4 +277,14 @@ mod tests {
let args = build_printtarg_args(&config); let args = build_printtarg_args(&config);
assert_eq!(args, vec!["-v", "-u", "-i", "SS", "-p", "200x400", "-t", "150", "custom_target"]); 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"]);
}
} }
+13
View File
@@ -45,3 +45,16 @@ pub fn emit_error(app: &AppHandle, id: &str, error: String) {
error: Some(error), 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,
});
}
+1
View File
@@ -17,6 +17,7 @@ pub fn run() {
commands::run_targen, commands::run_targen,
commands::run_printtarg, commands::run_printtarg,
commands::read_file_base64, commands::read_file_base64,
commands::run_chartread,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+7 -1
View File
@@ -38,9 +38,15 @@ impl ProcessManager {
let id_clone = id.clone(); let id_clone = id.clone();
let app_clone = app.clone(); let app_clone = app.clone();
tokio::spawn(async move { tokio::spawn(async move {
const JSON_ROW_PREFIX: &str = "ROW_COLORS_JSON: ";
let mut reader = BufReader::new(stdout).lines(); let mut reader = BufReader::new(stdout).lines();
while let Ok(Some(line)) = reader.next_line().await { 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);
}
} }
}); });
+35 -2
View File
@@ -177,9 +177,42 @@
<!-- Stage 3: chartread --> <!-- Stage 3: chartread -->
<section id="stage-3" class="stage hidden"> <section id="stage-3" class="stage hidden">
<h2>Measure Target</h2> <h2>Measure Target</h2>
<p>Read the printed patches with your instrument.</p> <p>Read the printed target patches with your spectrophotometer.</p>
<!-- State & Prompt Area -->
<div class="chartread-status">
<div class="status-label">State: <span id="chartreadState">IDLE</span></div>
<div class="status-prompt" id="chartreadPrompt">
Press 'Start Measurement' to begin reading the printed target.
</div>
</div>
<!-- Action Buttons -->
<div class="chartread-actions">
<button class="primary" id="btnStartRead">Start Measurement</button>
<button class="primary hidden" id="btnCalibrate">✓ Calibrate</button>
<button class="secondary hidden" id="btnRetry">↻ Retry Strip</button>
<button class="secondary hidden" id="btnSkip">⏭ Skip Strip</button>
<button class="danger hidden" id="btnCancel">✕ Cancel</button>
</div>
<!-- Progress Bar -->
<div class="read-progress-container hidden" id="readProgressContainer">
<div class="read-progress-bar" id="readProgress" style="width: 0%"></div>
</div>
<div class="read-progress-text" id="readProgressText"></div>
<!-- Live Stats -->
<div class="read-stats" id="readStats"></div>
<!-- Swatch Grid -->
<div class="swatch-grid" id="swatchGrid"></div> <div class="swatch-grid" id="swatchGrid"></div>
<button class="primary" id="btnRead">Start Reading (.ti3)</button>
<!-- Process Log -->
<div class="log-container hidden" id="chartreadLogContainer">
<h4>Process Output</h4>
<pre id="chartreadLog"></pre>
</div>
</section> </section>
<!-- Stage 4: colprof --> <!-- Stage 4: colprof -->
+4
View File
@@ -1,5 +1,6 @@
import { initTargen } from './targen.js'; import { initTargen } from './targen.js';
import { initPrinttarg } from './printtarg.js'; import { initPrinttarg } from './printtarg.js';
import { initChartread } from './chartread.js';
const { invoke } = window.__TAURI__.core; const { invoke } = window.__TAURI__.core;
@@ -31,4 +32,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Initialize Stage 2 // Initialize Stage 2
initPrinttarg(); initPrinttarg();
// Initialize Stage 3
initChartread();
}); });
+233
View File
@@ -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');
}
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Convert CIE L*a*b* to sRGB [0255] 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 [0100] 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})`;
}
+86
View File
@@ -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;
}
+2
View File
@@ -1,5 +1,6 @@
const { invoke } = window.__TAURI__.core; const { invoke } = window.__TAURI__.core;
const { listen } = window.__TAURI__.event; const { listen } = window.__TAURI__.event;
import { setStage2Result } from './chartread.js';
// Module-level state: set by Stage 1 when it completes // Module-level state: set by Stage 1 when it completes
let stage1Basename = ""; let stage1Basename = "";
@@ -118,6 +119,7 @@ export function initPrinttarg() {
renderTiffGallery(manifest, config.cwd); renderTiffGallery(manifest, config.cwd);
} }
setStage2Result(stage1Basename, stage1Cwd);
advanceToStage3(); advanceToStage3();
} else { } else {
logPre.textContent += `\n[ERROR] printtarg exited with code ${event.payload.code}.\n`; logPre.textContent += `\n[ERROR] printtarg exited with code ${event.payload.code}.\n`;
+127
View File
@@ -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;
}
}
+158
View File
@@ -341,3 +341,161 @@ button:disabled {
color: #666; 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;
}