Add PCP architecture docs and TGU ops updates
Capture current PCP architecture notes, service-map prototypes, TGU operations UI/map work, local configuration updates, database helper scripts, and request/sample JSON artifacts.
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { drawBaseMap } from "./canvas/drawBaseMap";
|
||||
import { drawPlanWorks } from "./canvas/drawPlanWorks";
|
||||
import { drawStations } from "./canvas/drawStations";
|
||||
import { drawSwath } from "./canvas/drawSwath";
|
||||
import { drawTracks } from "./canvas/drawTracks";
|
||||
import { createMapProjection } from "./geometry/mapProjection";
|
||||
import { createPolygonSpatialIndex, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex";
|
||||
import type { Tgu2DMapLayersState } from "./model/mapLayerTypes";
|
||||
import type { MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes";
|
||||
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
|
||||
|
||||
type Tgu2DMapViewProps = {
|
||||
scene: Tgu2DMapScene;
|
||||
layers: Tgu2DMapLayersState;
|
||||
redrawToken: number;
|
||||
onObjectSelect: (selection?: Tgu2DMapSelection) => void;
|
||||
};
|
||||
|
||||
type DragState = {
|
||||
startX: number;
|
||||
startY: number;
|
||||
centerAtStart: {
|
||||
lon: number;
|
||||
lat: number;
|
||||
};
|
||||
};
|
||||
|
||||
type TooltipState = {
|
||||
x: number;
|
||||
y: number;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
const INITIAL_VIEW: MapViewState = {
|
||||
centerLon: 45,
|
||||
centerLat: 30,
|
||||
zoom: 2
|
||||
};
|
||||
|
||||
export function Tgu2DMapView({ scene, layers, redrawToken, onObjectSelect }: Tgu2DMapViewProps) {
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const dragRef = useRef<DragState | undefined>(undefined);
|
||||
const hoverRef = useRef<MapPolygon | undefined>(undefined);
|
||||
const [size, setSize] = useState<MapSize>({ width: 1000, height: 560 });
|
||||
const [view, setView] = useState<MapViewState>(INITIAL_VIEW);
|
||||
const [tooltip, setTooltip] = useState<TooltipState>();
|
||||
const selectedPolygonRef = useRef<MapPolygon | undefined>(undefined);
|
||||
const activePolygons = useMemo(
|
||||
() => scene.polygons.filter((polygon) => layers[polygon.layer]),
|
||||
[scene.polygons, layers]
|
||||
);
|
||||
const polygonIndex = useMemo(() => createPolygonSpatialIndex(activePolygons), [activePolygons]);
|
||||
const projection = useMemo(() => createMapProjection(view, size), [view, size]);
|
||||
const visiblePolygons = useMemo(() => {
|
||||
const topLeft = projection.unproject({ x: 0, y: 0 });
|
||||
const bottomRight = projection.unproject({ x: size.width, y: size.height });
|
||||
const minLon = Math.min(topLeft.lon, bottomRight.lon);
|
||||
const maxLon = Math.max(topLeft.lon, bottomRight.lon);
|
||||
|
||||
if (maxLon - minLon > 300) {
|
||||
return activePolygons;
|
||||
}
|
||||
|
||||
return queryPolygonsByBBox(polygonIndex, {
|
||||
minLon,
|
||||
maxLon,
|
||||
minLat: Math.min(topLeft.lat, bottomRight.lat),
|
||||
maxLat: Math.max(topLeft.lat, bottomRight.lat)
|
||||
});
|
||||
}, [activePolygons, polygonIndex, projection, size.height, size.width]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = wrapperRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
setSize({
|
||||
width: Math.max(320, element.clientWidth),
|
||||
height: Math.max(320, element.clientHeight)
|
||||
});
|
||||
});
|
||||
resizeObserver.observe(element);
|
||||
setSize({
|
||||
width: Math.max(320, element.clientWidth),
|
||||
height: Math.max(320, element.clientHeight)
|
||||
});
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||
canvas.width = Math.floor(size.width * dpr);
|
||||
canvas.height = Math.floor(size.height * dpr);
|
||||
canvas.style.width = `${size.width}px`;
|
||||
canvas.style.height = `${size.height}px`;
|
||||
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
|
||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
context.clearRect(0, 0, size.width, size.height);
|
||||
drawBaseMap(context, projection, size);
|
||||
|
||||
if (layers.swath) {
|
||||
drawSwath(context, visiblePolygons.filter((polygon) => polygon.layer === "swath"), projection, size);
|
||||
}
|
||||
if (layers.planWorks) {
|
||||
drawPlanWorks(context, visiblePolygons.filter((polygon) => polygon.layer === "planWorks"), projection, size);
|
||||
}
|
||||
if (layers.tracks) {
|
||||
drawTracks(context, scene.lines.filter((line) => layers[line.layer]), projection);
|
||||
}
|
||||
if (layers.stations) {
|
||||
drawStations(context, scene.markers.filter((marker) => marker.layer === "stations"), projection);
|
||||
}
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(frameId);
|
||||
}, [layers, projection, redrawToken, scene.lines, scene.markers, size, visiblePolygons]);
|
||||
|
||||
const screenPointFromEvent = useCallback((event: React.PointerEvent | React.MouseEvent): ScreenPoint => {
|
||||
const bounds = wrapperRef.current?.getBoundingClientRect();
|
||||
return {
|
||||
x: bounds ? event.clientX - bounds.left : 0,
|
||||
y: bounds ? event.clientY - bounds.top : 0
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateHover = useCallback(
|
||||
(event: React.PointerEvent) => {
|
||||
const screenPoint = screenPointFromEvent(event);
|
||||
const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint));
|
||||
if (polygon?.id === hoverRef.current?.id) return;
|
||||
|
||||
hoverRef.current = polygon;
|
||||
if (!polygon) {
|
||||
setTooltip(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setTooltip({
|
||||
x: screenPoint.x + 12,
|
||||
y: screenPoint.y + 12,
|
||||
title: polygon.name,
|
||||
subtitle: layerTitle(polygon.layer)
|
||||
});
|
||||
},
|
||||
[polygonIndex, projection, screenPointFromEvent]
|
||||
);
|
||||
|
||||
const onPointerDown = (event: React.PointerEvent) => {
|
||||
const screenPoint = screenPointFromEvent(event);
|
||||
dragRef.current = {
|
||||
startX: screenPoint.x,
|
||||
startY: screenPoint.y,
|
||||
centerAtStart: { lon: view.centerLon, lat: view.centerLat }
|
||||
};
|
||||
wrapperRef.current?.setPointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const onPointerMove = (event: React.PointerEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) {
|
||||
updateHover(event);
|
||||
return;
|
||||
}
|
||||
|
||||
const screenPoint = screenPointFromEvent(event);
|
||||
const dx = screenPoint.x - drag.startX;
|
||||
const dy = screenPoint.y - drag.startY;
|
||||
const dragProjection = createMapProjection(
|
||||
{
|
||||
centerLon: drag.centerAtStart.lon,
|
||||
centerLat: drag.centerAtStart.lat,
|
||||
zoom: view.zoom
|
||||
},
|
||||
size
|
||||
);
|
||||
const nextCenter = dragProjection.unproject({ x: size.width / 2 - dx, y: size.height / 2 - dy });
|
||||
setView((current) => ({
|
||||
...current,
|
||||
centerLon: nextCenter.lon,
|
||||
centerLat: Math.max(-82, Math.min(82, nextCenter.lat))
|
||||
}));
|
||||
};
|
||||
|
||||
const onPointerUp = (event: React.PointerEvent) => {
|
||||
dragRef.current = undefined;
|
||||
wrapperRef.current?.releasePointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const onClick = (event: React.MouseEvent) => {
|
||||
const screenPoint = screenPointFromEvent(event);
|
||||
const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint));
|
||||
selectedPolygonRef.current = polygon;
|
||||
|
||||
if (!polygon) {
|
||||
onObjectSelect(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
onObjectSelect({
|
||||
type: "polygon",
|
||||
id: polygon.id,
|
||||
name: polygon.name,
|
||||
layer: polygon.layer,
|
||||
planId: polygon.planId,
|
||||
spacecraftId: polygon.spacecraftId,
|
||||
status: polygon.status
|
||||
});
|
||||
};
|
||||
|
||||
const onWheel = (event: React.WheelEvent) => {
|
||||
event.preventDefault();
|
||||
const screenPoint = screenPointFromEvent(event);
|
||||
const before = projection.unproject(screenPoint);
|
||||
const nextZoom = Math.max(0.8, Math.min(8, view.zoom + (event.deltaY < 0 ? 0.35 : -0.35)));
|
||||
const nextProjection = createMapProjection({ ...view, zoom: nextZoom }, size);
|
||||
const after = nextProjection.unproject(screenPoint);
|
||||
|
||||
setView((current) => ({
|
||||
...current,
|
||||
zoom: nextZoom,
|
||||
centerLon: current.centerLon + before.lon - after.lon,
|
||||
centerLat: Math.max(-82, Math.min(82, current.centerLat + before.lat - after.lat))
|
||||
}));
|
||||
};
|
||||
|
||||
const selectedPolygon = selectedPolygonRef.current;
|
||||
const overlayPolygons = uniquePolygons([selectedPolygon, hoverRef.current]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="tgu-2d-map"
|
||||
ref={wrapperRef}
|
||||
onClick={onClick}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onPointerLeave={() => {
|
||||
dragRef.current = undefined;
|
||||
hoverRef.current = undefined;
|
||||
setTooltip(undefined);
|
||||
}}
|
||||
onWheel={onWheel}
|
||||
>
|
||||
<canvas className="tgu-2d-map__canvas" ref={canvasRef} />
|
||||
<svg className="tgu-2d-map__overlay" width={size.width} height={size.height} aria-hidden="true">
|
||||
{overlayPolygons.map((polygon) => (
|
||||
<polygon
|
||||
key={polygon.id}
|
||||
points={polygon.points.map((point) => {
|
||||
const screen = projection.project(point);
|
||||
return `${screen.x},${screen.y}`;
|
||||
}).join(" ")}
|
||||
className={polygon.id === selectedPolygon?.id ? "is-selected" : "is-hovered"}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
{tooltip && (
|
||||
<div className="tgu-2d-map__tooltip" style={{ left: tooltip.x, top: tooltip.y }}>
|
||||
<div>{tooltip.title}</div>
|
||||
<span>{tooltip.subtitle}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="tgu-2d-map__attribution">Схематичная основа · Web Mercator</div>
|
||||
<div className="tgu-2d-map__zoom">
|
||||
<button type="button" onClick={() => setView((current) => ({ ...current, zoom: Math.min(8, current.zoom + 0.5) }))}>+</button>
|
||||
<button type="button" onClick={() => setView((current) => ({ ...current, zoom: Math.max(0.8, current.zoom - 0.5) }))}>-</button>
|
||||
<button type="button" onClick={() => setView(INITIAL_VIEW)}>□</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function layerTitle(layer: MapPolygon["layer"]): string {
|
||||
switch (layer) {
|
||||
case "planWorks":
|
||||
return "Работы плана";
|
||||
case "swath":
|
||||
return "Полоса обзора";
|
||||
case "tracks":
|
||||
return "Трасса КА";
|
||||
}
|
||||
}
|
||||
|
||||
function uniquePolygons(polygons: Array<MapPolygon | undefined>): MapPolygon[] {
|
||||
const result: MapPolygon[] = [];
|
||||
const ids = new Set<string>();
|
||||
for (const polygon of polygons) {
|
||||
if (!polygon || ids.has(polygon.id)) continue;
|
||||
ids.add(polygon.id);
|
||||
result.push(polygon);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user