Files
Entropyk/apps/web/src/components/Toolbar.tsx
sepehr 3358b74342 Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.
Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 22:46:46 +02:00

349 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useRef, useState } from "react";
import {
Play,
Loader2,
FileDown,
FileUp,
Eraser,
Boxes,
Grid3x3,
RotateCw,
Layers,
FlipHorizontal2,
FlipVertical2,
} from "lucide-react";
import { useDiagramStore } from "@/store/diagramStore";
import { buildScenarioConfig, validateConfig } from "@/lib/configBuilder";
import { computeDofLedger } from "@/lib/dofLedger";
import { simulate } from "@/lib/api";
import type { EntropykNodeData } from "@/lib/configBuilder";
import type { Node } from "@xyflow/react";
import MultiRunPanel from "@/components/panels/MultiRunPanel";
const FLUIDS = ["R410A", "R134a", "R290", "R744", "R32", "R1234yf", "Water", "Air"];
const BACKENDS = ["CoolProp", "Test"];
const SOLVERS = [
{ value: "newton", label: "Newton" },
{ value: "picard", label: "Picard" },
];
const EXAMPLES = [
{ file: "hx_air_water_4port.json", label: "HX airwater" },
{ file: "chiller_flooded_4port_watercooled.json", label: "Chiller flooded" },
{ file: "chiller_watercooled_r410a.json", label: "Chiller R410A" },
{ file: "chiller_aircooled_r134a.json", label: "Chiller air-cooled" },
{ file: "bphx_evaporator_condenser.json", label: "BPHX cycle" },
{ file: "heatpump_r410a_reversing_valve.json", label: "Heat pump 4-way" },
{ file: "chiller_r134a_exv_orifice.json", label: "EXV orifice" },
{ file: "chiller_r410a_full_physics.json", label: "R410A full physics" },
{ file: "capillary_tube_r134a.json", label: "Capillary smoke" },
];
export default function Toolbar() {
const {
nodes,
edges,
fluid,
fluidBackend,
solverStrategy,
maxIterations,
tolerance,
snapToGrid,
selectedNodeId,
setFluid,
setFluidBackend,
setSolverStrategy,
setLastConfig,
toggleSnapToGrid,
rotateNode,
flipNodeH,
flipNodeV,
setResult,
setSimulating,
simulating,
loadFromConfig,
clear,
} = useDiagramStore();
const [issues, setIssues] = useState<string[]>([]);
const [multiOpen, setMultiOpen] = useState(false);
const [exampleFile, setExampleFile] = useState(EXAMPLES[0].file);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const onSimulate = async () => {
const problems = validateConfig(nodes, edges);
const ledger = computeDofLedger(nodes as Node<EntropykNodeData>[], edges);
// Hard block only when over-constrained (no free lunch). Under-constrained
// diagrams are common while editing — surface as a soft issue after simulate
// via the status bar; the CLI still hard-fails on non-square systems.
if (ledger.balance === "over-constrained") {
problems.push(
`DoF over-constrained: ${ledger.nEquations} equations > ${ledger.nUnknowns} unknowns. ` +
`Remove a FIX (quality control, extra outlet closure) or FREE an actuator.`,
);
}
setIssues(problems);
if (problems.length > 0) return;
const config = buildScenarioConfig(nodes, edges, {
fluid,
fluidBackend,
solverStrategy,
maxIterations,
tolerance,
});
setSimulating(true);
setLastConfig(config);
setResult(null, null);
try {
const resp = await simulate(config);
if (resp.ok && resp.result) {
setResult(resp.result, null);
if (resp.result.dof && resp.result.dof.n_equations !== resp.result.dof.n_unknowns) {
setIssues([
`Server DoF: ${resp.result.dof.n_equations} eqs vs ${resp.result.dof.n_unknowns} unk (${resp.result.dof.balance})`,
]);
}
} else setResult(null, resp.error || "Simulation failed");
} catch (e) {
setResult(null, e instanceof Error ? e.message : String(e));
} finally {
setSimulating(false);
}
};
const onLoadExample = async () => {
try {
const res = await fetch(`/examples/${exampleFile}`);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
loadFromConfig(await res.json());
setIssues([]);
} catch (e) {
setIssues([`Could not load example: ${e instanceof Error ? e.message : e}`]);
}
};
const onImportJsonFile = async (file: File | undefined) => {
if (!file) return;
try {
const text = await file.text();
loadFromConfig(JSON.parse(text));
setIssues([]);
} catch (e) {
setIssues([`Could not import JSON: ${e instanceof Error ? e.message : e}`]);
} finally {
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
return (
<header className="flex h-11 items-center gap-2 border-b border-black/40 bg-[var(--bar)] px-3 text-[var(--bar-ink)]">
<div className="flex items-center gap-2 pr-1">
<Boxes size={16} className="text-[var(--bar-accent)]" />
<span className="mono text-[12.5px] font-semibold tracking-[0.14em] text-white">
ENTROPYK
</span>
</div>
<div className="bar-sep" />
<BarField label="Fluid">
<select value={fluid} onChange={(e) => setFluid(e.target.value)} className="bar-select mono">
{FLUIDS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</BarField>
<BarField label="Props">
<select
value={fluidBackend}
onChange={(e) => setFluidBackend(e.target.value)}
className="bar-select mono"
>
{BACKENDS.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</BarField>
<BarField label="Solver">
<select
value={solverStrategy}
onChange={(e) => setSolverStrategy(e.target.value)}
className="bar-select mono"
>
{SOLVERS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</BarField>
<div className="bar-sep" />
{/* Orientation — icon group, works on the selected part */}
<div className="flex items-center overflow-hidden rounded-[4px] border border-white/12">
<button
onClick={() => selectedNodeId && rotateNode(selectedNodeId, 1)}
disabled={!selectedNodeId}
className="bar-icon"
title="Rotation 90° (R) — sélectionne dabord une pièce"
>
<RotateCw size={13} />
</button>
<button
onClick={() => selectedNodeId && flipNodeH(selectedNodeId)}
disabled={!selectedNodeId}
className="bar-icon border-l border-white/12"
title="Miroir horizontal (H)"
>
<FlipHorizontal2 size={13} />
</button>
<button
onClick={() => selectedNodeId && flipNodeV(selectedNodeId)}
disabled={!selectedNodeId}
className="bar-icon border-l border-white/12"
title="Miroir vertical (V)"
>
<FlipVertical2 size={13} />
</button>
<button
onClick={toggleSnapToGrid}
className="bar-icon border-l border-white/12"
data-active={snapToGrid}
title="Aimanter à la grille"
>
<Grid3x3 size={13} />
</button>
</div>
<div className="flex-1" />
{issues.length > 0 && (
<div
className="mono max-w-[240px] truncate text-[11px] text-amber-300"
title={issues.join("\n")}
>
{issues.length} · {issues[0]}
</div>
)}
<select
value={exampleFile}
onChange={(e) => setExampleFile(e.target.value)}
className="bar-select mono max-w-[150px]"
title="Exemple de scénario"
>
{EXAMPLES.map((ex) => (
<option key={ex.file} value={ex.file}>
{ex.label}
</option>
))}
</select>
<button onClick={onLoadExample} className="bar-btn" title="Charger lexemple">
<FileDown size={13} /> Load
</button>
<button onClick={() => fileInputRef.current?.click()} className="bar-btn" title="Importer un JSON">
<FileUp size={13} /> Import
</button>
<button onClick={() => setMultiOpen(true)} className="bar-btn" title="Balayage paramétrique parallèle">
<Layers size={13} /> Multi-run
</button>
<button onClick={clear} className="bar-btn" title="Vider la feuille">
<Eraser size={13} />
</button>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(event) => void onImportJsonFile(event.target.files?.[0])}
/>
<button
onClick={onSimulate}
disabled={simulating}
className="ml-1 flex items-center gap-1.5 rounded-[4px] bg-[var(--bar-accent)] px-4 py-[7px] text-[12.5px] font-semibold text-[#0c1420] transition-all hover:brightness-110 disabled:opacity-50"
>
{simulating ? <Loader2 size={13} className="animate-spin" /> : <Play size={13} />}
{simulating ? "Solving…" : "Solve"}
</button>
{multiOpen && <MultiRunPanel onClose={() => setMultiOpen(false)} />}
<style jsx>{`
.bar-sep {
width: 1px;
height: 20px;
background: rgba(255, 255, 255, 0.12);
margin: 0 4px;
}
.bar-btn {
display: inline-flex;
align-items: center;
gap: 5px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
padding: 5px 9px;
font-size: 12px;
color: var(--bar-ink);
transition: all 0.12s ease;
}
.bar-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.bar-icon {
display: grid;
place-items: center;
width: 28px;
height: 26px;
color: var(--bar-ink);
background: rgba(255, 255, 255, 0.04);
transition: all 0.12s ease;
}
.bar-icon:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.bar-icon:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.bar-icon[data-active="true"] {
background: rgba(94, 176, 255, 0.22);
color: var(--bar-accent);
}
:global(.bar-select) {
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.06);
border-radius: 4px;
padding: 4px 6px;
font-size: 11.5px;
color: var(--bar-ink);
}
:global(.bar-select option) {
color: var(--ink);
background: #fff;
}
`}</style>
</header>
);
}
function BarField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="flex items-center gap-1.5 leading-none">
<span className="mono text-[9px] uppercase tracking-[0.1em] text-white/45">{label}</span>
{children}
</label>
);
}