HomeMAL' // MANUFACTORUM
datacrypt
Ballistic Cogitator / index.html
index.html63.6 KB
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Ballistic Cogitator</title>
    <meta name="description" content="Predict aim offset for archery — arrow flight model with component drag">
    <style>
        @font-face {
            font-family: 'Share Tech Mono';
            font-style: normal;
            font-weight: 400;
            src: url(lib/fonts/share-tech-mono.woff2) format('woff2');
        }
        :root {
            --bg:          #050a05;
            --surface:     #0a120a;
            --border:      #1e3a1e;
            --border-hi:   #2e5a2e;
            --red:         #cc3030;
            --red-dim:     #6b1a1a;
            --green:       #33dd55;
            --green-glow:  0 0 8px rgba(51, 221, 85, 0.4);
            --text:        #7fbb7f;
            --text-bright: #aaddaa;
            --text-dim:    #4a7a4a;
            --label:       #55bb55;
            --amber:       #ccaa22;
            --amber-dim:   #8a7718;
            --mono:        'Share Tech Mono', 'Courier', monospace;
            --serif:       'Palatino Linotype', 'Palatino', 'Book Antiqua', Georgia, serif;
        }

        /* ── scanline CRT overlay ── */
        @keyframes flicker {
            0%, 100% { opacity: 0.97; }
            50%      { opacity: 1; }
        }
        @keyframes glow-pulse {
            0%, 100% { text-shadow: 0 0 20px rgba(51, 204, 85, 0.4), 0 0 40px rgba(51, 204, 85, 0.15); }
            50%      { text-shadow: 0 0 25px rgba(51, 204, 85, 0.6), 0 0 50px rgba(51, 204, 85, 0.25); }
        }

        *, *::before, *::after { box-sizing: border-box; }

        body {
            background: #020502;
            color: var(--text);
            font-family: var(--mono);
            font-size: 19px;
            line-height: 1.6;
            margin: 0;
            padding: 0;
            -webkit-font-smoothing: none;
            -moz-osx-font-smoothing: unset;
        }
        /* scanline overlay */
        body::after {
            content: '';
            position: fixed;
            top: 0; left: 0; right: 0; bottom: 0;
            background: repeating-linear-gradient(
                0deg,
                rgba(0, 8, 0, 0.18) 0px,
                rgba(0, 8, 0, 0.18) 1px,
                transparent 1px,
                transparent 3px
            );
            pointer-events: none;
            z-index: 9999;
        }
        /* noise grain overlay */
        body::before {
            content: '';
            position: fixed;
            top: 0; left: 0; right: 0; bottom: 0;
            opacity: 0.03;
            background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
            pointer-events: none;
            z-index: 9998;
        }

        /* ── cogitator screen frame ── */
        #screen {
            max-width: 860px;
            margin: 12px auto;
            padding: 0;
            position: relative;
            background: var(--bg);
            border: 1px solid #3a7a3a;
            border-radius: 18px;
            box-shadow: inset 0 0 80px rgba(0,12,0,0.5), 0 0 25px rgba(30,120,30,0.25), 0 0 60px rgba(0,0,0,0.7);
            animation: flicker 4s ease-in-out infinite;
            overflow: hidden;
        }
        /* CRT glass reflection — convex highlight */
        #screen::before {
            content: '';
            position: absolute;
            top: 0; left: 0; right: 0; bottom: 0;
            border-radius: 18px;
            background: radial-gradient(
                ellipse 120% 60% at 50% 15%,
                rgba(120, 200, 120, 0.04) 0%,
                transparent 60%
            );
            pointer-events: none;
            z-index: 1;
        }
        #screen-inner {
            padding: 0 1.5em 4em;
        }

        /* ── status bar ── */
        #statusbar {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 0.4em 1em;
            font-size: 0.6em;
            text-transform: uppercase;
            letter-spacing: 0.15em;
            color: var(--text-dim);
            border-bottom: 1px solid var(--border);
            background: rgba(0,0,0,0.3);
            user-select: none;
        }
        #statusbar .cog { color: var(--red-dim); }
        #statusbar .active {
            color: var(--red);
            animation: blink-slow 3s ease-in-out infinite;
        }
        @keyframes blink-slow {
            0%, 100% { opacity: 1; }
            50% { opacity: 0.4; }
        }

        /* ── header ── */
        header {
            border-bottom: 1px solid var(--border);
            padding-bottom: 1em;
            margin-bottom: 2em;
            padding-top: 1.5em;
            border-top: 1px solid var(--border);
        }
        h1 {
            font-family: var(--mono);
            text-transform: uppercase;
            letter-spacing: 0.15em;
            color: #44dd66;
            text-shadow: 0 0 15px rgba(51, 204, 85, 0.4), 0 0 30px rgba(51, 204, 85, 0.15);
            font-size: 1.6em;
            font-weight: 400;
            margin: 0;
        }
        header .sub {
            color: var(--text-dim);
            font-size: 0.85em;
            margin-top: 0.3em;
        }

        /* ── controls ── */
        #controls { margin-bottom: 2em; }
        .ctrl-section {
            border: 1px solid var(--border);
            background: var(--surface);
            margin-bottom: 2px;
            border-radius: 8px;
        }
        .ctrl-section > summary,
        .ctrl-header {
            padding: 0.5em 1em;
            font-size: 0.7em;
            text-transform: uppercase;
            letter-spacing: 0.12em;
            color: var(--label);
            user-select: none;
            border-left: 2px solid #1a5a2a;
        }
        .ctrl-section > summary {
            cursor: pointer;
            list-style: none;
        }
        .ctrl-section > summary::-webkit-details-marker { display: none; }
        .ctrl-section > summary::before { content: '+ '; color: var(--text-dim); }
        .ctrl-section[open] > summary::before { content: '- '; }
        .ctrl-grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
            gap: 0.6em 1em;
            padding: 0.3em 1em 0.8em;
        }
        .ctrl-field label {
            display: block;
            font-size: 0.65em;
            text-transform: uppercase;
            letter-spacing: 0.1em;
            color: var(--label);
            margin-bottom: 0.15em;
        }
        .ctrl-field .iw {
            display: flex;
            align-items: center;
            gap: 0.4em;
        }
        /* kill browser spinners */
        input[type=number]::-webkit-inner-spin-button,
        input[type=number]::-webkit-outer-spin-button {
            -webkit-appearance: none; margin: 0;
        }
        input[type=number] { -moz-appearance: textfield; }
        .ctrl-field input,
        .ctrl-field select {
            background: #050a05;
            color: var(--green);
            border: 1px solid var(--border);
            font-family: var(--mono);
            font-size: 13px;
            padding: 0.4em 0.5em;
            width: 85px;
            outline: none;
        }
        .ctrl-field input:focus,
        .ctrl-field select:focus {
            border-color: var(--green);
            box-shadow: 0 0 6px rgba(51, 204, 85, 0.25);
        }
        .ctrl-field select {
            width: auto;
            -webkit-appearance: none;
            -moz-appearance: none;
            appearance: none;
            padding-right: 1.2em;
            background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5'%3E%3Cpath d='M0 0l4 5 4-5z' fill='%234a7a4a'/%3E%3C/svg%3E");
            background-repeat: no-repeat;
            background-position: right 0.4em center;
        }
        .ctrl-field .u {
            font-size: 0.75em;
            color: var(--text-dim);
            white-space: nowrap;
        }
        .unit-dot {
            width: 5px;
            height: 5px;
            border-radius: 50%;
            background: var(--border-hi);
            cursor: pointer;
            flex-shrink: 0;
            transition: background 0.15s;
        }
        .unit-dot:hover {
            background: var(--green);
            box-shadow: 0 0 4px rgba(51, 204, 85, 0.4);
        }
        .u-sec {
            font-size: 0.6em;
            color: var(--text-dim);
            opacity: 0.5;
            white-space: nowrap;
        }
        #compute-btn {
            display: block;
            width: 100%;
            padding: 0.7em;
            margin-top: 4px;
            background: var(--red-dim);
            color: var(--text-bright);
            border: 1px solid var(--red);
            border-radius: 8px;
            font-family: var(--mono);
            font-size: 0.8em;
            text-transform: uppercase;
            letter-spacing: 0.2em;
            cursor: pointer;
            transition: all 0.15s;
        }
        #compute-btn:hover {
            background: var(--red);
            color: #fff;
            box-shadow: 0 0 15px rgba(204, 48, 48, 0.3);
        }
        #compute-btn:active {
            background: #aa2020;
        }

        /* ── primary readout ── */
        #readout {
            text-align: center;
            padding: 1.8em 1em;
            margin-bottom: 2em;
            border: 1px solid var(--border);
            background: var(--surface);
            border-radius: 8px;
        }
        #readout .direction {
            font-size: 0.75em;
            text-transform: uppercase;
            letter-spacing: 0.25em;
            color: var(--label);
            margin-bottom: 0.2em;
        }
        #readout .value {
            font-size: 3.2em;
            font-weight: bold;
            color: var(--green);
            text-shadow: 0 0 20px rgba(51, 204, 85, 0.5);
            line-height: 1.1;
            letter-spacing: 0.08em;
            font-variant-numeric: tabular-nums;
            animation: glow-pulse 3s ease-in-out infinite;
        }
        #readout .value .unit {
            font-size: 0.35em;
            font-weight: 400;
            color: var(--text-dim);
            vertical-align: baseline;
            margin-left: 0.3em;
        }
        #readout .label {
            font-size: 0.8em;
            color: var(--text-dim);
            margin-top: 0.3em;
        }
        #readout.warn { border-color: var(--amber-dim); }
        #readout.warn .value {
            color: var(--amber);
            text-shadow: 0 0 20px rgba(204, 170, 34, 0.4);
            animation: none;
        }

        /* ── flight data grid ── */
        #flight {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 1px;
            background: var(--border);
            border: 1px solid var(--border);
            margin-bottom: 2em;
            border-radius: 8px;
            overflow: hidden;
        }
        .fd {
            background: var(--surface);
            padding: 0.8em 1em;
        }
        .fd .k {
            font-size: 0.75em;
            text-transform: uppercase;
            letter-spacing: 0.15em;
            color: var(--label);
        }
        .fd .v {
            color: var(--green);
            text-shadow: var(--green-glow);
            font-size: 1.1em;
            margin-top: 0.15em;
            letter-spacing: 0.05em;
        }
        .fd .v .sec {
            color: var(--text-dim);
            text-shadow: none;
            font-size: 0.8em;
        }

        /* ── chart ── */
        #chart-wrap {
            border: 1px solid var(--border);
            background: var(--surface);
            padding: 1em 1em 0.5em;
            margin-bottom: 2em;
            border-radius: 8px;
        }
        #chart-wrap .chart-title {
            font-size: 0.75em;
            text-transform: uppercase;
            letter-spacing: 0.15em;
            color: var(--label);
            margin-bottom: 0.6em;
        }
        canvas { max-width: 100%; }

        /* ── collapsible panels (tech details, distance table) ── */
        #tech, .tech-like {
            border: 1px solid var(--border);
            background: var(--surface);
            border-radius: 8px;
            margin-bottom: 2px;
        }
        #tech summary, .tech-like summary {
            padding: 0.8em 1em;
            cursor: pointer;
            font-size: 0.75em;
            text-transform: uppercase;
            letter-spacing: 0.15em;
            color: var(--label);
            list-style: none;
            user-select: none;
        }
        #tech summary::-webkit-details-marker,
        .tech-like summary::-webkit-details-marker { display: none; }
        #tech summary::before, .tech-like summary::before {
            content: '+ ';
            color: var(--text-dim);
        }
        #tech[open] summary::before, .tech-like[open] summary::before { content: '- '; }
        #tech .body, .tech-like .body {
            padding: 0 1em 1em;
        }

        /* ── distance table ── */
        .dist-tbl {
            width: 100%;
            border-collapse: collapse;
            font-size: 0.75em;
        }
        .dist-tbl th {
            text-transform: uppercase;
            letter-spacing: 0.08em;
            color: var(--label);
            border-bottom: 1px solid var(--border);
            padding: 0.4em 0.6em;
            text-align: right;
            font-weight: 400;
        }
        .dist-tbl th:first-child { text-align: left; }
        .dist-tbl td {
            padding: 0.3em 0.6em;
            color: var(--green);
            text-align: right;
            border-bottom: 1px solid rgba(30, 58, 30, 0.3);
        }
        .dist-tbl td:first-child {
            color: var(--text);
            text-align: left;
        }
        .dist-tbl tr.no-sol td {
            color: var(--text-dim);
        }
        .tgroup {
            margin-bottom: 1em;
        }
        .tgroup .th {
            font-size: 0.7em;
            text-transform: uppercase;
            letter-spacing: 0.12em;
            color: var(--label);
            border-bottom: 1px solid var(--border);
            padding-bottom: 0.3em;
            margin-bottom: 0.4em;
        }
        .trow {
            display: flex;
            justify-content: space-between;
            padding: 0.15em 0;
        }
        .trow .tk { color: var(--text-dim); }
        .trow .tv { color: var(--text); }

        /* ── no-solution state ── */
        #nosol {
            text-align: center;
            padding: 3em 1em;
            color: var(--red);
        }
        #nosol .hint {
            color: var(--text-dim);
            font-size: 0.85em;
            margin-top: 0.5em;
        }
    </style>
