Some checks failed
Build & Deploy / Validation, build et push (push) Failing after 5m8s
443 lines
18 KiB
JavaScript
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);
|
|
}
|
|
}
|
|
}
|
|
|