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:
Дмитрий Соловьев
2026-05-30 14:18:19 +03:00
parent 9fca4d4051
commit c3a1a8b4a1
86 changed files with 10426 additions and 74 deletions
@@ -0,0 +1,48 @@
import type { TguPlanUi } from "../../model/timelineTypes";
import { platformLabel } from "../tgu-planning/tguTimelineMapper";
import { statusStyle } from "../tgu-planning/tguStatus";
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
type Tgu2DMapDetailsProps = {
selectedPlan?: TguPlanUi;
selectedObject?: Tgu2DMapSelection;
};
export function Tgu2DMapDetails({ selectedPlan, selectedObject }: Tgu2DMapDetailsProps) {
const status = selectedPlan ? statusStyle(selectedPlan.status) : undefined;
return (
<section className="tgu-map-details">
<div>
<div className="tgu-map-details__label">План</div>
<div className="tgu-map-details__value mono">{selectedPlan?.planId || "-"}</div>
</div>
<div>
<div className="tgu-map-details__label">КА</div>
<div className="tgu-map-details__value">
{selectedPlan ? platformLabel(selectedPlan.platform, selectedPlan.spacecraftId) : "-"}
</div>
</div>
<div>
<div className="tgu-map-details__label">Статус</div>
<div className="tgu-map-details__value">
{status ? (
<span className="tgu-map-status-pill">
<i style={{ backgroundColor: status.color }} />
{status.label}
</span>
) : "-"}
</div>
</div>
<div>
<div className="tgu-map-details__label">Объект карты</div>
<div className="tgu-map-details__value mono">{selectedObject?.name || selectedObject?.id || "-"}</div>
</div>
<div>
<div className="tgu-map-details__label">Работы</div>
<div className="tgu-map-details__value">Работы плана</div>
</div>
</section>
);
}
@@ -0,0 +1,43 @@
import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
import type { Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { Tgu2DMapScene } from "./model/mapTypes";
export type Tgu2DMapLayerAvailability = Partial<Record<keyof Tgu2DMapLayersState, string>>;
export type Tgu2DMapSceneInput = {
range: TimelineRange;
selectedPlan?: TguPlanUi;
selectedSpacecraftId?: string;
platforms: TguPlatformUi[];
};
const UNAVAILABLE = "Данные слоя недоступны в текущем API.";
export function buildTgu2DMapScene(_input: Tgu2DMapSceneInput): Tgu2DMapScene {
return {
polygons: [],
lines: [],
markers: []
};
}
export function getLayerAvailability(layers: Tgu2DMapLayersState, scene: Tgu2DMapScene): Tgu2DMapLayerAvailability {
const availability: Tgu2DMapLayerAvailability = {};
if (layers.tracks && scene.lines.filter((line) => line.layer === "tracks").length === 0) {
availability.tracks = UNAVAILABLE;
}
if (layers.swath && scene.polygons.filter((polygon) => polygon.layer === "swath").length === 0) {
availability.swath = UNAVAILABLE;
}
if (layers.planWorks && scene.polygons.filter((polygon) => polygon.layer === "planWorks").length === 0) {
availability.planWorks = UNAVAILABLE;
}
if (layers.stations && scene.markers.filter((marker) => marker.layer === "stations").length === 0) {
availability.stations = UNAVAILABLE;
}
if (layers.spacecraftMarkers && scene.markers.filter((marker) => marker.layer === "spacecraftMarkers").length === 0) {
availability.spacecraftMarkers = UNAVAILABLE;
}
return availability;
}
@@ -0,0 +1,110 @@
import type { TguPlatformUi } from "../../model/timelineTypes";
import { filterPlatforms, platformLabel } from "../tgu-planning/tguTimelineMapper";
import { TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { Tgu2DMapLayerAvailability } from "./Tgu2DMapLayers";
type Tgu2DMapSidebarProps = {
platforms: TguPlatformUi[];
selectedSpacecraftId?: string;
selectedPlanId?: string;
layers: Tgu2DMapLayersState;
search: string;
layerAvailability: Tgu2DMapLayerAvailability;
onSearchChange: (value: string) => void;
onSelectSpacecraft: (spacecraftId?: string) => void;
onToggleLayer: (key: Tgu2DMapLayerKey) => void;
};
export function Tgu2DMapSidebar({
platforms,
selectedSpacecraftId,
selectedPlanId,
layers,
search,
layerAvailability,
onSearchChange,
onSelectSpacecraft,
onToggleLayer
}: Tgu2DMapSidebarProps) {
const filteredPlatforms = filterPlatforms(platforms, search);
return (
<aside className="tgu-map-panel">
<div className="tgu-panel-heading">
<span>Карта 2D</span>
<button className="tgu-link-button" type="button" onClick={() => onSelectSpacecraft(undefined)}>
Все
</button>
</div>
<div className="tgu-map-panel__section">
<div className="tgu-map-panel__label">Выбранный план</div>
<div className="tgu-map-panel__value mono">{selectedPlanId || "-"}</div>
</div>
<div className="tgu-map-panel__section">
<div className="tgu-map-panel__label">Слои</div>
<div className="tgu-map-layer-list">
{TGU_2D_MAP_LAYERS.map((layer) => (
<label className="tgu-map-layer" key={layer.key}>
<input type="checkbox" checked={layers[layer.key]} onChange={() => onToggleLayer(layer.key)} />
<span>
<span className="tgu-map-layer__title">{layer.label}</span>
<span className="tgu-map-layer__description">{layer.description}</span>
{layers[layer.key] && layerAvailability[layer.key] && (
<span className="tgu-map-layer__warning">{layerAvailability[layer.key]}</span>
)}
</span>
</label>
))}
</div>
</div>
<div className="tgu-map-panel__section">
<div className="tgu-map-panel__label">Космические аппараты</div>
<div className="tgu-search">
<input
type="search"
placeholder="Поиск name / NORAD / mission"
value={search}
onChange={(event) => onSearchChange(event.target.value)}
/>
</div>
<div className="tgu-map-spacecraft-list">
{filteredPlatforms.map((platform) => {
const selected = selectedSpacecraftId === platform.spacecraftId;
return (
<button
className={`tgu-map-spacecraft ${selected ? "is-selected" : ""}`}
type="button"
key={platform.spacecraftId}
onClick={() => onSelectSpacecraft(platform.spacecraftId)}
>
<span>
<span className="tgu-map-spacecraft__name">{platformLabel(platform, platform.spacecraftId)}</span>
<span className="tgu-map-spacecraft__meta">
NORAD {platform.noradId || platform.spacecraftId}
{platform.mission ? ` · ${platform.mission}` : ""}
</span>
</span>
<span className="tgu-spacecraft__count">{platform.planCount}</span>
</button>
);
})}
{filteredPlatforms.length === 0 && <div className="tgu-empty tgu-empty--compact">Нет КА по текущему поиску.</div>}
</div>
</div>
<div className="tgu-map-panel__section">
<div className="tgu-map-panel__label">Статусы планов</div>
<div className="tgu-map-status-legend">
<span><i className="status-accepted" />ACCEPTED</span>
<span><i className="status-rejected" />REJECTED</span>
<span><i className="status-waiting" />WAITING_DECISION</span>
<span><i className="status-planned" />PLANNED</span>
<span><i className="status-issuing" />ISSUING</span>
<span><i className="status-expired" />EXPIRED</span>
<span><i className="status-superseded" />SUPERSEDED</span>
<span><i className="status-ambiguous" />START_AMBIGUOUS</span>
</div>
</div>
</aside>
);
}
@@ -0,0 +1,131 @@
import { useMemo, useState } from "react";
import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
import { platformLabel } from "../tgu-planning/tguTimelineMapper";
import { DEFAULT_TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
import { buildTgu2DMapScene, getLayerAvailability } from "./Tgu2DMapLayers";
import { Tgu2DMapDetails } from "./Tgu2DMapDetails";
import { Tgu2DMapSidebar } from "./Tgu2DMapSidebar";
import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar";
import { Tgu2DMapView } from "./Tgu2DMapView";
import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval";
type Tgu2DMapTabProps = {
range: TimelineRange;
invalidRange: boolean;
selectedSpacecraftId?: string;
selectedPlan?: TguPlanUi;
platforms: TguPlatformUi[];
onSelectSpacecraft: (spacecraftId?: string) => void;
};
export function Tgu2DMapTab({
range,
invalidRange,
selectedSpacecraftId,
selectedPlan,
platforms,
onSelectSpacecraft
}: Tgu2DMapTabProps) {
const [layers, setLayers] = useState<Tgu2DMapLayersState>(DEFAULT_TGU_2D_MAP_LAYERS);
const [search, setSearch] = useState("");
const [redrawToken, setRedrawToken] = useState(0);
const [selectedObject, setSelectedObject] = useState<Tgu2DMapSelection>();
const satelliteId = selectedPlan?.spacecraftId ?? selectedSpacecraftId;
const mapInterval = getTgu2DMapIntervalState(range, selectedPlan);
const mapInvalidRange = invalidRange || mapInterval.tooLarge;
const platform = useMemo(
() => platforms.find((item) => item.spacecraftId === satelliteId),
[platforms, satelliteId]
);
const satelliteLabel = satelliteId ? platformLabel(platform, satelliteId) : "КА не выбран";
const intervalLabel = `${formatDate(mapInterval.range.fromMs)} - ${formatDate(mapInterval.range.toMs)}`;
const scene = useMemo(
() => buildTgu2DMapScene({ range: mapInterval.range, selectedPlan, selectedSpacecraftId: satelliteId, platforms }),
[mapInterval.range, platforms, satelliteId, selectedPlan]
);
const layerAvailability = useMemo(() => getLayerAvailability(layers, scene), [layers, scene]);
const overlayMessage = !satelliteId
? "Выберите КА в sidebar или план на timeline."
: invalidRange
? "Некорректный интервал: from должен быть меньше to."
: mapInterval.tooLarge
? TGU_2D_MAP_INTERVAL_WARNING
: undefined;
const toggleLayer = (key: Tgu2DMapLayerKey) => {
setLayers((current) => ({ ...current, [key]: !current[key] }));
};
return (
<main className="tgu-map-workspace">
<Tgu2DMapToolbar
satelliteLabel={satelliteLabel}
intervalLabel={intervalLabel}
layers={layers}
onRefresh={() => setRedrawToken((value) => value + 1)}
onToggleLayer={toggleLayer}
/>
<div className="tgu-map-content">
<Tgu2DMapSidebar
platforms={platforms}
selectedSpacecraftId={satelliteId}
selectedPlanId={selectedPlan?.planId}
layers={layers}
search={search}
layerAvailability={layerAvailability}
onSearchChange={setSearch}
onSelectSpacecraft={onSelectSpacecraft}
onToggleLayer={toggleLayer}
/>
<section className="tgu-map-stage">
{overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>}
<Tgu2DMapView
scene={mapInvalidRange ? { polygons: [], lines: [], markers: [] } : scene}
layers={layers}
redrawToken={redrawToken}
onObjectSelect={setSelectedObject}
/>
</section>
</div>
<div className="tgu-map-errors">
{Object.entries(layerAvailability).map(([layer, message]) =>
message ? (
<div className="tgu-alert tgu-alert--warning" key={layer}>
{layerLabel(layer as Tgu2DMapLayerKey)}: {message}
</div>
) : null
)}
</div>
<Tgu2DMapDetails selectedPlan={selectedPlan} selectedObject={selectedObject} />
</main>
);
}
function formatDate(ms: number): string {
return new Date(ms).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
function layerLabel(layer: Tgu2DMapLayerKey): string {
switch (layer) {
case "tracks":
return "Трасса КА";
case "swath":
return "Полоса обзора";
case "planWorks":
return "Работы плана";
case "stations":
return "Станции";
case "spacecraftMarkers":
return "Маркеры КА";
}
}
@@ -0,0 +1,44 @@
import { TGU_2D_MAP_LAYERS, type Tgu2DMapLayersState } from "./model/mapLayerTypes";
type Tgu2DMapToolbarProps = {
satelliteLabel: string;
intervalLabel: string;
layers: Tgu2DMapLayersState;
onRefresh: () => void;
onToggleLayer: (key: keyof Tgu2DMapLayersState) => void;
};
export function Tgu2DMapToolbar({
satelliteLabel,
intervalLabel,
layers,
onRefresh,
onToggleLayer
}: Tgu2DMapToolbarProps) {
return (
<div className="tgu-map-toolbar">
<div>
<div className="tgu-map-toolbar__title">Карта 2D</div>
<div className="tgu-map-toolbar__subtitle">
{satelliteLabel} · {intervalLabel}
</div>
</div>
<div className="tgu-map-toolbar__toggles">
{TGU_2D_MAP_LAYERS.map((layer) => (
<button
className={layers[layer.key] ? "is-active" : ""}
type="button"
key={layer.key}
onClick={() => onToggleLayer(layer.key)}
>
{layer.label}
</button>
))}
</div>
<button className="tgu-button" type="button" onClick={onRefresh}>
Перерисовать
</button>
</div>
);
}
@@ -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;
}
@@ -0,0 +1,70 @@
import type { MapProjection } from "../geometry/mapProjection";
import type { MapSize } from "../model/mapTypes";
const LAND: Record<string, [number, number][]> = {
"Сев. Америка": [[-166, 66], [-150, 70], [-125, 70], [-95, 72], [-70, 67], [-56, 53], [-52, 47], [-70, 41], [-81, 31], [-80, 25], [-97, 22], [-114, 31], [-124, 48], [-145, 60], [-164, 60]],
"Юж. Америка": [[-78, 9], [-60, 10], [-51, 4], [-43, -3], [-37, -13], [-55, -34], [-70, -52], [-74, -49], [-71, -18], [-80, -6], [-79, 3]],
"Африка": [[-16, 15], [-6, 27], [10, 37], [32, 31], [43, 12], [51, 12], [41, -3], [35, -18], [22, -34], [14, -24], [9, -1], [-9, 5], [-14, 9]],
"Европа": [[-10, 37], [-9, 43], [2, 51], [6, 62], [21, 70], [30, 67], [42, 63], [38, 58], [24, 56], [14, 54], [-4, 48], [-9, 40]],
"Азия": [[42, 63], [68, 72], [95, 77], [125, 73], [160, 70], [180, 66], [165, 60], [143, 53], [127, 38], [121, 31], [110, 21], [100, 7], [88, 22], [73, 17], [62, 25], [47, 38], [58, 55], [45, 60]],
"Австралия": [[114, -22], [114, -32], [129, -32], [147, -38], [153, -31], [146, -18], [136, -12], [125, -14], [122, -18]],
"Антарктида": [[-180, -72], [-150, -75], [-90, -72], [-30, -72], [30, -69], [90, -66], [150, -70], [180, -72], [180, -85], [-180, -85]]
};
export function drawBaseMap(ctx: CanvasRenderingContext2D, projection: MapProjection, size: MapSize) {
const gradient = ctx.createLinearGradient(0, 0, 0, size.height);
gradient.addColorStop(0, "#0b1717");
gradient.addColorStop(1, "#08100f");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size.width, size.height);
drawGrid(ctx, projection);
drawLand(ctx, projection);
}
function drawGrid(ctx: CanvasRenderingContext2D, projection: MapProjection) {
ctx.save();
ctx.strokeStyle = "rgba(116, 160, 154, 0.14)";
ctx.lineWidth = 1;
ctx.beginPath();
for (let lon = -180; lon <= 180; lon += 30) {
const top = projection.project({ lon, lat: 82 });
const bottom = projection.project({ lon, lat: -82 });
ctx.moveTo(top.x, top.y);
ctx.lineTo(bottom.x, bottom.y);
}
for (let lat = -60; lat <= 60; lat += 30) {
const left = projection.project({ lon: -180, lat });
const right = projection.project({ lon: 180, lat });
ctx.moveTo(left.x, left.y);
ctx.lineTo(right.x, right.y);
}
ctx.stroke();
ctx.restore();
}
function drawLand(ctx: CanvasRenderingContext2D, projection: MapProjection) {
ctx.save();
for (const polygon of Object.values(LAND)) {
ctx.beginPath();
polygon.forEach(([lon, lat], index) => {
const point = projection.project({ lon, lat });
if (index === 0) {
ctx.moveTo(point.x, point.y);
} else {
ctx.lineTo(point.x, point.y);
}
});
ctx.closePath();
ctx.fillStyle = "#173128";
ctx.fill();
ctx.strokeStyle = "rgba(139, 210, 180, 0.42)";
ctx.lineWidth = 0.9;
ctx.stroke();
}
ctx.restore();
}
@@ -0,0 +1,45 @@
import { statusStyle } from "../../tgu-planning/tguStatus";
import type { MapProjection } from "../geometry/mapProjection";
import type { MapPolygon } from "../model/mapTypes";
export function drawPlanWorks(
ctx: CanvasRenderingContext2D,
polygons: MapPolygon[],
projection: MapProjection,
viewport: { width: number; height: number }
) {
ctx.save();
for (const polygon of polygons) {
if (polygon.points.length < 3) continue;
const screenPoints = polygon.points.map((point) => projection.project(point));
if (!screenPoints.some((point) => point.x >= -32 && point.x <= viewport.width + 32 && point.y >= -32 && point.y <= viewport.height + 32)) {
continue;
}
const color = statusStyle(polygon.status || "PLANNED").color;
ctx.beginPath();
screenPoints.forEach((point, index) => {
if (index === 0) {
ctx.moveTo(point.x, point.y);
} else {
ctx.lineTo(point.x, point.y);
}
});
ctx.closePath();
ctx.fillStyle = hexToRgba(color, 0.2);
ctx.strokeStyle = hexToRgba(color, 0.72);
ctx.lineWidth = 1.2;
ctx.fill();
ctx.stroke();
}
ctx.restore();
}
function hexToRgba(hex: string, alpha: number): string {
const value = hex.replace("#", "");
const red = Number.parseInt(value.slice(0, 2), 16);
const green = Number.parseInt(value.slice(2, 4), 16);
const blue = Number.parseInt(value.slice(4, 6), 16);
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
@@ -0,0 +1,21 @@
import type { MapProjection } from "../geometry/mapProjection";
import type { MapMarker } from "../model/mapTypes";
export function drawStations(ctx: CanvasRenderingContext2D, markers: MapMarker[], projection: MapProjection) {
ctx.save();
for (const marker of markers) {
const point = projection.project(marker.point);
ctx.beginPath();
ctx.moveTo(point.x, point.y - 6);
ctx.lineTo(point.x + 5, point.y + 4);
ctx.lineTo(point.x - 5, point.y + 4);
ctx.closePath();
ctx.fillStyle = "rgba(213, 226, 226, 0.9)";
ctx.strokeStyle = "#08100f";
ctx.lineWidth = 1;
ctx.fill();
ctx.stroke();
}
ctx.restore();
}
@@ -0,0 +1,13 @@
import type { MapProjection } from "../geometry/mapProjection";
import type { MapPolygon } from "../model/mapTypes";
import { drawPlanWorks } from "./drawPlanWorks";
export function drawSwath(
ctx: CanvasRenderingContext2D,
polygons: MapPolygon[],
projection: MapProjection,
viewport: { width: number; height: number }
) {
drawPlanWorks(ctx, polygons, projection, viewport);
}
@@ -0,0 +1,27 @@
import type { MapProjection } from "../geometry/mapProjection";
import type { MapLine } from "../model/mapTypes";
export function drawTracks(ctx: CanvasRenderingContext2D, lines: MapLine[], projection: MapProjection) {
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(104, 195, 189, 0.76)";
ctx.lineWidth = 1.2;
for (const line of lines) {
if (line.points.length < 2) continue;
ctx.beginPath();
line.points.forEach((point, index) => {
const screen = projection.project(point);
if (index === 0) {
ctx.moveTo(screen.x, screen.y);
} else {
ctx.lineTo(screen.x, screen.y);
}
});
ctx.stroke();
}
ctx.restore();
}
@@ -0,0 +1,38 @@
import type { GeoPoint } from "../model/mapTypes";
export type BBox = {
minLon: number;
minLat: number;
maxLon: number;
maxLat: number;
};
export function bboxForPoints(points: GeoPoint[]): BBox {
let minLon = Number.POSITIVE_INFINITY;
let minLat = Number.POSITIVE_INFINITY;
let maxLon = Number.NEGATIVE_INFINITY;
let maxLat = Number.NEGATIVE_INFINITY;
for (const point of points) {
minLon = Math.min(minLon, point.lon);
minLat = Math.min(minLat, point.lat);
maxLon = Math.max(maxLon, point.lon);
maxLat = Math.max(maxLat, point.lat);
}
return { minLon, minLat, maxLon, maxLat };
}
export function bboxContainsPoint(bbox: BBox, point: GeoPoint): boolean {
return (
point.lon >= bbox.minLon &&
point.lon <= bbox.maxLon &&
point.lat >= bbox.minLat &&
point.lat <= bbox.maxLat
);
}
export function bboxIntersects(a: BBox, b: BBox): boolean {
return a.minLon <= b.maxLon && a.maxLon >= b.minLon && a.minLat <= b.maxLat && a.maxLat >= b.minLat;
}
@@ -0,0 +1,67 @@
import type { GeoPoint, MapSize, MapViewState, ScreenPoint } from "../model/mapTypes";
const TILE_SIZE = 256;
const MAX_LAT = 85.0511;
const DEG = Math.PI / 180;
const RAD = 180 / Math.PI;
export type MapProjection = {
worldSize: number;
project: (point: GeoPoint) => ScreenPoint;
unproject: (point: ScreenPoint) => GeoPoint;
metersPerPixel: (lat: number) => number;
};
export function createMapProjection(view: MapViewState, size: MapSize): MapProjection {
const worldSize = TILE_SIZE * 2 ** view.zoom;
const centerX = lonToWorldX(view.centerLon, worldSize);
const centerY = latToWorldY(view.centerLat, worldSize);
return {
worldSize,
project(point) {
let worldX = lonToWorldX(point.lon, worldSize);
const halfWorld = worldSize / 2;
while (worldX - centerX > halfWorld) worldX -= worldSize;
while (worldX - centerX < -halfWorld) worldX += worldSize;
return {
x: size.width / 2 + (worldX - centerX),
y: size.height / 2 + (latToWorldY(point.lat, worldSize) - centerY)
};
},
unproject(point) {
return {
lon: normalizeLon(worldXToLon(centerX + (point.x - size.width / 2), worldSize)),
lat: worldYToLat(centerY + (point.y - size.height / 2), worldSize)
};
},
metersPerPixel(lat) {
return (Math.cos(lat * DEG) * 2 * Math.PI * 6378137) / worldSize;
}
};
}
export function lonToWorldX(lon: number, worldSize: number): number {
return ((normalizeLon(lon) + 180) / 360) * worldSize;
}
export function latToWorldY(lat: number, worldSize: number): number {
const clamped = Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
const sin = Math.sin(clamped * DEG);
return (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) * worldSize;
}
export function worldXToLon(worldX: number, worldSize: number): number {
return (worldX / worldSize) * 360 - 180;
}
export function worldYToLat(worldY: number, worldSize: number): number {
const n = Math.PI - (2 * Math.PI * worldY) / worldSize;
return RAD * Math.atan(Math.sinh(n));
}
export function normalizeLon(lon: number): number {
return ((((lon + 180) % 360) + 360) % 360) - 180;
}
@@ -0,0 +1,21 @@
import type { GeoPoint } from "../model/mapTypes";
export function pointInPolygon(point: GeoPoint, polygon: GeoPoint[]): boolean {
if (polygon.length < 3) return false;
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const pi = polygon[i]!;
const pj = polygon[j]!;
const intersects =
pi.lat > point.lat !== pj.lat > point.lat &&
point.lon < ((pj.lon - pi.lon) * (point.lat - pi.lat)) / (pj.lat - pi.lat) + pi.lon;
if (intersects) {
inside = !inside;
}
}
return inside;
}
@@ -0,0 +1,22 @@
import type { GeoPoint } from "../model/mapTypes";
export function simplifyGeometry(points: GeoPoint[], minPixelDistance: number, project: (point: GeoPoint) => { x: number; y: number }): GeoPoint[] {
if (points.length <= 2 || minPixelDistance <= 0) return points;
const result: GeoPoint[] = [];
let previousScreenPoint = project(points[0]!);
result.push(points[0]!);
for (let index = 1; index < points.length - 1; index += 1) {
const point = points[index]!;
const screenPoint = project(point);
if (Math.hypot(screenPoint.x - previousScreenPoint.x, screenPoint.y - previousScreenPoint.y) >= minPixelDistance) {
result.push(point);
previousScreenPoint = screenPoint;
}
}
result.push(points[points.length - 1]!);
return result;
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { createPolygonSpatialIndex, hitTestPolygons, queryPolygonsByBBox } from "./spatialIndex";
import type { MapPolygon } from "../model/mapTypes";
const bottom: MapPolygon = {
id: "bottom",
name: "Bottom polygon",
kind: "planWork",
layer: "planWorks",
zIndex: 1,
points: [
{ lon: 0, lat: 0 },
{ lon: 10, lat: 0 },
{ lon: 10, lat: 10 },
{ lon: 0, lat: 10 }
]
};
const top: MapPolygon = {
id: "top",
name: "Top polygon",
kind: "planWork",
layer: "planWorks",
zIndex: 2,
points: [
{ lon: 5, lat: 5 },
{ lon: 15, lat: 5 },
{ lon: 15, lat: 15 },
{ lon: 5, lat: 15 }
]
};
describe("polygon spatial index", () => {
it("uses bbox candidates and returns the top polygon by zIndex", () => {
const index = createPolygonSpatialIndex([bottom, top]);
expect(hitTestPolygons(index, { lon: 6, lat: 6 })?.id).toBe("top");
expect(hitTestPolygons(index, { lon: 2, lat: 2 })?.id).toBe("bottom");
expect(hitTestPolygons(index, { lon: 20, lat: 20 })).toBeUndefined();
});
it("queries polygons by bbox", () => {
const index = createPolygonSpatialIndex([bottom, top]);
expect(queryPolygonsByBBox(index, { minLon: 11, minLat: 11, maxLon: 12, maxLat: 12 }).map((polygon) => polygon.id))
.toEqual(["top"]);
});
});
@@ -0,0 +1,44 @@
import { bboxContainsPoint, bboxForPoints, type BBox } from "./bbox";
import { pointInPolygon } from "./pointInPolygon";
import type { GeoPoint, MapPolygon } from "../model/mapTypes";
export type IndexedPolygon = {
polygon: MapPolygon;
bbox: BBox;
};
export type PolygonSpatialIndex = {
entries: IndexedPolygon[];
};
export function createPolygonSpatialIndex(polygons: MapPolygon[]): PolygonSpatialIndex {
return {
entries: polygons.map((polygon) => ({
polygon,
bbox: bboxForPoints(polygon.points)
}))
};
}
export function hitTestPolygons(index: PolygonSpatialIndex, point: GeoPoint): MapPolygon | undefined {
let selected: MapPolygon | undefined;
for (const entry of index.entries) {
if (!bboxContainsPoint(entry.bbox, point)) continue;
if (!pointInPolygon(point, entry.polygon.points)) continue;
if (!selected || entry.polygon.zIndex >= selected.zIndex) {
selected = entry.polygon;
}
}
return selected;
}
export function queryPolygonsByBBox(index: PolygonSpatialIndex, bbox: BBox): MapPolygon[] {
return index.entries
.filter((entry) => entry.bbox.minLon <= bbox.maxLon && entry.bbox.maxLon >= bbox.minLon)
.filter((entry) => entry.bbox.minLat <= bbox.maxLat && entry.bbox.maxLat >= bbox.minLat)
.map((entry) => entry.polygon);
}
@@ -0,0 +1,46 @@
export type Tgu2DMapLayerKey = "tracks" | "swath" | "planWorks" | "stations" | "spacecraftMarkers";
export type Tgu2DMapLayersState = Record<Tgu2DMapLayerKey, boolean>;
export type Tgu2DMapLayerDefinition = {
key: Tgu2DMapLayerKey;
label: string;
description: string;
};
export const TGU_2D_MAP_LAYERS: Tgu2DMapLayerDefinition[] = [
{
key: "tracks",
label: "Трасса КА",
description: "Проекция движения КА на поверхность."
},
{
key: "swath",
label: "Полоса обзора",
description: "Массовые полосы обзора через canvas."
},
{
key: "planWorks",
label: "Работы плана",
description: "Полигоны работ выбранного плана."
},
{
key: "stations",
label: "Станции",
description: "Наземные станции приёма."
},
{
key: "spacecraftMarkers",
label: "Маркеры КА",
description: "Текущие или расчётные позиции КА."
}
];
export const DEFAULT_TGU_2D_MAP_LAYERS: Tgu2DMapLayersState = {
tracks: true,
swath: true,
planWorks: true,
stations: true,
spacecraftMarkers: true
};
@@ -0,0 +1,20 @@
import type { TguPlanStatus } from "../../../model/tguTypes";
export type Tgu2DMapSelection =
| {
type: "polygon";
id: string;
name: string;
layer: "planWorks" | "swath" | "tracks";
planId?: string;
spacecraftId?: string;
status?: TguPlanStatus;
}
| {
type: "marker";
id: string;
name: string;
layer: "stations" | "spacecraftMarkers";
spacecraftId?: string;
};
@@ -0,0 +1,57 @@
import type { TguPlanStatus } from "../../../model/tguTypes";
export type GeoPoint = {
lon: number;
lat: number;
};
export type ScreenPoint = {
x: number;
y: number;
};
export type MapSize = {
width: number;
height: number;
};
export type MapViewState = {
centerLon: number;
centerLat: number;
zoom: number;
};
export type MapPolygon = {
id: string;
name: string;
kind: "planWork" | "swath" | "trackZone";
layer: "planWorks" | "swath" | "tracks";
points: GeoPoint[];
status?: TguPlanStatus;
planId?: string;
spacecraftId?: string;
zIndex: number;
};
export type MapLine = {
id: string;
layer: "tracks";
points: GeoPoint[];
spacecraftId?: string;
zIndex: number;
};
export type MapMarker = {
id: string;
layer: "stations" | "spacecraftMarkers";
point: GeoPoint;
label: string;
zIndex: number;
};
export type Tgu2DMapScene = {
polygons: MapPolygon[];
lines: MapLine[];
markers: MapMarker[];
};
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import type { TguPlanUi } from "../../model/timelineTypes";
import { DEFAULT_TGU_2D_MAP_LAYERS } from "./model/mapLayerTypes";
import { getTgu2DMapIntervalState } from "./tgu2DMapInterval";
function plan(startTime: string, endTime: string): TguPlanUi {
return {
planId: "plan-1",
spacecraftId: "56756",
startTime,
endTime,
kppId: "KPP-1",
status: "PLANNED",
startMs: Date.parse(startTime),
endMs: Date.parse(endTime)
};
}
describe("tgu2DMapInterval", () => {
it("marks common map interval longer than 7 days as too large", () => {
const state = getTgu2DMapIntervalState({
fromMs: Date.parse("2026-05-29T00:00:00"),
toMs: Date.parse("2026-06-05T00:00:01")
});
expect(state.tooLarge).toBe(true);
});
it("uses selected plan interval without the common interval restriction", () => {
const selectedPlan = plan("2026-05-29T01:00:00", "2026-06-07T02:00:00");
const state = getTgu2DMapIntervalState(
{
fromMs: Date.parse("2026-05-29T00:00:00"),
toMs: Date.parse("2026-06-10T00:00:00")
},
selectedPlan
);
expect(state).toEqual({
range: {
fromMs: selectedPlan.startMs,
toMs: selectedPlan.endMs
},
tooLarge: false
});
});
it("enables all requested 2D map layers by default", () => {
expect(DEFAULT_TGU_2D_MAP_LAYERS).toEqual({
tracks: true,
swath: true,
planWorks: true,
stations: true,
spacecraftMarkers: true
});
});
});
@@ -0,0 +1,29 @@
import type { TguPlanUi, TimelineRange } from "../../model/timelineTypes";
export const MAX_TGU_2D_MAP_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
export const TGU_2D_MAP_INTERVAL_WARNING =
"Интервал карты больше 7 суток. Для тяжёлых canvas-слоёв выберите меньший интервал или конкретный план.";
export type Tgu2DMapIntervalState = {
range: TimelineRange;
tooLarge: boolean;
};
export function getTgu2DMapIntervalState(range: TimelineRange, selectedPlan?: TguPlanUi): Tgu2DMapIntervalState {
if (selectedPlan) {
return {
range: {
fromMs: selectedPlan.startMs,
toMs: selectedPlan.endMs
},
tooLarge: false
};
}
return {
range,
tooLarge: range.toMs - range.fromMs > MAX_TGU_2D_MAP_INTERVAL_MS
};
}
@@ -2,20 +2,14 @@ import type { ReactNode } from "react";
type TguPlanningLayoutProps = {
toolbar: ReactNode;
sidebar: ReactNode;
timeline: ReactNode;
details: ReactNode;
children: ReactNode;
};
export function TguPlanningLayout({ toolbar, sidebar, timeline, details }: TguPlanningLayoutProps) {
export function TguPlanningLayout({ toolbar, children }: TguPlanningLayoutProps) {
return (
<div className="tgu-app-shell">
{toolbar}
<main className="tgu-workspace">
{sidebar}
<section className="tgu-center">{timeline}</section>
{details}
</main>
{children}
</div>
);
}
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchPlans, fetchPlatforms, sendPlanDecision } from "../../api/tguApi";
import type { TguPlanDecision, TguPlatform } from "../../model/tguTypes";
import type { TguPlanUi, TimelineRange } from "../../model/timelineTypes";
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
import { TguPlanDetails } from "./TguPlanDetails";
import { TguPlanningLayout } from "./TguPlanningLayout";
import { TguSidebar } from "./TguSidebar";
@@ -24,6 +25,7 @@ export function TguPlanningPage() {
const [search, setSearch] = useState("");
const [selectedSpacecraftId, setSelectedSpacecraftId] = useState<string>();
const [selectedPlanId, setSelectedPlanId] = useState<string>();
const [activeTab, setActiveTab] = useState<"timeline" | "map">("timeline");
const [decisionInFlight, setDecisionInFlight] = useState(false);
const [decisionNotice, setDecisionNotice] = useState<string>();
const [decisionError, setDecisionError] = useState<string>();
@@ -146,7 +148,9 @@ export function TguPlanningPage() {
fromValue={fromValue}
toValue={toValue}
loading={loading}
activeTab={activeTab}
error={pageError}
onTabChange={setActiveTab}
onFromChange={setFromValue}
onToChange={setToValue}
onApply={applyRange}
@@ -154,40 +158,60 @@ export function TguPlanningPage() {
onRefresh={refresh}
/>
}
sidebar={
<TguSidebar
platforms={platforms}
>
{activeTab === "timeline" ? (
<main className="tgu-workspace">
<TguSidebar
platforms={platforms}
selectedSpacecraftId={selectedSpacecraftId}
search={search}
platformLoadFailed={platformLoadFailed}
onSearchChange={setSearch}
onSelectSpacecraft={(spacecraftId) => {
setSelectedSpacecraftId(spacecraftId);
setSelectedPlanId(undefined);
setDecisionNotice(undefined);
}}
/>
<section className="tgu-center">
<TguTimeline
rows={rows}
range={appliedRange}
invalidRange={invalidRange}
selectedPlanId={selectedPlanId}
onSelectPlan={(planId) => {
setSelectedPlanId(planId);
const plan = plans.find((item) => item.planId === planId);
if (plan) {
setSelectedSpacecraftId(plan.spacecraftId);
}
}}
/>
</section>
<TguPlanDetails
plan={selectedPlan}
decisionInFlight={decisionInFlight}
notice={decisionNotice}
error={decisionError}
onDecision={handleDecision}
onClose={() => setSelectedPlanId(undefined)}
/>
</main>
) : (
<Tgu2DMapTab
range={appliedRange}
invalidRange={invalidRange}
selectedSpacecraftId={selectedSpacecraftId}
search={search}
platformLoadFailed={platformLoadFailed}
onSearchChange={setSearch}
selectedPlan={selectedPlan}
platforms={platforms}
onSelectSpacecraft={(spacecraftId) => {
setSelectedSpacecraftId(spacecraftId);
setSelectedPlanId(undefined);
setDecisionNotice(undefined);
}}
/>
}
timeline={
<TguTimeline
rows={rows}
range={appliedRange}
invalidRange={invalidRange}
selectedPlanId={selectedPlanId}
onSelectPlan={setSelectedPlanId}
/>
}
details={
<TguPlanDetails
plan={selectedPlan}
decisionInFlight={decisionInFlight}
notice={decisionNotice}
error={decisionError}
onDecision={handleDecision}
onClose={() => setSelectedPlanId(undefined)}
/>
}
/>
)}
</TguPlanningLayout>
);
}
@@ -136,7 +136,11 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec
})}
{segments.map((segment) => {
const y = TIMELINE_AXIS_HEIGHT + segment.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2;
const y =
TIMELINE_AXIS_HEIGHT +
segment.rowIndex * TIMELINE_ROW_HEIGHT +
TIMELINE_ROW_HEIGHT / 2 +
segment.laneOffset;
const x1 = TIMELINE_LABEL_WIDTH + segment.x;
const x2 = TIMELINE_LABEL_WIDTH + segment.x + segment.width;
const style = statusStyle(segment.plan.status);
@@ -176,8 +180,8 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec
}
function TimelineArrow({ from, to }: { from: TimelinePlanSegment; to: TimelinePlanSegment }) {
const y1 = TIMELINE_AXIS_HEIGHT + from.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2;
const y2 = TIMELINE_AXIS_HEIGHT + to.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2;
const y1 = TIMELINE_AXIS_HEIGHT + from.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2 + from.laneOffset;
const y2 = TIMELINE_AXIS_HEIGHT + to.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2 + to.laneOffset;
const x1 = TIMELINE_LABEL_WIDTH + from.x + from.width + 4;
const x2 = TIMELINE_LABEL_WIDTH + to.x - 6;
const mid = x1 + Math.max(18, (x2 - x1) / 2);
@@ -4,7 +4,9 @@ type TguToolbarProps = {
fromValue: string;
toValue: string;
loading: boolean;
activeTab: "timeline" | "map";
error?: string;
onTabChange: (tab: "timeline" | "map") => void;
onFromChange: (value: string) => void;
onToChange: (value: string) => void;
onApply: () => void;
@@ -16,7 +18,9 @@ export function TguToolbar({
fromValue,
toValue,
loading,
activeTab,
error,
onTabChange,
onFromChange,
onToChange,
onApply,
@@ -34,6 +38,27 @@ export function TguToolbar({
</div>
</div>
<div className="tgu-tabs" role="tablist" aria-label="Разделы планирования ТГУ">
<button
className={activeTab === "timeline" ? "is-active" : ""}
type="button"
role="tab"
aria-selected={activeTab === "timeline"}
onClick={() => onTabChange("timeline")}
>
Timeline
</button>
<button
className={activeTab === "map" ? "is-active" : ""}
type="button"
role="tab"
aria-selected={activeTab === "map"}
onClick={() => onTabChange("map")}
>
Карта 2D
</button>
</div>
<label className="tgu-field">
<span>С</span>
<input type="datetime-local" value={fromValue} onChange={(event) => onFromChange(event.target.value)} />
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { TguPlanUi, TimelineRow } from "../../model/timelineTypes";
import { buildSequentialLinks, clipPlanToRange, timeToX } from "./tguTimelineLayout";
import { buildSequentialLinks, buildTimelineSegments, clipPlanToRange, timeToX } from "./tguTimelineLayout";
const range = {
fromMs: Date.parse("2026-05-29T00:00:00"),
@@ -55,4 +55,29 @@ describe("tguTimelineLayout", () => {
{ spacecraftId: "56756", fromPlanId: "p1", toPlanId: "p2" }
]);
});
it("places sequential plans on alternating timeline lanes", () => {
const rows: TimelineRow[] = [
{
spacecraftId: "56756",
plans: [
plan("p1", "2026-05-29T01:00:00", "2026-05-29T02:00:00"),
plan("p2", "2026-05-29T03:00:00", "2026-05-29T04:00:00"),
plan("p3", "2026-05-29T05:00:00", "2026-05-29T06:00:00"),
plan("p4", "2026-05-29T07:00:00", "2026-05-29T08:00:00"),
plan("p5", "2026-05-29T09:00:00", "2026-05-29T10:00:00"),
plan("p6", "2026-05-29T11:00:00", "2026-05-29T12:00:00")
]
}
];
expect(buildTimelineSegments(rows, range, 1200).map((segment) => segment.laneOffset)).toEqual([
0,
-12,
0,
12,
0,
-12
]);
});
});
@@ -4,6 +4,7 @@ export const TIMELINE_LABEL_WIDTH = 220;
export const TIMELINE_ROW_HEIGHT = 58;
export const TIMELINE_AXIS_HEIGHT = 46;
export const TIMELINE_BOTTOM_PADDING = 18;
export const TIMELINE_LANE_STEP = 12;
export function timeToX(timeMs: number, range: TimelineRange, timelineWidth: number): number {
return ((timeMs - range.fromMs) / (range.toMs - range.fromMs)) * timelineWidth;
@@ -28,25 +29,32 @@ export function buildTimelineSegments(
const segments: TimelinePlanSegment[] = [];
rows.forEach((row, rowIndex) => {
for (const plan of row.plans) {
const clipped = clipPlanToRange(plan, range);
if (!clipped) continue;
const visiblePlans = row.plans
.map((plan) => ({ plan, clipped: clipPlanToRange(plan, range) }))
.filter((item): item is { plan: TguPlanUi; clipped: { startMs: number; endMs: number } } => item.clipped !== null)
.sort((a, b) => a.plan.startMs - b.plan.startMs || a.plan.endMs - b.plan.endMs);
const x = timeToX(clipped.startMs, range, timelineWidth);
const x2 = timeToX(clipped.endMs, range, timelineWidth);
visiblePlans.forEach((item, planIndex) => {
const x = timeToX(item.clipped.startMs, range, timelineWidth);
const x2 = timeToX(item.clipped.endMs, range, timelineWidth);
segments.push({
plan,
plan: item.plan,
rowIndex,
laneOffset: timelineLaneOffset(planIndex),
x,
x2,
width: Math.max(3, x2 - x)
});
}
});
});
return segments;
}
export function timelineLaneOffset(index: number): number {
return [0, -1, 0, 1][index % 4] * TIMELINE_LANE_STEP;
}
export function buildSequentialLinks(rows: TimelineRow[]): TimelineLink[] {
const links: TimelineLink[] = [];