/* ============================================================ MapView.jsx — canvas world map (ESM, default export) Renders: land + graticule, ROIs, stations, night/terminator, satellite sine tracks, footprints, capture swaths, downlinks. Imperative draw(simTime, ui) called from the app RAF loop. ============================================================ */ import React, { useRef, useEffect } from "react"; import { geoEquirectangular, geoPath, geoGraticule, geoCircle } from "d3-geo"; import { feature } from "topojson-client"; import * as SIM from "./sim.js"; import { hexA } from "./util.js"; // Карта мира раздаётся со своего origin (Vite ?url), без рантайм-зависимости от CDN // (см. INTEGRATION.md §3). Файл: assets/land-110m.json (world-atlas@2). import landUrl from "./assets/land-110m.json?url"; // keep the original call-sites (d3.* / topojson.*) working unchanged const d3 = { geoEquirectangular, geoPath, geoGraticule, geoCircle }; const topojson = { feature }; // Подспутниковая точка: реальный сэмплер env.subSat (flight-line) или встроенная // симуляция SIM.subSat. См. INTEGRATION.md §5.2. function subSatOf(env, sat, tMs) { return env.subSat ? env.subSat(sat, tMs) : SIM.subSat(sat, tMs, env.epochMs); } const MAP_C = { ocean: "#0a0e0f", land: "#171c1e", landStroke: "#252d30", grat: "rgba(120,150,150,0.06)", gratText: "rgba(150,180,178,0.25)", amber: "#d99a4e", blue: "#5b9bd5", link: "#6f8fae", red: "#d9685f", teal: "#5fb3a3", night: "rgba(20,28,46,0.55)", nightEdge: "rgba(90,120,170,0.18)", station: "#cdd6d3", }; function MapView({ env, apiRef }) { const wrapRef = useRef(null); const canvasRef = useRef(null); const st = useRef({ w: 0, h: 0, dpr: 1, proj: null, path: null, base: null, land: null, ready: false, }); // ---- load world land (topojson via unpkg, graceful fallback) ---- useEffect(() => { let alive = true; fetch(landUrl) .then((r) => r.json()) .then((topo) => { if (!alive) return; st.current.land = topojson.feature(topo, topo.objects.land); buildBase(); if (apiRef.current.onReady) apiRef.current.onReady(); }) .catch(() => { buildBase(); }); return () => { alive = false; }; }, []); // ---- sizing ---- function resize() { const wrap = wrapRef.current, cv = canvasRef.current; if (!wrap || !cv) return; const r = wrap.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2); st.current.w = r.width; st.current.h = r.height; st.current.dpr = dpr; cv.width = r.width * dpr; cv.height = r.height * dpr; cv.style.width = r.width + "px"; cv.style.height = r.height + "px"; const proj = d3.geoEquirectangular() .scale(r.width / (2 * Math.PI)) .translate([r.width / 2, r.height / 2]) .rotate([-10, 0]); st.current.proj = proj; buildBase(); } useEffect(() => { resize(); const ro = new ResizeObserver(resize); if (wrapRef.current) ro.observe(wrapRef.current); return () => ro.disconnect(); }, []); // ---- cached base layer (land, graticule, ROIs, stations) ---- function buildBase() { const s = st.current; if (!s.w || !s.proj) return; const dpr = s.dpr; const off = document.createElement("canvas"); off.width = s.w * dpr; off.height = s.h * dpr; const ctx = off.getContext("2d"); ctx.scale(dpr, dpr); const path = d3.geoPath(s.proj, ctx); // ocean ctx.fillStyle = MAP_C.ocean; ctx.fillRect(0, 0, s.w, s.h); // graticule const grat = d3.geoGraticule().step([30, 30]); ctx.beginPath(); path(grat()); ctx.strokeStyle = MAP_C.grat; ctx.lineWidth = 1; ctx.stroke(); // land if (s.land) { ctx.beginPath(); path(s.land); ctx.fillStyle = MAP_C.land; ctx.fill(); ctx.strokeStyle = MAP_C.landStroke; ctx.lineWidth = 0.6; ctx.stroke(); } // ROIs (заявки) — faint amber dashed (planar path, avoids d3 winding-complement) ctx.save(); ctx.lineWidth = 1; ctx.setLineDash([4, 3]); for (const r of env.rois) { tracePoly(ctx, s.proj, r.poly); ctx.fillStyle = "rgba(217,154,78,0.05)"; ctx.fill(); ctx.strokeStyle = "rgba(217,154,78,0.5)"; ctx.stroke(); } ctx.restore(); // station labels ctx.font = "9px 'IBM Plex Mono', monospace"; ctx.textAlign = "left"; ctx.textBaseline = "middle"; for (const stn of env.stations) { const pt = s.proj([stn.lon, stn.lat]); if (!pt) continue; ctx.fillStyle = "rgba(205,214,211,0.55)"; ctx.fillText(stn.name.toUpperCase(), pt[0] + 8, pt[1] - 6); } s.base = off; s.path = path; s.ready = true; } // ---- helpers ---- function project(lon, lat) { const p = st.current.proj && st.current.proj([lon, lat]); return p; } // draw a great-circle-ish track polyline, breaking at antimeridian wrap function strokeTrack(ctx, pts, color, width, alpha) { const s = st.current, half = s.w * 0.5; ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineJoin = "round"; ctx.lineCap = "round"; ctx.beginPath(); let prev = null, started = false; for (const ll of pts) { const p = s.proj(ll); if (!p) { started = false; continue; } if (started && prev && Math.abs(p[0] - prev[0]) > half) started = false; if (!started) { ctx.moveTo(p[0], p[1]); started = true; } else ctx.lineTo(p[0], p[1]); prev = p; } ctx.stroke(); ctx.restore(); } // ---- main per-frame draw ---- function draw(tMs, ui) { const s = st.current, cv = canvasRef.current; if (!cv || !s.proj) return; const ctx = cv.getContext("2d"); ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0); ctx.clearRect(0, 0, s.w, s.h); if (s.base) ctx.drawImage(s.base, 0, 0, s.w, s.h); const path = d3.geoPath(s.proj, ctx); const focus = ui.focusId; // --- night overlay (тень Земли) --- const ss = SIM.subSolar(tMs); const anti = [SIM.wrapLon(ss.lon + 180), -ss.lat]; const nightPoly = d3.geoCircle().center(anti).radius(90)(); ctx.beginPath(); path(nightPoly); ctx.fillStyle = MAP_C.night; ctx.fill(); ctx.lineWidth = 1; ctx.strokeStyle = MAP_C.nightEdge; ctx.stroke(); // --- per-sat dynamic --- const dash = (tMs / 30) % 16; const sats = env.sats; // tracks first (under glyphs) for (const sat of sats) { const dim = focus && focus !== sat.id; const pts = []; for (let dm = -52; dm <= 52; dm += 1.6) { pts.push([0, 0]); // placeholder replaced below } // build actual track points pts.length = 0; for (let dm = -52; dm <= 52; dm += 1.6) { const p = subSatOf(env, sat, tMs + dm * 60000); if (p) pts.push([p.lon, p.lat]); } strokeTrack(ctx, pts, sat.color, dim ? 0.8 : 1.4, dim ? 0.10 : 0.30); } // events + glyphs for (const sat of sats) { const dim = focus && focus !== sat.id; const stt = SIM.statusAt(sat, tMs, env); const p = stt.p; const pt = s.proj([p.lon, p.lat]); if (!pt) continue; const baseA = dim ? 0.25 : 1; // footprint circle (swath of view) const fp = d3.geoCircle().center([p.lon, p.lat]).radius(sat.payload === "radar" ? 6.5 : 8)(); ctx.beginPath(); path(fp); ctx.fillStyle = hexA(sat.color, dim ? 0.03 : 0.055); ctx.fill(); ctx.strokeStyle = hexA(sat.color, dim ? 0.08 : 0.16); ctx.lineWidth = 1; ctx.stroke(); // capture (маршрут съёмки) — amber swath along track + ROI highlight if (stt.state === "capture") { const seg = []; for (let dm = -7; dm <= 7; dm += 1) { const q = subSatOf(env, sat, tMs + dm * 60000); if (q) seg.push([q.lon, q.lat]); } strokeTrack(ctx, seg, MAP_C.amber, dim ? 3 : 6, dim ? 0.3 : 0.85); ctx.setLineDash([]); // контур съёмки есть не у всех режимов плана — рисуем только когда задан if (stt.roi && stt.roi.poly && stt.roi.poly.length) { tracePoly(ctx, s.proj, stt.roi.poly); ctx.fillStyle = "rgba(217,154,78,0.16)"; ctx.fill(); ctx.strokeStyle = MAP_C.amber; ctx.lineWidth = 1.4; ctx.stroke(); } } // link (связь с КА) — пунктир к станции на всём окне радиовидимости (ЗРВ), // даже когда сброс не идёт. Тоньше/бледнее сброса; реальный сброс ляжет поверх. if (env.sched) { const lk = SIM.intervalAt((env.sched[sat.id] || {}).link, tMs); if (lk && lk.lon != null && lk.lat != null) { const lp = s.proj([lk.lon, lk.lat]); if (lp) { ctx.save(); ctx.setLineDash([3, 6]); ctx.lineDashOffset = -dash; ctx.strokeStyle = hexA(MAP_C.link, dim ? 0.22 : 0.55); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(pt[0], pt[1]); ctx.lineTo(lp[0], lp[1]); ctx.stroke(); ctx.restore(); } } } // downlink (сброс) — animated dashed line to station if (stt.state === "downlink" && stt.station && stt.station.lon != null && stt.station.lat != null) { const sp = s.proj([stt.station.lon, stt.station.lat]); if (sp) { ctx.save(); ctx.setLineDash([5, 5]); ctx.lineDashOffset = -dash; ctx.strokeStyle = hexA(MAP_C.blue, dim ? 0.4 : 0.95); ctx.lineWidth = 1.6; ctx.beginPath(); ctx.moveTo(pt[0], pt[1]); ctx.lineTo(sp[0], sp[1]); ctx.stroke(); ctx.restore(); // station glow ctx.beginPath(); ctx.arc(sp[0], sp[1], 7, 0, 7); ctx.fillStyle = hexA(MAP_C.blue, 0.18); ctx.fill(); } } // stations as triangles (drawn here so glow sits under) // (drawn once globally below to avoid repaint per sat) -> skip // sat glyph drawSatGlyph(ctx, pt[0], pt[1], sat, stt, baseA, sat.id === focus); // label if (!dim || sat.id === focus) { ctx.font = "10px 'IBM Plex Mono', monospace"; ctx.textAlign = "left"; ctx.textBaseline = "middle"; ctx.fillStyle = hexA("#e6ebe9", baseA); ctx.fillText(sat.name, pt[0] + 11, pt[1] + 0.5); } } // stations triangles on top for (const stn of env.stations) { const sp = s.proj([stn.lon, stn.lat]); if (!sp) continue; drawTriangle(ctx, sp[0], sp[1], 5, MAP_C.station); } } function drawSatGlyph(ctx, x, y, sat, stt, alpha, isFocus) { const ringColor = stt.state === "capture" ? MAP_C.amber : stt.state === "downlink" ? MAP_C.blue : stt.state === "prohibit" ? MAP_C.red : null; // halo ctx.beginPath(); ctx.arc(x, y, 9, 0, 7); ctx.fillStyle = hexA(sat.color, 0.10 * alpha); ctx.fill(); // ring if (ringColor) { ctx.beginPath(); ctx.arc(x, y, 7.5, 0, 7); ctx.strokeStyle = hexA(ringColor, alpha); ctx.lineWidth = 1.6; ctx.setLineDash([]); ctx.stroke(); } if (isFocus) { ctx.beginPath(); ctx.arc(x, y, 11, 0, 7); ctx.strokeStyle = hexA(MAP_C.teal, 0.9); ctx.lineWidth = 1; ctx.stroke(); } // diamond body ctx.save(); ctx.translate(x, y); ctx.rotate(Math.PI / 4); const r = 3.4; ctx.fillStyle = stt.eclipse ? hexA(sat.color, 0.5 * alpha) : hexA(sat.color, alpha); ctx.strokeStyle = hexA("#05080a", alpha); ctx.lineWidth = 1; ctx.beginPath(); ctx.rect(-r, -r, r * 2, r * 2); ctx.fill(); ctx.stroke(); ctx.restore(); } function drawTriangle(ctx, x, y, r, color) { ctx.beginPath(); ctx.moveTo(x, y - r); ctx.lineTo(x + r, y + r * 0.8); ctx.lineTo(x - r, y + r * 0.8); ctx.closePath(); ctx.fillStyle = hexA(color, 0.85); ctx.fill(); ctx.strokeStyle = "rgba(10,14,16,0.9)"; ctx.lineWidth = 0.8; ctx.stroke(); } // pointer → lon/lat + nearest sat function onMove(e) { const s = st.current, r = canvasRef.current.getBoundingClientRect(); const x = e.clientX - r.left, y = e.clientY - r.top; const ll = s.proj && s.proj.invert([x, y]); if (apiRef.current.onCursor) apiRef.current.onCursor(ll ? { lon: ll[0], lat: ll[1] } : null); // hit test sats handled by app via getHover; expose nearest if (apiRef.current.onPick) { let best = null, bd = 16; for (const sat of env.sats) { const p = subSatOf(env, sat, apiRef.current.simTime()); const pp = p && s.proj([p.lon, p.lat]); if (!pp) continue; const d = Math.hypot(pp[0] - x, pp[1] - y); if (d < bd) { bd = d; best = sat.id; } } apiRef.current.onPick(best, x, y); } } function onClick() { if (apiRef.current.onClickPick) apiRef.current.onClickPick(); } // expose imperative API useEffect(() => { apiRef.current.draw = draw; apiRef.current.project = project; }); return (