feat: Stage 3 - Interactive chartread Subprocess State Machine & Swatch Grid #13
Executable
+30
@@ -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
|
||||
@@ -171,6 +171,39 @@ pub async fn read_file_base64(path: String) -> Result<String, String> {
|
||||
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)]
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+35
-2
@@ -177,9 +177,42 @@
|
||||
<!-- Stage 3: chartread -->
|
||||
<section id="stage-3" class="stage hidden">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Stage 4: colprof -->
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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})`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user