382 lines
20 KiB
HTML
382 lines
20 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Entropyk - Test UI</title>
|
|
<style>
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; font-family: 'Segoe UI', system-ui, sans-serif; background: #1a1d23; color: #e4e6eb; min-height: 100vh; }
|
|
.layout { display: grid; grid-template-columns: 180px 1fr 320px; grid-template-rows: 48px 1fr 140px; height: 100vh; }
|
|
header { grid-column: 1 / -1; background: #252830; padding: 0 20px; display: flex; align-items: center; border-bottom: 1px solid #3a3f4b; }
|
|
header h1 { margin: 0; font-size: 1.2rem; font-weight: 600; }
|
|
.palette { background: #252830; padding: 16px; border-right: 1px solid #3a3f4b; overflow-y: auto; }
|
|
.palette h3 { margin: 0 0 12px 0; font-size: 0.75rem; text-transform: uppercase; color: #8b919e; }
|
|
.palette-item { padding: 10px 12px; margin-bottom: 8px; background: #2d323d; border-radius: 8px; cursor: grab; font-size: 0.9rem; border: 2px solid transparent; transition: all 0.15s; }
|
|
.palette-item:hover { background: #363c48; border-color: #5a9cf5; }
|
|
.palette-item:active { cursor: grabbing; }
|
|
.palette-item.compressor { border-left: 4px solid #f59e5b; }
|
|
.palette-item.pump { border-left: 4px solid #5a9cf5; }
|
|
.palette-item.pipe { border-left: 4px solid #6ee7b7; }
|
|
.palette-item.valve { border-left: 4px solid #c4b5fd; }
|
|
.canvas { background: linear-gradient(rgba(58,63,75,0.3) 1px, transparent 1px), linear-gradient(90deg, rgba(58,63,75,0.3) 1px, transparent 1px); background-size: 24px 24px; background-color: #1e2229; position: relative; overflow: hidden; }
|
|
.canvas .component { position: absolute; min-width: 120px; padding: 12px 16px; background: #2d323d; border-radius: 10px; cursor: move; user-select: none; border: 2px solid transparent; box-shadow: 0 4px 12px rgba(0,0,0,0.3); }
|
|
.canvas .component:hover { border-color: #5a9cf5; }
|
|
.canvas .component.selected { border-color: #5a9cf5; box-shadow: 0 0 0 2px rgba(90,156,245,0.3); }
|
|
.canvas .component .label { font-weight: 600; font-size: 0.85rem; }
|
|
.canvas .component .type { font-size: 0.7rem; color: #8b919e; margin-top: 4px; }
|
|
.canvas .component .ports { display: flex; justify-content: space-between; margin-top: 8px; gap: 8px; }
|
|
.canvas .component .port { width: 12px; height: 12px; border-radius: 50%; background: #5a9cf5; border: 2px solid #3a3f4b; cursor: crosshair; flex-shrink: 0; }
|
|
.canvas .component .port.in { background: #6ee7b7; }
|
|
.canvas .component .port.out { background: #f59e5b; }
|
|
.canvas .component .port.connected { border-color: #5a9cf5; box-shadow: 0 0 6px #5a9cf5; }
|
|
.canvas .component.compressor { border-left: 4px solid #f59e5b; }
|
|
.canvas .component.pump { border-left: 4px solid #5a9cf5; }
|
|
.canvas .component.pipe { border-left: 4px solid #6ee7b7; }
|
|
.canvas .component.valve { border-left: 4px solid #c4b5fd; }
|
|
.config-panel { background: #252830; padding: 16px; border-left: 1px solid #3a3f4b; overflow-y: auto; }
|
|
.config-panel h3 { margin: 0 0 12px 0; font-size: 0.75rem; text-transform: uppercase; color: #8b919e; }
|
|
.config-panel .empty { color: #6b7280; font-size: 0.9rem; }
|
|
.config-panel .section { margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #3a3f4b; }
|
|
.config-panel .section:last-of-type { border-bottom: none; }
|
|
.config-panel input, .config-panel select, .config-panel textarea { width: 100%; padding: 8px 10px; margin-bottom: 8px; background: #1e2229; border: 1px solid #3a3f4b; border-radius: 6px; color: #e4e6eb; font-size: 0.9rem; }
|
|
.config-panel input:focus, .config-panel select:focus { outline: none; border-color: #5a9cf5; }
|
|
.config-panel label { display: block; font-size: 0.75rem; color: #8b919e; margin-bottom: 4px; }
|
|
.footer { grid-column: 1 / -1; background: #252830; padding: 12px 20px; border-top: 1px solid #3a3f4b; display: flex; align-items: center; gap: 12px; }
|
|
.footer button { padding: 8px 16px; background: #5a9cf5; border: none; border-radius: 6px; color: white; font-weight: 600; cursor: pointer; font-size: 0.9rem; }
|
|
.footer button:hover { background: #4a8ce5; }
|
|
.footer button.secondary { background: #3a3f4b; color: #e4e6eb; }
|
|
.footer button.secondary:hover { background: #4a4f5b; }
|
|
.footer button.danger { background: #ef4444; }
|
|
.footer button.danger:hover { background: #dc2626; }
|
|
#json-output { flex: 1; padding: 8px 12px; background: #1e2229; border: 1px solid #3a3f4b; border-radius: 6px; color: #8b919e; font-family: monospace; font-size: 0.75rem; resize: none; min-height: 80px; }
|
|
.connection-mode { background: #f59e5b !important; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="layout">
|
|
<header><h1>🔧 Entropyk - Test UI</h1></header>
|
|
<aside class="palette">
|
|
<h3>Composants</h3>
|
|
<div class="palette-item compressor" data-type="compressor" draggable="true">Compresseur</div>
|
|
<div class="palette-item pump" data-type="pump" draggable="true">Pompe</div>
|
|
<div class="palette-item pipe" data-type="pipe" draggable="true">Conduite</div>
|
|
<div class="palette-item valve" data-type="valve" draggable="true">Détendeur</div>
|
|
</aside>
|
|
<main class="canvas" id="canvas">
|
|
<svg id="connections-svg" style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0"></svg>
|
|
<div id="components-container" style="position:absolute;top:0;left:0;width:100%;height:100%;z-index:1"></div>
|
|
</main>
|
|
<aside class="config-panel">
|
|
<h3>Configuration</h3>
|
|
<div id="config-content"><p class="empty">Sélectionnez un composant</p></div>
|
|
</aside>
|
|
<footer class="footer">
|
|
<button onclick="calculate()">Calculer</button>
|
|
<button class="secondary" id="btn-connect" onclick="toggleConnectMode()">Relier (sortie → entrée)</button>
|
|
<button class="secondary" onclick="exportJson()">Exporter JSON</button>
|
|
<button class="secondary" onclick="clearAll()">Effacer tout</button>
|
|
<textarea id="json-output" readonly placeholder="Résultats..."></textarea>
|
|
</footer>
|
|
</div>
|
|
|
|
<script>
|
|
let components = [];
|
|
let connections = [];
|
|
let selectedId = null;
|
|
let idCounter = 0;
|
|
let dragOffset = { x: 0, y: 0 };
|
|
let connectMode = false;
|
|
let connectFrom = null;
|
|
|
|
const canvas = document.getElementById('canvas');
|
|
const componentsContainer = document.getElementById('components-container');
|
|
const configContent = document.getElementById('config-content');
|
|
const connectionsSvg = document.getElementById('connections-svg');
|
|
|
|
function getPorts(type) {
|
|
if (type === 'compressor') return [{ id: 'suction', name: 'Aspiration', dir: 'in' }, { id: 'discharge', name: 'Refoulement', dir: 'out' }];
|
|
return [{ id: 'inlet', name: 'Entrée', dir: 'in' }, { id: 'outlet', name: 'Sortie', dir: 'out' }];
|
|
}
|
|
|
|
function getDefaultConfig(type) {
|
|
switch (type) {
|
|
case 'compressor': return { fluid: 'R134a', mass_coeffs: '0.05,0.001,0.0005,0.00001', power_coeffs: '1000,50,30,0.5' };
|
|
case 'pump': return { fluid: 'Water', head_coeffs: '30,-10,-50', eff_coeffs: '0.5,0.3,-0.5', density: 1000 };
|
|
case 'pipe': return { fluid: 'Water', length: 10, diameter: 0.022, roughness: 0.0000015 };
|
|
case 'valve': return { fluid: 'R134a', opening: 1 };
|
|
default: return {};
|
|
}
|
|
}
|
|
|
|
document.querySelectorAll('.palette-item').forEach(item => {
|
|
item.addEventListener('dragstart', (e) => {
|
|
e.dataTransfer.setData('type', item.dataset.type);
|
|
e.dataTransfer.effectAllowed = 'copy';
|
|
});
|
|
});
|
|
|
|
canvas.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
});
|
|
|
|
canvas.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
const type = e.dataTransfer.getData('type');
|
|
if (!type) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
addComponent(type, e.clientX - rect.left - 60, e.clientY - rect.top - 40);
|
|
});
|
|
|
|
function addComponent(type, x, y) {
|
|
idCounter++;
|
|
const id = `comp_${idCounter}`;
|
|
const ports = getPorts(type);
|
|
components.push({
|
|
id, type, x, y,
|
|
label: `${type}_${idCounter}`,
|
|
config: getDefaultConfig(type),
|
|
ports: Object.fromEntries(ports.map(p => [p.id, null]))
|
|
});
|
|
renderAll();
|
|
selectComponent(id);
|
|
}
|
|
|
|
function renderComponent(comp) {
|
|
let el = document.getElementById(comp.id);
|
|
if (!el) {
|
|
el = document.createElement('div');
|
|
el.className = `component ${comp.type}`;
|
|
el.id = comp.id;
|
|
el.innerHTML = `<div class="label"></div><div class="type"></div><div class="ports"></div>`;
|
|
el.addEventListener('mousedown', (e) => {
|
|
if (e.target.closest('.port')) return;
|
|
selectComponent(comp.id);
|
|
dragOffset = { x: e.clientX - comp.x, y: e.clientY - comp.y };
|
|
});
|
|
const portsDiv = el.querySelector('.ports');
|
|
getPorts(comp.type).forEach(p => {
|
|
const portEl = document.createElement('div');
|
|
portEl.className = `port ${p.dir}`;
|
|
portEl.title = p.name;
|
|
portEl.dataset.port = p.id;
|
|
portEl.addEventListener('mousedown', (e) => { e.stopPropagation(); portClick(comp.id, p.id, p.dir); });
|
|
portsDiv.appendChild(portEl);
|
|
});
|
|
componentsContainer.appendChild(el);
|
|
}
|
|
el.style.left = comp.x + 'px';
|
|
el.style.top = comp.y + 'px';
|
|
el.querySelector('.label').textContent = comp.label;
|
|
el.querySelector('.type').textContent = comp.type;
|
|
el.querySelectorAll('.port').forEach((p, i) => {
|
|
const portId = getPorts(comp.type)[i].id;
|
|
p.classList.toggle('connected', !!comp.ports[portId]);
|
|
});
|
|
}
|
|
|
|
function portClick(compId, portId, dir) {
|
|
if (!connectMode) return;
|
|
const comp = components.find(c => c.id === compId);
|
|
if (!comp) return;
|
|
if (!connectFrom) {
|
|
if (dir !== 'out') return;
|
|
connectFrom = { compId, portId };
|
|
document.getElementById('btn-connect').classList.add('connection-mode');
|
|
return;
|
|
}
|
|
if (dir !== 'in') { connectFrom = null; document.getElementById('btn-connect').classList.remove('connection-mode'); return; }
|
|
const fromComp = components.find(c => c.id === connectFrom.compId);
|
|
if (!fromComp || fromComp.id === compId) { connectFrom = null; document.getElementById('btn-connect').classList.remove('connection-mode'); return; }
|
|
connections = connections.filter(c => !(c.from.compId === connectFrom.compId && c.from.portId === connectFrom.portId) && !(c.to.compId === compId && c.to.portId === portId));
|
|
const oldTo = comp.ports[portId];
|
|
if (oldTo) {
|
|
const oldComp = components.find(c => c.id === oldTo.compId);
|
|
if (oldComp) oldComp.ports[oldTo.portId] = null;
|
|
}
|
|
connections.push({ from: connectFrom, to: { compId, portId } });
|
|
fromComp.ports[connectFrom.portId] = { compId, portId };
|
|
comp.ports[portId] = { compId: connectFrom.compId, portId: connectFrom.portId };
|
|
connectFrom = null;
|
|
document.getElementById('btn-connect').classList.remove('connection-mode');
|
|
renderAll();
|
|
}
|
|
|
|
function toggleConnectMode() {
|
|
connectMode = !connectMode;
|
|
connectFrom = null;
|
|
document.getElementById('btn-connect').classList.toggle('connection-mode', connectMode);
|
|
document.getElementById('btn-connect').textContent = connectMode ? 'Annuler' : 'Relier (sortie → entrée)';
|
|
}
|
|
|
|
function renderConnections() {
|
|
let path = '';
|
|
connections.forEach(c => {
|
|
const fromComp = components.find(x => x.id === c.from.compId);
|
|
const toComp = components.find(x => x.id === c.to.compId);
|
|
if (!fromComp || !toComp) return;
|
|
const fromEl = document.getElementById(fromComp.id);
|
|
const toEl = document.getElementById(toComp.id);
|
|
if (!fromEl || !toEl) return;
|
|
const fromPort = fromEl.querySelector(`.port.out, .port[data-port="${c.from.portId}"]`);
|
|
const toPort = toEl.querySelector(`.port.in, .port[data-port="${c.to.portId}"]`);
|
|
if (!fromPort || !toPort) return;
|
|
const r = canvas.getBoundingClientRect();
|
|
const fromRect = fromPort.getBoundingClientRect();
|
|
const toRect = toPort.getBoundingClientRect();
|
|
const x1 = fromRect.left - r.left + fromRect.width / 2;
|
|
const y1 = fromRect.top - r.top + fromRect.height / 2;
|
|
const x2 = toRect.left - r.left + toRect.width / 2;
|
|
const y2 = toRect.top - r.top + toRect.height / 2;
|
|
path += `M ${x1} ${y1} C ${x1 + 80} ${y1}, ${x2 - 80} ${y2}, ${x2} ${y2} `;
|
|
});
|
|
connectionsSvg.innerHTML = `<path d="${path}" fill="none" stroke="#5a9cf5" stroke-width="2"/>`;
|
|
}
|
|
|
|
function renderAll() {
|
|
components.forEach(renderComponent);
|
|
renderConnections();
|
|
}
|
|
|
|
function selectComponent(id) {
|
|
selectedId = id;
|
|
document.querySelectorAll('.component').forEach(el => el.classList.toggle('selected', el.id === id));
|
|
renderConfigPanel();
|
|
}
|
|
|
|
canvas.addEventListener('mousemove', (e) => {
|
|
if (selectedId && e.buttons === 1 && !e.target.closest('.port')) {
|
|
const comp = components.find(c => c.id === selectedId);
|
|
if (comp) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
comp.x = Math.max(0, e.clientX - rect.left - dragOffset.x);
|
|
comp.y = Math.max(0, e.clientY - rect.top - dragOffset.y);
|
|
renderAll();
|
|
}
|
|
}
|
|
});
|
|
|
|
function escapeAttr(v) {
|
|
if (v === undefined || v === null) return '';
|
|
return String(v).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
}
|
|
|
|
function renderConfigPanel() {
|
|
if (!selectedId) {
|
|
configContent.innerHTML = '<p class="empty">Sélectionnez un composant pour configurer</p>';
|
|
return;
|
|
}
|
|
const comp = components.find(c => c.id === selectedId);
|
|
if (!comp) return;
|
|
|
|
let html = `<div class="section"><label>Nom</label><input type="text" value="${escapeAttr(comp.label)}" oninput="updateConfig('${comp.id}','label',this.value);updateLabel('${comp.id}',this.value)"></div>`;
|
|
|
|
html += `<div class="section"><label>Fluide</label><select onchange="updateConfig('${comp.id}','fluid',this.value)">`;
|
|
['R134a', 'R410A', 'Water', 'Air'].forEach(f => {
|
|
html += `<option value="${f}" ${comp.config.fluid === f ? 'selected' : ''}>${f}</option>`;
|
|
});
|
|
html += '</select></div>';
|
|
|
|
html += '<div class="section"><label>Ports (connexions)</label>';
|
|
getPorts(comp.type).forEach(p => {
|
|
const conn = comp.ports[p.id];
|
|
const label = conn ? components.find(c => c.id === conn.compId)?.label || conn.compId : '— Non connecté';
|
|
html += `<div style="margin-bottom:6px;font-size:0.8rem"><b>${p.name}</b>: ${label}</div>`;
|
|
});
|
|
html += '<small style="color:#8b919e">Mode "Relier" pour connecter sortie→entrée</small></div>';
|
|
|
|
if (comp.type === 'compressor') {
|
|
html += `<div class="section"><label>Coeffs débit (a00,a10,a01,a11)</label><input type="text" value="${escapeAttr(comp.config.mass_coeffs)}" oninput="updateConfig('${comp.id}','mass_coeffs',this.value)" placeholder="0.05,0.001,0.0005,0.00001"></div>`;
|
|
html += `<div class="section"><label>Coeffs puissance (b00,b10,b01,b11)</label><input type="text" value="${escapeAttr(comp.config.power_coeffs)}" oninput="updateConfig('${comp.id}','power_coeffs',this.value)" placeholder="1000,50,30,0.5"></div>`;
|
|
} else if (comp.type === 'pump') {
|
|
html += `<div class="section"><label>Hauteur H (a0,a1,a2)</label><input type="text" value="${escapeAttr(comp.config.head_coeffs)}" oninput="updateConfig('${comp.id}','head_coeffs',this.value)" placeholder="30,-10,-50"></div>`;
|
|
html += `<div class="section"><label>Rendement η (b0,b1,b2)</label><input type="text" value="${escapeAttr(comp.config.eff_coeffs)}" oninput="updateConfig('${comp.id}','eff_coeffs',this.value)" placeholder="0.5,0.3,-0.5"></div>`;
|
|
html += `<div class="section"><label>Densité (kg/m³)</label><input type="number" value="${escapeAttr(comp.config.density)}" oninput="updateConfig('${comp.id}','density',parseFloat(this.value)||0)" min="1" step="1"></div>`;
|
|
} else if (comp.type === 'pipe') {
|
|
html += `<div class="section"><label>Longueur (m)</label><input type="number" value="${escapeAttr(comp.config.length)}" oninput="updateConfig('${comp.id}','length',parseFloat(this.value)||0)" min="0.1" step="0.1"></div>`;
|
|
html += `<div class="section"><label>Diamètre (m)</label><input type="number" value="${escapeAttr(comp.config.diameter)}" oninput="updateConfig('${comp.id}','diameter',parseFloat(this.value)||0)" min="0.001" step="0.001"></div>`;
|
|
html += `<div class="section"><label>Rugosité (m)</label><input type="number" value="${escapeAttr(comp.config.roughness)}" oninput="updateConfig('${comp.id}','roughness',parseFloat(this.value)||0)" min="0" step="0.0000001"></div>`;
|
|
} else if (comp.type === 'valve') {
|
|
html += `<div class="section"><label>Ouverture (0-1)</label><input type="number" value="${escapeAttr(comp.config.opening)}" oninput="updateConfig('${comp.id}','opening',parseFloat(this.value)||0)" min="0" max="1" step="0.1"></div>`;
|
|
}
|
|
|
|
html += `<div class="section"><button class="danger" onclick="removeComponent('${comp.id}')">Supprimer</button></div>`;
|
|
configContent.innerHTML = html;
|
|
}
|
|
|
|
function updateLabel(id, value) {
|
|
const comp = components.find(c => c.id === id);
|
|
if (comp) { comp.label = value; renderComponent(comp); }
|
|
}
|
|
|
|
function updateConfig(id, key, value) {
|
|
const comp = components.find(c => c.id === id);
|
|
if (!comp) return;
|
|
if (key === 'label') comp.label = value;
|
|
else if (comp.config) comp.config[key] = value;
|
|
}
|
|
|
|
function removeComponent(id) {
|
|
connections = connections.filter(c => c.from.compId !== id && c.to.compId !== id);
|
|
components = components.filter(c => c.id !== id);
|
|
components.forEach(c => { Object.keys(c.ports || {}).forEach(k => { if (c.ports[k]?.compId === id) c.ports[k] = null; }); });
|
|
document.getElementById(id)?.remove();
|
|
if (selectedId === id) { selectedId = null; renderConfigPanel(); }
|
|
renderConnections();
|
|
}
|
|
|
|
async function calculate() {
|
|
const config = {
|
|
components: components.map(c => ({
|
|
id: c.id, type: c.type, label: c.label,
|
|
config: { ...c.config, fluid: c.config.fluid || 'R134a' },
|
|
ports: c.ports,
|
|
connections: connections.filter(x => x.from.compId === c.id || x.to.compId === c.id)
|
|
}))
|
|
};
|
|
const out = document.getElementById('json-output');
|
|
if (components.length === 0) { out.value = 'Ajoutez des composants puis cliquez Calculer.'; return; }
|
|
try {
|
|
const r = await fetch('/api/calculate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ components: config.components.map(c => ({ id: c.id, type: c.type, label: c.label, config: c.config })) }) });
|
|
const data = await r.json();
|
|
let text = '';
|
|
for (const res of data.results) {
|
|
text += `\n=== ${res.label} (${res.type}) ===\n`;
|
|
if (res.error) text += 'Erreur: ' + res.error + '\n';
|
|
else text += JSON.stringify(res.results, null, 2) + '\n';
|
|
}
|
|
out.value = text.trim() || JSON.stringify(data, null, 2);
|
|
} catch (e) {
|
|
out.value = 'Erreur: ' + e.message + '\n\nLancez: cargo run -p entropyk-demo --bin ui-server';
|
|
}
|
|
}
|
|
|
|
function exportJson() {
|
|
document.getElementById('json-output').value = JSON.stringify({
|
|
components: components.map(c => ({ ...c, config: c.config })),
|
|
connections
|
|
}, null, 2);
|
|
}
|
|
|
|
function clearAll() {
|
|
components = [];
|
|
connections = [];
|
|
selectedId = null;
|
|
document.querySelectorAll('#components-container .component').forEach(el => el.remove());
|
|
renderConfigPanel();
|
|
renderConnections();
|
|
document.getElementById('json-output').value = '';
|
|
}
|
|
|
|
canvas.addEventListener('mousedown', (e) => {
|
|
if (!e.target.closest('.component') && !e.target.closest('.config-panel')) {
|
|
selectedId = null;
|
|
document.querySelectorAll('.component').forEach(el => el.classList.remove('selected'));
|
|
renderConfigPanel();
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|