Files
dc-observatio/services/pcp-tgu-ops-ui/src/features/constellation/MapView.jsx
T
Дмитрий Соловьев 5fa3ceaac9 feat(группировка): реальная съёмка/сброс/связь и заполнение борта
Дашборд «Группировка» наполняется из плана: режимы SURVEY/DROP → интервалы
съёмки и сброса с контуром и станцией, окна ЗРВ выделены в отдельную «связь»
(раньше показывались как сброс). Заполнение бортового накопителя считается
из объёмов маршрутов (volumeMb) с модельной ёмкостью 500 ГБ
(ONBOARD_CAPACITY_MB) — memAt по таймлайну, подсветка контура/линии сброса
на карте.
2026-06-08 14:00:03 +03:00

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;