2D-карта (станции, заявки) и вкладка «Заявки»

Новая вкладка «Заявки»: показ заявок за интервал на 2D-карте,
создание заявки рисованием полигона на карте с формой параметров
(Оптика/РСА) и удаление выбранной заявки (createRequest/deleteRequest,
RequestCreatePanel/RequestDetailsPanel, polygonToWkt). В Tgu2DMapView
добавлен изолированный режим рисования полигона.

2D-карта: панели информации о станции и о заявках, слой станций
(stationsApi), поддержка выбора объектов через спатиальный индекс.
Список слоёв упрощён — трасса/полоса/маркеры КА убраны из переключателя,
по умолчанию включены работы плана, станции и заявки.
This commit is contained in:
Дмитрий Соловьев
2026-06-01 22:17:50 +03:00
parent 715b5108d6
commit 0128c5569c
26 changed files with 1851 additions and 96 deletions
@@ -7,9 +7,9 @@ import { drawSwath } from "./canvas/drawSwath";
import { drawTiles } from "./canvas/drawTiles";
import { drawTracks } from "./canvas/drawTracks";
import { createMapProjection } from "./geometry/mapProjection";
import { createPolygonSpatialIndex, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex";
import { createPolygonSpatialIndex, hitTestAllPolygons, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex";
import type { Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes";
import type { GeoPoint, MapMarker, MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes";
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
type Tgu2DMapViewProps = {
@@ -17,12 +17,18 @@ type Tgu2DMapViewProps = {
layers: Tgu2DMapLayersState;
redrawToken: number;
onObjectSelect: (selection?: Tgu2DMapSelection) => void;
onRequestsAtPoint?: (polygons: MapPolygon[]) => void;
hoveredRequestPolygonId?: string;
selectedRequestPolygonId?: string;
pickMode?: boolean;
onPickPoint?: (point: { lat: number; lon: number }) => void;
targets?: Tgu2DMapTarget[];
pickTarget?: Tgu2DMapTarget;
selectedTargetId?: string;
onTargetSelect?: (id: string) => void;
drawMode?: boolean;
draftPolygon?: GeoPoint[];
onDrawAddPoint?: (point: GeoPoint) => void;
};
export type Tgu2DMapTarget = {
@@ -59,17 +65,26 @@ export function Tgu2DMapView({
layers,
redrawToken,
onObjectSelect,
onRequestsAtPoint,
hoveredRequestPolygonId,
selectedRequestPolygonId,
pickMode = false,
onPickPoint,
targets = [],
pickTarget,
selectedTargetId,
onTargetSelect
onTargetSelect,
drawMode = false,
draftPolygon = [],
onDrawAddPoint
}: Tgu2DMapViewProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const dragRef = useRef<DragState | undefined>(undefined);
const hasDraggedRef = useRef(false);
const hoverRef = useRef<MapPolygon | undefined>(undefined);
const hoverMarkerRef = useRef<MapMarker | undefined>(undefined);
const selectedMarkerRef = useRef<MapMarker | undefined>(undefined);
const [size, setSize] = useState<MapSize>({ width: 1000, height: 560 });
const [view, setView] = useState<MapViewState>(INITIAL_VIEW);
const [tileVersion, setTileVersion] = useState(0);
@@ -81,6 +96,10 @@ export function Tgu2DMapView({
[scene.polygons, layers]
);
const polygonIndex = useMemo(() => createPolygonSpatialIndex(activePolygons), [activePolygons]);
const activeMarkers = useMemo(
() => scene.markers.filter((marker) => layers[marker.layer]),
[scene.markers, layers]
);
const projection = useMemo(() => createMapProjection(view, size), [view, size]);
const visiblePolygons = useMemo(() => {
// When the canvas is wider than the world tile, the entire globe is visible —
@@ -170,11 +189,15 @@ export function Tgu2DMapView({
const updateHover = useCallback(
(event: React.PointerEvent) => {
const screenPoint = screenPointFromEvent(event);
const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint));
if (polygon?.id === hoverRef.current?.id) return;
const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
const polygon = marker ? undefined : hitTestPolygons(polygonIndex, projection.unproject(screenPoint));
if (marker?.id === hoverMarkerRef.current?.id && polygon?.id === hoverRef.current?.id) return;
hoverMarkerRef.current = marker;
hoverRef.current = polygon;
if (!polygon) {
if (!marker && !polygon) {
setTooltip(undefined);
return;
}
@@ -182,15 +205,16 @@ export function Tgu2DMapView({
setTooltip({
x: screenPoint.x + 12,
y: screenPoint.y + 12,
title: polygon.name,
subtitle: layerTitle(polygon.layer)
title: marker ? marker.label : polygon!.name,
subtitle: marker ? "Станция" : layerTitle(polygon!.layer)
});
},
[polygonIndex, projection, screenPointFromEvent]
[activeMarkers, polygonIndex, projection, screenPointFromEvent]
);
const onPointerDown = (event: React.PointerEvent) => {
const screenPoint = screenPointFromEvent(event);
hasDraggedRef.current = false;
dragRef.current = {
startX: screenPoint.x,
startY: screenPoint.y,
@@ -199,6 +223,8 @@ export function Tgu2DMapView({
wrapperRef.current?.setPointerCapture(event.pointerId);
};
const DRAG_THRESHOLD = 4;
const onPointerMove = (event: React.PointerEvent) => {
const drag = dragRef.current;
if (!drag) {
@@ -209,6 +235,9 @@ export function Tgu2DMapView({
const screenPoint = screenPointFromEvent(event);
const dx = screenPoint.x - drag.startX;
const dy = screenPoint.y - drag.startY;
if (!hasDraggedRef.current && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) {
hasDraggedRef.current = true;
}
const dragProjection = createMapProjection(
{
centerLon: drag.centerAtStart.lon,
@@ -231,15 +260,53 @@ export function Tgu2DMapView({
};
const onClick = (event: React.MouseEvent) => {
if (hasDraggedRef.current) return;
const screenPoint = screenPointFromEvent(event);
if (drawMode) {
onDrawAddPoint?.(projection.unproject(screenPoint));
return;
}
if (pickMode) {
onPickPoint?.(projection.unproject(screenPoint));
onObjectSelect(undefined);
return;
}
const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint));
const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
if (marker) {
selectedMarkerRef.current = marker;
selectedPolygonRef.current = undefined;
onRequestsAtPoint?.([]);
onObjectSelect({
type: "marker",
id: marker.id,
name: marker.label,
layer: marker.layer,
stationNumber: marker.stationNumber,
lat: marker.point.lat,
lon: marker.point.lon,
altitude: marker.altitude,
elevationMin: marker.elevationMin,
elevationMax: marker.elevationMax
});
return;
}
const geoPoint = projection.unproject(screenPoint);
const polygon = hitTestPolygons(polygonIndex, geoPoint);
if (polygon?.layer === "requests" && onRequestsAtPoint) {
const allRequests = hitTestAllPolygons(polygonIndex, geoPoint).filter((p) => p.layer === "requests");
onRequestsAtPoint(allRequests);
selectedPolygonRef.current = undefined;
selectedMarkerRef.current = undefined;
onObjectSelect(undefined);
return;
}
onRequestsAtPoint?.([]);
selectedPolygonRef.current = polygon;
selectedMarkerRef.current = undefined;
if (!polygon) {
onObjectSelect(undefined);
@@ -276,11 +343,21 @@ export function Tgu2DMapView({
};
const selectedPolygon = selectedPolygonRef.current;
const overlayPolygons = uniquePolygons([selectedPolygon, hoverRef.current]);
const selectedMarker = selectedMarkerRef.current;
const externalSelectedPolygon = useMemo(
() => selectedRequestPolygonId ? activePolygons.find((p) => p.id === selectedRequestPolygonId) : undefined,
[selectedRequestPolygonId, activePolygons]
);
const externalHoveredPolygon = useMemo(
() => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined,
[hoveredRequestPolygonId, activePolygons]
);
const overlayPolygons = uniquePolygons([externalSelectedPolygon, selectedPolygon, externalHoveredPolygon, hoverRef.current]);
const overlayMarkers = uniqueMarkers([selectedMarker, hoverMarkerRef.current]);
return (
<div
className={`tgu-2d-map ${pickMode ? "is-picking" : ""}`}
className={`tgu-2d-map ${pickMode || drawMode ? "is-picking" : ""}`}
ref={wrapperRef}
onClick={onClick}
onPointerDown={onPointerDown}
@@ -290,6 +367,7 @@ export function Tgu2DMapView({
onPointerLeave={() => {
dragRef.current = undefined;
hoverRef.current = undefined;
hoverMarkerRef.current = undefined;
setTooltip(undefined);
}}
onWheel={onWheel}
@@ -299,7 +377,10 @@ export function Tgu2DMapView({
{overlayPolygons.flatMap((polygon) => {
const raw = polygon.points.map((point) => projection.project(point));
const pts = unwrapX(raw, projection.worldSize);
const cls = polygon.id === selectedPolygon?.id ? "is-selected" : "is-hovered";
const cls =
polygon.id === selectedPolygon?.id || polygon.id === externalSelectedPolygon?.id
? "is-selected"
: "is-hovered";
return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => {
const visible = pts.some((p) => {
const x = p.x + dx;
@@ -315,6 +396,11 @@ export function Tgu2DMapView({
);
});
})}
{overlayMarkers.map((marker) => {
const pt = projection.project(marker.point);
const cls = marker.id === selectedMarker?.id ? "is-selected" : "is-hovered";
return <circle key={marker.id} cx={pt.x} cy={pt.y} r={10} className={cls} />;
})}
{targets.map((t) => {
const screen = projection.project(t);
const isSelected = t.id === selectedTargetId;
@@ -344,6 +430,22 @@ export function Tgu2DMapView({
</g>
);
})()}
{draftPolygon.length > 0 && (() => {
const pts = unwrapX(draftPolygon.map((point) => projection.project(point)), projection.worldSize);
const path = pts.map((p) => `${p.x},${p.y}`).join(" ");
return (
<g className="tgu-2d-map__draft">
{pts.length >= 2 && (
pts.length >= 3
? <polygon className="tgu-2d-map__draft-fill" points={path} />
: <polyline className="tgu-2d-map__draft-line" points={path} />
)}
{pts.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={4} className="tgu-2d-map__draft-vertex" />
))}
</g>
);
})()}
</svg>
{tooltip && (
<div className="tgu-2d-map__tooltip" style={{ left: tooltip.x, top: tooltip.y }}>
@@ -388,6 +490,36 @@ function unwrapX(points: { x: number; y: number }[], worldSize: number): { x: nu
return out;
}
function hitTestMarkers(
markers: MapMarker[],
screenPoint: ScreenPoint,
projection: ReturnType<typeof createMapProjection>,
hitRadius = 10
): MapMarker | undefined {
let best: MapMarker | undefined;
let bestDist = hitRadius;
for (const marker of markers) {
const pt = projection.project(marker.point);
const dist = Math.sqrt((pt.x - screenPoint.x) ** 2 + (pt.y - screenPoint.y) ** 2);
if (dist <= bestDist) {
bestDist = dist;
best = marker;
}
}
return best;
}
function uniqueMarkers(markers: Array<MapMarker | undefined>): MapMarker[] {
const result: MapMarker[] = [];
const ids = new Set<string>();
for (const marker of markers) {
if (!marker || ids.has(marker.id)) continue;
ids.add(marker.id);
result.push(marker);
}
return result;
}
function uniquePolygons(polygons: Array<MapPolygon | undefined>): MapPolygon[] {
const result: MapPolygon[] = [];
const ids = new Set<string>();