mcServerWebsite/app/assets/controllers/calculateur_controller.js
Ploush 10976f6145
Some checks failed
Build & Deploy / Validation, build et push (push) Failing after 5m8s
Add Create mod calculator
2026-07-07 14:49:38 +02:00

443 lines
18 KiB
JavaScript

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)) + ' <small>pN</small>';
if (thrustapp) thrustapp.innerHTML = big(Math.abs(T) * pressure(y + Y_OFFSET)) + ' <small>pN</small>';
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)) + ' <small>pN</small>';
if (lift) lift.innerHTML = big(balloonLift(V, y)) + ' <small>kpg</small>';
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) + ' <small>m³</small>';
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)
? "<b>Altitude hors atmosphère :</b> 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);
}
}
}