diff --git a/app/assets/controllers/calculateur_controller.js b/app/assets/controllers/calculateur_controller.js new file mode 100644 index 0000000..f2341bc --- /dev/null +++ b/app/assets/controllers/calculateur_controller.js @@ -0,0 +1,443 @@ +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); + } + } +} + diff --git a/app/assets/js/calcultateur_create.js b/app/assets/js/calcultateur_create.js new file mode 100644 index 0000000..4878e6e --- /dev/null +++ b/app/assets/js/calcultateur_create.js @@ -0,0 +1,233 @@ +"use strict"; +(function(){ +const root = document.querySelector(".aero-calc"); +if (!root) { + console.error('calculateur legacy: .aero-calc container not found'); + return; +} +const $ = id => { + const el = root.querySelector("#"+id); + // silencieusement retourner null si l'élément n'existe pas + return el; +}; + +const C = {}; +function readConsts(){ + try { + const ct = $("aero-ct"); if(ct) C.CT=parseFloat(ct.value); else console.warn('aero-ct not found'); + const sair = $("aero-sair"); if(sair) C.sAir=parseFloat(sair.value); else console.warn('aero-sair not found'); + const g = $("aero-g"); if(g) C.g=parseFloat(g.value); else console.warn('aero-g not found'); + const sea = $("aero-sea"); if(sea) C.sea=parseFloat(sea.value); else console.warn('aero-sea not found'); + const k = $("aero-k"); if(k) C.k=parseFloat(k.value); else console.warn('aero-k not found'); + const pmax = $("aero-pmax"); if(pmax) C.pMax=parseFloat(pmax.value); else console.warn('aero-pmax not found'); + const ytop = $("aero-ytop"); if(ytop) C.yTop=parseFloat(ytop.value); else console.warn('aero-ytop not found'); + const ybot = $("aero-ybot"); if(ybot) C.yBot=parseFloat(ybot.value); else console.warn('aero-ybot not found'); + } catch(e) { console.error('readConsts error:', e); } +} + +function pressure(y){ let p=Math.exp(C.k*(y-C.sea)); if(p>C.pMax)p=C.pMax; return p<0?0:p; } +// Le mod echantillonne la pression au CENTRE du bloc : getBlockPos().getCenter() -> y+0.5 +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); +function 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}); +function big(x){ if(!isFinite(x)) return "n/a"; return Math.abs(x)>=1000?nf.format(x):nf2.format(x); } + +function updateProp(){ + try { + const sails = $("aero-sails"); const P = sails ? parseFloat(sails.value)||0 : 0; + const rpm = $("aero-rpm"); const N = rpm ? parseFloat(rpm.value)||0 : 0; + const palt = $("aero-palt"); const y = palt ? parseFloat(palt.value)||0 : 0; + const T=propThrust(P,N); + const thrust = $("aero-othrust"); if(thrust) thrust.innerHTML=big(Math.abs(T))+' pN'; + const thrustapp = $("aero-othrustapp"); if(thrustapp) thrustapp.innerHTML=big(Math.abs(T)*pressure(y+Y_OFFSET))+' pN'; + } catch(e) { console.error('updateProp error:', e); } +} +function updateBalloon(){ + try { + const vol = $("aero-vol"); const V = vol ? parseFloat(vol.value)||0 : 0; + const balt = $("aero-balt"); const y = balt ? parseFloat(balt.value)||0 : 0; + const force = $("aero-oforce"); if(force) force.innerHTML=big(balloonForce(V,y))+' pN'; + const lift = $("aero-olift"); if(lift) lift.innerHTML=big(balloonLift(V,y))+' kpg'; + const press = $("aero-opress"); if(press) press.textContent=nf3.format(pressure(y)); + } catch(e) { console.error('updateBalloon error:', e); } +} +function updateVolume(){ + try { + const vmass = $("aero-vmass"); const m = vmass ? parseFloat(vmass.value)||0 : 0; + const valt = $("aero-valt"); const y = valt ? parseFloat(valt.value)||0 : 0; + const V=requiredVolume(m,y); + const volreq = $("aero-ovolreq"); if(volreq) volreq.innerHTML=big(V)+' '; + const cube = $("aero-ocube"); 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" + : ""; + const warn = $("aero-vwarn"); if(warn) warn.innerHTML=(pressure(y)<=0) + ? "Altitude hors atmosphère : pression nulle, aucun volume fini ne suffit." : ""; + } catch(e) { console.error('updateVolume error:', e); } +} + +// ---- graphe ---- +let mode="mass"; +const canvas=$("aero-chart"), tip=$("aero-tip"); +if (!canvas) { + console.error('calculateur legacy: canvas not found, graph disabled'); +} +const ctx = canvas ? canvas.getContext("2d") : null; +let geom=null; +const tv=n=>getComputedStyle(root).getPropertyValue(n).trim()||"#888"; + +function 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; } +function 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); } + +function drawChart(){ +if(!canvas||!ctx) { console.warn('calculateur legacy: graph not available'); 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 fg=tv("--aero-fg"), 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(); + +function refLine(yv,label){ +if(yvyMax) 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}; +} + +if(canvas && tip) { +canvas.addEventListener("mousemove",e=>{ +if(!geom)return; const rect=canvas.getBoundingClientRect(); const mx=e.clientX-rect.left; +if(mxgeom.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); +let txt = 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.textContent=txt; 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;}); +} + +const modeSelector = $("aero-mode"); +// Affiche uniquement l'input correspondant au mode courant +function 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) ---- +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])); +function applyUrlParams(){ +const params = new URLSearchParams(window.location.search); +paramInputs.forEach(el=>{ const k=keyOf(el); if(params.has(k)) el.value=params.get(k); }); +} +function 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(); + window.history.replaceState(null, "", window.location.pathname + (qs?"?"+qs:"") + window.location.hash); +} catch(e){ console.error('calculateur legacy: syncUrl error:', e); } +} + +if(modeSelector) { +modeSelector.addEventListener("change",e=>{ +mode=e.target.value; +applyModeVisibility(); +drawChart(); +syncUrl(); +}); +} + +root.querySelectorAll("input,select").forEach(el=>el.addEventListener("input",()=>{ +try { + readConsts(); updateProp(); updateBalloon(); updateVolume(); drawChart(); syncUrl(); +} catch(e) { + console.error('calculateur legacy: error during input event:', e); +} +})); +window.addEventListener("resize",()=>{ if(geom) drawChart(); }); + +try { + applyUrlParams(); + if(modeSelector) mode = modeSelector.value; + applyModeVisibility(); + readConsts(); updateProp(); updateBalloon(); updateVolume(); drawChart(); +} catch(e) { + console.error('calculateur legacy: error during initialization:', e); +} +})(); diff --git a/app/assets/styles/calculator/index.css b/app/assets/styles/calculator/index.css new file mode 100644 index 0000000..cdc01de --- /dev/null +++ b/app/assets/styles/calculator/index.css @@ -0,0 +1,534 @@ +/* ================================================================ + assets/styles/calculator/index.css + Styles du calculateur Create Aeronautics + Cohérent avec le système « bulle » + tokens globaux (app.css). + Lisible en thème clair ET sombre. + ================================================================ */ + +/* ── TOKENS DU GRAPHE ─────────────────────────────────────────── + Lus en JS via getComputedStyle(.aero-calc) pour dessiner le canvas + (accent, warn, muted, border, fg). Doivent exister pour les 2 thèmes. */ +.aero-calc { + --aero-fg: var(--text-light); + --aero-muted: var(--muted-light); + --aero-border: rgba(0, 0, 0, 0.14); + --aero-accent: var(--violet); + --aero-warn: #ef4444; +} + +[data-theme="dark"] .aero-calc { + --aero-fg: var(--text-dark); + --aero-muted: var(--muted-dark); + --aero-border: rgba(255, 255, 255, 0.16); + --aero-accent: var(--violet-light); + --aero-warn: #f87171; +} + +/* Couleur de texte par défaut des bulles en thème clair + (les bulles ne fixent la couleur qu'en sombre) */ +.aero-calc .bulle { + color: var(--text-light); +} + +[data-theme="dark"] .aero-calc .bulle { + color: var(--text-dark); +} + +/* ── EN-TÊTE ────────────────────────────────────────────────────── */ +.aero-calc .bulle-header { + text-align: center; + gap: 0.5rem; +} + +.aero-calc h1 { + font-family: 'Syne', sans-serif; + font-size: clamp(1.5rem, 3vw, 2rem); + font-weight: 800; + letter-spacing: -0.01em; + color: var(--text-light); + transition: color var(--duration-slow); +} + +[data-theme="dark"] .aero-calc h1 { + color: var(--text-dark); +} + +.aero-calc .ac-sub { + max-width: 62ch; + font-size: 0.9rem; + line-height: 1.5; + color: var(--muted-light); + transition: color var(--duration-slow); +} + +[data-theme="dark"] .aero-calc .ac-sub { + color: var(--muted-dark); +} + +/* ── TITRES DE SECTION ──────────────────────────────────────────── */ +.aero-calc .ac-h2, +.aero-calc .ac-h3 { + font-family: 'Syne', sans-serif; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--text-light); + transition: color var(--duration-slow); +} + +.aero-calc .ac-h2 { font-size: 1.25rem; } +.aero-calc .ac-h3 { font-size: 1.05rem; } + +[data-theme="dark"] .aero-calc .ac-h2, +[data-theme="dark"] .aero-calc .ac-h3 { + color: var(--text-dark); +} + +/* Filet accent sous les titres de section principaux */ +.aero-calc .ac-h2 { + display: inline-block; + padding-bottom: 0.35rem; + border-bottom: 2px solid var(--violet); + margin-bottom: 0.35rem; +} + +/* ── TEXTES D'AIDE (hint / desc / note) ─────────────────────────── */ +.aero-calc .ac-hint, +.aero-calc .ac-desc, +.aero-calc .ac-note, +.aero-calc .ac-cube { + font-size: 0.82rem; + line-height: 1.5; + color: var(--muted-light); + transition: color var(--duration-slow); +} + +[data-theme="dark"] .aero-calc .ac-hint, +[data-theme="dark"] .aero-calc .ac-desc, +[data-theme="dark"] .aero-calc .ac-note, +[data-theme="dark"] .aero-calc .ac-cube { + color: var(--muted-dark); +} + +.aero-calc .ac-desc { margin-bottom: 1rem; } +.aero-calc .ac-hint { margin-bottom: 0.85rem; } +.aero-calc .ac-note { margin-top: 0.85rem; } +.aero-calc .ac-cube { margin-top: 0.4rem; font-style: italic; } + +.aero-calc .ac-note b { color: var(--violet); font-weight: 600; } +[data-theme="dark"] .aero-calc .ac-note b { color: var(--violet-light); } + +/* ── CONSTANTES (details / summary) ─────────────────────────────── */ +.aero-calc .ac-consts { + border: 1px solid var(--border-light); + border-radius: 12px; + padding: 0.25rem 1rem; + transition: border-color var(--duration-slow), background var(--duration-slow); +} + +[data-theme="dark"] .aero-calc .ac-consts { + border-color: var(--border-dark); +} + +.aero-calc .ac-consts summary { + cursor: pointer; + list-style: none; + padding: 0.75rem 0; + font-family: 'Syne', sans-serif; + font-weight: 600; + font-size: 0.95rem; + color: var(--text-light); + display: flex; + align-items: center; + gap: 0.5rem; + transition: color var(--duration-slow); +} + +[data-theme="dark"] .aero-calc .ac-consts summary { + color: var(--text-dark); +} + +.aero-calc .ac-consts summary::-webkit-details-marker { display: none; } + +/* Chevron custom qui pivote à l'ouverture */ +.aero-calc .ac-consts summary::before { + content: ''; + width: 0.5rem; + height: 0.5rem; + border-right: 2px solid var(--violet); + border-bottom: 2px solid var(--violet); + transform: rotate(-45deg); + transition: transform var(--duration-normal); + flex-shrink: 0; +} + +[data-theme="dark"] .aero-calc .ac-consts summary::before { + border-color: var(--violet-light); +} + +.aero-calc .ac-consts[open] summary::before { + transform: rotate(45deg); +} + +.aero-calc .ac-consts-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.85rem; + padding: 0.5rem 0 1rem; +} + +/* ── CHAMPS (label + input) ─────────────────────────────────────── */ +.aero-calc .ac-field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.aero-calc .ac-field label { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--text-light); + transition: color var(--duration-slow); +} + +[data-theme="dark"] .aero-calc .ac-field label { + color: var(--text-dark); +} + +/* Unité affichée à droite du label */ +.aero-calc .ac-field label .u { + font-weight: 500; + font-size: 0.72rem; + color: var(--muted-light); + white-space: nowrap; + transition: color var(--duration-slow); +} + +[data-theme="dark"] .aero-calc .ac-field label .u { + color: var(--muted-dark); +} + +.aero-calc .ac-field input, +.aero-calc .ac-field select, +.aero-calc .ac-controls select { + width: 100%; + padding: 0.55rem 0.8rem; + border-radius: 10px; + border: 1px solid var(--border-light); + background-color: var(--nav-bg-light); + color: var(--text-light); + font-family: 'DM Sans', sans-serif; + font-size: 0.9rem; + outline: none; + appearance: none; + transition: border-color var(--duration-normal), box-shadow var(--duration-normal), + background var(--duration-slow), color var(--duration-slow); +} + +[data-theme="dark"] .aero-calc .ac-field input, +[data-theme="dark"] .aero-calc .ac-field select, +[data-theme="dark"] .aero-calc .ac-controls select { + background-color: var(--nav-bg-dark); + border-color: var(--border-dark); + color: var(--text-dark); +} + +.aero-calc .ac-field input:focus, +.aero-calc .ac-field select:focus { + border-color: var(--violet); + box-shadow: 0 0 0 3px var(--violet-glow); +} + +/* Flèche des +
+
+
+
+
+
+
+ + + + +
+ +
+

Hélice

+

Poussée le long de l'axe du bearing.

+
+
+
+
Poussée brute
0 pN
+
Appliquée à l'arrêt (× P_air)
0 pN
+
+
+
+
+

Ballon chauffé

+

Pleinement chauffé : volume rempli = capacité V.

+
+
+
Force ascensionnelle brute
0 pN
+
Portance (masse soulevable)
0 kpg
+
Pression locale
1.000
+
+
+ +
+

Altitude maximale du ballon

+

Altitude de flottaison neutre où la portance compense le poids.

+ +
+
+ + +
+
+
+
+ +
+ +
+
+
+ altitude max + repères (mer, limite) + ne décolle pas +
+

m kpg = m blocs standard (masse défaut 1 kpg/bloc). L'atténuation de pression sur les 40 derniers mètres sous la limite haute n'est pas modélisée.

+
+ + +
+

Volume requis

+

Volume d'air chaud minimal pour flotter à une altitude visée.

+
+
+
+
+
+
+
Volume requis
0
+

+
+
+

+
+ +
+

Formules

+
+
Poussée hélice brute
T = C_T · P^(3/2) · N
+
Appliquée (à l'arrêt, A_flux = 1)
F = T · A_flux · P_air
+
Force ballon brute (pleinement chauffé)
F = V · s_air · g · P_air(y)
+
Portance (masse)
L = V · s_air · P_air(y)
+
Pression atmosphérique
P_air(y) = min( e^(k·(y − y_mer)) , P_max )
+
Altitude max (flottaison neutre)
y_max = y_mer + ln( V·s_air / m ) / k
+
Volume requis
V = m / ( s_air · P_air(y) )
+
+
+ + +{% endblock %} + +{% block javascripts %} + {{ parent() }} + +{% endblock %} + diff --git a/app/templates/partials/_header.html.twig b/app/templates/partials/_header.html.twig index bc00366..06916ea 100644 --- a/app/templates/partials/_header.html.twig +++ b/app/templates/partials/_header.html.twig @@ -39,12 +39,18 @@ > Statistiques - Actualité + + Calculateur +