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,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[] = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user