feat(v3): simplify the project to contain only required tools

This commit is contained in:
2026-07-09 04:02:31 +03:30
parent 20a43d5c9a
commit 2112e00982
19 changed files with 482 additions and 508 deletions

View File

@@ -2,16 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import CanvasPane, { CanvasThumbnail } from "./components/CanvasPane.jsx";
import Controls from "./components/Controls.jsx";
import HistogramPanel from "./components/HistogramPanel.jsx";
import { applyStateOperation, combineStates, getOperations, listStates, uploadImage } from "./lib/api.js";
function defaultCropParams(state) {
return {
x: 0,
y: 0,
width: state?.width || 256,
height: state?.height || 256
};
}
import { applyStateOperation, combineStates, deleteState, getOperations, listStates, uploadImage } from "./lib/api.js";
export default function App() {
const [session, setSession] = useState(null);
@@ -29,10 +20,10 @@ export default function App() {
getOperations()
.then((payload) => {
setOperations(payload.operations || []);
const first = payload.operations?.find((operation) => operation.id === "crop") || payload.operations?.[0];
const first = payload.operations?.[0];
if (first) {
setSelectedOperation(first.id);
setParams(Object.fromEntries(Object.entries(first.params || {}).map(([key, schema]) => [key, schema.default])));
setParams({ _repeat: 1, ...Object.fromEntries(Object.entries(first.params || {}).map(([key, schema]) => [key, schema.default])) });
}
})
.catch((error) => setStatus(error.message));
@@ -59,7 +50,6 @@ export default function App() {
setSelectedStateIds(initialStates[0] ? [initialStates[0].state_id] : []);
setTransform({ x: 0, y: 0, scale: 1 });
setStatus(`${payload.width} x ${payload.height} ${payload.color_mode} image loaded as S0.`);
if (selectedOperation === "crop") setParams(defaultCropParams(initialStates[0]));
} catch (error) {
setStatus(error.message);
} finally {
@@ -69,7 +59,7 @@ export default function App() {
function handleSelectOperation(operationId, nextParams) {
setSelectedOperation(operationId);
setParams(operationId === "crop" ? { ...nextParams, ...defaultCropParams(activeState) } : nextParams);
setParams(nextParams);
}
function handleParamChange(key, value) {
@@ -112,14 +102,35 @@ export default function App() {
setSelectedStateIds((current) => current.includes(stateId) ? current.filter((id) => id !== stateId) : [...current, stateId]);
}
async function handleDeleteState(state) {
if (!state || !session) return;
setBusy(true);
setStatus(`Deleting ${state.label}...`);
try {
await deleteState(state.state_id);
const remaining = await listStates(session.session_id);
const nextStates = remaining.states || [];
setStates(nextStates);
const currentActive = nextStates.find((item) => item.state_id === activeState?.state_id);
const fallback = activeState?.state_id === state.state_id ? nextStates.at(-1) || nextStates[0] || null : currentActive || nextStates.at(-1) || nextStates[0] || null;
setActiveState(fallback);
setSelectedStateIds((current) => current.filter((id) => id !== state.state_id));
setStatus(`${state.label} deleted.`);
} catch (error) {
setStatus(error.message);
} finally {
setBusy(false);
}
}
const originalState = states[0] || null;
const viewportTitle = useMemo(() => {
if (!activeState) return "No active state";
return `${activeState.label} · ${activeState.width} x ${activeState.height} ${activeState.color_mode}`;
return `${activeState.label} - ${activeState.width} x ${activeState.height} ${activeState.color_mode}`;
}, [activeState]);
return (
<div className="flex min-h-screen flex-col bg-zinc-950 text-zinc-100 lg:flex-row">
<div className="flex h-screen overflow-hidden bg-zinc-950 text-zinc-100">
<Controls
operations={operations}
selectedOperation={selectedOperation}
@@ -134,13 +145,13 @@ export default function App() {
onApply={handleApply}
onSelectState={(state) => {
setActiveState(state);
if (selectedOperation === "crop") setParams(defaultCropParams(state));
}}
onToggleCombineState={toggleCombineState}
onCombine={handleCombine}
onDeleteState={handleDeleteState}
/>
<main className="flex min-h-0 flex-1 flex-col">
<main className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-800 bg-zinc-950 px-5 py-3">
<div>
<p className="text-xs uppercase tracking-[0.18em] text-cyan-300">Professor Slide Workspace</p>
@@ -149,9 +160,15 @@ export default function App() {
<div className="text-sm text-zinc-400">{busy ? "Working..." : status}</div>
</header>
<div className="relative min-h-0 flex-1 bg-zinc-800">
<div className="relative min-h-0 flex-1 overflow-hidden bg-zinc-800">
<CanvasThumbnail title="S0 Original" imageData={originalState?.image_data} />
<CanvasPane title="Active State" imageData={activeState?.image_data} histogram={activeState?.histogram} transform={transform} onTransform={setTransform} />
<CanvasPane
title="Active State"
imageData={activeState?.image_data}
histogram={activeState?.histogram}
transform={transform}
onTransform={setTransform}
/>
</div>
<HistogramPanel original={originalState?.histogram} processed={activeState?.histogram} />

View File

@@ -6,12 +6,13 @@ vi.mock("./lib/api.js", () => ({
listStates: () => Promise.resolve({ states: [] }),
uploadImage: vi.fn(),
applyStateOperation: vi.fn(),
combineStates: vi.fn()
combineStates: vi.fn(),
deleteState: vi.fn()
}));
describe("App real render", () => {
it("mounts without mocking third-party components", () => {
render(<App />);
expect(screen.getByText("Academic Image Processing Workspace")).toBeInTheDocument();
expect(screen.getByText("Image Processing Workspace")).toBeInTheDocument();
});
});

View File

@@ -6,18 +6,23 @@ vi.mock("react-quick-pinch-zoom", () => ({
}));
vi.mock("./lib/api.js", () => ({
getOperations: () => Promise.resolve({ operations: [] }),
getOperations: () => Promise.resolve({
operations: [
{ id: "histeq", label: "Histogram Equalization", chapter: "Basic", slide_group: "Histogram", params: {}, matrices: [] }
]
}),
listStates: () => Promise.resolve({ states: [] }),
uploadImage: vi.fn(),
applyStateOperation: vi.fn(),
combineStates: vi.fn()
combineStates: vi.fn(),
deleteState: vi.fn()
}));
describe("App", () => {
it("renders the academic workspace immediately", () => {
it("renders the workspace immediately", async () => {
render(<App />);
expect(screen.getByText("Academic Image Processing Workspace")).toBeInTheDocument();
expect(screen.getByText("Image Processing Workspace")).toBeInTheDocument();
expect(screen.getByText("Image States")).toBeInTheDocument();
expect(screen.getByText("Combine Selected States")).toBeInTheDocument();
expect(await screen.findByText("Arithmetic / Logic")).toBeInTheDocument();
});
});

View File

@@ -1,9 +1,14 @@
import { Combine, Crop, Layers, SlidersHorizontal, Upload } from "lucide-react";
import { Combine, Layers, SlidersHorizontal, Trash2, Upload } from "lucide-react";
const REPEAT_SCHEMA = { type: "int", default: 1, min: 1, max: 20, step: 1, label: "N (times)" };
function defaultParams(operation) {
return Object.fromEntries(
Object.entries(operation?.params || {}).map(([key, schema]) => [key, schema.default])
);
return {
_repeat: 1,
...Object.fromEntries(
Object.entries(operation?.params || {}).map(([key, schema]) => [key, schema.default])
)
};
}
function groupOperations(operations) {
@@ -16,15 +21,24 @@ function groupOperations(operations) {
}
function ParamControl({ name, schema, value, onChange }) {
const label = schema.label || name;
function parseNumeric(raw) {
let next = schema.type === "int" ? parseInt(raw || schema.default, 10) : Number(raw);
if (schema.type === "int" && schema.odd && next % 2 === 0) next += 1;
if (Number.isFinite(schema.min)) next = Math.max(schema.min, next);
if (Number.isFinite(schema.max)) next = Math.min(schema.max, next);
return next;
}
if (schema.type === "select") {
return (
<label className="mb-3 block text-xs text-zinc-300">
<span className="mb-1 block">{name}</span>
<span className="mb-1 block">{label}</span>
<select className="w-full border border-zinc-700 bg-zinc-950 px-2 py-2 text-sm" value={value ?? schema.default} onChange={(event) => onChange(name, event.target.value)}>
{schema.choices.map((choice) => (
<option key={choice} value={choice}>{choice}</option>
))}
</select>
{schema.description ? <span className="mt-1 block text-[11px] leading-relaxed text-zinc-500">{schema.description}</span> : null}
</label>
);
}
@@ -32,14 +46,15 @@ function ParamControl({ name, schema, value, onChange }) {
return (
<label className="mb-3 flex items-center gap-2 text-xs text-zinc-300">
<input type="checkbox" checked={Boolean(value ?? schema.default)} onChange={(event) => onChange(name, event.target.checked)} />
{name}
{label}
{schema.description ? <span className="text-[11px] leading-relaxed text-zinc-500">{schema.description}</span> : null}
</label>
);
}
return (
<label className="mb-4 block">
<div className="mb-2 flex items-center justify-between text-xs text-zinc-300">
<span>{name}{schema.odd ? " (odd)" : ""}</span>
<span>{label}{schema.odd ? " (odd)" : ""}</span>
<input
className="w-20 border border-zinc-700 bg-zinc-950 px-2 py-1 text-right tabular-nums"
type="number"
@@ -47,7 +62,7 @@ function ParamControl({ name, schema, value, onChange }) {
max={schema.max}
step={schema.step}
value={value ?? schema.default}
onChange={(event) => onChange(name, schema.type === "int" ? parseInt(event.target.value || schema.default, 10) : Number(event.target.value))}
onChange={(event) => onChange(name, parseNumeric(event.target.value))}
/>
</div>
<input
@@ -56,13 +71,19 @@ function ParamControl({ name, schema, value, onChange }) {
max={schema.max}
step={schema.step}
value={value ?? schema.default}
onChange={(event) => onChange(name, schema.type === "int" ? parseInt(event.target.value, 10) : Number(event.target.value))}
onChange={(event) => onChange(name, parseNumeric(event.target.value))}
className="w-full accent-cyan-400"
/>
{schema.description ? <span className="mt-1 block text-[11px] leading-relaxed text-zinc-500">{schema.description}</span> : null}
</label>
);
}
function shouldShowParam(schema, params) {
if (!schema.show_when) return true;
return params?.[schema.show_when.param] === schema.show_when.value;
}
export default function Controls({
operations,
selectedOperation,
@@ -78,23 +99,91 @@ export default function Controls({
onSelectState,
onToggleCombineState,
onCombine,
onDeleteState,
}) {
const grouped = groupOperations(operations);
const operation = operations.find((item) => item.id === selectedOperation);
const combineActions = [
["add", "add"],
["subtract", "subtract"],
["dot_product", "dot product"],
["average", "average selected"],
["and", "and"],
["or", "or"]
];
function renderMatrixPreview(matrix) {
if (matrix.kernels) {
return (
<div key={matrix.title} className="border border-zinc-800 bg-zinc-900/70 p-2">
<div className="mb-2 text-xs font-semibold text-cyan-200">{matrix.title}</div>
<div className="grid grid-cols-2 gap-2">
{matrix.kernels.map((kernel) => (
<div key={kernel.label}>
<div className="mb-1 text-[11px] text-zinc-400">{kernel.label}</div>
<div className="grid w-max gap-1" style={{ gridTemplateColumns: `repeat(${kernel.matrix[0].length}, minmax(1.75rem, auto))` }}>
{kernel.matrix.flat().map((value, index) => (
<span key={index} className="border border-zinc-700 px-2 py-1 text-center text-xs tabular-nums text-zinc-200">{value}</span>
))}
</div>
</div>
))}
</div>
</div>
);
}
return (
<div key={matrix.title} className="border border-zinc-800 bg-zinc-900/70 p-2">
<div className="mb-1 text-xs font-semibold text-cyan-200">{matrix.title}{matrix.scale ? ` (${matrix.scale})` : ""}</div>
<div className="grid w-max gap-1" style={{ gridTemplateColumns: `repeat(${matrix.matrix[0].length}, minmax(1.75rem, auto))` }}>
{matrix.matrix.flat().map((value, index) => (
<span key={index} className="border border-zinc-700 px-2 py-1 text-center text-xs tabular-nums text-zinc-200">{value}</span>
))}
</div>
</div>
);
}
function renderCombineActions() {
return (
<div className="border border-zinc-800 bg-zinc-950/70 p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-cyan-200">
<Combine size={14} />
Arithmetic / Logic
</div>
<div className="grid grid-cols-2 gap-2">
{combineActions.map(([kind, label]) => (
<button key={kind} disabled={selectedStateIds.length < 2 || busy} onClick={() => onCombine(kind)} className="border border-zinc-700 bg-zinc-900 px-2 py-2 text-xs disabled:cursor-not-allowed disabled:opacity-40">
{label}
</button>
))}
</div>
</div>
);
}
function renderParameterDrawer(item) {
if (selectedOperation !== item.id) return null;
const canApply = Boolean(activeState) && !busy;
const visibleParams = Object.entries(item.params || {}).filter(([, schema]) => shouldShowParam(schema, params));
return (
<div className="border border-emerald-700 bg-zinc-950 p-3">
<div className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-300">
<SlidersHorizontal size={14} />
Parameters
</div>
{Object.entries(item.params || {}).map(([name, schema]) => (
{item.formula ? <div className="mb-3 border border-zinc-800 bg-zinc-900/70 p-2 text-xs leading-relaxed text-zinc-300">{item.formula}</div> : null}
{item.repeatable ? <ParamControl name="_repeat" schema={REPEAT_SCHEMA} value={params._repeat} onChange={onParamChange} /> : null}
{visibleParams.map(([name, schema]) => (
<ParamControl key={name} name={name} schema={schema} value={params[name]} onChange={onParamChange} />
))}
{Object.keys(item.params || {}).length === 0 ? <p className="mb-3 text-sm text-zinc-500">No parameters.</p> : null}
<button disabled={!activeState || busy} onClick={onApply} className="w-full border border-emerald-600 bg-emerald-950/60 px-3 py-2 text-sm font-semibold text-emerald-100 disabled:cursor-not-allowed disabled:opacity-40">
{visibleParams.length === 0 ? <p className="mb-3 text-sm text-zinc-500">No operation-specific parameters.</p> : null}
{item.matrices?.length ? (
<div className="mb-3 space-y-2">
{item.matrices.map(renderMatrixPreview)}
</div>
) : null}
<button disabled={!canApply} onClick={onApply} className="w-full border border-emerald-600 bg-emerald-950/60 px-3 py-2 text-sm font-semibold text-emerald-100 disabled:cursor-not-allowed disabled:opacity-40">
Apply to Active State
</button>
</div>
@@ -102,9 +191,9 @@ export default function Controls({
}
return (
<aside className="flex h-full w-full flex-col border-r border-zinc-800 bg-zinc-950 lg:w-[430px]">
<aside className="flex h-screen w-[430px] shrink-0 flex-col overflow-hidden border-r border-zinc-800 bg-zinc-950">
<div className="border-b border-zinc-800 px-5 py-4">
<h1 className="text-lg font-semibold text-zinc-50">Academic Image Processing Workspace</h1>
<h1 className="text-lg font-semibold text-zinc-50">Image Processing Workspace</h1>
<p className="mt-1 text-xs text-zinc-400">MATLAB-like states organized by lecture chapters</p>
</div>
@@ -129,33 +218,29 @@ export default function Controls({
<div className="truncate text-sm text-zinc-100">{state.label}</div>
<div className="truncate text-xs text-zinc-500">{state.width}x{state.height} - {state.operation}</div>
</button>
<button
type="button"
disabled={state.sequence === 0 || busy}
onClick={() => onDeleteState(state)}
className="border border-zinc-700 p-1.5 text-zinc-400 hover:border-red-500 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-30"
title={state.sequence === 0 ? "S0 cannot be deleted" : `Delete ${state.label}`}
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</section>
<section className="border-b border-zinc-800 p-4">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-zinc-100">
<Combine size={16} className="text-cyan-300" />
Combine Selected States
</div>
<div className="grid grid-cols-3 gap-2">
{["add", "subtract", "dot_product", "average", "and", "or"].map((kind) => (
<button key={kind} disabled={selectedStateIds.length < 2 || busy} onClick={() => onCombine(kind)} className="border border-zinc-700 bg-zinc-900 px-2 py-2 text-xs disabled:cursor-not-allowed disabled:opacity-40">
{kind}
</button>
))}
</div>
</section>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{Object.entries(grouped).map(([chapter, groups]) => (
<details key={chapter} open={chapter.includes("Chapter 3")} className="mb-3 border border-zinc-800 bg-zinc-900/60">
<details key={chapter} open={chapter === "Basic" || chapter.includes("Chapter 3")} className="mb-3 border border-zinc-800 bg-zinc-900/60">
<summary className="cursor-pointer px-3 py-3 text-sm font-semibold text-zinc-100">{chapter}</summary>
<div className="space-y-3 border-t border-zinc-800 p-3">
{chapter === "Basic" ? renderCombineActions() : null}
{Object.entries(groups).map(([slideGroup, items]) => (
<details key={slideGroup} className="border border-zinc-800 bg-zinc-950/70">
<summary className="cursor-pointer px-3 py-2 text-xs font-semibold text-cyan-200">{slideGroup}</summary>
<section key={slideGroup} className="border border-zinc-800 bg-zinc-950/70">
<div className="border-b border-zinc-800 px-3 py-2 text-xs font-semibold text-cyan-200">{slideGroup}</div>
<div className="grid grid-cols-1 gap-2 p-2">
{items.map((item) => (
<div key={item.id} className="space-y-2">
@@ -164,14 +249,13 @@ export default function Controls({
onClick={() => onSelectOperation(item.id, defaultParams(item))}
className={`w-full border px-3 py-2 text-left text-sm ${selectedOperation === item.id ? "border-emerald-500 bg-emerald-950/50 text-emerald-100" : "border-zinc-700 bg-zinc-900 text-zinc-200 hover:bg-zinc-800"} disabled:cursor-not-allowed disabled:opacity-40`}
>
{item.id === "crop" ? <Crop size={14} className="mr-2 inline" /> : null}
{item.label}
</button>
{renderParameterDrawer(item)}
</div>
))}
</div>
</details>
</section>
))}
</div>
</details>

View File

@@ -51,6 +51,14 @@ export async function getStateHistogram(stateId) {
return parseResponse(response);
}
export async function deleteState(stateId) {
const response = await fetch(`${API_BASE}/api/states/${stateId}/`, {
method: "DELETE"
});
if (response.status === 204) return {};
return parseResponse(response);
}
export async function processImage(sessionId, operation, params) {
const response = await fetch(`${API_BASE}/api/process/`, {
method: "POST",

View File

@@ -13,6 +13,13 @@ body {
min-height: 100vh;
background: #09090b;
color: #e4e4e7;
overflow: hidden;
}
html,
body,
#root {
height: 100%;
}
canvas {