// Saamid overhead map // Renders mic array, drone position, fading trail, range rings, grid, bearing ticks // Map style: 'hybrid' (default), 'radar' (rings only), 'grid' (grid only), 'sat' (terrain blob) // React hooks accessed via React.* to avoid global-scope name clashes across Babel files function SaamidMap({ mics, drone, // {x, y, alt, prob, type} or null trail, // array of {x, y, alpha} mapStyle, // 'hybrid' | 'radar' | 'grid' | 'sat' threatType, // 'clear' | 'detected' | 'decoy' | 'unknown' T, // i18n dir, // 'rtl' | 'ltr' onMicMove, showMicLabels, showCompass, highlightedMicId, }) { // World bounds: -80..+80 metres in x and y, mic array centred near (1,1) const W = 100; // half-width metres const cx = 1, cy = 1; // map centre in metres (mic array centroid) const VIEW = 800; // px viewBox const scale = VIEW / (W * 2); const wx = (mx) => (mx - cx + W) * scale; const wy = (my) => VIEW - (my - cy + W) * scale; // y up const ringDistances = [10, 25, 50, 75, 100]; // metres // bearing & range from origin (mic centroid) const bearingDeg = drone ? (Math.atan2(drone.x - cx, drone.y - cy) * 180 / Math.PI + 360) % 360 : null; const range = drone ? Math.sqrt((drone.x - cx) ** 2 + (drone.y - cy) ** 2) : null; // colors via CSS vars const cs = useThemeVars(); return (
{/* radial vignette */} {/* satellite-style terrain blob */} {/* background */} {/* satellite-style terrain (only for sat) */} {mapStyle === "sat" && ( )} {/* protected zone (always) */} {/* GRID (hybrid + grid) */} {(mapStyle === "hybrid" || mapStyle === "grid") && ( {/* dotted base */} {/* every-10m lines */} {Array.from({ length: 21 }).map((_, i) => { const v = -100 + i * 10; return ( ); })} {/* axis labels every 25m */} {[-50, -25, 25, 50].map((v) => ( {v} {v} ))} )} {/* RANGE RINGS (hybrid + radar + sat) */} {(mapStyle === "hybrid" || mapStyle === "radar" || mapStyle === "sat") && ( {ringDistances.map((d, i) => ( ))} {ringDistances.map((d) => ( {d}m ))} {/* bearing tick marks every 10°, labels every 30° */} {Array.from({ length: 36 }).map((_, i) => { const ang = i * 10; const rad = (ang - 90) * Math.PI / 180; const r1 = ringDistances[ringDistances.length - 1] * scale; const tickLen = ang % 30 === 0 ? 8 : 4; return ( ); })} {[0, 90, 180, 270].map((ang) => { const rad = (ang - 90) * Math.PI / 180; const r1 = ringDistances[ringDistances.length - 1] * scale + 14; const labels = { 0: "N", 90: "E", 180: "S", 270: "W" }; return ( {labels[ang]} ); })} {/* sweep beam if detecting */} {threatType !== "clear" && drone && ( )} )} {/* vignette overlay */} {/* Trail (fading) */} {trail.map((p, i) => ( ))} {trail.length > 1 && ( `${wx(p.x)},${wy(p.y)}`).join(" ")} fill="none" stroke="rgba(214,48,48,0.45)" strokeWidth="1.2" /> )} {/* Drone marker */} {drone && ( {/* crosshair ticks */} {[0, 90, 180, 270].map((a) => { const rad = a * Math.PI / 180; return ( ); })} )} {/* Mic array — rendered as a SINGLE grouped marker at the centroid. The 4-mic 2m square would collapse to one pixel at this scale, and the main map's job is showing drone position relative to the array, not inter-mic geometry. Per-mic labels live in the inset only. */} {(() => { const ax = mics.reduce((s, m) => s + m.x, 0) / Math.max(mics.length, 1); const ay = mics.reduce((s, m) => s + m.y, 0) / Math.max(mics.length, 1); const cxp = wx(ax), cyp = wy(ay); const isHi = highlightedMicId != null; return ( {isHi && ( )} {/* diamond glyph: 4 mics → diamond shape; rotated square */} ); })()} {/* Mic-array zoom inset */} {/* Compass overlay (top-end corner) */} {showCompass !== false && (
N
{T.bearing} {bearingDeg != null ? Math.round(bearingDeg) : "—"}°
{T.distance} {range != null ? range.toFixed(1) : "—"} {T.range_m}
)}
); } function MicArrayInset({ mics, cs, dir, T }) { // Tiny inset showing the mic array enlarged (since it's only ~2m, hard to see at world scale) // Inset is bigger so 2m geometry is legible; pad symmetrically and use // the largest of (extent, 2m) so a single-mic edit doesn't blow up scale. const PAD = 0.6; const xs = mics.map(m => m.x), ys = mics.map(m => m.y); const cx = (Math.min(...xs) + Math.max(...xs)) / 2; const cy = (Math.min(...ys) + Math.max(...ys)) / 2; const ext = Math.max( Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys), 2.0, ); const half = ext / 2 + PAD; const minX = cx - half, maxX = cx + half; const minY = cy - half, maxY = cy + half; const w = maxX - minX, h = maxY - minY; const SIZE = 180; const s = SIZE / Math.max(w, h); const tx = (m) => (m.x - minX) * s; const ty = (m) => SIZE - (m.y - minY) * s; // pin to bottom-end corner of the SVG viewBox (800) const x = dir === "rtl" ? 16 : 800 - SIZE - 16; const y = 800 - SIZE - 16; // Quadrant-aware label offset so labels don't overlap the marker glyph // or its neighbours: NE label sits NE of marker, etc. const labelOffset = (m) => { const dx = m.x - cx, dy = m.y - cy; const ox = dx >= 0 ? 9 : -9; const oy = dy >= 0 ? -9 : 13; const anchor = dx >= 0 ? "start" : "end"; return { ox, oy, anchor }; }; // bounding 2m square outline (anchored at min corner of the actual array) const aMinX = Math.min(...xs), aMinY = Math.min(...ys); const aMaxX = Math.max(...xs), aMaxY = Math.max(...ys); const boxX1 = (aMinX - minX) * s, boxX2 = (aMaxX - minX) * s; const boxY1 = SIZE - (aMaxY - minY) * s, boxY2 = SIZE - (aMinY - minY) * s; return ( {/* No title text — Arabic translation overflows the inset width. The dashed bounding box, M1-Mn labels and the 1m scale bar make this panel self-explanatory. */} {/* bounding 2m square outline */} {/* dimension labels on the bounding box */} {(aMaxX - aMinX).toFixed(1)}m {(aMaxY - aMinY).toFixed(1)}m {mics.map((m) => { const lo = labelOffset(m); return ( M{m.id} ); })} {/* scale bar */} 1m ); } // helper to read CSS vars at render time function useThemeVars() { const [vars, setVars] = React.useState(() => readVars()); React.useEffect(() => { const mo = new MutationObserver(() => setVars(readVars())); mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); return () => mo.disconnect(); }, []); return vars; } function readVars() { const cs = getComputedStyle(document.documentElement); const v = (name) => cs.getPropertyValue(name).trim() || "#000"; return { mapBg: v("--map-bg"), mapInk: v("--map-ink"), bg: v("--bg"), bgElev: v("--bg-elev"), text: v("--text"), textMuted: v("--text-muted"), textFaint: v("--text-faint"), accent: v("--accent"), accent2: v("--accent-2"), gridLine: v("--grid-line"), gridLineStrong: v("--grid-line-strong"), surfaceLine: v("--surface-line"), surfaceLineStrong: v("--surface-line-strong"), charcoal: v("--charcoal-900"), }; } Object.assign(window, { SaamidMap, useThemeVars });