5fa3ceaac9
Дашборд «Группировка» наполняется из плана: режимы SURVEY/DROP → интервалы съёмки и сброса с контуром и станцией, окна ЗРВ выделены в отдельную «связь» (раньше показывались как сброс). Заполнение бортового накопителя считается из объёмов маршрутов (volumeMb) с модельной ёмкостью 500 ГБ (ONBOARD_CAPACITY_MB) — memAt по таймлайну, подсветка контура/линии сброса на карте.
361 lines
13 KiB
React
361 lines
13 KiB
React
/* ============================================================
|
|
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 (
|
|
<div className="map-wrap" ref={wrapRef}>
|
|
<canvas ref={canvasRef} onMouseMove={onMove} onMouseLeave={() => apiRef.current.onCursor && apiRef.current.onCursor(null)} onClick={onClick} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// trace a small lon/lat polygon as a planar projected path (no antimeridian crossing)
|
|
function tracePoly(ctx, proj, poly) {
|
|
ctx.beginPath();
|
|
poly.forEach((pt, i) => {
|
|
const p = proj(pt); if (!p) return;
|
|
if (i === 0) ctx.moveTo(p[0], p[1]); else ctx.lineTo(p[0], p[1]);
|
|
});
|
|
ctx.closePath();
|
|
}
|
|
|
|
export default MapView;
|