chore: remove BMAD framework files and IDE configuration artifacts
Clean up unused BMAD workflow, agent, and command files across all IDE configurations (.agent, .clinerules, .cursor, .gemini, .github, .kilocode, .opencode) and internal module files (_bmad/bmb, _bmad/bmm). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
79
tools/circuit-builder-ui/README.md
Normal file
79
tools/circuit-builder-ui/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Entropyk Circuit Builder UI
|
||||
|
||||
Outil web minimal pour créer des configurations de circuits HVAC et les tester avec le CLI Entropyk (validate + run).
|
||||
|
||||
## Prérequis
|
||||
|
||||
- Python 3.6+
|
||||
- Entropyk compilé en release : `cargo build --release -p entropyk-cli`
|
||||
|
||||
## Lancement
|
||||
|
||||
Depuis **la racine du dépôt** Entropyk :
|
||||
|
||||
```bash
|
||||
cd /chemin/vers/Entropyk
|
||||
python3 tools/circuit-builder-ui/server.py
|
||||
```
|
||||
|
||||
Puis ouvre un navigateur sur : **http://localhost:8765**
|
||||
|
||||
Le serveur sert l’UI et exécute le CLI pour les actions « Valider » et « Lancer simulation ». Le binaire attendu est `target/release/entropyk-cli`.
|
||||
|
||||
## Utilisation
|
||||
|
||||
1. **Scénario** : nom, fluide (ex. R410A), backend (Test ou CoolProp).
|
||||
2. **Circuits** : ajouter un ou plusieurs circuits ; pour chaque circuit :
|
||||
- **Composants** : type (Placeholder, Compressor, Condenser, Evaporator, etc.), nom, paramètres.
|
||||
- **Arêtes** : `from` / `to` au format `composant:port` (ex. `comp:outlet` → `cond:inlet`).
|
||||
3. **Solver** : stratégie (fallback, newton, picard), max itérations, tolérance.
|
||||
4. **Couplages thermiques** (optionnel) : circuit chaud/froid, UA, efficacité.
|
||||
5. **Charger un exemple** : simple_working.json ou chiller_r410a_minimal.json.
|
||||
6. **Télécharger JSON** : exporte la config pour usage en ligne de commande.
|
||||
7. **Valider (CLI)** : envoie la config au serveur qui lance `entropyk-cli validate`.
|
||||
8. **Lancer simulation (CLI)** : lance `entropyk-cli run` et affiche le résultat.
|
||||
|
||||
Tous les types de composants supportés par le CLI peuvent être testés depuis l’UI (Placeholder, Compressor, Condenser, Evaporator, ExpansionValve, HeatExchanger, Pump, etc.). La palette inclut aussi **sources/puits** (Source eau, Puits eau, Source réfrig., Puits réfrig.) et **Splitter/Merger** ; exportés en Placeholder pour le CLI. Pour conditions aux limites réelles : API Rust (BrineSource, RefrigerantSource…), voir `docs/migration/boundary-conditions.md`.
|
||||
|
||||
---
|
||||
|
||||
## Application UI complète plus tard : quelle option technique ?
|
||||
|
||||
Tu prévois une vraie application UI à part entière. Voici les options et une recommandation.
|
||||
|
||||
### 1. **Application web (front + API)**
|
||||
|
||||
- **Stack typique** : React, Vue ou Svelte + backend (Rust actix/axum, ou Node, ou Python FastAPI).
|
||||
- **Avantages** : un seul front déployable partout (cloud, on-premise), mises à jour centralisées, pas d’installateur.
|
||||
- **Inconvénients** : les calculs doivent tourner côté serveur (ou via WebAssembly si tu portes le moteur un jour). Il faut exposer une API (ex. POST /run avec la config JSON) qui appelle le CLI ou la lib Entropyk.
|
||||
- **Bonne option si** : tu veux un outil accessible via navigateur, multi-utilisateurs ou hébergé.
|
||||
|
||||
### 2. **Desktop avec Tauri (Rust + web view)**
|
||||
|
||||
- **Stack** : Tauri 2 + front en HTML/JS ou framework (React, Vue, etc.) ; la logique métier et les appels au moteur sont en Rust.
|
||||
- **Avantages** : réutilisation directe d’Entropyk comme crate Rust (pas de sous-process CLI), binaire léger, pas de serveur, offline, performances maximales.
|
||||
- **Inconvénients** : c’est une app desktop (packaging Windows/macOS/Linux), un peu plus de mise en place au début.
|
||||
- **Bonne option si** : tu veux une application native installable, un seul binaire, et garder tout le calcul dans le même processus Rust. **C’est souvent le meilleur choix pour un outil HVAC pro à long terme.**
|
||||
|
||||
### 3. **Desktop avec Electron**
|
||||
|
||||
- **Stack** : app Node + moteur de rendu Chromium, front web (React, etc.) ; le CLI peut être lancé en sous-process depuis Node.
|
||||
- **Avantages** : même stack qu’en web, écosystème npm riche.
|
||||
- **Inconvénients** : gros binaire, consommation mémoire, il faut quand même appeler le CLI (ou un binaire natif) pour les calculs.
|
||||
- **Bonne option si** : tu as déjà une équipe 100 % web et tu veux du desktop sans toucher au Rust en front.
|
||||
|
||||
### 4. **Hybride : web UI + petit service local**
|
||||
|
||||
- **Principe** : une SPA (React/Vue) qui parle à un petit service local (Rust ou Python) qui exécute le CLI ou utilise la lib Entropyk.
|
||||
- **Avantages** : UI 100 % web, calculs locaux, pas d’hébergement serveur obligatoire.
|
||||
- **Inconvénients** : il faut installer et lancer le service local en plus du navigateur.
|
||||
- **Bonne option si** : tu veux une UI web mais que les calculs restent sur la machine de l’utilisateur.
|
||||
|
||||
---
|
||||
|
||||
### Recommandation
|
||||
|
||||
- **Pour un outil interne / démo / prototypage rapide** : garder cette UI (Circuit Builder) + serveur Python tel quel, ou remplacer le serveur par un petit backend Rust (axum) qui appelle la lib Entropyk.
|
||||
- **Pour une application produit complète (HVAC, multi-circuits, résultats structurés)** : viser **Tauri 2 + front web (React/Vue/Svelte)**. Tu réutilises Entropyk en Rust, une seule codebase UI, et tu distribues un installateur par plateforme. C’est la solution la plus propre et la plus pérenne pour une app desktop basée sur Entropyk.
|
||||
|
||||
Si tu indiques si tu vises plutôt **desktop** ou **web hébergé**, on peut détailler l’architecture (Tauri vs web API) et les prochaines étapes concrètes.
|
||||
93
tools/circuit-builder-ui/REFERENCE.md
Normal file
93
tools/circuit-builder-ui/REFERENCE.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Circuit Builder UI — Référence (basée sur le code, sans supposition)
|
||||
|
||||
Ce document décrit **ce qui existe réellement** dans le CLI, la lib et l’UI. Aucune supposition : uniquement ce qui est implémenté et utilisé.
|
||||
|
||||
---
|
||||
|
||||
## 1. Types de composants que le CLI crée (`run.rs` → `create_component`)
|
||||
|
||||
Le CLI instancie **uniquement** les types suivants. Tout autre type dans le JSON provoque une erreur « Unknown component type ».
|
||||
|
||||
| Type dans le JSON | Création réelle |
|
||||
|-------------------|------------------|
|
||||
| `ScrewEconomizerCompressor`, `ScrewCompressor` | Compresseur vis avec courbes, économiseur |
|
||||
| `MchxCondenserCoil`, `MchxCoil` | Condenseur à air (UA, T_air, fan_speed, n_air) |
|
||||
| `FloodedEvaporator` | Évaporateur noyé (UA, target_quality, refrigerant, secondary_fluid) |
|
||||
| `Condenser`, `CondenserCoil` | Condenseur simple (UA, optionnel t_sat_k) |
|
||||
| `Evaporator`, `EvaporatorCoil` | Évaporateur simple (UA, optionnel t_sat_k, superheat_k) |
|
||||
| `HeatExchanger` | Échangeur LMTD avec conditions hot/cold (hot_fluid, hot_t_inlet_c, hot_pressure_bar, hot_mass_flow_kg_s, cold_*) |
|
||||
| `Compressor` | Compresseur AHRI 540 (speed_rpm, displacement_m3, efficiency, fluid, m1–m10) |
|
||||
| `ExpansionValve` | Détendeur (fluid, opening) |
|
||||
| `Pump` | Pompe (name) — équations 0, composant pass-through |
|
||||
| `Placeholder` | Composant générique (n_equations) pour compléter la topologie |
|
||||
|
||||
**Ce que le CLI ne crée pas** (même si la lib les a) :
|
||||
`AirSource`, `AirSink`, `BrineSource`, `BrineSink`, `WaterSource`, `WaterSink`, `Fan`.
|
||||
Ils n’apparaissent nulle part dans le `match component_type` de `run.rs`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Modèle de configuration (JSON)
|
||||
|
||||
- **Un scénario** : `name`, `fluid`, `circuits[]`, optionnel `thermal_couplings[]`, `solver`.
|
||||
- **Un circuit** : `id`, `components[]`, `edges[]`. Chaque circuit est un graphe de composants (un fluide par circuit dans l’usage actuel).
|
||||
- **Une arête** : `from: "nom:port"`, `to: "nom:port"`. Ports par défaut : `inlet` (0), `outlet` (1). Pour le screw : `suction`, `discharge`, `economizer`.
|
||||
- **Couplage thermique** : `hot_circuit`, `cold_circuit`, `ua`, `efficiency`. Lie deux circuits (ex. réfrigérant → eau).
|
||||
|
||||
Exemple réel à 2 circuits : `crates/cli/examples/heat_pump_r410a.json` (circuit 0 = réfrigérant, circuit 1 = eau, 1 thermal_coupling).
|
||||
|
||||
---
|
||||
|
||||
## 3. Ce que l’UI fait aujourd’hui (code dans `index.html`)
|
||||
|
||||
### Export (`buildConfig()`)
|
||||
|
||||
- **Un seul circuit** : `circuits: [circuit]` avec `id: 0`. Pas de circuit 1, pas de `thermal_couplings`.
|
||||
- **Mapping des types UI → JSON** :
|
||||
- `WaterSource`, `WaterSink`, `AirSource`, `AirSink`, `RefrigerantSource`, `RefrigerantSink`, `FlowSplitter`, `FlowMerger` → **toujours** `type: "Placeholder"` + `n_equations: 2` (et autres params conservés). C’est obligatoire car le CLI ne connaît pas ces types.
|
||||
- `CondenserWater` → `type: "HeatExchanger"` avec hot = fluide projet (réfrigérant), cold = Water + conditions secondaires.
|
||||
- `CondenserAir` → `type: "MchxCondenserCoil"` avec `ua_nominal_kw_k`, `air_inlet_temp_c`, `fan_speed`, `n_air_exponent`, `coil_index: 0`.
|
||||
- `EvaporatorWater`, `EvaporatorAir` → `type: "HeatExchanger"` avec hot/cold cohérents (secondaire / réfrigérant).
|
||||
- `Compressor`, `ExpansionValve`, `HeatExchanger`, `Pump` → type inchangé + params.
|
||||
- **Arêtes** : `from: "nom:outlet"`, `to: "nom:inlet"` (noms dérivés des nœuds).
|
||||
|
||||
### Limites actuelles de l’UI
|
||||
|
||||
1. **Un seul circuit exporté** : pas de deuxième circuit (ex. eau), pas de `thermal_couplings`. Donc on ne peut pas produire un JSON du type `heat_pump_r410a.json` depuis l’UI.
|
||||
2. **Sources/puits** : Source eau, Puits eau, Source air, Puits air, etc. sont exportés en **Placeholder**. Le solver les traite comme des blocs à `n_equations` sans physique (pas de vraie source air/eau). Les placer sur le schéma permet de représenter visuellement un circuit, mais ce circuit n’est pas simulé comme « circuit air » ou « circuit eau ».
|
||||
3. **Ventilateur** : il n’existe pas comme composant séparé dans le CLI. Le condenseur à air est modélisé par **MchxCondenserCoil** (T_air entrée + vitesse ventilateur en paramètres). L’UI reflète ça en exportant un seul nœud « Condenseur à air » en `MchxCondenserCoil`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Lib vs CLI (composants)
|
||||
|
||||
| Composant | Dans la lib (crates/components) | Créé par le CLI |
|
||||
|-----------|--------------------------------|------------------|
|
||||
| AirSource, AirSink | Oui (`air_boundary.rs`) | Non |
|
||||
| BrineSource, BrineSink | Oui (`brine_boundary.rs`) | Non |
|
||||
| RefrigerantSource, RefrigerantSink | (existent ou équivalent) | Non (utiliser Placeholder) |
|
||||
| Fan | Oui (`fan.rs`) | Non |
|
||||
| Pump | Oui | Oui (composant pass-through, 0 équations) |
|
||||
| HeatExchanger | Oui | Oui (avec hot/cold conditions) |
|
||||
| MchxCondenserCoil | Oui | Oui |
|
||||
| Condenser / Evaporator (simples) | Oui | Oui |
|
||||
|
||||
Donc : **l’outil UI est cohérent avec le CLI** pour tout ce qui est exporté en Compressor, Condenser*, Evaporator*, HeatExchanger, MchxCondenserCoil, ExpansionValve, Pump, Placeholder. Les « sources air/eau » et le « ventilateur » ne sont pas des approximations de l’UI : le CLI ne propose tout simplement pas ces types.
|
||||
|
||||
---
|
||||
|
||||
## 5. Vision claire de l’outil (sans approximation)
|
||||
|
||||
**Aujourd’hui, l’outil est :**
|
||||
|
||||
- Un **éditeur visuel pour un seul circuit** (en pratique le circuit réfrigérant).
|
||||
- Les nœuds « Condenseur à eau/air », « Évaporateur à eau/air » sont convertis en **HeatExchanger** ou **MchxCondenserCoil** avec les bons côtés (réfrigérant / eau ou air) et conditions aux limites. C’est aligné avec le CLI.
|
||||
- Les nœuds « Source air », « Puits air », « Source eau », etc. sont des **Placeholders** : ils servent à dessiner un schéma et à garder la topologie (nombre d’arêtes, n_equations), mais le fluide « air » ou « eau » n’est pas simulé comme tel par le CLI.
|
||||
|
||||
**Pour aller vers une machine complète (ex. PAC air-eau) sans approximation :**
|
||||
|
||||
1. **Multi-circuit dans l’UI** : pouvoir définir au moins 2 circuits (ex. 0 = réfrigérant, 1 = eau) et des arêtes par circuit.
|
||||
2. **Export des thermal_couplings** : quand deux circuits existent, exporter un bloc `thermal_couplings` (hot_circuit, cold_circuit, ua, efficiency).
|
||||
3. **Support CLI pour sources/puits** (optionnel) : si un jour le CLI crée `AirSource`/`AirSink` ou `BrineSource`/`BrineSink`, l’UI pourra alors exporter ces types au lieu de Placeholder pour les circuits air/eau.
|
||||
|
||||
Ce document peut servir de base pour implémenter ces évolutions ou pour expliquer précisément le comportement actuel à un ingénieur HVAC.
|
||||
640
tools/circuit-builder-ui/index.html
Normal file
640
tools/circuit-builder-ui/index.html
Normal file
@@ -0,0 +1,640 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Entropyk — Éditeur visuel de machine</title>
|
||||
<style>
|
||||
:root { --bg: #0f1419; --surface: #1a1f26; --card: #252d38; --accent: #58a6ff; --green: #3fb950; --orange: #d29922; --red: #f85149; --text: #e6edf3; --muted: #8b949e; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.topbar { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1rem; background: var(--surface); border-bottom: 1px solid #30363d; flex-shrink: 0; }
|
||||
.topbar h1 { font-size: 1rem; font-weight: 600; margin: 0; color: var(--accent); }
|
||||
.topbar input, .topbar select { padding: 0.35rem 0.6rem; border: 1px solid #30363d; border-radius: 6px; background: var(--card); color: var(--text); }
|
||||
.topbar .spacer { flex: 1; }
|
||||
.topbar button { padding: 0.4rem 0.8rem; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem; }
|
||||
.topbar button.primary { background: var(--accent); color: #fff; }
|
||||
.topbar button.secondary { background: var(--card); color: var(--text); border: 1px solid #30363d; }
|
||||
.topbar button:hover { filter: brightness(1.1); }
|
||||
.main { display: flex; flex: 1; min-height: 0; }
|
||||
.palette { width: 200px; background: var(--surface); border-right: 1px solid #30363d; padding: 0.75rem; overflow-y: auto; flex-shrink: 0; }
|
||||
.palette h3 { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); margin: 0 0 0.5rem; }
|
||||
.palette-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.6rem; margin-bottom: 0.25rem; background: var(--card); border-radius: 8px; cursor: grab; border: 2px solid transparent; font-size: 0.875rem; }
|
||||
.palette-item:hover { border-color: var(--accent); }
|
||||
.palette-item .icon { width: 28px; height: 28px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 0.75rem; }
|
||||
.palette-item.Compressor .icon { background: #23863633; color: var(--green); }
|
||||
.palette-item.Condenser .icon, .palette-item.CondenserWater .icon, .palette-item.CondenserAir .icon { background: #1f6feb33; color: var(--accent); }
|
||||
.palette-item.Evaporator .icon, .palette-item.EvaporatorWater .icon, .palette-item.EvaporatorAir .icon { background: #8957e533; color: #a371f7; }
|
||||
.palette-item.ExpansionValve .icon { background: #d2992233; color: var(--orange); }
|
||||
.palette-item.Placeholder .icon { background: #6e768133; color: var(--muted); }
|
||||
.palette-item.Pump .icon { background: #388bfd33; color: #58a6ff; }
|
||||
.palette-item.HeatExchanger .icon { background: #da363633; color: var(--red); }
|
||||
.palette-item.WaterSource .icon { background: #23863633; color: var(--green); }
|
||||
.palette-item.WaterSink .icon { background: #da363633; color: var(--red); }
|
||||
.palette-item.AirSource .icon { background: #58a6ff33; color: #79c0ff; }
|
||||
.palette-item.AirSink .icon { background: #6e768133; color: var(--muted); }
|
||||
.palette-item.RefrigerantSource .icon { background: #1f6feb33; color: var(--accent); }
|
||||
.palette-item.RefrigerantSink .icon { background: #8957e533; color: #a371f7; }
|
||||
.palette-item.FlowSplitter .icon { background: #d2992233; color: var(--orange); }
|
||||
.palette-item.FlowMerger .icon { background: #d2992233; color: var(--orange); }
|
||||
.canvas-wrap { flex: 1; position: relative; overflow: hidden; background: #0d1117; }
|
||||
#canvas { display: block; width: 100%; height: 100%; cursor: default; }
|
||||
.canvas-wrap.mode-add #canvas { cursor: crosshair; }
|
||||
.canvas-wrap.mode-connect #canvas { cursor: pointer; }
|
||||
.props { width: 280px; background: var(--surface); border-left: 1px solid #30363d; padding: 0.75rem; overflow-y: auto; flex-shrink: 0; }
|
||||
.props h3 { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); margin: 0 0 0.5rem; }
|
||||
.props label { display: block; margin-top: 0.5rem; font-size: 0.8rem; color: var(--muted); }
|
||||
.props input, .props select { width: 100%; padding: 0.4rem; border: 1px solid #30363d; border-radius: 6px; background: var(--card); color: var(--text); margin-top: 0.2rem; }
|
||||
.props .empty { font-size: 0.875rem; color: var(--muted); margin-top: 1rem; }
|
||||
.props button.danger { background: var(--red); color: #fff; border: none; padding: 0.4rem 0.8rem; border-radius: 6px; cursor: pointer; margin-top: 0.75rem; font-size: 0.8rem; }
|
||||
.result { padding: 0.5rem; margin-top: 0.5rem; border-radius: 6px; font-size: 0.8rem; white-space: pre-wrap; max-height: 120px; overflow: auto; }
|
||||
.result.ok { background: #23863622; border: 1px solid var(--green); }
|
||||
.result.err { background: #f8514922; border: 1px solid var(--red); }
|
||||
.mode-btns { display: flex; gap: 0.25rem; margin-bottom: 0.5rem; }
|
||||
.mode-btns button { padding: 0.35rem 0.6rem; border: 1px solid #30363d; border-radius: 6px; background: var(--card); color: var(--text); cursor: pointer; font-size: 0.8rem; }
|
||||
.mode-btns button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<h1>Entropyk — Machine thermodynamique</h1>
|
||||
<input type="text" id="projectName" placeholder="Nom du projet" style="width:180px" />
|
||||
<label style="font-size:0.8rem;color:var(--muted)">Fluide</label>
|
||||
<select id="fluid" style="width:100px"><option value="R410A">R410A</option><option value="R134a">R134a</option><option value="R744">R744</option></select>
|
||||
<div class="spacer"></div>
|
||||
<select id="exampleSelect" style="width:200px">
|
||||
<option value="">Charger un exemple…</option>
|
||||
<option value="/examples/simple_working.json">Cycle simple (placeholders)</option>
|
||||
<option value="/examples/chiller_r410a_minimal.json">Chiller 2 circuits</option>
|
||||
</select>
|
||||
<button type="button" id="btnValidate" class="secondary">Valider</button>
|
||||
<button type="button" id="btnRun" class="primary">Lancer simulation</button>
|
||||
<button type="button" id="btnExport" class="secondary">Télécharger JSON</button>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<aside class="palette">
|
||||
<h3>Composants</h3>
|
||||
<p style="font-size:0.8rem;color:var(--muted);margin:0 0 0.5rem">Cliquez pour sélectionner, puis cliquez sur le schéma pour placer. Ou glissez-déposez.</p>
|
||||
<div class="palette-info" style="font-size:0.7rem;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:0.4rem 0.5rem;margin-bottom:0.5rem;line-height:1.35">
|
||||
<strong>Logique HVAC</strong> : Condenseur = réfrigérant (chaud) + eau/air (froid). Évaporateur = eau/air (chaud) + réfrigérant (froid). Compresseur = réfrigérant uniquement. Condenseur à air = ventilateur intégré (vitesse 0–1). Circuit air = Source air → … → Puits air (export Placeholder tant que le CLI n’a pas de circuit air dédié).
|
||||
</div>
|
||||
<div class="mode-btns">
|
||||
<button type="button" id="modeMove" class="active">Déplacer</button>
|
||||
<button type="button" id="modeAdd">Ajouter</button>
|
||||
<button type="button" id="modeConnect">Relier</button>
|
||||
</div>
|
||||
<p id="modeHint" style="font-size:0.75rem;color:var(--muted);margin:0.25rem 0 0.5rem">Déplacer : glissez les blocs.</p>
|
||||
<p id="connectStatus" style="font-size:0.8rem;font-weight:600;color:var(--accent);margin:0.25rem 0 0.5rem;min-height:1.2em;"></p>
|
||||
<h3 style="margin:0.5rem 0 0.25rem;font-size:0.7rem;color:var(--muted)">Sources & puits</h3>
|
||||
<div class="palette-item WaterSource" data-type="WaterSource" draggable="true"><span class="icon">↓S</span> Source eau</div>
|
||||
<div class="palette-item WaterSink" data-type="WaterSink" draggable="true"><span class="icon">S↑</span> Puits eau</div>
|
||||
<div class="palette-item AirSource" data-type="AirSource" draggable="true"><span class="icon">↓A</span> Source air</div>
|
||||
<div class="palette-item AirSink" data-type="AirSink" draggable="true"><span class="icon">A↑</span> Puits air</div>
|
||||
<div class="palette-item RefrigerantSource" data-type="RefrigerantSource" draggable="true"><span class="icon">↓R</span> Source réfrig.</div>
|
||||
<div class="palette-item RefrigerantSink" data-type="RefrigerantSink" draggable="true"><span class="icon">R↑</span> Puits réfrig.</div>
|
||||
<h3 style="margin:0.5rem 0 0.25rem;font-size:0.7rem;color:var(--muted)">Circuit frigorifique</h3>
|
||||
<div class="palette-item Compressor" data-type="Compressor" draggable="true"><span class="icon">C</span> Compresseur (réfrigérant)</div>
|
||||
<div class="palette-item CondenserWater" data-type="CondenserWater" draggable="true"><span class="icon">Co</span> Condenseur à eau</div>
|
||||
<div class="palette-item CondenserAir" data-type="CondenserAir" draggable="true"><span class="icon">Co</span> Condenseur à air</div>
|
||||
<div class="palette-item EvaporatorWater" data-type="EvaporatorWater" draggable="true"><span class="icon">E</span> Évaporateur à eau</div>
|
||||
<div class="palette-item EvaporatorAir" data-type="EvaporatorAir" draggable="true"><span class="icon">E</span> Évaporateur à air</div>
|
||||
<div class="palette-item ExpansionValve" data-type="ExpansionValve" draggable="true"><span class="icon">V</span> Détendeur</div>
|
||||
<div class="palette-item HeatExchanger" data-type="HeatExchanger" draggable="true"><span class="icon">H</span> Échangeur générique (2 fluides)</div>
|
||||
<h3 style="margin:0.5rem 0 0.25rem;font-size:0.7rem;color:var(--muted)">Circuit eau / secondaire</h3>
|
||||
<div class="palette-item Pump" data-type="Pump" draggable="true"><span class="icon">P</span> Pompe</div>
|
||||
<div class="palette-item FlowSplitter" data-type="FlowSplitter" draggable="true"><span class="icon">⊳</span> Splitter</div>
|
||||
<div class="palette-item FlowMerger" data-type="FlowMerger" draggable="true"><span class="icon">⊲</span> Merger</div>
|
||||
<h3 style="margin:0.5rem 0 0.25rem;font-size:0.7rem;color:var(--muted)">Divers</h3>
|
||||
<div class="palette-item Placeholder" data-type="Placeholder" draggable="true"><span class="icon">?</span> Placeholder</div>
|
||||
</aside>
|
||||
|
||||
<div class="canvas-wrap" id="canvasWrap">
|
||||
<svg id="canvas" viewBox="0 0 1200 700" xmlns="http://www.w3.org/2000/svg"></svg>
|
||||
</div>
|
||||
|
||||
<aside class="props">
|
||||
<h3>Propriétés</h3>
|
||||
<div id="propsEmpty" class="empty">Cliquez sur un composant sur le schéma pour modifier son nom et ses paramètres.</div>
|
||||
<div id="propsForm" style="display:none">
|
||||
<label>Nom (pour les connexions)</label>
|
||||
<input type="text" id="propName" placeholder="ex: comp" />
|
||||
<label>Type</label>
|
||||
<input type="text" id="propType" readonly style="opacity:0.8" />
|
||||
<div id="propParams"></div>
|
||||
<button type="button" class="danger" id="btnDeleteNode">Supprimer le composant</button>
|
||||
</div>
|
||||
<div id="resultBox" style="display:none" class="result"></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const COMPONENT_DEFAULTS = {
|
||||
Placeholder: { n_equations: 2 },
|
||||
WaterSource: { n_equations: 2 },
|
||||
WaterSink: { n_equations: 2 },
|
||||
AirSource: { n_equations: 2, t_air_c: 35, pressure_bar: 1.01, rh_percent: 50 },
|
||||
AirSink: { n_equations: 2, pressure_bar: 1.01 },
|
||||
RefrigerantSource: { n_equations: 2 },
|
||||
RefrigerantSink: { n_equations: 2 },
|
||||
FlowSplitter: { n_equations: 2 },
|
||||
FlowMerger: { n_equations: 2 },
|
||||
Compressor: { fluid: "R410A", speed_rpm: 2900, displacement_m3: 0.000025, efficiency: 0.82, m1: 0.88, m2: 2.2, m3: 450, m4: 1400, m5: -2, m6: 1.5, m7: 550, m8: 1500, m9: -2.5, m10: 1.8 },
|
||||
CondenserWater: { ua: 5000, t_sat_cond_c: 45, ref_pressure_bar: 26, ref_mass_flow_kg_s: 0.05, sec_t_inlet_c: 30, sec_pressure_bar: 2, sec_mass_flow_kg_s: 1.2 },
|
||||
CondenserAir: { ua: 5000, t_sat_cond_c: 45, ref_pressure_bar: 26, ref_mass_flow_kg_s: 0.05, sec_t_inlet_c: 35, sec_pressure_bar: 1.01, sec_mass_flow_kg_s: 2.5, fan_speed: 1, n_air_exponent: 0.5 },
|
||||
EvaporatorWater: { ua: 6000, t_sat_evap_c: 5, superheat_k: 5, ref_pressure_bar: 7, ref_mass_flow_kg_s: 0.05, sec_t_inlet_c: 12, sec_pressure_bar: 2, sec_mass_flow_kg_s: 1.0 },
|
||||
EvaporatorAir: { ua: 6000, t_sat_evap_c: 5, superheat_k: 5, ref_pressure_bar: 7, ref_mass_flow_kg_s: 0.05, sec_t_inlet_c: 7, sec_pressure_bar: 1.01, sec_mass_flow_kg_s: 2.0 },
|
||||
ExpansionValve: { fluid: "R410A", opening: 1 },
|
||||
HeatExchanger: { ua: 6000, hot_fluid: "Air", hot_t_inlet_c: 7, hot_pressure_bar: 1.01, hot_mass_flow_kg_s: 0.8, cold_fluid: "R410A", cold_t_inlet_c: 0, cold_pressure_bar: 7, cold_mass_flow_kg_s: 0.04 },
|
||||
Pump: {}
|
||||
};
|
||||
const EXPORT_AS_PLACEHOLDER = ["WaterSource", "WaterSink", "AirSource", "AirSink", "RefrigerantSource", "RefrigerantSink", "FlowSplitter", "FlowMerger"];
|
||||
const NODE_LABEL = {
|
||||
WaterSource: "Src eau", WaterSink: "Puits eau", AirSource: "Src air", AirSink: "Puits air",
|
||||
RefrigerantSource: "Src réf.", RefrigerantSink: "Puits réf.",
|
||||
FlowSplitter: "Split", FlowMerger: "Merge",
|
||||
CondenserWater: "Cond. eau", CondenserAir: "Cond. air", EvaporatorWater: "Évap. eau", EvaporatorAir: "Évap. air"
|
||||
};
|
||||
const PARAM_META = {
|
||||
n_equations: { type: "number", label: "n_equations" },
|
||||
ua: { type: "number", label: "UA (W/K)" },
|
||||
t_sat_k: { type: "number", label: "t_sat_k" },
|
||||
t_sat_cond_c: { type: "number", label: "T sat cond. (°C)" },
|
||||
t_sat_evap_c: { type: "number", label: "T sat évap. (°C)" },
|
||||
superheat_k: { type: "number", label: "Surchauffe (K)" },
|
||||
fluid: { type: "text", label: "Réfrigérant" },
|
||||
opening: { type: "number", label: "Ouverture" },
|
||||
speed_rpm: { type: "number", label: "Vitesse (tr/min)" },
|
||||
displacement_m3: { type: "number", label: "Cylindrée (m³)" },
|
||||
efficiency: { type: "number", label: "Rendement" },
|
||||
ref_pressure_bar: { type: "number", label: "P réfrig. (bar)" },
|
||||
ref_mass_flow_kg_s: { type: "number", label: "Débit réfrig. (kg/s)" },
|
||||
sec_t_inlet_c: { type: "number", label: "T entrée secondaire (°C)" },
|
||||
sec_pressure_bar: { type: "number", label: "P secondaire (bar)" },
|
||||
sec_mass_flow_kg_s: { type: "number", label: "Débit secondaire (kg/s)" },
|
||||
fan_speed: { type: "number", label: "Vitesse ventilateur (0–1)" },
|
||||
n_air_exponent: { type: "number", label: "Exposant n_air (loi débit)" },
|
||||
t_air_c: { type: "number", label: "T air (°C)" },
|
||||
pressure_bar: { type: "number", label: "P (bar)" },
|
||||
rh_percent: { type: "number", label: "Humidité relative (%)" },
|
||||
m1: { type: "number", label: "m1" }, m2: { type: "number", label: "m2" },
|
||||
m3: { type: "number", label: "m3" }, m4: { type: "number", label: "m4" },
|
||||
m5: { type: "number", label: "m5" }, m6: { type: "number", label: "m6" },
|
||||
m7: { type: "number", label: "m7" }, m8: { type: "number", label: "m8" },
|
||||
m9: { type: "number", label: "m9" }, m10: { type: "number", label: "m10" },
|
||||
hot_fluid: { type: "text", label: "Fluide chaud" }, cold_fluid: { type: "text", label: "Fluide froid" },
|
||||
hot_t_inlet_c: { type: "number", label: "T entrée chaud (°C)" }, cold_t_inlet_c: { type: "number", label: "T entrée froid (°C)" },
|
||||
hot_pressure_bar: { type: "number", label: "P chaud (bar)" }, cold_pressure_bar: { type: "number", label: "P froid (bar)" },
|
||||
hot_mass_flow_kg_s: { type: "number", label: "Débit chaud (kg/s)" }, cold_mass_flow_kg_s: { type: "number", label: "Débit froid (kg/s)" }
|
||||
};
|
||||
const PARAM_ORDER = {
|
||||
CondenserWater: ["ua", "t_sat_cond_c", "ref_pressure_bar", "ref_mass_flow_kg_s", "sec_t_inlet_c", "sec_pressure_bar", "sec_mass_flow_kg_s"],
|
||||
CondenserAir: ["ua", "t_sat_cond_c", "ref_pressure_bar", "ref_mass_flow_kg_s", "sec_t_inlet_c", "sec_pressure_bar", "sec_mass_flow_kg_s", "fan_speed", "n_air_exponent"],
|
||||
EvaporatorWater: ["ua", "t_sat_evap_c", "superheat_k", "ref_pressure_bar", "ref_mass_flow_kg_s", "sec_t_inlet_c", "sec_pressure_bar", "sec_mass_flow_kg_s"],
|
||||
EvaporatorAir: ["ua", "t_sat_evap_c", "superheat_k", "ref_pressure_bar", "ref_mass_flow_kg_s", "sec_t_inlet_c", "sec_pressure_bar", "sec_mass_flow_kg_s"]
|
||||
};
|
||||
|
||||
let state = { mode: "move", nodes: [], edges: [], selectedId: null, connectFrom: null, dragNode: null, dragOffset: null, addType: null, mouseX: null, mouseY: null };
|
||||
let nodeIdSeq = 0;
|
||||
const NODE_W = 100, NODE_H = 52;
|
||||
|
||||
function getNode(id) { return state.nodes.find(n => n.id === id); }
|
||||
function getNodeByName(name) { return state.nodes.find(n => n.name === name); }
|
||||
|
||||
function addNode(type, x, y) {
|
||||
const name = type.toLowerCase().slice(0, 3) + "_" + (++nodeIdSeq);
|
||||
const params = { ...COMPONENT_DEFAULTS[type] };
|
||||
state.nodes.push({ id: "n" + nodeIdSeq, type, name, x, y, params });
|
||||
render();
|
||||
}
|
||||
|
||||
function deleteNode(id) {
|
||||
state.nodes = state.nodes.filter(n => n.id !== id);
|
||||
state.edges = state.edges.filter(e => e.from !== id && e.to !== id);
|
||||
if (state.selectedId === id) state.selectedId = null;
|
||||
render();
|
||||
showProps();
|
||||
}
|
||||
|
||||
function addEdge(fromId, toId) {
|
||||
const from = getNode(fromId), to = getNode(toId);
|
||||
if (!from || !to || fromId === toId) return;
|
||||
if (state.edges.some(e => e.from === fromId && e.to === toId)) return;
|
||||
state.edges.push({ from: fromId, to: toId });
|
||||
state.connectFrom = null;
|
||||
setModeHint();
|
||||
render();
|
||||
}
|
||||
|
||||
function buildConfig() {
|
||||
const fluid = document.getElementById("fluid").value.trim() || "R410A";
|
||||
const name = document.getElementById("projectName").value.trim() || "Machine";
|
||||
const circuit = {
|
||||
id: 0,
|
||||
components: state.nodes.map(n => {
|
||||
if (EXPORT_AS_PLACEHOLDER.includes(n.type)) {
|
||||
return { type: "Placeholder", name: n.name, ...n.params };
|
||||
}
|
||||
const p = n.params;
|
||||
if (n.type === "CondenserWater") {
|
||||
return {
|
||||
type: "HeatExchanger",
|
||||
name: n.name,
|
||||
ua: p.ua,
|
||||
hot_fluid: fluid,
|
||||
hot_t_inlet_c: (p.t_sat_cond_c != null ? p.t_sat_cond_c : 45) + 5,
|
||||
hot_pressure_bar: p.ref_pressure_bar,
|
||||
hot_mass_flow_kg_s: p.ref_mass_flow_kg_s,
|
||||
cold_fluid: "Water",
|
||||
cold_t_inlet_c: p.sec_t_inlet_c,
|
||||
cold_pressure_bar: p.sec_pressure_bar,
|
||||
cold_mass_flow_kg_s: p.sec_mass_flow_kg_s
|
||||
};
|
||||
}
|
||||
if (n.type === "CondenserAir") {
|
||||
return {
|
||||
type: "MchxCondenserCoil",
|
||||
name: n.name,
|
||||
ua_nominal_kw_k: (p.ua != null ? p.ua : 5000) / 1000,
|
||||
n_air_exponent: p.n_air_exponent != null ? p.n_air_exponent : 0.5,
|
||||
air_inlet_temp_c: p.sec_t_inlet_c != null ? p.sec_t_inlet_c : 35,
|
||||
fan_speed: p.fan_speed != null ? p.fan_speed : 1,
|
||||
coil_index: 0
|
||||
};
|
||||
}
|
||||
if (n.type === "EvaporatorWater") {
|
||||
return {
|
||||
type: "HeatExchanger",
|
||||
name: n.name,
|
||||
ua: p.ua,
|
||||
hot_fluid: "Water",
|
||||
hot_t_inlet_c: p.sec_t_inlet_c,
|
||||
hot_pressure_bar: p.sec_pressure_bar,
|
||||
hot_mass_flow_kg_s: p.sec_mass_flow_kg_s,
|
||||
cold_fluid: fluid,
|
||||
cold_t_inlet_c: p.t_sat_evap_c != null ? p.t_sat_evap_c : 5,
|
||||
cold_pressure_bar: p.ref_pressure_bar,
|
||||
cold_mass_flow_kg_s: p.ref_mass_flow_kg_s
|
||||
};
|
||||
}
|
||||
if (n.type === "EvaporatorAir") {
|
||||
return {
|
||||
type: "HeatExchanger",
|
||||
name: n.name,
|
||||
ua: p.ua,
|
||||
hot_fluid: "Air",
|
||||
hot_t_inlet_c: p.sec_t_inlet_c,
|
||||
hot_pressure_bar: p.sec_pressure_bar,
|
||||
hot_mass_flow_kg_s: p.sec_mass_flow_kg_s,
|
||||
cold_fluid: fluid,
|
||||
cold_t_inlet_c: p.t_sat_evap_c != null ? p.t_sat_evap_c : 5,
|
||||
cold_pressure_bar: p.ref_pressure_bar,
|
||||
cold_mass_flow_kg_s: p.ref_mass_flow_kg_s
|
||||
};
|
||||
}
|
||||
return { type: n.type, name: n.name, ...n.params };
|
||||
}),
|
||||
edges: state.edges.map(e => {
|
||||
const a = getNode(e.from), b = getNode(e.to);
|
||||
return a && b ? { from: a.name + ":outlet", to: b.name + ":inlet" } : null;
|
||||
}).filter(Boolean)
|
||||
};
|
||||
return { name, fluid, circuits: [circuit], solver: { strategy: "fallback", max_iterations: 100, tolerance: 1e-6 } };
|
||||
}
|
||||
|
||||
function render() {
|
||||
const svg = document.getElementById("canvas");
|
||||
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
|
||||
defs.innerHTML = '<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0,0 L0,6 L9,3 z" fill="#58a6ff"/></marker>';
|
||||
svg.innerHTML = "";
|
||||
svg.appendChild(defs);
|
||||
|
||||
const gEdges = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
||||
state.edges.forEach(e => {
|
||||
const a = getNode(e.from), b = getNode(e.to);
|
||||
if (!a || !b) return;
|
||||
const x1 = a.x + NODE_W; const y1 = a.y + NODE_H / 2;
|
||||
const x2 = b.x; const y2 = b.y + NODE_H / 2;
|
||||
const mid = (x1 + x2) / 2;
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("d", `M ${x1} ${y1} C ${mid} ${y1} ${mid} ${y2} ${x2} ${y2}`);
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("stroke", "#58a6ff");
|
||||
path.setAttribute("stroke-width", "2.5");
|
||||
path.setAttribute("marker-end", "url(#arrow)");
|
||||
gEdges.appendChild(path);
|
||||
});
|
||||
if (state.connectFrom && state.mouseX != null && state.mouseY != null) {
|
||||
const a = getNode(state.connectFrom);
|
||||
if (a) {
|
||||
const x1 = a.x + NODE_W; const y1 = a.y + NODE_H / 2;
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
const mid = (x1 + state.mouseX) / 2;
|
||||
path.setAttribute("d", `M ${x1} ${y1} C ${mid} ${y1} ${mid} ${state.mouseY} ${state.mouseX} ${state.mouseY}`);
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("stroke", "#58a6ff");
|
||||
path.setAttribute("stroke-width", "2");
|
||||
path.setAttribute("stroke-dasharray", "6 4");
|
||||
path.setAttribute("marker-end", "url(#arrow)");
|
||||
gEdges.appendChild(path);
|
||||
}
|
||||
}
|
||||
svg.appendChild(gEdges);
|
||||
|
||||
const gNodes = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
||||
state.nodes.forEach(n => {
|
||||
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
||||
g.setAttribute("data-id", n.id);
|
||||
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||
rect.setAttribute("x", n.x);
|
||||
rect.setAttribute("y", n.y);
|
||||
rect.setAttribute("width", NODE_W);
|
||||
rect.setAttribute("height", NODE_H);
|
||||
rect.setAttribute("rx", "8");
|
||||
rect.setAttribute("fill", state.selectedId === n.id ? "#388bfd44" : "#21262d");
|
||||
rect.setAttribute("stroke", state.connectFrom === n.id ? "#58a6ff" : "#30363d");
|
||||
rect.setAttribute("stroke-width", state.selectedId === n.id || state.connectFrom === n.id ? "2" : "1");
|
||||
const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||
label.setAttribute("x", n.x + NODE_W / 2);
|
||||
label.setAttribute("y", n.y + 20);
|
||||
label.setAttribute("text-anchor", "middle");
|
||||
label.setAttribute("fill", "#e6edf3");
|
||||
label.setAttribute("font-size", "12");
|
||||
label.setAttribute("pointer-events", "none");
|
||||
label.textContent = NODE_LABEL[n.type] || n.type.slice(0, 2);
|
||||
const nameText = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||
nameText.setAttribute("x", n.x + NODE_W / 2);
|
||||
nameText.setAttribute("y", n.y + 38);
|
||||
nameText.setAttribute("text-anchor", "middle");
|
||||
nameText.setAttribute("fill", "#8b949e");
|
||||
nameText.setAttribute("font-size", "10");
|
||||
nameText.setAttribute("pointer-events", "none");
|
||||
nameText.textContent = n.name;
|
||||
g.appendChild(rect);
|
||||
g.appendChild(label);
|
||||
g.appendChild(nameText);
|
||||
const hitRect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||
hitRect.setAttribute("x", n.x);
|
||||
hitRect.setAttribute("y", n.y);
|
||||
hitRect.setAttribute("width", NODE_W);
|
||||
hitRect.setAttribute("height", NODE_H);
|
||||
hitRect.setAttribute("fill", "transparent");
|
||||
hitRect.setAttribute("cursor", state.mode === "connect" ? "pointer" : state.mode === "move" ? "grab" : "default");
|
||||
hitRect.setAttribute("data-id", n.id);
|
||||
g.appendChild(hitRect);
|
||||
gNodes.appendChild(g);
|
||||
});
|
||||
svg.appendChild(gNodes);
|
||||
if (state.mode === "connect") setModeHint();
|
||||
}
|
||||
|
||||
function showProps() {
|
||||
const empty = document.getElementById("propsEmpty");
|
||||
const form = document.getElementById("propsForm");
|
||||
if (state.selectedId) {
|
||||
const n = getNode(state.selectedId);
|
||||
if (n) {
|
||||
empty.style.display = "none";
|
||||
form.style.display = "block";
|
||||
document.getElementById("propName").value = n.name;
|
||||
document.getElementById("propType").value = n.type;
|
||||
const container = document.getElementById("propParams");
|
||||
container.innerHTML = "";
|
||||
const order = PARAM_ORDER[n.type];
|
||||
const keys = order ? order.filter(k => n.params.hasOwnProperty(k)).concat(Object.keys(n.params).filter(k => !order.includes(k))) : Object.keys(n.params);
|
||||
let didRefHeader = false;
|
||||
keys.forEach((k) => {
|
||||
if (PARAM_ORDER[n.type]) {
|
||||
if (!didRefHeader && (k === "ua" || k === "t_sat_cond_c" || k === "t_sat_evap_c")) {
|
||||
didRefHeader = true;
|
||||
const refHeader = document.createElement("div");
|
||||
refHeader.className = "prop-section";
|
||||
refHeader.style.cssText = "grid-column:1/-1;font-size:0.7rem;color:var(--muted);margin-bottom:0.2rem;";
|
||||
refHeader.textContent = "Côté réfrigérant";
|
||||
container.appendChild(refHeader);
|
||||
}
|
||||
if (k === "sec_t_inlet_c") {
|
||||
const sep = document.createElement("div");
|
||||
sep.className = "prop-section";
|
||||
sep.style.cssText = "grid-column:1/-1;font-size:0.7rem;color:var(--muted);margin:0.4rem 0 0.2rem;padding-top:0.3rem;border-top:1px solid var(--border);";
|
||||
sep.textContent = "Côté secondaire (eau / air)";
|
||||
container.appendChild(sep);
|
||||
}
|
||||
if (n.type === "CondenserAir" && k === "fan_speed") {
|
||||
const fanSep = document.createElement("div");
|
||||
fanSep.className = "prop-section";
|
||||
fanSep.style.cssText = "grid-column:1/-1;font-size:0.7rem;color:var(--muted);margin:0.4rem 0 0.2rem;padding-top:0.3rem;border-top:1px solid var(--border);";
|
||||
fanSep.textContent = "Ventilateur (export MchxCondenserCoil)";
|
||||
container.appendChild(fanSep);
|
||||
}
|
||||
}
|
||||
const meta = PARAM_META[k] || { type: "number", label: k };
|
||||
const label = document.createElement("label");
|
||||
label.textContent = meta.label;
|
||||
const input = document.createElement("input");
|
||||
input.type = meta.type === "number" ? "number" : "text";
|
||||
input.dataset.key = k;
|
||||
input.value = n.params[k];
|
||||
input.addEventListener("input", () => { n.params[k] = meta.type === "number" ? parseFloat(input.value) || 0 : input.value; });
|
||||
container.appendChild(label);
|
||||
container.appendChild(input);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
empty.style.display = "block";
|
||||
form.style.display = "none";
|
||||
}
|
||||
|
||||
function pt(svg, e) {
|
||||
const p = svg.createSVGPoint();
|
||||
p.x = e.clientX;
|
||||
p.y = e.clientY;
|
||||
const ctm = svg.getScreenCTM();
|
||||
if (!ctm) return { x: 0, y: 0 };
|
||||
const tp = p.matrixTransform(ctm.inverse());
|
||||
return { x: tp.x, y: tp.y };
|
||||
}
|
||||
|
||||
function nodeAtPoint(x, y) {
|
||||
for (let i = state.nodes.length - 1; i >= 0; i--) {
|
||||
const n = state.nodes[i];
|
||||
if (x >= n.x && x <= n.x + NODE_W && y >= n.y && y <= n.y + NODE_H) return n.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const canvas = document.getElementById("canvas");
|
||||
const wrap = document.getElementById("canvasWrap");
|
||||
|
||||
canvas.addEventListener("mousedown", e => {
|
||||
e.preventDefault();
|
||||
const p = pt(canvas, e);
|
||||
const id = nodeAtPoint(p.x, p.y);
|
||||
|
||||
if (state.mode === "connect") {
|
||||
if (id) {
|
||||
if (state.connectFrom) {
|
||||
if (state.connectFrom === id) {
|
||||
state.connectFrom = null;
|
||||
} else {
|
||||
addEdge(state.connectFrom, id);
|
||||
}
|
||||
} else {
|
||||
state.connectFrom = id;
|
||||
}
|
||||
} else {
|
||||
state.connectFrom = null;
|
||||
}
|
||||
setModeHint();
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.mode === "add" && state.addType) {
|
||||
if (!id) addNode(state.addType, Math.max(0, p.x - NODE_W / 2), Math.max(0, p.y - NODE_H / 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
state.selectedId = id;
|
||||
state.dragNode = id;
|
||||
const n = getNode(id);
|
||||
state.dragOffset = { x: p.x - n.x, y: p.y - n.y };
|
||||
showProps();
|
||||
render();
|
||||
} else {
|
||||
state.selectedId = null;
|
||||
showProps();
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
function onMouseMove(e) {
|
||||
const p = pt(canvas, e);
|
||||
state.mouseX = p.x;
|
||||
state.mouseY = p.y;
|
||||
if (state.connectFrom) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (!state.dragNode) return;
|
||||
const n = getNode(state.dragNode);
|
||||
n.x = Math.max(0, p.x - state.dragOffset.x);
|
||||
n.y = Math.max(0, p.y - state.dragOffset.y);
|
||||
render();
|
||||
}
|
||||
canvas.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
|
||||
canvas.addEventListener("mouseup", () => { state.dragNode = null; });
|
||||
window.addEventListener("mouseup", () => { state.dragNode = null; });
|
||||
|
||||
canvas.addEventListener("dragover", e => { e.preventDefault(); });
|
||||
canvas.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
const type = e.dataTransfer.getData("type");
|
||||
if (!type) return;
|
||||
const p = pt(canvas, e);
|
||||
addNode(type, Math.max(0, p.x - NODE_W / 2), Math.max(0, p.y - NODE_H / 2));
|
||||
});
|
||||
|
||||
document.querySelectorAll(".palette-item").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
state.addType = el.dataset.type;
|
||||
document.querySelectorAll(".palette-item").forEach(x => x.style.borderColor = "transparent");
|
||||
el.style.borderColor = "var(--accent)";
|
||||
});
|
||||
el.addEventListener("dragstart", e => { e.dataTransfer.setData("type", el.dataset.type); });
|
||||
});
|
||||
|
||||
function setModeHint() {
|
||||
const h = document.getElementById("modeHint");
|
||||
const s = document.getElementById("connectStatus");
|
||||
if (state.mode === "move") {
|
||||
h.textContent = "Déplacer : glissez les blocs.";
|
||||
s.textContent = "";
|
||||
} else if (state.mode === "add") {
|
||||
h.textContent = "Ajouter : choisissez un composant ci-dessus, puis cliquez sur le schéma.";
|
||||
s.textContent = "";
|
||||
} else {
|
||||
h.textContent = "Relier : cliquez sur un bloc (sortie), puis sur un autre (entrée).";
|
||||
s.textContent = state.connectFrom ? "→ Maintenant cliquez sur le bloc cible (entrée)." : "Cliquez sur le 1er bloc.";
|
||||
}
|
||||
}
|
||||
document.getElementById("modeMove").onclick = () => { state.mode = "move"; state.connectFrom = null; wrap.className = "canvas-wrap"; document.querySelectorAll(".mode-btns button").forEach(b => b.classList.remove("active")); document.getElementById("modeMove").classList.add("active"); setModeHint(); render(); };
|
||||
document.getElementById("modeAdd").onclick = () => { state.mode = "add"; state.connectFrom = null; wrap.className = "canvas-wrap mode-add"; document.querySelectorAll(".mode-btns button").forEach(b => b.classList.remove("active")); document.getElementById("modeAdd").classList.add("active"); setModeHint(); };
|
||||
document.getElementById("modeConnect").onclick = () => { state.mode = "connect"; wrap.className = "canvas-wrap mode-connect"; document.querySelectorAll(".mode-btns button").forEach(b => b.classList.remove("active")); document.getElementById("modeConnect").classList.add("active"); state.connectFrom = null; setModeHint(); render(); };
|
||||
|
||||
document.getElementById("propName").addEventListener("input", e => {
|
||||
const n = getNode(state.selectedId);
|
||||
if (n) n.name = e.target.value.trim() || n.name;
|
||||
render();
|
||||
});
|
||||
document.getElementById("btnDeleteNode").onclick = () => { if (state.selectedId) deleteNode(state.selectedId); };
|
||||
|
||||
document.getElementById("exampleSelect").addEventListener("change", async function() {
|
||||
const path = this.value;
|
||||
if (!path) return;
|
||||
const res = await fetch(path);
|
||||
const config = await res.json();
|
||||
state.nodes = [];
|
||||
state.edges = [];
|
||||
state.selectedId = null;
|
||||
nodeIdSeq = 0;
|
||||
const c = config.circuits[0];
|
||||
if (!c) return;
|
||||
const nameToId = {};
|
||||
(c.components || []).forEach((comp, i) => {
|
||||
const id = "n" + (++nodeIdSeq);
|
||||
nameToId[comp.name] = id;
|
||||
const params = { ...comp };
|
||||
delete params.type;
|
||||
delete params.name;
|
||||
state.nodes.push({
|
||||
id,
|
||||
type: comp.type,
|
||||
name: comp.name,
|
||||
x: 150 + (i % 4) * 220,
|
||||
y: 80 + Math.floor(i / 4) * 120,
|
||||
params
|
||||
});
|
||||
});
|
||||
(c.edges || []).forEach(edge => {
|
||||
const fromName = (edge.from || "").split(":")[0];
|
||||
const toName = (edge.to || "").split(":")[0];
|
||||
const fromId = nameToId[fromName];
|
||||
const toId = nameToId[toName];
|
||||
if (fromId && toId) state.edges.push({ from: fromId, to: toId });
|
||||
});
|
||||
document.getElementById("projectName").value = config.name || "";
|
||||
document.getElementById("fluid").value = config.fluid || "R410A";
|
||||
this.value = "";
|
||||
render();
|
||||
showProps();
|
||||
});
|
||||
|
||||
async function postCli(endpoint) {
|
||||
const res = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(buildConfig()) });
|
||||
const data = await res.json();
|
||||
const box = document.getElementById("resultBox");
|
||||
box.style.display = "block";
|
||||
box.className = "result " + (data.ok ? "ok" : "err");
|
||||
box.textContent = (data.stdout || "") + (data.stderr ? "\n" + data.stderr : "");
|
||||
}
|
||||
|
||||
document.getElementById("btnValidate").onclick = () => postCli("/validate");
|
||||
document.getElementById("btnRun").onclick = () => postCli("/run");
|
||||
document.getElementById("btnExport").onclick = () => {
|
||||
const blob = new Blob([JSON.stringify(buildConfig(), null, 2)], { type: "application/json" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = "entropyk-config.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
};
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
131
tools/circuit-builder-ui/server.py
Normal file
131
tools/circuit-builder-ui/server.py
Normal file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Minimal backend for Circuit Builder UI.
|
||||
Serves the static UI and runs entropyk-cli validate/run on POST.
|
||||
Run from repository root: python tools/circuit-builder-ui/server.py
|
||||
Then open http://localhost:8765
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
PORT = 8765
|
||||
# Path to entropyk-cli from repo root (run server from repo root)
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
CLI_PATH = REPO_ROOT / "target" / "release" / "entropyk-cli"
|
||||
UI_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def run_cli(subcommand, config_json_str, run_output=None):
|
||||
"""Run entropyk-cli validate or run; config_json_str written to temp file."""
|
||||
if not CLI_PATH.exists():
|
||||
return False, f"CLI not found. Build first: cargo build --release -p entropyk-cli\nExpected: {CLI_PATH}", ""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||
f.write(config_json_str)
|
||||
tmp_path = f.name
|
||||
try:
|
||||
cmd = [str(CLI_PATH), subcommand, "--config", tmp_path]
|
||||
if run_output is not None:
|
||||
cmd.extend(["-o", run_output])
|
||||
r = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
stdout = r.stdout or ""
|
||||
stderr = r.stderr or ""
|
||||
if r.returncode != 0:
|
||||
return False, stdout or stderr or f"Exit code {r.returncode}", stderr
|
||||
return True, stdout, stderr
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Timeout (60s)", ""
|
||||
except Exception as e:
|
||||
return False, str(e), ""
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
path = urlparse(self.path).path
|
||||
if path == "/" or path == "/index.html":
|
||||
self.serve_file(UI_DIR / "index.html", "text/html")
|
||||
elif path == "/examples/simple_working.json":
|
||||
self.serve_file(REPO_ROOT / "crates" / "cli" / "examples" / "simple_working.json", "application/json")
|
||||
elif path == "/examples/chiller_r410a_minimal.json":
|
||||
self.serve_file(REPO_ROOT / "crates" / "cli" / "examples" / "chiller_r410a_minimal.json", "application/json")
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def serve_file(self, filepath, content_type):
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
data = f.read()
|
||||
except FileNotFoundError:
|
||||
self.send_error(404)
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_POST(self):
|
||||
path = urlparse(self.path).path
|
||||
if path != "/validate" and path != "/run":
|
||||
self.send_error(404)
|
||||
return
|
||||
content_len = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_len)
|
||||
try:
|
||||
config_str = body.decode("utf-8")
|
||||
json.loads(config_str)
|
||||
except Exception as e:
|
||||
self.send_json(400, {"ok": False, "error": f"Invalid JSON: {e}"})
|
||||
return
|
||||
if path == "/validate":
|
||||
ok, out, err = run_cli("validate", config_str)
|
||||
else:
|
||||
ok, out, err = run_cli("run", config_str, run_output=os.path.devnull)
|
||||
self.send_json(200, {"ok": ok, "stdout": out, "stderr": err})
|
||||
|
||||
def send_json(self, status, obj):
|
||||
body = json.dumps(obj).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
print(f"[{self.log_date_time_string()}] {format % args}")
|
||||
|
||||
|
||||
def main():
|
||||
os.chdir(REPO_ROOT)
|
||||
print(f"Entropyk Circuit Builder UI")
|
||||
print(f" UI: http://localhost:{PORT}/")
|
||||
print(f" CLI: {CLI_PATH} {'(found)' if CLI_PATH.exists() else '(not found — run: cargo build --release -p entropyk-cli)'}")
|
||||
print(f" Root: {REPO_ROOT}")
|
||||
server = HTTPServer(("", PORT), Handler)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nBye.")
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user