</head>
<body>
<div id="screen">
    <div id="screen-inner">

    <header>
        <h1>Ballistic Cogitator</h1>
    </header>

    <!-- ── parameter controls ── -->
    <div id="controls">
        <div class="ctrl-section">
            <div class="ctrl-header">Target</div>
            <div class="ctrl-grid">
                <div class="ctrl-field"><label>Distance</label><div class="iw"><input type="number" id="p_targetX" value="70" step="1"><span class="u">m</span></div></div>
                <div class="ctrl-field"><label>Height</label><div class="iw"><input type="number" id="p_targetHeight" value="1.3" step="0.1"><span class="u">m</span></div></div>
                <div class="ctrl-field"><label>Face size</label><div class="iw"><input type="number" id="p_targetSize" value="0.80" step="0.05"><span class="u">m</span></div></div>
            </div>
        </div>
        <div class="ctrl-section">
            <div class="ctrl-header">Bow</div>
            <div class="ctrl-grid">
                <div class="ctrl-field"><label>Draw weight</label><div class="iw"><input type="number" id="p_drawWeight" value="40" step="1"><span class="u">lbs</span></div></div>
                <div class="ctrl-field"><label>Draw length</label><div class="iw"><input type="number" id="p_drawLength" value="28" step="0.5"><span class="u">in</span></div></div>
                <div class="ctrl-field"><label>Brace height</label><div class="iw"><input type="number" id="p_braceHeight" value="8.75" step="0.25"><span class="u">in</span></div></div>
                <div class="ctrl-field"><label>Efficiency</label><div class="iw"><input type="number" id="p_efficiency" value="0.78" step="0.01" min="0" max="1"></div></div>
                <div class="ctrl-field"><label>V&#8320; override</label><div class="iw"><input type="number" id="p_V0Override" value="" step="0.1" placeholder="&#8212;"><span class="u">m/s</span></div></div>
            </div>
        </div>
        <div class="ctrl-section">
            <div class="ctrl-header">Arrow</div>
            <div class="ctrl-grid">
                <div class="ctrl-field"><label>Total mass</label><div class="iw"><input type="number" id="p_arrowMass" value="25" step="0.5"><span class="u">g</span></div></div>
                <div class="ctrl-field"><label>Point mass</label><div class="iw"><input type="number" id="p_pointMass" value="8" step="0.5"><span class="u">g</span></div></div>
                <div class="ctrl-field"><label>Point type</label><div class="iw"><select id="p_pointType"><option value="bullet">Bullet</option><option value="conical">Conical</option><option value="combo">Combo</option></select></div></div>
                <div class="ctrl-field"><label>Tube material</label><div class="iw"><select id="p_tubeMaterial"><option value="carbon">Carbon</option><option value="alu_carbon">Alu/Carbon</option><option value="aluminum">Aluminum</option><option value="wood">Wood</option><option value="bamboo">Bamboo</option></select></div></div>
                <div class="ctrl-field"><label>Tube diameter</label><div class="iw"><input type="number" id="p_tubeDiameter" value="6" step="0.1"><span class="u">mm</span></div></div>
                <div class="ctrl-field"><label>Tube length</label><div class="iw"><input type="number" id="p_tubeLength" value="700" step="5"><span class="u">mm</span></div></div>
                <div class="ctrl-field"><label>Fletching count</label><div class="iw"><input type="number" id="p_fletchCount" value="3" step="1" min="1" max="6"></div></div>
                <div class="ctrl-field"><label>Vane shape</label><div class="iw"><select id="p_fletchShape"><option value="shield">Shield</option><option value="parabolic">Parabolic</option><option value="manchu">Manchu</option></select></div></div>
                <div class="ctrl-field"><label>Vane length</label><div class="iw"><input type="number" id="p_fletchLength" value="50" step="1"><span class="u">mm</span></div></div>
                <div class="ctrl-field"><label>Vane height</label><div class="iw"><input type="number" id="p_fletchHeight" value="10" step="1"><span class="u">mm</span></div></div>
            </div>
        </div>
        <details class="ctrl-section">
            <summary>Environment</summary>
            <div class="ctrl-grid">
                <div class="ctrl-field"><label>Temperature</label><div class="iw"><input type="number" id="p_temperature" value="20" step="1"><span class="u">&#176;C</span></div></div>
                <div class="ctrl-field"><label>Altitude</label><div class="iw"><input type="number" id="p_altitude" value="0" step="50"><span class="u">m</span></div></div>
                <div class="ctrl-field"><label>Humidity</label><div class="iw"><input type="number" id="p_humidity" value="0" step="5" min="0" max="100"><span class="u">%</span></div></div>
                <div class="ctrl-field"><label>Wind (along)</label><div class="iw"><input type="number" id="p_windX" value="0" step="0.5"><span class="u">m/s</span></div></div>
                <div class="ctrl-field"><label>Wind (vertical)</label><div class="iw"><input type="number" id="p_windY" value="0" step="0.5"><span class="u">m/s</span></div></div>
                <div class="ctrl-field"><label>Ground slope</label><div class="iw"><input type="number" id="p_groundSlope" value="0" step="1"><span class="u">&#176;</span></div></div>
                <div class="ctrl-field"><label>Rest height</label><div class="iw"><input type="number" id="p_y0" value="1.5" step="0.1"><span class="u">m</span></div></div>
            </div>
        </details>
        <button id="compute-btn">&#9881; Compute trajectory</button>
    </div>

    <div id="readout"></div>
    <div id="flight"></div>
    <div id="chart-wrap">
        <div class="chart-title">Trajectory</div>
        <canvas id="trajectory_chart"></canvas>
    </div>
    <details id="dist-table" class="tech-like">
        <summary>Distance table</summary>
        <div class="body" id="dist-body"></div>
    </details>
    <details id="tech">
        <summary>Technical details</summary>
        <div class="body" id="tech-body"></div>
    </details>

    <script src="lib/chart.js"></script>
    <script>

    // ================================================================
    //  Configuration  (defaults — overwritten by readParams)
    // ================================================================

    var _temperature   = 20;
    var _altitude      = 0;
    var _humidity      = 0;
    var _windX         = 0;
    var _windY         = 0;
    var _groundSlope   = 0;

    var _bow = {
        drawWeight:  40,
        drawLength:  28,
        braceHeight: 8.75,
        efficiency:  0.78,
    };

    var _V0Override    = null;
    var _y0            = 1.5;

    var _targetX       = 70;
    var _targetHeight  = 1.3;
    var _targetSize    = 0.80;

    var _arrow = {
        mass: 0.025,
        pointMass:     0.008,
        nockMass:      0.001,
        fletchingMass: 0.001,
        tube: { diameter: 0.006, length: 0.70, roughness: 0.00002 },
        point: { diameter: 0.006, type: 'bullet' },
        fletching: {
            length: 0.05, thickness: 0.001, roughness: 0.00001,
            count: 3, area: 0.00028, section: 0.0000035, offset: 0.04,
        },
        nock: { diameter: 0.006 },
    };

    var _dt        = 0.001;
    var _tolerance = 0.001;

    // derived — recomputed in recalculate()
    var _g = 9.81;
    var _pressure, _rho, _eta, _nu, _V0, _arrowProps, _slopeRad, _targetY;

    // ================================================================
    //  Physics — atmosphere
    //  Sources:
    //    Barometric formula:   https://fr.wikipedia.org/wiki/Variation_de_la_pression_atmosph%C3%A9rique_avec_l%27altitude
    //    Air density:          https://fr.wikipedia.org/wiki/Masse_volumique_de_l%27air
    //    Dynamic viscosity:    https://en.wikipedia.org/wiki/Viscosity
    //    Reynolds number:      https://en.wikipedia.org/wiki/Reynolds_number
    // ================================================================

    function pressureAtAltitude(h) {
        var p0 = 101325; var L = 0.0065; var T0 = 288.15; var gM_RL = 5.2559;
        return p0 * Math.pow(1 - L * h / T0, gM_RL);
    }

    function airDensity(temperature, pressure, humidity) {
        var T = temperature + 273.15;
        return (1 / (287.06 * T)) *
            (pressure - 230.617 * humidity * Math.exp((17.5043 * temperature) / (241.2 + temperature)));
    }

    function dynamicViscosity(temperature) {
        return 2.791e-7 * Math.pow(temperature + 273.15, 0.7355);
    }

    function kinematicViscosity(eta, rho) { return eta / rho; }
    function reynolds(velocity, length, kinVisc) { return velocity * length / kinVisc; }
    function circularSection(diameter) { return (Math.PI / 4) * diameter * diameter; }
    function cylinderSurface(length, diameter) { return Math.PI * length * diameter; }

    // ================================================================
    //  Bow — energy model
    // ================================================================

    function computeV0(bow, arrowMass) {
        var F      = bow.drawWeight * 4.44822;
        var stroke = (bow.drawLength - bow.braceHeight) * 0.0254;
        var E      = 0.5 * F * stroke;
        return Math.sqrt(2 * bow.efficiency * E / arrowMass);
    }

    function storedEnergy(bow) {
        var F      = bow.drawWeight * 4.44822;
        var stroke = (bow.drawLength - bow.braceHeight) * 0.0254;
        return 0.5 * F * stroke;
    }

    // ================================================================
    //  Arrow — geometry and inertia
    // ================================================================

    function computeArrowProps(arrow) {
        var L  = arrow.tube.length;
        var mp = arrow.pointMass, mn = arrow.nockMass;
        var mf = arrow.fletchingMass, mt = arrow.mass - mp - mn - mf;
        var fo = arrow.fletching.offset;
        var cg = (mn * 0 + mf * fo + mt * L / 2 + mp * L) / arrow.mass;
        var I = mn * cg * cg
              + mf * (cg - fo) * (cg - fo)
              + (mt * L * L / 12) + mt * (cg - L / 2) * (cg - L / 2)
              + mp * (L - cg) * (L - cg);
        var dFletch = cg - fo;
        return { cg: cg, I: I, dFletch: dFletch, tubeMass: mt };
    }

    // ================================================================
    //  Ground slope
    // ================================================================

    function groundHeight(x) { return x * Math.tan(_slopeRad); }

    // ================================================================
    //  Drag — arrow component model
    //  Sources:
    //    Park (2011):    doi:10.1177/1754337111407124
    //    Arc Academie:   http://arcacademie.nursit.com/spip.php?article122#nh5
    // ================================================================

    var _pointCxTable = {
        bullet:  0.0838918877244576,
        conical: 0.08013553454276547,
        combo:   0.08661708513078327,
    };

    function relativeRoughness(Ra, L) { return Ra / L; }
    function tubeFriction(Ra, L) { return 0.032 * Math.pow(relativeRoughness(Ra, L), 0.2); }

    function tubeCx(V, tube, kinVisc) {
        var Re = reynolds(V, tube.length, kinVisc);
        var S  = cylinderSurface(tube.length, tube.diameter);
        var s  = circularSection(tube.diameter);
        var CxLead;
        if (tube.roughness <= 0.00001 || Re <= 450000) {
            CxLead = (0.031 / Math.pow(Re, 1 / 7)) * (S / s);
        } else {
            CxLead = tubeFriction(tube.roughness, tube.length) * (S / s);
        }
        var CxTrail = 0.029 / Math.sqrt(CxLead);
        return CxLead + CxTrail;
    }

    function pointCx(type) { return _pointCxTable[type] || _pointCxTable.bullet; }

    function fletchingFriction(V, length, kinVisc) {
        var Re = reynolds(V, length, kinVisc);
        return 1 / Math.pow(1.503 * Math.log(Re) - 5.6, 2);
    }

    function fletchingCx(V, arrow, kinVisc) {
        var f = arrow.fletching, stube = circularSection(arrow.tube.diameter);
        var Cf = fletchingFriction(V, f.length, kinVisc);
        var CxLead;
        if (f.roughness <= 0.000007) {
            CxLead = (0.031 / Math.pow(reynolds(V, f.length, kinVisc), 1 / 7)) * (f.section / stube);
        } else {
            CxLead = 2 * f.count * Cf * f.area / stube;
        }
        var CxTrail = f.count * (0.135 / Math.pow(2 * Cf * f.length / f.thickness, 1 / 3)) * (f.section / stube);
        return CxLead + CxTrail;
    }

    function nockCx() { return 0.3; }

    function axialDrag(V, arrow, rho, nu) {
        var q = 0.5 * rho * V * V;
        var stube = circularSection(arrow.tube.diameter);
        var spt = circularSection(arrow.point.diameter);
        var snk = circularSection(arrow.nock.diameter);
        return q * (
            pointCx(arrow.point.type) * spt +
            tubeCx(V, arrow.tube, nu) * stube +
            fletchingCx(V, arrow, nu) * stube +
            nockCx() * snk
        );
    }

    function crossflowDrag(V, alpha, arrow, rho) {
        var sinA = Math.sin(alpha);
        return 0.5 * rho * V * V * 1.2 * arrow.tube.length * arrow.tube.diameter * sinA * sinA;
    }

    // ================================================================
    //  Lift — fletching at angle of attack
    //  Park (2011) figures 9-10
    // ================================================================

    function fletchingCl(alpha) { return 1.25 * Math.sin(2 * alpha); }

    function fletchingLift(V, alpha, arrow, rho) {
        return arrow.fletching.count * 0.5 * rho * V * V * fletchingCl(alpha) * arrow.fletching.area;
    }

    // ================================================================
    //  Trajectory — RK4 numerical integration
    //  State: [x, y, vx, vy, theta, omega]
    // ================================================================

    function derivatives(state, arrow, props, rho, nu) {
        var vx = state[2], vy = state[3], theta = state[4], omega = state[5];
        var vrx = vx - _windX, vry = vy - _windY;
        var V = Math.sqrt(vrx * vrx + vry * vry);
        if (V < 1e-6) return [0, 0, 0, -_g, 0, 0];
        var alphaV = Math.atan2(vry, vrx);
        var alpha = theta - alphaV;
        var alphaLocal = alpha + omega * props.dFletch / V;
        var D = axialDrag(V, arrow, rho, nu) + crossflowDrag(V, alpha, arrow, rho);
        var Fdx = -D * (vrx / V), Fdy = -D * (vry / V);
        var L = fletchingLift(V, alpha, arrow, rho);
        var Flx = L * (-vry / V), Fly = L * (vrx / V);
        var ax = (Fdx + Flx) / arrow.mass;
        var ay = (Fdy + Fly) / arrow.mass - _g;
        var Mfletch = -fletchingLift(V, alphaLocal, arrow, rho) * props.dFletch;
        return [vx, vy, ax, ay, omega, Mfletch / props.I];
    }

    function rk4Step(state, dt, arrow, props, rho, nu) {
        var k1 = derivatives(state, arrow, props, rho, nu);
        var s2 = state.map(function(s, i) { return s + 0.5 * dt * k1[i]; });
        var k2 = derivatives(s2, arrow, props, rho, nu);
        var s3 = state.map(function(s, i) { return s + 0.5 * dt * k2[i]; });
        var k3 = derivatives(s3, arrow, props, rho, nu);
        var s4 = state.map(function(s, i) { return s + dt * k3[i]; });
        var k4 = derivatives(s4, arrow, props, rho, nu);
        return state.map(function(s, i) {
            return s + (dt / 6) * (k1[i] + 2 * k2[i] + 2 * k3[i] + k4[i]);
        });
    }

    function computeTrajectory(V0, angle, y0, arrow, props, rho, nu, dt, maxTime) {
        dt = dt || 0.001; maxTime = maxTime || 30;
        var state = [0, y0, V0 * Math.cos(angle), V0 * Math.sin(angle), angle, 0];
        var points = [{ x: state[0], y: state[1], vx: state[2], vy: state[3], theta: state[4], omega: state[5], t: 0 }];
        for (var t = dt; t <= maxTime; t += dt) {
            state = rk4Step(state, dt, arrow, props, rho, nu);
            points.push({ x: state[0], y: state[1], vx: state[2], vy: state[3], theta: state[4], omega: state[5], t: t });
            if (state[1] < groundHeight(state[0])) break;
            // headwind can push vx negative — x no longer monotonic, stop here
            if (state[2] <= 0) break;
        }
        return points;
    }

    function heightAtDistance(traj, tx) {
        for (var i = 1; i < traj.length; i++) {
            if (traj[i].x >= tx) {
                var p = traj[i-1], c = traj[i], f = (tx - p.x) / (c.x - p.x);
                return p.y + f * (c.y - p.y);
            }
        }
        return null;
    }
    function velocityAtDistance(traj, tx) {
        for (var i = 1; i < traj.length; i++) {
            if (traj[i].x >= tx) {
                var p = traj[i-1], c = traj[i], f = (tx - p.x) / (c.x - p.x);
                var vx = p.vx + f * (c.vx - p.vx), vy = p.vy + f * (c.vy - p.vy);
                return Math.sqrt(vx * vx + vy * vy);
            }
        }
        return null;
    }
    function timeAtDistance(traj, tx) {
        for (var i = 1; i < traj.length; i++) {
            if (traj[i].x >= tx) {
                var p = traj[i-1], c = traj[i], f = (tx - p.x) / (c.x - p.x);
                return p.t + f * (c.t - p.t);
            }
        }
        return null;
    }
    function aoaAtDistance(traj, tx) {
        for (var i = 1; i < traj.length; i++) {
            if (traj[i].x >= tx) {
                var p = traj[i-1], c = traj[i], f = (tx - p.x) / (c.x - p.x);
                var th = p.theta + f * (c.theta - p.theta);
                var vx = p.vx + f * (c.vx - p.vx), vy = p.vy + f * (c.vy - p.vy);
                return th - Math.atan2(vy - _windY, vx - _windX);
            }
        }
        return null;
    }

    // ================================================================
    //  Solver
    // ================================================================

    function solve(V0, y0, targetX, targetY, arrow, props, rho, nu, tolerance, dt) {
        tolerance = tolerance || 0.001; dt = dt || 0.001;
        // coarse scan: with drag, height at target is not monotonic in angle
        // (range shrinks past ~40 deg), so find the lowest crossing bracket
        var scanStep = 0.05, lo = null, hi = null;
        var prevA = -0.3;
        var prevY = evalAngle(prevA, V0, y0, targetX, arrow, props, rho, nu, dt);
        for (var a = prevA + scanStep; a <= 1.2 + 1e-9; a += scanStep) {
            var ya = evalAngle(a, V0, y0, targetX, arrow, props, rho, nu, dt);
            if (ya !== null && ya >= targetY && (prevY === null || prevY < targetY)) {
                lo = prevA; hi = a; break;
            }
            prevA = a; prevY = ya;
        }
        if (hi === null) return null;
        var mid, yMid;
        for (var i = 0; i < 60; i++) {
            mid = (lo + hi) / 2;
            yMid = evalAngle(mid, V0, y0, targetX, arrow, props, rho, nu, dt);
            if (yMid === null) { lo = mid; continue; }
            if (Math.abs(yMid - targetY) < tolerance) break;
            if (yMid < targetY) lo = mid; else hi = mid;
        }
        var trajectory = computeTrajectory(V0, mid, y0, arrow, props, rho, nu, dt);
        var vAtTarget = velocityAtDistance(trajectory, targetX);
        if (vAtTarget === null) return null;
        return {
            angle: mid, angleDeg: mid * 180 / Math.PI,
            aimOffset: (y0 + targetX * Math.tan(mid)) - targetY,
            heightAtTarget: heightAtDistance(trajectory, targetX),
            velocityAtTarget: vAtTarget,
            flightTime: timeAtDistance(trajectory, targetX),
            aoaAtTarget: aoaAtDistance(trajectory, targetX),
            trajectory: trajectory,
        };
    }
    function evalAngle(angle, V0, y0, targetX, arrow, props, rho, nu, dt) {
        return heightAtDistance(computeTrajectory(V0, angle, y0, arrow, props, rho, nu, dt), targetX);
    }
    function minimumVelocity(targetX, targetY, y0) {
        return Math.sqrt(_g * Math.sqrt(targetX * targetX + (targetY - y0) * (targetY - y0)));
    }

    // ================================================================
    //  Utilities
    // ================================================================

    function round(n, p) { p = p || 0.1; var f = 1/p; return Math.round(n * f) / f; }
    function mToFps(m) { return round(m * 3.28084, 0.1); }

    // ================================================================
    //  Unit conversion system
    // ================================================================

    var _units = {
        p_targetX:      { base: 'm',   alt: 'yd',  factor: 1.09361 },
        p_drawLength:   { base: 'in',  alt: 'cm',  factor: 2.54 },
        p_braceHeight:  { base: 'in',  alt: 'cm',  factor: 2.54 },
        p_V0Override:   { base: 'm/s', alt: 'fps', factor: 3.28084 },
        p_arrowMass:    { base: 'g',   alt: 'gr',  factor: 15.4324 },
        p_pointMass:    { base: 'g',   alt: 'gr',  factor: 15.4324 },
        p_tubeDiameter: { base: 'mm',  alt: 'in',  factor: 0.03937 },
        p_tubeLength:   { base: 'mm',  alt: 'in',  factor: 0.03937 },
        p_fletchLength: { base: 'mm',  alt: 'in',  factor: 0.03937 },
        p_fletchHeight: { base: 'mm',  alt: 'in',  factor: 0.03937 },
    };

    // Read input value, always returns base units.
    // Prefers the exact stored base value (dataset.baseVal) so unit
    // toggling never accumulates rounding error.
    function num(id, fallback) {
        var el = document.getElementById(id);
        var v = parseFloat(el.value);
        if (isNaN(v)) return fallback;
        var u = _units[id];
        if (u) {
            var bv = parseFloat(el.dataset.baseVal);
            if (!isNaN(bv)) return bv;
            if (el.dataset.unitMode === 'alt') {
                v = u.factor ? v / u.factor : u.toBase(v);
            }
        }
        return v;
    }

    function convertVal(u, val, toAlt) {
        if (u.factor) return toAlt ? val * u.factor : val / u.factor;
        return toAlt ? u.toAlt(val) : u.toBase(val);
    }

    function smartRound(v) {
        var a = Math.abs(v);
        return a < 1 ? round(v, 0.001) : a < 10 ? round(v, 0.01) : round(v, 0.1);
    }

    function updateSecondary(id) {
        var input = document.getElementById(id);
        var u = _units[id];
        if (!u || !input._secSpan) return;
        var baseVal = parseFloat(input.dataset.baseVal);
        if (isNaN(baseVal) || input.value === '') { input._secSpan.textContent = ''; return; }
        var isBase = input.dataset.unitMode === 'base';
        var converted = isBase ? convertVal(u, baseVal, true) : baseVal;
        var altUnit = isBase ? u.alt : u.base;
        input._secSpan.textContent = smartRound(converted) + ' ' + altUnit;
    }

    function updateAllSecondary() {
        for (var id in _units) updateSecondary(id);
    }

    function setupUnitToggles() {
        for (var id in _units) {
            (function(id) {
                var input = document.getElementById(id);
                if (!input) return;
                var u = _units[id];
                var iw = input.closest('.iw');
                if (!iw) return;
                var unitSpan = iw.querySelector('.u');
                if (!unitSpan) return;

                input.dataset.unitMode = 'base';
                if (input.value !== '' && !isNaN(parseFloat(input.value)))
                    input.dataset.baseVal = parseFloat(input.value);

                // dot toggle — before input
                var dot = document.createElement('span');
                dot.className = 'unit-dot';
                dot.title = 'Switch to ' + u.alt;
                iw.insertBefore(dot, input);

                // secondary readout — after unit
                var sec = document.createElement('span');
                sec.className = 'u-sec';
                iw.appendChild(sec);
                input._secSpan = sec;

                dot.addEventListener('click', function() {
                    var isBase = input.dataset.unitMode === 'base';
                    var baseVal = parseFloat(input.dataset.baseVal);

                    // display converts from the exact stored base value —
                    // toggling back and forth never drifts
                    if (!isNaN(baseVal) && input.value !== '') {
                        input.value = smartRound(isBase ? convertVal(u, baseVal, true) : baseVal);
                    }

                    input.dataset.unitMode = isBase ? 'alt' : 'base';
                    unitSpan.childNodes[0].textContent = isBase ? u.alt : u.base;
                    dot.title = 'Switch to ' + (isBase ? u.base : u.alt);
                    updateSecondary(id);
                });

                // user edits redefine the stored base value
                input.addEventListener('input', function() {
                    var v = parseFloat(input.value);
                    if (isNaN(v) || input.value === '') {
                        input.dataset.baseVal = '';
                    } else {
                        input.dataset.baseVal = input.dataset.unitMode === 'alt' ? convertVal(u, v, false) : v;
                    }
                    updateSecondary(id);
                });
            })(id);
        }
    }

    // ================================================================
    //  Parameters — read from DOM
    // ================================================================

    function readParams() {
        _targetX       = num('p_targetX', 70);
        _targetHeight  = num('p_targetHeight', 1.3);
        _targetSize    = num('p_targetSize', 0.80);

        _bow.drawWeight  = num('p_drawWeight', 40);
        _bow.drawLength  = num('p_drawLength', 28);
        _bow.braceHeight = num('p_braceHeight', 8.75);
        _bow.efficiency  = num('p_efficiency', 0.78);
        var v0str = document.getElementById('p_V0Override').value;
        if (v0str !== '' && !isNaN(parseFloat(v0str))) {
            _V0Override = num('p_V0Override', null);
        } else {
            _V0Override = null;
        }

        _arrow.mass          = num('p_arrowMass', 25) / 1000;
        _arrow.pointMass     = num('p_pointMass', 8) / 1000;
        _arrow.point.type    = document.getElementById('p_pointType').value;
        _arrow.tube.diameter = num('p_tubeDiameter', 6) / 1000;
        _arrow.tube.length   = num('p_tubeLength', 700) / 1000;
        // surface roughness by material (Ra in metres)
        //   carbon:      ~12 µm (composite, good finish)
        //   alu/carbon:  ~5 µm  (hybrid, polished)
        //   aluminum:    ~3 µm  (anodized, very smooth)
        //   wood:        ~100 µm (natural grain)
        //   bamboo:      ~200 µm (natural nodes + grain)
        var mat = document.getElementById('p_tubeMaterial').value;
        _arrow.tube.roughness = mat === 'alu_carbon' ? 0.000005
                              : mat === 'aluminum'   ? 0.000003
                              : mat === 'wood'       ? 0.0001
                              : mat === 'bamboo'     ? 0.0002
                              : 0.000012;
        _arrow.point.diameter = _arrow.tube.diameter;
        _arrow.nock.diameter  = _arrow.tube.diameter;
        _arrow.fletching.count  = parseInt(document.getElementById('p_fletchCount').value) || 3;
        _arrow.fletching.length = num('p_fletchLength', 50) / 1000;
        var fletchH = num('p_fletchHeight', 10) / 1000;
        var fletchShape = document.getElementById('p_fletchShape').value;
        // area factors: shield ≈ 0.50, parabolic ≈ 0.67, manchu ≈ 0.85
        var shapeFactor = fletchShape === 'parabolic' ? 0.67
                        : fletchShape === 'manchu'    ? 0.85
                        : 0.50;
        _arrow.fletching.area = _arrow.fletching.length * fletchH * shapeFactor;
        // frontal section per vane — thickness × height
        _arrow.fletching.section = _arrow.fletching.thickness * fletchH;

        _temperature   = num('p_temperature', 20);
        _altitude      = num('p_altitude', 0);
        _humidity      = num('p_humidity', 0) / 100;
        _windX         = num('p_windX', 0);
        _windY         = num('p_windY', 0);
        _groundSlope   = num('p_groundSlope', 0);
        _y0            = num('p_y0', 1.5);

        updateAllSecondary();
    }

    // ================================================================
    //  Recalculate — main entry point
    // ================================================================

    var _chartInstance = null;

    function recalculate() {
        readParams();

        _pressure  = pressureAtAltitude(_altitude);
        _rho       = airDensity(_temperature, _pressure, _humidity);
        _eta       = dynamicViscosity(_temperature);
        _nu        = kinematicViscosity(_eta, _rho);
        _V0        = _V0Override || computeV0(_bow, _arrow.mass);
        _arrowProps = computeArrowProps(_arrow);
        _slopeRad  = _groundSlope * Math.PI / 180;
        _targetY   = groundHeight(_targetX) + _targetHeight;

        var result = solve(_V0, _y0, _targetX, _targetY, _arrow, _arrowProps, _rho, _nu, _tolerance, _dt);

        if (!result) {
            var vmin = minimumVelocity(_targetX, _targetY, _y0);
            document.getElementById('readout').className = '';
            document.getElementById('readout').innerHTML =
                '<div id="nosol">No solution &mdash; arrow cannot reach the target.' +
                '<div class="hint">Vmin ~ ' + round(vmin,0.1) + ' m/s</div></div>';
            document.getElementById('flight').innerHTML = '';
            if (_chartInstance) { _chartInstance.destroy(); _chartInstance = null; }
            document.getElementById('dist-body').innerHTML = '';
            document.getElementById('tech-body').innerHTML = '';
        } else {
            display(result);
        }
    }

    // ================================================================
    //  Display
    // ================================================================

    function display(r) {
        var sign  = r.aimOffset >= 0 ? 'above' : 'below';
        var arrow = r.aimOffset >= 0 ? '&#9650;' : '&#9660;';

        var aoaDeg = Math.abs(r.aoaAtTarget * 180 / Math.PI);
        var vRatio = r.velocityAtTarget / _V0;
        var warn = vRatio < 0.25 || aoaDeg > 5;

        // readout
        var readoutEl = document.getElementById('readout');
        readoutEl.className = warn ? 'warn' : '';
        readoutEl.innerHTML =
            '<div class="direction">aim ' + sign + ' target center</div>' +
            '<div class="value">' + round(Math.abs(r.aimOffset), 0.01) + '<span class="unit">m</span></div>' +
            (warn ? '<div class="direction" style="margin-top:0.5em;color:var(--amber)">' +
                (vRatio < 0.25 ? '&#9888; low energy at target' : '&#9888; high angle of attack') + '</div>' : '');

        // flight data
        document.getElementById('flight').innerHTML =
            cell('Initial speed', round(_V0, 0.1) + ' m/s', mToFps(_V0) + ' fps') +
            cell('Launch angle', round(r.angleDeg, 0.01) + '&#176;', '') +
            cell('Speed at target', round(r.velocityAtTarget, 0.1) + ' m/s', mToFps(r.velocityAtTarget) + ' fps') +
            cell('Flight time', round(r.flightTime, 0.001) + ' s', '') +
            cell('AoA at target', (r.aoaAtTarget * 180 / Math.PI).toFixed(3) + '&#176;', '') +
            cell('FOC', round((_arrowProps.cg - _arrow.tube.length / 2) / _arrow.tube.length * 100, 0.1) + ' %', '') +
            cell('Arrow KE', round(0.5 * _arrow.mass * _V0 * _V0, 0.1) + ' J',
                 round(_arrow.mass * r.velocityAtTarget * r.velocityAtTarget * 0.5, 0.1) + ' J at target') +
            cell('Momentum', round(_arrow.mass * _V0, 0.001) + ' kg·m/s',
                 round(_arrow.mass * r.velocityAtTarget, 0.001) + ' kg·m/s at target');

        // chart
        renderChart(r.trajectory);

        // distance table
        (function() {
            var tbody = '';
            var distances = [10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 90];
            for (var i = 0; i < distances.length; i++) {
                var d = distances[i];
                var tgtY = _targetHeight + d * Math.tan(_slopeRad);
                // coarser dt: table values are rounded, 5x cheaper
                var dr = solve(_V0, _y0, d, tgtY, _arrow, _arrowProps, _rho, _nu, _tolerance, 0.005);
                if (!dr) {
                    tbody += '<tr class="no-sol"><td>' + d + ' m</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td></tr>';
                } else {
                    var ke = 0.5 * _arrow.mass * dr.velocityAtTarget * dr.velocityAtTarget;
                    tbody += '<tr' + (d === _targetX ? ' style="color:var(--amber)"' : '') + '>' +
                        '<td>' + d + ' m</td>' +
                        '<td>' + round(dr.angleDeg, 0.01) + '&#176;</td>' +
                        '<td>' + round(Math.abs(dr.aimOffset), 0.01) + ' m</td>' +
                        '<td>' + round(dr.velocityAtTarget, 0.1) + '</td>' +
                        '<td>' + round(dr.flightTime, 0.01) + '</td>' +
                        '<td>' + round(ke, 0.1) + '</td></tr>';
                }
            }
            document.getElementById('dist-body').innerHTML =
                '<table class="dist-tbl"><thead><tr>' +
                '<th>Dist</th><th>Angle</th><th>Aim offset</th><th>V (m/s)</th><th>Time (s)</th><th>KE (J)</th>' +
                '</tr></thead><tbody>' + tbody + '</tbody></table>';
        })();

        // technical details
        var Ebow = storedEnergy(_bow);
        var html = '';

        html += tgroup('Bow', [
            trow(_bow.drawWeight + ' lbs draw', _bow.drawLength + '&#8243; / ' + _bow.braceHeight + '&#8243; brace'),
            trow('Stored energy', round(Ebow, 0.1) + ' J'),
            trow('Efficiency', (_bow.efficiency * 100) + ' %'),
            trow('V&#8320; source', _V0Override ? 'override' : 'bow model'),
        ]);

        html += tgroup('Atmosphere', [
            trow('Altitude', _altitude + ' m ASL'),
            trow('Humidity', round(_humidity * 100, 1) + ' %'),
            trow('Pressure', round(_pressure, 1) + ' Pa'),
            trow('Air density', round(_rho, 0.001) + ' kg/m&#179;'),
            trow('Kin. viscosity', _nu.toExponential(3) + ' m&#178;/s'),
        ]);

        if (_windX !== 0 || _windY !== 0)
            html += tgroup('Wind', [
                trow('Along axis', _windX + ' m/s'),
                trow('Vertical', _windY + ' m/s'),
            ]);
        if (_groundSlope !== 0)
            html += tgroup('Terrain', [ trow('Slope', _groundSlope + '&#176;') ]);

        var foc = (_arrowProps.cg - _arrow.tube.length / 2) / _arrow.tube.length * 100;
        html += tgroup('Arrow geometry', [
            trow('CG from nock', round(_arrowProps.cg * 1000, 0.1) + ' mm'),
            trow('FOC', round(foc, 0.1) + ' %'),
            trow('Moment of inertia', _arrowProps.I.toExponential(3) + ' kg&#183;m&#178;'),
            trow('Fletching lever', round(_arrowProps.dFletch * 1000, 0.1) + ' mm'),
        ]);

        html += tgroup('Drag coefficients', [
            trow('Surface Ra', (_arrow.tube.roughness * 1e6).toFixed(0) + ' &#181;m (' + document.getElementById('p_tubeMaterial').value + ')'),
            trow('Tube Cx', tubeCx(_V0, _arrow.tube, _nu).toFixed(4)),
            trow('Point Cx', pointCx(_arrow.point.type).toFixed(4)),
            trow('Fletching Cx', fletchingCx(_V0, _arrow, _nu).toFixed(4)),
            trow('Nock Cx', nockCx().toFixed(1)),
            trow('Axial drag at V&#8320;', axialDrag(_V0, _arrow, _rho, _nu).toFixed(4) + ' N'),
        ]);

        html += tgroup('Trajectory', [
            trow('Range', round(r.trajectory[r.trajectory.length - 1].x, 0.1) + ' m'),
            trow('Max altitude', round(Math.max.apply(null, r.trajectory.map(function(p){return p.y;})), 0.1) + ' m'),
            trow('Vmin (approx)', round(minimumVelocity(_targetX, _targetY, _y0), 0.1) + ' m/s'),
        ]);

        document.getElementById('tech-body').innerHTML = html;
    }

    function cell(label, val, sec) {
        return '<div class="fd"><div class="k">' + label + '</div>' +
               '<div class="v">' + val + (sec ? ' <span class="sec">' + sec + '</span>' : '') + '</div></div>';
    }
    function tgroup(title, rows) {
        return '<div class="tgroup"><div class="th">&#9881; ' + title + '</div>' + rows.join('') + '</div>';
    }
    function trow(k, v) {
        return '<div class="trow"><span class="tk">' + k + '</span><span class="tv">' + v + '</span></div>';
    }

    // ================================================================
    //  Chart
    // ================================================================

    function renderChart(trajectory) {
        // clip trajectory at target distance
        var clipped = [];
        for (var ci = 0; ci < trajectory.length; ci++) {
            if (trajectory[ci].x > _targetX) {
                // interpolate exact target point
                if (ci > 0) {
                    var p = trajectory[ci-1], c = trajectory[ci];
                    var f = (_targetX - p.x) / (c.x - p.x);
                    clipped.push({ x: _targetX, y: p.y + f * (c.y - p.y) });
                }
                break;
            }
            clipped.push(trajectory[ci]);
        }

        var step   = Math.max(1, Math.floor(clipped.length / 600));
        var points = clipped.filter(function(_, i) { return i % step === 0 || i === clipped.length - 1; });
        var xyData = points.map(function(p) { return { x: round(p.x, 0.01), y: round(p.y, 0.01) }; });

        var endX   = _targetX + 3;
        var maxY   = 0, apexPt = points[0];
        for (var i = 0; i < points.length; i++) {
            if (points[i].y > maxY) { maxY = points[i].y; apexPt = points[i]; }
        }

        var hitY = heightAtDistance(trajectory, _targetX);
        var yMax = Math.ceil(maxY + 1.5);

        // ground fill data
        var groundData = [];
        for (var gx = 0; gx <= endX; gx += 1)
            groundData.push({ x: gx, y: round(groundHeight(gx), 0.01) });

        // ── chart plugins ──

        var vignettePlugin = {
            id: 'vignette',
            beforeDraw: function(chart) {
                var ctx = chart.ctx, a = chart.chartArea;
                if (!a) return;
                ctx.save();
                // dark phosphor base — Mechanicus cogitator screen
                ctx.fillStyle = '#040804';
                ctx.fillRect(a.left, a.top, a.right - a.left, a.bottom - a.top);
                // green radial vignette
                var cx = (a.left + a.right) / 2, cy = (a.top + a.bottom) / 2;
                var r = Math.max(a.right - a.left, a.bottom - a.top) * 0.7;
                var grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
                grad.addColorStop(0, 'rgba(10, 30, 10, 0.08)');
                grad.addColorStop(1, 'rgba(0, 0, 0, 0.45)');
                ctx.fillStyle = grad;
                ctx.fillRect(a.left, a.top, a.right - a.left, a.bottom - a.top);
                // scanlines
                ctx.globalAlpha = 0.035;
                for (var sy = a.top; sy < a.bottom; sy += 3) {
                    ctx.fillStyle = '#000';
                    ctx.fillRect(a.left, sy, a.right - a.left, 1);
                }
                ctx.restore();
            }
        };

        var glowPlugin = {
            id: 'trajectoryGlow',
            beforeDatasetsDraw: function(chart) {
                var meta = chart.getDatasetMeta(0);
                if (!meta || !meta.dataset) return;
                var ctx = chart.ctx;
                ctx.save();
                ctx.shadowColor = 'rgba(51, 204, 85, 0.5)';
                ctx.shadowBlur = 14;
                meta.dataset.draw(ctx);
                ctx.restore();
            }
        };

        var annotPlugin = {
            id: 'annotations',
            afterDatasetsDraw: function(chart) {
                var ctx = chart.ctx, xScale = chart.scales.x, yScale = chart.scales.y;
                if (!xScale || !yScale) return;
                var a = chart.chartArea;
                ctx.save();
                ctx.font = '9px monospace';

                // apex — amber crosshair + diamond
                ctx.font = '9px monospace';
                var ax = xScale.getPixelForValue(apexPt.x);
                var ay = yScale.getPixelForValue(apexPt.y);
                ctx.strokeStyle = 'rgba(51, 204, 85, 0.2)';
                ctx.setLineDash([2, 4]);
                ctx.lineWidth = 0.5;
                ctx.beginPath();
                ctx.moveTo(ax, ay); ctx.lineTo(ax, yScale.getPixelForValue(0));
                ctx.stroke();
                ctx.beginPath();
                ctx.moveTo(xScale.getPixelForValue(0), ay); ctx.lineTo(ax, ay);
                ctx.stroke();
                ctx.setLineDash([]);
                ctx.fillStyle = '#33cc55';
                ctx.beginPath();
                ctx.moveTo(ax, ay - 4); ctx.lineTo(ax + 3, ay);
                ctx.lineTo(ax, ay + 4); ctx.lineTo(ax - 3, ay);
                ctx.closePath(); ctx.fill();
                ctx.fillStyle = 'rgba(51, 204, 85, 0.7)';
                ctx.textAlign = 'left';
                ctx.fillText('APEX ' + round(apexPt.y, 0.1) + 'm', ax + 6, ay - 10);

                // impact marker — red crosshair with glow
                if (hitY !== null) {
                    var hx = xScale.getPixelForValue(_targetX);
                    var hy = yScale.getPixelForValue(hitY);
                    ctx.strokeStyle = '#cc3030';
                    ctx.lineWidth = 1.5;
                    ctx.beginPath();
                    ctx.moveTo(hx - 7, hy); ctx.lineTo(hx + 7, hy);
                    ctx.stroke();
                    ctx.beginPath();
                    ctx.moveTo(hx, hy - 7); ctx.lineTo(hx, hy + 7);
                    ctx.stroke();
                    ctx.fillStyle = '#ff3333';
                    ctx.shadowColor = 'rgba(204, 48, 48, 0.8)';
                    ctx.shadowBlur = 10;
                    ctx.beginPath();
                    ctx.arc(hx, hy, 3, 0, Math.PI * 2);
                    ctx.fill();
                    ctx.shadowBlur = 0;
                }

                ctx.restore();
            }
        };

        // ── build chart ──

        if (_chartInstance) _chartInstance.destroy();

        _chartInstance = new Chart('trajectory_chart', {
            type: 'scatter',
            plugins: [vignettePlugin, glowPlugin, annotPlugin],
            data: {
                datasets: [
                    {
                        label: 'Arrow',
                        data: xyData,
                        showLine: true,
                        fill: true,
                        tension: 0.15,
                        pointRadius: 0,
                        borderColor: '#33cc55',
                        borderWidth: 2,
                        backgroundColor: function(ctx) {
                            var chart = ctx.chart, a = chart.chartArea;
                            if (!a) return 'rgba(51, 204, 85, 0.03)';
                            var g = chart.ctx.createLinearGradient(0, a.top, 0, a.bottom);
                            g.addColorStop(0, 'rgba(51, 204, 85, 0.12)');
                            g.addColorStop(0.5, 'rgba(51, 204, 85, 0.03)');
                            g.addColorStop(1, 'rgba(51, 204, 85, 0.0)');
                            return g;
                        },
                        order: 2,
                    },
                    {
                        label: '_glow',
                        data: xyData,
                        showLine: true,
                        fill: false,
                        tension: 0.15,
                        pointRadius: 0,
                        borderColor: 'rgba(51, 204, 85, 0.10)',
                        borderWidth: 10,
                        order: 3,
                    },
                    {
                        label: 'Target',
                        data: [
                            { x: _targetX, y: _targetY - _targetSize / 2 },
                            { x: _targetX, y: _targetY },
                            { x: _targetX, y: _targetY + _targetSize / 2 },
                        ],
                        showLine: true,
                        fill: false,
                        pointRadius: [5, 0, 5],
                        pointStyle: ['triangle', false, 'triangle'],
                        pointRotation: [0, 0, 180],
                        pointBackgroundColor: '#cc3030',
                        pointBorderColor: '#ff4444',
                        pointBorderWidth: 1,
                        borderColor: '#cc3030',
                        borderWidth: 3,
                        order: 1,
                    },
                    {
                        label: '_targetGlow',
                        data: [
                            { x: _targetX, y: _targetY - _targetSize / 2 },
                            { x: _targetX, y: _targetY + _targetSize / 2 },
                        ],
                        showLine: true,
                        fill: false,
                        pointRadius: 0,
                        borderColor: 'rgba(204, 48, 48, 0.12)',
                        borderWidth: 14,
                        order: 4,
                    },
                    {
                        label: 'Ground',
                        data: groundData,
                        showLine: true,
                        fill: true,
                        pointRadius: 0,
                        borderColor: 'rgba(40, 80, 40, 0.3)',
                        backgroundColor: function(ctx) {
                            var chart = ctx.chart, a = chart.chartArea;
                            if (!a) return 'rgba(20, 50, 20, 0.06)';
                            var g = chart.ctx.createLinearGradient(0, a.bottom - 30, 0, a.bottom);
                            g.addColorStop(0, 'rgba(20, 50, 20, 0.0)');
                            g.addColorStop(1, 'rgba(20, 50, 20, 0.15)');
                            return g;
                        },
                        borderWidth: 1,
                        order: 6,
                    },
                ],
            },
            options: {
                responsive: true,
                aspectRatio: Math.min(Math.max(endX / yMax, 1.2), 3.5),
                animation: false,
                events: [],
                layout: { padding: { top: 10, right: 15 } },
                plugins: {
                    legend: { display: false },
                    title:  { display: false },
                    tooltip: { enabled: false },
                },
                scales: {
                    x: {
                        grid: {
                            color: 'rgba(40, 80, 40, 0.10)',
                            tickColor: 'rgba(40, 80, 40, 0.18)',
                        },
                        border: { color: '#1a3a1a' },
                        ticks: {
                            color: '#447744',
                            font: { family: 'monospace', size: 10 },
                            maxTicksLimit: 15,
                        },
                        title: {
                            display: true,
                            text: 'distance (m)',
                            color: '#447744',
                            font: { family: 'monospace', size: 9 },
                        },
                    },
                    y: {
                        min: 0,
                        max: yMax,
                        grid: {
                            color: 'rgba(40, 80, 40, 0.10)',
                            tickColor: 'rgba(40, 80, 40, 0.18)',
                        },
                        border: { color: '#1a3a1a' },
                        ticks: {
                            color: '#447744',
                            font: { family: 'monospace', size: 10 },
                        },
                        title: {
                            display: true,
                            text: 'altitude (m)',
                            color: '#447744',
                            font: { family: 'monospace', size: 9 },
                        },
                    },
                },
            },
        });
    }

    // ================================================================
    //  Init
    // ================================================================

    setupUnitToggles();
    recalculate();

    document.getElementById('compute-btn').addEventListener('click', recalculate);
    document.getElementById('controls').addEventListener('keydown', function(e) {
        if (e.key === 'Enter') recalculate();
    });

    // ================================================================
    //  Auto-resize iframe in parent (blog integration)
    // ================================================================
    (function() {
        if (window.parent === window) return;
        var screen = document.getElementById('screen');
        function notify() {
            var style = getComputedStyle(screen);
            var total = screen.offsetHeight + parseFloat(style.marginTop) + parseFloat(style.marginBottom);
            window.parent.postMessage({ type: 'divePlannerResize', height: Math.ceil(total) }, '*');
        }
        notify();
        new ResizeObserver(notify).observe(screen);
    })();

    </script>
    </div><!-- /screen-inner -->
</div><!-- /screen -->
</body>
</html>