pcp-tgu-ops-ui: вкладка «Группировка» (состояние группировки) на реальных данных
Встраивание дизайн-хэндоффа constellation dashboard отдельной вкладкой рядом с Планы/Карта/Комплексный план/Редактор/Заявки. - модуль src/features/constellation/ (прототип на JSX + realData.ts/ConstellationTab.tsx) - реальные данные вместо симуляции: платформы (OPERATIONAL), станции, заявки→ROI (WKT), трасса flight-line как эфемерида (сэмплер позиции через env.subSat), ЗРВ→сброс, съёмка по пролёту над ROI; оптика/локация — по имени КА из НСИ - d3-geo + topojson-client; карта world-atlas локально (без CDN) - изоляция стилей на .cdash (конфликт --line с theme.css), высота .app=100% - локальное время в часах и оси таймлайна (конвенция проекта) - allowJs:true в tsconfig.app.json под JSX-модуль
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
/* ============================================================
|
||||
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",
|
||||
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([]);
|
||||
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();
|
||||
}
|
||||
|
||||
// downlink (сброс) — animated dashed line to station
|
||||
if (stt.state === "downlink") {
|
||||
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;
|
||||
Reference in New Issue
Block a user