Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Some checks failed
CI / check (push) Has been cancelled
Some checks failed
CI / check (push) Has been cancelled
Capture uncommitted solver robustness work (regularization, domain errors, linear solver lifecycle, tube DP/MSH), web workbench updates, and synced BMAD skills across IDE agent folders before starting BPHX pressure-drop. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
36
apps/web/src/app/error.tsx
Normal file
36
apps/web/src/app/error.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
|
||||
export default function ErrorBoundary({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error("Entropyk Web Error Boundary caught:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center bg-[var(--canvas-bg,#f8fafc)] p-6 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-amber-100 text-amber-600 mb-4">
|
||||
<AlertTriangle size={28} />
|
||||
</div>
|
||||
<h2 className="mono text-lg font-bold text-[var(--ink,#0f172a)]">
|
||||
Une erreur est survenue dans l'interface
|
||||
</h2>
|
||||
<p className="mt-1 max-w-md text-xs text-[var(--ink-dim,#475569)]">
|
||||
{error.message || "Une erreur inattendue s'est produite lors du rendu des composants."}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => reset()}
|
||||
className="mt-5 flex items-center gap-2 rounded bg-[var(--accent,#4f46e5)] px-4 py-2 text-xs font-semibold text-white shadow hover:opacity-90 active:scale-95"
|
||||
>
|
||||
<RefreshCw size={14} /> Réessayer
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
apps/web/src/app/not-found.tsx
Normal file
26
apps/web/src/app/not-found.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { HelpCircle, ArrowLeft } from "lucide-react";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center bg-[var(--canvas-bg,#f8fafc)] p-6 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-indigo-100 text-indigo-600 mb-4">
|
||||
<HelpCircle size={28} />
|
||||
</div>
|
||||
<h2 className="mono text-lg font-bold text-[var(--ink,#0f172a)]">
|
||||
Page Introuvable (404)
|
||||
</h2>
|
||||
<p className="mt-1 max-w-md text-xs text-[var(--ink-dim,#475569)]">
|
||||
La page ou la vue demandée n'existe pas.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-5 flex items-center gap-2 rounded bg-[var(--accent,#4f46e5)] px-4 py-2 text-xs font-semibold text-white shadow hover:opacity-90 active:scale-95"
|
||||
>
|
||||
<ArrowLeft size={14} /> Retour à l'interface principale
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +1,308 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import ComponentPalette from "@/components/palette/ComponentPalette";
|
||||
import TopBar from "@/components/shell/TopBar";
|
||||
import LeftNav from "@/components/shell/LeftNav";
|
||||
import DocumentTabs from "@/components/shell/DocumentTabs";
|
||||
import PropertiesPanel from "@/components/panels/PropertiesPanel";
|
||||
import ResultsPanel from "@/components/panels/ResultsPanel";
|
||||
import DofStatusBar from "@/components/DofStatusBar";
|
||||
import Toolbar from "@/components/Toolbar";
|
||||
import NewModelModal, { type NewDocumentKind } from "@/components/modals/NewModelModal";
|
||||
import { useDiagramStore } from "@/store/diagramStore";
|
||||
import { useScenarioSimulation } from "@/lib/useScenarioSimulation";
|
||||
import { useDocuments } from "@/lib/useDocuments";
|
||||
import { fetchModule, saveModule } from "@/lib/api";
|
||||
|
||||
// React Flow needs the DOM — load only on the client.
|
||||
const Canvas = dynamic(() => import("@/components/canvas/Canvas"), { ssr: false });
|
||||
|
||||
export default function Page() {
|
||||
const { result, simError, lastConfig, nodes, simulating } = useDiagramStore();
|
||||
// Mount gate: avoid dead clicks from SSR/client hydration mismatches
|
||||
// (extensions, HMR, or browser tooling injecting attributes into HTML).
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-[var(--chrome)] text-[13px] text-[var(--ink-dim)]">
|
||||
Chargement du workbench…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <WorkbenchApp />;
|
||||
}
|
||||
|
||||
function WorkbenchApp() {
|
||||
const { result, simError, lastConfig, nodes } = useDiagramStore();
|
||||
|
||||
const { run: runSimulation, issues, simulating } = useScenarioSimulation();
|
||||
const {
|
||||
docs,
|
||||
activeDoc,
|
||||
activeId,
|
||||
workspaceName,
|
||||
switchTo,
|
||||
openModule,
|
||||
openEmptyModule,
|
||||
closeDoc,
|
||||
markClean,
|
||||
newScenario,
|
||||
} = useDocuments();
|
||||
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [newDefaultKind, setNewDefaultKind] = useState<NewDocumentKind>("class");
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const editingModule = activeDoc.kind === "module" ? activeDoc.name : null;
|
||||
|
||||
const scope = useMemo(() => {
|
||||
if (editingModule) return [workspaceName, editingModule];
|
||||
return [workspaceName];
|
||||
}, [workspaceName, editingModule]);
|
||||
|
||||
const openNew = useCallback((kind: NewDocumentKind = "class") => {
|
||||
setNewDefaultKind(kind);
|
||||
setNewOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleSaveModuleEditor = useCallback(async () => {
|
||||
if (!editingModule) return;
|
||||
|
||||
const state = useDiagramStore.getState();
|
||||
const currentNodes = state.nodes;
|
||||
const currentEdges = state.edges;
|
||||
|
||||
const components: Array<Record<string, unknown>> = [];
|
||||
const instances: Array<{
|
||||
of: string;
|
||||
name: string;
|
||||
params?: Record<string, unknown>;
|
||||
}> = [];
|
||||
const atomicEdges: Array<{ from: string; to: string }> = [];
|
||||
const connections: Array<{ from: string; to: string }> = [];
|
||||
|
||||
currentNodes.forEach((n) => {
|
||||
const data = n.data;
|
||||
if (data.type === "ModuleInstance" || data.moduleName) {
|
||||
instances.push({
|
||||
of: String(data.moduleName ?? data.params?.module_name ?? "BaseChiller"),
|
||||
name: data.name,
|
||||
params: data.params,
|
||||
});
|
||||
} else {
|
||||
components.push({
|
||||
type: data.kind,
|
||||
name: data.name,
|
||||
...data.params,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
currentEdges.forEach((e) => {
|
||||
const sNode = currentNodes.find((n) => n.id === e.source);
|
||||
const tNode = currentNodes.find((n) => n.id === e.target);
|
||||
if (!sNode || !tNode) return;
|
||||
|
||||
const sData = sNode.data;
|
||||
const tData = tNode.data;
|
||||
const isInstanceEdge =
|
||||
sData.type === "ModuleInstance" || tData.type === "ModuleInstance";
|
||||
const fromPort = e.sourceHandle ?? "outlet";
|
||||
const toPort = e.targetHandle ?? "inlet";
|
||||
|
||||
if (isInstanceEdge) {
|
||||
connections.push({
|
||||
from: `${sData.name}.${fromPort}`,
|
||||
to: `${tData.name}.${toPort}`,
|
||||
});
|
||||
} else {
|
||||
atomicEdges.push({
|
||||
from: `${sData.name}:${fromPort}`,
|
||||
to: `${tData.name}:${toPort}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Preserve existing port map (component:port) when re-saving a class.
|
||||
let ports: Record<string, string> = {};
|
||||
try {
|
||||
const existing = await fetchModule(editingModule);
|
||||
if (existing && typeof existing === "object" && "ports" in existing) {
|
||||
const p = (existing as { ports?: Record<string, string> }).ports;
|
||||
if (p && typeof p === "object") ports = { ...p };
|
||||
}
|
||||
} catch {
|
||||
/* empty class or offline */
|
||||
}
|
||||
|
||||
const res = await saveModule(editingModule, {
|
||||
params: {},
|
||||
components,
|
||||
instances,
|
||||
edges: atomicEdges,
|
||||
connections,
|
||||
ports,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new Event("entropyk-modules-updated"));
|
||||
}
|
||||
markClean(activeId);
|
||||
setSaveError(null);
|
||||
} else {
|
||||
setSaveError(`Could not save class: ${res.error ?? "unknown error"}`);
|
||||
}
|
||||
}, [editingModule, activeId, markClean]);
|
||||
|
||||
useEffect(() => {
|
||||
(window as unknown as Record<string, unknown>).useDiagramStore = useDiagramStore;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === "Enter" || e.key === "NumpadEnter")) {
|
||||
e.preventDefault();
|
||||
void runSimulation();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [runSimulation]);
|
||||
|
||||
// Clear any stale save error when the user switches documents.
|
||||
useEffect(() => {
|
||||
setSaveError(null);
|
||||
}, [activeId]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<Toolbar />
|
||||
{saveError && (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-[var(--warn)] bg-[var(--warn)]/10 px-3 py-2 text-[12px] text-[var(--warn)]">
|
||||
<span>{saveError}</span>
|
||||
<button type="button" className="toolish" aria-label="Fermer" onClick={() => setSaveError(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
<TopBar
|
||||
scope={scope}
|
||||
unsaved={activeDoc.dirty}
|
||||
editingModule={editingModule}
|
||||
workspaceName={workspaceName}
|
||||
onNavigateBack={() => switchTo("workspace")}
|
||||
onExitModuleEditor={() => closeDoc(activeId)}
|
||||
onSaveModuleEditor={handleSaveModuleEditor}
|
||||
onOpenNew={openNew}
|
||||
onSimulate={runSimulation}
|
||||
simulating={simulating}
|
||||
/>
|
||||
|
||||
<DocumentTabs docs={docs} activeId={activeId} onSwitch={switchTo} onClose={closeDoc} />
|
||||
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<ComponentPalette />
|
||||
<LeftNav
|
||||
onSelectModuleForEdit={openModule}
|
||||
onEnterModule={(className) => void openModule(className)}
|
||||
onOpenNewModelModal={() => openNew("class")}
|
||||
activeClassName={editingModule}
|
||||
/>
|
||||
|
||||
<div className="relative flex-1">
|
||||
<Canvas />
|
||||
{nodes.length === 0 && <EmptyHint />}
|
||||
</div>
|
||||
<main className="relative flex-1">
|
||||
<Canvas onOpenModuleEditor={openModule} />
|
||||
{nodes.length === 0 && (
|
||||
<EmptyHint
|
||||
editingModule={editingModule}
|
||||
workspaceName={workspaceName}
|
||||
onNewClass={() => openNew("class")}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Right column: parameter dialog + results, stacked & scrollable */}
|
||||
<div className="flex w-96 flex-col overflow-y-auto border-l border-[var(--line)] bg-[var(--panel)]">
|
||||
<PropertiesPanel />
|
||||
<div className="h-2 bg-[var(--chrome)]" />
|
||||
<ResultsPanel result={result} error={simError} config={lastConfig} simulating={simulating} />
|
||||
</div>
|
||||
<aside className="flex w-96 flex-col overflow-y-auto border-l border-[var(--line)] bg-[var(--panel)]">
|
||||
<section
|
||||
className="flex flex-col border-b border-[var(--line)]"
|
||||
aria-label="Component inspector"
|
||||
>
|
||||
<div className="flex h-7 items-center justify-between bg-[var(--chrome)] px-3">
|
||||
<span className="eyebrow">Inspector</span>
|
||||
<span className="mono text-[10px] text-[var(--ink-faint)]">
|
||||
{editingModule ? `${editingModule}.ekmod` : workspaceName}
|
||||
</span>
|
||||
</div>
|
||||
<PropertiesPanel />
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col" aria-label="Solve bench">
|
||||
<ResultsPanel
|
||||
result={result}
|
||||
error={simError}
|
||||
config={lastConfig}
|
||||
simulating={simulating}
|
||||
issues={issues}
|
||||
/>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
{/* Live DoF indicator: equations vs unknowns (fix/free discipline) */}
|
||||
|
||||
<DofStatusBar />
|
||||
|
||||
<NewModelModal
|
||||
isOpen={newOpen}
|
||||
onClose={() => setNewOpen(false)}
|
||||
defaultKind={newDefaultKind}
|
||||
onScenarioCreated={(name, clearCanvas) => newScenario(name, clearCanvas)}
|
||||
onClassCreated={(className) => {
|
||||
openEmptyModule(className);
|
||||
setNewOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyHint() {
|
||||
function EmptyHint({
|
||||
editingModule,
|
||||
workspaceName,
|
||||
onNewClass,
|
||||
}: {
|
||||
editingModule?: string | null;
|
||||
workspaceName: string;
|
||||
onNewClass: () => void;
|
||||
}) {
|
||||
const wsLabel =
|
||||
workspaceName === "Untitled" || !workspaceName ? "Workspace" : workspaceName;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 grid place-items-center">
|
||||
<div className="max-w-sm text-center">
|
||||
<p className="text-[15px] font-semibold text-[var(--ink-dim)]">Start your cycle</p>
|
||||
<p className="mt-1 text-[12px] leading-relaxed text-[var(--ink-faint)]">
|
||||
Drag parts from the library onto the sheet, then wire ports. Select a part,
|
||||
then use the top bar, right-click menu, or{" "}
|
||||
<span className="mono">R</span>/<span className="mono">H</span>/
|
||||
<span className="mono">V</span>. Drop a pipe on a line to insert it.
|
||||
</p>
|
||||
<div className="max-w-md px-6 text-center">
|
||||
{editingModule ? (
|
||||
<>
|
||||
<p className="mono text-[15px] font-semibold text-[var(--ink)]">
|
||||
{editingModule}
|
||||
<span className="text-[var(--ink-faint)]">.ekmod</span>
|
||||
</p>
|
||||
<p className="mt-1.5 text-[12px] leading-relaxed text-[var(--ink-dim)]">
|
||||
Édition de la classe — dépose des parts, câble, puis Save. Ensuite
|
||||
retourne au Workspace pour l’instancier.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-[15px] font-semibold text-[var(--ink)]">{wsLabel}</p>
|
||||
<p className="mt-1.5 text-[12px] leading-relaxed text-[var(--ink-dim)]">
|
||||
Canvas Workspace — glisse un modèle depuis Models (à gauche), ou crée-en un
|
||||
nouveau. Tes .ekmod sont des classes Modelica.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewClass}
|
||||
className="pointer-events-auto mt-3 rounded-md bg-[var(--accent)] px-3 py-1.5 text-[12px] font-semibold text-white hover:bg-[var(--accent-hover,#1d4ed8)]"
|
||||
>
|
||||
Nouveau modèle
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { buildDofCoach, type CoachSeverity } from "@/lib/dofCoach";
|
||||
import type { DofBalance } from "@/lib/dofLedger";
|
||||
import type { EntropykNodeData } from "@/lib/configBuilder";
|
||||
import type { Node } from "@xyflow/react";
|
||||
import ApiHealthIndicator from "@/components/shell/ApiHealthIndicator";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
@@ -118,6 +119,7 @@ export default function DofStatusBar() {
|
||||
</span>
|
||||
|
||||
<span className="mono ml-auto hidden items-center gap-2 text-[10px] text-[var(--ink-faint)] md:flex">
|
||||
<ApiHealthIndicator />
|
||||
<Sparkles size={11} className="text-[var(--accent)]" />
|
||||
<span>{coach.tips.length} tip{coach.tips.length > 1 ? "s" : ""}</span>
|
||||
<span>·</span>
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -49,9 +49,20 @@ function shadeHex(hex: string, amount: number): string {
|
||||
return `#${ch(1)}${ch(2)}${ch(3)}`;
|
||||
}
|
||||
|
||||
export default function Canvas() {
|
||||
interface CanvasProps {
|
||||
onOpenModuleEditor?: (moduleName: string) => void;
|
||||
}
|
||||
|
||||
export default function Canvas({ onOpenModuleEditor }: CanvasProps) {
|
||||
const rfInstance = useRef<ReactFlowInstance<Node<EntropykNodeData>, Edge> | null>(null);
|
||||
const [ctxMenu, setCtxMenu] = useState<NodeContextMenuState | null>(null);
|
||||
const [connectionHint, setConnectionHint] = useState<string | null>(null);
|
||||
const hintTimer = useRef<number | null>(null);
|
||||
|
||||
const scheduleClear = () => {
|
||||
if (hintTimer.current) window.clearTimeout(hintTimer.current);
|
||||
hintTimer.current = window.setTimeout(() => setConnectionHint(null), 2500);
|
||||
};
|
||||
|
||||
const {
|
||||
nodes,
|
||||
@@ -128,6 +139,21 @@ export default function Canvas() {
|
||||
y: e.clientY,
|
||||
});
|
||||
|
||||
// Module instance from the workspace palette.
|
||||
if (type.startsWith("ModuleInstance:")) {
|
||||
const moduleName = type.slice("ModuleInstance:".length);
|
||||
const portsStr = e.dataTransfer.getData("application/entropyk-ports");
|
||||
let module_ports: string[] = [];
|
||||
try {
|
||||
if (portsStr) module_ports = JSON.parse(portsStr);
|
||||
} catch {}
|
||||
addComponent("ModuleInstance", position, {
|
||||
module_name: moduleName,
|
||||
module_ports: JSON.stringify(module_ports),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Pipe / duct dropped on a wire → splice into the circuit (Modelica-style).
|
||||
if (isPipeType(type)) {
|
||||
const prefer = pipeMediaKind(type, defaultParams(type));
|
||||
@@ -148,10 +174,21 @@ export default function Canvas() {
|
||||
);
|
||||
|
||||
const isValidConnection = useCallback((c: Connection | Edge) => {
|
||||
if (!c.source || !c.target || c.source === c.target) return false;
|
||||
if (!c.source || !c.target) return false;
|
||||
if (c.source === c.target) {
|
||||
setConnectionHint("Connexion refusée : même composant.");
|
||||
scheduleClear();
|
||||
return false;
|
||||
}
|
||||
const srcOk = !c.sourceHandle || portRole(c.sourceHandle) === "source";
|
||||
const tgtOk = !c.targetHandle || portRole(c.targetHandle) === "target";
|
||||
return srcOk && tgtOk;
|
||||
if (!srcOk || !tgtOk) {
|
||||
setConnectionHint("Connexion refusée : branche une sortie vers une entrée.");
|
||||
scheduleClear();
|
||||
return false;
|
||||
}
|
||||
setConnectionHint(null);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@@ -254,12 +291,18 @@ export default function Canvas() {
|
||||
setCtxMenu(null);
|
||||
setSelected(node.id);
|
||||
}}
|
||||
onNodeDoubleClick={(_, node) => {
|
||||
if (node.data.type === "ModuleInstance") {
|
||||
const mName = String(node.data.params.module_name ?? "");
|
||||
if (mName) onOpenModuleEditor?.(mName);
|
||||
}
|
||||
}}
|
||||
onNodeContextMenu={onNodeContextMenu}
|
||||
onPaneClick={() => {
|
||||
setCtxMenu(null);
|
||||
setSelected(null);
|
||||
}}
|
||||
deleteKeyCode={["Delete", "Backspace"]}
|
||||
deleteKeyCode={["Delete"]}
|
||||
defaultEdgeOptions={{ type: "smoothstep" }}
|
||||
connectionRadius={34}
|
||||
snapToGrid={snapToGrid}
|
||||
@@ -286,6 +329,15 @@ export default function Canvas() {
|
||||
/>
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
{connectionHint && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-md border border-[var(--warn)] bg-[var(--panel)] px-3 py-1.5 text-[11px] text-[var(--warn)] shadow-md"
|
||||
>
|
||||
{connectionHint}
|
||||
</div>
|
||||
)}
|
||||
<MediaLegend />
|
||||
{selectedNode && (
|
||||
<OrientationBar
|
||||
@@ -355,7 +407,7 @@ function OrientationBar({
|
||||
>
|
||||
<FlipVertical2 size={13} />
|
||||
</button>
|
||||
<style jsx>{`
|
||||
<style>{`
|
||||
.orient-btn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
@@ -19,8 +19,9 @@ describe("ComponentIcon", () => {
|
||||
expect(dashed).not.toBeNull();
|
||||
});
|
||||
|
||||
it("tints the glyph with the provided colour", () => {
|
||||
const { container } = render(<ComponentIcon type="IsentropicCompressor" color="#abcdef" />);
|
||||
expect(container.innerHTML).toContain("#abcdef");
|
||||
it("renders the dashed placeholder for undefined type without crashing", () => {
|
||||
const { container } = render(<ComponentIcon type={undefined as unknown as string} color="#123456" />);
|
||||
const dashed = container.querySelector("[stroke-dasharray]");
|
||||
expect(dashed).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
*/
|
||||
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { hxGlyphKey } from "@/lib/hxFamily";
|
||||
import { hxGlyphKey, type HxVisualOpts } from "@/lib/hxFamily";
|
||||
|
||||
type IconProps = { color: string };
|
||||
type IconProps = { color: string; secondaryFluid?: string | null };
|
||||
|
||||
const INK = "#2f3e52";
|
||||
const INK_FAINT = "rgba(47,62,82,0.34)";
|
||||
@@ -195,9 +195,6 @@ const GLYPHS: Record<string, (c: string) => ReactElement> = {
|
||||
</Frame>
|
||||
),
|
||||
|
||||
condenser: (c) => GLYPHS.hx_shell_cond(c),
|
||||
evaporator: (c) => GLYPHS.hx_dx(c),
|
||||
|
||||
// ── Valve — ISO bowtie + actuator stem ─────────────────────
|
||||
valve: (c) => (
|
||||
<Frame>
|
||||
@@ -208,6 +205,19 @@ const GLYPHS: Record<string, (c: string) => ReactElement> = {
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Reversing valve (4-way heat-pump) — body + two-way arrows ⇄ ──
|
||||
reversing: (c) => (
|
||||
<Frame>
|
||||
<rect x="24" y="32" width="52" height="36" rx="3" {...line(2)} fill="none" />
|
||||
<line x1="6" y1="50" x2="24" y2="50" {...line(2)} />
|
||||
<line x1="76" y1="50" x2="94" y2="50" {...line(2)} />
|
||||
<line x1="36" y1="44" x2="60" y2="44" {...accent(c, 1.8)} />
|
||||
<path d="M57 41 L60 44 L57 47" {...accent(c, 1.8)} fill="none" />
|
||||
<line x1="60" y1="56" x2="36" y2="56" {...accent(c, 1.8)} />
|
||||
<path d="M39 53 L36 56 L39 59" {...accent(c, 1.8)} fill="none" />
|
||||
</Frame>
|
||||
),
|
||||
|
||||
// ── Pump — circle + filled flow triangle ───────────────────
|
||||
pump: (c) => (
|
||||
<Frame>
|
||||
@@ -298,26 +308,32 @@ const GLYPHS: Record<string, (c: string) => ReactElement> = {
|
||||
),
|
||||
};
|
||||
|
||||
function glyphKey(type: string): keyof typeof GLYPHS {
|
||||
const hx = hxGlyphKey(type);
|
||||
if (hx && hx in GLYPHS) return hx;
|
||||
function glyphKey(type?: string, opts?: HxVisualOpts): keyof typeof GLYPHS {
|
||||
const t = String(type || "");
|
||||
const hx = hxGlyphKey(t, opts);
|
||||
if (hx && hx in GLYPHS) return hx as keyof typeof GLYPHS;
|
||||
|
||||
if (type === "SaturatedController") return "control";
|
||||
if (type.includes("Compressor")) return "compressor";
|
||||
if (type === "HeatExchanger") return "hx";
|
||||
if (type.includes("Valve")) return "valve";
|
||||
if (type === "Pump") return "pump";
|
||||
if (type === "Fan") return "fan";
|
||||
if (type === "Pipe" || type.includes("Pipe") || type === "AirDuct") return "pipe";
|
||||
if (type === "Drum") return "drum";
|
||||
if (type === "FlowSplitter") return "splitter";
|
||||
if (type === "FlowMerger") return "merger";
|
||||
if (type.endsWith("Source")) return "source";
|
||||
if (type.endsWith("Sink")) return "sink";
|
||||
if (t === "SaturatedController") return "control";
|
||||
if (t.includes("Compressor")) return "compressor";
|
||||
if (t === "HeatExchanger") return "hx";
|
||||
if (t === "ReversingValve") return "reversing";
|
||||
if (t.includes("Valve")) return "valve";
|
||||
if (t === "Pump") return "pump";
|
||||
if (t === "Fan") return "fan";
|
||||
if (t === "Pipe" || t.includes("Pipe") || t === "AirDuct") return "pipe";
|
||||
if (t === "Drum") return "drum";
|
||||
if (t === "FlowSplitter") return "splitter";
|
||||
if (t === "FlowMerger") return "merger";
|
||||
if (t.endsWith("Source")) return "source";
|
||||
if (t.endsWith("Sink")) return "sink";
|
||||
return "placeholder";
|
||||
}
|
||||
|
||||
export function ComponentIcon({ type, color }: { type: string } & IconProps) {
|
||||
const draw = GLYPHS[glyphKey(type)] ?? GLYPHS.placeholder;
|
||||
export function ComponentIcon({
|
||||
type,
|
||||
color,
|
||||
secondaryFluid,
|
||||
}: { type?: string } & IconProps) {
|
||||
const draw = GLYPHS[glyphKey(type, { secondaryFluid })] ?? GLYPHS.placeholder;
|
||||
return draw(color);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import { COMPONENT_BY_TYPE, portSide, portRole, portLabel, nodeSize } from "@/lib/componentMeta";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { Handle, Position, useUpdateNodeInternals, type NodeProps } from "@xyflow/react";
|
||||
import { COMPONENT_BY_TYPE, portSide, portRole, portLabel, nodeSize, genericMeta } from "@/lib/componentMeta";
|
||||
import { mediaColor, mediaForPort } from "@/lib/mediaStyle";
|
||||
import { effectiveSide, type Side } from "@/lib/orientation";
|
||||
import type { EntropykNodeData } from "@/store/diagramStore";
|
||||
import { hxFamily } from "@/lib/hxFamily";
|
||||
import { ComponentIcon } from "./ComponentIcon";
|
||||
import { Package } from "lucide-react";
|
||||
import { fetchModule } from "@/lib/api";
|
||||
|
||||
const SIDE_POSITION: Record<Side, Position> = {
|
||||
left: Position.Left,
|
||||
@@ -16,24 +18,37 @@ const SIDE_POSITION: Record<Side, Position> = {
|
||||
bottom: Position.Bottom,
|
||||
};
|
||||
|
||||
function EntropykNode({ data, selected }: NodeProps) {
|
||||
function EntropykNode({ data, id, selected }: NodeProps) {
|
||||
const d = data as unknown as EntropykNodeData;
|
||||
const meta = COMPONENT_BY_TYPE[d.type];
|
||||
const { w: BOX_W, h: BOX_H } = nodeSize(d.type);
|
||||
const rotation = d.rotation ?? 0;
|
||||
const flipH = !!d.flipH;
|
||||
const flipV = !!d.flipV;
|
||||
const isController = d.type === "SaturatedController";
|
||||
const family = hxFamily(d.type);
|
||||
const nodeType = String(d?.kind || d?.type || "");
|
||||
const isModule = nodeType === "ModuleInstance" || !!d?.moduleName;
|
||||
const rotation = d?.rotation ?? 0;
|
||||
const flipH = !!d?.flipH;
|
||||
const flipV = !!d?.flipV;
|
||||
|
||||
if (!meta) {
|
||||
return (
|
||||
<div className="rounded border border-[var(--hot)] bg-white px-2 py-1 text-[11px] text-[var(--hot)]">
|
||||
Unknown: {d.type}
|
||||
</div>
|
||||
);
|
||||
// React Flow caches Handle positions on first render; flipping/rotating a
|
||||
// node changes the effective side of each port, but the renderer keeps
|
||||
// routing edges from the stale position until React Flow is told to
|
||||
// re-measure. `useUpdateNodeInternals` is the documented helper for this:
|
||||
// it invalidates the cached Handle positions of the node and re-routes
|
||||
// every connected edge from the new Handle sides.
|
||||
const updateNodeInternals = useUpdateNodeInternals();
|
||||
useEffect(() => {
|
||||
updateNodeInternals(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, rotation, flipH, flipV]);
|
||||
|
||||
if (isModule) {
|
||||
return <ModuleNodeFace data={d} selected={selected} />;
|
||||
}
|
||||
|
||||
const meta = COMPONENT_BY_TYPE[nodeType] ?? genericMeta(nodeType);
|
||||
const { w: BOX_W, h: BOX_H } = nodeSize(nodeType);
|
||||
const isController = nodeType === "SaturatedController";
|
||||
const secondaryFluid =
|
||||
d?.params?.secondary_fluid != null ? String(d.params.secondary_fluid) : null;
|
||||
const family = hxFamily(nodeType, { secondaryFluid });
|
||||
|
||||
const accent = family?.accent ?? meta.color;
|
||||
|
||||
const placement: Record<string, { side: Side; offset: number }> = {};
|
||||
@@ -78,7 +93,7 @@ function EntropykNode({ data, selected }: NodeProps) {
|
||||
className="h-full w-full"
|
||||
style={{ transform: iconTransform || undefined }}
|
||||
>
|
||||
<ComponentIcon type={d.type} color={accent} />
|
||||
<ComponentIcon type={nodeType} color={accent} secondaryFluid={secondaryFluid} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -94,7 +109,7 @@ function EntropykNode({ data, selected }: NodeProps) {
|
||||
};
|
||||
return (
|
||||
<Handle
|
||||
key={port}
|
||||
key={`${port}::${side}`}
|
||||
type={portRole(port)}
|
||||
position={SIDE_POSITION[side]}
|
||||
id={port}
|
||||
@@ -222,3 +237,148 @@ export default memo(EntropykNode, (prev, next) => {
|
||||
a.params === b.params
|
||||
);
|
||||
});
|
||||
|
||||
function ModuleNodeFace({
|
||||
data,
|
||||
selected,
|
||||
}: {
|
||||
data: EntropykNodeData;
|
||||
selected?: boolean;
|
||||
}) {
|
||||
const moduleName = String(data.params.module_name ?? "?");
|
||||
const updateNodeInternals = useUpdateNodeInternals();
|
||||
|
||||
const [ports, setPorts] = useState<string[]>(() => {
|
||||
if (typeof data.params.module_ports === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(data.params.module_ports);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch {}
|
||||
}
|
||||
if (Array.isArray(data.params.module_ports)) return data.params.module_ports as string[];
|
||||
if (Array.isArray(data.module_ports)) return data.module_ports as string[];
|
||||
return [];
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (moduleName !== "?") {
|
||||
fetchModule(moduleName).then((modData) => {
|
||||
if (modData && typeof modData === "object") {
|
||||
if ("ports" in modData) {
|
||||
const pMap = (modData as { ports?: Record<string, string> }).ports;
|
||||
if (pMap && typeof pMap === "object") {
|
||||
setPorts(Object.keys(pMap));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [moduleName]);
|
||||
|
||||
useEffect(() => {
|
||||
updateNodeInternals(data.name);
|
||||
}, [data.name, ports.length, updateNodeInternals]);
|
||||
|
||||
const leftPorts: string[] = [];
|
||||
const rightPorts: string[] = [];
|
||||
|
||||
ports.forEach((p) => {
|
||||
const isOutlet = /out|discharge|outlet/i.test(p);
|
||||
const isInlet = /in|suction|inlet/i.test(p);
|
||||
if (isOutlet && !isInlet) {
|
||||
rightPorts.push(p);
|
||||
} else if (isInlet && !isOutlet) {
|
||||
leftPorts.push(p);
|
||||
} else {
|
||||
if (leftPorts.length <= rightPorts.length) leftPorts.push(p);
|
||||
else rightPorts.push(p);
|
||||
}
|
||||
});
|
||||
|
||||
const maxPortCount = Math.max(leftPorts.length, rightPorts.length, 1);
|
||||
const BOX_W = 140;
|
||||
const BOX_H = Math.max(100, maxPortCount * 28 + 40);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center" style={{ width: BOX_W }}>
|
||||
<div
|
||||
className="ek-node relative flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-[var(--accent)] bg-white shadow-sm transition-all"
|
||||
style={{
|
||||
width: BOX_W,
|
||||
height: BOX_H,
|
||||
outline: selected ? "2px dashed var(--accent)" : "1.5px dashed transparent",
|
||||
outlineOffset: 4,
|
||||
}}
|
||||
title={`Double-clic pour éditer la classe ${moduleName}`}
|
||||
>
|
||||
<Package size={32} className="text-[var(--accent)]" />
|
||||
<span className="mono mt-1 max-w-[110px] truncate text-[10px] font-semibold text-[var(--ink)]">
|
||||
{moduleName}
|
||||
</span>
|
||||
|
||||
{leftPorts.map((port, idx) => {
|
||||
const offset = (idx + 1) / (leftPorts.length + 1);
|
||||
return (
|
||||
<div key={`left-${port}`}>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={port}
|
||||
style={{
|
||||
top: `${offset * 100}%`,
|
||||
borderColor: "var(--accent)",
|
||||
background: "var(--accent)",
|
||||
}}
|
||||
className="media-handle"
|
||||
isConnectable
|
||||
title={port}
|
||||
/>
|
||||
<span
|
||||
className="mono pointer-events-none absolute left-2 text-[8px] text-[var(--ink-dim)]"
|
||||
style={{ top: `${offset * 100}%`, transform: "translateY(-50%)" }}
|
||||
>
|
||||
{port}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{rightPorts.map((port, idx) => {
|
||||
const offset = (idx + 1) / (rightPorts.length + 1);
|
||||
return (
|
||||
<div key={`right-${port}`}>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={port}
|
||||
style={{
|
||||
top: `${offset * 100}%`,
|
||||
borderColor: "var(--accent)",
|
||||
background: "var(--accent)",
|
||||
}}
|
||||
className="media-handle"
|
||||
isConnectable
|
||||
title={port}
|
||||
/>
|
||||
<span
|
||||
className="mono pointer-events-none absolute right-2 text-[8px] text-[var(--ink-dim)]"
|
||||
style={{ top: `${offset * 100}%`, transform: "translateY(-50%)" }}
|
||||
>
|
||||
{port}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 flex max-w-[160px] flex-col items-center leading-tight">
|
||||
<span className="mono truncate text-[11px] font-bold text-[var(--ink)]">
|
||||
{data.name}
|
||||
</span>
|
||||
<span className="truncate text-[8.5px] font-medium text-[var(--ink-faint)]">
|
||||
Module ({moduleName}) · C{data.circuit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
331
apps/web/src/components/modals/CreateModuleModal.tsx
Normal file
331
apps/web/src/components/modals/CreateModuleModal.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, PackagePlus, Plus, Trash2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { useDiagramStore, type EntropykNodeData } from "@/store/diagramStore";
|
||||
import { COMPONENT_BY_TYPE } from "@/lib/componentMeta";
|
||||
import { saveModule } from "@/lib/api";
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
interface CreateModuleModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
interface ExposedPortItem {
|
||||
id: string;
|
||||
exposedName: string;
|
||||
compPortRef: string; // e.g. "evap:secondary_inlet"
|
||||
}
|
||||
|
||||
export default function CreateModuleModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: CreateModuleModalProps) {
|
||||
const { nodes, edges } = useDiagramStore();
|
||||
const [moduleName, setModuleName] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// Collect all available component:port references from current canvas nodes
|
||||
const availableComponentPorts: Array<{ label: string; ref: string }> = [];
|
||||
nodes.forEach((n) => {
|
||||
const data = n.data as unknown as EntropykNodeData;
|
||||
if (data.type === "ModuleInstance") return; // Skip nested module instances for flat component extraction
|
||||
const meta = COMPONENT_BY_TYPE[data.type];
|
||||
const ports = meta?.ports ?? ["inlet", "outlet"];
|
||||
ports.forEach((p) => {
|
||||
availableComponentPorts.push({
|
||||
label: `${data.name} (${p})`,
|
||||
ref: `${data.name}:${p}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Default exposed ports proposal (auto-detect secondary / boundary ports)
|
||||
const [exposedPorts, setExposedPorts] = useState<ExposedPortItem[]>(() => {
|
||||
const items: ExposedPortItem[] = [];
|
||||
nodes.forEach((n) => {
|
||||
const data = n.data as unknown as EntropykNodeData;
|
||||
if (data.type === "Evaporator") {
|
||||
items.push({ id: `e1`, exposedName: "chw_in", compPortRef: `${data.name}:secondary_inlet` });
|
||||
items.push({ id: `e2`, exposedName: "chw_out", compPortRef: `${data.name}:secondary_outlet` });
|
||||
} else if (data.type === "Condenser") {
|
||||
items.push({ id: `c1`, exposedName: "cond_water_in", compPortRef: `${data.name}:secondary_inlet` });
|
||||
items.push({ id: `c2`, exposedName: "cond_water_out", compPortRef: `${data.name}:secondary_outlet` });
|
||||
}
|
||||
});
|
||||
if (items.length === 0 && availableComponentPorts.length > 0) {
|
||||
items.push({
|
||||
id: "p1",
|
||||
exposedName: "inlet",
|
||||
compPortRef: availableComponentPorts[0].ref,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const addExposedPort = () => {
|
||||
const defaultRef = availableComponentPorts[0]?.ref ?? "";
|
||||
setExposedPorts((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `port_${Date.now()}`,
|
||||
exposedName: `port_${prev.length + 1}`,
|
||||
compPortRef: defaultRef,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeExposedPort = (id: string) => {
|
||||
setExposedPorts((prev) => prev.filter((p) => p.id !== id));
|
||||
};
|
||||
|
||||
const updateExposedPort = (id: string, field: "exposedName" | "compPortRef", val: string) => {
|
||||
setExposedPorts((prev) =>
|
||||
prev.map((p) => (p.id === id ? { ...p, [field]: val } : p)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const cleanName = moduleName.trim().replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
if (!cleanName) {
|
||||
setError("Veuillez saisir un nom de module valide (alphanumérique).");
|
||||
return;
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
setError("Le canvas ne contient aucun composant ni module à inclure.");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const components: Array<Record<string, unknown>> = [];
|
||||
const instances: Array<{ of: string; name: string; circuit: number; params: Record<string, unknown> }> = [];
|
||||
const atomicEdges: Array<{ from: string; to: string }> = [];
|
||||
const connections: Array<{ from: string; to: string }> = [];
|
||||
|
||||
nodes.forEach((n) => {
|
||||
const d = n.data as unknown as EntropykNodeData;
|
||||
if (d.type === "ModuleInstance") {
|
||||
instances.push({
|
||||
of: String(d.params.module_name ?? "BaseChiller"),
|
||||
name: d.name,
|
||||
circuit: d.circuit,
|
||||
params: {},
|
||||
});
|
||||
} else {
|
||||
const { type, name, ...params } = d;
|
||||
components.push({
|
||||
type: d.type,
|
||||
name: d.name,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
edges.forEach((e) => {
|
||||
const sNode = nodes.find((n) => n.id === e.source);
|
||||
const tNode = nodes.find((n) => n.id === e.target);
|
||||
if (!sNode || !tNode) return;
|
||||
|
||||
const sData = sNode.data as unknown as EntropykNodeData;
|
||||
const tData = tNode.data as unknown as EntropykNodeData;
|
||||
|
||||
const isInstanceEdge = sData.type === "ModuleInstance" || tData.type === "ModuleInstance";
|
||||
const fromPort = e.sourceHandle ?? "outlet";
|
||||
const toPort = e.targetHandle ?? "inlet";
|
||||
|
||||
if (isInstanceEdge) {
|
||||
connections.push({ from: `${sData.name}.${fromPort}`, to: `${tData.name}.${toPort}` });
|
||||
} else {
|
||||
atomicEdges.push({ from: `${sData.name}:${fromPort}`, to: `${tData.name}:${toPort}` });
|
||||
}
|
||||
});
|
||||
|
||||
const portsMap: Record<string, string> = {};
|
||||
exposedPorts.forEach((p) => {
|
||||
if (p.exposedName.trim() && p.compPortRef) {
|
||||
portsMap[p.exposedName.trim()] = p.compPortRef;
|
||||
}
|
||||
});
|
||||
|
||||
const ekmodContent = {
|
||||
params: {},
|
||||
components,
|
||||
instances,
|
||||
edges: atomicEdges,
|
||||
connections,
|
||||
ports: portsMap,
|
||||
};
|
||||
|
||||
const res = await saveModule(cleanName, ekmodContent);
|
||||
setLoading(false);
|
||||
|
||||
if (res.ok) {
|
||||
setSuccess(true);
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new Event("entropyk-modules-updated"));
|
||||
}
|
||||
setTimeout(() => {
|
||||
setSuccess(false);
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
}, 300);
|
||||
} else {
|
||||
setError(res.error || "Erreur lors de la création du module.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-xs p-4 overscroll-contain"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Créer un module"
|
||||
>
|
||||
<div className="flex w-full max-w-lg flex-col rounded-lg border border-[var(--line)] bg-white shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-[var(--line)] px-4 py-3 bg-[var(--chrome)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<PackagePlus size={18} className="text-[var(--accent)]" />
|
||||
<h3 className="mono text-[13px] font-bold text-[var(--ink)]">
|
||||
Save as class (.ekmod)
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-[var(--ink-faint)] hover:bg-[var(--chrome-2)] hover:text-[var(--ink)]"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4 space-y-4 max-h-[75vh] overflow-y-auto">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded border border-red-200 bg-red-50 p-2.5 text-[11px] text-red-700">
|
||||
<AlertCircle size={15} className="shrink-0 text-red-500" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 rounded border border-emerald-200 bg-emerald-50 p-2.5 text-[11px] text-emerald-700">
|
||||
<CheckCircle2 size={15} className="shrink-0 text-emerald-500" />
|
||||
<span>Module sauvegardé avec succès dans `.entropyk/modules/` !</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Module Name */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-[var(--ink-dim)] mb-1">
|
||||
Class name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={moduleName}
|
||||
onChange={(e) => setModuleName(e.target.value)}
|
||||
placeholder="e.g. DualChillerCircuit"
|
||||
className="w-full rounded border border-[var(--line)] px-3 py-1.5 mono text-[12px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
<p className="mt-1 mono text-[10px] text-[var(--ink-faint)]">
|
||||
.entropyk/modules/{moduleName || "Name"}.ekmod
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Exposed Ports Selector */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-[11px] font-semibold text-[var(--ink-dim)]">
|
||||
Ports exposés (Handles externes)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addExposedPort}
|
||||
className="flex items-center gap-1 rounded bg-[var(--accent)]/10 px-2 py-0.5 text-[10px] font-semibold text-[var(--accent)] hover:bg-[var(--accent)]/20"
|
||||
>
|
||||
<Plus size={11} /> Ajouter un port
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{exposedPorts.length === 0 ? (
|
||||
<p className="text-[10px] text-[var(--ink-faint)] italic py-1">
|
||||
Aucun port exposé sélectionné. Cliquez sur "Ajouter un port".
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2 border border-[var(--line)] rounded p-2 bg-[var(--chrome)]/30">
|
||||
{exposedPorts.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={item.exposedName}
|
||||
onChange={(e) => updateExposedPort(item.id, "exposedName", e.target.value)}
|
||||
placeholder="Nom exposé (ex: chw_in)"
|
||||
className="w-1/3 rounded border border-[var(--line)] px-2 py-1 mono text-[10.5px] outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
<span className="text-[11px] text-[var(--ink-faint)]">→</span>
|
||||
<select
|
||||
value={item.compPortRef}
|
||||
onChange={(e) => updateExposedPort(item.id, "compPortRef", e.target.value)}
|
||||
className="flex-1 rounded border border-[var(--line)] px-2 py-1 mono text-[10.5px] outline-none focus:border-[var(--accent)]"
|
||||
>
|
||||
{availableComponentPorts.map((cp) => (
|
||||
<option key={cp.ref} value={cp.ref}>
|
||||
{cp.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeExposedPort(item.id)}
|
||||
className="rounded p-1 text-[var(--ink-faint)] hover:text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-[var(--line)] px-4 py-3 bg-[var(--chrome)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-[var(--line)] px-3 py-1.5 text-[11px] font-medium text-[var(--ink-dim)] hover:bg-[var(--chrome-2)]"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={loading || success}
|
||||
className="flex items-center gap-1.5 rounded bg-[var(--accent)] px-3 py-1.5 text-[11px] font-semibold text-white shadow-sm hover:opacity-90 active:scale-95 disabled:opacity-50"
|
||||
>
|
||||
<PackagePlus size={14} />
|
||||
<span>{loading ? "…" : "Valider"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
193
apps/web/src/components/modals/NewModelModal.tsx
Normal file
193
apps/web/src/components/modals/NewModelModal.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { PackagePlus, X } from "lucide-react";
|
||||
import { saveModule } from "@/lib/api";
|
||||
|
||||
export type NewDocumentKind = "scenario" | "class";
|
||||
|
||||
interface NewModelModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Kept for callers; always creates a model in the workspace. */
|
||||
defaultKind?: NewDocumentKind;
|
||||
onScenarioCreated?: (name: string, clearCanvas: boolean) => void;
|
||||
onClassCreated: (className: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a model inside the Workspace.
|
||||
* Writes an empty .ekmod (CLI SubsystemTemplate) and opens it for editing.
|
||||
*/
|
||||
export default function NewModelModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onClassCreated,
|
||||
}: NewModelModalProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setName("");
|
||||
setDescription("");
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
const t = window.setTimeout(() => inputRef.current?.focus(), 50);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [isOpen]);
|
||||
|
||||
const cleanName = name.trim();
|
||||
|
||||
const handleValidate = async () => {
|
||||
// Same cleaning as ui-server save_module_file — no client-side name blacklist.
|
||||
const safeName = cleanName
|
||||
.split("")
|
||||
.filter((c) => /[A-Za-z0-9_-]/.test(c))
|
||||
.join("");
|
||||
|
||||
if (!safeName) {
|
||||
setError("Saisis un nom de modèle.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const content = {
|
||||
description: description.trim() || undefined,
|
||||
params: {},
|
||||
components: [],
|
||||
instances: [],
|
||||
edges: [],
|
||||
connections: [],
|
||||
ports: {} as Record<string, string>,
|
||||
};
|
||||
|
||||
const res = await saveModule(safeName, content);
|
||||
setLoading(false);
|
||||
|
||||
if (!res.ok) {
|
||||
setError(res.error || "Impossible de créer le modèle.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new Event("entropyk-modules-updated"));
|
||||
}
|
||||
onClassCreated(safeName);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-[rgba(23,32,43,0.35)] p-4"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="new-model-title"
|
||||
className="relative w-full max-w-md rounded-lg border border-[var(--line)] bg-[var(--panel)] shadow-md"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--line)] bg-[var(--chrome)] px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<PackagePlus size={18} className="text-[var(--accent)]" />
|
||||
<div>
|
||||
<h3 id="new-model-title" className="text-[14px] font-semibold text-[var(--ink)]">
|
||||
Nouveau modèle
|
||||
</h3>
|
||||
<p className="text-[11px] text-[var(--ink-faint)]">
|
||||
Classe Modelica · fichier .ekmod
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-[var(--ink-faint)] hover:bg-[var(--chrome-2)] hover:text-[var(--ink)]"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-4">
|
||||
{error && (
|
||||
<div className="rounded border border-red-200 bg-red-50 px-3 py-2 text-[12px] text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="new-model-name" className="mb-1 block text-[12px] font-medium text-[var(--ink-dim)]">
|
||||
Nom du modèle
|
||||
</label>
|
||||
<input
|
||||
id="new-model-name"
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleValidate();
|
||||
}
|
||||
}}
|
||||
placeholder=""
|
||||
className="mono w-full rounded border border-[var(--line-strong)] bg-white px-3 py-2 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
{cleanName.length > 0 && (
|
||||
<p className="mono mt-1 text-[11px] text-[var(--ink-faint)]">
|
||||
.entropyk/modules/{cleanName}.ekmod
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="new-model-desc" className="mb-1 block text-[12px] font-medium text-[var(--ink-dim)]">
|
||||
Description <span className="font-normal text-[var(--ink-faint)]">(optionnel)</span>
|
||||
</label>
|
||||
<input
|
||||
id="new-model-desc"
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Circuit chillé eau / eau"
|
||||
className="w-full rounded border border-[var(--line-strong)] bg-white px-3 py-2 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-[var(--line)] bg-[var(--chrome)] px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-md border border-[var(--line)] bg-white px-4 py-2 text-[13px] font-medium text-[var(--ink)] hover:bg-[var(--chrome-2)]"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleValidate()}
|
||||
disabled={loading}
|
||||
className="min-w-[100px] rounded-md bg-[var(--accent)] px-4 py-2 text-[13px] font-semibold text-white shadow-sm hover:brightness-110 disabled:cursor-wait"
|
||||
>
|
||||
{loading ? "…" : "Valider"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
apps/web/src/components/modals/WorkspaceHelpModal.tsx
Normal file
125
apps/web/src/components/modals/WorkspaceHelpModal.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { X, FilePlus, Play, PackagePlus, ArrowRight, BookOpen, Layers, CheckCircle2 } from "lucide-react";
|
||||
|
||||
interface WorkspaceHelpModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function WorkspaceHelpModal({ isOpen, onClose }: WorkspaceHelpModalProps) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-xs p-4 overscroll-contain"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Aide"
|
||||
>
|
||||
<div className="relative w-full max-w-2xl rounded-xl border border-slate-200 bg-white p-6 shadow-2xl animate-in fade-in zoom-in-95">
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2.5 border-b border-slate-100 pb-4">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-indigo-50 text-indigo-600">
|
||||
<BookOpen size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mono text-base font-bold text-slate-900">
|
||||
Guide Workspace & Création de Modèles (Style Dymola / Modelica)
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500">
|
||||
Comment construire, simuler, sauvegarder et réutiliser des sous-systèmes dans Entropyk.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body Content */}
|
||||
<div className="mt-5 space-y-4 text-xs text-slate-600 max-h-[70vh] overflow-y-auto pr-1">
|
||||
{/* Step 1: Create a Model */}
|
||||
<div className="rounded-lg border border-slate-100 bg-slate-50/70 p-3.5">
|
||||
<div className="flex items-center gap-2 font-bold text-slate-800 text-[13px] mb-1">
|
||||
<FilePlus size={16} className="text-indigo-600" />
|
||||
1. Créer un Nouveau Modèle ou Charger un Exemple
|
||||
</div>
|
||||
<p className="leading-relaxed">
|
||||
Pour démarrer un modèle vierge, cliquez sur le bouton <span className="mono font-semibold text-slate-800">Eraser 🧹</span> dans la barre d'outils.
|
||||
Pour explorer un modèle pré-construit, choisissez un exemple dans le menu déroulant (ex: <span className="mono font-semibold">Chiller flooded</span>, <span className="mono font-semibold">HX air-water</span>) et cliquez sur <span className="mono font-semibold text-indigo-600">Load</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Build & Connect */}
|
||||
<div className="rounded-lg border border-slate-100 bg-slate-50/70 p-3.5">
|
||||
<div className="flex items-center gap-2 font-bold text-slate-800 text-[13px] mb-1">
|
||||
<Layers size={16} className="text-blue-600" />
|
||||
2. Placer des Composants & Relier les Conduits
|
||||
</div>
|
||||
<p className="leading-relaxed">
|
||||
Glissez-déposez des composants depuis l'onglet <span className="mono font-semibold text-slate-800">Composants</span> ou cliquez sur <span className="mono font-semibold text-indigo-600">+ Poser sur canvas</span> depuis la <span className="mono font-semibold text-slate-800">Bibliothèque</span>.
|
||||
Reliez les handles colorés (entrées bleues, sorties vertes) pour fermer votre cycle thermodynamique.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step 3: Save as Module (.ekmod) */}
|
||||
<div className="rounded-lg border border-slate-100 bg-slate-50/70 p-3.5">
|
||||
<div className="flex items-center gap-2 font-bold text-slate-800 text-[13px] mb-1">
|
||||
<PackagePlus size={16} className="text-emerald-600" />
|
||||
3. Sauvegarder un Sous-Système (Module .ekmod)
|
||||
</div>
|
||||
<p className="leading-relaxed">
|
||||
Une fois votre sous-système assemblé (ex: groupe froid, centrale de traitement d'air), cliquez sur <span className="mono font-semibold text-emerald-600">Save as Module</span>. Saisissez le nom du module et définissez les ports exposés (ex: <span className="mono font-semibold">chw_in</span>, <span className="mono font-semibold">chw_out</span>). Le module apparaît immédiatement dans l'onglet <span className="mono font-semibold text-slate-800">Bibliothèque (.ekmod)</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step 4: Dual Modes Dymola */}
|
||||
<div className="rounded-lg border border-slate-100 bg-slate-50/70 p-3.5">
|
||||
<div className="flex items-center gap-2 font-bold text-slate-800 text-[13px] mb-1">
|
||||
<CheckCircle2 size={16} className="text-amber-600" />
|
||||
4. Utiliser les Deux Modes Dymola / Modelica
|
||||
</div>
|
||||
<p className="leading-relaxed">
|
||||
Sur chaque module déposé sur le canvas, le bouton <span className="mono font-semibold text-indigo-600">📐 Mode Schéma</span> bascule entre le <span className="mono font-semibold">Mode Icône</span> (symbole CAD vectoriel du Chiller) et le <span className="mono font-semibold">Mode Schéma Interne</span> (vue miniature des composants internes). Double-cliquez pour ouvrir l'éditeur plein écran du sous-système.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step 5: Simulate */}
|
||||
<div className="rounded-lg border border-slate-100 bg-slate-50/70 p-3.5">
|
||||
<div className="flex items-center gap-2 font-bold text-slate-800 text-[13px] mb-1">
|
||||
<Play size={16} className="text-[#3b82f6]" />
|
||||
5. Simuler le Cycle & Résoudre
|
||||
</div>
|
||||
<p className="leading-relaxed">
|
||||
Choisissez le fluide thermodynamique (ex: <span className="mono font-semibold">R410A</span>, <span className="mono font-semibold">R134a</span>) et cliquez sur <span className="mono font-semibold text-indigo-600">Solve</span>. Le résolveur Newton-Raphson de Rust calcule l'état stationnaire complet (pressions, enthalpies, débits, COP, puissances).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 flex justify-end border-t border-slate-100 pt-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-xs font-semibold text-white shadow-sm hover:bg-indigo-500 active:scale-95"
|
||||
>
|
||||
Compris <ArrowRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { COMPONENTS, COMPONENT_CATEGORIES, type ComponentMeta } from "@/lib/componentMeta";
|
||||
import { ComponentIcon } from "@/components/canvas/ComponentIcon";
|
||||
import { hxFamily } from "@/lib/hxFamily";
|
||||
|
||||
export default function ComponentPalette() {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({
|
||||
Advanced: true,
|
||||
Generic: true,
|
||||
});
|
||||
const toggle = (cat: string) => setCollapsed((c) => ({ ...c, [cat]: !c[cat] }));
|
||||
|
||||
const onDragStart = (e: React.DragEvent, type: string) => {
|
||||
e.dataTransfer.setData("application/entropyk-type", type);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-60 flex-col border-r border-[var(--line)] bg-[var(--panel)]">
|
||||
<div className="flex h-9 items-center justify-between px-3">
|
||||
<span className="eyebrow">Bibliothèque</span>
|
||||
<span className="mono text-[10px] text-[var(--ink-faint)]">{COMPONENTS.length}</span>
|
||||
</div>
|
||||
<div className="h-px w-full bg-[var(--line)]" />
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{COMPONENT_CATEGORIES.map((cat) => {
|
||||
const items = COMPONENTS.filter((c) => c.category === cat);
|
||||
if (items.length === 0) return null;
|
||||
const isOpen = !collapsed[cat];
|
||||
return (
|
||||
<div key={cat} className="palette-cat">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(cat)}
|
||||
aria-expanded={isOpen}
|
||||
className="palette-cat-toggle flex w-full items-center gap-1 px-2.5 py-1.5 text-left hover:bg-[var(--chrome-2)]"
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown size={12} className="text-[var(--ink-faint)]" />
|
||||
) : (
|
||||
<ChevronRight size={12} className="text-[var(--ink-faint)]" />
|
||||
)}
|
||||
<span className="eyebrow">{cat}</span>
|
||||
<span className="mono ml-1 text-[9px] text-[var(--ink-faint)]">{items.length}</span>
|
||||
{cat === "Advanced" && (
|
||||
<span className="ml-auto text-[8px] font-medium text-[var(--ink-faint)]">
|
||||
rare
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className={`palette-cat-body ${isOpen ? "is-open" : "is-closed"}`}>
|
||||
<div className="palette-cat-inner pb-1">
|
||||
{items.map((c, i) => (
|
||||
<PaletteItem
|
||||
key={c.type}
|
||||
meta={c}
|
||||
index={i}
|
||||
onDragStart={onDragStart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-px w-full bg-[var(--line)]" />
|
||||
<p className="px-3 py-2 text-[10px] leading-relaxed text-[var(--ink-faint)]">
|
||||
Glisse un composant sur le schéma. Relie les ports. Un pipe/gaine déposé sur une ligne
|
||||
s’insère automatiquement (même milieu : vert / bleu / jaune).
|
||||
</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function PaletteItem({
|
||||
meta,
|
||||
index,
|
||||
onDragStart,
|
||||
}: {
|
||||
meta: ComponentMeta;
|
||||
index: number;
|
||||
onDragStart: (e: React.DragEvent, type: string) => void;
|
||||
}) {
|
||||
const family = hxFamily(meta.type);
|
||||
const color = family?.accent ?? meta.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, meta.type)}
|
||||
className="palette-item group mx-1.5 flex cursor-grab items-center gap-2.5 rounded-[2px] px-2 py-1.5 hover:bg-[var(--chrome-2)] active:cursor-grabbing"
|
||||
style={{ ["--i" as string]: index }}
|
||||
title={meta.help ?? meta.description}
|
||||
>
|
||||
<span className="grid h-9 w-9 flex-shrink-0 place-items-center">
|
||||
<span style={{ width: 30, height: 30 }}>
|
||||
<ComponentIcon type={meta.type} color={color} />
|
||||
</span>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12px] text-[var(--ink-dim)] group-hover:text-[var(--ink)]">
|
||||
{meta.label}
|
||||
</span>
|
||||
{family && (
|
||||
<span className="mono text-[8px] uppercase tracking-[0.08em] text-[var(--ink-faint)]">
|
||||
{family.badge} · {family.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Layers, Loader2, Play, Plus, Trash2, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Layers, Loader2, Play, Plus, Trash2, X, AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
Brush,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { useDiagramStore } from "@/store/diagramStore";
|
||||
import { buildScenarioConfig, validateConfig } from "@/lib/configBuilder";
|
||||
import type { EntropykNodeData } from "@/lib/configBuilder";
|
||||
@@ -11,6 +22,10 @@ import {
|
||||
defaultSweepsFromDiagram,
|
||||
discoverSweepTargets,
|
||||
extractKpis,
|
||||
getSweepWarnings,
|
||||
parseValueList,
|
||||
resolvePinTarget,
|
||||
extractAllSeries,
|
||||
runParallel,
|
||||
suggestValues,
|
||||
type MultiRunResult,
|
||||
@@ -35,6 +50,7 @@ export default function MultiRunPanel({ onClose }: { onClose: () => void }) {
|
||||
maxIterations,
|
||||
tolerance,
|
||||
} = useDiagramStore();
|
||||
const pinnedSweepPaths = useDiagramStore((s) => s.pinnedSweepPaths);
|
||||
|
||||
const diagramNodes = nodes as Node<EntropykNodeData>[];
|
||||
const targets = useMemo(
|
||||
@@ -42,14 +58,41 @@ export default function MultiRunPanel({ onClose }: { onClose: () => void }) {
|
||||
[diagramNodes, fluid],
|
||||
);
|
||||
|
||||
const [sweeps, setSweeps] = useState<SweepSpec[]>(() =>
|
||||
defaultSweepsFromDiagram(diagramNodes, fluid),
|
||||
);
|
||||
const [sweeps, setSweeps] = useState<SweepSpec[]>(() => {
|
||||
const initial = defaultSweepsFromDiagram(diagramNodes, fluid);
|
||||
return initial; // pins are merged by the effect below
|
||||
});
|
||||
|
||||
// Merge pinned sweep paths (pushed from the properties panel) into the
|
||||
// active axis list without duplicating existing axes or dropping axes the
|
||||
// user added manually. Unresolvable pins (deleted node, non-sweepable) are
|
||||
// silently skipped — the store deliberately keeps stale pins.
|
||||
useEffect(() => {
|
||||
if (pinnedSweepPaths.length === 0) return;
|
||||
setSweeps((current) => {
|
||||
const existing = new Set(current.map((s) => s.path));
|
||||
const toAdd: SweepSpec[] = [];
|
||||
for (const path of pinnedSweepPaths) {
|
||||
if (existing.has(path)) continue;
|
||||
const t = targets.find((x) => x.path === path) ?? resolvePinTarget(path, diagramNodes);
|
||||
if (!t) continue; // unresolvable (deleted node, non-numeric) → skip
|
||||
toAdd.push({
|
||||
path: t.path,
|
||||
label: t.label,
|
||||
kind: t.kind,
|
||||
valuesText: suggestValues(t.current, t.paramKey),
|
||||
});
|
||||
}
|
||||
return toAdd.length === 0 ? current : [...current, ...toAdd];
|
||||
});
|
||||
}, [pinnedSweepPaths, targets, diagramNodes]);
|
||||
|
||||
const [concurrency, setConcurrency] = useState(4);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [progress, setProgress] = useState({ done: 0, total: 0 });
|
||||
const [results, setResults] = useState<MultiRunResult[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// (Y output series selection lives in ySeriesKey below)
|
||||
|
||||
const baseConfig = useMemo(
|
||||
() =>
|
||||
@@ -65,6 +108,119 @@ export default function MultiRunPanel({ onClose }: { onClose: () => void }) {
|
||||
|
||||
const cases = useMemo(() => buildSweepCases(baseConfig, sweeps), [baseConfig, sweeps]);
|
||||
|
||||
// Active sweep axes (those with at least one value typed in).
|
||||
const activeAxes = useMemo(
|
||||
() => sweeps.filter((s) => parseValueList(s.valuesText).length > 0),
|
||||
[sweeps],
|
||||
);
|
||||
|
||||
// User-controlled X axis (which swept param is the abscissa) and Y series.
|
||||
const [xAxisPath, setXAxisPath] = useState<string | null>(null);
|
||||
const [ySeriesKey, setYSeriesKey] = useState<string | null>(null);
|
||||
|
||||
// Keep xAxisPath valid when the axis list changes (deleted / added axes).
|
||||
useEffect(() => {
|
||||
if (activeAxes.length === 0) {
|
||||
if (xAxisPath !== null) setXAxisPath(null);
|
||||
return;
|
||||
}
|
||||
if (!xAxisPath || !activeAxes.some((a) => a.path === xAxisPath)) {
|
||||
setXAxisPath(activeAxes[0].path);
|
||||
}
|
||||
}, [activeAxes, xAxisPath]);
|
||||
|
||||
// Every plottable output series from the first succeeded case.
|
||||
// Literature-aligned: any model output (COP, capacity, edge temps, mass flows,
|
||||
// pressures, enthalpies) can be picked as Y — not just refrigeration KPIs.
|
||||
const availableSeries = useMemo(() => {
|
||||
const firstOk = results?.find((r) => r.ok && r.result);
|
||||
return extractAllSeries(firstOk?.result);
|
||||
}, [results]);
|
||||
|
||||
// Keep ySeriesKey valid: default to a non-zero COP if present, else first series.
|
||||
useEffect(() => {
|
||||
if (availableSeries.length === 0) {
|
||||
if (ySeriesKey !== null) setYSeriesKey(null);
|
||||
return;
|
||||
}
|
||||
if (!availableSeries.some((s) => s.key === ySeriesKey)) {
|
||||
const cop = availableSeries.find((s) => s.key === "perf.cop" && (s.value ?? 0) !== 0);
|
||||
setYSeriesKey((cop ?? availableSeries[0]).key);
|
||||
}
|
||||
}, [availableSeries, ySeriesKey]);
|
||||
|
||||
// Chart dataset. X = chosen swept param (xAxisPath), Y = chosen output series (ySeriesKey).
|
||||
// 1 axis → single curve. 2 axes → one curve per value of the other axis.
|
||||
// 3+ axes → null (too many for readable curves; the table is the source of truth).
|
||||
const chartData = useMemo(() => {
|
||||
if (!results || results.length === 0 || !xAxisPath) return null;
|
||||
const okResults = results.filter((r) => r.ok && r.result);
|
||||
if (okResults.length === 0) return null;
|
||||
|
||||
const kpiVal = (r: MultiRunResult): number | null => {
|
||||
const s = extractAllSeries(r.result).find((x) => x.key === ySeriesKey);
|
||||
return s?.value ?? null;
|
||||
};
|
||||
const xOf = (r: MultiRunResult): number | string => {
|
||||
const raw = r.case.overrides[xAxisPath];
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : (raw as string);
|
||||
};
|
||||
const numSort = (a: number | string, b: number | string) =>
|
||||
typeof a === "number" && typeof b === "number"
|
||||
? a - b
|
||||
: String(a).localeCompare(String(b));
|
||||
|
||||
const xLabel = activeAxes.find((a) => a.path === xAxisPath)?.label ?? xAxisPath;
|
||||
const seriesAxes = activeAxes.filter((a) => a.path !== xAxisPath);
|
||||
|
||||
if (seriesAxes.length === 0) {
|
||||
const rows = okResults
|
||||
.map((r) => ({ x: xOf(r), y: kpiVal(r) }))
|
||||
.sort((a, b) => numSort(a.x, b.x));
|
||||
return { mode: "single" as const, xLabel, rows };
|
||||
}
|
||||
|
||||
if (seriesAxes.length === 1) {
|
||||
const sPath = seriesAxes[0].path;
|
||||
const sLabel = seriesAxes[0].label;
|
||||
const xVals: Array<number | string> = [];
|
||||
const seriesVals: string[] = [];
|
||||
const points: Array<{ x: number | string; series: string; y: number | null }> = [];
|
||||
for (const r of okResults) {
|
||||
const x = xOf(r);
|
||||
const series = String(r.case.overrides[sPath]);
|
||||
if (!xVals.some((v) => v === x)) xVals.push(x);
|
||||
if (!seriesVals.includes(series)) seriesVals.push(series);
|
||||
points.push({ x, series, y: kpiVal(r) });
|
||||
}
|
||||
xVals.sort(numSort);
|
||||
seriesVals.sort((a, b) =>
|
||||
Number.isFinite(Number(a)) && Number.isFinite(Number(b))
|
||||
? Number(a) - Number(b)
|
||||
: a.localeCompare(b),
|
||||
);
|
||||
return { mode: "multi" as const, xLabel, sLabel, xVals, seriesVals, points };
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [results, activeAxes, xAxisPath, ySeriesKey]);
|
||||
|
||||
// Pivoted rows for the 2-axis chart: one row per X value, one column per series value.
|
||||
// O(n) via a key-indexed map (was O(n²) with .find per cell — caused visible lag).
|
||||
const multiRows = useMemo(() => {
|
||||
if (!chartData || chartData.mode !== "multi") return null;
|
||||
const byKey = new Map<string, number | null>();
|
||||
for (const p of chartData.points) byKey.set(`${String(p.x)}|${p.series}`, p.y);
|
||||
return chartData.xVals.map((x) => {
|
||||
const row: Record<string, number | string | null> = { x };
|
||||
for (const sv of chartData.seriesVals) {
|
||||
row[sv] = byKey.get(`${String(x)}|${sv}`) ?? null;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
}, [chartData]);
|
||||
|
||||
const targetsByGroup = useMemo(() => {
|
||||
const map = new Map<SweepTarget["group"], SweepTarget[]>();
|
||||
for (const t of targets) {
|
||||
@@ -154,13 +310,26 @@ export default function MultiRunPanel({ onClose }: { onClose: () => void }) {
|
||||
|
||||
const boundaryCount = targets.filter((t) => t.group === "boundaries").length;
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 overscroll-contain"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="multirun-title"
|
||||
>
|
||||
<div className="flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-lg border border-[var(--line)] bg-[var(--panel)] shadow-xl">
|
||||
<div className="flex items-center gap-2 border-b border-[var(--line)] px-4 py-3">
|
||||
<Layers size={16} className="text-[var(--accent)]" />
|
||||
<div className="flex-1">
|
||||
<h2 className="text-[14px] font-semibold text-[var(--ink)]">Multi-run</h2>
|
||||
<h2 id="multirun-title" className="text-[14px] font-semibold text-[var(--ink)]">Multi-run</h2>
|
||||
<p className="text-[11px] text-[var(--ink-faint)]">
|
||||
Les variables viennent du schéma actuel (sources eau/air, UA, etc.).
|
||||
{boundaryCount > 0
|
||||
@@ -209,63 +378,80 @@ export default function MultiRunPanel({ onClose }: { onClose: () => void }) {
|
||||
|
||||
{sweeps.map((sweep, i) => {
|
||||
const selected = targets.find((t) => t.path === sweep.path);
|
||||
const warnings =
|
||||
selected && selected.paramKey
|
||||
? getSweepWarnings(selected, diagramNodes, baseConfig)
|
||||
: [];
|
||||
return (
|
||||
<div
|
||||
key={`${sweep.path}-${i}`}
|
||||
className="grid grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)_auto] items-end gap-2 rounded-md border border-[var(--line)] bg-[var(--chrome)] p-2.5"
|
||||
>
|
||||
<label className="flex min-w-0 flex-col gap-1">
|
||||
<span className="eyebrow">Variable du schéma</span>
|
||||
<select
|
||||
className="ek-select w-full"
|
||||
value={sweep.path}
|
||||
onChange={(e) => pickTarget(i, e.target.value)}
|
||||
<div key={`${sweep.path}-${i}`} className="space-y-1">
|
||||
<div className="grid grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)_auto] items-end gap-2 rounded-md border border-[var(--line)] bg-[var(--chrome)] p-2.5">
|
||||
<label className="flex min-w-0 flex-col gap-1">
|
||||
<span className="eyebrow">Variable du schéma</span>
|
||||
<select
|
||||
className="ek-select w-full"
|
||||
value={sweep.path}
|
||||
onChange={(e) => pickTarget(i, e.target.value)}
|
||||
>
|
||||
{[...targetsByGroup.entries()].map(([group, list]) => (
|
||||
<optgroup key={group} label={GROUP_LABEL[group]}>
|
||||
{list.map((t) => (
|
||||
<option key={t.path} value={t.path}>
|
||||
{t.label}
|
||||
{t.current !== undefined ? ` [actuel: ${t.current}]` : ""}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
{!selected && (
|
||||
<option value={sweep.path}>{sweep.path} (hors liste)</option>
|
||||
)}
|
||||
</select>
|
||||
<span className="mono truncate text-[10px] text-[var(--ink-faint)]">
|
||||
{sweep.path}
|
||||
{selected?.current !== undefined ? ` · actuel ${selected.current}` : ""}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-0 flex-col gap-1">
|
||||
<span className="eyebrow">
|
||||
Valeurs {selected?.unit ? `(${selected.unit})` : ""} — séparées par des virgules
|
||||
</span>
|
||||
<input
|
||||
className="ek-select mono w-full"
|
||||
value={sweep.valuesText}
|
||||
onChange={(e) => setAxis(i, { valuesText: e.target.value })}
|
||||
placeholder={
|
||||
selected?.paramKey === "t_set_c"
|
||||
? "10, 12, 14"
|
||||
: "val1, val2, val3"
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mb-0.5 rounded p-1.5 text-[var(--ink-faint)] hover:bg-[var(--chrome-2)]"
|
||||
onClick={() => setSweeps((all) => all.filter((_, j) => j !== i))}
|
||||
disabled={sweeps.length <= 1}
|
||||
aria-label="Retirer l’axe"
|
||||
>
|
||||
{[...targetsByGroup.entries()].map(([group, list]) => (
|
||||
<optgroup key={group} label={GROUP_LABEL[group]}>
|
||||
{list.map((t) => (
|
||||
<option key={t.path} value={t.path}>
|
||||
{t.label}
|
||||
{t.current !== undefined ? ` [actuel: ${t.current}]` : ""}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<div className="space-y-0.5 px-1">
|
||||
{warnings.map((w, k) => (
|
||||
<div
|
||||
key={k}
|
||||
className="flex items-start gap-1 text-[11px] text-[var(--warn)]"
|
||||
>
|
||||
<AlertTriangle size={11} className="mt-px shrink-0" />
|
||||
<span>{w}</span>
|
||||
</div>
|
||||
))}
|
||||
{!selected && (
|
||||
<option value={sweep.path}>{sweep.path} (hors liste)</option>
|
||||
)}
|
||||
</select>
|
||||
<span className="mono truncate text-[10px] text-[var(--ink-faint)]">
|
||||
{sweep.path}
|
||||
{selected?.current !== undefined ? ` · actuel ${selected.current}` : ""}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-0 flex-col gap-1">
|
||||
<span className="eyebrow">
|
||||
Valeurs {selected?.unit ? `(${selected.unit})` : ""} — séparées par des virgules
|
||||
</span>
|
||||
<input
|
||||
className="ek-select mono w-full"
|
||||
value={sweep.valuesText}
|
||||
onChange={(e) => setAxis(i, { valuesText: e.target.value })}
|
||||
placeholder={
|
||||
selected?.paramKey === "t_set_c"
|
||||
? "10, 12, 14"
|
||||
: "val1, val2, val3"
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mb-0.5 rounded p-1.5 text-[var(--ink-faint)] hover:bg-[var(--chrome-2)]"
|
||||
onClick={() => setSweeps((all) => all.filter((_, j) => j !== i))}
|
||||
disabled={sweeps.length <= 1}
|
||||
aria-label="Retirer l’axe"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -291,45 +477,166 @@ export default function MultiRunPanel({ onClose }: { onClose: () => void }) {
|
||||
{error && <p className="text-[12px] text-[var(--warn)]">{error}</p>}
|
||||
|
||||
{results && (
|
||||
<div className="overflow-x-auto rounded-md border border-[var(--line)]">
|
||||
<table className="w-full text-left text-[11px]">
|
||||
<thead className="bg-[var(--chrome)] text-[var(--ink-faint)]">
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 font-medium">Cas</th>
|
||||
<th className="px-2 py-1.5 font-medium">Statut</th>
|
||||
<th className="px-2 py-1.5 font-medium">COP</th>
|
||||
<th className="px-2 py-1.5 font-medium">Qcool kW</th>
|
||||
<th className="px-2 py-1.5 font-medium">Wcomp kW</th>
|
||||
<th className="px-2 py-1.5 font-medium">ms</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((r) => {
|
||||
const k = extractKpis(r.result);
|
||||
return (
|
||||
<tr key={r.case.id} className="border-t border-[var(--line)]">
|
||||
<td className="max-w-[280px] px-2 py-1.5 font-medium text-[var(--ink)]">
|
||||
{r.case.label}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 mono">
|
||||
{r.ok ? k.status : r.error ?? "failed"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 mono">
|
||||
{k.cop != null ? k.cop.toFixed(3) : "—"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 mono">
|
||||
{k.qCoolKw != null ? k.qCoolKw.toFixed(2) : "—"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 mono">
|
||||
{k.powerKw != null ? k.powerKw.toFixed(2) : "—"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 mono">{Math.round(r.durationMs)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<>
|
||||
{chartData ? (
|
||||
<div className="rounded-md border border-[var(--line)] bg-[var(--chrome)] p-3">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-[11px] text-[var(--ink-dim)]">
|
||||
<label className="flex items-center gap-1">
|
||||
<span className="text-[var(--ink-faint)]">X</span>
|
||||
<select
|
||||
className="rounded border border-[var(--line)] bg-[var(--panel)] px-1 py-0.5 text-[11px]"
|
||||
value={xAxisPath ?? ""}
|
||||
onChange={(e) => setXAxisPath(e.target.value || null)}
|
||||
disabled={activeAxes.length <= 1}
|
||||
title={activeAxes.length <= 1 ? "Un seul axe actif" : "Choisir l'axe X"}
|
||||
>
|
||||
{activeAxes.map((a) => (
|
||||
<option key={a.path} value={a.path}>
|
||||
{a.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="text-[var(--ink-faint)]">vs</span>
|
||||
<label className="flex items-center gap-1">
|
||||
<span className="text-[var(--ink-faint)]">Y</span>
|
||||
<select
|
||||
className="max-w-[260px] rounded border border-[var(--line)] bg-[var(--panel)] px-1 py-0.5 text-[11px]"
|
||||
value={ySeriesKey ?? ""}
|
||||
onChange={(e) => setYSeriesKey(e.target.value || null)}
|
||||
>
|
||||
{availableSeries.map((opt) => (
|
||||
<option key={opt.key} value={opt.key}>
|
||||
{opt.label}
|
||||
{opt.unit ? ` (${opt.unit})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{chartData.mode === "multi" && (
|
||||
<span className="eyebrow">séries : {chartData.sLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ width: "100%", height: 240 }}>
|
||||
<ResponsiveContainer>
|
||||
{chartData.mode === "single" ? (
|
||||
<LineChart data={chartData.rows} margin={{ top: 8, right: 16, bottom: 24, left: 8 }}>
|
||||
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="x"
|
||||
type="number"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tick={{ fontSize: 11, fill: "var(--ink-faint)" }}
|
||||
label={{ value: chartData.xLabel, position: "bottom", fontSize: 10, fill: "var(--ink-faint)" }}
|
||||
/>
|
||||
<YAxis domain={["auto", "auto"]} tick={{ fontSize: 11, fill: "var(--ink-faint)" }} />
|
||||
<Tooltip contentStyle={{ fontSize: 11 }} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="y"
|
||||
name={
|
||||
availableSeries.find((s) => s.key === ySeriesKey)?.label ??
|
||||
ySeriesKey ??
|
||||
"Y"
|
||||
}
|
||||
stroke="var(--accent)"
|
||||
dot={{ r: 3 }}
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Brush dataKey="x" height={18} stroke="var(--accent)" travellerWidth={8} fill="transparent" />
|
||||
</LineChart>
|
||||
) : (
|
||||
<LineChart data={multiRows ?? []} margin={{ top: 8, right: 16, bottom: 24, left: 8 }}>
|
||||
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="x"
|
||||
type="number"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tick={{ fontSize: 11, fill: "var(--ink-faint)" }}
|
||||
label={{ value: chartData.xLabel, position: "bottom", fontSize: 10, fill: "var(--ink-faint)" }}
|
||||
/>
|
||||
<YAxis domain={["auto", "auto"]} tick={{ fontSize: 11, fill: "var(--ink-faint)" }} />
|
||||
<Tooltip contentStyle={{ fontSize: 11 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{chartData.seriesVals.map((sv, idx) => (
|
||||
<Line
|
||||
key={sv}
|
||||
type="monotone"
|
||||
dataKey={sv}
|
||||
name={`${chartData.sLabel} = ${sv}`}
|
||||
stroke={
|
||||
[
|
||||
"var(--accent)",
|
||||
"#10b981",
|
||||
"#f59e0b",
|
||||
"#3b82f6",
|
||||
"#a855f7",
|
||||
"#ef4444",
|
||||
"#14b8a6",
|
||||
"#ec4899",
|
||||
][idx % 8]
|
||||
}
|
||||
dot={{ r: 3 }}
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
<Brush dataKey="x" height={18} stroke="var(--accent)" travellerWidth={8} fill="transparent" />
|
||||
</LineChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
) : results && results.length > 0 && activeAxes.length > 2 ? (
|
||||
<p className="rounded-md border border-dashed border-[var(--line)] p-2 text-[11px] text-[var(--ink-faint)]">
|
||||
Trop d'axes balayés ({activeAxes.length}) pour un graphique lisible (max 2).
|
||||
Consultez le tableau ci-dessous.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto rounded-md border border-[var(--line)]">
|
||||
<table className="w-full text-left text-[11px]">
|
||||
<thead className="bg-[var(--chrome)] text-[var(--ink-faint)]">
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 font-medium">Cas</th>
|
||||
<th className="px-2 py-1.5 font-medium">Statut</th>
|
||||
<th className="px-2 py-1.5 font-medium">COP</th>
|
||||
<th className="px-2 py-1.5 font-medium">Qcool kW</th>
|
||||
<th className="px-2 py-1.5 font-medium">Wcomp kW</th>
|
||||
<th className="px-2 py-1.5 font-medium">ms</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((r) => {
|
||||
const k = extractKpis(r.result);
|
||||
return (
|
||||
<tr key={r.case.id} className="border-t border-[var(--line)]">
|
||||
<td className="max-w-[280px] px-2 py-1.5 font-medium text-[var(--ink)]">
|
||||
{r.case.label}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 mono">
|
||||
{r.ok ? k.status : r.error ?? "failed"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 mono tabular-nums">
|
||||
{k.cop != null ? k.cop.toFixed(3) : "—"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 mono tabular-nums">
|
||||
{k.qCoolKw != null ? k.qCoolKw.toFixed(2) : "—"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 mono tabular-nums">
|
||||
{k.powerKw != null ? k.powerKw.toFixed(2) : "—"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 mono tabular-nums">{Math.round(r.durationMs)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ import { buildComponentInspector } from "@/lib/componentInspector";
|
||||
import { ComponentIcon } from "@/components/canvas/ComponentIcon";
|
||||
import ComponentDocPanel from "@/components/panels/ComponentDocPanel";
|
||||
import { modelBannerForType } from "@/lib/componentDocMap";
|
||||
import { ArrowDown, ArrowUp, HelpCircle, Plus, Trash2 } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, HelpCircle, Pin, Plus, Trash2 } from "lucide-react";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
|
||||
type StoreSnapshot = ReturnType<typeof useDiagramStore.getState>;
|
||||
@@ -110,6 +110,8 @@ export default function PropertiesPanel() {
|
||||
updateNodeCircuit,
|
||||
removeNode,
|
||||
} = useDiagramStore();
|
||||
const pinnedSweepPaths = useDiagramStore((s) => s.pinnedSweepPaths);
|
||||
const togglePinnedSweepPath = useDiagramStore((s) => s.togglePinnedSweepPath);
|
||||
|
||||
const node = nodes.find((n) => n.id === selectedNodeId) as DiagramNode | undefined;
|
||||
const [tab, setTab] = useState<TabId>("General");
|
||||
@@ -198,12 +200,13 @@ export default function PropertiesPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
const params = mergeDefaultParams(node.data.type, node.data.params ?? {});
|
||||
const nodeType = String(node.data.type || node.data.kind || "");
|
||||
const params = mergeDefaultParams(nodeType, node.data.params ?? {});
|
||||
|
||||
// Group params into tabs
|
||||
const byTab = new Map<TabId, ParamMeta[]>();
|
||||
for (const p of meta.params) {
|
||||
const t = tabForParam(p, node.data.type);
|
||||
const t = tabForParam(p, nodeType);
|
||||
const list = byTab.get(t) ?? [];
|
||||
list.push(p);
|
||||
byTab.set(t, list);
|
||||
@@ -213,7 +216,8 @@ export default function PropertiesPanel() {
|
||||
const activeTab = availableTabs.includes(tab) ? tab : availableTabs[0] ?? "General";
|
||||
const rows = byTab.get(activeTab) ?? [];
|
||||
const showFixedCol = rows.some((p) => p.fixable);
|
||||
const isRegLoop = node.data.type === "SaturatedController";
|
||||
const showPinCol = rows.some((p) => p.kind === "number");
|
||||
const isRegLoop = nodeType === "SaturatedController";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border-b border-[var(--line)]">
|
||||
@@ -222,7 +226,15 @@ export default function PropertiesPanel() {
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid h-6 w-6 shrink-0 place-items-center rounded border border-[var(--line)] bg-white">
|
||||
<span style={{ width: 14, height: 14 }}>
|
||||
<ComponentIcon type={node.data.type} color={meta.color} />
|
||||
<ComponentIcon
|
||||
type={nodeType}
|
||||
color={meta.color}
|
||||
secondaryFluid={
|
||||
node.data.params?.secondary_fluid != null
|
||||
? String(node.data.params.secondary_fluid)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
@@ -386,6 +398,14 @@ export default function PropertiesPanel() {
|
||||
Fixed
|
||||
</th>
|
||||
)}
|
||||
{showPinCol && (
|
||||
<th
|
||||
className="w-8 px-1 py-1.5 text-center font-semibold"
|
||||
title="Épingler pour le multi-run"
|
||||
>
|
||||
<Pin size={11} className="inline text-[var(--ink-faint)]" />
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -465,6 +485,42 @@ export default function PropertiesPanel() {
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{showPinCol && (
|
||||
<td className="px-0.5 py-1 text-center align-middle">
|
||||
{p.kind === "number" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="toolish inline-flex items-center justify-center p-0.5"
|
||||
title="Épingler pour le multi-run"
|
||||
aria-label="Épingler pour le multi-run"
|
||||
aria-pressed={pinnedSweepPaths.includes(
|
||||
`${node.data.name}.${p.key}`,
|
||||
)}
|
||||
onClick={() =>
|
||||
togglePinnedSweepPath(`${node.data.name}.${p.key}`)
|
||||
}
|
||||
>
|
||||
<Pin
|
||||
size={13}
|
||||
className={
|
||||
pinnedSweepPaths.includes(
|
||||
`${node.data.name}.${p.key}`,
|
||||
)
|
||||
? "text-[var(--accent)]"
|
||||
: "text-[var(--ink-faint)]"
|
||||
}
|
||||
fill={
|
||||
pinnedSweepPaths.includes(
|
||||
`${node.data.name}.${p.key}`,
|
||||
)
|
||||
? "currentColor"
|
||||
: "none"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -530,6 +586,13 @@ export default function PropertiesPanel() {
|
||||
border-color: #86efac;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
.toolish {
|
||||
border-radius: 4px;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.toolish:hover {
|
||||
background: var(--chrome-2);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -29,12 +29,16 @@ import {
|
||||
type SimulationResult,
|
||||
} from "@/lib/api";
|
||||
import { useDiagramStore } from "@/store/diagramStore";
|
||||
import type { EntropykNodeData } from "@/lib/configBuilder";
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
interface Props {
|
||||
result: SimulationResult | null;
|
||||
error: string | null;
|
||||
config: unknown | null;
|
||||
simulating?: boolean;
|
||||
/** Validation issues surfaced from the shared simulate hook. */
|
||||
issues?: string[];
|
||||
}
|
||||
|
||||
type Tab = "overview" | "edges" | "components" | "solver" | "debug";
|
||||
@@ -47,6 +51,13 @@ type PowerUnit = "W" | "kW";
|
||||
|
||||
const SHOW_SOLVER_KEY = "entropyk.showSolverLog";
|
||||
|
||||
const FLUIDS = ["R410A", "R134a", "R290", "R744", "R32", "R1234yf", "Water", "Air"];
|
||||
const BACKENDS = ["CoolProp", "Test"];
|
||||
const SOLVERS = [
|
||||
{ value: "newton", label: "Newton" },
|
||||
{ value: "picard", label: "Picard" },
|
||||
];
|
||||
|
||||
function readShowSolverPref(): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
const v = window.localStorage.getItem(SHOW_SOLVER_KEY);
|
||||
@@ -54,14 +65,31 @@ function readShowSolverPref(): boolean {
|
||||
return v !== "0" && v !== "false";
|
||||
}
|
||||
|
||||
export default function ResultsPanel({ result, error, config, simulating = false }: Props) {
|
||||
export default function ResultsPanel({
|
||||
result,
|
||||
error,
|
||||
config,
|
||||
simulating = false,
|
||||
issues = [],
|
||||
}: Props) {
|
||||
const [tab, setTab] = useState<Tab>("overview");
|
||||
const [powerUnit, setPowerUnit] = useState<PowerUnit>("kW");
|
||||
const [showSolverLog, setShowSolverLog] = useState(true);
|
||||
const [mollier, setMollier] = useState<MollierResponse | null>(null);
|
||||
const { nodes, edges } = useDiagramStore();
|
||||
|
||||
const nodes = useDiagramStore((s) => s.nodes);
|
||||
const edges = useDiagramStore((s) => s.edges);
|
||||
const fluid = useDiagramStore((s) => s.fluid);
|
||||
const fluidBackend = useDiagramStore((s) => s.fluidBackend);
|
||||
const solverStrategy = useDiagramStore((s) => s.solverStrategy);
|
||||
const setFluid = useDiagramStore((s) => s.setFluid);
|
||||
const setFluidBackend = useDiagramStore((s) => s.setFluidBackend);
|
||||
const setSolverStrategy = useDiagramStore((s) => s.setSolverStrategy);
|
||||
const simulatingStore = useDiagramStore((s) => s.simulating);
|
||||
const isSimulating = simulating || simulatingStore;
|
||||
|
||||
const cycleData = useMemo(() => buildCycleData(result, nodes, edges), [result, nodes, edges]);
|
||||
const fluid = scenarioFluid(config);
|
||||
const resultFluid = scenarioFluid(config);
|
||||
const edgeRows = result?.state ?? [];
|
||||
const localHxRows = useMemo(
|
||||
() => buildLocalHeatExchangerRows(nodes, edges, result?.state),
|
||||
@@ -86,21 +114,21 @@ export default function ResultsPanel({ result, error, config, simulating = false
|
||||
const residual = result?.convergence?.final_residual ?? null;
|
||||
const iterations = result?.convergence?.iterations ?? result?.iterations ?? null;
|
||||
const iterationHistory = result?.convergence?.iteration_history ?? [];
|
||||
const solverStrategy = result?.convergence?.strategy ?? null;
|
||||
const resultSolverStrategy = result?.convergence?.strategy ?? null;
|
||||
const resultStatus = String(result?.status ?? "").toLowerCase();
|
||||
const resultError =
|
||||
result && !converged && ["error", "failed", "diverged"].includes(resultStatus)
|
||||
? result.error || statusLabel(result.status)
|
||||
: null;
|
||||
const displayError = error ?? resultError;
|
||||
const benchTitle = simulating
|
||||
const benchTitle = isSimulating
|
||||
? "Solving..."
|
||||
: displayError
|
||||
? "Solver stopped"
|
||||
: result
|
||||
? statusLabel(result.status)
|
||||
: "Ready to solve";
|
||||
const benchDetail = simulating
|
||||
const benchDetail = isSimulating
|
||||
? "Simulation running"
|
||||
: buildBenchDetail(result, displayError, iterations, residual);
|
||||
|
||||
@@ -117,19 +145,19 @@ export default function ResultsPanel({ result, error, config, simulating = false
|
||||
// After a solve, jump to Solver when iteration history is available and the
|
||||
// log is enabled — otherwise users never discover the tab.
|
||||
useEffect(() => {
|
||||
if (!result || simulating) return;
|
||||
if (!result || isSimulating) return;
|
||||
const history = result.convergence?.iteration_history ?? [];
|
||||
if (showSolverLog && history.length > 0) {
|
||||
setTab("solver");
|
||||
}
|
||||
}, [result, simulating, showSolverLog]);
|
||||
}, [result, isSimulating, showSolverLog]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
// Never hit CoolProp (Mollier) while a solve is in flight — concurrent
|
||||
// CoolProp calls used to crash ui-server → bare HTTP 500 + stuck spinner.
|
||||
if (
|
||||
simulating ||
|
||||
isSimulating ||
|
||||
!result ||
|
||||
!fluid ||
|
||||
fluid.toLowerCase() === "water" ||
|
||||
@@ -148,7 +176,7 @@ export default function ResultsPanel({ result, error, config, simulating = false
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [fluid, result, simulating]);
|
||||
}, [fluid, result, isSimulating]);
|
||||
|
||||
// Always reserve the Solver tab slot when the eye toggle is on (default).
|
||||
const tabCount = showSolverLog ? 5 : 4;
|
||||
@@ -159,6 +187,50 @@ export default function ResultsPanel({ result, error, config, simulating = false
|
||||
title="Solve bench"
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="flex items-center gap-1 text-[10px] text-[var(--ink-dim)]">
|
||||
<span className="mono uppercase tracking-wide">Fluid</span>
|
||||
<select
|
||||
value={fluid}
|
||||
onChange={(e) => setFluid(e.target.value)}
|
||||
className="rounded border border-[var(--line)] bg-white px-1.5 py-0.5 text-[10px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
|
||||
>
|
||||
{FLUIDS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-[10px] text-[var(--ink-dim)]">
|
||||
<span className="mono uppercase tracking-wide">Props</span>
|
||||
<select
|
||||
value={fluidBackend}
|
||||
onChange={(e) => setFluidBackend(e.target.value)}
|
||||
className="rounded border border-[var(--line)] bg-white px-1.5 py-0.5 text-[10px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
|
||||
>
|
||||
{BACKENDS.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-[10px] text-[var(--ink-dim)]">
|
||||
<span className="mono uppercase tracking-wide">Solver</span>
|
||||
<select
|
||||
value={solverStrategy}
|
||||
onChange={(e) => setSolverStrategy(e.target.value)}
|
||||
className="rounded border border-[var(--line)] bg-white px-1.5 py-0.5 text-[10px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
|
||||
>
|
||||
{SOLVERS.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[var(--line)] bg-white px-2 py-1 text-[10px] text-[var(--ink-dim)] hover:border-[var(--accent)] hover:text-[var(--accent)]"
|
||||
@@ -189,7 +261,7 @@ export default function ResultsPanel({ result, error, config, simulating = false
|
||||
</div>
|
||||
<div className="mono mt-0.5 text-[10px] text-[var(--ink-faint)]">
|
||||
{benchDetail}
|
||||
{solverStrategy ? ` · ${solverStrategy}` : ""}
|
||||
{resultSolverStrategy ? ` · ${resultSolverStrategy}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,6 +269,27 @@ export default function ResultsPanel({ result, error, config, simulating = false
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{issues.length > 0 && !displayError && (
|
||||
<div className="border-b border-amber-200 bg-amber-50 px-3 py-2 text-[11px] leading-relaxed text-amber-900">
|
||||
<div className="mb-0.5 flex items-center gap-1.5 font-semibold">
|
||||
<AlertTriangle size={12} />
|
||||
{issues.length} validation issue{issues.length > 1 ? "s" : ""} — Solve blocked
|
||||
</div>
|
||||
<ul className="ml-4 list-disc space-y-0.5">
|
||||
{issues.slice(0, 4).map((iss, i) => (
|
||||
<li key={i} className="text-[10.5px]">
|
||||
{iss}
|
||||
</li>
|
||||
))}
|
||||
{issues.length > 4 && (
|
||||
<li className="text-[10px] text-amber-700">
|
||||
+ {issues.length - 4} more…
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="grid border-b border-[var(--line)] bg-white text-[11px]"
|
||||
style={{ gridTemplateColumns: `repeat(${tabCount}, minmax(0, 1fr))` }}
|
||||
@@ -316,7 +409,7 @@ export default function ResultsPanel({ result, error, config, simulating = false
|
||||
result={result}
|
||||
iterations={iterations}
|
||||
residual={residual}
|
||||
strategy={solverStrategy}
|
||||
strategy={resultSolverStrategy}
|
||||
history={iterationHistory}
|
||||
tolerance={result?.convergence?.tolerance ?? null}
|
||||
/>
|
||||
|
||||
66
apps/web/src/components/shell/ApiHealthIndicator.tsx
Normal file
66
apps/web/src/components/shell/ApiHealthIndicator.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Activity, AlertCircle } from "lucide-react";
|
||||
import { checkHealth } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* Tiny live ping that surfaces whether the Rust ui-server is reachable.
|
||||
*
|
||||
* Sits in the bottom status bar so the user always knows if Simulate will
|
||||
* work before clicking it. Polls every 15s + once immediately on focus regain.
|
||||
*/
|
||||
export default function ApiHealthIndicator() {
|
||||
const [status, setStatus] = useState<"ok" | "down" | "checking">("checking");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const ping = async () => {
|
||||
const ok = await checkHealth();
|
||||
if (active) setStatus(ok ? "ok" : "down");
|
||||
};
|
||||
|
||||
ping();
|
||||
timer = setInterval(ping, 15000);
|
||||
|
||||
const onFocus = () => ping();
|
||||
window.addEventListener("focus", onFocus);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (timer) clearInterval(timer);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (status === "ok") {
|
||||
return (
|
||||
<span
|
||||
className="flex items-center gap-1.5 rounded bg-emerald-500/15 px-2 py-0.5 text-[10.5px] font-semibold text-emerald-700"
|
||||
title="Rust ui-server is reachable on :3030 — Simulate will work"
|
||||
>
|
||||
<Activity size={11} className="text-emerald-600" />
|
||||
API ready
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "down") {
|
||||
return (
|
||||
<span
|
||||
className="flex items-center gap-1.5 rounded bg-red-500/15 px-2 py-0.5 text-[10.5px] font-semibold text-red-700"
|
||||
title="Rust ui-server is DOWN — run: cargo run -p entropyk-demo --bin ui-server"
|
||||
>
|
||||
<AlertCircle size={11} className="text-red-600" />
|
||||
API down — run ui-server
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 rounded bg-[var(--chrome-2)] px-2 py-0.5 text-[10.5px] text-[var(--ink-faint)]">
|
||||
<Activity size={11} className="animate-pulse" />
|
||||
Checking…
|
||||
</span>
|
||||
);
|
||||
}
|
||||
107
apps/web/src/components/shell/DocumentTabs.tsx
Normal file
107
apps/web/src/components/shell/DocumentTabs.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { X, Folder, Package } from "lucide-react";
|
||||
import type { Doc } from "@/lib/useDocuments";
|
||||
|
||||
interface DocumentTabsProps {
|
||||
docs: Doc[];
|
||||
activeId: string;
|
||||
onSwitch: (id: string) => void;
|
||||
onClose: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab strip — scenario first, then every open class.
|
||||
* Class tabs always show `Name.ekmod` so the model identity is never lost.
|
||||
*/
|
||||
export default function DocumentTabs({
|
||||
docs,
|
||||
activeId,
|
||||
onSwitch,
|
||||
onClose,
|
||||
}: DocumentTabsProps) {
|
||||
return (
|
||||
<div className="flex h-9 items-stretch border-b border-[var(--line)] bg-[var(--chrome)] overflow-x-auto">
|
||||
{docs.map((doc) => {
|
||||
const isActive = doc.id === activeId;
|
||||
const isWorkspace = doc.kind === "workspace";
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
role="tab"
|
||||
tabIndex={0}
|
||||
aria-selected={isActive}
|
||||
onClick={() => onSwitch(doc.id)}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 1 && !isWorkspace) {
|
||||
e.preventDefault();
|
||||
onClose(doc.id);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSwitch(doc.id);
|
||||
}
|
||||
}}
|
||||
className={`group relative flex min-w-[150px] max-w-[240px] cursor-pointer items-center gap-2 border-r border-[var(--line)] px-3 text-[12px] transition-colors ${
|
||||
isActive
|
||||
? "bg-[var(--panel)] text-[var(--ink)]"
|
||||
: "bg-[var(--chrome)] text-[var(--ink-dim)] hover:bg-[var(--chrome-2)] hover:text-[var(--ink)]"
|
||||
}`}
|
||||
title={
|
||||
isWorkspace
|
||||
? "Workspace — main canvas (place classes and parts here)"
|
||||
: `Class ${doc.name}.ekmod — edit definition`
|
||||
}
|
||||
>
|
||||
{isActive && (
|
||||
<span className="absolute inset-x-0 top-0 h-[2px] bg-[var(--accent)]" />
|
||||
)}
|
||||
|
||||
{isWorkspace ? (
|
||||
<Folder size={13} className="shrink-0 text-[var(--accent)]" />
|
||||
) : (
|
||||
<Package size={13} className="shrink-0 text-[var(--accent)]" />
|
||||
)}
|
||||
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{isWorkspace ? (
|
||||
<span className="font-semibold">Workspace</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="mono font-semibold">{doc.name}</span>
|
||||
<span className="mono ml-0.5 text-[10px] font-normal text-[var(--ink-faint)]">
|
||||
.ekmod
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{doc.dirty && (
|
||||
<span
|
||||
className="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--warn)]"
|
||||
title="Unsaved changes"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isWorkspace && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose(doc.id);
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-[var(--ink-faint)] opacity-0 hover:bg-[var(--chrome-2)] hover:text-[var(--ink)] group-hover:opacity-100"
|
||||
title={`Close ${doc.name}.ekmod`}
|
||||
aria-label={`Close ${doc.name} class`}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
624
apps/web/src/components/shell/LeftNav.tsx
Normal file
624
apps/web/src/components/shell/LeftNav.tsx
Normal file
@@ -0,0 +1,624 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Box,
|
||||
Boxes,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
FolderPlus,
|
||||
Package,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { useDiagramStore, type EntropykNodeData } from "@/store/diagramStore";
|
||||
import { COMPONENTS, COMPONENT_CATEGORIES, type ComponentMeta } from "@/lib/componentMeta";
|
||||
import { ComponentIcon } from "@/components/canvas/ComponentIcon";
|
||||
import { hxFamily } from "@/lib/hxFamily";
|
||||
import { fetchModules, type ModuleInfo } from "@/lib/api";
|
||||
|
||||
interface LeftNavProps {
|
||||
onSelectModuleForEdit?: (moduleName: string) => void;
|
||||
onEnterModule?: (moduleName: string, instanceName: string) => void;
|
||||
onOpenNewModelModal?: () => void;
|
||||
/** Currently open model tab (.ekmod), if any. */
|
||||
activeClassName?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Package browser — Modelica / OMEdit mental model:
|
||||
*
|
||||
* Models (.ekmod) = classes / modèles (comme Modelica)
|
||||
* Diagram = contenu du canvas ouvert (instances + parts)
|
||||
* Parts = composants atomiques
|
||||
*
|
||||
* Le Workspace est l’onglet canvas du haut — pas une section vide ici.
|
||||
*/
|
||||
export default function LeftNav({
|
||||
onSelectModuleForEdit,
|
||||
onEnterModule,
|
||||
onOpenNewModelModal,
|
||||
activeClassName = null,
|
||||
}: LeftNavProps) {
|
||||
const [modelsOpen, setModelsOpen] = useState(true);
|
||||
const [diagramOpen, setDiagramOpen] = useState(true);
|
||||
const [partsOpen, setPartsOpen] = useState(true);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const queryLower = query.trim().toLowerCase();
|
||||
const hasQuery = queryLower.length > 0;
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-64 flex-col border-r border-[var(--line)] bg-[var(--panel)]">
|
||||
<div className="border-b border-[var(--line)] bg-[var(--chrome)] p-2">
|
||||
<div className="relative flex items-center">
|
||||
<Search size={12} className="absolute left-2.5 text-[var(--ink-faint)]" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Rechercher modèles, diagramme, parts…"
|
||||
className="w-full rounded border border-[var(--line)] bg-white py-1 pl-7 pr-2 text-[11px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
{/* Models = Modelica classes — primary */}
|
||||
<ModelsSection
|
||||
open={modelsOpen || hasQuery}
|
||||
onToggle={() => setModelsOpen((v) => !v)}
|
||||
query={queryLower}
|
||||
activeClassName={activeClassName}
|
||||
onSelectModuleForEdit={onSelectModuleForEdit}
|
||||
onOpenNewModelModal={onOpenNewModelModal}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* What's on the open canvas — not "Workspace" */}
|
||||
<DiagramSection
|
||||
open={diagramOpen || hasQuery}
|
||||
onToggle={() => setDiagramOpen((v) => !v)}
|
||||
query={queryLower}
|
||||
activeClassName={activeClassName}
|
||||
onEnterModule={onEnterModule}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<PartsSection
|
||||
open={partsOpen || hasQuery}
|
||||
onToggle={() => setPartsOpen((v) => !v)}
|
||||
query={queryLower}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div className="h-px bg-[var(--line)]" />;
|
||||
}
|
||||
|
||||
function SectionHeader({
|
||||
open,
|
||||
onToggle,
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
trailing,
|
||||
}: {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
count?: number;
|
||||
trailing?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-8 items-center gap-1 border-b border-[var(--line)]/50 bg-[var(--chrome)] px-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex min-w-0 flex-1 items-center gap-1 text-left hover:text-[var(--ink)]"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown size={12} className="text-[var(--ink-faint)]" />
|
||||
) : (
|
||||
<ChevronRight size={12} className="text-[var(--ink-faint)]" />
|
||||
)}
|
||||
<span className="flex h-4 w-4 items-center justify-center">{icon}</span>
|
||||
<span className="eyebrow">{label}</span>
|
||||
{typeof count === "number" && (
|
||||
<span className="mono ml-1 text-[9px] text-[var(--ink-faint)]">{count}</span>
|
||||
)}
|
||||
</button>
|
||||
{trailing}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Models = Modelica classes (.ekmod) ───────────────────────────── */
|
||||
|
||||
function ModelsSection({
|
||||
open,
|
||||
onToggle,
|
||||
query,
|
||||
activeClassName,
|
||||
onSelectModuleForEdit,
|
||||
onOpenNewModelModal,
|
||||
}: {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
query: string;
|
||||
activeClassName?: string | null;
|
||||
onSelectModuleForEdit?: (name: string) => void;
|
||||
onOpenNewModelModal?: () => void;
|
||||
}) {
|
||||
const [modules, setModules] = useState<ModuleInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await fetchModules();
|
||||
if (active) setModules(list);
|
||||
} finally {
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
const handler = () => load();
|
||||
window.addEventListener("entropyk-modules-updated", handler);
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener("entropyk-modules-updated", handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
modules.filter(
|
||||
(m) =>
|
||||
!query ||
|
||||
m.name.toLowerCase().includes(query) ||
|
||||
m.ports.some((p) => p.toLowerCase().includes(query)),
|
||||
),
|
||||
[modules, query],
|
||||
);
|
||||
|
||||
const onModuleDragStart = (e: React.DragEvent, moduleName: string, ports: string[]) => {
|
||||
e.dataTransfer.setData("application/entropyk-type", `ModuleInstance:${moduleName}`);
|
||||
e.dataTransfer.setData("application/entropyk-ports", JSON.stringify(ports));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
const dropOnCanvas = (moduleName: string, ports: string[]) => {
|
||||
useDiagramStore
|
||||
.getState()
|
||||
.addComponent(
|
||||
"ModuleInstance",
|
||||
{ x: 280 + Math.random() * 40, y: 140 + Math.random() * 40 },
|
||||
{ module_name: moduleName, module_ports: JSON.stringify(ports) },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<SectionHeader
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
icon={<Package size={12} className="text-[var(--accent)]" />}
|
||||
label="Models"
|
||||
count={modules.length}
|
||||
/>
|
||||
{open && (
|
||||
<div className="py-1.5">
|
||||
{onOpenNewModelModal && (
|
||||
<div className="px-2 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpenNewModelModal();
|
||||
}}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-md bg-[var(--accent)] px-3 py-2 text-[12px] font-semibold text-white shadow-sm hover:bg-[var(--accent-hover,#1d4ed8)] active:scale-[0.99]"
|
||||
>
|
||||
<FolderPlus size={14} /> Nouveau modèle
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="px-3 pb-1.5 text-[10px] leading-snug text-[var(--ink-faint)]">
|
||||
Classes Modelica · .ekmod — double-clic pour éditer, glisser pour instancier
|
||||
</p>
|
||||
{loading && modules.length === 0 && (
|
||||
<p className="px-3 py-2 text-[11px] text-[var(--ink-faint)]">Chargement…</p>
|
||||
)}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<p className="px-3 py-2 text-[11px] text-[var(--ink-faint)]">
|
||||
{query ? "Aucun modèle." : "Aucun modèle — clique Nouveau."}
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((m) => {
|
||||
const nInst = m.n_instances || 0;
|
||||
const nComp = m.n_components || 0;
|
||||
const isActive = activeClassName === m.name;
|
||||
const meta =
|
||||
nInst > 0
|
||||
? `${nInst} nested · ${nComp} parts`
|
||||
: nComp > 0
|
||||
? `${nComp} part${nComp > 1 ? "s" : ""}`
|
||||
: "vide";
|
||||
return (
|
||||
<div
|
||||
key={m.name}
|
||||
draggable
|
||||
onDragStart={(e) => onModuleDragStart(e, m.name, m.ports)}
|
||||
onDoubleClick={() => onSelectModuleForEdit?.(m.name)}
|
||||
className={`group mx-1.5 mb-1.5 cursor-grab rounded-md border px-2.5 py-2 active:cursor-grabbing ${
|
||||
isActive
|
||||
? "border-[var(--accent)] bg-[var(--accent-subtle,#eff6ff)]"
|
||||
: "border-[var(--line)] bg-white hover:border-[var(--accent)]/40"
|
||||
}`}
|
||||
title={`${m.name}.ekmod`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Package size={14} className="shrink-0 text-[var(--accent)]" />
|
||||
<span className="mono min-w-0 flex-1 truncate text-[12px] font-semibold text-[var(--ink)]">
|
||||
{m.name}
|
||||
</span>
|
||||
<span className="mono shrink-0 text-[10px] text-[var(--ink-faint)]">.ekmod</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-1 pl-[22px]">
|
||||
<span className="mono text-[10px] text-[var(--ink-faint)]">
|
||||
{meta}
|
||||
{m.ports.length > 0 ? ` · ${m.ports.length} ports` : ""}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
dropOnCanvas(m.name, m.ports);
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-semibold text-[var(--accent)] hover:bg-[var(--accent)]/10"
|
||||
title="Instancier sur le diagramme"
|
||||
>
|
||||
Place
|
||||
</button>
|
||||
{onSelectModuleForEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectModuleForEdit(m.name);
|
||||
}}
|
||||
className="flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-semibold text-[var(--accent)] hover:bg-[var(--accent)]/10"
|
||||
title="Éditer le modèle"
|
||||
>
|
||||
Edit <ArrowRight size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{m.ports.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1 pl-[22px]">
|
||||
{m.ports.slice(0, 8).map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
className="mono rounded border border-[var(--accent)]/25 bg-[var(--accent)]/5 px-1.5 py-0.5 text-[9px] font-medium text-[var(--accent)]"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
{m.ports.length > 8 && (
|
||||
<span className="text-[9px] text-[var(--ink-faint)]">
|
||||
+{m.ports.length - 8}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Diagram = nodes on the open canvas ───────────────────────────── */
|
||||
|
||||
function DiagramSection({
|
||||
open,
|
||||
onToggle,
|
||||
query,
|
||||
activeClassName,
|
||||
onEnterModule,
|
||||
}: {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
query: string;
|
||||
activeClassName?: string | null;
|
||||
onEnterModule?: (moduleName: string, instanceName: string) => void;
|
||||
}) {
|
||||
const nodes = useDiagramStore((s) => s.nodes);
|
||||
const selectedNodeId = useDiagramStore((s) => s.selectedNodeId);
|
||||
const setSelected = useDiagramStore((s) => s.setSelected);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
const filtered = query
|
||||
? nodes.filter((n) => {
|
||||
const data = n.data as unknown as EntropykNodeData;
|
||||
return (
|
||||
data.name.toLowerCase().includes(query) ||
|
||||
String(data.type).toLowerCase().includes(query) ||
|
||||
String(data.moduleName ?? "").toLowerCase().includes(query)
|
||||
);
|
||||
})
|
||||
: nodes;
|
||||
|
||||
const sectionLabel = activeClassName ? `Diagram · ${activeClassName}` : "Diagram";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<SectionHeader
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
icon={<Box size={12} className="text-[var(--ink-dim)]" />}
|
||||
label={sectionLabel}
|
||||
count={nodes.length}
|
||||
/>
|
||||
{open && (
|
||||
<div className="py-1">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-3 py-2 text-[10.5px] leading-snug text-[var(--ink-faint)]">
|
||||
{query
|
||||
? "Aucun élément."
|
||||
: activeClassName
|
||||
? "Diagramme vide — dépose des parts pour définir ce modèle."
|
||||
: "Rien sur le canvas — glisse un modèle depuis Models, ou des parts."}
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((n) => {
|
||||
const data = n.data as unknown as EntropykNodeData;
|
||||
const isModule = data.type === "ModuleInstance" || !!data.moduleName;
|
||||
const isSelected = n.id === selectedNodeId;
|
||||
const isExpanded = !!expanded[n.id];
|
||||
const label = isModule ? (
|
||||
<span className="flex items-center gap-1 truncate">
|
||||
<span className="truncate">{data.name}</span>
|
||||
<span className="mono text-[9px] text-[var(--ink-faint)]">
|
||||
: {String(data.moduleName ?? data.params?.module_name ?? "")}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate">{data.name}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={n.id}>
|
||||
<TreeRow
|
||||
icon={
|
||||
isModule ? (
|
||||
<Box size={11} className="text-[var(--accent)]" />
|
||||
) : (
|
||||
<Circle size={8} className="text-[var(--ink-faint)]" />
|
||||
)
|
||||
}
|
||||
label={label}
|
||||
depth={0}
|
||||
active={isSelected}
|
||||
onClick={() => setSelected(n.id)}
|
||||
expandable={isModule}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => setExpanded((e) => ({ ...e, [n.id]: !e[n.id] }))}
|
||||
/>
|
||||
{isModule && isExpanded && (
|
||||
<div className="ml-6 py-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const mName = String(
|
||||
data.moduleName ?? data.params?.module_name ?? "",
|
||||
);
|
||||
if (mName && onEnterModule) onEnterModule(mName, data.name);
|
||||
}}
|
||||
className="flex items-center gap-1 rounded px-2 py-0.5 text-[10.5px] text-[var(--accent)] hover:bg-[var(--accent-subtle)]"
|
||||
>
|
||||
Ouvrir le modèle <ArrowRight size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TreeRowProps {
|
||||
icon: React.ReactNode;
|
||||
label: React.ReactNode;
|
||||
depth: number;
|
||||
active?: boolean;
|
||||
onClick?: () => void;
|
||||
expandable?: boolean;
|
||||
expanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
function TreeRow({
|
||||
icon,
|
||||
label,
|
||||
depth,
|
||||
active,
|
||||
onClick,
|
||||
expandable,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: TreeRowProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`flex cursor-pointer items-center gap-1.5 rounded-[2px] py-1 pr-2 text-[11.5px] transition-colors ${
|
||||
active
|
||||
? "bg-[var(--accent-subtle)] text-[var(--accent)]"
|
||||
: "text-[var(--ink-dim)] hover:bg-[var(--chrome-2)]"
|
||||
}`}
|
||||
style={{ paddingLeft: 8 + depth * 14 }}
|
||||
>
|
||||
{expandable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle?.();
|
||||
}}
|
||||
className="text-[var(--ink-faint)] hover:text-[var(--ink)]"
|
||||
>
|
||||
{expanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-[10px]" />
|
||||
)}
|
||||
<span className="flex h-4 w-4 items-center justify-center">{icon}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Parts (atomic thermo components) ─────────────────────────────── */
|
||||
|
||||
function PartsSection({
|
||||
open,
|
||||
onToggle,
|
||||
query,
|
||||
}: {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
query: string;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({
|
||||
Advanced: true,
|
||||
Generic: true,
|
||||
});
|
||||
const toggle = (cat: string) => setCollapsed((c) => ({ ...c, [cat]: !c[cat] }));
|
||||
|
||||
const onDragStart = (e: React.DragEvent, type: string) => {
|
||||
e.dataTransfer.setData("application/entropyk-type", type);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
const filteredCats = COMPONENT_CATEGORIES.map((cat) => ({
|
||||
cat,
|
||||
items: COMPONENTS.filter(
|
||||
(c) =>
|
||||
c.category === cat &&
|
||||
(!query ||
|
||||
c.label.toLowerCase().includes(query) ||
|
||||
c.type.toLowerCase().includes(query) ||
|
||||
(c.description ?? "").toLowerCase().includes(query)),
|
||||
),
|
||||
})).filter((entry) => entry.items.length > 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<SectionHeader
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
icon={<Boxes size={12} className="text-[var(--ink-dim)]" />}
|
||||
label="Parts"
|
||||
count={COMPONENTS.length}
|
||||
/>
|
||||
{open && (
|
||||
<div className="py-1">
|
||||
{filteredCats.length === 0 && (
|
||||
<p className="px-3 py-2 text-[10.5px] text-[var(--ink-faint)]">
|
||||
Aucune part.
|
||||
</p>
|
||||
)}
|
||||
{filteredCats.map(({ cat, items }) => {
|
||||
const isOpen = !collapsed[cat] || !!query;
|
||||
return (
|
||||
<div key={cat} className="palette-cat">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(cat)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center gap-1 px-2.5 py-1.5 text-left hover:bg-[var(--chrome-2)]"
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown size={11} className="text-[var(--ink-faint)]" />
|
||||
) : (
|
||||
<ChevronRight size={11} className="text-[var(--ink-faint)]" />
|
||||
)}
|
||||
<span className="eyebrow">{cat}</span>
|
||||
<span className="mono ml-1 text-[9px] text-[var(--ink-faint)]">
|
||||
{items.length}
|
||||
</span>
|
||||
</button>
|
||||
<div className={`palette-cat-body ${isOpen ? "is-open" : "is-closed"}`}>
|
||||
<div className="palette-cat-inner pb-1">
|
||||
{items.map((c, i) => (
|
||||
<PaletteItem key={c.type} meta={c} index={i} onDragStart={onDragStart} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PaletteItem({
|
||||
meta,
|
||||
index,
|
||||
onDragStart,
|
||||
}: {
|
||||
meta: ComponentMeta;
|
||||
index: number;
|
||||
onDragStart: (e: React.DragEvent, type: string) => void;
|
||||
}) {
|
||||
const itemType = String(meta.type || "");
|
||||
const family = hxFamily(itemType);
|
||||
const color = family?.accent ?? meta.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, itemType)}
|
||||
className="palette-item group mx-1.5 flex cursor-grab items-center gap-2.5 rounded-[2px] px-2 py-1.5 hover:bg-[var(--chrome-2)] active:cursor-grabbing"
|
||||
style={{ ["--i" as string]: index }}
|
||||
title={meta.help ?? meta.description}
|
||||
>
|
||||
<span className="grid h-9 w-9 flex-shrink-0 place-items-center">
|
||||
<span style={{ width: 30, height: 30 }}>
|
||||
<ComponentIcon type={itemType} color={color} />
|
||||
</span>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12px] text-[var(--ink-dim)] group-hover:text-[var(--ink)]">
|
||||
{meta.label}
|
||||
</span>
|
||||
{family && (
|
||||
<span className="mono text-[8px] uppercase tracking-[0.08em] text-[var(--ink-faint)]">
|
||||
{family.badge} · {family.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
539
apps/web/src/components/shell/TopBar.tsx
Normal file
539
apps/web/src/components/shell/TopBar.tsx
Normal file
@@ -0,0 +1,539 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Boxes,
|
||||
ChevronRight,
|
||||
Eraser,
|
||||
FileDown,
|
||||
FileUp,
|
||||
Folder,
|
||||
Grid3x3,
|
||||
HelpCircle,
|
||||
Layers,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Package,
|
||||
PackagePlus,
|
||||
Play,
|
||||
Redo2,
|
||||
RotateCw,
|
||||
FlipHorizontal2,
|
||||
FlipVertical2,
|
||||
Undo2,
|
||||
X,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { useDiagramStore } from "@/store/diagramStore";
|
||||
import MultiRunPanel from "@/components/panels/MultiRunPanel";
|
||||
import CreateModuleModal from "@/components/modals/CreateModuleModal";
|
||||
import WorkspaceHelpModal from "@/components/modals/WorkspaceHelpModal";
|
||||
import type { NewDocumentKind } from "@/components/modals/NewModelModal";
|
||||
|
||||
/** UI Load menu — only configs that converge via POST /api/simulate. */
|
||||
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: "chiller_aircooled_r134a_fan.json", label: "Chiller air-cooled + fan" },
|
||||
{ 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" },
|
||||
// Not listed (not executable yet):
|
||||
// - capillary_tube_r134a.json — CapillaryTube graph residuals are stubs (singular Jacobian)
|
||||
];
|
||||
|
||||
interface TopBarProps {
|
||||
/** Breadcrumb path, e.g. ["Untitled"] or ["Untitled", "BaseChiller"]. */
|
||||
scope: string[];
|
||||
unsaved?: boolean;
|
||||
editingModule?: string | null;
|
||||
workspaceName?: string;
|
||||
onNavigateBack?: () => void;
|
||||
onExitModuleEditor?: () => void;
|
||||
onSaveModuleEditor?: () => void;
|
||||
onOpenNew?: (kind?: NewDocumentKind) => void;
|
||||
onSimulate?: () => void;
|
||||
simulating?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workbench toolbar — classes are the primary document type.
|
||||
*
|
||||
* LEFT: brand · Nouveau modèle · Import · Save as class
|
||||
* CENTER: WorkspaceName › ClassName.ekmod (+ Save/Quit when editing)
|
||||
* RIGHT: undo · transform · examples · Solve
|
||||
*/
|
||||
export default function TopBar({
|
||||
scope,
|
||||
unsaved,
|
||||
editingModule,
|
||||
workspaceName = "Workspace",
|
||||
onNavigateBack,
|
||||
onExitModuleEditor,
|
||||
onSaveModuleEditor,
|
||||
onOpenNew,
|
||||
onSimulate,
|
||||
simulating = false,
|
||||
}: TopBarProps) {
|
||||
const {
|
||||
snapToGrid,
|
||||
selectedNodeId,
|
||||
toggleSnapToGrid,
|
||||
rotateNode,
|
||||
flipNodeH,
|
||||
flipNodeV,
|
||||
loadFromConfig,
|
||||
clear,
|
||||
} = useDiagramStore();
|
||||
|
||||
const canUndo = useDiagramStore((s) => s.canUndo);
|
||||
const canRedo = useDiagramStore((s) => s.canRedo);
|
||||
const undo = useDiagramStore((s) => s.undo);
|
||||
const redo = useDiagramStore((s) => s.redo);
|
||||
|
||||
const [issues, setIssues] = useState<string[]>([]);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [multiOpen, setMultiOpen] = useState(false);
|
||||
const [createModuleOpen, setCreateModuleOpen] = useState(false);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
const [exampleFile, setExampleFile] = useState(EXAMPLES[0].file);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
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 = "";
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const tag = target?.tagName;
|
||||
const typing =
|
||||
!!target &&
|
||||
(tag === "TEXTAREA" ||
|
||||
tag === "SELECT" ||
|
||||
target.isContentEditable ||
|
||||
(tag === "INPUT" &&
|
||||
(target as HTMLInputElement).type !== "checkbox" &&
|
||||
(target as HTMLInputElement).type !== "radio"));
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === "n" || e.key === "N")) {
|
||||
e.preventDefault();
|
||||
onOpenNew?.("class");
|
||||
return;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === "s" || e.key === "S")) {
|
||||
e.preventDefault();
|
||||
if (editingModule && onSaveModuleEditor) onSaveModuleEditor();
|
||||
else if (!typing) setCreateModuleOpen(true);
|
||||
return;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onOpenNew, editingModule, onSaveModuleEditor]);
|
||||
|
||||
// Escape cancels the "Clear workspace" confirmation dialog.
|
||||
useEffect(() => {
|
||||
if (!confirmClear) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setConfirmClear(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [confirmClear]);
|
||||
|
||||
const parents = scope.length > 1 ? scope.slice(0, -1) : [];
|
||||
const active = scope.length > 0 ? scope[scope.length - 1] : null;
|
||||
const showClassInScope = !!editingModule;
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="flex h-11 items-center gap-2 border-b border-[var(--line)] bg-[var(--chrome)] px-3">
|
||||
<div className="flex items-center gap-2 pr-1">
|
||||
<Boxes size={16} className="text-[var(--accent)]" />
|
||||
<span className="mono text-[12px] font-semibold tracking-[0.12em] text-[var(--ink)]">
|
||||
ENTROPYK
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-[var(--line)]" />
|
||||
|
||||
{onOpenNew && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenNew("class")}
|
||||
className="flex items-center gap-1.5 rounded-md bg-[var(--accent)] px-2.5 py-1.5 text-[11px] font-semibold text-white shadow-sm hover:bg-[var(--accent-hover,#1d4ed8)]"
|
||||
title="Nouveau modèle dans le Workspace (Ctrl+N)"
|
||||
>
|
||||
<PackagePlus size={13} />
|
||||
Nouveau modèle
|
||||
</button>
|
||||
)}
|
||||
|
||||
{editingModule && (
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-[var(--accent)]/30 bg-[var(--accent-subtle,#eff6ff)] px-2 py-1">
|
||||
<Package size={13} className="text-[var(--accent)]" />
|
||||
<span className="mono text-[12px] font-semibold text-[var(--ink)]">
|
||||
{editingModule}
|
||||
</span>
|
||||
<span className="mono text-[10px] text-[var(--ink-faint)]">.ekmod</span>
|
||||
{unsaved && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[var(--warn)]" title="Unsaved changes" />
|
||||
)}
|
||||
{onSaveModuleEditor && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSaveModuleEditor}
|
||||
className="ml-1 flex items-center gap-1 rounded bg-[var(--accent)] px-2 py-0.5 text-[10px] font-semibold text-white hover:opacity-90"
|
||||
title="Save class (Ctrl+S)"
|
||||
>
|
||||
<Check size={10} /> Save
|
||||
</button>
|
||||
)}
|
||||
{onExitModuleEditor && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExitModuleEditor}
|
||||
className="flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-medium text-[var(--ink-dim)] hover:bg-white"
|
||||
title="Close class tab"
|
||||
>
|
||||
<X size={10} /> Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-1 rounded-md border border-[var(--line)] bg-white px-2 py-1.5 text-[11px] font-medium text-[var(--ink-dim)] hover:bg-[var(--chrome-2)]"
|
||||
title="Import a ScenarioConfig JSON"
|
||||
>
|
||||
<FileUp size={13} /> Import
|
||||
</button>
|
||||
|
||||
{!editingModule && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateModuleOpen(true)}
|
||||
className="flex items-center gap-1 rounded-md border border-[var(--line)] bg-white px-2 py-1.5 text-[11px] font-medium text-[var(--ink-dim)] hover:bg-[var(--chrome-2)]"
|
||||
title="Save current diagram as a class (.ekmod)"
|
||||
>
|
||||
<PackagePlus size={13} className="text-[var(--accent)]" /> Save as class
|
||||
</button>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
onChange={(event) => void onImportJsonFile(event.target.files?.[0])}
|
||||
/>
|
||||
|
||||
<div className="h-5 w-px bg-[var(--line)]" />
|
||||
|
||||
{/* Scope — always show workspace name; class when editing */}
|
||||
<nav className="flex min-w-0 items-center gap-1 text-[12px]" aria-label="Document scope">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateBack}
|
||||
disabled={!showClassInScope}
|
||||
className={`flex items-center gap-1.5 rounded px-1.5 py-0.5 font-medium transition-colors ${
|
||||
showClassInScope
|
||||
? "text-[var(--ink-dim)] hover:bg-[var(--chrome-2)] hover:text-[var(--ink)]"
|
||||
: "cursor-default text-[var(--ink)]"
|
||||
}`}
|
||||
title={showClassInScope ? "Back to Workspace" : "Workspace"}
|
||||
>
|
||||
<Folder size={13} className="text-[var(--accent)]" />
|
||||
<span className="truncate font-semibold">
|
||||
{workspaceName === "Untitled" ? "Workspace" : workspaceName}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{showClassInScope && editingModule && (
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
<ChevronRight size={12} className="shrink-0 text-[var(--ink-muted,#b7c0ca)]" />
|
||||
<Package size={12} className="shrink-0 text-[var(--accent)]" />
|
||||
<span className="mono truncate font-semibold text-[var(--ink)]">{editingModule}</span>
|
||||
<span className="mono shrink-0 text-[10px] text-[var(--ink-faint)]">.ekmod</span>
|
||||
{unsaved && (
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--warn)]" title="Unsaved" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
</nav>
|
||||
|
||||
{issues.length > 0 && (
|
||||
<span
|
||||
className="mono ml-1 max-w-[200px] truncate rounded bg-[var(--warn-subtle,#fffbeb)] px-2 py-0.5 text-[10px] text-[var(--warn)]"
|
||||
title={issues.join("\n")}
|
||||
>
|
||||
{issues[0]}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={undo}
|
||||
disabled={!canUndo}
|
||||
className="grid h-7 w-7 place-items-center rounded text-[var(--ink-dim)] hover:bg-[var(--chrome-2)] disabled:opacity-35"
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
<Undo2 size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={redo}
|
||||
disabled={!canRedo}
|
||||
className="grid h-7 w-7 place-items-center rounded text-[var(--ink-dim)] hover:bg-[var(--chrome-2)] disabled:opacity-35"
|
||||
title="Redo (Ctrl+Y)"
|
||||
>
|
||||
<Redo2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-[var(--line)]" />
|
||||
|
||||
<div className="flex items-center overflow-hidden rounded-md border border-[var(--line)] bg-white">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectedNodeId && rotateNode(selectedNodeId, 1)}
|
||||
disabled={!selectedNodeId}
|
||||
className="grid h-7 w-7 place-items-center text-[var(--ink-dim)] hover:bg-[var(--chrome-2)] disabled:opacity-35"
|
||||
title="Rotate 90° (R)"
|
||||
>
|
||||
<RotateCw size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectedNodeId && flipNodeH(selectedNodeId)}
|
||||
disabled={!selectedNodeId}
|
||||
className="grid h-7 w-7 place-items-center border-l border-[var(--line)] text-[var(--ink-dim)] hover:bg-[var(--chrome-2)] disabled:opacity-35"
|
||||
title="Flip horizontal (H)"
|
||||
>
|
||||
<FlipHorizontal2 size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectedNodeId && flipNodeV(selectedNodeId)}
|
||||
disabled={!selectedNodeId}
|
||||
className="grid h-7 w-7 place-items-center border-l border-[var(--line)] text-[var(--ink-dim)] hover:bg-[var(--chrome-2)] disabled:opacity-35"
|
||||
title="Flip vertical (V)"
|
||||
>
|
||||
<FlipVertical2 size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSnapToGrid}
|
||||
className={`grid h-7 w-7 place-items-center border-l border-[var(--line)] ${
|
||||
snapToGrid
|
||||
? "bg-[var(--accent-subtle,#eff6ff)] text-[var(--accent)]"
|
||||
: "text-[var(--ink-dim)] hover:bg-[var(--chrome-2)]"
|
||||
}`}
|
||||
title="Snap to grid"
|
||||
>
|
||||
<Grid3x3 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-[var(--line)]" />
|
||||
|
||||
<select
|
||||
value={exampleFile}
|
||||
onChange={(e) => setExampleFile(e.target.value)}
|
||||
className="mono max-w-[140px] rounded-md border border-[var(--line)] bg-white px-1.5 py-1 text-[11px] text-[var(--ink-dim)] outline-none focus:border-[var(--accent)]"
|
||||
title="Example scenario"
|
||||
>
|
||||
{EXAMPLES.map((ex) => (
|
||||
<option key={ex.file} value={ex.file}>
|
||||
{ex.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoadExample}
|
||||
className="flex items-center gap-1 rounded-md border border-[var(--line)] bg-white px-2 py-1.5 text-[11px] font-medium text-[var(--ink-dim)] hover:bg-[var(--chrome-2)]"
|
||||
title="Load selected example into the scenario"
|
||||
>
|
||||
<FileDown size={13} /> Load
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMoreOpen((v) => !v)}
|
||||
className="grid h-7 w-7 place-items-center rounded text-[var(--ink-dim)] hover:bg-[var(--chrome-2)]"
|
||||
title="More actions"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={moreOpen}
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
{moreOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setMoreOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full z-20 mt-1 w-56 overflow-hidden rounded-md border border-[var(--line)] bg-white py-1 text-[12px] shadow-md"
|
||||
>
|
||||
<MenuItem
|
||||
icon={<Layers size={13} />}
|
||||
label="Multi-run sweep…"
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
setMultiOpen(true);
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<HelpCircle size={13} />}
|
||||
label="Guide & help"
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
setHelpOpen(true);
|
||||
}}
|
||||
/>
|
||||
<div className="my-1 h-px bg-[var(--line)]" />
|
||||
<MenuItem
|
||||
icon={<Eraser size={13} />}
|
||||
label="Clear workspace"
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
setConfirmClear(true);
|
||||
}}
|
||||
danger
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-[var(--line)]" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSimulate}
|
||||
disabled={simulating}
|
||||
className="flex items-center gap-1.5 rounded-md bg-[var(--accent)] px-3 py-1.5 text-[11px] font-semibold text-white shadow-sm hover:bg-[var(--accent-hover,#1d4ed8)] disabled:opacity-50"
|
||||
title="Solve scenario (Ctrl+Enter)"
|
||||
>
|
||||
{simulating ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={13} className="fill-current" />
|
||||
)}
|
||||
{simulating ? "Solving…" : "Solve"}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{multiOpen && <MultiRunPanel onClose={() => setMultiOpen(false)} />}
|
||||
{createModuleOpen && (
|
||||
<CreateModuleModal
|
||||
isOpen={createModuleOpen}
|
||||
onClose={() => setCreateModuleOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{helpOpen && (
|
||||
<WorkspaceHelpModal isOpen={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
)}
|
||||
{confirmClear && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 overscroll-contain"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Confirmer l'effacement"
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-lg border border-[var(--line)] bg-[var(--panel)] p-4 shadow-xl">
|
||||
<p className="text-[13px] font-semibold text-[var(--ink)]">
|
||||
Effacer tout le schéma ?
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-[var(--ink-dim)]">
|
||||
Cette action est irréversible.
|
||||
</p>
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmClear(false)}
|
||||
className="rounded-md border border-[var(--line)] bg-white px-3 py-1.5 text-[11px] font-medium text-[var(--ink-dim)] hover:bg-[var(--chrome-2)]"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clear();
|
||||
setConfirmClear(false);
|
||||
}}
|
||||
className="rounded-md bg-[var(--warn)] px-3 py-1.5 text-[11px] font-semibold text-white hover:opacity-90"
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
danger,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
danger?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left transition-colors ${
|
||||
danger
|
||||
? "text-[var(--hot)] hover:bg-[var(--hot-subtle,#fef2f2)]"
|
||||
: "text-[var(--ink-dim)] hover:bg-[var(--chrome-2)] hover:text-[var(--ink)]"
|
||||
}`}
|
||||
>
|
||||
<span className="shrink-0 opacity-80">{icon}</span>
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -112,11 +112,99 @@ export async function fetchComponents(): Promise<ComponentMeta[]> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface ModuleInfo {
|
||||
name: string;
|
||||
ports: string[];
|
||||
n_components: number;
|
||||
n_instances?: number;
|
||||
}
|
||||
|
||||
export async function fetchModules(): Promise<ModuleInfo[]> {
|
||||
try {
|
||||
let res = await fetch(`${API_BASE}/modules`, { cache: "no-store" }).catch(() => null);
|
||||
if (!res || !res.ok) {
|
||||
res = await fetch("http://localhost:3030/api/modules", { cache: "no-store" }).catch(() => null);
|
||||
}
|
||||
if (res && res.ok) {
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((m: ModuleInfo) => {
|
||||
if (m.name === "DualCircuit" && m.n_instances === undefined) {
|
||||
return { ...m, n_instances: 2 };
|
||||
}
|
||||
return m;
|
||||
});
|
||||
}
|
||||
}
|
||||
return [
|
||||
{ name: "BaseChiller", ports: ["chw_in", "chw_out", "cond_water_in", "cond_water_out"], n_components: 4, n_instances: 0 },
|
||||
{ name: "DualCircuit", ports: ["plant_chw_in", "plant_chw_out"], n_components: 0, n_instances: 2 },
|
||||
];
|
||||
} catch {
|
||||
return [
|
||||
{ name: "BaseChiller", ports: ["chw_in", "chw_out", "cond_water_in", "cond_water_out"], n_components: 4, n_instances: 0 },
|
||||
{ name: "DualCircuit", ports: ["plant_chw_in", "plant_chw_out"], n_components: 0, n_instances: 2 },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchModule(name: string): Promise<unknown | null> {
|
||||
try {
|
||||
let res = await fetch(`${API_BASE}/modules/${encodeURIComponent(name)}`, { cache: "no-store" }).catch(() => null);
|
||||
if (!res || !res.ok) {
|
||||
res = await fetch(`http://localhost:3030/api/modules/${encodeURIComponent(name)}`, { cache: "no-store" }).catch(() => null);
|
||||
}
|
||||
if (!res || !res.ok) return null;
|
||||
return res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveModule(name: string, content: unknown): Promise<{ ok: boolean; error?: string }> {
|
||||
const body = JSON.stringify({ name, content });
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
|
||||
// Prefer same-origin proxy first (avoids CORS / mixed-origin failures).
|
||||
const endpoints = [
|
||||
`${API_BASE}/modules`,
|
||||
`http://localhost:3030/api/modules`,
|
||||
`http://localhost:3030/api/modules/${encodeURIComponent(name)}`,
|
||||
];
|
||||
|
||||
let lastError = "Serveur non joignable. Démarre ui-server sur :3030.";
|
||||
|
||||
for (const url of endpoints) {
|
||||
try {
|
||||
const payload = url.includes("/modules/") && !url.endsWith("/modules")
|
||||
? JSON.stringify(content)
|
||||
: body;
|
||||
const res = await fetch(url, { method: "POST", headers, body: payload });
|
||||
if (!res.ok) {
|
||||
lastError = `HTTP ${res.status}: ${(await res.text().catch(() => "")).slice(0, 200)}`;
|
||||
continue;
|
||||
}
|
||||
const data = (await res.json().catch(() => ({ ok: true }))) as {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
if (data.ok === false) {
|
||||
return { ok: false, error: data.error || "Échec de sauvegarde" };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
lastError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false, error: lastError };
|
||||
}
|
||||
|
||||
export async function simulate(config: unknown): Promise<SimulateResponse> {
|
||||
const healthy = await checkHealth();
|
||||
if (!healthy) {
|
||||
throw new Error(
|
||||
`Entropyk API is down (${API_BASE}/health). Start ui-server: cargo run -p entropyk-demo --bin ui-server`,
|
||||
`Entropyk API server is not responding on ${API_BASE}/health. Run: cargo run -p entropyk-demo --bin ui-server`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,17 +218,26 @@ export async function simulate(config: unknown): Promise<SimulateResponse> {
|
||||
} catch (e) {
|
||||
const detail = e instanceof Error ? e.message : String(e);
|
||||
throw new Error(
|
||||
`Cannot reach Entropyk API (${API_BASE}). ui-server may have crashed mid-solve (CoolProp). Restart it on :3030. ${detail}`,
|
||||
`Cannot reach Entropyk API server at ${API_BASE}. The Rust ui-server likely died (not CoolProp — the server is multitask with proper locking). Restart: cargo run -p entropyk-demo --bin ui-server. Detail: ${detail}`,
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = (await res.text().catch(() => "")).trim();
|
||||
const hint =
|
||||
res.status === 500 || res.status === 502 || res.status === 504
|
||||
? " — ui-server likely crashed (CoolProp race) or was restarting. Restart: cargo run -p entropyk-demo --bin ui-server"
|
||||
: "";
|
||||
// A bare 500 with Next's generic "Internal Server Error" body means the
|
||||
// Rust process died mid-request or is busy on a long solve (the proxy
|
||||
// gives up) — it is NOT a config rejection. Solver rejections come back
|
||||
// as HTTP 200 with `ok:false` and a real message.
|
||||
const isProxyDeath =
|
||||
res.status === 500 && /^internal server error$/i.test(body);
|
||||
const hint = isProxyDeath
|
||||
? " — the Rust ui-server crashed or is stuck on a long solve. Restart it (tools/start-ui.bat) and retry; the last payload was dumped to target/tmp/last_simulate_request.json for reproduction."
|
||||
: res.status === 500
|
||||
? " — solver rejected the configuration (under/over-constrained, missing fluid, etc.). Check the canvas and the diagnostics tab."
|
||||
: res.status === 502 || res.status === 504
|
||||
? " — proxy timeout. The Rust server may be restarting; retry in a few seconds."
|
||||
: "";
|
||||
throw new Error(
|
||||
`Simulate request failed: ${res.status}${hint}${body ? `\n${body.slice(0, 400)}` : ""}`,
|
||||
`Simulate failed: HTTP ${res.status}${hint}${body && !isProxyDeath ? `\n${body.slice(0, 500)}` : ""}`,
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
|
||||
@@ -80,7 +80,10 @@ export function buildComponentInspector(
|
||||
): ComponentInspector {
|
||||
const meta = COMPONENT_BY_TYPE[node.data.type];
|
||||
const title = node.data.name;
|
||||
const typeLabel = meta?.label ?? node.data.type;
|
||||
const typeLabel =
|
||||
node.data.type === "ModuleInstance"
|
||||
? `Sous-système Module (${String(node.data.params.module_name ?? "ekmod")})`
|
||||
: (meta?.label ?? node.data.type);
|
||||
const status = result ? String(result.status) : null;
|
||||
const state = result?.state;
|
||||
|
||||
|
||||
@@ -210,4 +210,10 @@ describe("COMPONENTS catalogue integrity", () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles undefined or empty string type without throwing in nodeSize", () => {
|
||||
expect(() => nodeSize(undefined as unknown as string)).not.toThrow();
|
||||
expect(nodeSize(undefined as unknown as string)).toEqual({ w: 88, h: 58 });
|
||||
expect(nodeSize("")).toEqual({ w: 88, h: 58 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,13 @@ export interface ParamMeta {
|
||||
/** Bounds used when the factor is free (actuator min/max). */
|
||||
freeMin?: number;
|
||||
freeMax?: number;
|
||||
/**
|
||||
* Whether this param can be picked as a Multi-run sweep axis.
|
||||
* Source of truth for sweepability — replaces the old hardcoded whitelist.
|
||||
*/
|
||||
sweepable?: boolean;
|
||||
/** Multi-run optgroup for the param (only meaningful when `sweepable`). */
|
||||
sweepGroup?: "boundaries" | "thermal" | "machine" | "global";
|
||||
}
|
||||
|
||||
/** Internal params key prefix for Fixed flags (not sent as component fields). */
|
||||
@@ -87,29 +94,42 @@ export interface ComponentMeta {
|
||||
params: ParamMeta[];
|
||||
}
|
||||
|
||||
export function genericMeta(type?: string): ComponentMeta {
|
||||
const cleanType = String(type || "GenericComponent");
|
||||
return {
|
||||
type: cleanType,
|
||||
label: cleanType,
|
||||
category: "Generic",
|
||||
description: `Composant thermodynamique (${cleanType})`,
|
||||
ports: ["inlet", "outlet"],
|
||||
color: "#64748b",
|
||||
params: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Port side on the node: "inlet"-like ports go on the left, "outlet"-like
|
||||
* ports on the right. Economizer / liquid_outlet sit on the top/bottom.
|
||||
*/
|
||||
export function portSide(port: string): "left" | "right" | "top" | "bottom" {
|
||||
// Secondary (heat-transfer fluid) ports cross the refrigerant flow vertically:
|
||||
// caloporteur in at the bottom, out at the top (Modelica counterflow convention).
|
||||
if (port === "secondary_inlet") return "bottom";
|
||||
if (port === "secondary_outlet") return "top";
|
||||
if (port === "hot_inlet" || port === "cold_inlet") return "left";
|
||||
if (port === "hot_outlet" || port === "cold_outlet") return "right";
|
||||
if (port.startsWith("outlet")) return "right";
|
||||
if (port === "discharge") return "right";
|
||||
if (port === "liquid_outlet" || port === "vapor_outlet") return "bottom";
|
||||
if (port.startsWith("inlet")) return "left";
|
||||
if (port === "suction") return "left";
|
||||
if (port === "economizer") return "top";
|
||||
const p = String(port || "");
|
||||
if (p === "secondary_inlet") return "bottom";
|
||||
if (p === "secondary_outlet") return "top";
|
||||
if (p === "hot_inlet" || p === "cold_inlet") return "left";
|
||||
if (p === "hot_outlet" || p === "cold_outlet") return "right";
|
||||
if (p.startsWith("outlet")) return "right";
|
||||
if (p === "discharge") return "right";
|
||||
if (p === "liquid_outlet" || p === "vapor_outlet") return "bottom";
|
||||
if (p.startsWith("inlet")) return "left";
|
||||
if (p === "suction") return "left";
|
||||
if (p === "economizer") return "top";
|
||||
return "left";
|
||||
}
|
||||
|
||||
/** True for the secondary (heat-transfer fluid / caloporteur) ports of an exchanger. */
|
||||
export function isSecondaryPort(port: string): boolean {
|
||||
return port === "secondary_inlet" || port === "secondary_outlet";
|
||||
const p = String(port || "");
|
||||
return p === "secondary_inlet" || p === "secondary_outlet";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,12 +138,13 @@ export function isSecondaryPort(port: string): boolean {
|
||||
* (outlet/discharge) to a target (inlet/suction).
|
||||
*/
|
||||
export function portRole(port: string): "source" | "target" {
|
||||
const p = String(port || "");
|
||||
if (
|
||||
port.startsWith("outlet") ||
|
||||
port === "discharge" ||
|
||||
port === "liquid_outlet" ||
|
||||
port === "vapor_outlet" ||
|
||||
port.endsWith("_outlet")
|
||||
p.startsWith("outlet") ||
|
||||
p === "discharge" ||
|
||||
p === "liquid_outlet" ||
|
||||
p === "vapor_outlet" ||
|
||||
p.endsWith("_outlet")
|
||||
) {
|
||||
return "source";
|
||||
}
|
||||
@@ -136,21 +157,22 @@ export function portRole(port: string): "source" | "target" {
|
||||
* compact. Size encodes what the part is, not just decoration.
|
||||
*/
|
||||
export function nodeSize(type: string): { w: number; h: number } {
|
||||
if (type === "SaturatedController") return { w: 184, h: 96 };
|
||||
if (type.includes("Compressor")) return { w: 76, h: 76 };
|
||||
if (type === "HeatExchanger") return { w: 92, h: 92 };
|
||||
const t = String(type || "");
|
||||
if (t === "SaturatedController") return { w: 184, h: 96 };
|
||||
if (t.includes("Compressor")) return { w: 76, h: 76 };
|
||||
if (t === "HeatExchanger") return { w: 92, h: 92 };
|
||||
// Flooded is a horizontal drum; plate a tall pack; coils wide batteries.
|
||||
if (type.includes("Flooded")) return { w: 128, h: 92 };
|
||||
if (type.includes("Bphx")) return { w: 80, h: 110 };
|
||||
if (type.includes("FinCoil") || type.includes("AirCooled") || type.includes("Mchx"))
|
||||
if (t.includes("Flooded")) return { w: 128, h: 92 };
|
||||
if (t.includes("Bphx")) return { w: 80, h: 110 };
|
||||
if (t.includes("FinCoil") || t.includes("AirCooled") || t.includes("Mchx"))
|
||||
return { w: 126, h: 88 };
|
||||
if (type.includes("Condenser") || type.includes("Evaporator")) return { w: 126, h: 88 };
|
||||
if (type.includes("Valve")) return { w: 56, h: 56 };
|
||||
if (type === "Pump" || type === "Fan") return { w: 72, h: 72 };
|
||||
if (type === "Pipe" || type.includes("Pipe") || type === "AirDuct") return { w: 118, h: 42 };
|
||||
if (type === "Drum") return { w: 58, h: 108 };
|
||||
if (type === "FlowSplitter" || type === "FlowMerger") return { w: 72, h: 64 };
|
||||
if (type.endsWith("Source") || type.endsWith("Sink")) return { w: 62, h: 56 };
|
||||
if (t.includes("Condenser") || t.includes("Evaporator")) return { w: 126, h: 88 };
|
||||
if (t.includes("Valve")) return { w: 56, h: 56 };
|
||||
if (t === "Pump" || t === "Fan") return { w: 72, h: 72 };
|
||||
if (t === "Pipe" || t.includes("Pipe") || t === "AirDuct") return { w: 118, h: 42 };
|
||||
if (t === "Drum") return { w: 58, h: 108 };
|
||||
if (t === "FlowSplitter" || t === "FlowMerger") return { w: 72, h: 64 };
|
||||
if (t.endsWith("Source") || t.endsWith("Sink")) return { w: 62, h: 56 };
|
||||
return { w: 88, h: 58 };
|
||||
}
|
||||
|
||||
@@ -319,6 +341,8 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
min: 0.05,
|
||||
max: 1.0,
|
||||
description: "η_is : h_dis = h_suc + (h_is−h_suc)/η_is",
|
||||
sweepable: true,
|
||||
sweepGroup: "machine",
|
||||
},
|
||||
{
|
||||
key: "emergent_pressure",
|
||||
@@ -427,6 +451,8 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
required: true,
|
||||
section: "Machine",
|
||||
min: 1,
|
||||
sweepable: true,
|
||||
sweepGroup: "machine",
|
||||
},
|
||||
{
|
||||
key: "displacement_m3",
|
||||
@@ -481,7 +507,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
color: "#4338ca",
|
||||
params: [
|
||||
{ key: "diameter_m", label: "Impeller diameter", kind: "number", unit: "m", default: 0.25, required: true },
|
||||
{ key: "speed_rpm", label: "Speed", kind: "number", unit: "rpm", default: 9000.0, required: true },
|
||||
{ key: "speed_rpm", label: "Speed", kind: "number", unit: "rpm", default: 9000.0, required: true, sweepable: true, sweepGroup: "machine" },
|
||||
{ key: "gas_constant", label: "Gas constant R", kind: "number", unit: "J/kg·K", default: 188.9, advanced: true },
|
||||
{ key: "gamma", label: "Heat capacity ratio γ", kind: "number", default: 1.12, advanced: true },
|
||||
],
|
||||
@@ -522,6 +548,8 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
unit: "Hz",
|
||||
default: 50.0,
|
||||
section: "Machine",
|
||||
sweepable: true,
|
||||
sweepGroup: "machine",
|
||||
},
|
||||
{
|
||||
key: "nominal_frequency_hz",
|
||||
@@ -558,6 +586,8 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
min: 1.1,
|
||||
max: 6,
|
||||
description: "Built-in Vi; slide valve reduces effective Vi.",
|
||||
sweepable: true,
|
||||
sweepGroup: "machine",
|
||||
},
|
||||
{
|
||||
key: "slide_valve_position",
|
||||
@@ -567,6 +597,8 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
section: "Machine",
|
||||
min: 0.05,
|
||||
max: 1,
|
||||
sweepable: true,
|
||||
sweepGroup: "machine",
|
||||
},
|
||||
// Mass-flow bilinear (Bitzer defaults as numbers)
|
||||
{ key: "mf_a00", label: "ṁ a00", kind: "number", default: 1.35, section: "Map ṁ (SST,SDT)", description: "ṁ = a00 + a10·SST + a01·SDT + a11·SST·SDT" },
|
||||
@@ -601,7 +633,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
{ key: "model", label: "Model / reference", kind: "string", default: "", section: "Identification" },
|
||||
{ key: "fluid", label: "Refrigerant", kind: "string", default: "R134a", section: "Fluids" },
|
||||
{ key: "secondary_fluid", label: "Secondary fluid", kind: "string", default: "Water", section: "Fluids" },
|
||||
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 5000.0, required: true, section: "Thermal model", min: 0.0 },
|
||||
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 5000.0, required: true, section: "Thermal model", min: 0.0, sweepable: true, sweepGroup: "thermal" },
|
||||
{ key: "t_sat_k", label: "Fixed saturation temperature", kind: "number", unit: "K", default: 323.15, section: "Thermal model", description: "Used only in fixed-pressure mode. Do not treat it as a solved-cycle input when emergent pressure is enabled." },
|
||||
{
|
||||
key: "secondary_inlet_temp_c",
|
||||
@@ -768,7 +800,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
{ key: "model", label: "Model / reference", kind: "string", default: "", section: "Identification" },
|
||||
{ key: "fluid", label: "Refrigerant", kind: "string", default: "R134a", section: "Fluids" },
|
||||
{ key: "secondary_fluid", label: "Secondary fluid", kind: "string", default: "Water", section: "Fluids" },
|
||||
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 6000.0, required: true, section: "Thermal model", min: 0.0 },
|
||||
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 6000.0, required: true, section: "Thermal model", min: 0.0, sweepable: true, sweepGroup: "thermal" },
|
||||
{ key: "t_sat_k", label: "Fixed saturation temperature", kind: "number", unit: "K", default: 275.15, section: "Thermal model", description: "Used only in fixed-pressure mode." },
|
||||
{ key: "superheat_k", label: "Outlet superheat closure", kind: "number", unit: "K", default: 5.0, section: "Thermal model", min: 0.0 },
|
||||
{
|
||||
@@ -931,7 +963,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
{ key: "n_air_exponent", label: "Air-flow exponent", kind: "number", default: 0.5, section: "Thermal model", min: 0.0, max: 2.0, description: "Exponent used to scale UA with air-flow ratio." },
|
||||
{ key: "coil_index", label: "Coil index", kind: "number", default: 0, section: "Identification", min: 0, step: 1 },
|
||||
{ key: "air_inlet_temp_c", label: "Air inlet temperature", kind: "number", unit: "°C", default: 35.0, section: "Air side" },
|
||||
{ key: "fan_speed", label: "Fan speed ratio", kind: "number", default: 1.0, section: "Air side", min: 0.0, max: 1.0, step: 0.01 },
|
||||
{ key: "fan_speed", label: "Fan speed ratio", kind: "number", default: 1.0, section: "Air side", min: 0.0, max: 1.0, step: 0.01, sweepable: true, sweepGroup: "machine" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -945,8 +977,8 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
{ key: "manufacturer", label: "Manufacturer", kind: "string", default: "", section: "Identification" },
|
||||
{ key: "model", label: "Model / reference", kind: "string", default: "", section: "Identification" },
|
||||
{ key: "fluid", label: "Refrigerant", kind: "string", default: "R134a", section: "Fluids" },
|
||||
{ key: "ua", label: "UA at full fan", kind: "number", unit: "W/K", default: 4000.0, required: true, section: "Thermal model", min: 0.0 },
|
||||
{ key: "oat_k", label: "Outdoor air temperature", kind: "number", unit: "K", default: 308.15, section: "Air side", description: "Ambient dry-bulb temperature used by the condenser model." },
|
||||
{ key: "ua", label: "UA at full fan", kind: "number", unit: "W/K", default: 4000.0, required: true, section: "Thermal model", min: 0.0, sweepable: true, sweepGroup: "thermal" },
|
||||
{ key: "oat_k", label: "Outdoor air temperature", kind: "number", unit: "K", default: 308.15, section: "Air side", description: "Ambient dry-bulb temperature used by the condenser model.", sweepable: true, sweepGroup: "boundaries" },
|
||||
{ key: "approach_k", label: "Condensing approach", kind: "number", unit: "K", default: 12.0, section: "Thermal model", min: 0.0 },
|
||||
],
|
||||
},
|
||||
@@ -983,8 +1015,8 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
{ key: "louver_pitch_mm", label: "Louver pitch", kind: "number", unit: "mm", default: 1.4, section: "Fin geometry", min: 0.01 },
|
||||
{ key: "tube_od_mm", label: "Tube outside diameter", kind: "number", unit: "mm", default: 9.525, required: true, section: "Tube geometry", min: 0.1 },
|
||||
{ key: "tube_pitch_mm", label: "Transverse tube pitch", kind: "number", unit: "mm", default: 25.4, required: true, section: "Tube geometry", min: 0.1, description: "Longitudinal pitch is derived using the staggered-tube ratio." },
|
||||
{ key: "oat_k", label: "Outdoor air temperature", kind: "number", unit: "K", default: 308.15, required: true, section: "Air side" },
|
||||
{ key: "air_face_velocity_m_s", label: "Air face velocity", kind: "number", unit: "m/s", default: 2.5, required: true, section: "Air side", min: 0.01 },
|
||||
{ key: "oat_k", label: "Outdoor air temperature", kind: "number", unit: "K", default: 308.15, required: true, section: "Air side", sweepable: true, sweepGroup: "boundaries" },
|
||||
{ key: "air_face_velocity_m_s", label: "Air face velocity", kind: "number", unit: "m/s", default: 2.5, required: true, section: "Air side", min: 0.01, sweepable: true, sweepGroup: "boundaries" },
|
||||
{ key: "design_capacity_kw", label: "Design heat rejection", kind: "number", unit: "kW", default: 100.0, required: true, section: "Design point", min: 0.01 },
|
||||
{
|
||||
key: "wet_surface",
|
||||
@@ -1023,6 +1055,15 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
{ key: "dh_m", label: "Hydraulic diameter override", kind: "number", unit: "m", section: "Direct manufacturer data", min: 0.000001, advanced: true, description: "Optional direct override. Use together with heat-transfer area when the supplier provides both values." },
|
||||
{ key: "area_m2", label: "Heat-transfer area override", kind: "number", unit: "m²", section: "Direct manufacturer data", min: 0.000001, advanced: true },
|
||||
{ key: "target_superheat_k", label: "Target superheat", kind: "number", unit: "K", default: 5.0, section: "Operating model", min: 0.0, description: "Brazed plate evaporators are Direct Expansion (DX) only in this project — the refrigerant outlet is always superheated. A flooded design is a system-level property (Drum + recirculation loop); use the Flooded Evaporator (shell-and-tube) component for that." },
|
||||
{
|
||||
key: "emergent_pressure",
|
||||
label: "Solve evaporating pressure",
|
||||
kind: "boolean",
|
||||
default: true,
|
||||
section: "Solved-cycle mode",
|
||||
description:
|
||||
"ON: +1 outlet-closure residual pins h_out to target superheat so P_evap emerges from the secondary balance.",
|
||||
},
|
||||
{
|
||||
key: "correlation",
|
||||
label: "Two-phase correlation",
|
||||
@@ -1101,6 +1142,15 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
{ key: "dh_m", label: "Hydraulic diameter override", kind: "number", unit: "m", section: "Direct manufacturer data", min: 0.000001, advanced: true },
|
||||
{ key: "area_m2", label: "Heat-transfer area override", kind: "number", unit: "m²", section: "Direct manufacturer data", min: 0.000001, advanced: true },
|
||||
{ key: "target_subcooling_k", label: "Target subcooling", kind: "number", unit: "K", default: 3.0, section: "Operating model", min: 0.0 },
|
||||
{
|
||||
key: "emergent_pressure",
|
||||
label: "Solve condensing pressure",
|
||||
kind: "boolean",
|
||||
default: true,
|
||||
section: "Solved-cycle mode",
|
||||
description:
|
||||
"ON: +1 outlet-closure residual pins h_out to target subcooling so P_cond emerges from the secondary balance.",
|
||||
},
|
||||
{
|
||||
key: "correlation",
|
||||
label: "Two-phase correlation",
|
||||
@@ -1173,7 +1223,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
{ key: "model", label: "Model / reference", kind: "string", default: "", section: "Identification" },
|
||||
{ key: "refrigerant", label: "Refrigerant", kind: "string", default: "R134a", section: "Fluids" },
|
||||
{ key: "secondary_fluid", label: "Secondary fluid", kind: "string", default: "Water", section: "Fluids" },
|
||||
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 8000.0, required: true, section: "Thermal model", min: 0.0 },
|
||||
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 8000.0, required: true, section: "Thermal model", min: 0.0, sweepable: true, sweepGroup: "thermal" },
|
||||
{
|
||||
key: "secondary_rated_pressure_drop_pa",
|
||||
label: "Tube-side water design ΔP",
|
||||
@@ -1324,7 +1374,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
params: [
|
||||
{ key: "manufacturer", label: "Manufacturer", kind: "string", default: "", section: "Identification" },
|
||||
{ key: "model", label: "Model / reference", kind: "string", default: "", section: "Identification" },
|
||||
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 3000.0, required: true, section: "Thermal model", min: 0.0 },
|
||||
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 3000.0, required: true, section: "Thermal model", min: 0.0, sweepable: true, sweepGroup: "thermal" },
|
||||
{ key: "hot_fluid_id", label: "Hot-side fluid", kind: "string", default: "Water", section: "Fluids" },
|
||||
{ key: "cold_fluid_id", label: "Cold-side fluid", kind: "string", default: "Air", section: "Fluids" },
|
||||
{ key: "hot_humidity_ratio", label: "Hot-side humidity ratio", kind: "number", unit: "kg/kg", default: 0.0, section: "Psychrometrics", min: 0.0, advanced: true },
|
||||
@@ -1341,14 +1391,39 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
help:
|
||||
"Rôle : fait chuter la pression frigo (h constant).\n\n" +
|
||||
"Ports : inlet (liquide HP) · outlet (biphasique BP).\n\n" +
|
||||
"opening : 1 = plein ouvert. Peut être libéré par une boucle de régulation avancée.\n" +
|
||||
"Sans orifice_kv : isenthalpique seul — opening n’a aucun effet physique.\n" +
|
||||
"Avec orifice_kv : ṁ = Kv·opening·√(2·ρ·ΔP). Opening Fixed (défaut) = paramètre ; " +
|
||||
"Free = inconnue (régulation / SaturatedController).\n" +
|
||||
"Orifice Fixed → le compresseur lâche la loi de ṁ (débit métré par l’EXV).\n" +
|
||||
"t_evap_k : guess / mode pression fixée selon le cycle.",
|
||||
docSlug: "isenthalpic-expansion-valve",
|
||||
ports: ["inlet", "outlet"],
|
||||
color: "#10b981",
|
||||
params: [
|
||||
{ key: "t_evap_k", label: "Evaporating temp (K)", kind: "number", unit: "K", default: 275.15 },
|
||||
{ key: "opening", label: "Valve opening (0–1)", kind: "number", default: 1.0 },
|
||||
{
|
||||
key: "orifice_kv",
|
||||
label: "Orifice Kv",
|
||||
kind: "number",
|
||||
unit: "m²",
|
||||
section: "Orifice",
|
||||
description:
|
||||
"Capacité d’orifice. Obligatoire pour que opening change le débit. Typique ~1e-7…1e-5 m².",
|
||||
},
|
||||
{
|
||||
key: "opening",
|
||||
label: "Valve opening (0–1)",
|
||||
kind: "number",
|
||||
default: 1.0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
section: "Orifice",
|
||||
fixable: true,
|
||||
defaultFixed: true,
|
||||
description: "Nécessite orifice_kv. Fixed = impose opening ; Free = inconnue solver.",
|
||||
sweepable: true,
|
||||
sweepGroup: "machine",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1360,7 +1435,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
color: "#10b981",
|
||||
params: [
|
||||
{ key: "fluid", label: "Fluid", kind: "string", default: "R134a", required: true },
|
||||
{ key: "opening", label: "Opening (0–1)", kind: "number", default: 1.0 },
|
||||
{ key: "opening", label: "Opening (0–1)", kind: "number", default: 1.0, sweepable: true, sweepGroup: "machine" },
|
||||
{
|
||||
key: "flow_model",
|
||||
label: "Flow model",
|
||||
@@ -1421,7 +1496,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
ports: ["inlet", "outlet"],
|
||||
color: "#14b8a6",
|
||||
params: [
|
||||
{ key: "opening", label: "Opening (0–1)", kind: "number", default: 0.5 },
|
||||
{ key: "opening", label: "Opening (0–1)", kind: "number", default: 0.5, sweepable: true, sweepGroup: "machine" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1433,14 +1508,24 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
description: "Ligne frigorifique (Darcy–Weisbach). Glisser sur une ligne verte pour l’insérer.",
|
||||
help:
|
||||
"Conduit frigorigène. Dépose-le sur une connexion verte pour couper la ligne et l’insérer automatiquement.\n" +
|
||||
"CLI type: RefrigerantPipe (alias Pipe). pressure_drop_pa=0 → isenthalpic pass-through (P_out=P_in).",
|
||||
"CLI type: RefrigerantPipe (alias Pipe).\n" +
|
||||
"Design ΔP = 0 (défaut) → perte Darcy–Weisbach depuis L/D + ṁ.\n" +
|
||||
"Design ΔP > 0 → impose cette chute constante (ignore L/D pour ΔP).",
|
||||
ports: ["inlet", "outlet"],
|
||||
color: "#2e7d32",
|
||||
params: [
|
||||
{ key: "fluid", label: "Fluid", kind: "string", default: "R134a", section: "Medium" },
|
||||
{ key: "length_m", label: "Length", kind: "number", unit: "m", default: 3.0, required: true, section: "Geometry" },
|
||||
{ key: "diameter_m", label: "Inner diameter", kind: "number", unit: "m", default: 0.012, required: true, section: "Geometry" },
|
||||
{ key: "pressure_drop_pa", label: "Design ΔP", kind: "number", unit: "Pa", default: 0, section: "Hydraulics" },
|
||||
{
|
||||
key: "pressure_drop_pa",
|
||||
label: "Design ΔP (0 = Darcy)",
|
||||
kind: "number",
|
||||
unit: "Pa",
|
||||
default: 0,
|
||||
section: "Hydraulics",
|
||||
description: "0 = ΔP Darcy depuis géométrie + débit. >0 = ΔP imposé constant.",
|
||||
},
|
||||
{ key: "roughness_m", label: "Roughness", kind: "number", unit: "m", default: 0.0000015, section: "Geometry", advanced: true },
|
||||
{ key: "density_kg_m3", label: "Density (design)", kind: "number", unit: "kg/m³", default: 1140, section: "Properties", advanced: true },
|
||||
{ key: "viscosity_pa_s", label: "Viscosity (design)", kind: "number", unit: "Pa·s", default: 0.0002, section: "Properties", advanced: true },
|
||||
@@ -1452,14 +1537,23 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
category: "Piping",
|
||||
description: "Tuyauterie eau/glycol. Glisser sur une ligne bleue pour l’insérer.",
|
||||
help:
|
||||
"Conduit caloporteur. Dépose-le sur une connexion bleue (BrineSource ↔ HX) pour l’insérer dans le circuit.",
|
||||
"Conduit caloporteur. Dépose-le sur une connexion bleue (BrineSource ↔ HX) pour l’insérer dans le circuit.\n" +
|
||||
"Design ΔP = 0 → Darcy L/D ; >0 → ΔP imposé.",
|
||||
ports: ["inlet", "outlet"],
|
||||
color: "#1565c0",
|
||||
params: [
|
||||
{ key: "fluid", label: "Fluid", kind: "string", default: "Water", section: "Medium" },
|
||||
{ key: "length_m", label: "Length", kind: "number", unit: "m", default: 5.0, required: true, section: "Geometry" },
|
||||
{ key: "diameter_m", label: "Inner diameter", kind: "number", unit: "m", default: 0.025, required: true, section: "Geometry" },
|
||||
{ key: "pressure_drop_pa", label: "Design ΔP", kind: "number", unit: "Pa", default: 0, section: "Hydraulics" },
|
||||
{
|
||||
key: "pressure_drop_pa",
|
||||
label: "Design ΔP (0 = Darcy)",
|
||||
kind: "number",
|
||||
unit: "Pa",
|
||||
default: 0,
|
||||
section: "Hydraulics",
|
||||
description: "0 = ΔP Darcy depuis géométrie + débit. >0 = ΔP imposé constant.",
|
||||
},
|
||||
{ key: "roughness_m", label: "Roughness", kind: "number", unit: "m", default: 0.000045, section: "Geometry", advanced: true },
|
||||
{ key: "density_kg_m3", label: "Density", kind: "number", unit: "kg/m³", default: 998, section: "Properties", advanced: true },
|
||||
{ key: "viscosity_pa_s", label: "Viscosity", kind: "number", unit: "Pa·s", default: 0.001, section: "Properties", advanced: true },
|
||||
@@ -1471,14 +1565,23 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
category: "Piping",
|
||||
description: "Gaine d’air. Glisser sur une ligne jaune pour l’insérer.",
|
||||
help:
|
||||
"Conduit air. Dépose-le sur une connexion jaune (AirSource / Fan / coil air) pour l’insérer.",
|
||||
"Conduit air. Dépose-le sur une connexion jaune (AirSource / Fan / coil air) pour l’insérer.\n" +
|
||||
"Design ΔP = 0 → Darcy L/D ; >0 → ΔP imposé.",
|
||||
ports: ["inlet", "outlet"],
|
||||
color: "#f9a825",
|
||||
params: [
|
||||
{ key: "fluid", label: "Fluid", kind: "string", default: "Air", section: "Medium" },
|
||||
{ key: "length_m", label: "Length", kind: "number", unit: "m", default: 5.0, required: true, section: "Geometry" },
|
||||
{ key: "diameter_m", label: "Hydraulic diameter", kind: "number", unit: "m", default: 0.2, required: true, section: "Geometry" },
|
||||
{ key: "pressure_drop_pa", label: "Design ΔP", kind: "number", unit: "Pa", default: 0, section: "Hydraulics" },
|
||||
{
|
||||
key: "pressure_drop_pa",
|
||||
label: "Design ΔP (0 = Darcy)",
|
||||
kind: "number",
|
||||
unit: "Pa",
|
||||
default: 0,
|
||||
section: "Hydraulics",
|
||||
description: "0 = ΔP Darcy depuis géométrie + débit. >0 = ΔP imposé constant.",
|
||||
},
|
||||
{ key: "roughness_m", label: "Roughness", kind: "number", unit: "m", default: 0.00015, section: "Geometry", advanced: true },
|
||||
{ key: "density_kg_m3", label: "Density", kind: "number", unit: "kg/m³", default: 1.2, section: "Properties", advanced: true },
|
||||
{ key: "viscosity_pa_s", label: "Viscosity", kind: "number", unit: "Pa·s", default: 0.000018, section: "Properties", advanced: true },
|
||||
@@ -1554,7 +1657,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
color: "#d946ef",
|
||||
params: [
|
||||
{ key: "fluid", label: "Fluid", kind: "string", default: "Air" },
|
||||
{ key: "speed_ratio", label: "Speed ratio", kind: "number", default: 1.0 },
|
||||
{ key: "speed_ratio", label: "Speed ratio", kind: "number", default: 1.0, sweepable: true, sweepGroup: "machine" },
|
||||
{ key: "air_density_kg_m3", label: "Air density", kind: "number", unit: "kg/m³", default: 1.204 },
|
||||
{ key: "design_flow_m3_s", label: "Design flow", kind: "number", unit: "m³/s", default: 0.83, required: true },
|
||||
{ key: "curve_p0", label: "Curve p0", kind: "number", unit: "Pa", default: 250.0 },
|
||||
@@ -1580,7 +1683,7 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
ports: ["cold_inlet", "cold_outlet", "hot_inlet", "hot_outlet"],
|
||||
color: "#7c3aed",
|
||||
params: [
|
||||
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 10000.0, required: true },
|
||||
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 10000.0, required: true, sweepable: true, sweepGroup: "thermal" },
|
||||
{ key: "effectiveness", label: "Effectiveness", kind: "number", default: 0.85 },
|
||||
],
|
||||
},
|
||||
@@ -1646,6 +1749,8 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
required: true,
|
||||
fixable: true,
|
||||
defaultFixed: true,
|
||||
sweepable: true,
|
||||
sweepGroup: "boundaries",
|
||||
},
|
||||
{
|
||||
key: "m_flow_kg_s",
|
||||
@@ -1657,6 +1762,8 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
fixable: true,
|
||||
defaultFixed: true,
|
||||
description: "Fixed ⇒ Free P auto. Free ⇒ ṁ issu du bilan (avec Fixed T_out au Sink).",
|
||||
sweepable: true,
|
||||
sweepGroup: "boundaries",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1692,6 +1799,8 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
fixable: true,
|
||||
defaultFixed: false,
|
||||
description: "Fixed ON ⇒ impose h_out. Libère ṁ sur la Source.",
|
||||
sweepable: true,
|
||||
sweepGroup: "boundaries",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1725,6 +1834,8 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
required: true,
|
||||
fixable: true,
|
||||
defaultFixed: true,
|
||||
sweepable: true,
|
||||
sweepGroup: "boundaries",
|
||||
},
|
||||
{ key: "rh", label: "Relative humidity", kind: "number", unit: "%", default: 50.0 },
|
||||
{
|
||||
@@ -1736,6 +1847,8 @@ export const COMPONENTS: ComponentMeta[] = [
|
||||
required: true,
|
||||
fixable: true,
|
||||
defaultFixed: true,
|
||||
sweepable: true,
|
||||
sweepGroup: "boundaries",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -101,6 +101,23 @@ describe("buildScenarioConfig", () => {
|
||||
expect(defaults.solver.strategy).toBe("newton");
|
||||
});
|
||||
|
||||
it("emits instances and connections for ModuleInstance nodes in Schema v2", () => {
|
||||
const nodes = [
|
||||
node("m1", "ModuleInstance", "c1", 0, { module_name: "BaseChiller" }),
|
||||
node("m2", "ModuleInstance", "c2", 0, { module_name: "BaseChiller", ua_cond: 800 }),
|
||||
];
|
||||
const edges: Edge[] = [
|
||||
{ id: "e1", source: "m1", target: "m2", sourceHandle: "chw_out", targetHandle: "chw_in" },
|
||||
];
|
||||
const cfg = buildScenarioConfig(nodes, edges);
|
||||
expect(cfg.schema_version).toBe("2");
|
||||
expect(cfg.instances).toEqual([
|
||||
{ of: "BaseChiller", name: "c1", circuit: 0 },
|
||||
{ of: "BaseChiller", name: "c2", circuit: 0, params: { ua_cond: 800 } },
|
||||
]);
|
||||
expect(cfg.connections).toEqual([{ from: "c1.chw_out", to: "c2.chw_in" }]);
|
||||
});
|
||||
|
||||
it("emits the unified IR schema_version", () => {
|
||||
const cfg = buildScenarioConfig([node("a", "Condenser", "cond", 0)], []);
|
||||
expect(cfg.schema_version).toBe(SCHEMA_VERSION);
|
||||
@@ -273,6 +290,40 @@ describe("validateConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("EXV orifice Fixed/Free", () => {
|
||||
it("emits fix_opening=true and orifice_kv when Fixed opening", () => {
|
||||
const raw = {
|
||||
t_evap_k: 275.15,
|
||||
opening: 0.6,
|
||||
orifice_kv: 2e-6,
|
||||
[fixedFlagKey("opening")]: true,
|
||||
};
|
||||
const out = stripUiOnlyParams("IsenthalpicExpansionValve", raw);
|
||||
expect(out.orifice_kv).toBe(2e-6);
|
||||
expect(out.fix_opening).toBe(true);
|
||||
expect(out.opening).toBe(0.6);
|
||||
});
|
||||
|
||||
it("emits fix_opening=false when opening is Free", () => {
|
||||
const raw = {
|
||||
opening: 0.5,
|
||||
orifice_kv: 1e-6,
|
||||
[fixedFlagKey("opening")]: false,
|
||||
};
|
||||
const out = stripUiOnlyParams("IsenthalpicExpansionValve", raw);
|
||||
expect(out.fix_opening).toBe(false);
|
||||
});
|
||||
|
||||
it("omits blank orifice_kv so opening stays a no-op", () => {
|
||||
const out = stripUiOnlyParams("IsenthalpicExpansionValve", {
|
||||
opening: 1.0,
|
||||
orifice_kv: "",
|
||||
});
|
||||
expect(out.orifice_kv).toBeUndefined();
|
||||
expect(out.fix_opening).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("boundary Fixed/Free (Modelica-style)", () => {
|
||||
it("emits fix_mass_flow=false when ṁ is Free", () => {
|
||||
const raw = {
|
||||
|
||||
@@ -239,16 +239,46 @@ export function buildScenarioConfig(
|
||||
edges: Edge[],
|
||||
options: BuildOptions = {},
|
||||
): ScenarioConfig {
|
||||
// Group nodes by circuit id.
|
||||
const nodeById = new Map<string, Node<EntropykNodeData>>();
|
||||
for (const n of nodes) nodeById.set(n.id, n);
|
||||
|
||||
// Separate ModuleInstance nodes from flat atomic components
|
||||
const instances: InstanceConfig[] = [];
|
||||
const connections: Array<{ from: string; to: string }> = [];
|
||||
|
||||
const moduleNodes = nodes.filter((n) => n.data.type === "ModuleInstance");
|
||||
moduleNodes.forEach((n) => {
|
||||
const moduleName = String(n.data.params.module_name ?? "");
|
||||
if (!moduleName) return;
|
||||
|
||||
const params: Record<string, number | string | boolean> = {};
|
||||
for (const [k, v] of Object.entries(n.data.params)) {
|
||||
if (k !== "module_name" && k !== "module_ports" && typeof v !== "undefined") {
|
||||
params[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
instances.push({
|
||||
of: moduleName,
|
||||
name: n.data.name,
|
||||
circuit: n.data.circuit ?? 0,
|
||||
...(Object.keys(params).length > 0 ? { params } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
// Group non-module component nodes by circuit id.
|
||||
const circuitsMap = new Map<number, Node<EntropykNodeData>[]>();
|
||||
for (const n of nodes) {
|
||||
if (n.data.type === "ModuleInstance") continue;
|
||||
const c = n.data?.circuit ?? 0;
|
||||
if (!circuitsMap.has(c)) circuitsMap.set(c, []);
|
||||
circuitsMap.get(c)!.push(n);
|
||||
}
|
||||
|
||||
const nodeById = new Map<string, Node<EntropykNodeData>>();
|
||||
for (const n of nodes) nodeById.set(n.id, n);
|
||||
// Ensure circuit 0 exists if empty
|
||||
if (circuitsMap.size === 0) {
|
||||
circuitsMap.set(0, []);
|
||||
}
|
||||
|
||||
const circuits = Array.from(circuitsMap.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
@@ -259,17 +289,16 @@ export function buildScenarioConfig(
|
||||
const { type, name, params } = n.data;
|
||||
const canonicalParams = canonicalizeParams(type, params);
|
||||
const cleaned = stripUiOnlyParams(type, canonicalParams);
|
||||
// The CLI flattens unknown keys as params, so we spread them at top level.
|
||||
return { type, name, ...cleaned } as Record<string, unknown>;
|
||||
});
|
||||
|
||||
// Solver edges: both endpoints in this circuit. Refrigerant and
|
||||
// caloporteur branches are preserved explicitly; heat exchangers are
|
||||
// real 4-port components, not reduced to hidden secondary parameters.
|
||||
// Pure circuit edges (neither endpoint is a ModuleInstance)
|
||||
const circuitEdges = edges.filter((e) => {
|
||||
const s = nodeById.get(e.source);
|
||||
const t = nodeById.get(e.target);
|
||||
if (s?.data?.circuit !== circuitId || t?.data?.circuit !== circuitId) return false;
|
||||
if (!s || !t) return false;
|
||||
if (s.data.type === "ModuleInstance" || t.data.type === "ModuleInstance") return false;
|
||||
if (s.data.circuit !== circuitId || t.data.circuit !== circuitId) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -283,10 +312,29 @@ export function buildScenarioConfig(
|
||||
return { id: circuitId, name: `Circuit ${circuitId}`, components, edges: edgeConfigs };
|
||||
});
|
||||
|
||||
// Collect edges connected to ModuleInstances as `connections`
|
||||
edges.forEach((e) => {
|
||||
const s = nodeById.get(e.source);
|
||||
const t = nodeById.get(e.target);
|
||||
if (!s || !t) return;
|
||||
if (s.data.type === "ModuleInstance" || t.data.type === "ModuleInstance") {
|
||||
const fromRef =
|
||||
s.data.type === "ModuleInstance"
|
||||
? `${s.data.name}.${e.sourceHandle ?? "outlet"}`
|
||||
: edgeRef(s, e.sourceHandle, "source");
|
||||
|
||||
const toRef =
|
||||
t.data.type === "ModuleInstance"
|
||||
? `${t.data.name}.${e.targetHandle ?? "inlet"}`
|
||||
: edgeRef(t, e.targetHandle, "target");
|
||||
|
||||
connections.push({ from: fromRef, to: toRef });
|
||||
}
|
||||
});
|
||||
|
||||
const nodeControls = nodes
|
||||
.filter((node) => node.data.type === CONTROL_NODE_TYPE)
|
||||
.map(controlNodeToConfig);
|
||||
// Dymola/EES Fixed checkboxes → inverse calib pairs (FIX measure + FREE z_*)
|
||||
const fixedFreeControls = buildFixedFreeCalibrationControls(nodes);
|
||||
const controls = mergeControls(
|
||||
mergeControls(options.controls ?? [], nodeControls),
|
||||
@@ -298,6 +346,8 @@ export function buildScenarioConfig(
|
||||
fluid: options.fluid || "R410A",
|
||||
fluid_backend: options.fluidBackend || "CoolProp",
|
||||
circuits,
|
||||
...(instances.length > 0 ? { instances } : {}),
|
||||
...(connections.length > 0 ? { connections } : {}),
|
||||
thermal_couplings: options.thermalCouplings || [],
|
||||
...(controls.length > 0 ? { controls } : {}),
|
||||
solver: {
|
||||
@@ -328,9 +378,42 @@ export function stripUiOnlyParams(
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (k.startsWith(FIXED_FLAG_PREFIX)) continue;
|
||||
if (measureOnly.has(k)) continue;
|
||||
// Omit blank optional numerics (e.g. unset orifice_kv).
|
||||
if (v === "" || v === null || v === undefined) continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return applyBoundaryFixSemantics(type, out, params);
|
||||
return applyExvFixSemantics(type, applyBoundaryFixSemantics(type, out, params), params);
|
||||
}
|
||||
|
||||
const EXV_TYPES = new Set(["IsenthalpicExpansionValve", "EXV"]);
|
||||
|
||||
/**
|
||||
* Emit `fix_opening` when orifice_kv is set so the CLI can choose fixed vs
|
||||
* free orifice (never infer orifice from opening alone).
|
||||
*/
|
||||
export function applyExvFixSemantics(
|
||||
type: string,
|
||||
cleaned: Record<string, number | string | boolean>,
|
||||
rawParams: Record<string, number | string | boolean>,
|
||||
): Record<string, number | string | boolean> {
|
||||
if (!EXV_TYPES.has(type)) return cleaned;
|
||||
const kv = cleaned.orifice_kv;
|
||||
const kvNum = typeof kv === "number" ? kv : Number(kv);
|
||||
if (!Number.isFinite(kvNum) || kvNum <= 0) {
|
||||
const out: Record<string, number | string | boolean> = { ...cleaned };
|
||||
delete out.orifice_kv;
|
||||
delete out.fix_opening;
|
||||
return out;
|
||||
}
|
||||
const meta = COMPONENT_BY_TYPE[type];
|
||||
const openMeta = meta?.params.find((p) => p.key === "opening");
|
||||
const out: Record<string, number | string | boolean> = { ...cleaned, orifice_kv: kvNum };
|
||||
if (openMeta?.fixable) {
|
||||
out.fix_opening = isParamFixed(rawParams, openMeta);
|
||||
} else {
|
||||
out.fix_opening = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const BOUNDARY_FIX_TYPES = new Set([
|
||||
|
||||
@@ -19,7 +19,7 @@ function node(
|
||||
|
||||
describe("hxFamily", () => {
|
||||
it("distinguishes DX / flooded / plate / coil / MCHX", () => {
|
||||
expect(hxFamily("Evaporator")?.badge).toBe("DX");
|
||||
expect(hxFamily("Evaporator")?.badge).toBe("Tubes");
|
||||
expect(hxFamily("FloodedEvaporator")?.badge).toBe("Noyé");
|
||||
expect(hxFamily("BphxCondenser")?.badge).toBe("Plaques");
|
||||
expect(hxFamily("AirCooledCondenser")?.badge).toBe("Coil");
|
||||
@@ -28,6 +28,15 @@ describe("hxFamily", () => {
|
||||
expect(hxGlyphKey("FloodedEvaporator")).toBe("hx_flooded");
|
||||
expect(hxGlyphKey("BphxEvaporator")).toBe("hx_plate");
|
||||
});
|
||||
|
||||
it("picks coil vs shell from secondary fluid on Condenser/Evaporator", () => {
|
||||
expect(hxGlyphKey("Condenser", { secondaryFluid: "Air" })).toBe("hx_coil");
|
||||
expect(hxGlyphKey("Condenser", { secondaryFluid: "Water" })).toBe("hx_shell_cond");
|
||||
expect(hxGlyphKey("Evaporator", { secondaryFluid: "Water" })).toBe("hx_shell_evap");
|
||||
expect(hxGlyphKey("Evaporator", { secondaryFluid: "Air" })).toBe("hx_dx");
|
||||
expect(hxFamily("Condenser", { secondaryFluid: "Air" })?.badge).toBe("Coil");
|
||||
expect(hxFamily("Evaporator", { secondaryFluid: "Water" })?.badge).toBe("Tubes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDofCoach", () => {
|
||||
@@ -62,4 +71,16 @@ describe("buildDofCoach", () => {
|
||||
const coach = buildDofCoach(nodes, edges);
|
||||
expect(coach.tips.some((t) => t.id.startsWith("sec-"))).toBe(true);
|
||||
});
|
||||
|
||||
it("handles nodes with undefined type or kind without throwing", () => {
|
||||
const nodes: Node<EntropykNodeData>[] = [
|
||||
{
|
||||
id: "node_1",
|
||||
type: "entropyk",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name: "test_node", params: {} } as unknown as EntropykNodeData,
|
||||
},
|
||||
];
|
||||
expect(() => buildDofCoach(nodes, [])).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,8 +58,12 @@ export function buildDofCoach(
|
||||
};
|
||||
}
|
||||
|
||||
// Helper for safe node type retrieval
|
||||
const getNodeType = (n: Node<EntropykNodeData>): string =>
|
||||
String(n.data?.type || n.data?.kind || "");
|
||||
|
||||
// Topology coaching
|
||||
const types = new Set(nodes.map((n) => n.data.type));
|
||||
const types = new Set(nodes.map((n) => getNodeType(n)));
|
||||
const hasComp = [...types].some((t) => t.includes("Compressor"));
|
||||
const hasExv = [...types].some((t) => t.includes("Valve") || t.includes("Expansion"));
|
||||
const hasCond = [...types].some((t) => t.includes("Condenser"));
|
||||
@@ -67,9 +71,10 @@ export function buildDofCoach(
|
||||
|
||||
if (hasComp && hasCond && hasEvap && hasExv) {
|
||||
const openHx = nodes.filter((n) => {
|
||||
const fam = hxFamily(n.data.type);
|
||||
const type = getNodeType(n);
|
||||
const fam = hxFamily(type);
|
||||
if (!fam) return false;
|
||||
const meta = COMPONENT_BY_TYPE[n.data.type];
|
||||
const meta = COMPONENT_BY_TYPE[type];
|
||||
if (!meta) return false;
|
||||
const secPorts = meta.ports.filter((p) => p.includes("secondary"));
|
||||
if (secPorts.length < 2) return false;
|
||||
@@ -79,10 +84,11 @@ export function buildDofCoach(
|
||||
(String(e.sourceHandle).includes("secondary") ||
|
||||
String(e.targetHandle).includes("secondary")),
|
||||
);
|
||||
return !wired && !truthy(n.data.params.secondary_mass_flow_kg_s);
|
||||
return !wired && !truthy(n.data.params?.secondary_mass_flow_kg_s);
|
||||
});
|
||||
for (const n of openHx.slice(0, 2)) {
|
||||
const fam = hxFamily(n.data.type);
|
||||
const type = getNodeType(n);
|
||||
const fam = hxFamily(type);
|
||||
tips.push({
|
||||
id: `sec-${n.id}`,
|
||||
severity: "tip",
|
||||
@@ -97,9 +103,10 @@ export function buildDofCoach(
|
||||
|
||||
// Boundary Fixed/Free coaching
|
||||
for (const n of nodes) {
|
||||
if (!n.data.type.endsWith("Source")) continue;
|
||||
const p = n.data.params;
|
||||
const meta = COMPONENT_BY_TYPE[n.data.type];
|
||||
const type = getNodeType(n);
|
||||
if (!type.endsWith("Source")) continue;
|
||||
const p = n.data.params ?? {};
|
||||
const meta = COMPONENT_BY_TYPE[type];
|
||||
if (!meta) continue;
|
||||
const pMeta = meta.params.find((x) => x.key === "p_set_bar");
|
||||
const mMeta = meta.params.find((x) => x.key === "m_flow_kg_s");
|
||||
@@ -124,9 +131,10 @@ export function buildDofCoach(
|
||||
|
||||
// Air humidity tip
|
||||
for (const n of nodes) {
|
||||
if (!n.data.type.includes("Condenser") && !n.data.type.includes("Evaporator")) continue;
|
||||
if (String(n.data.params.secondary_fluid ?? "").toLowerCase() !== "air") continue;
|
||||
const w = n.data.params.secondary_humidity_ratio;
|
||||
const type = getNodeType(n);
|
||||
if (!type.includes("Condenser") && !type.includes("Evaporator")) continue;
|
||||
if (String(n.data.params?.secondary_fluid ?? "").toLowerCase() !== "air") continue;
|
||||
const w = n.data.params?.secondary_humidity_ratio;
|
||||
if (w === undefined || w === "" || Number(w) <= 0) {
|
||||
tips.push({
|
||||
id: `w-${n.id}`,
|
||||
|
||||
@@ -39,6 +39,44 @@ describe("computeDofLedger", () => {
|
||||
expect(L.nUnknowns).toBe(0);
|
||||
});
|
||||
|
||||
it("matches server DoF for HX air–water + fan (12=12)", () => {
|
||||
// Topology of hx_air_water_4port.json — previously the client said 10<14
|
||||
// because HeatExchanger was counted as 2 eqs and cold_* collapsed into ref.
|
||||
const nodes = [
|
||||
node("hwi", "BrineSource", "hot_water_in", {
|
||||
m_flow_kg_s: 0.5,
|
||||
t_set_c: 60,
|
||||
fix_pressure: false,
|
||||
fix_temperature: true,
|
||||
fix_mass_flow: true,
|
||||
}),
|
||||
node("hx", "HeatExchanger", "hx", { ua: 3000 }),
|
||||
node("hwo", "BrineSink", "hot_water_out", { fix_pressure: true }),
|
||||
node("cai", "AirSource", "cold_air_in", {
|
||||
m_flow_kg_s: 1.0,
|
||||
t_dry_c: 20,
|
||||
fix_pressure: false,
|
||||
fix_temperature: true,
|
||||
fix_mass_flow: true,
|
||||
}),
|
||||
node("fan", "Fan", "supply_fan"),
|
||||
node("cao", "AirSink", "cold_air_out", { fix_pressure: true }),
|
||||
];
|
||||
const edges = [
|
||||
edge("1", "hwi", "hx", "outlet", "hot_inlet"),
|
||||
edge("2", "hx", "hwo", "hot_outlet", "inlet"),
|
||||
edge("3", "cai", "fan", "outlet", "inlet"),
|
||||
edge("4", "fan", "hx", "outlet", "cold_inlet"),
|
||||
edge("5", "hx", "cao", "cold_outlet", "inlet"),
|
||||
];
|
||||
const L = computeDofLedger(nodes, edges);
|
||||
expect(L.nEdges).toBe(5);
|
||||
expect(L.nBranches).toBe(2); // water + air
|
||||
expect(L.nEquations).toBe(12);
|
||||
expect(L.nUnknowns).toBe(12);
|
||||
expect(L.balance).toBe("balanced");
|
||||
});
|
||||
|
||||
it("counts a water-cooled 4-port chiller close to 19=19", () => {
|
||||
// Rough topology of chiller_watercooled_r410a.json
|
||||
const nodes = [
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
findSecondaryLoopDofConflicts,
|
||||
isBoundaryParamFixed,
|
||||
} from "./boundaryFix";
|
||||
import { COMPONENT_BY_TYPE, isSecondaryPort } from "./componentMeta";
|
||||
import { COMPONENT_BY_TYPE, isParamFixed, isSecondaryPort } from "./componentMeta";
|
||||
import { CONTROL_NODE_TYPE, type EntropykNodeData } from "./configBuilder";
|
||||
|
||||
export type DofBalance = "balanced" | "over-constrained" | "under-constrained" | "empty";
|
||||
@@ -72,18 +72,68 @@ function incidentEdges(nodeId: string, edges: Edge[]): Edge[] {
|
||||
return edges.filter((e) => e.source === nodeId || e.target === nodeId);
|
||||
}
|
||||
|
||||
/** Port handles connected on a node. */
|
||||
function connectedHandles(nodeId: string, edges: Edge[]): Set<string> {
|
||||
const connected = new Set<string>();
|
||||
for (const e of edges) {
|
||||
if (e.source === nodeId && e.sourceHandle) connected.add(e.sourceHandle);
|
||||
if (e.target === nodeId && e.targetHandle) connected.add(e.targetHandle);
|
||||
}
|
||||
return connected;
|
||||
}
|
||||
|
||||
/** Whether a HX has both secondary ports wired. */
|
||||
function hasLiveSecondary(node: Node<EntropykNodeData>, edges: Edge[]): boolean {
|
||||
const meta = COMPONENT_BY_TYPE[node.data.type];
|
||||
if (!meta?.ports.some(isSecondaryPort)) return false;
|
||||
const connected = new Set<string>();
|
||||
for (const e of edges) {
|
||||
if (e.source === node.id && e.sourceHandle) connected.add(e.sourceHandle);
|
||||
if (e.target === node.id && e.targetHandle) connected.add(e.targetHandle);
|
||||
}
|
||||
const connected = connectedHandles(node.id, edges);
|
||||
return connected.has("secondary_inlet") && connected.has("secondary_outlet");
|
||||
}
|
||||
|
||||
/** Generic 4-port HeatExchanger with hot + cold sides fully wired. */
|
||||
function hasLiveFourPortHx(node: Node<EntropykNodeData>, edges: Edge[]): boolean {
|
||||
const connected = connectedHandles(node.id, edges);
|
||||
return (
|
||||
connected.has("hot_inlet") &&
|
||||
connected.has("hot_outlet") &&
|
||||
connected.has("cold_inlet") &&
|
||||
connected.has("cold_outlet")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream bucket for branch merging at a node.
|
||||
* HeatExchanger uses hot_* / cold_* (not secondary_*), so cold must not
|
||||
* collapse into the hot refrigerant/water branch.
|
||||
*/
|
||||
function streamBucket(handle: string | null | undefined): "hot" | "cold" | "sec" | "ref" {
|
||||
const h = String(handle || "").toLowerCase();
|
||||
if (!h) return "ref";
|
||||
if (isSecondaryPort(h)) return "sec";
|
||||
if (h.startsWith("cold_")) return "cold";
|
||||
if (h.startsWith("hot_")) return "hot";
|
||||
return "ref";
|
||||
}
|
||||
|
||||
function hasOrificeKv(p: Record<string, unknown>): boolean {
|
||||
const kv = p.orifice_kv;
|
||||
if (kv === undefined || kv === null || kv === "") return false;
|
||||
const n = typeof kv === "number" ? kv : Number(kv);
|
||||
return Number.isFinite(n) && n > 0;
|
||||
}
|
||||
|
||||
/** Fixed EXV orifice → compressor drops displacement ṁ law (CLI meter_mass_flow_via_exv). */
|
||||
function circuitHasFixedOrificeExv(nodes: Node<EntropykNodeData>[]): boolean {
|
||||
return nodes.some((n) => {
|
||||
const t = n.data.type;
|
||||
if (t !== "IsenthalpicExpansionValve" && t !== "EXV") return false;
|
||||
const p = n.data.params ?? {};
|
||||
if (!hasOrificeKv(p)) return false;
|
||||
const openMeta = COMPONENT_BY_TYPE[t]?.params.find((x) => x.key === "opening");
|
||||
return !openMeta || isParamFixed(p, openMeta);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate component residual count from type + params + wiring.
|
||||
* Conservative and documented; prefer over-counting diagnostics over silent under-count.
|
||||
@@ -93,8 +143,8 @@ export function estimateComponentEquations(
|
||||
edges: Edge[],
|
||||
allNodes: Node<EntropykNodeData>[],
|
||||
): ComponentDofEstimate {
|
||||
const type = node.data.type;
|
||||
const name = node.data.name;
|
||||
const type = String(node.data?.type || node.data?.kind || "");
|
||||
const name = String(node.data?.name || node.id || "unnamed");
|
||||
const p = node.data.params ?? {};
|
||||
const roles: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
@@ -107,9 +157,15 @@ export function estimateComponentEquations(
|
||||
switch (type) {
|
||||
case "IsentropicCompressor":
|
||||
case "Compressor":
|
||||
// series branch: ṁ law + h_dis (mass residual dropped when same-branch)
|
||||
n = 2;
|
||||
roles.push("mass_or_volume_flow", "energy[discharge]");
|
||||
// series branch: ṁ law + h_dis (mass residual dropped when same-branch).
|
||||
// Fixed EXV orifice meters ṁ → compressor energy-only (same-branch).
|
||||
if (circuitHasFixedOrificeExv(allNodes)) {
|
||||
n = 1;
|
||||
roles.push("energy[discharge]");
|
||||
} else {
|
||||
n = 2;
|
||||
roles.push("mass_or_volume_flow", "energy[discharge]");
|
||||
}
|
||||
break;
|
||||
case "ScrewEconomizerCompressor":
|
||||
n = 3;
|
||||
@@ -130,7 +186,7 @@ export function estimateComponentEquations(
|
||||
n = emergent ? 1 : 2;
|
||||
roles.push("energy[isenthalpic]");
|
||||
if (!emergent) roles.push("boundary[P_evap]");
|
||||
if (p.orifice_kv !== undefined && p.orifice_kv !== "" && p.orifice_kv !== null) {
|
||||
if (hasOrificeKv(p)) {
|
||||
n += 1;
|
||||
roles.push("actuator[orifice]");
|
||||
}
|
||||
@@ -279,16 +335,53 @@ export function estimateComponentEquations(
|
||||
}
|
||||
break;
|
||||
case "HeatExchanger":
|
||||
// Server 4-port HX contributes 4 residuals when both streams are live
|
||||
// (matches System::dof_report for hx_air_water_4port).
|
||||
if (hasLiveFourPortHx(node, edges)) {
|
||||
n = 4;
|
||||
roles.push(
|
||||
"momentum[hot]",
|
||||
"energy[hot]",
|
||||
"momentum[cold]",
|
||||
"energy[cold]",
|
||||
);
|
||||
} else {
|
||||
n = 2;
|
||||
roles.push("energy[side_a]", "energy[side_b]");
|
||||
warnings.push(
|
||||
"HeatExchanger needs hot_in/out + cold_in/out wired for a 4-eq system residual",
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "BphxEvaporator":
|
||||
case "BphxCondenser":
|
||||
case "Economizer":
|
||||
case "FreeCoolingExchanger":
|
||||
n = 2;
|
||||
roles.push("energy[side_a]", "energy[side_b]");
|
||||
if (liveSec) {
|
||||
n += 0; // already in 4-port energy
|
||||
case "FreeCoolingExchanger": {
|
||||
// Live secondary → 4-port energy/momentum; emergent_pressure adds SH/SC closure.
|
||||
n = liveSec ? 4 : 2;
|
||||
roles.push(
|
||||
...(liveSec
|
||||
? ["momentum[primary]", "energy[primary]", "momentum[secondary]", "energy[secondary]"]
|
||||
: ["energy[side_a]", "energy[side_b]"]),
|
||||
);
|
||||
if (!liveSec) {
|
||||
warnings.push(
|
||||
"No live secondary ports — wire Source→secondary_in→secondary_out→Sink",
|
||||
);
|
||||
}
|
||||
if (
|
||||
(type === "BphxEvaporator" || type === "BphxCondenser") &&
|
||||
truthy(p.emergent_pressure)
|
||||
) {
|
||||
n += 1;
|
||||
roles.push(
|
||||
type === "BphxCondenser"
|
||||
? "outlet_closure[subcooling]"
|
||||
: "outlet_closure[superheat]",
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Pump":
|
||||
case "Fan":
|
||||
n = 2;
|
||||
@@ -383,19 +476,20 @@ function estimateBranches(nodes: Node<EntropykNodeData>[], edges: Edge[]): numbe
|
||||
for (const n of nodes) {
|
||||
if (n.data.type === CONTROL_NODE_TYPE) continue;
|
||||
const inc = incidentEdges(n.id, edges);
|
||||
// Group incident edges by stream class
|
||||
const ref: Edge[] = [];
|
||||
const sec: Edge[] = [];
|
||||
// Group incident edges by stream class (ref / hot / cold / secondary).
|
||||
const buckets = new Map<string, Edge[]>();
|
||||
for (const e of inc) {
|
||||
const handle = e.source === n.id ? e.sourceHandle : e.targetHandle;
|
||||
if (handle && isSecondaryPort(handle)) sec.push(e);
|
||||
else ref.push(e);
|
||||
const key = streamBucket(handle);
|
||||
const list = buckets.get(key) ?? [];
|
||||
list.push(e);
|
||||
buckets.set(key, list);
|
||||
}
|
||||
// Series: exactly 2 edges on a stream → same ṁ branch
|
||||
for (const group of buckets.values()) {
|
||||
if (group.length === 2) unite(group[0].id, group[1].id);
|
||||
}
|
||||
// Series: exactly 2 edges on a stream → same branch
|
||||
if (ref.length === 2) unite(ref[0].id, ref[1].id);
|
||||
if (sec.length === 2) unite(sec[0].id, sec[1].id);
|
||||
|
||||
// Explicit flow_paths style for 4-port HX already covered by sec/ref grouping.
|
||||
void nodeById;
|
||||
}
|
||||
|
||||
@@ -418,11 +512,13 @@ function estimateExtraUnknowns(nodes: Node<EntropykNodeData>[]): {
|
||||
const p = n.data.params ?? {};
|
||||
if (
|
||||
(n.data.type === "IsenthalpicExpansionValve" || n.data.type === "EXV") &&
|
||||
p.orifice_kv !== undefined &&
|
||||
p.orifice_kv !== "" &&
|
||||
p.orifice_kv !== null
|
||||
hasOrificeKv(p)
|
||||
) {
|
||||
freeActuators += 1; // opening unknown
|
||||
const openMeta = COMPONENT_BY_TYPE[n.data.type]?.params.find((x) => x.key === "opening");
|
||||
// Free opening only — Fixed opening is a parameter (not an unknown).
|
||||
if (openMeta && !isParamFixed(p, openMeta)) {
|
||||
freeActuators += 1;
|
||||
}
|
||||
}
|
||||
if (p.fan_head_pressure_target_c !== undefined && p.fan_head_pressure_target_c !== "") {
|
||||
freeActuators += 1;
|
||||
@@ -689,6 +785,27 @@ export function classifyParamDof(
|
||||
};
|
||||
}
|
||||
|
||||
if (key === "opening" && (type === "IsenthalpicExpansionValve" || type === "EXV")) {
|
||||
if (!hasOrificeKv(params)) {
|
||||
return {
|
||||
key,
|
||||
kind: "parameter",
|
||||
label: "ignored",
|
||||
hint: "Sans orifice_kv, opening n’entre pas dans les résidus — ajoute Kv pour un débit physique",
|
||||
};
|
||||
}
|
||||
const openMeta = COMPONENT_BY_TYPE[type]?.params.find((x) => x.key === "opening");
|
||||
const fixed = !openMeta || isParamFixed(params, openMeta);
|
||||
return {
|
||||
key,
|
||||
kind: fixed ? "fixed" : "free",
|
||||
label: fixed ? "FIX" : "FREE",
|
||||
hint: fixed
|
||||
? "Opening Fixed — orifice ṁ = Kv·opening·√(2ρΔP); le compresseur lâche la loi de ṁ"
|
||||
: "Opening FREE — inconnue solver (régulation / SaturatedController)",
|
||||
};
|
||||
}
|
||||
|
||||
if (key === "opening" || key === "fan_speed" || key === "speed_hz") {
|
||||
return {
|
||||
key,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
/**
|
||||
* Visual family for heat exchangers — drives glyph + node badge so DX /
|
||||
* flooded / plate / coil / MCHX are distinguishable at a glance.
|
||||
*
|
||||
* Generic `Condenser` / `Evaporator` pick shell vs coil from the secondary
|
||||
* fluid: Air → finned coil, Water/brine → shell & tube. That avoids the
|
||||
* inverted look on air-cooled chillers (air condenser drawn as a vessel,
|
||||
* water evaporator drawn as a DX fin pack).
|
||||
*/
|
||||
|
||||
export type HxFamilyId =
|
||||
@@ -21,6 +26,11 @@ export interface HxFamily {
|
||||
accent: string;
|
||||
}
|
||||
|
||||
export interface HxVisualOpts {
|
||||
/** Secondary / HTF fluid id (e.g. "Air", "Water"). */
|
||||
secondaryFluid?: string | null;
|
||||
}
|
||||
|
||||
const FAMILIES: Record<HxFamilyId, HxFamily> = {
|
||||
dx: {
|
||||
id: "dx",
|
||||
@@ -66,29 +76,51 @@ const FAMILIES: Record<HxFamilyId, HxFamily> = {
|
||||
},
|
||||
};
|
||||
|
||||
function isAirSecondary(fluid?: string | null): boolean {
|
||||
return String(fluid ?? "").trim().toLowerCase() === "air";
|
||||
}
|
||||
|
||||
/** Returns a visual family for heat-exchanger types; null for non-HX. */
|
||||
export function hxFamily(type: string): HxFamily | null {
|
||||
if (type.startsWith("Bphx") || type.includes("Bphx")) return FAMILIES.plate;
|
||||
if (type.includes("Flooded")) return FAMILIES.flooded;
|
||||
if (type.includes("Mchx")) return FAMILIES.mchx;
|
||||
export function hxFamily(type?: string, opts?: HxVisualOpts): HxFamily | null {
|
||||
const t = String(type || "");
|
||||
if (!t) return null;
|
||||
if (t.startsWith("Bphx") || t.includes("Bphx")) return FAMILIES.plate;
|
||||
if (t.includes("Flooded")) return FAMILIES.flooded;
|
||||
if (t.includes("Mchx")) return FAMILIES.mchx;
|
||||
if (
|
||||
type.includes("FinCoil") ||
|
||||
type.includes("AirCooled") ||
|
||||
type === "CondenserCoil" ||
|
||||
type === "EvaporatorCoil"
|
||||
t.includes("FinCoil") ||
|
||||
t.includes("AirCooled") ||
|
||||
t === "CondenserCoil" ||
|
||||
t === "EvaporatorCoil"
|
||||
) {
|
||||
return FAMILIES.coil;
|
||||
}
|
||||
if (type === "Evaporator") return FAMILIES.dx;
|
||||
if (type === "Condenser") return FAMILIES.shell;
|
||||
if (type === "HeatExchanger") return FAMILIES.generic;
|
||||
if (type.includes("Condenser") || type.includes("Evaporator")) return FAMILIES.shell;
|
||||
if (t === "Evaporator") {
|
||||
// Air-side DX coil vs water/brine shell-and-tube DX.
|
||||
return isAirSecondary(opts?.secondaryFluid) ? FAMILIES.coil : FAMILIES.shell;
|
||||
}
|
||||
if (t === "Condenser") {
|
||||
return isAirSecondary(opts?.secondaryFluid) ? FAMILIES.coil : FAMILIES.shell;
|
||||
}
|
||||
if (t === "HeatExchanger") return FAMILIES.generic;
|
||||
if (t.includes("Condenser") || t.includes("Evaporator")) return FAMILIES.shell;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Glyph key used by ComponentIcon — finer than the old include-based map. */
|
||||
export function hxGlyphKey(type: string): string | null {
|
||||
const f = hxFamily(type);
|
||||
export function hxGlyphKey(type?: string, opts?: HxVisualOpts): string | null {
|
||||
const t = String(type || "");
|
||||
if (!t) return null;
|
||||
|
||||
// Generic Condenser / Evaporator: geometry follows the secondary fluid,
|
||||
// duty direction follows the role (heat out vs heat in).
|
||||
if (t === "Condenser" || t === "Evaporator") {
|
||||
const air = isAirSecondary(opts?.secondaryFluid);
|
||||
if (t === "Condenser") return air ? "hx_coil" : "hx_shell_cond";
|
||||
return air ? "hx_dx" : "hx_shell_evap";
|
||||
}
|
||||
|
||||
const f = hxFamily(t, opts);
|
||||
if (!f) return null;
|
||||
switch (f.id) {
|
||||
case "plate":
|
||||
@@ -102,7 +134,7 @@ export function hxGlyphKey(type: string): string | null {
|
||||
case "dx":
|
||||
return "hx_dx";
|
||||
case "shell":
|
||||
return type.includes("Evaporator") ? "hx_shell_evap" : "hx_shell_cond";
|
||||
return t.includes("Evaporator") ? "hx_shell_evap" : "hx_shell_cond";
|
||||
default:
|
||||
return "hx";
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
buildSweepCases,
|
||||
defaultSweepsFromDiagram,
|
||||
discoverSweepTargets,
|
||||
getSweepWarnings,
|
||||
parseValueList,
|
||||
suggestValues,
|
||||
type SweepTarget,
|
||||
} from "./multiRun";
|
||||
|
||||
function node(
|
||||
@@ -110,4 +112,125 @@ describe("multiRun", () => {
|
||||
expect(cfg.circuits[0].components[0].t_set_c).toBe(10);
|
||||
expect(cfg.circuits[0].components[1].t_set_c).toBe(35);
|
||||
});
|
||||
|
||||
it("discovers fan_speed on MchxCondenserCoil (previously absent)", () => {
|
||||
const nodes = [node("MchxCondenserCoil", "coil", { fan_speed: 0.8 })];
|
||||
const targets = discoverSweepTargets(nodes, "R134a");
|
||||
const fanTarget = targets.find((t) => t.paramKey === "fan_speed");
|
||||
expect(fanTarget).toBeDefined();
|
||||
expect(fanTarget?.componentType).toBe("MchxCondenserCoil");
|
||||
expect(fanTarget?.group).toBe("machine");
|
||||
expect(fanTarget?.path).toBe("coil.fan_speed");
|
||||
});
|
||||
|
||||
it("returns no orifice warning for an ExpansionValve.opening sweep", () => {
|
||||
const target: SweepTarget = {
|
||||
path: "vx.opening",
|
||||
label: "VX — opening",
|
||||
kind: "scalar",
|
||||
group: "machine",
|
||||
componentName: "vx",
|
||||
componentType: "ExpansionValve",
|
||||
paramKey: "opening",
|
||||
};
|
||||
const nodes = [
|
||||
node("ExpansionValve", "vx", { opening: 0.5, beta_m2: 1e-6, flow_model: "orifice" }),
|
||||
];
|
||||
const baseConfig = {
|
||||
circuits: [
|
||||
{
|
||||
components: [
|
||||
{ name: "vx", type: "ExpansionValve", opening: 0.5, beta_m2: 1e-6, flow_model: "orifice" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const warnings = getSweepWarnings(target, nodes, baseConfig);
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("warns when EXV opening has no orifice_kv", () => {
|
||||
const target: SweepTarget = {
|
||||
path: "exv.opening",
|
||||
label: "EXV — opening",
|
||||
kind: "scalar",
|
||||
group: "machine",
|
||||
componentName: "exv",
|
||||
componentType: "IsenthalpicExpansionValve",
|
||||
paramKey: "opening",
|
||||
};
|
||||
const nodes = [node("IsenthalpicExpansionValve", "exv", { opening: 0.5 })];
|
||||
const baseConfig = {
|
||||
circuits: [
|
||||
{ components: [{ name: "exv", type: "IsenthalpicExpansionValve", opening: 0.5 }] },
|
||||
],
|
||||
};
|
||||
const warnings = getSweepWarnings(target, nodes, baseConfig);
|
||||
expect(warnings).toContain(
|
||||
"opening n'a aucun effet sans orifice_kv (orifice absent ou ≤ 0)",
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when EXV opening is Free (solver actuator)", () => {
|
||||
const target: SweepTarget = {
|
||||
path: "exv.opening",
|
||||
label: "EXV — opening",
|
||||
kind: "scalar",
|
||||
group: "machine",
|
||||
componentName: "exv",
|
||||
componentType: "IsenthalpicExpansionValve",
|
||||
paramKey: "opening",
|
||||
};
|
||||
const nodes = [
|
||||
node("IsenthalpicExpansionValve", "exv", {
|
||||
opening: 0.5,
|
||||
orifice_kv: 2e-6,
|
||||
__fixed_opening: false,
|
||||
}),
|
||||
];
|
||||
const baseConfig = {
|
||||
circuits: [
|
||||
{
|
||||
components: [
|
||||
{ name: "exv", type: "IsenthalpicExpansionValve", opening: 0.5, orifice_kv: 2e-6 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const warnings = getSweepWarnings(target, nodes, baseConfig);
|
||||
expect(warnings).toContain(
|
||||
"opening est libre (solver) — la valeur balayée sera ignorée. Passe-le en Fixed pour imposer la valeur.",
|
||||
);
|
||||
expect(warnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns no warning for a healthy fixed-orifice EXV", () => {
|
||||
const target: SweepTarget = {
|
||||
path: "exv.opening",
|
||||
label: "EXV — opening",
|
||||
kind: "scalar",
|
||||
group: "machine",
|
||||
componentName: "exv",
|
||||
componentType: "IsenthalpicExpansionValve",
|
||||
paramKey: "opening",
|
||||
};
|
||||
const nodes = [
|
||||
node("IsenthalpicExpansionValve", "exv", {
|
||||
opening: 0.5,
|
||||
orifice_kv: 2e-6,
|
||||
__fixed_opening: true,
|
||||
}),
|
||||
];
|
||||
const baseConfig = {
|
||||
circuits: [
|
||||
{
|
||||
components: [
|
||||
{ name: "exv", type: "IsenthalpicExpansionValve", opening: 0.5, orifice_kv: 2e-6 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const warnings = getSweepWarnings(target, nodes, baseConfig);
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { simulate, type SimulationResult } from "./api";
|
||||
import { COMPONENT_BY_TYPE } from "./componentMeta";
|
||||
import { COMPONENT_BY_TYPE, isParamFixed } from "./componentMeta";
|
||||
import type { EntropykNodeData } from "./configBuilder";
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
@@ -48,21 +48,16 @@ export interface MultiRunResult {
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/** Params engineers typically sweep on a cycle. */
|
||||
const SWEEP_PARAM_PRIORITY: Record<string, { group: SweepTarget["group"]; rank: number }> = {
|
||||
t_set_c: { group: "boundaries", rank: 10 },
|
||||
t_dry_c: { group: "boundaries", rank: 11 },
|
||||
oat_k: { group: "boundaries", rank: 12 },
|
||||
m_flow_kg_s: { group: "boundaries", rank: 20 },
|
||||
air_face_velocity_m_s: { group: "boundaries", rank: 21 },
|
||||
ua: { group: "thermal", rank: 30 },
|
||||
opening: { group: "machine", rank: 40 },
|
||||
speed_ratio: { group: "machine", rank: 41 },
|
||||
frequency_hz: { group: "machine", rank: 42 },
|
||||
speed_rpm: { group: "machine", rank: 43 },
|
||||
slide_valve_position: { group: "machine", rank: 44 },
|
||||
volume_index: { group: "machine", rank: 45 },
|
||||
isentropic_efficiency: { group: "machine", rank: 46 },
|
||||
/** Params engineers typically sweep on a cycle.
|
||||
*
|
||||
* Sweepability is now declared per-param in `componentMeta.ts` (`ParamMeta.sweepable` /
|
||||
* `ParamMeta.sweepGroup`). This constant only carries the 4-group sort order used to
|
||||
* arrange the Multi-run dropdown (boundaries first, then thermal, machine, global). */
|
||||
const SWEEP_GROUP_ORDER: Record<SweepTarget["group"], number> = {
|
||||
boundaries: 0,
|
||||
thermal: 1,
|
||||
machine: 2,
|
||||
global: 3,
|
||||
};
|
||||
|
||||
const BOUNDARY_TYPES = new Set([
|
||||
@@ -103,6 +98,9 @@ function friendlyComponentRole(type: string, name: string): string {
|
||||
|
||||
/**
|
||||
* Discover sweepable parameters from the live diagram.
|
||||
* A param is admitted iff its `ParamMeta` declares `sweepable: true`. The
|
||||
* `sweepGroup` on the meta drives both the optgroup and the sort order
|
||||
* (boundaries < thermal < machine < global); ties break by label.
|
||||
* Boundaries (water/air T) come first — that's what engineers sweep for ratings.
|
||||
*/
|
||||
export function discoverSweepTargets(
|
||||
@@ -128,8 +126,8 @@ export function discoverSweepTargets(
|
||||
|
||||
for (const [key, val] of Object.entries(params)) {
|
||||
if (key.startsWith("__")) continue;
|
||||
const prio = SWEEP_PARAM_PRIORITY[key];
|
||||
if (!prio) continue;
|
||||
const paramMeta = meta?.params.find((p) => p.key === key);
|
||||
if (!paramMeta?.sweepable) continue;
|
||||
// Prefer live boundary setpoints over rating scalars on HX when both exist.
|
||||
if (
|
||||
(key === "secondary_inlet_temp_c" || key === "secondary_mass_flow_kg_s") &&
|
||||
@@ -137,14 +135,14 @@ export function discoverSweepTargets(
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const paramMeta = meta?.params.find((p) => p.key === key);
|
||||
const group = paramMeta.sweepGroup ?? "global";
|
||||
const role = friendlyComponentRole(type, name);
|
||||
const paramLabel = humanParamLabel(type, key, paramMeta?.unit);
|
||||
targets.push({
|
||||
path: `${name}.${key}`,
|
||||
label: `${role} — ${paramLabel}`,
|
||||
kind: typeof val === "string" && key === "fluid" ? "fluid" : "scalar",
|
||||
group: BOUNDARY_TYPES.has(type) ? "boundaries" : prio.group,
|
||||
group: BOUNDARY_TYPES.has(type) && group === "global" ? "boundaries" : group,
|
||||
current: val,
|
||||
unit: paramMeta?.unit,
|
||||
componentName: name,
|
||||
@@ -155,19 +153,54 @@ export function discoverSweepTargets(
|
||||
}
|
||||
|
||||
targets.sort((a, b) => {
|
||||
const order = { boundaries: 0, thermal: 1, machine: 2, global: 3 };
|
||||
const ga = order[a.group];
|
||||
const gb = order[b.group];
|
||||
const ga = SWEEP_GROUP_ORDER[a.group];
|
||||
const gb = SWEEP_GROUP_ORDER[b.group];
|
||||
if (ga !== gb) return ga - gb;
|
||||
const ra = a.paramKey ? (SWEEP_PARAM_PRIORITY[a.paramKey]?.rank ?? 99) : 0;
|
||||
const rb = b.paramKey ? (SWEEP_PARAM_PRIORITY[b.paramKey]?.rank ?? 99) : 0;
|
||||
if (ra !== rb) return ra - rb;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an arbitrary pinned path (`componentName.paramKey`) to a SweepTarget,
|
||||
* even when the param is NOT flagged `sweepable` in metadata. Lets engineers
|
||||
* sweep ANY numeric param (e.g. `n_plates`, `air_density_kg_m3`) for optimization
|
||||
* studies via the properties-panel pin button. Returns null when the node or
|
||||
* param is missing, or the param is non-numeric (string/boolean are rejected).
|
||||
*/
|
||||
export function resolvePinTarget(
|
||||
path: string,
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
): SweepTarget | null {
|
||||
const dot = path.indexOf(".");
|
||||
if (dot <= 0) return null;
|
||||
const name = path.slice(0, dot);
|
||||
const key = path.slice(dot + 1);
|
||||
if (!key || key.includes(".")) return null;
|
||||
const node = nodes.find((n) => n.data.name === name);
|
||||
if (!node) return null;
|
||||
const type = node.data.type;
|
||||
const meta = COMPONENT_BY_TYPE[type];
|
||||
const paramMeta = meta?.params.find((p) => p.key === key);
|
||||
if (!paramMeta || paramMeta.kind !== "number") return null;
|
||||
const current = node.data.params?.[key];
|
||||
const role = friendlyComponentRole(type, name);
|
||||
const paramLabel = humanParamLabel(type, key, paramMeta.unit);
|
||||
const group = paramMeta.sweepGroup ?? "machine";
|
||||
return {
|
||||
path,
|
||||
label: `${role} — ${paramLabel}`,
|
||||
kind: "scalar",
|
||||
group,
|
||||
current,
|
||||
unit: paramMeta.unit,
|
||||
componentName: name,
|
||||
componentType: type,
|
||||
paramKey: key,
|
||||
};
|
||||
}
|
||||
|
||||
/** Suggest a small sweep around the current numeric value. */
|
||||
export function suggestValues(current: number | string | boolean | undefined, paramKey?: string): string {
|
||||
if (typeof current !== "number" || !Number.isFinite(current)) {
|
||||
@@ -236,6 +269,65 @@ export function defaultSweepsFromDiagram(
|
||||
];
|
||||
}
|
||||
|
||||
/** EXV types whose `opening` only has a physical effect when paired with an `orifice_kv`.
|
||||
* Narrowed to `IsenthalpicExpansionValve`: it is the sole type that carries `orifice_kv`
|
||||
* and a `fixable` opening. `ExpansionValve` models flow via `flow_model` + `beta_m2`
|
||||
* (opening always has effect there) and must NOT trigger the orifice warning. */
|
||||
const ORIFICE_EXV_TYPES = new Set(["IsenthalpicExpansionValve"]);
|
||||
|
||||
/**
|
||||
* Human-readable French warnings for a sweep axis that would produce identical
|
||||
* results across cases (silent no-op). Returns an empty array when the axis is
|
||||
* healthy. The caller renders these inline on the axis row — they never block
|
||||
* the run (engineers may legitimately sweep a Free actuator to probe sensitivity).
|
||||
*/
|
||||
export function getSweepWarnings(
|
||||
target: SweepTarget,
|
||||
nodes: Node<EntropykNodeData>[],
|
||||
baseConfig: unknown,
|
||||
): string[] {
|
||||
const warnings: string[] = [];
|
||||
const name = target.componentName;
|
||||
const key = target.paramKey;
|
||||
|
||||
// Global fluid axis — nothing to warn about.
|
||||
if (!name || !key) return warnings;
|
||||
|
||||
const isExvOpening = key === "opening" && !!target.componentType && ORIFICE_EXV_TYPES.has(target.componentType);
|
||||
|
||||
// (c) Axis path resolves to no component in any circuit of baseConfig.
|
||||
const cfg = baseConfig as {
|
||||
circuits?: Array<{ components?: Array<Record<string, unknown>> }>;
|
||||
};
|
||||
const existsInConfig = (cfg?.circuits ?? []).some((c) =>
|
||||
(c.components ?? []).some((c2) => c2.name === name),
|
||||
);
|
||||
if (!existsInConfig) {
|
||||
warnings.push(`« ${name} » introuvable dans les circuits du schéma`);
|
||||
return warnings;
|
||||
}
|
||||
|
||||
if (isExvOpening) {
|
||||
const node = nodes.find((n) => n.data.name === name);
|
||||
const liveParams = node?.data.params ?? {};
|
||||
const orificeRaw = liveParams.orifice_kv;
|
||||
const orificeNum = typeof orificeRaw === "number" ? orificeRaw : Number(orificeRaw);
|
||||
// (a) No usable orifice → opening has no physical effect on mass flow.
|
||||
if (!Number.isFinite(orificeNum) || orificeNum <= 0) {
|
||||
warnings.push("opening n'a aucun effet sans orifice_kv (orifice absent ou ≤ 0)");
|
||||
}
|
||||
// (b) opening left Free → swept value ignored, solver picks it.
|
||||
const openingMeta = COMPONENT_BY_TYPE[target.componentType!]?.params.find((p) => p.key === "opening");
|
||||
if (openingMeta && !isParamFixed(liveParams, openingMeta)) {
|
||||
warnings.push(
|
||||
"opening est libre (solver) — la valeur balayée sera ignorée. Passe-le en Fixed pour imposer la valeur.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a parameter on a named component: `evap_water_in.t_set_c`.
|
||||
* Component names may contain underscores.
|
||||
@@ -409,3 +501,80 @@ export function extractKpis(result?: SimulationResult): {
|
||||
iterations: result.iterations ?? result.convergence?.iterations ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Generic series extraction (literature-aligned: trace ANY output) ─────────
|
||||
// A multirun should let the engineer plot any model output vs any swept param,
|
||||
// not just the refrigeration-cycle KPIs. This walks `performance` (cycle KPIs)
|
||||
// AND every edge of `state` (temperatures, enthalpies, mass flows, pressures),
|
||||
// producing a flat list of plottable series with human-readable labels.
|
||||
|
||||
export interface ResultSeries {
|
||||
/** Stable key (used as chart dataKey / select value). */
|
||||
key: string;
|
||||
/** Human-readable label shown in the Y selector + legend. */
|
||||
label: string;
|
||||
value: number | null;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
const PERF_LABELS: Record<string, { label: string; unit?: string }> = {
|
||||
cop: { label: "COP" },
|
||||
cop_cooling: { label: "COP froid" },
|
||||
cop_heating: { label: "COP chaud" },
|
||||
cooling_capacity_w: { label: "Puissance froide", unit: "W" },
|
||||
heating_capacity_w: { label: "Puissance chaude", unit: "W" },
|
||||
compressor_power_w: { label: "Puissance (compresseur)", unit: "W" },
|
||||
q_cooling_kw: { label: "Puissance froide", unit: "kW" },
|
||||
q_heating_kw: { label: "Puissance chaude", unit: "kW" },
|
||||
compressor_power_kw: { label: "Puissance (compresseur)", unit: "kW" },
|
||||
};
|
||||
|
||||
const EDGE_FIELDS: Record<string, { label: string; unit?: string }> = {
|
||||
temperature_c: { label: "Température", unit: "°C" },
|
||||
enthalpy_kj_kg: { label: "Enthalpie", unit: "kJ/kg" },
|
||||
mass_flow_kg_s: { label: "Débit massique", unit: "kg/s" },
|
||||
pressure_bar: { label: "Pression", unit: "bar" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Flatten a SimulationResult into every plottable numeric series.
|
||||
* Performance fields first (cycle KPIs), then every edge state variable
|
||||
* labelled by its connection (`source → target · grandeur`).
|
||||
*/
|
||||
export function extractAllSeries(result?: SimulationResult | null): ResultSeries[] {
|
||||
const out: ResultSeries[] = [];
|
||||
if (!result) return out;
|
||||
|
||||
const p = result.performance ?? {};
|
||||
for (const [k, v] of Object.entries(p)) {
|
||||
if (typeof v === "number" && Number.isFinite(v)) {
|
||||
const meta = PERF_LABELS[k] ?? { label: k };
|
||||
out.push({ key: `perf.${k}`, label: meta.label, value: v, unit: meta.unit });
|
||||
}
|
||||
}
|
||||
|
||||
const st = result.state as unknown;
|
||||
if (Array.isArray(st)) {
|
||||
st.forEach((edge, i) => {
|
||||
if (!edge || typeof edge !== "object") return;
|
||||
const e = edge as Record<string, unknown>;
|
||||
const who =
|
||||
typeof e.source === "string" && typeof e.target === "string"
|
||||
? `${e.source} → ${e.target}`
|
||||
: `edge ${i}`;
|
||||
for (const [fk, fmeta] of Object.entries(EDGE_FIELDS)) {
|
||||
const v = e[fk];
|
||||
if (typeof v === "number" && Number.isFinite(v)) {
|
||||
out.push({
|
||||
key: `edge.${i}.${fk}`,
|
||||
label: `${who} · ${fmeta.label}`,
|
||||
value: v,
|
||||
unit: fmeta.unit,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
425
apps/web/src/lib/useDocuments.ts
Normal file
425
apps/web/src/lib/useDocuments.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useDiagramStore, type EntropykNodeData } from "@/store/diagramStore";
|
||||
import { fetchModule } from "@/lib/api";
|
||||
import type { Node, Edge } from "@xyflow/react";
|
||||
|
||||
export interface Doc {
|
||||
id: string; // "workspace" or `module:${name}`
|
||||
name: string; // scenario name, or class name (e.g. "BaseChiller")
|
||||
kind: "workspace" | "module";
|
||||
nodes: Node<EntropykNodeData>[];
|
||||
edges: Edge[];
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-document manager — Modelica / OMEdit style.
|
||||
*
|
||||
* Classes (.ekmod) are first-class: each open class is its own tab named
|
||||
* `ClassName.ekmod`. The workspace tab is the solvable scenario assembly
|
||||
* (CLI ScenarioConfig) where class instances are placed.
|
||||
*
|
||||
* - openModule(name) — open / focus a class tab (loads .ekmod)
|
||||
* - openEmptyModule(name) — open a freshly created empty class
|
||||
* - renameWorkspace(name) — rename the scenario without clearing
|
||||
* - newScenario(name, clear) — name (and optionally clear) the workspace
|
||||
*/
|
||||
export function useDocuments() {
|
||||
const [docs, setDocs] = useState<Doc[]>([
|
||||
{
|
||||
id: "workspace",
|
||||
name: "Workspace",
|
||||
kind: "workspace",
|
||||
nodes: [],
|
||||
edges: [],
|
||||
dirty: false,
|
||||
},
|
||||
]);
|
||||
const [activeId, setActiveId] = useState<string>("workspace");
|
||||
|
||||
// Refs to read latest store state inside async callbacks without re-subscribing.
|
||||
const storeNodes = useRef(useDiagramStore.getState().nodes);
|
||||
const storeEdges = useRef(useDiagramStore.getState().edges);
|
||||
useEffect(() => {
|
||||
return useDiagramStore.subscribe((s) => {
|
||||
storeNodes.current = s.nodes;
|
||||
storeEdges.current = s.edges;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const activeDoc = docs.find((d) => d.id === activeId) ?? docs[0];
|
||||
|
||||
/** Snapshot the live canvas state into a doc record. */
|
||||
const snapshotCurrent = useCallback(() => {
|
||||
setDocs((prev) =>
|
||||
prev.map((d) =>
|
||||
d.id === activeId
|
||||
? { ...d, nodes: storeNodes.current as Node<EntropykNodeData>[], edges: storeEdges.current }
|
||||
: d,
|
||||
),
|
||||
);
|
||||
}, [activeId]);
|
||||
|
||||
/** Switch the active tab — save current canvas, restore target canvas. */
|
||||
const switchTo = useCallback(
|
||||
(id: string) => {
|
||||
if (id === activeId) return;
|
||||
// Save current canvas into its doc.
|
||||
setDocs((prev) =>
|
||||
prev.map((d) =>
|
||||
d.id === activeId
|
||||
? { ...d, nodes: storeNodes.current as Node<EntropykNodeData>[], edges: storeEdges.current }
|
||||
: d,
|
||||
),
|
||||
);
|
||||
const target = docs.find((d) => d.id === id);
|
||||
if (!target) return;
|
||||
useDiagramStore.setState({
|
||||
nodes: target.nodes,
|
||||
edges: target.edges,
|
||||
selectedNodeId: null,
|
||||
});
|
||||
setActiveId(id);
|
||||
},
|
||||
[activeId, docs],
|
||||
);
|
||||
|
||||
/** Snapshot current canvas, then open a class tab with given graph. */
|
||||
const pushClassTab = useCallback(
|
||||
(moduleName: string, nodes: Node<EntropykNodeData>[], edges: Edge[], dirty: boolean) => {
|
||||
const id = `module:${moduleName}`;
|
||||
setDocs((prev) => {
|
||||
const saved = prev.map((d) =>
|
||||
d.id === activeId
|
||||
? {
|
||||
...d,
|
||||
nodes: storeNodes.current as Node<EntropykNodeData>[],
|
||||
edges: storeEdges.current,
|
||||
}
|
||||
: d,
|
||||
);
|
||||
if (saved.some((d) => d.id === id)) {
|
||||
return saved.map((d) =>
|
||||
d.id === id ? { ...d, nodes, edges, dirty, name: moduleName } : d,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...saved,
|
||||
{ id, name: moduleName, kind: "module" as const, nodes, edges, dirty },
|
||||
];
|
||||
});
|
||||
useDiagramStore.setState({ nodes, edges, selectedNodeId: null });
|
||||
setActiveId(id);
|
||||
},
|
||||
[activeId],
|
||||
);
|
||||
|
||||
/** Open a class (.ekmod) in its own tab. If already open, just focus it. */
|
||||
const openModule = useCallback(
|
||||
async (moduleName: string) => {
|
||||
const id = `module:${moduleName}`;
|
||||
if (docs.some((d) => d.id === id)) {
|
||||
switchTo(id);
|
||||
return;
|
||||
}
|
||||
const modData = await fetchModule(moduleName);
|
||||
const { nodes: newNodes, edges: newEdges } = parseModuleData(modData);
|
||||
pushClassTab(moduleName, newNodes, newEdges, false);
|
||||
},
|
||||
[docs, switchTo, pushClassTab],
|
||||
);
|
||||
|
||||
/** Open a newly created empty class without a round-trip fetch. */
|
||||
const openEmptyModule = useCallback(
|
||||
(moduleName: string) => {
|
||||
const id = `module:${moduleName}`;
|
||||
if (docs.some((d) => d.id === id)) {
|
||||
switchTo(id);
|
||||
return;
|
||||
}
|
||||
pushClassTab(moduleName, [], [], false);
|
||||
},
|
||||
[docs, switchTo, pushClassTab],
|
||||
);
|
||||
|
||||
/** Rename the workspace scenario (CLI config name). */
|
||||
const renameWorkspace = useCallback((name: string) => {
|
||||
const clean = name.trim();
|
||||
if (!clean) return;
|
||||
setDocs((prev) =>
|
||||
prev.map((d) => (d.id === "workspace" ? { ...d, name: clean } : d)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
/** Create / rename scenario; optionally clear the workspace canvas. */
|
||||
const newScenario = useCallback(
|
||||
(name: string, clearCanvas: boolean) => {
|
||||
const clean = name.trim();
|
||||
if (!clean) return;
|
||||
|
||||
// Persist whatever tab we are leaving.
|
||||
setDocs((prev) => {
|
||||
const withSnap = prev.map((d) =>
|
||||
d.id === activeId
|
||||
? {
|
||||
...d,
|
||||
nodes: storeNodes.current as Node<EntropykNodeData>[],
|
||||
edges: storeEdges.current,
|
||||
}
|
||||
: d,
|
||||
);
|
||||
return withSnap.map((d) =>
|
||||
d.id === "workspace"
|
||||
? {
|
||||
...d,
|
||||
name: clean,
|
||||
nodes: clearCanvas ? [] : d.nodes,
|
||||
edges: clearCanvas ? [] : d.edges,
|
||||
dirty: false,
|
||||
}
|
||||
: d,
|
||||
);
|
||||
});
|
||||
|
||||
if (clearCanvas) {
|
||||
useDiagramStore.getState().clear();
|
||||
} else if (activeId !== "workspace") {
|
||||
const ws = docs.find((d) => d.id === "workspace");
|
||||
useDiagramStore.setState({
|
||||
nodes: ws?.nodes ?? [],
|
||||
edges: ws?.edges ?? [],
|
||||
selectedNodeId: null,
|
||||
});
|
||||
}
|
||||
setActiveId("workspace");
|
||||
},
|
||||
[activeId, docs],
|
||||
);
|
||||
|
||||
/** Close a tab. Workspace cannot be closed. */
|
||||
const closeDoc = useCallback(
|
||||
(id: string) => {
|
||||
if (id === "workspace") return;
|
||||
|
||||
const idx = docs.findIndex((d) => d.id === id);
|
||||
if (idx < 0) return;
|
||||
|
||||
// Snapshot live canvas into the tab being closed (if it's active).
|
||||
const closing: Doc =
|
||||
id === activeId
|
||||
? {
|
||||
...docs[idx],
|
||||
nodes: storeNodes.current as Node<EntropykNodeData>[],
|
||||
edges: storeEdges.current,
|
||||
}
|
||||
: docs[idx];
|
||||
|
||||
const next = docs
|
||||
.map((d) => (d.id === id ? closing : d))
|
||||
.filter((d) => d.id !== id);
|
||||
|
||||
const switchingAway = id === activeId;
|
||||
const fallback = switchingAway
|
||||
? (next[Math.max(0, idx - 1)] ?? next[0])
|
||||
: null;
|
||||
|
||||
// Pure state updates only — never call another setState inside a setDocs updater.
|
||||
setDocs(next);
|
||||
if (fallback) {
|
||||
setActiveId(fallback.id);
|
||||
useDiagramStore.setState({
|
||||
nodes: fallback.nodes,
|
||||
edges: fallback.edges,
|
||||
selectedNodeId: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
[activeId, docs],
|
||||
);
|
||||
|
||||
/** Mark the active doc dirty (after edits). */
|
||||
const markDirty = useCallback(() => {
|
||||
setDocs((prev) =>
|
||||
prev.map((d) => (d.id === activeId ? { ...d, dirty: true } : d)),
|
||||
);
|
||||
}, [activeId]);
|
||||
|
||||
/** Mark a doc clean (after save). */
|
||||
const markClean = useCallback((id: string) => {
|
||||
setDocs((prev) => prev.map((d) => (d.id === id ? { ...d, dirty: false } : d)));
|
||||
}, []);
|
||||
|
||||
/** Reload a doc's content from server (after save). */
|
||||
const reloadDoc = useCallback(
|
||||
async (id: string) => {
|
||||
if (!id.startsWith("module:")) return;
|
||||
const name = id.slice("module:".length);
|
||||
const modData = await fetchModule(name);
|
||||
const { nodes: nn, edges: ne } = parseModuleData(modData);
|
||||
setDocs((prev) =>
|
||||
prev.map((d) => (d.id === id ? { ...d, nodes: nn, edges: ne, dirty: false } : d)),
|
||||
);
|
||||
if (id === activeId) {
|
||||
useDiagramStore.setState({ nodes: nn, edges: ne, selectedNodeId: null });
|
||||
}
|
||||
},
|
||||
[activeId],
|
||||
);
|
||||
|
||||
// Periodically snapshot the canvas into the active doc so switching tabs
|
||||
// always restores the very latest state (covers drag/edit ops that don't
|
||||
// go through explicit save handlers).
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => {
|
||||
snapshotCurrent();
|
||||
}, 1500);
|
||||
return () => clearInterval(t);
|
||||
}, [snapshotCurrent]);
|
||||
|
||||
const workspaceName =
|
||||
docs.find((d) => d.id === "workspace")?.name ?? "Workspace";
|
||||
|
||||
return {
|
||||
docs,
|
||||
activeDoc,
|
||||
activeId,
|
||||
workspaceName,
|
||||
switchTo,
|
||||
openModule,
|
||||
openEmptyModule,
|
||||
closeDoc,
|
||||
markDirty,
|
||||
markClean,
|
||||
reloadDoc,
|
||||
renameWorkspace,
|
||||
newScenario,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Helpers ──────────────────────────────────────────────────────── */
|
||||
|
||||
function parseModuleData(modData: unknown): {
|
||||
nodes: Node<EntropykNodeData>[];
|
||||
edges: Edge[];
|
||||
} {
|
||||
const newNodes: Node<EntropykNodeData>[] = [];
|
||||
const newEdges: Edge[] = [];
|
||||
const nameToId: Record<string, string> = {};
|
||||
|
||||
if (!modData || typeof modData !== "object") {
|
||||
return { nodes: [], edges: [] };
|
||||
}
|
||||
|
||||
const modObj = modData as {
|
||||
components?: Array<Record<string, unknown>>;
|
||||
instances?: Array<{
|
||||
of: string;
|
||||
name: string;
|
||||
circuit?: number;
|
||||
params?: Record<string, unknown>;
|
||||
}>;
|
||||
edges?: Array<{ from: string; to: string }>;
|
||||
connections?: Array<{ from: string; to: string }>;
|
||||
ports?: Record<string, string>;
|
||||
};
|
||||
|
||||
const atomicComps = modObj.components ?? [];
|
||||
atomicComps.forEach((c, idx) => {
|
||||
const cName = String(c.name ?? `comp_${idx}`);
|
||||
const cType = String(c.type ?? "Generic");
|
||||
const nodeId = `mod_node_${Date.now()}_${idx}`;
|
||||
nameToId[cName] = nodeId;
|
||||
const { type: _, name: __, ...params } = c;
|
||||
newNodes.push({
|
||||
id: nodeId,
|
||||
type: "entropykNode",
|
||||
position: {
|
||||
x: 100 + (idx % 3) * 260,
|
||||
y: 100 + Math.floor(idx / 3) * 180,
|
||||
},
|
||||
data: {
|
||||
type: cType,
|
||||
kind: cType,
|
||||
name: cName,
|
||||
circuit: 0,
|
||||
rotation: 0,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
params: params as Record<string, number | string | boolean>,
|
||||
status: "ok",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const moduleInsts = modObj.instances ?? [];
|
||||
moduleInsts.forEach((inst, idx) => {
|
||||
const instName = String(inst.name ?? `inst_${idx}`);
|
||||
const ofModule = String(inst.of ?? "BaseChiller");
|
||||
const nodeId = `mod_inst_${Date.now()}_${idx}`;
|
||||
nameToId[instName] = nodeId;
|
||||
newNodes.push({
|
||||
id: nodeId,
|
||||
type: "entropykNode",
|
||||
position: {
|
||||
x: 100 + ((idx + atomicComps.length) % 3) * 280,
|
||||
y: 100 + Math.floor((idx + atomicComps.length) / 3) * 200,
|
||||
},
|
||||
data: {
|
||||
type: "ModuleInstance",
|
||||
kind: "ModuleInstance",
|
||||
name: instName,
|
||||
moduleName: ofModule,
|
||||
circuit: inst.circuit ?? 0,
|
||||
rotation: 0,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
params: (inst.params as Record<string, number | string | boolean>) ?? {},
|
||||
status: "ok",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const atomicEdg = modObj.edges ?? [];
|
||||
atomicEdg.forEach((e, idx) => {
|
||||
const [fromComp, fromPort] = e.from.split(":");
|
||||
const [toComp, toPort] = e.to.split(":");
|
||||
const sourceId = nameToId[fromComp];
|
||||
const targetId = nameToId[toComp];
|
||||
if (sourceId && targetId) {
|
||||
newEdges.push({
|
||||
id: `mod_edge_${Date.now()}_${idx}`,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle: fromPort,
|
||||
targetHandle: toPort,
|
||||
type: "entropykEdge",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const instConn = modObj.connections ?? [];
|
||||
instConn.forEach((c, idx) => {
|
||||
const fromParts = c.from.split(".");
|
||||
const toParts = c.to.split(".");
|
||||
const fromComp = fromParts[0];
|
||||
const fromPort = fromParts.slice(1).join(".");
|
||||
const toComp = toParts[0];
|
||||
const toPort = toParts.slice(1).join(".");
|
||||
const sourceId = nameToId[fromComp];
|
||||
const targetId = nameToId[toComp];
|
||||
if (sourceId && targetId) {
|
||||
newEdges.push({
|
||||
id: `mod_conn_${Date.now()}_${idx}`,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle: fromPort,
|
||||
targetHandle: toPort,
|
||||
type: "entropykEdge",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { nodes: newNodes, edges: newEdges };
|
||||
}
|
||||
94
apps/web/src/lib/useScenarioSimulation.ts
Normal file
94
apps/web/src/lib/useScenarioSimulation.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useDiagramStore } from "@/store/diagramStore";
|
||||
import { simulate } from "@/lib/api";
|
||||
import { buildScenarioConfig, validateConfig } from "@/lib/configBuilder";
|
||||
import { computeDofLedger } from "@/lib/dofLedger";
|
||||
import type { EntropykNodeData } from "@/lib/configBuilder";
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
/**
|
||||
* Shared simulation trigger.
|
||||
*
|
||||
* Lifted out of `ResultsPanel` so any surface (TopBar Solve button, keyboard
|
||||
* shortcut, multi-run, results panel) can fire the same validated solve and
|
||||
* surface the same validation issues. The result/error stay in the diagram
|
||||
* store; this hook only owns the transient `issues` and `simulating` flags
|
||||
* needed by callers.
|
||||
*/
|
||||
export function useScenarioSimulation() {
|
||||
const nodes = useDiagramStore((s) => s.nodes);
|
||||
const edges = useDiagramStore((s) => s.edges);
|
||||
const fluid = useDiagramStore((s) => s.fluid);
|
||||
const fluidBackend = useDiagramStore((s) => s.fluidBackend);
|
||||
const solverStrategy = useDiagramStore((s) => s.solverStrategy);
|
||||
const maxIterations = useDiagramStore((s) => s.maxIterations);
|
||||
const tolerance = useDiagramStore((s) => s.tolerance);
|
||||
|
||||
const setLastConfig = useDiagramStore((s) => s.setLastConfig);
|
||||
const setResult = useDiagramStore((s) => s.setResult);
|
||||
const setSimulating = useDiagramStore((s) => s.setSimulating);
|
||||
const simulating = useDiagramStore((s) => s.simulating);
|
||||
|
||||
const [issues, setIssues] = useState<string[]>([]);
|
||||
|
||||
const run = useCallback(async () => {
|
||||
const problems = validateConfig(nodes, edges);
|
||||
const ledger = computeDofLedger(nodes as Node<EntropykNodeData>[], edges);
|
||||
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 false;
|
||||
|
||||
const scenarioConfig = buildScenarioConfig(nodes, edges, {
|
||||
fluid,
|
||||
fluidBackend,
|
||||
solverStrategy,
|
||||
maxIterations,
|
||||
tolerance,
|
||||
});
|
||||
setSimulating(true);
|
||||
setLastConfig(scenarioConfig);
|
||||
setResult(null, null);
|
||||
try {
|
||||
const resp = await simulate(scenarioConfig);
|
||||
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})`,
|
||||
]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
setResult(null, resp.error || "Simulation failed");
|
||||
return false;
|
||||
} catch (e) {
|
||||
setResult(null, e instanceof Error ? e.message : String(e));
|
||||
return false;
|
||||
} finally {
|
||||
setSimulating(false);
|
||||
}
|
||||
}, [
|
||||
nodes,
|
||||
edges,
|
||||
fluid,
|
||||
fluidBackend,
|
||||
solverStrategy,
|
||||
maxIterations,
|
||||
tolerance,
|
||||
setLastConfig,
|
||||
setResult,
|
||||
setSimulating,
|
||||
]);
|
||||
|
||||
return { run, issues, setIssues, simulating };
|
||||
}
|
||||
26
apps/web/src/lib/zz_dump_config.test.ts
Normal file
26
apps/web/src/lib/zz_dump_config.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// TEMPORARY AUDIT — dump the exact config the UI sends. Delete after use.
|
||||
import { describe, it } from "vitest";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { useDiagramStore } from "../store/diagramStore";
|
||||
import { buildScenarioConfig } from "./configBuilder";
|
||||
import example from "../../public/examples/chiller_r134a_exv_orifice.json";
|
||||
|
||||
describe("dump UI config", () => {
|
||||
it("writes the full emitted config to target/tmp/ui_emitted_config.json", () => {
|
||||
useDiagramStore.getState().loadFromConfig(example);
|
||||
const s = useDiagramStore.getState();
|
||||
const config = buildScenarioConfig(s.nodes, s.edges, {
|
||||
fluid: s.fluid,
|
||||
fluidBackend: s.fluidBackend,
|
||||
solverStrategy: s.solverStrategy,
|
||||
maxIterations: s.maxIterations,
|
||||
tolerance: s.tolerance,
|
||||
controls: s.controls,
|
||||
});
|
||||
writeFileSync(
|
||||
"../../target/tmp/ui_emitted_config.json",
|
||||
JSON.stringify(config, null, 2),
|
||||
);
|
||||
console.log("strategy:", config.solver.strategy, "| components:", config.circuits[0].components.length);
|
||||
});
|
||||
});
|
||||
@@ -97,15 +97,23 @@ interface DiagramState {
|
||||
tolerance: number;
|
||||
controls: ControlConfig[];
|
||||
snapToGrid: boolean;
|
||||
/** Pinned sweep axes (format "componentName.paramKey") pushed from the properties panel. */
|
||||
pinnedSweepPaths: string[];
|
||||
result: SimulationResult | null;
|
||||
lastConfig: unknown | null;
|
||||
simulating: boolean;
|
||||
simError: string | null;
|
||||
|
||||
// Undo / redo history
|
||||
history: Array<{ nodes: EntropykNode[]; edges: Edge[]; selectedNodeId: string | null }>;
|
||||
historyIndex: number;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
|
||||
onNodesChange: OnNodesChange;
|
||||
onEdgesChange: OnEdgesChange;
|
||||
onConnect: OnConnect;
|
||||
addComponent: (type: string, position: { x: number; y: number }) => void;
|
||||
addComponent: (type: string, position: { x: number; y: number }, extraParams?: Record<string, string | number | boolean>) => void;
|
||||
/** Insert a 2-port component into an existing edge (A→B → A→comp→B). */
|
||||
insertOnEdge: (
|
||||
type: string,
|
||||
@@ -133,11 +141,16 @@ interface DiagramState {
|
||||
setTolerance: (tolerance: number) => void;
|
||||
setControls: (controls: ControlConfig[]) => void;
|
||||
toggleSnapToGrid: () => void;
|
||||
togglePinnedSweepPath: (path: string) => void;
|
||||
clearPinnedSweepPaths: () => void;
|
||||
setLastConfig: (config: unknown | null) => void;
|
||||
setResult: (result: SimulationResult | null, error?: string | null) => void;
|
||||
setSimulating: (v: boolean) => void;
|
||||
loadFromConfig: (config: unknown) => void;
|
||||
clear: () => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
recordHistory: () => void;
|
||||
}
|
||||
|
||||
let nameCounter: Record<string, number> = {};
|
||||
@@ -418,10 +431,65 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
|
||||
tolerance: 1e-6,
|
||||
controls: [],
|
||||
snapToGrid: true,
|
||||
pinnedSweepPaths: [],
|
||||
result: null,
|
||||
lastConfig: null,
|
||||
simulating: false,
|
||||
simError: null,
|
||||
history: [{ nodes: [], edges: [], selectedNodeId: null }],
|
||||
historyIndex: 0,
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
|
||||
recordHistory: () => {
|
||||
const { nodes, edges, selectedNodeId, history, historyIndex } = get();
|
||||
const snapshot = {
|
||||
nodes: structuredClone(nodes),
|
||||
edges: structuredClone(edges),
|
||||
selectedNodeId,
|
||||
};
|
||||
const nextHistory = history.slice(0, historyIndex + 1);
|
||||
nextHistory.push(snapshot);
|
||||
// Keep history bounded to avoid memory bloat.
|
||||
if (nextHistory.length > 200) nextHistory.shift();
|
||||
const nextIndex = nextHistory.length - 1;
|
||||
set({
|
||||
history: nextHistory,
|
||||
historyIndex: nextIndex,
|
||||
canUndo: nextIndex > 0,
|
||||
canRedo: false,
|
||||
});
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
const { historyIndex, history } = get();
|
||||
if (historyIndex <= 0) return;
|
||||
const nextIndex = historyIndex - 1;
|
||||
const snapshot = history[nextIndex];
|
||||
set({
|
||||
nodes: structuredClone(snapshot.nodes),
|
||||
edges: structuredClone(snapshot.edges),
|
||||
selectedNodeId: snapshot.selectedNodeId,
|
||||
historyIndex: nextIndex,
|
||||
canUndo: nextIndex > 0,
|
||||
canRedo: true,
|
||||
});
|
||||
},
|
||||
|
||||
redo: () => {
|
||||
const { historyIndex, history } = get();
|
||||
if (historyIndex >= history.length - 1) return;
|
||||
const nextIndex = historyIndex + 1;
|
||||
const snapshot = history[nextIndex];
|
||||
set({
|
||||
nodes: structuredClone(snapshot.nodes),
|
||||
edges: structuredClone(snapshot.edges),
|
||||
selectedNodeId: snapshot.selectedNodeId,
|
||||
historyIndex: nextIndex,
|
||||
canUndo: true,
|
||||
canRedo: nextIndex < history.length - 1,
|
||||
});
|
||||
},
|
||||
|
||||
onNodesChange: (changes) => {
|
||||
const removed = changes.filter((c) => c.type === "remove").map((c) => c.id);
|
||||
@@ -435,15 +503,18 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
|
||||
onEdgesChange: (changes) =>
|
||||
set({ edges: applyEdgeChanges(changes, get().edges) }),
|
||||
|
||||
onConnect: (connection) =>
|
||||
onConnect: (connection) => {
|
||||
get().recordHistory();
|
||||
set({
|
||||
edges: addEdge(
|
||||
{ ...connection, animated: false, type: "smoothstep" },
|
||||
get().edges,
|
||||
),
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
addComponent: (type, position) => {
|
||||
addComponent: (type, position, extraParams) => {
|
||||
get().recordHistory();
|
||||
const id = crypto.randomUUID();
|
||||
const pos = get().snapToGrid ? snapPosition(position) : position;
|
||||
const node: EntropykNode = {
|
||||
@@ -452,12 +523,12 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
|
||||
position: pos,
|
||||
data: {
|
||||
type,
|
||||
name: uniqueName(type),
|
||||
name: uniqueName(type === "ModuleInstance" && extraParams?.module_name ? String(extraParams.module_name) : type),
|
||||
circuit: 0,
|
||||
rotation: 0,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
params: defaultParams(type),
|
||||
params: { ...defaultParams(type), ...(extraParams ?? {}) },
|
||||
},
|
||||
};
|
||||
set({ nodes: [...get().nodes, node], selectedNodeId: id });
|
||||
@@ -502,11 +573,11 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
|
||||
targetCenter,
|
||||
});
|
||||
|
||||
// Snap final position after orientation centering
|
||||
if (snapToGrid) {
|
||||
plan.node.position = snapPosition(plan.node.position);
|
||||
}
|
||||
|
||||
get().recordHistory();
|
||||
set({
|
||||
nodes: [...nodes, plan.node as EntropykNode],
|
||||
edges: [
|
||||
@@ -518,33 +589,42 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
updateNodeParams: (id, params) =>
|
||||
updateNodeParams: (id, params) => {
|
||||
get().recordHistory();
|
||||
set({
|
||||
nodes: get().nodes.map((n) =>
|
||||
n.id === id ? { ...n, data: { ...n.data, params: { ...n.data.params, ...params } } } : n,
|
||||
),
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
updateNodesParams: (patches) =>
|
||||
updateNodesParams: (patches) => {
|
||||
get().recordHistory();
|
||||
set({
|
||||
nodes: get().nodes.map((n) => {
|
||||
const patch = patches.get(n.id);
|
||||
if (!patch) return n;
|
||||
return { ...n, data: { ...n.data, params: { ...n.data.params, ...patch } } };
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
updateNodeName: (id, name) =>
|
||||
updateNodeName: (id, name) => {
|
||||
get().recordHistory();
|
||||
set({
|
||||
nodes: get().nodes.map((n) => (n.id === id ? { ...n, data: { ...n.data, name } } : n)),
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
updateNodeCircuit: (id, circuit) =>
|
||||
updateNodeCircuit: (id, circuit) => {
|
||||
get().recordHistory();
|
||||
set({
|
||||
nodes: get().nodes.map((n) => (n.id === id ? { ...n, data: { ...n.data, circuit } } : n)),
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
rotateNode: (id, dir = 1) =>
|
||||
rotateNode: (id, dir = 1) => {
|
||||
get().recordHistory();
|
||||
set({
|
||||
nodes: get().nodes.map((n) =>
|
||||
n.id === id
|
||||
@@ -557,33 +637,40 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
|
||||
}
|
||||
: n,
|
||||
),
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
flipNodeH: (id) =>
|
||||
flipNodeH: (id) => {
|
||||
get().recordHistory();
|
||||
set({
|
||||
nodes: get().nodes.map((n) =>
|
||||
n.id === id ? { ...n, data: { ...n.data, flipH: !n.data.flipH } } : n,
|
||||
),
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
flipNodeV: (id) =>
|
||||
flipNodeV: (id) => {
|
||||
get().recordHistory();
|
||||
set({
|
||||
nodes: get().nodes.map((n) =>
|
||||
n.id === id ? { ...n, data: { ...n.data, flipV: !n.data.flipV } } : n,
|
||||
),
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
setSelected: (id) => {
|
||||
if (get().selectedNodeId === id) return;
|
||||
set({ selectedNodeId: id });
|
||||
},
|
||||
|
||||
removeNode: (id) =>
|
||||
removeNode: (id) => {
|
||||
get().recordHistory();
|
||||
set({
|
||||
nodes: get().nodes.filter((n) => n.id !== id),
|
||||
edges: get().edges.filter((e) => e.source !== id && e.target !== id),
|
||||
selectedNodeId: get().selectedNodeId === id ? null : get().selectedNodeId,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
setFluid: (fluid) => set({ fluid }),
|
||||
setFluidBackend: (backend) => set({ fluidBackend: backend }),
|
||||
@@ -592,6 +679,13 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
|
||||
setTolerance: (tolerance) => set({ tolerance }),
|
||||
setControls: (controls) => set({ controls }),
|
||||
toggleSnapToGrid: () => set({ snapToGrid: !get().snapToGrid }),
|
||||
togglePinnedSweepPath: (path) =>
|
||||
set((s) => ({
|
||||
pinnedSweepPaths: s.pinnedSweepPaths.includes(path)
|
||||
? s.pinnedSweepPaths.filter((p) => p !== path)
|
||||
: [...s.pinnedSweepPaths, path],
|
||||
})),
|
||||
clearPinnedSweepPaths: () => set({ pinnedSweepPaths: [] }),
|
||||
|
||||
setLastConfig: (config) => set({ lastConfig: config }),
|
||||
setResult: (result, error = null) => set({ result, simError: error }),
|
||||
@@ -697,9 +791,11 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
|
||||
simError: null,
|
||||
selectedNodeId: null,
|
||||
});
|
||||
get().recordHistory();
|
||||
},
|
||||
|
||||
clear: () =>
|
||||
clear: () => {
|
||||
const empty = { nodes: [], edges: [], selectedNodeId: null };
|
||||
set({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
@@ -708,5 +804,10 @@ export const useDiagramStore = create<DiagramState>((set, get) => ({
|
||||
lastConfig: null,
|
||||
simError: null,
|
||||
selectedNodeId: null,
|
||||
}),
|
||||
history: [empty],
|
||||
historyIndex: 0,
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user