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₀ override</label><div class="iw"><input type="number" id="p_V0Override" value="" step="0.1" placeholder="—"><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">°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">°</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">⚙ 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 — 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 ? '▲' : '▼';
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 ? '⚠ low energy at target' : '⚠ 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) + '°', '') +
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) + '°', '') +
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) + '°</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 + '″ / ' + _bow.braceHeight + '″ brace'),
trow('Stored energy', round(Ebow, 0.1) + ' J'),
trow('Efficiency', (_bow.efficiency * 100) + ' %'),
trow('V₀ 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³'),
trow('Kin. viscosity', _nu.toExponential(3) + ' m²/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 + '°') ]);
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·m²'),
trow('Fletching lever', round(_arrowProps.dFletch * 1000, 0.1) + ' mm'),
]);
html += tgroup('Drag coefficients', [
trow('Surface Ra', (_arrow.tube.roughness * 1e6).toFixed(0) + ' µ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₀', 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">⚙ ' + 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>
