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>
This commit is contained in:
348
apps/web/src/components/Toolbar.tsx
Normal file
348
apps/web/src/components/Toolbar.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
"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 air–water" },
|
||||
{ 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 d’abord 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 l’exemple">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user