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:
19
apps/web/capture_screenshot.js
Normal file
19
apps/web/capture_screenshot.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const { chromium } = require("C:/Users/ramez/AppData/Roaming/npm/node_modules/playwright");
|
||||
|
||||
(async () => {
|
||||
console.log("Launching browser...");
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
console.log("Navigating to http://localhost:3001 ...");
|
||||
try {
|
||||
await page.goto("http://localhost:3001", { waitUntil: "networkidle", timeout: 10000 });
|
||||
} catch (e) {
|
||||
console.log("Network idle timeout or error, proceeding to screenshot...", e.message);
|
||||
}
|
||||
|
||||
const screenshotPath = "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/entropyk_ui_screenshot.png";
|
||||
await page.screenshot({ path: screenshotPath, fullPage: false });
|
||||
console.log(`Screenshot saved to ${screenshotPath}`);
|
||||
await browser.close();
|
||||
})();
|
||||
22
apps/web/interact_and_screenshot.js
Normal file
22
apps/web/interact_and_screenshot.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const { chromium } = require("C:/Users/ramez/AppData/Roaming/npm/node_modules/playwright");
|
||||
|
||||
(async () => {
|
||||
console.log("Launching browser for interactive test on port 3005...");
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
console.log("Navigating to http://localhost:3005 ...");
|
||||
await page.goto("http://localhost:3005", { waitUntil: "domcontentloaded" });
|
||||
|
||||
console.log("Waiting for UI element...");
|
||||
await page.waitForSelector("button:has-text('Bibliothèque')", { timeout: 15000 });
|
||||
|
||||
console.log("Clicking Bibliothèque tab...");
|
||||
await page.click("button:has-text('Bibliothèque')");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const screenshotPath = "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/entropyk_ui_interactive.png";
|
||||
await page.screenshot({ path: screenshotPath });
|
||||
console.log(`Interactive screenshot saved to ${screenshotPath}`);
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -1,32 +1,112 @@
|
||||
{
|
||||
"name": "BPHX Evaporator and Condenser Bounded Test",
|
||||
"name": "BPHX Water-Cooled Chiller R134a",
|
||||
"description": "Full vapor-compression cycle with brazed-plate evaporator and condenser (4-port). Both Bphx units use emergent_pressure so outlet closures (superheat / subcooling) square the DoF like Condenser/Evaporator.",
|
||||
|
||||
"fluid": "R134a",
|
||||
"fluid_backend": "CoolProp",
|
||||
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Refrigerant + water loops",
|
||||
"components": [
|
||||
{ "type": "RefrigerantSource", "name": "src", "fluid": "R134a", "p_set_bar": 5.0, "quality": 0.3 },
|
||||
{ "type": "BphxEvaporator", "name": "evap", "ua": 2000.0, "refrigerant": "R134a", "secondary_fluid": "Water", "secondary_inlet_temp_c": 12.0, "secondary_mass_flow_kg_s": 0.5, "secondary_cp_j_per_kgk": 4186.0 },
|
||||
{ "type": "RefrigerantSink", "name": "sink", "fluid": "R134a", "p_back_bar": 5.0 }
|
||||
{
|
||||
"type": "IsentropicCompressor",
|
||||
"name": "comp",
|
||||
"isentropic_efficiency": 0.70,
|
||||
"t_cond_k": 318.15,
|
||||
"t_evap_k": 278.15,
|
||||
"superheat_k": 5.0,
|
||||
"emergent_pressure": true,
|
||||
"displacement_m3": 6.5e-5,
|
||||
"speed_hz": 50.0,
|
||||
"volumetric_efficiency": 0.92
|
||||
},
|
||||
{
|
||||
"type": "BphxCondenser",
|
||||
"name": "cond",
|
||||
"refrigerant": "R134a",
|
||||
"secondary_fluid": "Water",
|
||||
"n_plates": 40,
|
||||
"plate_length_m": 0.40,
|
||||
"plate_width_m": 0.12,
|
||||
"target_subcooling_k": 5.0,
|
||||
"emergent_pressure": true,
|
||||
"correlation": "Longo2004",
|
||||
"ua": 2500.0
|
||||
},
|
||||
{
|
||||
"type": "IsenthalpicExpansionValve",
|
||||
"name": "exv",
|
||||
"t_evap_k": 278.15,
|
||||
"emergent_pressure": true
|
||||
},
|
||||
{
|
||||
"type": "BphxEvaporator",
|
||||
"name": "evap",
|
||||
"refrigerant": "R134a",
|
||||
"secondary_fluid": "Water",
|
||||
"n_plates": 40,
|
||||
"plate_length_m": 0.40,
|
||||
"plate_width_m": 0.12,
|
||||
"target_superheat_k": 5.0,
|
||||
"emergent_pressure": true,
|
||||
"correlation": "Longo2004",
|
||||
"ua": 2000.0
|
||||
},
|
||||
{
|
||||
"type": "BrineSource",
|
||||
"name": "cond_water_in",
|
||||
"fluid": "Water",
|
||||
"p_set_bar": 2.0,
|
||||
"t_set_c": 30.0,
|
||||
"m_flow_kg_s": 0.40,
|
||||
"fix_pressure": false,
|
||||
"fix_temperature": true,
|
||||
"fix_mass_flow": true
|
||||
},
|
||||
{
|
||||
"type": "BrineSink",
|
||||
"name": "cond_water_out",
|
||||
"fluid": "Water",
|
||||
"p_back_bar": 2.0,
|
||||
"fix_pressure": true
|
||||
},
|
||||
{
|
||||
"type": "BrineSource",
|
||||
"name": "evap_water_in",
|
||||
"fluid": "Water",
|
||||
"p_set_bar": 3.0,
|
||||
"t_set_c": 12.0,
|
||||
"m_flow_kg_s": 0.50,
|
||||
"fix_pressure": false,
|
||||
"fix_temperature": true,
|
||||
"fix_mass_flow": true
|
||||
},
|
||||
{
|
||||
"type": "BrineSink",
|
||||
"name": "evap_water_out",
|
||||
"fluid": "Water",
|
||||
"p_back_bar": 3.0,
|
||||
"fix_pressure": true
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "src:outlet", "to": "evap:inlet" },
|
||||
{ "from": "evap:outlet", "to": "sink:inlet" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"components": [
|
||||
{ "type": "RefrigerantSource", "name": "src2", "fluid": "R134a", "p_set_bar": 15.0, "quality": 1.0 },
|
||||
{ "type": "BphxCondenser", "name": "cond", "ua": 2000.0, "refrigerant": "R134a", "secondary_fluid": "Water", "secondary_inlet_temp_c": 30.0, "secondary_mass_flow_kg_s": 0.4, "secondary_cp_j_per_kgk": 4186.0 },
|
||||
{ "type": "RefrigerantSink", "name": "sink2", "fluid": "R134a", "p_back_bar": 15.0 }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "src2:outlet", "to": "cond:inlet" },
|
||||
{ "from": "cond:outlet", "to": "sink2:inlet" }
|
||||
{ "from": "comp:outlet", "to": "cond:inlet" },
|
||||
{ "from": "cond:outlet", "to": "exv:inlet" },
|
||||
{ "from": "exv:outlet", "to": "evap:inlet" },
|
||||
{ "from": "evap:outlet", "to": "comp:inlet" },
|
||||
{ "from": "cond_water_in:outlet", "to": "cond:secondary_inlet" },
|
||||
{ "from": "cond:secondary_outlet", "to": "cond_water_out:inlet" },
|
||||
{ "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
|
||||
{ "from": "evap:secondary_outlet", "to": "evap_water_out:inlet" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"solver": { "strategy": "fallback", "max_iterations": 100, "tolerance": 1e-6 }
|
||||
|
||||
"solver": {
|
||||
"strategy": "fallback",
|
||||
"max_iterations": 300,
|
||||
"tolerance": 1e-6
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"schema_version": "1",
|
||||
"name": "Capillary Tube R134a Smoke",
|
||||
"description": "WIP smoke fixture: RefrigerantSource → CapillaryTube → RefrigerantSink. DoF can square, but CapillaryTube Component residuals are still stubs (mass/enthalpy continuity only — no frictional ΔP residual), so the Newton Jacobian is singular. Kept for parsing/wiring tests; not yet a UI Load example.",
|
||||
|
||||
"fluid": "R134a",
|
||||
"fluid_backend": "CoolProp",
|
||||
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Capillary smoke",
|
||||
"components": [
|
||||
{
|
||||
"type": "RefrigerantSource",
|
||||
"name": "src",
|
||||
"fluid": "R134a",
|
||||
"p_set_bar": 12.0,
|
||||
"quality": 0.0
|
||||
},
|
||||
{
|
||||
"type": "CapillaryTube",
|
||||
"name": "cap",
|
||||
@@ -17,11 +28,21 @@
|
||||
"h_inlet_kj_kg": 250.0,
|
||||
"p_outlet_bar": 3.5,
|
||||
"h_outlet_kj_kg": 250.0
|
||||
},
|
||||
{
|
||||
"type": "RefrigerantSink",
|
||||
"name": "sink",
|
||||
"fluid": "R134a",
|
||||
"p_back_bar": 3.5
|
||||
}
|
||||
],
|
||||
"edges": []
|
||||
"edges": [
|
||||
{ "from": "src:outlet", "to": "cap:inlet" },
|
||||
{ "from": "cap:outlet", "to": "sink:inlet" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"solver": {
|
||||
"strategy": "newton",
|
||||
"max_iterations": 50,
|
||||
|
||||
122
apps/web/public/examples/chiller_aircooled_r134a_fan.json
Normal file
122
apps/web/public/examples/chiller_aircooled_r134a_fan.json
Normal file
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"name": "Air-Cooled Chiller R134a with Condenser Fan",
|
||||
"description": "Same emergent-pressure air-cooled chiller as chiller_aircooled_r134a.json, with an explicit Fan on the condenser air path: AirSource → Fan → Cond(secondary) → AirSink. Fan curve sized for ~1.2 kg/s (~1.0 m³/s) against the coil secondary ΔP (150 Pa). secondary_humidity_ratio MUST match AirSource psychrometrics (W at T_dry, RH, P).",
|
||||
|
||||
"fluid": "R134a",
|
||||
"fluid_backend": "CoolProp",
|
||||
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Refrigerant + secondary loops",
|
||||
"components": [
|
||||
{
|
||||
"type": "IsentropicCompressor",
|
||||
"name": "comp",
|
||||
"isentropic_efficiency": 0.70,
|
||||
"t_cond_k": 318.15,
|
||||
"t_evap_k": 278.15,
|
||||
"superheat_k": 5.0,
|
||||
"emergent_pressure": true,
|
||||
"displacement_m3": 6.5e-5,
|
||||
"speed_hz": 50.0,
|
||||
"volumetric_efficiency": 0.92
|
||||
},
|
||||
{
|
||||
"type": "Condenser",
|
||||
"name": "cond",
|
||||
"ua": 2500.0,
|
||||
"emergent_pressure": true,
|
||||
"subcooling_k": 5.0,
|
||||
"secondary_fluid": "Air",
|
||||
"secondary_humidity_ratio": 0.01412,
|
||||
"dp_model": "isobaric",
|
||||
"secondary_rated_pressure_drop_pa": 150,
|
||||
"secondary_rated_m_flow_kg_s": 1.2
|
||||
},
|
||||
{
|
||||
"type": "IsenthalpicExpansionValve",
|
||||
"name": "exv",
|
||||
"t_evap_k": 278.15,
|
||||
"emergent_pressure": true
|
||||
},
|
||||
{
|
||||
"type": "Evaporator",
|
||||
"name": "evap",
|
||||
"ua": 1468.0,
|
||||
"emergent_pressure": true,
|
||||
"secondary_fluid": "Water",
|
||||
"dp_model": "isobaric",
|
||||
"secondary_rated_pressure_drop_pa": 40000,
|
||||
"secondary_rated_m_flow_kg_s": 0.4778
|
||||
},
|
||||
{
|
||||
"type": "AirSource",
|
||||
"name": "cond_air_in",
|
||||
"p_set_bar": 1.01325,
|
||||
"t_dry_c": 35.0,
|
||||
"rh": 40.0,
|
||||
"m_flow_kg_s": 1.2,
|
||||
"fix_pressure": false,
|
||||
"fix_temperature": true,
|
||||
"fix_mass_flow": true
|
||||
},
|
||||
{
|
||||
"type": "Fan",
|
||||
"name": "cond_fan",
|
||||
"fluid": "Air",
|
||||
"speed_ratio": 1.0,
|
||||
"air_density_kg_m3": 1.15,
|
||||
"design_flow_m3_s": 1.04,
|
||||
"curve_p0": 250.0,
|
||||
"curve_p1": 0.0,
|
||||
"curve_p2": -20.0,
|
||||
"eff_e0": 0.65,
|
||||
"eff_e1": 0.0,
|
||||
"eff_e2": 0.0
|
||||
},
|
||||
{
|
||||
"type": "AirSink",
|
||||
"name": "cond_air_out",
|
||||
"p_back_bar": 1.01325,
|
||||
"fix_pressure": true
|
||||
},
|
||||
{
|
||||
"type": "BrineSource",
|
||||
"name": "evap_water_in",
|
||||
"fluid": "Water",
|
||||
"p_set_bar": 3.0,
|
||||
"t_set_c": 12.0,
|
||||
"m_flow_kg_s": 0.4778,
|
||||
"fix_pressure": false,
|
||||
"fix_temperature": true,
|
||||
"fix_mass_flow": true
|
||||
},
|
||||
{
|
||||
"type": "BrineSink",
|
||||
"name": "evap_water_out",
|
||||
"fluid": "Water",
|
||||
"p_back_bar": 3.0,
|
||||
"fix_pressure": true
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "comp:outlet", "to": "cond:inlet" },
|
||||
{ "from": "cond:outlet", "to": "exv:inlet" },
|
||||
{ "from": "exv:outlet", "to": "evap:inlet" },
|
||||
{ "from": "evap:outlet", "to": "comp:inlet" },
|
||||
{ "from": "cond_air_in:outlet", "to": "cond_fan:inlet" },
|
||||
{ "from": "cond_fan:outlet", "to": "cond:secondary_inlet" },
|
||||
{ "from": "cond:secondary_outlet", "to": "cond_air_out:inlet" },
|
||||
{ "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
|
||||
{ "from": "evap:secondary_outlet", "to": "evap_water_out:inlet" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"solver": {
|
||||
"strategy": "newton",
|
||||
"max_iterations": 300,
|
||||
"tolerance": 1e-6
|
||||
}
|
||||
}
|
||||
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,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
28
apps/web/test_dual_circuit.js
Normal file
28
apps/web/test_dual_circuit.js
Normal file
@@ -0,0 +1,28 @@
|
||||
const { chromium } = require("C:/Users/ramez/AppData/Roaming/npm/node_modules/playwright");
|
||||
|
||||
(async () => {
|
||||
console.log("Testing DualCircuit opening in Module Editor...");
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
await page.goto("http://localhost:3008", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Click Bibliothèque tab
|
||||
console.log("Clicking Bibliothèque tab...");
|
||||
await page.click("button:has-text('Bibliothèque')");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Click "Éditer" specifically on DualCircuit card
|
||||
console.log("Clicking Éditer on DualCircuit card...");
|
||||
const editBtn = page.locator("div.group:has(h4:has-text('DualCircuit')) button:has-text('Éditer')").first();
|
||||
await editBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Take screenshot
|
||||
const screenshotPath = "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/dual_circuit_editor.png";
|
||||
await page.screenshot({ path: screenshotPath });
|
||||
console.log(`Saved screenshot to ${screenshotPath}`);
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
48
apps/web/test_dymola_modes.js
Normal file
48
apps/web/test_dymola_modes.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const { chromium } = require("C:/Users/ramez/AppData/Roaming/npm/node_modules/playwright");
|
||||
|
||||
(async () => {
|
||||
console.log("Testing Dymola Dual Modes with Playwright...");
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
await page.goto("http://localhost:3005", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 1. Open Bibliothèque tab
|
||||
console.log("Opening Bibliothèque tab...");
|
||||
await page.click("button:has-text('Bibliothèque')");
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// 2. Drag BaseChiller to canvas
|
||||
console.log("Dragging BaseChiller module onto canvas...");
|
||||
const moduleCard = page.locator("div:has-text('BaseChiller')").first();
|
||||
const canvas = page.locator(".react-flow__pane");
|
||||
|
||||
const cardBounds = await moduleCard.boundingBox();
|
||||
const canvasBounds = await canvas.boundingBox();
|
||||
|
||||
if (cardBounds && canvasBounds) {
|
||||
await page.mouse.move(cardBounds.x + cardBounds.width / 2, cardBounds.y + cardBounds.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(canvasBounds.x + canvasBounds.width / 2, canvasBounds.y + canvasBounds.height / 2, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// Screenshot Mode 1: Icon View
|
||||
await page.screenshot({ path: "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/dymola_mode_1_icon.png" });
|
||||
|
||||
// Toggle Mode 2: Internal Diagram Mode
|
||||
const schemaToggle = page.locator("button:has-text('Mode Schéma')").first();
|
||||
if (await schemaToggle.isVisible()) {
|
||||
console.log("Clicking Mode Schéma toggle...");
|
||||
await schemaToggle.click();
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
// Screenshot Mode 2: Internal Diagram View
|
||||
await page.screenshot({ path: "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/dymola_mode_2_diagram.png" });
|
||||
|
||||
console.log("Both Dymola modes captured successfully!");
|
||||
await browser.close();
|
||||
})();
|
||||
32
apps/web/test_guide_and_examples.js
Normal file
32
apps/web/test_guide_and_examples.js
Normal file
@@ -0,0 +1,32 @@
|
||||
const { chromium } = require("C:/Users/ramez/AppData/Roaming/npm/node_modules/playwright");
|
||||
|
||||
(async () => {
|
||||
console.log("Testing Workspace Guide and Auto-Load Examples...");
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
await page.goto("http://localhost:3008", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// 1. Select example "Chiller flooded" from dropdown
|
||||
console.log("Selecting Chiller flooded example...");
|
||||
await page.selectOption("select[title*='Exemple']", "chiller_flooded_4port_watercooled.json");
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Take screenshot of loaded example on canvas
|
||||
const screenshotPath1 = "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/example_flooded_chiller_loaded.png";
|
||||
await page.screenshot({ path: screenshotPath1 });
|
||||
console.log(`Saved screenshot 1 to ${screenshotPath1}`);
|
||||
|
||||
// 2. Click Guide button
|
||||
console.log("Clicking Guide button...");
|
||||
await page.click("button:has-text('Guide')");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Take screenshot of Workspace Help Modal
|
||||
const screenshotPath2 = "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/workspace_guide_modal.png";
|
||||
await page.screenshot({ path: screenshotPath2 });
|
||||
console.log(`Saved screenshot 2 to ${screenshotPath2}`);
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
47
apps/web/test_modelica_ux_overhaul.js
Normal file
47
apps/web/test_modelica_ux_overhaul.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const { chromium } = require("C:/Users/ramez/AppData/Roaming/npm/node_modules/playwright");
|
||||
|
||||
(async () => {
|
||||
console.log("Testing Modelica UX Overhaul and Subsystem Creation...");
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
await page.goto("http://localhost:3008", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// 1. Open Bibliothèque tab
|
||||
console.log("Opening Bibliothèque tab...");
|
||||
const bibTab = page.locator("button").filter({ hasText: /^Bibliothèque/ }).first();
|
||||
await bibTab.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Take screenshot 1: Library with DualCircuit (2 sous-modules)
|
||||
const screenshotPath1 = "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/modelica_workspace_and_dualcircuit_count.png";
|
||||
await page.screenshot({ path: screenshotPath1 });
|
||||
console.log(`Saved screenshot 1 to ${screenshotPath1}`);
|
||||
|
||||
// 2. Click "+ Nouveau Modèle" button in Bibliothèque
|
||||
console.log("Clicking + Nouveau Modèle...");
|
||||
await page.click("button:has-text('+ Nouveau Modèle')");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Take screenshot 2: New Model Wizard Modal
|
||||
const screenshotPath2 = "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/new_model_wizard_modal.png";
|
||||
await page.screenshot({ path: screenshotPath2 });
|
||||
console.log(`Saved screenshot 2 to ${screenshotPath2}`);
|
||||
|
||||
// 3. Fill model name "CustomChiller" and submit
|
||||
console.log("Entering CustomChiller...");
|
||||
await page.fill("input[placeholder*='CustomChillerCircuit']", "CustomChiller");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
console.log("Clicking Créer la Classe Modèle...");
|
||||
await page.click("button:has-text('Créer la Classe Modèle')");
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Take screenshot 3: Active Modelica Editor view
|
||||
const screenshotPath3 = "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/modelica_class_editor_banner.png";
|
||||
await page.screenshot({ path: screenshotPath3 });
|
||||
console.log(`Saved screenshot 3 to ${screenshotPath3}`);
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
38
apps/web/test_modes_button.js
Normal file
38
apps/web/test_modes_button.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const { chromium } = require("C:/Users/ramez/AppData/Roaming/npm/node_modules/playwright");
|
||||
|
||||
(async () => {
|
||||
console.log("Testing Dymola modes via + Poser sur canvas button...");
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
await page.goto("http://localhost:3001", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 1. Click Bibliothèque tab
|
||||
console.log("Clicking Bibliothèque tab...");
|
||||
await page.click("button:has-text('Bibliothèque')");
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// 2. Click "+ Poser sur canvas" on BaseChiller
|
||||
console.log("Clicking + Poser sur canvas on BaseChiller...");
|
||||
const addBtn = page.locator("button:has-text('+ Poser sur canvas')").first();
|
||||
await addBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Screenshot Mode 1: Dymola Icon View
|
||||
await page.screenshot({ path: "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/dymola_mode_1_icon.png" });
|
||||
|
||||
// 3. Click Mode Schéma button in node header
|
||||
const modeBtn = page.locator("button:has-text('Mode Schéma')").first();
|
||||
if (await modeBtn.isVisible()) {
|
||||
console.log("Clicking Mode Schéma button...");
|
||||
await modeBtn.click();
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
// Screenshot Mode 2: In-Place Diagram View
|
||||
await page.screenshot({ path: "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/dymola_mode_2_diagram.png" });
|
||||
|
||||
console.log("Both Dymola modes captured successfully!");
|
||||
await browser.close();
|
||||
})();
|
||||
49
apps/web/test_modes_direct.js
Normal file
49
apps/web/test_modes_direct.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const { chromium } = require("C:/Users/ramez/AppData/Roaming/npm/node_modules/playwright");
|
||||
|
||||
(async () => {
|
||||
console.log("Testing Dymola modes Playwright drag & drop...");
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
await page.goto("http://localhost:3001", { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Click Bibliothèque tab
|
||||
console.log("Clicking Bibliothèque tab...");
|
||||
await page.click("button:has-text('Bibliothèque')");
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// Drag BaseChiller onto canvas
|
||||
const card = page.locator("h4:has-text('BaseChiller')").first();
|
||||
const canvas = page.locator(".react-flow__pane").first();
|
||||
|
||||
const cBox = await card.boundingBox();
|
||||
const cvBox = await canvas.boundingBox();
|
||||
|
||||
if (cBox && cvBox) {
|
||||
console.log("Dragging BaseChiller module onto canvas...");
|
||||
await page.mouse.move(cBox.x + cBox.width / 2, cBox.y + cBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(cvBox.x + cvBox.width / 2, cvBox.y + cvBox.height / 2, { steps: 15 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
// Screenshot Mode 1: Icon View
|
||||
await page.screenshot({ path: "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/dymola_mode_1_icon.png" });
|
||||
|
||||
// Click Mode Schéma button inside node header
|
||||
const modeBtn = page.locator("button:has-text('Mode Schéma')").first();
|
||||
if (await modeBtn.isVisible()) {
|
||||
console.log("Clicking Mode Schéma button...");
|
||||
await modeBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// Screenshot Mode 2: Internal Diagram View
|
||||
await page.screenshot({ path: "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/dymola_mode_2_diagram.png" });
|
||||
|
||||
console.log("Screenshots captured!");
|
||||
await browser.close();
|
||||
})();
|
||||
51
apps/web/test_modes_event.js
Normal file
51
apps/web/test_modes_event.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const { chromium } = require("C:/Users/ramez/AppData/Roaming/npm/node_modules/playwright");
|
||||
|
||||
(async () => {
|
||||
console.log("Simulating HTML5 drag & drop event with relative canvas bounds...");
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
await page.goto("http://localhost:3001", { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Dispatch drop event on React Flow pane
|
||||
await page.evaluate(() => {
|
||||
const canvasPane = document.querySelector(".react-flow__pane");
|
||||
if (!canvasPane) return;
|
||||
|
||||
const rect = canvasPane.getBoundingClientRect();
|
||||
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.setData("application/entropyk-type", "ModuleInstance:BaseChiller");
|
||||
dataTransfer.setData("application/entropyk-ports", JSON.stringify(["chw_in", "chw_out", "cond_water_in", "cond_water_out"]));
|
||||
|
||||
const dropEvent = new DragEvent("drop", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: rect.left + 300,
|
||||
clientY: rect.top + 200,
|
||||
dataTransfer,
|
||||
});
|
||||
|
||||
canvasPane.dispatchEvent(dropEvent);
|
||||
});
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Screenshot Mode 1: Dymola Icon View
|
||||
await page.screenshot({ path: "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/dymola_mode_1_icon.png" });
|
||||
|
||||
// Click Mode Schéma button in header
|
||||
const schemaBtn = page.locator("button:has-text('Mode Schéma')").first();
|
||||
if (await schemaBtn.isVisible()) {
|
||||
console.log("Clicking Mode Schéma button...");
|
||||
await schemaBtn.click();
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
// Screenshot Mode 2: In-Place Diagram View
|
||||
await page.screenshot({ path: "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/dymola_mode_2_diagram.png" });
|
||||
|
||||
console.log("Both Dymola modes captured successfully!");
|
||||
await browser.close();
|
||||
})();
|
||||
42
apps/web/test_modes_simple.js
Normal file
42
apps/web/test_modes_simple.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const { chromium } = require("C:/Users/ramez/AppData/Roaming/npm/node_modules/playwright");
|
||||
|
||||
(async () => {
|
||||
console.log("Starting simple Dymola test script on port 3008...");
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
page.on('console', msg => console.log('BROWSER LOG:', msg.text()));
|
||||
|
||||
await page.goto("http://localhost:3008", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
console.log("Clicking Bibliothèque tab...");
|
||||
await page.click("button:has-text('Bibliothèque')");
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
console.log("Looking for Poser sur canvas button...");
|
||||
const btn = page.locator("button:has-text('Poser sur canvas')").first();
|
||||
await btn.waitFor({ state: "visible", timeout: 10000 });
|
||||
await btn.click();
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Take screenshot Mode 1: Dymola Icon Mode
|
||||
const pathMode1 = "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/dymola_mode_1_icon.png";
|
||||
await page.screenshot({ path: pathMode1 });
|
||||
console.log(`Saved Mode 1 screenshot to ${pathMode1}`);
|
||||
|
||||
// Click Mode Schéma toggle button on the node
|
||||
console.log("Looking for Mode Schéma button on node...");
|
||||
const schemaBtn = page.locator("button:has-text('Mode Schéma')").first();
|
||||
if (await schemaBtn.isVisible()) {
|
||||
await schemaBtn.click();
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
|
||||
// Take screenshot Mode 2: In-Place Internal Diagram Mode
|
||||
const pathMode2 = "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/dymola_mode_2_diagram.png";
|
||||
await page.screenshot({ path: pathMode2 });
|
||||
console.log(`Saved Mode 2 screenshot to ${pathMode2}`);
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
26
apps/web/test_module_editor.js
Normal file
26
apps/web/test_module_editor.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const { chromium } = require("C:/Users/ramez/AppData/Roaming/npm/node_modules/playwright");
|
||||
|
||||
(async () => {
|
||||
console.log("Launching browser for Module Editor test...");
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
await page.goto("http://localhost:3005", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 1. Click on Bibliothèque tab
|
||||
console.log("Clicking Bibliothèque tab...");
|
||||
await page.click("button:has-text('Bibliothèque')");
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// 2. Click on "Éditer" for BaseChiller
|
||||
console.log("Clicking Éditer on BaseChiller...");
|
||||
const editBtn = page.locator("button:has-text('Éditer')").first();
|
||||
await editBtn.click();
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
const screenshotPath = "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/3_module_editor_view.png";
|
||||
await page.screenshot({ path: screenshotPath });
|
||||
console.log(`Module editor screenshot saved to ${screenshotPath}`);
|
||||
await browser.close();
|
||||
})();
|
||||
25
apps/web/test_module_rendering.js
Normal file
25
apps/web/test_module_rendering.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const { chromium } = require("C:/Users/ramez/AppData/Roaming/npm/node_modules/playwright");
|
||||
|
||||
(async () => {
|
||||
console.log("Launching browser for Module UI test...");
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
await page.goto("http://localhost:3005", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 1. Click on Bibliothèque tab
|
||||
console.log("Clicking Bibliothèque tab...");
|
||||
await page.click("button:has-text('Bibliothèque')");
|
||||
await page.waitForTimeout(800);
|
||||
await page.screenshot({ path: "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/1_library_tab.png" });
|
||||
|
||||
// 2. Click on "Save as Module" button in Toolbar
|
||||
console.log("Clicking Save as Module button...");
|
||||
await page.click("button:has-text('Save as Module')");
|
||||
await page.waitForTimeout(600);
|
||||
await page.screenshot({ path: "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/2_save_module_modal.png" });
|
||||
|
||||
console.log("Screenshots captured successfully.");
|
||||
await browser.close();
|
||||
})();
|
||||
48
apps/web/test_save_flow.js
Normal file
48
apps/web/test_save_flow.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const { chromium } = require("C:/Users/ramez/AppData/Roaming/npm/node_modules/playwright");
|
||||
|
||||
(async () => {
|
||||
console.log("Testing Save as Module flow with modal close wait...");
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
await page.goto("http://localhost:3008", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const bibTab = page.locator("button").filter({ hasText: /^Bibliothèque$/ }).first();
|
||||
|
||||
// 1. Open Bibliothèque tab
|
||||
console.log("Opening Bibliothèque tab...");
|
||||
await bibTab.click();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// 2. Click + Poser sur canvas on BaseChiller
|
||||
console.log("Clicking + Poser sur canvas on BaseChiller...");
|
||||
await page.click("button:has-text('+ Poser sur canvas')");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 3. Click Save as Module button in toolbar
|
||||
console.log("Clicking Save as Module button...");
|
||||
await page.click("button:has-text('Save as Module')");
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// 4. Fill module name: CustomChiller
|
||||
console.log("Entering module name CustomChiller...");
|
||||
await page.fill("input[placeholder*='DualChillerCircuit']", "CustomChiller");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 5. Click "Créer le Module" and wait for modal to close
|
||||
console.log("Clicking Créer le Module...");
|
||||
await page.click("button:has-text('Créer le Module')");
|
||||
await page.waitForTimeout(1000); // Wait for modal 300ms fadeout & close
|
||||
|
||||
// 6. Verify CustomChiller in Bibliothèque list
|
||||
console.log("Opening Bibliothèque tab...");
|
||||
await bibTab.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const screenshotPath = "C:/Users/ramez/.gemini/antigravity-ide/brain/e16f5552-47ce-4483-9b25-8266bb395491/custom_chiller_saved.png";
|
||||
await page.screenshot({ path: screenshotPath });
|
||||
console.log(`Saved screenshot to ${screenshotPath}`);
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
60
apps/web/tmp-screenshot.js
Normal file
60
apps/web/tmp-screenshot.js
Normal file
@@ -0,0 +1,60 @@
|
||||
const { chromium } = require("C:/Users/ramez/AppData/Roaming/npm/node_modules/playwright");
|
||||
const path = require("path");
|
||||
const outDir = process.argv[2] || process.cwd();
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
|
||||
|
||||
const snap = async (name) => {
|
||||
const p = path.join(outDir, name);
|
||||
await page.screenshot({ path: p, fullPage: false });
|
||||
console.log("saved", p);
|
||||
};
|
||||
|
||||
try {
|
||||
await page.goto("http://localhost:3006", { waitUntil: "networkidle", timeout: 20000 });
|
||||
await page.waitForTimeout(2500);
|
||||
await snap("01-home-empty.png");
|
||||
|
||||
await page.getByRole("button", { name: "Bibliothèque" }).first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
await snap("02-library-tab.png");
|
||||
|
||||
// Find example select near "Load" button
|
||||
const select = page.locator("select").filter({ hasText: /Chiller/ }).first();
|
||||
if (await select.count() > 0) {
|
||||
await select.selectOption("chiller_flooded_4port_watercooled.json");
|
||||
await page.waitForTimeout(2500);
|
||||
await snap("03-example-loaded.png");
|
||||
} else {
|
||||
console.log("example select not found");
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: /Solve/i }).first().click();
|
||||
await page.waitForTimeout(3000);
|
||||
await snap("04-after-solve.png");
|
||||
|
||||
// Click an evaporator node label
|
||||
try {
|
||||
const evap = page.locator("text=evap_1").first();
|
||||
await evap.click({ timeout: 3000 });
|
||||
await page.waitForTimeout(600);
|
||||
await snap("05-properties-panel.png");
|
||||
} catch (e) { console.log("evap label click failed:", e.message); }
|
||||
|
||||
await page.getByRole("button", { name: /Guide/i }).first().click();
|
||||
await page.waitForTimeout(800);
|
||||
await snap("06-guide-modal.png");
|
||||
|
||||
// Click + Nouveau Modèle in library panel
|
||||
await page.getByRole("button", { name: /Nouveau Modèle/i }).first().click();
|
||||
await page.waitForTimeout(800);
|
||||
await snap("07-new-model-modal.png");
|
||||
} catch (e) {
|
||||
console.error("Error:", e.message);
|
||||
await snap("zz-error.png");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})();
|
||||
40
apps/web/tmp-screenshot2.js
Normal file
40
apps/web/tmp-screenshot2.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const { chromium } = require("C:/Users/ramez/AppData/Roaming/npm/node_modules/playwright");
|
||||
const path = require("path");
|
||||
const outDir = process.argv[2] || process.cwd();
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
|
||||
const snap = async (name) => {
|
||||
const p = path.join(outDir, name);
|
||||
await page.screenshot({ path: p, fullPage: false });
|
||||
console.log("saved", p);
|
||||
};
|
||||
try {
|
||||
await page.goto("http://localhost:3006", { waitUntil: "networkidle", timeout: 20000 });
|
||||
await page.waitForTimeout(2500);
|
||||
await page.selectOption("select[title*='Exemple']", "chiller_flooded_4port_watercooled.json");
|
||||
await page.waitForTimeout(2500);
|
||||
// click on canvas center to maybe select a node
|
||||
await page.click(".react-flow__pane", { position: { x: 600, y: 450 } });
|
||||
await page.waitForTimeout(500);
|
||||
await page.click(".react-flow__pane", { position: { x: 800, y: 450 } });
|
||||
await page.waitForTimeout(600);
|
||||
await snap("08-properties-panel.png");
|
||||
|
||||
// click on comp node roughly
|
||||
await page.click(".react-flow__pane", { position: { x: 500, y: 450 } });
|
||||
await page.waitForTimeout(600);
|
||||
await snap("09-properties-comp.png");
|
||||
|
||||
// Open new model modal via toolbar button
|
||||
await page.click("button:has-text('Nouveau Modèle')");
|
||||
await page.waitForTimeout(800);
|
||||
await snap("10-new-model-modal.png");
|
||||
} catch (e) {
|
||||
console.error("Error:", e.message);
|
||||
await snap("zz-error2.png");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})();
|
||||
90
apps/web/tmp_write_audit.py
Normal file
90
apps/web/tmp_write_audit.py
Normal file
@@ -0,0 +1,90 @@
|
||||
# coding: utf-8
|
||||
import os
|
||||
|
||||
content = """# Audit UX / Visuel — Entropyk Web Workbench
|
||||
|
||||
**Date :** 2026-07-18
|
||||
**Périmètre :** `apps/web/src` (Next.js 15 + React 19 + React Flow + Tailwind)
|
||||
**Méthode :** lecture intégrale du code source, capture d'écran locale, heuristiques Nielsen + spécifiques outils CAO/simulation.
|
||||
**Langue du rapport :** français (le destinataire est Sepehr).
|
||||
|
||||
---
|
||||
|
||||
## 1. Vue d'ensemble du produit
|
||||
|
||||
Entropyk Web est un éditeur de schémas thermodynamiques couplé à un solveur stationnaire Rust. Il vise le même usage que les workbenches Modelica (Dymola, OpenModelica, Wolfram System Modeler) ou les outils HVAC spécialisés : construire un cycle frigorifique / pompe à chaleur, paramétrer les composants, résoudre et inspecter les résultats. L'application est structurée en trois surfaces principales : une palette de composants à gauche, un canvas React Flow au centre, un panneau de propriétés / résultats à droite, et une barre d'outils en haut. Le positionnement technique est solide (IR ScenarioConfig partagé avec le CLI, backend Rust via l'API, modèles de composants riches), mais l'interface visuelle donne actuellement l'impression d'un prototype avancé : beaucoup de fonctionnalités, peu de cohérence graphique, et des signaux visuels qui ne correspondent pas toujours à la sémantique d'un outil d'ingénierie.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture de l'information actuelle
|
||||
|
||||
La disposition globale est celle d'un IDE classique :
|
||||
|
||||
- **Top bar** (`Toolbar.tsx:159-422`) : choix du fluide, backend, solver, contrôles d'orientation, import/export, exemples, solve.
|
||||
- **Left sidebar** (`ComponentPalette.tsx:28-118`) : onglets Composants / Bibliothèque, liste pliée par catégories.
|
||||
- **Center canvas** (`Canvas.tsx:259-335`) : grille React Flow, nœuds, arêtes, légende des milieux, barre d'orientation flottante.
|
||||
- **Right panel** (`page.tsx:262-271`) : pile verticale `PropertiesPanel` + `ResultsPanel` (w-96).
|
||||
- **Bottom bar** (`DofStatusBar.tsx:81-228`) : indicateur DoF (degrés de liberté) repliable.
|
||||
|
||||
Cette architecture est fonctionnelle et assez proche de Dymola / OMEdit : palette + canvas + panneau de paramètres + barre de statut. Le problème n'est donc pas la structure, mais la façon dont chaque surface est habillée et articulée.
|
||||
|
||||
---
|
||||
|
||||
## 3. Inventaire des écrans / composants clés
|
||||
|
||||
| Écran / Composant | Fichier | Rôle | Observations initiales |
|
||||
|---|---|---|---|
|
||||
| Page principale | `app/page.tsx:18-285` | Layout global, module editor, empty hint | Langue mêlée FR/EN dans le hint (Start your cycle + texte français). |
|
||||
| Toolbar | `components/Toolbar.tsx` | Barre d'action principale | Badges verts/oranges, emojis, styles inlines. |
|
||||
| Palette atomique | `components/palette/ComponentPalette.tsx` | Catalogue de composants | Catégories en anglais, items textuels, noms tronqués. |
|
||||
| Bibliothèque de modules | `components/palette/ModuleLibraryPanel.tsx` | Modules .ekmod | Cartes claires mais badges et couleurs inconsistantes. |
|
||||
| Canvas | `components/canvas/Canvas.tsx` | Schéma interactif | Grille React Flow, couleurs de milieu, flottante orientation. |
|
||||
| Nœud | `components/canvas/EntropykNode.tsx` | Rendu d'un composant | Icônes SVG propres, labels en dessous, handles carrés. |
|
||||
| Icônes | `components/canvas/ComponentIcon.tsx` | Glyphes P&ID | Style technique-dessin cohérent. |
|
||||
| Propriétés | `components/panels/PropertiesPanel.tsx` | Paramètres du nœud sélectionné | Tableau à onglets, bug Modèle : Modèle, colonnes étroites. |
|
||||
| Résultats | `components/panels/ResultsPanel.tsx` | Bench, edges, solver, debug | Residual trace confus, onglets inégaux. |
|
||||
| DoF status | `components/DofStatusBar.tsx` | Indicateur équations/inconnues | Bonne idée, couleurs décalées. |
|
||||
| Modales | `modals/NewModelModal.tsx`, `WorkspaceHelpModal.tsx`, `CreateModuleModal.tsx` | Assistants et aide | Styles différents : indigo/violet vs bleu accent. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Analyse heuristique
|
||||
|
||||
### 4.1. Visibilité du statut système (solve, DoF, erreurs)
|
||||
|
||||
- **Points positifs :** la barre DoF est présente en permanence avec un code couleur et une explication 19 eqs = 19 unk (`DofStatusBar.tsx:103-114`). L'état de solve est visible dans le bench (`ResultsPanel.tsx:176-198`).
|
||||
- **Points faibles :**
|
||||
- Le statut de solve utilise un indicateur graphique à barres verticales (`ResidualTrace`, `ResultsPanel.tsx:663-685`) qui ne ressemble à aucun pattern conventionnel et ne communique pas clairement la convergence.
|
||||
- Les erreurs sont affichées dans un bloc rouge en bas du panneau résultats, mais l'utilisateur n'est pas guidé vers le composant en cause.
|
||||
- Le badge WORKSPACE SCÉNARIO vert dans la toolbar est redondant avec le titre de la fenêtre et occupe de la place précieuse.
|
||||
|
||||
### 4.2. Cohérence visuelle (typo, couleurs, espacements, bordures, arrondis)
|
||||
|
||||
C'est le domaine le plus fragilisé. Le système de variables CSS dans `globals.css:11-48` est bien pensé (`--chrome`, `--panel`, `--ink`, `--accent`, `--line`), mais il est constamment contourné par des utilitaires Tailwind directs :
|
||||
|
||||
- `emerald-500`, `amber-500`, `indigo-600`, `slate-950`, `blue-500` apparaissent dans `Toolbar.tsx`, `NewModelModal.tsx`, `WorkspaceHelpModal.tsx`, `EntropykNode.tsx`.
|
||||
- Les arrondis varient : `rounded-[2px]` dans la palette, `rounded-lg` dans les modales, `rounded-[4px]` dans la toolbar, `rounded-md` dans ResultsPanel.
|
||||
- Les bordures alternent `var(--line)` (gris clair) et `slate-200`/`slate-300` (gris froid).
|
||||
- Les tailles de police sont très fines : 8 px, 9 px, 9.5 px, 10 px, 10.5 px, 11 px. À 8 px, certains labels de port (`portLabel`) et badges deviennent difficiles à lire sur un écran standard.
|
||||
- Les ombres sont soit absentes, soit très présentes (`shadow-2xl` dans les modales), sans hiérarchie claire.
|
||||
|
||||
### 4.3. Mapping mental Modelica (workspace, classes, modèles, instances, ports, icônes vs schéma)
|
||||
|
||||
- **Points positifs :** l'application s'efforce explicitement de mapper les concepts Modelica : `NewModelModal` est intitulé Assistant Nouveau Modèle / Classe Modelica (`NewModelModal.tsx:99`), le module node a un mode icône / mode schéma (`EntropykNode.tsx:323-338`), les ports sont des handles carrés comme en Modelica, les paramètres sont classés en onglets General/Model/Calibration (`PropertiesPanel.tsx:53-90`).
|
||||
- **Points faibles :**
|
||||
- Le passage Workspace ↔ Édition de classe est signalé par un badge vert/orange dans la toolbar, mais il n'y a pas d'arborescence de classes, ni d'onglets de navigation, ni d'indicateur de profondeur (breadcrumbs). Un utilisateur Dymola s'attend à un package browser.
|
||||
- L'icône du module est un SVG hardcodé `ChillerIconSchematic` (`EntropykNode.tsx:457-471`) qui représente un chiller générique, quel que soit le module. Cela rompt le modèle mental icône personnalisée par classe.
|
||||
- Les instances de modules n'ont pas de distinction visuelle claire avec les composants atomiques, hormis la bannière interne.
|
||||
|
||||
### 4.4. Liberté et contrôle de l'utilisateur (undo, navigation, édition de modules)
|
||||
|
||||
- **Undo/redo :** absent du store. L'utilisateur ne peut pas annuler un déplacement, une suppression ou une modification de paramètre. C'est un gap majeur pour un éditeur de schéma.
|
||||
- **Module editor :** le backup du workspace est en mémoire (`page.tsx:27-30`), sans historique. La sauvegarde se fait via `alert` (`page.tsx:219`), ce qui est abrupt.
|
||||
- **Navigation :** pas de breadcrumbs, pas d'onglets multiples, pas de vue d'ensemble du projet.
|
||||
|
||||
### 4.5. Prévention des erreurs (validation, feedback)
|
||||
|
||||
- **Points positifs :** `validateConfig` (`lib/configBuilder.ts:638-723`) vérifie les paramètres requis, les doublons de noms, les ports secondaires incomplets. Le DoF coach donne des conseils actionnables (`lib/dofCoach.ts`).
|
||||
- **Points faibles :**
|
||||
- Les erreurs sont des listes de texte dans `issues` de la toolbar (`Toolbar.tsx:284-291`) et un `ErrorCard` dans ResultsPanel. Elles ne sont pas reliées aux nœuds concernés (pas de highlight, pas de badge sur le composant).
|
||||
- Certains warnings DoF sont difficiles à comprendre pour un utilisateur non expert (Modelica MassFlowSource_T : décoche
|
||||
1
apps/web/tsconfig.tsbuildinfo
Normal file
1
apps/web/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user