Spaces:
Running
Running
| ;(function () { | |
| 'use strict' | |
| function $(id) { | |
| return document.getElementById(id) | |
| } | |
| function svgEl(tag, attrs) { | |
| var el = document.createElementNS('http://www.w3.org/2000/svg', tag) | |
| if (attrs) for (var k in attrs) el.setAttribute(k, attrs[k]) | |
| return el | |
| } | |
| function rgba(triplet, alpha) { | |
| return 'rgba(' + triplet + ',' + alpha + ')' | |
| } | |
| var svg = null | |
| var wrap = null | |
| var W = 0 | |
| var H = 0 | |
| var camCX = 0 | |
| var camCY = 0 | |
| var SCALE = 0 | |
| var zoomLevel = 1.0 | |
| /** World km from camera to farthest canvas corner when zoom label = 100%; +/- zoom scales this radius. */ | |
| var BASE_VIEW_RADIUS_KM = 1.5 | |
| /** Keeps map labels readable vs panel type (~15% bump; pair with .coastal-surveillance-sim font-size). */ | |
| var MAP_LABEL_SCALE = 1.15 | |
| var worldG = null | |
| var bgSky = null | |
| var bgSea = null | |
| var horizLine = null | |
| var ringsG = null | |
| var compassG = null | |
| var zonesG = null | |
| var dynamicCamG = null | |
| var coneEl = null | |
| var camPointer = null | |
| var camDot = null | |
| var camCorners = [] | |
| var boatG = null | |
| var boatHalo = null | |
| var boatIconG = null | |
| var boatIconScaleG = null | |
| var boatHull = null | |
| var trajLine = null | |
| var boatTrail = null | |
| var bearingLine = null | |
| var anchorSOuter = null | |
| var anchorS = null | |
| var anchorEOuter = null | |
| var anchorE = null | |
| var anchorSLabel = null | |
| var anchorELabel = null | |
| var trailFlat = [] | |
| var TRAIL_CAP = 100 | |
| var TRAIL_MIN_DIST = 0.0028 | |
| /** Min blind+detected metres before showing blind share (avoids noisy % on tiny streaks). */ | |
| var MIN_BLIND_SHARE_M = 10 | |
| var lastTrailIconAngle = NaN | |
| var FONT_SANS = 'Saira Condensed, Arial Narrow, Arial, sans-serif' | |
| var statCache = {} | |
| var lastTrackSx = NaN | |
| var lastTrackSy = NaN | |
| var lastTrackEx = NaN | |
| var lastTrackEy = NaN | |
| var lastDrawScale = NaN | |
| var prevInFov = null | |
| var boatStart = { x: 0.0, y: 1.0 } | |
| var boatEnd = { x: 0.0, y: 0.15 } | |
| var dragging = null | |
| var camHeading = 0 | |
| var boatT = 0 | |
| var boatDir = 1 | |
| var lastTime = null | |
| var prevVisible = null | |
| var detectedStreak = 0 | |
| var blindStreak = 0 | |
| var lastDetectedStreak = 0 | |
| var lastBlindStreak = 0 | |
| var prevDetectedStreak = 0 | |
| var prevBlindStreak = 0 | |
| var lastPinchDist = null | |
| var running = false | |
| var camDir = -1 | |
| var inpFov = null | |
| var inpFrontSpeed = null | |
| var inpBackSpeed = null | |
| var inpFrontArc = null | |
| var inpBoatSpeed = null | |
| var cachedConeFov = NaN | |
| var cachedConeR = NaN | |
| var cachedPtrLen = NaN | |
| var lastDrawBx = NaN | |
| var lastDrawBy = NaN | |
| var hitL = 0 | |
| var hitT = 0 | |
| var hitSx = 1 | |
| var hitSy = 1 | |
| var resizeRaf = 0 | |
| var panelStatsRaf = 0 | |
| var panelStatsPayload = null | |
| var zoomVisualRaf = 0 | |
| var C = { | |
| p: '', d: '', s: '', w: '', s1: '', s2: '', | |
| pr: '', dr: '', sr: '', wr: '', | |
| pc: '', dc: '', sc: '', wc: '', hi: '', lo: '', | |
| } | |
| var systemQuery = window.matchMedia('(prefers-color-scheme: light)') | |
| function loadColors() { | |
| var cs = getComputedStyle(document.querySelector('.coastal-surveillance-sim')) | |
| function v(n) { return cs.getPropertyValue(n).trim() } | |
| C.p = v('--primary'); C.d = v('--danger'); C.s = v('--success'); C.w = v('--warning') | |
| C.s1 = v('--surface-1'); C.s2 = v('--surface-2') | |
| C.pr = v('--primary-rgb'); C.dr = v('--danger-rgb'); C.sr = v('--success-rgb'); C.wr = v('--warning-rgb') | |
| C.pc = v('--primary-content'); C.dc = v('--danger-content'); C.sc = v('--success-content'); C.wc = v('--warning-content') | |
| C.hi = v('--content-hi'); C.lo = v('--content-lo') | |
| if (horizLine) horizLine.setAttribute('stroke', rgba(C.pr, 0.4)) | |
| if (zoomVisualRaf) { cancelAnimationFrame(zoomVisualRaf); zoomVisualRaf = 0 } | |
| buildWorldElements() | |
| buildZones() | |
| prevInFov = null | |
| syncThemedSceneStrokes() | |
| if (panelStatsRaf) { cancelAnimationFrame(panelStatsRaf); panelStatsRaf = 0 } | |
| panelStatsPayload = null | |
| } | |
| function initTheme() { | |
| var btns = document.querySelectorAll('.theme-btn') | |
| function applyTheme(pref) { | |
| var sim = document.querySelector('.coastal-surveillance-sim') | |
| sim.dataset.theme = pref === 'system' ? (systemQuery.matches ? 'light' : 'dark') : pref | |
| loadColors() | |
| for (var i = 0; i < btns.length; i++) | |
| btns[i].classList.toggle('active', btns[i].dataset.themeVal === pref) | |
| } | |
| applyTheme(localStorage.getItem('theme') || 'system') | |
| systemQuery.addEventListener('change', function () { | |
| if (localStorage.getItem('theme') === 'system') applyTheme('system') | |
| }) | |
| for (var j = 0; j < btns.length; j++) | |
| btns[j].addEventListener('click', function () { | |
| localStorage.setItem('theme', this.dataset.themeVal) | |
| applyTheme(this.dataset.themeVal) | |
| }) | |
| } | |
| function resetStreaks() { | |
| detectedStreak = blindStreak = lastDetectedStreak = lastBlindStreak = 0 | |
| prevDetectedStreak = prevBlindStreak = prevVisible = null | |
| } | |
| function clearTrail(resetIconAngle) { | |
| trailFlat.length = 0 | |
| if (resetIconAngle !== false) lastTrailIconAngle = NaN | |
| if (boatTrail) boatTrail.setAttribute('points', '') | |
| } | |
| function flushTrailPoly() { | |
| if (!boatTrail || !trailFlat.length) return | |
| var s = trailFlat[0] + ',' + trailFlat[1] | |
| for (var i = 2; i < trailFlat.length; i += 2) | |
| s += ' ' + trailFlat[i] + ',' + trailFlat[i + 1] | |
| boatTrail.setAttribute('points', s) | |
| } | |
| function appendTrail(bx, by) { | |
| var m = trailFlat.length | |
| if (m >= 2) { | |
| var lx = trailFlat[m - 2] | |
| var ly = trailFlat[m - 1] | |
| if (Math.hypot(bx - lx, by - ly) < TRAIL_MIN_DIST) return | |
| } | |
| trailFlat.push(bx, by) | |
| while (trailFlat.length > TRAIL_CAP * 2) { | |
| trailFlat.shift() | |
| trailFlat.shift() | |
| } | |
| flushTrailPoly() | |
| } | |
| function sectorPath(fromDeg, toDeg, R, maxSteps) { | |
| var span = ((toDeg - fromDeg) + 360) % 360 | |
| var steps = Math.ceil(span / 2) | |
| steps = maxSteps != null && maxSteps > 0 | |
| ? Math.max(4, Math.min(maxSteps, Math.max(steps, 4))) | |
| : Math.max(64, steps) | |
| var d = 'M 0 0' | |
| for (var i = 0; i <= steps; i++) { | |
| var deg = fromDeg + (span / steps) * i | |
| var rad = deg * Math.PI / 180 | |
| d += ' L ' + (Math.sin(rad) * R) + ' ' + (Math.cos(rad) * R) | |
| } | |
| return d + ' Z' | |
| } | |
| function zoneFillRadiusWorld() { | |
| if (SCALE === 0) return 0.1 | |
| function distWorld(px, py) { | |
| return Math.hypot((px - camCX) / SCALE, (camCY - py) / SCALE) | |
| } | |
| return Math.max(Math.max(distWorld(0, 0), distWorld(W, 0), distWorld(W, H), distWorld(0, H)) * 1.08, 0.04) | |
| } | |
| function coneRadiusWorld() { | |
| if (SCALE === 0) return 0.1 | |
| return Math.max(zoneFillRadiusWorld(), Math.hypot(W, H) / SCALE * 1.12) | |
| } | |
| function syncConeAndPointerShape(p) { | |
| if (!coneEl || SCALE === 0) return | |
| var fov = p ? p.fov : +inpFov.value | |
| var R = coneRadiusWorld() | |
| var ptrLen = 32 / SCALE | |
| if (fov === cachedConeFov && Math.abs(R - cachedConeR) < 1e-6 && Math.abs(ptrLen - cachedPtrLen) < 1e-12) return | |
| cachedConeFov = fov | |
| cachedConeR = R | |
| cachedPtrLen = ptrLen | |
| var half = fov / 2 | |
| coneEl.setAttribute('d', sectorPath(-half, half, R, 22)) | |
| camPointer.setAttribute('x2', 0) | |
| camPointer.setAttribute('y2', ptrLen) | |
| } | |
| function syncThemedSceneStrokes() { | |
| if (!coneEl) return | |
| coneEl.setAttribute('fill', rgba(C.pr, 0.12)) | |
| coneEl.setAttribute('stroke', rgba(C.pr, 0.6)) | |
| camPointer.setAttribute('stroke', rgba(C.pr, 0.9)) | |
| camDot.setAttribute('fill', C.p) | |
| camDot.setAttribute('stroke', rgba(C.pr, 0.5)) | |
| trajLine.setAttribute('stroke', rgba(C.dr, 0.25)) | |
| bearingLine.setAttribute('stroke', rgba(C.dr, 0.12)) | |
| if (boatTrail) boatTrail.setAttribute('stroke', rgba(C.dr, 0.55)) | |
| for (var i = 0; i < 4; i++) camCorners[i].setAttribute('stroke', rgba(C.pr, 0.5)) | |
| anchorSOuter.setAttribute('stroke', rgba(C.dr, 0.72)) | |
| anchorS.setAttribute('fill', rgba(C.dr, 0.52)) | |
| anchorS.setAttribute('stroke', rgba(C.dr, 0.45)) | |
| anchorEOuter.setAttribute('stroke', rgba(C.wr, 0.72)) | |
| anchorE.setAttribute('fill', rgba(C.wr, 0.48)) | |
| anchorE.setAttribute('stroke', rgba(C.wr, 0.42)) | |
| anchorSLabel.setAttribute('fill', rgba(C.dr, 0.88)) | |
| anchorELabel.setAttribute('fill', rgba(C.wr, 0.88)) | |
| } | |
| function updateTrackSvgGeomIfNeeded() { | |
| if (boatStart.x === lastTrackSx && boatStart.y === lastTrackSy && | |
| boatEnd.x === lastTrackEx && boatEnd.y === lastTrackEy) return | |
| if (!isNaN(lastTrackSx)) clearTrail() | |
| lastTrackSx = boatStart.x | |
| lastTrackSy = boatStart.y | |
| lastTrackEx = boatEnd.x | |
| lastTrackEy = boatEnd.y | |
| trajLine.setAttribute('x1', boatStart.x) | |
| trajLine.setAttribute('y1', boatStart.y) | |
| trajLine.setAttribute('x2', boatEnd.x) | |
| trajLine.setAttribute('y2', boatEnd.y) | |
| anchorSOuter.setAttribute('cx', boatStart.x) | |
| anchorSOuter.setAttribute('cy', boatStart.y) | |
| anchorS.setAttribute('cx', boatStart.x) | |
| anchorS.setAttribute('cy', boatStart.y) | |
| anchorEOuter.setAttribute('cx', boatEnd.x) | |
| anchorEOuter.setAttribute('cy', boatEnd.y) | |
| anchorE.setAttribute('cx', boatEnd.x) | |
| anchorE.setAttribute('cy', boatEnd.y) | |
| var syOff = 14 * MAP_LABEL_SCALE / SCALE | |
| anchorSLabel.setAttribute('x', boatStart.x) | |
| anchorSLabel.setAttribute('y', -(boatStart.y - syOff)) | |
| anchorELabel.setAttribute('x', boatEnd.x) | |
| anchorELabel.setAttribute('y', -(boatEnd.y - syOff)) | |
| } | |
| function updateScaleOnlySvgGeom() { | |
| var cr = 8 / SCALE | |
| var cornerPx = 14 / SCALE | |
| camDot.setAttribute('r', cr) | |
| for (var ci = 0; ci < 4; ci++) { | |
| var wx = (ci === 0 || ci === 3 ? -1 : 1) * cornerPx | |
| var wy = (ci < 2 ? 1 : -1) * cornerPx | |
| camCorners[ci].setAttribute('x1', wx * 0.4) | |
| camCorners[ci].setAttribute('y1', wy * 0.4) | |
| camCorners[ci].setAttribute('x2', wx) | |
| camCorners[ci].setAttribute('y2', wy) | |
| } | |
| var rHit = 6 / SCALE | |
| var rRing = 10 / SCALE | |
| anchorS.setAttribute('r', rHit) | |
| anchorSOuter.setAttribute('r', rRing) | |
| anchorE.setAttribute('r', rHit) | |
| anchorEOuter.setAttribute('r', rRing) | |
| boatHalo.setAttribute('r', 22 / SCALE) | |
| var fsA = 11 * MAP_LABEL_SCALE / SCALE | |
| anchorSLabel.setAttribute('font-size', fsA) | |
| anchorELabel.setAttribute('font-size', fsA) | |
| var iconPx = 15 / SCALE | |
| boatIconScaleG.setAttribute('transform', 'scale(' + iconPx + ')') | |
| syncConeAndPointerShape(null) | |
| lastTrackSx = NaN | |
| } | |
| function buildLineZone(x1, y1, x2, y2) { | |
| return svgEl('line', { | |
| x1: x1, y1: y1, x2: x2, y2: y2, | |
| stroke: rgba(C.pr, 0.5), 'stroke-width': 1, 'stroke-dasharray': '5 5', | |
| 'vector-effect': 'non-scaling-stroke', | |
| }) | |
| } | |
| function buildZones() { | |
| if (!zonesG || SCALE === 0) return | |
| while (zonesG.firstChild) zonesG.removeChild(zonesG.firstChild) | |
| var frontArc = +inpFrontArc.value | |
| var fh = frontArc / 2 | |
| var R = zoneFillRadiusWorld() | |
| zonesG.appendChild(svgEl('path', { d: sectorPath(-fh, fh, R), fill: rgba(C.pr, 0.07), stroke: 'none' })) | |
| zonesG.appendChild(svgEl('path', { d: sectorPath(fh, 360 - fh, R), fill: rgba(C.dr, 0.06), stroke: 'none' })) | |
| var e1r = (-fh) * Math.PI / 180 | |
| var e2r = fh * Math.PI / 180 | |
| zonesG.appendChild(buildLineZone(0, 0, Math.sin(e1r) * R, Math.cos(e1r) * R)) | |
| zonesG.appendChild(buildLineZone(0, 0, Math.sin(e2r) * R, Math.cos(e2r) * R)) | |
| var labelR = 80 / SCALE | |
| var fs = 11 * MAP_LABEL_SCALE / SCALE | |
| var tf = svgEl('text', { x: 0, y: -labelR, transform: 'scale(1,-1)', | |
| fill: C.pc, 'font-size': fs, 'font-family': FONT_SANS, 'text-anchor': 'middle' }) | |
| tf.textContent = 'FRONT ' + frontArc + '\u00b0' | |
| zonesG.appendChild(tf) | |
| var tb = svgEl('text', { x: 0, y: 18 * MAP_LABEL_SCALE / SCALE, transform: 'scale(1,-1)', | |
| fill: C.dc, 'font-size': fs, 'font-family': FONT_SANS, 'text-anchor': 'middle' }) | |
| tb.textContent = 'BACK ' + (360 - frontArc) + '\u00b0' | |
| zonesG.appendChild(tb) | |
| var edgeTxt = '\u00b1' + fh.toFixed(0) + '\u00b0' | |
| for (var ei = 0; ei < 2; ei++) { | |
| var rad = ei ? e2r : e1r | |
| var tx = svgEl('text', { | |
| x: Math.sin(rad) * labelR, y: -(Math.cos(rad) * labelR), | |
| transform: 'scale(1,-1)', fill: C.lo, | |
| 'font-size': fs, 'font-family': FONT_SANS, 'text-anchor': 'middle', | |
| }) | |
| tx.textContent = edgeTxt | |
| zonesG.appendChild(tx) | |
| } | |
| } | |
| function fmtDist(d) { | |
| return d < 1 ? Math.round(d * 1000) + 'm' : d.toFixed(d < 10 ? 2 : 1).replace(/\.0+$/, '') + 'km' | |
| } | |
| function buildWorldElements() { | |
| if (!ringsG || !compassG || SCALE === 0) return | |
| while (ringsG.firstChild) ringsG.removeChild(ringsG.firstChild) | |
| var nice = [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0] | |
| var ringInterval = nice[0] | |
| for (var i = 0; i < nice.length; i++) { | |
| var px = nice[i] * SCALE | |
| if (px >= 100 && px <= 240) { ringInterval = nice[i]; break } | |
| if (px < 100) ringInterval = nice[i] | |
| } | |
| var maxDist = viewportMaxCornerPx() / SCALE | |
| var maxRing = Math.ceil(maxDist / ringInterval) + 1 | |
| var fs = 12 * MAP_LABEL_SCALE / SCALE | |
| var tw = 5 / SCALE | |
| for (var ri = 1; ri <= maxRing; ri++) { | |
| var r = ri * ringInterval | |
| ringsG.appendChild(svgEl('circle', { cx: 0, cy: 0, r: r, fill: 'none', | |
| stroke: rgba(C.pr, 0.25), 'stroke-width': 0.5, 'vector-effect': 'non-scaling-stroke' })) | |
| ringsG.appendChild(svgEl('line', { x1: -tw, y1: r, x2: tw, y2: r, | |
| stroke: rgba(C.pr, 0.7), 'stroke-width': 1, 'vector-effect': 'non-scaling-stroke' })) | |
| var lbl = svgEl('text', { x: 8 * MAP_LABEL_SCALE / SCALE, y: -r, transform: 'scale(1,-1)', | |
| fill: C.pc, 'font-size': fs, 'font-family': FONT_SANS }) | |
| lbl.textContent = fmtDist(r) | |
| ringsG.appendChild(lbl) | |
| } | |
| while (compassG.firstChild) compassG.removeChild(compassG.firstChild) | |
| var compassR = Math.min(camCX * 0.88, camCY * 0.92) / SCALE | |
| var fsC = 11 * MAP_LABEL_SCALE / SCALE | |
| compassG.appendChild(svgEl('line', { x1: 0, y1: 0, x2: 0, y2: compassR + 20 / SCALE, | |
| stroke: rgba(C.pr, 0.2), 'stroke-width': 0.5, 'stroke-dasharray': (4 / SCALE) + ' ' + (4 / SCALE), | |
| 'vector-effect': 'non-scaling-stroke' })) | |
| for (var deg = 0; deg < 360; deg += 10) { | |
| var maj = deg % 30 === 0 | |
| var rad = deg * Math.PI / 180 | |
| var wx0 = Math.sin(rad) * compassR | |
| var wy0 = Math.cos(rad) * compassR | |
| var tlen = (maj ? 8 : 4) / SCALE | |
| compassG.appendChild(svgEl('line', { | |
| x1: wx0, y1: wy0, x2: Math.sin(rad) * (compassR + tlen), y2: Math.cos(rad) * (compassR + tlen), | |
| stroke: rgba(C.pr, maj ? 0.65 : 0.35), | |
| 'stroke-width': maj ? 1 : 0.5, | |
| 'vector-effect': 'non-scaling-stroke', | |
| })) | |
| if (maj) { | |
| var lr = compassR + (8 + 10) * MAP_LABEL_SCALE / SCALE | |
| var t = svgEl('text', { x: Math.sin(rad) * lr, y: -Math.cos(rad) * lr, transform: 'scale(1,-1)', | |
| fill: C.lo, 'font-size': fsC, 'font-family': FONT_SANS, 'text-anchor': 'middle', 'dominant-baseline': 'middle' }) | |
| t.textContent = deg + '\u00b0' | |
| compassG.appendChild(t) | |
| } | |
| } | |
| } | |
| function updateWorldTransform() { | |
| if (worldG) worldG.setAttribute('transform', | |
| 'translate(' + camCX + ',' + camCY + ') scale(' + SCALE + ',' + (-SCALE) + ')') | |
| } | |
| function refreshSvgHitBox() { | |
| if (!svg || W <= 0 || H <= 0) return | |
| var r = svg.getBoundingClientRect() | |
| if (r.width < 1 || r.height < 1) return | |
| hitL = r.left | |
| hitT = r.top | |
| hitSx = W / r.width | |
| hitSy = H / r.height | |
| } | |
| function rebuildWorldSvg() { | |
| buildWorldElements() | |
| buildZones() | |
| if (coneEl && SCALE > 0) { | |
| updateScaleOnlySvgGeom() | |
| lastDrawScale = SCALE | |
| } | |
| refreshSvgHitBox() | |
| } | |
| function viewportMaxCornerPx() { | |
| if (W <= 0 || H <= 0) return 1 | |
| return Math.max( | |
| Math.hypot(camCX, camCY), | |
| Math.hypot(W - camCX, camCY), | |
| Math.hypot(camCX, H - camCY), | |
| Math.hypot(W - camCX, H - camCY) | |
| ) | |
| } | |
| function resize() { | |
| if (!svg || !wrap) return | |
| W = wrap.clientWidth | |
| H = wrap.clientHeight | |
| svg.setAttribute('width', W) | |
| svg.setAttribute('height', H) | |
| camCX = W / 2 | |
| camCY = H * 0.9 | |
| SCALE = viewportMaxCornerPx() / BASE_VIEW_RADIUS_KM * zoomLevel | |
| updateWorldTransform() | |
| if (bgSky) { bgSky.setAttribute('width', W); bgSky.setAttribute('height', camCY) } | |
| if (bgSea) { bgSea.setAttribute('y', camCY); bgSea.setAttribute('width', W); bgSea.setAttribute('height', H - camCY) } | |
| if (horizLine) { horizLine.setAttribute('x2', W); horizLine.setAttribute('y1', camCY); horizLine.setAttribute('y2', camCY) } | |
| rebuildWorldSvg() | |
| } | |
| function updateZoomLabel() { | |
| var el = $('zoom-label') | |
| if (el) el.textContent = Math.round(zoomLevel * 100) + '%' | |
| } | |
| function flushZoomVisuals() { | |
| zoomVisualRaf = 0 | |
| if (!svg || SCALE <= 0) return | |
| updateWorldTransform() | |
| rebuildWorldSvg() | |
| } | |
| function scheduleZoomVisuals() { | |
| if (!zoomVisualRaf) zoomVisualRaf = requestAnimationFrame(flushZoomVisuals) | |
| } | |
| function applyZoom(delta) { | |
| var factor = delta > 0 ? 1.12 : 1 / 1.12 | |
| zoomLevel = Math.max(0.3, Math.min(8, zoomLevel * factor)) | |
| SCALE = viewportMaxCornerPx() / BASE_VIEW_RADIUS_KM * zoomLevel | |
| scheduleZoomVisuals() | |
| updateZoomLabel() | |
| } | |
| function getParams() { | |
| return { | |
| fov: +inpFov.value, | |
| frontSpeed: +inpFrontSpeed.value, | |
| backSpeed: +inpBackSpeed.value, | |
| frontArc: +inpFrontArc.value, | |
| boatSpeed: +inpBoatSpeed.value, | |
| } | |
| } | |
| function worldToCanvas(wx, wy) { | |
| return { x: camCX + wx * SCALE, y: camCY - wy * SCALE } | |
| } | |
| function canvasToWorld(cx, cy) { | |
| return { x: (cx - camCX) / SCALE, y: (camCY - cy) / SCALE } | |
| } | |
| function isFront(heading, arc) { | |
| var h = ((heading % 360) + 360) % 360 | |
| if (h > 180) h -= 360 | |
| return Math.abs(h) <= arc / 2 | |
| } | |
| function setDir(d) { | |
| camDir = d | |
| var cw = $('dir-cw') | |
| var ccw = $('dir-ccw') | |
| if (cw) cw.classList.toggle('active', d === 1) | |
| if (ccw) ccw.classList.toggle('active', d === -1) | |
| } | |
| function isBoatVisible(bx, by, heading, fovVal) { | |
| var boatB = (Math.atan2(bx, by) * 180) / Math.PI | |
| /* Cone uses rotate(heading) inside worldG scale(S,-S); boresight world bearing = -heading in this atan2 convention. */ | |
| var rel = (((boatB + heading + 540) % 360) - 180) | |
| return Math.abs(rel) <= fovVal / 2 && by > -0.05 | |
| } | |
| function draw(p, bx, by, inFov) { | |
| if (!coneEl || SCALE === 0) return | |
| if (SCALE !== lastDrawScale) { | |
| lastDrawScale = SCALE | |
| updateScaleOnlySvgGeom() | |
| } | |
| updateTrackSvgGeomIfNeeded() | |
| syncConeAndPointerShape(p) | |
| dynamicCamG.setAttribute('transform', 'rotate(' + camHeading + ')') | |
| var segX = boatEnd.x - boatStart.x | |
| var segY = boatEnd.y - boatStart.y | |
| var udx = segX * boatDir | |
| var udy = segY * boatDir | |
| var pl = Math.hypot(segX, segY) | |
| /* Bow is drawn along +icon Y (world north). worldG uses scale(S,-S), so screen dir ∝ (udx,-udy). */ | |
| var iconAngle = 0 | |
| if (pl > 1e-5) { | |
| iconAngle = (Math.atan2(udy, udx) * 180) / Math.PI - 90 | |
| lastTrailIconAngle = iconAngle | |
| } else if (!isNaN(lastTrailIconAngle)) iconAngle = lastTrailIconAngle | |
| boatIconG.setAttribute('transform', 'rotate(' + iconAngle + ')') | |
| if (bx !== lastDrawBx || by !== lastDrawBy) { | |
| lastDrawBx = bx | |
| lastDrawBy = by | |
| bearingLine.setAttribute('x2', bx) | |
| bearingLine.setAttribute('y2', by) | |
| boatG.setAttribute('transform', 'translate(' + bx + ',' + by + ')') | |
| } | |
| if (prevInFov !== inFov) { | |
| prevInFov = inFov | |
| boatHull.setAttribute('fill', inFov ? C.s : C.d) | |
| boatHull.setAttribute('stroke', inFov ? rgba(C.sr, 0.9) : rgba(C.dr, 0.85)) | |
| } | |
| if (inFov) { | |
| boatHalo.setAttribute('display', '') | |
| boatHalo.setAttribute('fill', rgba(C.sr, 0.14)) | |
| } else boatHalo.setAttribute('display', 'none') | |
| } | |
| function schedulePanelStats(p, bx, by, inFov) { | |
| panelStatsPayload = { p: p, bx: bx, by: by, inFov: inFov } | |
| if (!panelStatsRaf) panelStatsRaf = requestAnimationFrame(function () { | |
| panelStatsRaf = 0 | |
| if (!panelStatsPayload) return | |
| var q = panelStatsPayload | |
| panelStatsPayload = null | |
| updateStats(q.p, q.bx, q.by, q.inFov) | |
| }) | |
| } | |
| function updateStats(p, bx, by, inFov) { | |
| var c = statCache | |
| var dist = Math.hypot(bx, by) | |
| var st = p.frontArc / p.frontSpeed + (360 - p.frontArc) / p.backSpeed | |
| function u(el, k, s) { | |
| if (el && c[k] !== s) { el.textContent = s; c[k] = s } | |
| } | |
| u(c.elScan, 'lastScan', st.toFixed(1) + 's') | |
| u(c.elDist, 'lastDist', (dist * 1000).toFixed(0) + 'm') | |
| var travelM = (inFov ? detectedStreak : lastDetectedStreak).toFixed(1) + 'm' | |
| var blindM = (!inFov ? blindStreak : lastBlindStreak).toFixed(1) + 'm' | |
| if (c.elTravel) { | |
| u(c.elTravel, 'lastTravel', travelM) | |
| var to = inFov ? '1' : '0.5' | |
| if (c.lastTravelOp !== to) { c.elTravel.style.opacity = to; c.lastTravelOp = to } | |
| } | |
| if (c.elBlind) { | |
| u(c.elBlind, 'lastBlind', blindM) | |
| var bo = !inFov ? '1' : '0.5' | |
| if (c.lastBlindOp !== bo) { c.elBlind.style.opacity = bo; c.lastBlindOp = bo } | |
| } | |
| u(c.elTravelPrev, 'lastTravelPrev', prevDetectedStreak > 0 ? 'prev: ' + prevDetectedStreak.toFixed(1) + 'm' : 'prev: —') | |
| u(c.elBlindPrev, 'lastBlindPrev', prevBlindStreak > 0 ? 'prev: ' + prevBlindStreak.toFixed(1) + 'm' : 'prev: —') | |
| var prevTotalM = prevDetectedStreak + prevBlindStreak | |
| if (c.elRisk && c.elRiskBar) { | |
| if (prevTotalM >= MIN_BLIND_SHARE_M) { | |
| var share = (prevBlindStreak / prevTotalM) * 100 | |
| var shareT = share.toFixed(0) + '%' | |
| var col = share > 66 ? 'var(--danger)' : share > 33 ? 'var(--warning)' : 'var(--success)' | |
| var w = share + '%' | |
| u(c.elRisk, 'lastRiskT', shareT) | |
| if (c.lastRiskCol !== col) { c.elRisk.style.color = col; c.lastRiskCol = col } | |
| if (c.lastRiskW !== w) { c.elRiskBar.style.width = w; c.lastRiskW = w } | |
| if (c.lastRiskBg !== col) { c.elRiskBar.style.background = col; c.lastRiskBg = col } | |
| } else { | |
| u(c.elRisk, 'lastRiskT', '—') | |
| if (c.lastRiskW !== '0%') { c.elRiskBar.style.width = '0%'; c.lastRiskW = '0%' } | |
| } | |
| } | |
| var vis = inFov ? 'YES' : 'NO' | |
| var visCls = 'val ' + (inFov ? 'active' : 'inactive') | |
| if (c.elVis) { | |
| u(c.elVis, 'lastVis', vis) | |
| if (c.lastVisCls !== visCls) { c.elVis.className = visCls; c.lastVisCls = visCls } | |
| } | |
| } | |
| function animate(ts) { | |
| if (!running || !svg) return | |
| if (lastTime === null) lastTime = ts | |
| var dt = Math.min((ts - lastTime) / 1000, 0.1) | |
| lastTime = ts | |
| var p = getParams() | |
| var boatKmS = p.boatSpeed * 0.000514 | |
| var pathLen = Math.hypot(boatEnd.x - boatStart.x, boatEnd.y - boatStart.y) | |
| if (pathLen > 0.001) { | |
| boatT += boatDir * (boatKmS * dt) / pathLen | |
| if (boatT >= 1) { boatT = 1; boatDir = -1 } | |
| if (boatT <= 0) { boatT = 0; boatDir = 1 } | |
| } | |
| var speed = isFront(camHeading, p.frontArc) ? p.frontSpeed : p.backSpeed | |
| camHeading -= camDir * speed * dt | |
| if (camHeading >= 180) camHeading -= 360 | |
| if (camHeading < -180) camHeading += 360 | |
| var bx = boatStart.x + (boatEnd.x - boatStart.x) * boatT | |
| var by = boatStart.y + (boatEnd.y - boatStart.y) * boatT | |
| var visible = isBoatVisible(bx, by, camHeading, p.fov) | |
| var distThisFrame = p.boatSpeed * 0.514 * dt | |
| if (coneEl && SCALE > 0 && pathLen > 0.001 && !visible) appendTrail(bx, by) | |
| if (prevVisible !== null && visible !== prevVisible) { | |
| if (visible) { | |
| clearTrail(false) | |
| prevBlindStreak = blindStreak | |
| lastBlindStreak = blindStreak | |
| blindStreak = 0 | |
| } else { | |
| prevDetectedStreak = detectedStreak | |
| lastDetectedStreak = detectedStreak | |
| detectedStreak = 0 | |
| } | |
| } | |
| if (visible) detectedStreak += distThisFrame | |
| else blindStreak += distThisFrame | |
| prevVisible = visible | |
| if (!coneEl || SCALE === 0) updateStats(p, bx, by, visible) | |
| else { | |
| draw(p, bx, by, visible) | |
| schedulePanelStats(p, bx, by, visible) | |
| } | |
| if (running) requestAnimationFrame(animate) | |
| } | |
| function getCanvasXY(e) { | |
| var src = 'touches' in e && e.touches.length ? e.touches[0] : e | |
| return { x: (src.clientX - hitL) * hitSx, y: (src.clientY - hitT) * hitSy } | |
| } | |
| function onResize() { | |
| if (!resizeRaf) resizeRaf = requestAnimationFrame(function () { resizeRaf = 0; resize() }) | |
| } | |
| function tryDragStart(e, thresh) { | |
| var pt = getCanvasXY(e) | |
| var s = worldToCanvas(boatStart.x, boatStart.y) | |
| var end = worldToCanvas(boatEnd.x, boatEnd.y) | |
| if (Math.hypot(pt.x - s.x, pt.y - s.y) < thresh) dragging = 'start' | |
| else if (Math.hypot(pt.x - end.x, pt.y - end.y) < thresh) dragging = 'end' | |
| } | |
| function dragToWorld(e) { | |
| var pt = getCanvasXY(e) | |
| var w = canvasToWorld(pt.x, pt.y) | |
| w.y = Math.max(0.05, Math.min(1.3, w.y)) | |
| w.x = Math.max(-1.4, Math.min(1.4, w.x)) | |
| if (dragging === 'start') boatStart = w | |
| else if (dragging === 'end') boatEnd = w | |
| } | |
| function onMouseDown(e) { | |
| lastPointerEv = e | |
| tryDragStart(e, 26) | |
| updateSvgCursor(e) | |
| } | |
| var CURSOR_NEAR = 30 | |
| var lastPointerEv = null | |
| function updateSvgCursor(e) { | |
| if (!svg || !SCALE) return | |
| if (dragging) { | |
| svg.style.cursor = 'grabbing' | |
| return | |
| } | |
| var pt = getCanvasXY(e) | |
| var s = worldToCanvas(boatStart.x, boatStart.y) | |
| var end = worldToCanvas(boatEnd.x, boatEnd.y) | |
| if (Math.hypot(pt.x - s.x, pt.y - s.y) < CURSOR_NEAR || | |
| Math.hypot(pt.x - end.x, pt.y - end.y) < CURSOR_NEAR) | |
| svg.style.cursor = 'grab' | |
| else svg.style.cursor = '' | |
| } | |
| function onMouseMove(e) { | |
| lastPointerEv = e | |
| if (dragging) dragToWorld(e) | |
| updateSvgCursor(e) | |
| } | |
| function onMouseUp() { | |
| dragging = null | |
| if (svg) { | |
| if (lastPointerEv) updateSvgCursor(lastPointerEv) | |
| else svg.style.cursor = '' | |
| } | |
| } | |
| function onTouchStart(e) { | |
| if (e.touches.length === 2) { | |
| lastPinchDist = Math.hypot( | |
| e.touches[0].clientX - e.touches[1].clientX, | |
| e.touches[0].clientY - e.touches[1].clientY) | |
| dragging = null | |
| return | |
| } | |
| if (e.touches.length !== 1) return | |
| e.preventDefault() | |
| tryDragStart(e, 34) | |
| } | |
| function onTouchMove(e) { | |
| if (e.touches.length === 2 && lastPinchDist !== null) { | |
| e.preventDefault() | |
| var dist = Math.hypot( | |
| e.touches[0].clientX - e.touches[1].clientX, | |
| e.touches[0].clientY - e.touches[1].clientY) | |
| applyZoom(dist - lastPinchDist) | |
| lastPinchDist = dist | |
| return | |
| } | |
| if (!dragging) return | |
| e.preventDefault() | |
| dragToWorld(e) | |
| } | |
| function onTouchEnd(e) { | |
| dragging = null | |
| if (e.touches.length < 2) lastPinchDist = null | |
| } | |
| function onWheel(e) { | |
| e.preventDefault() | |
| applyZoom(-e.deltaY) | |
| } | |
| function bindRange(id, valSpanId, onInput) { | |
| var input = $(id) | |
| var span = valSpanId ? $(valSpanId) : null | |
| function sync() { if (span) span.textContent = input.value } | |
| input.addEventListener('input', function () { | |
| sync() | |
| resetStreaks() | |
| if (onInput) onInput() | |
| }) | |
| sync() | |
| } | |
| /** VOS #1 = shipped defaults; proposal #2 uses 50° FOV (slider is degrees, not a zoom %). */ | |
| var CAMERA_PRESETS = { | |
| vos1: { fov: 18, frontSpeed: 6, backSpeed: 60, frontArc: 180 }, | |
| prop1: { fov: 18, frontSpeed: 7, backSpeed: 120, frontArc: 125 }, | |
| prop2: { fov: 50, frontSpeed: 8, backSpeed: 120, frontArc: 125 }, | |
| } | |
| function clearPresetDropdown() { | |
| var sel = $('camera-preset') | |
| if (sel) sel.value = '' | |
| } | |
| function applyCameraPreset(key) { | |
| var p = CAMERA_PRESETS[key] | |
| if (!p || !inpFov) return | |
| inpFov.value = String(p.fov) | |
| inpFrontSpeed.value = String(p.frontSpeed) | |
| inpBackSpeed.value = String(p.backSpeed) | |
| inpFrontArc.value = String(p.frontArc) | |
| $('val-fov').textContent = inpFov.value | |
| $('val-front-speed').textContent = inpFrontSpeed.value | |
| $('val-back-speed').textContent = inpBackSpeed.value | |
| $('val-front-arc').textContent = inpFrontArc.value | |
| cachedConeFov = cachedPtrLen = NaN | |
| resetStreaks() | |
| buildZones() | |
| var sel = $('camera-preset') | |
| if (sel) sel.value = key | |
| } | |
| function init() { | |
| svg = $('c') | |
| wrap = $('canvas-wrap') | |
| if (!svg || !wrap) return | |
| bgSky = svgEl('rect', { x: 0, y: 0, fill: 'var(--surface-1)' }) | |
| bgSea = svgEl('rect', { x: 0, y: 0, fill: 'var(--surface-2)' }) | |
| horizLine = svgEl('line', { x1: 0, 'stroke-dasharray': '6 4' }) | |
| ringsG = svgEl('g', { id: 'rings' }) | |
| compassG = svgEl('g', { id: 'compass' }) | |
| zonesG = svgEl('g', { id: 'zones' }) | |
| worldG = svgEl('g', { id: 'world' }) | |
| worldG.appendChild(compassG) | |
| worldG.appendChild(zonesG) | |
| worldG.appendChild(ringsG) | |
| dynamicCamG = svgEl('g', { id: 'dynamic-cam' }) | |
| coneEl = svgEl('path', { 'stroke-linejoin': 'round', 'stroke-width': 1, 'vector-effect': 'non-scaling-stroke' }) | |
| camPointer = svgEl('line', { x1: 0, y1: 0, x2: 0, y2: 0.1, 'stroke-width': 1.5, 'vector-effect': 'non-scaling-stroke' }) | |
| dynamicCamG.appendChild(coneEl) | |
| dynamicCamG.appendChild(camPointer) | |
| trajLine = svgEl('line', { 'stroke-width': 1, 'stroke-dasharray': '4 3', 'vector-effect': 'non-scaling-stroke' }) | |
| boatTrail = svgEl('polyline', { | |
| fill: 'none', 'stroke-width': 2, 'stroke-linecap': 'round', 'stroke-linejoin': 'round', | |
| 'vector-effect': 'non-scaling-stroke', | |
| }) | |
| bearingLine = svgEl('line', { x1: 0, y1: 0, 'stroke-width': 0.5, 'vector-effect': 'non-scaling-stroke' }) | |
| boatG = svgEl('g', { id: 'boat-layer' }) | |
| boatHalo = svgEl('circle', { cx: 0, cy: 0, stroke: 'none' }) | |
| boatIconG = svgEl('g') | |
| boatIconScaleG = svgEl('g') | |
| boatHull = svgEl('path', { | |
| d: 'M 0 1.05 L 0.38 0.22 L 0.26 -0.72 L -0.26 -0.72 L -0.38 0.22 Z', | |
| 'stroke-width': 1.2, 'stroke-linejoin': 'round', 'vector-effect': 'non-scaling-stroke', | |
| }) | |
| boatIconScaleG.appendChild(boatHull) | |
| boatIconG.appendChild(boatIconScaleG) | |
| boatG.appendChild(boatHalo) | |
| boatG.appendChild(boatIconG) | |
| anchorSOuter = svgEl('circle', { | |
| class: 'drag-anchor-ring drag-anchor-ring--start', | |
| fill: 'none', 'stroke-width': 2.5, 'vector-effect': 'non-scaling-stroke', | |
| }) | |
| anchorS = svgEl('circle', { class: 'drag-anchor-core drag-anchor-core--start', 'stroke-width': 1.5, 'vector-effect': 'non-scaling-stroke' }) | |
| anchorEOuter = svgEl('circle', { | |
| class: 'drag-anchor-ring drag-anchor-ring--end', | |
| fill: 'none', 'stroke-width': 2.5, 'vector-effect': 'non-scaling-stroke', | |
| }) | |
| anchorE = svgEl('circle', { class: 'drag-anchor-core drag-anchor-core--end', 'stroke-width': 1.5, 'vector-effect': 'non-scaling-stroke' }) | |
| anchorSLabel = svgEl('text', { | |
| class: 'drag-anchor-label', | |
| 'text-anchor': 'middle', 'dominant-baseline': 'middle', transform: 'scale(1,-1)', 'font-family': FONT_SANS, | |
| }) | |
| anchorSLabel.textContent = 'S' | |
| anchorELabel = svgEl('text', { | |
| class: 'drag-anchor-label drag-anchor-label--end', | |
| 'text-anchor': 'middle', 'dominant-baseline': 'middle', transform: 'scale(1,-1)', 'font-family': FONT_SANS, | |
| }) | |
| anchorELabel.textContent = 'E' | |
| camDot = svgEl('circle', { cx: 0, cy: 0, 'stroke-width': 1.5, 'vector-effect': 'non-scaling-stroke' }) | |
| for (var ci = 0; ci < 4; ci++) camCorners.push(svgEl('line', { 'stroke-width': 1, 'vector-effect': 'non-scaling-stroke' })) | |
| worldG.appendChild(dynamicCamG) | |
| worldG.appendChild(trajLine) | |
| worldG.appendChild(boatTrail) | |
| worldG.appendChild(bearingLine) | |
| worldG.appendChild(boatG) | |
| worldG.appendChild(anchorSOuter) | |
| worldG.appendChild(anchorS) | |
| worldG.appendChild(anchorEOuter) | |
| worldG.appendChild(anchorE) | |
| worldG.appendChild(anchorSLabel) | |
| worldG.appendChild(anchorELabel) | |
| worldG.appendChild(camDot) | |
| for (var cj = 0; cj < 4; cj++) worldG.appendChild(camCorners[cj]) | |
| svg.appendChild(bgSky) | |
| svg.appendChild(bgSea) | |
| svg.appendChild(horizLine) | |
| svg.appendChild(worldG) | |
| inpFov = $('input-fov') | |
| inpFrontSpeed = $('input-front-speed') | |
| inpBackSpeed = $('input-back-speed') | |
| inpFrontArc = $('input-front-arc') | |
| inpBoatSpeed = $('input-boat-speed') | |
| cachedConeFov = cachedConeR = cachedPtrLen = NaN | |
| lastDrawBx = lastDrawBy = NaN | |
| initTheme() | |
| running = true | |
| resize() | |
| updateZoomLabel() | |
| setDir(-1) | |
| window.addEventListener('resize', onResize) | |
| svg.addEventListener('mousedown', onMouseDown) | |
| svg.addEventListener('mousemove', onMouseMove) | |
| svg.addEventListener('mouseup', onMouseUp) | |
| svg.addEventListener('mouseleave', function () { | |
| if (!dragging) svg.style.cursor = '' | |
| }) | |
| svg.addEventListener('touchstart', onTouchStart, { passive: false }) | |
| svg.addEventListener('touchmove', onTouchMove, { passive: false }) | |
| svg.addEventListener('touchend', onTouchEnd) | |
| svg.addEventListener('touchcancel', onTouchEnd) | |
| svg.addEventListener('wheel', onWheel, { passive: false }) | |
| $('zoom-out').addEventListener('click', function () { applyZoom(-1) }) | |
| $('zoom-in').addEventListener('click', function () { applyZoom(1) }) | |
| $('dir-cw').addEventListener('click', function () { setDir(1) }) | |
| $('dir-ccw').addEventListener('click', function () { setDir(-1) }) | |
| bindRange('input-fov', 'val-fov', function () { | |
| cachedConeFov = cachedPtrLen = NaN | |
| clearPresetDropdown() | |
| }) | |
| bindRange('input-front-speed', 'val-front-speed', clearPresetDropdown) | |
| bindRange('input-back-speed', 'val-back-speed', clearPresetDropdown) | |
| bindRange('input-front-arc', 'val-front-arc', function () { | |
| clearPresetDropdown() | |
| buildZones() | |
| }) | |
| bindRange('input-boat-speed', 'val-boat-speed') | |
| var presetSelect = $('camera-preset') | |
| if (presetSelect) { | |
| presetSelect.addEventListener('change', function () { | |
| var k = presetSelect.value | |
| if (k) applyCameraPreset(k) | |
| }) | |
| } | |
| statCache.elScan = $('st-scan') | |
| statCache.elDist = $('st-dist') | |
| statCache.elTravel = $('st-travel') | |
| statCache.elBlind = $('st-blind') | |
| statCache.elTravelPrev = $('st-travel-prev') | |
| statCache.elBlindPrev = $('st-blind-prev') | |
| statCache.elRisk = $('st-risk') | |
| statCache.elRiskBar = $('st-risk-bar') | |
| statCache.elVis = $('st-vis') | |
| requestAnimationFrame(animate) | |
| } | |
| if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init) | |
| else init() | |
| })() | |