import { Controller } from '@hotwired/stimulus'; export default class extends Controller { #canvas; #tip; #resizeHandler; connect() { const root = this.element; const $ = id => root.querySelector("#" + id); // debug try{ console.debug && console.debug('calculateur: connect', root); }catch(e){} // Store references for cleanup this.#canvas = $("aero-chart"); this.#tip = $("aero-tip"); const C = {}; const readConsts = () => { try { const ct = $("aero-ct"); if(ct) C.CT=parseFloat(ct.value); const sair = $("aero-sair"); if(sair) C.sAir=parseFloat(sair.value); const g = $("aero-g"); if(g) C.g=parseFloat(g.value); const sea = $("aero-sea"); if(sea) C.sea=parseFloat(sea.value); const k = $("aero-k"); if(k) C.k=parseFloat(k.value); const pmax = $("aero-pmax"); if(pmax) C.pMax=parseFloat(pmax.value); const ytop = $("aero-ytop"); if(ytop) C.yTop=parseFloat(ytop.value); const ybot = $("aero-ybot"); if(ybot) C.yBot=parseFloat(ybot.value); } catch(err) { console.error('calculateur:readConsts error:', err); } }; const pressure = (y) => { let p = Math.exp(C.k * (y - C.sea)); if (p > C.pMax) p = C.pMax; return p < 0 ? 0 : p; }; const Y_OFFSET = 0.5; const propThrust = (P, N) => C.CT * Math.pow(Math.max(P, 0), 1.5) * N; const balloonForce = (V, y) => Math.max(V, 0) * C.sAir * C.g * pressure(y); const balloonLift = (V, y) => Math.max(V, 0) * C.sAir * pressure(y); const maxAltitude = (V, m) => { if (V <= 0 || m <= 0) return { y: C.yTop, status: "ceiling" }; const t = m / (V * C.sAir); if (t > C.pMax) return { y: C.yBot, status: "grounded" }; let y = C.sea + Math.log(t) / C.k; if (y >= C.yTop) return { y: C.yTop, status: "ceiling" }; if (y <= C.yBot) return { y: C.yBot, status: "bottom" }; return { y, status: "ok" }; }; const requiredVolume = (m, y) => m / (C.sAir * pressure(y)); const nf = new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }); const nf2 = new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 2 }); const nf3 = new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 3 }); const big = (x) => { if (!isFinite(x)) return "n/a"; return Math.abs(x) >= 1000 ? nf.format(x) : nf2.format(x); }; const updateProp = () => { try { const sails = $("aero-sails"); const rpm = $("aero-rpm"); const palt = $("aero-palt"); const P = sails ? parseFloat(sails.value) || 0 : 0; const N = rpm ? parseFloat(rpm.value) || 0 : 0; const y = palt ? parseFloat(palt.value) || 0 : 0; const T = propThrust(P, N); const thrust = $("aero-othrust"); const thrustapp = $("aero-othrustapp"); if (thrust) thrust.innerHTML = big(Math.abs(T)) + ' pN'; if (thrustapp) thrustapp.innerHTML = big(Math.abs(T) * pressure(y + Y_OFFSET)) + ' pN'; try { console.debug && console.debug('calculateur:updateProp', { P, N, y, T }); } catch (e) {} } catch(err) { console.error('calculateur:updateProp error:', err); } }; const updateBalloon = () => { try { const vol = $("aero-vol"); const balt = $("aero-balt"); const V = vol ? parseFloat(vol.value) || 0 : 0; const y = balt ? parseFloat(balt.value) || 0 : 0; const force = $("aero-oforce"); const lift = $("aero-olift"); const press = $("aero-opress"); if (force) force.innerHTML = big(balloonForce(V, y)) + ' pN'; if (lift) lift.innerHTML = big(balloonLift(V, y)) + ' kpg'; if (press) press.textContent = nf3.format(pressure(y)); try { console.debug && console.debug('calculateur:updateBalloon', { V, y }); } catch (e) {} } catch(err) { console.error('calculateur:updateBalloon error:', err); } }; const updateVolume = () => { try { const vmass = $("aero-vmass"); const valt = $("aero-valt"); const m = vmass ? parseFloat(vmass.value) || 0 : 0; const y = valt ? parseFloat(valt.value) || 0 : 0; const V = requiredVolume(m, y); const volreq = $("aero-ovolreq"); const cube = $("aero-ocube"); const warn = $("aero-vwarn"); if (volreq) volreq.innerHTML = big(V) + ' '; if (cube) cube.textContent = (isFinite(V) && V > 0) ? "≈ cube de " + nf2.format(Math.cbrt(V)) + " blocs de côté · " + nf.format(Math.ceil(V)) + " blocs d'air" : ""; if (warn) warn.innerHTML = (pressure(y) <= 0) ? "Altitude hors atmosphère : pression nulle, aucun volume fini ne suffit." : ""; try { console.debug && console.debug('calculateur:updateVolume', { m, y, V }); } catch (e) {} } catch(err) { console.error('calculateur:updateVolume error:', err); } }; // ---- graphe ---- let mode = "mass"; // prefer document-level IDs for canvas/tip (unique across page) let canvas = null; let tip = null; let ctx = null; let geom = null; try { canvas = document.getElementById("aero-chart"); tip = document.getElementById("aero-tip"); if (!canvas || !tip) { throw new Error('canvas or tip element not found'); } this.#canvas = canvas; this.#tip = tip; ctx = canvas.getContext("2d"); if (!ctx) { throw new Error('2d context not available'); } } catch (err) { console.error('calculateur: graph init failed:', err.message); try{ root.dataset.calculateurConnected = '1'; }catch(e){} // mark as connected but disable graph features canvas = null; tip = null; ctx = null; } const tv = (n) => getComputedStyle(root).getPropertyValue(n).trim() || "#888"; const niceStep = (raw) => { const p = Math.pow(10, Math.floor(Math.log10(raw))); const n = raw / p; return (n < 1.5 ? 1 : n < 3 ? 2 : n < 7 ? 5 : 10) * p; }; const abbr = (v) => { const a = Math.abs(v); if (a >= 1e6) return nf2.format(v / 1e6) + "M"; if (a >= 1e3) return nf2.format(v / 1e3) + "k"; return nf.format(v); }; const drawChart = () => { // skip if graph not available if (!canvas || !ctx) return; const dpr = window.devicePixelRatio || 1; const cssW = canvas.parentElement.clientWidth; const cssH = Math.max(260, Math.round(cssW * 0.44)); canvas.style.height = cssH + "px"; canvas.width = Math.round(cssW * dpr); canvas.height = Math.round(cssH * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, cssW, cssH); const padL = 54, padR = 16, padT = 14, padB = 38, pw = cssW - padL - padR, ph = cssH - padT - padB; const muted = tv("--aero-muted"), border = tv("--aero-border"), accent = tv("--aero-accent"), warn = tv("--aero-warn"); let xMin, xMax, xLabel, fixed; if (mode === "mass") { const fixvol = $("aero-fixvol"); fixed = Math.max(1, fixvol ? parseFloat(fixvol.value) || 1 : 1); xMin = 1; xMax = Math.max(2, fixed * C.sAir * C.pMax * 1.15); xLabel = "poids m (kpg)"; } else { const fixmass = $("aero-fixmass"); fixed = Math.max(1, fixmass ? parseFloat(fixmass.value) || 1 : 1); xMin = Math.max(1, fixed / (C.sAir * C.pMax) * 0.5); xMax = Math.max(xMin * 1.5, 4 * fixed / C.sAir); xLabel = "volume V (m³)"; } const yMin = C.yBot, yMax = C.yTop; const X = x => padL + (x - xMin) / (xMax - xMin) * pw; const Y = y => padT + (1 - (y - yMin) / (yMax - yMin)) * ph; ctx.font = '11px ' + (getComputedStyle(root).fontFamily || "sans-serif"); // grille ctx.strokeStyle = border; ctx.fillStyle = muted; ctx.lineWidth = 1; ctx.textBaseline = "middle"; ctx.textAlign = "right"; const yStep = niceStep((yMax - yMin) / 6); for (let yy = Math.ceil(yMin / yStep) * yStep; yy <= yMax; yy += yStep) { const py = Y(yy); ctx.beginPath(); ctx.moveTo(padL, py); ctx.lineTo(padL + pw, py); ctx.stroke(); ctx.fillText(String(yy), padL - 7, py); } ctx.textAlign = "center"; ctx.textBaseline = "top"; const xStep = niceStep((xMax - xMin) / 6); for (let xx = Math.ceil(xMin / xStep) * xStep; xx <= xMax; xx += xStep) { const px = X(xx); ctx.beginPath(); ctx.moveTo(px, padT); ctx.lineTo(px, padT + ph); ctx.stroke(); ctx.fillText(abbr(xx), px, padT + ph + 7); } ctx.fillStyle = muted; ctx.fillText(xLabel, padL + pw / 2, padT + ph + 22); ctx.save(); ctx.translate(13, padT + ph / 2); ctx.rotate(-Math.PI / 2); ctx.fillText("altitude max y", 0, 0); ctx.restore(); const refLine = (yv, label) => { if (yv < yMin || yv > yMax) return; const py = Y(yv); ctx.strokeStyle = muted; ctx.setLineDash([4, 4]); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(padL, py); ctx.lineTo(padL + pw, py); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = muted; ctx.textAlign = "left"; ctx.textBaseline = "bottom"; ctx.fillText(label, padL + 5, py - 2); }; refLine(C.sea, "niveau mer " + C.sea); refLine(C.yTop, "limite " + C.yTop); // courbe const steps = 240, pts = []; for (let i = 0; i <= steps; i++) { const x = xMin + (xMax - xMin) * i / steps; const r = (mode === "mass") ? maxAltitude(fixed, x) : maxAltitude(x, fixed); pts.push({ x, y: r.y, status: r.status }); } // zone grounded ctx.fillStyle = warn; ctx.globalAlpha = .10; let gs = null; for (let i = 0; i <= steps; i++) { const g = pts[i].status === "grounded"; if (g && gs === null) gs = pts[i].x; if ((!g || i === steps) && gs !== null) { ctx.fillRect(X(gs), padT, X(pts[i].x) - X(gs), ph); gs = null; } } ctx.globalAlpha = 1; ctx.lineWidth = 2; ctx.strokeStyle = accent; ctx.beginPath(); let started = false; for (const p of pts) { if (p.status === "grounded") { started = false; continue; } const px = X(p.x), py = Y(p.y); if (!started) { ctx.moveTo(px, py); started = true; } else { ctx.lineTo(px, py); } } ctx.stroke(); geom = { padL, pw, padT, ph, xMin, xMax, X, Y, fixed }; }; // Event listeners (only if canvas available) if (canvas && tip) { canvas.addEventListener("mousemove", e => { if (!geom) return; const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; if (mx < geom.padL || mx > geom.padL + geom.pw) { tip.style.opacity = 0; return; } const x = geom.xMin + (mx - geom.padL) / geom.pw * (geom.xMax - geom.xMin); const r = (mode === "mass") ? maxAltitude(geom.fixed, x) : maxAltitude(x, geom.fixed); tip.textContent = r.status === "grounded" ? abbr(x) + " : ne décolle pas" : r.status === "ceiling" ? abbr(x) + " : plafond (y " + Math.round(r.y) + ")" : abbr(x) + " : y " + Math.round(r.y); tip.style.left = geom.X(x) + "px"; tip.style.top = geom.Y(r.y) + "px"; tip.style.opacity = 1; }); canvas.addEventListener("mouseleave", () => { tip.style.opacity = 0; }); } else { if (!canvas) console.warn('calculateur: canvas not available, graph interactions disabled'); if (!tip) console.warn('calculateur: tip not available, graph tooltip disabled'); } // Affiche uniquement l'input correspondant au mode courant const applyModeVisibility = () => { const fixvolwrap = $("aero-fixvolwrap"); const fixmasswrap = $("aero-fixmasswrap"); if (fixvolwrap) fixvolwrap.style.display = mode === "mass" ? "" : "none"; if (fixmasswrap) fixmasswrap.style.display = mode === "vol" ? "" : "none"; }; // ---- Partage par URL (paramètres GET, tous optionnels) ---- // Clé = id de l'input sans le préfixe "aero-". On ne sérialise que les // valeurs qui diffèrent de leur valeur par défaut → URLs courtes. const paramInputs = Array.from(root.querySelectorAll("input, select")) .filter(el => el.id && el.id.startsWith("aero-")); const keyOf = el => el.id.slice(5); const defaults = new Map(paramInputs.map(el => [el, el.value])); // Applique les paramètres présents dans l'URL aux inputs (au chargement) const applyUrlParams = () => { const params = new URLSearchParams(window.location.search); paramInputs.forEach(el => { const k = keyOf(el); if (params.has(k)) el.value = params.get(k); }); }; // Reflète l'état courant des inputs dans l'URL (sans polluer l'historique) const syncUrl = () => { try { const params = new URLSearchParams(); paramInputs.forEach(el => { if (el.value !== defaults.get(el)) params.set(keyOf(el), el.value); }); const qs = params.toString(); const url = window.location.pathname + (qs ? "?" + qs : "") + window.location.hash; window.history.replaceState(null, "", url); } catch(err) { console.error('calculateur:syncUrl error:', err); } }; // Store event handlers for cleanup const handleModeChange = e => { try { mode = e.target.value; applyModeVisibility(); drawChart(); syncUrl(); } catch(err) { console.error('calculateur:handleModeChange error:', err); } }; const handleInput = () => { try { readConsts(); updateProp(); updateBalloon(); updateVolume(); drawChart(); syncUrl(); } catch(err) { console.error('calculateur:handleInput error:', err); } }; this.#resizeHandler = () => { if (geom) drawChart(); }; const modeEl = $("aero-mode"); if (modeEl) modeEl.addEventListener("change", handleModeChange); root.querySelectorAll("input,select").forEach(el => el.addEventListener("input", handleInput)); window.addEventListener("resize", this.#resizeHandler); // Store handlers for cleanup this.handleModeChange = handleModeChange; this.handleInput = handleInput; this.root = root; this.$ = $; try { // Charge l'état partagé depuis l'URL, puis synchronise le mode applyUrlParams(); if (modeEl) mode = modeEl.value; applyModeVisibility(); readConsts(); updateProp(); updateBalloon(); updateVolume(); drawChart(); } catch(err) { console.error('calculateur: error during initialization:', err); } try{ root.dataset.calculateurConnected = '1'; }catch(e){} } disconnect() { // Clean up event listeners if (this.root && this.$) { const $ = this.$; if (this.handleModeChange) { const modeEl = $("aero-mode"); if (modeEl) modeEl.removeEventListener("change", this.handleModeChange); } if (this.handleInput) { this.root.querySelectorAll("input,select").forEach(el => el.removeEventListener("input", this.handleInput) ); } } if (this.#resizeHandler) { window.removeEventListener("resize", this.#resizeHandler); } } }