Новый сервис pcp-tgu-ui-service: ТГУ-фронт на headless-auth nstart + платформенный CI/деплой

This commit is contained in:
Дмитрий Соловьев
2026-06-09 13:35:13 +03:00
parent 0b617594fd
commit 505bf1f31c
129 changed files with 21640 additions and 0 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,92 @@
import type { StationDto } from "../../api/stationsApi";
import type { RequestMapItem } from "../../model/requestTypes";
import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
import { parseWktPolygon } from "./geometry/wktParser";
import type { Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { MapMarker, MapPolygon, Tgu2DMapScene } from "./model/mapTypes";
export type Tgu2DMapLayerAvailability = Partial<Record<keyof Tgu2DMapLayersState, string>>;
export type Tgu2DMapSceneInput = {
range: TimelineRange;
selectedPlan?: TguPlanUi;
platforms: TguPlatformUi[];
hiddenSpacecraftIds?: ReadonlySet<string>;
requestItems?: RequestMapItem[];
stationItems?: StationDto[];
};
const UNAVAILABLE = "Данные слоя недоступны в текущем API.";
export function buildTgu2DMapScene(input: Tgu2DMapSceneInput): Tgu2DMapScene {
const requestPolygons = buildRequestPolygons(input.requestItems ?? []);
const stationMarkers = buildStationMarkers(input.stationItems ?? []);
return {
polygons: requestPolygons,
lines: [],
markers: stationMarkers
};
}
function buildRequestPolygons(items: RequestMapItem[]): MapPolygon[] {
const polygons: MapPolygon[] = [];
let zIndex = 0;
for (const item of items) {
const rings = parseWktPolygon(item.geometry);
for (const ring of rings) {
if (ring.length < 3) continue;
polygons.push({
id: `request:${item.id}:${zIndex}`,
name: item.name,
kind: "request",
layer: "requests",
points: ring,
requestId: item.id,
surveyType: item.surveyType,
zIndex: zIndex++
});
}
}
return polygons;
}
export function buildStationMarkers(items: StationDto[]): MapMarker[] {
return items.map((item, index) => ({
id: item.id ?? `station:${index}`,
layer: "stations" as const,
point: { lon: item.position.long, lat: item.position.lat },
label: item.name,
zIndex: index,
stationNumber: item.number,
altitude: item.position.height,
elevationMin: item.elevationMin,
elevationMax: item.elevationMax
}));
}
export function getLayerAvailability(
layers: Tgu2DMapLayersState,
scene: Tgu2DMapScene,
stationsFetchFailed?: boolean
): 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 && stationsFetchFailed) {
availability.stations = UNAVAILABLE;
}
if (layers.spacecraftMarkers && scene.markers.filter((marker) => marker.layer === "spacecraftMarkers").length === 0) {
availability.spacecraftMarkers = UNAVAILABLE;
}
return availability;
}
@@ -0,0 +1,116 @@
import type { RequestMapItem } from "../../model/requestTypes";
import type { MapPolygon } from "./model/mapTypes";
import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
export type RequestAtPoint = { polygon: MapPolygon; item: RequestMapItem };
type Props = {
requests: RequestAtPoint[];
selectedPolygonId?: string;
onHover: (polygonId: string | undefined) => void;
onSelect: (polygonId: string) => void;
onClose: () => void;
};
export function Tgu2DMapRequestsPanel({ requests, selectedPolygonId, onHover, onSelect, onClose }: Props) {
const selectedEntry = selectedPolygonId ? requests.find((r) => r.polygon.id === selectedPolygonId) : undefined;
return (
<aside className="tgu-map-requests-panel">
<div className="tgu-panel-heading">
<span>Заявки в точке ({requests.length})</span>
<button className="tgu-link-button" type="button" onClick={onClose}>
Закрыть
</button>
</div>
<div className="tgu-map-requests-list">
{requests.map(({ polygon, item }) => (
<button
key={polygon.id}
className={`tgu-map-request-row ${polygon.id === selectedPolygonId ? "is-selected" : ""}`}
type="button"
onMouseEnter={() => onHover(polygon.id)}
onMouseLeave={() => onHover(undefined)}
onClick={() => onSelect(polygon.id)}
>
<i className={`tgu-map-request-row__type req-${item.surveyType.toLowerCase()}`} />
<span className="tgu-map-request-row__body">
<span className="tgu-map-request-row__name">{item.name || item.id}</span>
<span className="tgu-map-request-row__meta">
{formatDateRange(item.beginDateTime, item.endDateTime)}
{" · "}
{item.surveyType}
{" · "}
{item.status}
</span>
</span>
</button>
))}
</div>
{selectedEntry && (
<div className="tgu-map-request-details">
<div className="tgu-map-panel__label">Детали заявки</div>
<dl className="tgu-map-request-details__grid">
<dt>ID</dt>
<dd className="mono">{selectedEntry.item.id}</dd>
<dt>Название</dt>
<dd>{selectedEntry.item.name || "—"}</dd>
<dt>Статус</dt>
<dd>{selectedEntry.item.status}</dd>
<dt>Тип съёмки</dt>
<dd>{selectedEntry.item.surveyType}</dd>
<dt>Начало</dt>
<dd className="mono">{formatDateTime(selectedEntry.item.beginDateTime)}</dd>
<dt>Конец</dt>
<dd className="mono">{formatDateTime(selectedEntry.item.endDateTime)}</dd>
<dt>Важность</dt>
<dd className="mono">{selectedEntry.item.importance}</dd>
<dt>Покрытие</dt>
<dd className="mono">{selectedEntry.item.coveragePercent != null ? `${selectedEntry.item.coveragePercent.toFixed(1)} %` : "—"}</dd>
<dt>КПП</dt>
<dd className="mono">{selectedEntry.item.kpp?.join(", ") || "—"}</dd>
<dt>Приоритет передачи</dt>
<dd>{selectedEntry.item.highPriorityTransmit ? "Да" : "Нет"}</dd>
<dt>Создано</dt>
<dd className="mono">{formatDateTime(selectedEntry.item.createdAt)}</dd>
<dt>Изменено</dt>
<dd className="mono">{formatDateTime(selectedEntry.item.updatedAt)}</dd>
</dl>
</div>
)}
</aside>
);
}
function formatDateTime(value: string): string {
return formatLocalDateTime(parseLocal(value));
}
function formatDateRange(begin: string, end: string): string {
const b = new Date(parseLocal(begin));
const e = new Date(parseLocal(end));
const sameDay =
b.getFullYear() === e.getFullYear() &&
b.getMonth() === e.getMonth() &&
b.getDate() === e.getDate();
const dateOpts: Intl.DateTimeFormatOptions = { day: "2-digit", month: "2-digit" };
const timeOpts: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit" };
const beginStr = `${b.toLocaleDateString("ru-RU", dateOpts)} ${b.toLocaleTimeString("ru-RU", timeOpts)}`;
const endStr = sameDay
? b.toLocaleTimeString("ru-RU", timeOpts)
: `${e.toLocaleDateString("ru-RU", dateOpts)} ${e.toLocaleTimeString("ru-RU", timeOpts)}`;
return `${beginStr} ${endStr}`;
}
@@ -0,0 +1,202 @@
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";
import type { RequestsState } from "./Tgu2DMapTab";
import { parseLocal } from "../../utils/localTime";
import { DateTime24Field } from "../../components/timeFields";
const DAY_MS = 24 * 60 * 60 * 1000;
type Tgu2DMapSidebarProps = {
platforms: TguPlatformUi[];
selectedPlanId?: string;
hiddenIds: ReadonlySet<string>;
layers: Tgu2DMapLayersState;
search: string;
layerAvailability: Tgu2DMapLayerAvailability;
requestsState: RequestsState;
onSearchChange: (value: string) => void;
onToggleSpacecraft: (id: string) => void;
onShowAll: () => void;
onToggleLayer: (key: Tgu2DMapLayerKey) => void;
onRequestsFromChange: (value: string) => void;
onRequestsToChange: (value: string) => void;
onRequestsApply: () => void;
onRequestsQuickRange: (fromMs: number, toMs: number) => void;
};
export function Tgu2DMapSidebar({
platforms,
selectedPlanId,
hiddenIds,
layers,
search,
layerAvailability,
requestsState,
onSearchChange,
onToggleSpacecraft,
onShowAll,
onToggleLayer,
onRequestsFromChange,
onRequestsToChange,
onRequestsApply,
onRequestsQuickRange
}: Tgu2DMapSidebarProps) {
const filteredPlatforms = filterPlatforms(platforms, search);
const hiddenCount = hiddenIds.size;
const quickToday = () => {
const now = new Date();
const fromMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
onRequestsQuickRange(fromMs, fromMs + DAY_MS);
};
const quick7days = () => {
const fromMs = Date.now();
onRequestsQuickRange(fromMs, fromMs + 7 * DAY_MS);
};
const quick30days = () => {
const fromMs = Date.now();
onRequestsQuickRange(fromMs, fromMs + 30 * DAY_MS);
};
const requestsFromMs = parseLocal(requestsState.fromValue);
const requestsToMs = parseLocal(requestsState.toValue);
const requestsRangeInvalid =
!Number.isFinite(requestsFromMs) ||
!Number.isFinite(requestsToMs) ||
requestsFromMs >= requestsToMs;
return (
<aside className="tgu-map-panel">
<div className="tgu-panel-heading">
<span>Карта</span>
{hiddenCount > 0 && (
<button className="tgu-link-button" type="button" onClick={onShowAll}>
Показать все ({hiddenCount} скрыто)
</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-map-requests-filter">
<label className="tgu-field">
<span>С</span>
<DateTime24Field value={requestsState.fromValue} onChange={onRequestsFromChange} />
</label>
<label className="tgu-field">
<span>По</span>
<DateTime24Field value={requestsState.toValue} onChange={onRequestsToChange} />
</label>
<div className="tgu-map-requests-filter__actions">
<button
className="tgu-button tgu-button--primary tgu-button--sm"
type="button"
onClick={onRequestsApply}
disabled={requestsState.loading || requestsRangeInvalid}
>
{requestsState.loading ? "Загрузка..." : "Загрузить"}
</button>
<button className="tgu-button tgu-button--sm" type="button" onClick={quickToday} disabled={requestsState.loading}>
Сегодня
</button>
<button className="tgu-button tgu-button--sm" type="button" onClick={quick7days} disabled={requestsState.loading}>
7 дней
</button>
<button className="tgu-button tgu-button--sm" type="button" onClick={quick30days} disabled={requestsState.loading}>
30 дней
</button>
</div>
{requestsState.error && (
<div className="tgu-alert tgu-alert--danger tgu-alert--compact">{requestsState.error}</div>
)}
{requestsState.loading && requestsState.totalItems > 0 && (
<div className="tgu-map-requests-filter__status">
Загрузка: {requestsState.loadedCount} / {requestsState.totalItems}...
</div>
)}
{requestsState.loaded && !requestsState.error && (
<div className="tgu-map-requests-filter__status">
{requestsState.items.length === 0
? "Заявок не найдено."
: `Загружено: ${requestsState.items.length} из ${requestsState.totalItems}`}
{requestsState.truncated && (
<span className="tgu-map-requests-filter__truncated"> (показаны первые 5000 уточните интервал)</span>
)}
</div>
)}
{requestsState.loaded && requestsState.items.length > 0 && (
<div className="tgu-map-requests-legend">
<span><i className="req-optics" />OPTICS</span>
<span><i className="req-rsa" />RSA</span>
<span><i className="req-combined" />COMBINED</span>
</div>
)}
</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 visible = !hiddenIds.has(platform.spacecraftId);
return (
<label
className={`tgu-map-spacecraft ${visible ? "" : "is-hidden"}`}
key={platform.spacecraftId}
>
<input
type="checkbox"
checked={visible}
onChange={() => onToggleSpacecraft(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>
</label>
);
})}
{filteredPlatforms.length === 0 && <div className="tgu-empty tgu-empty--compact">Нет КА по текущему поиску.</div>}
</div>
</div>
</aside>
);
}
@@ -0,0 +1,43 @@
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
type StationSelection = Extract<Tgu2DMapSelection, { type: "marker" }> & { layer: "stations" };
type Props = {
selection: StationSelection;
onClose: () => void;
};
export function Tgu2DMapStationPanel({ selection, onClose }: Props) {
return (
<aside className="tgu-map-requests-panel">
<div className="tgu-panel-heading">
<span>Станция</span>
<button className="tgu-link-button" type="button" onClick={onClose}>
Закрыть
</button>
</div>
<div className="tgu-map-request-details" style={{ flex: 1 }}>
<div className="tgu-map-panel__label">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
<dt>Номер</dt>
<dd className="mono">{selection.stationNumber ?? "—"}</dd>
<dt>Широта</dt>
<dd className="mono">{selection.lat != null ? `${selection.lat.toFixed(4)}°` : "—"}</dd>
<dt>Долгота</dt>
<dd className="mono">{selection.lon != null ? `${selection.lon.toFixed(4)}°` : "—"}</dd>
<dt>Высота, м</dt>
<dd className="mono">{selection.altitude != null ? selection.altitude.toFixed(1) : "—"}</dd>
<dt>Угол места мин, °</dt>
<dd className="mono">{selection.elevationMin != null ? selection.elevationMin.toFixed(1) : "—"}</dd>
<dt>Угол места макс, °</dt>
<dd className="mono">{selection.elevationMax != null ? selection.elevationMax.toFixed(1) : "—"}</dd>
</dl>
</div>
</aside>
);
}
@@ -0,0 +1,324 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchRequestsForMap } from "../../api/requestApi";
import { fetchStations, type StationDto } from "../../api/stationsApi";
import type { RequestMapItem } from "../../model/requestTypes";
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 type { MapPolygon } from "./model/mapTypes";
import { buildTgu2DMapScene, getLayerAvailability } from "./Tgu2DMapLayers";
import { Tgu2DMapDetails } from "./Tgu2DMapDetails";
import { Tgu2DMapRequestsPanel, type RequestAtPoint } from "./Tgu2DMapRequestsPanel";
import { Tgu2DMapSidebar } from "./Tgu2DMapSidebar";
import { Tgu2DMapStationPanel } from "./Tgu2DMapStationPanel";
import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar";
import { Tgu2DMapView } from "./Tgu2DMapView";
import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval";
import { parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
type Tgu2DMapTabProps = {
range: TimelineRange;
invalidRange: boolean;
selectedPlan?: TguPlanUi;
platforms: TguPlatformUi[];
};
export type RequestsState = {
fromValue: string;
toValue: string;
items: RequestMapItem[];
loading: boolean;
loadedCount: number;
error?: string;
totalItems: number;
truncated: boolean;
loaded: boolean;
};
function buildInitialRequestsState(range: TimelineRange): RequestsState {
return {
fromValue: toLocalInputValue(range.fromMs),
toValue: toLocalInputValue(range.toMs),
items: [],
loading: false,
loadedCount: 0,
totalItems: 0,
truncated: false,
loaded: false
};
}
export function Tgu2DMapTab({
range,
invalidRange,
selectedPlan,
platforms
}: 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 [hiddenIds, setHiddenIds] = useState<ReadonlySet<string>>(new Set());
const [requestsState, setRequestsState] = useState<RequestsState>(() => buildInitialRequestsState(range));
const [stations, setStations] = useState<StationDto[]>([]);
const [stationsFetchFailed, setStationsFetchFailed] = useState(false);
const [requestsAtPoint, setRequestsAtPoint] = useState<RequestAtPoint[]>([]);
const [selectedRequestPolygonId, setSelectedRequestPolygonId] = useState<string | undefined>();
const [hoveredRequestPolygonId, setHoveredRequestPolygonId] = useState<string | undefined>();
const [stationPanel, setStationPanel] = useState<(Tgu2DMapSelection & { type: "marker"; layer: "stations" }) | undefined>();
useEffect(() => {
fetchStations()
.then((items) => { setStations(items); setStationsFetchFailed(false); })
.catch(() => setStationsFetchFailed(true));
}, []);
const mapInterval = getTgu2DMapIntervalState(range, selectedPlan);
const mapInvalidRange = invalidRange || mapInterval.tooLarge;
const loadRequests = useCallback(async (fromValue: string, toValue: string) => {
const fromMs = parseLocal(fromValue);
const toMs = parseLocal(toValue);
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return;
setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 }));
try {
const result = await fetchRequestsForMap(
{
endFrom: toLocalIso(fromMs),
beginTo: toLocalIso(toMs)
},
(loaded, total) => {
setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total }));
}
);
setRequestsState((current) => ({
...current,
items: result.items,
loadedCount: result.items.length,
totalItems: result.totalItems,
truncated: result.truncated,
loading: false,
loaded: true
}));
} catch (error) {
setRequestsState((current) => ({
...current,
loading: false,
error: error instanceof Error ? error.message : "Не удалось загрузить заявки."
}));
}
}, []);
const applyRequestsRange = useCallback(() => {
void loadRequests(requestsState.fromValue, requestsState.toValue);
}, [loadRequests, requestsState.fromValue, requestsState.toValue]);
const setRequestsFrom = useCallback((fromValue: string) => {
setRequestsState((current) => ({ ...current, fromValue }));
}, []);
const setRequestsTo = useCallback((toValue: string) => {
setRequestsState((current) => ({ ...current, toValue }));
}, []);
const setRequestsQuickRange = useCallback((fromMs: number, toMs: number) => {
const fromValue = toLocalInputValue(fromMs);
const toValue = toLocalInputValue(toMs);
setRequestsState((current) => ({ ...current, fromValue, toValue }));
void loadRequests(fromValue, toValue);
}, [loadRequests]);
useEffect(() => {
setRequestsState(buildInitialRequestsState(range));
}, [range]);
const toggleSpacecraft = useCallback((id: string) => {
setHiddenIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const showAll = useCallback(() => setHiddenIds(new Set()), []);
const planPlatform = useMemo(
() => selectedPlan ? platforms.find((p) => p.spacecraftId === selectedPlan.spacecraftId) : undefined,
[platforms, selectedPlan]
);
const satelliteLabel = selectedPlan
? platformLabel(planPlatform, selectedPlan.spacecraftId)
: hiddenIds.size > 0
? `Скрыто: ${hiddenIds.size} КА`
: "Все КА";
const intervalLabel = `${formatDate(mapInterval.range.fromMs)} - ${formatDate(mapInterval.range.toMs)}`;
const scene = useMemo(
() => buildTgu2DMapScene({
range: mapInterval.range,
selectedPlan,
platforms,
hiddenSpacecraftIds: hiddenIds,
requestItems: layers.requests ? requestsState.items : [],
stationItems: layers.stations ? stations : []
}),
[mapInterval.range, platforms, selectedPlan, hiddenIds, requestsState.items, layers.requests, stations, layers.stations]
);
const layerAvailability = useMemo(
() => getLayerAvailability(layers, scene, stationsFetchFailed),
[layers, scene, stationsFetchFailed]
);
const overlayMessage = invalidRange
? "Некорректный интервал: from должен быть меньше to."
: mapInterval.tooLarge
? TGU_2D_MAP_INTERVAL_WARNING
: undefined;
const toggleLayer = (key: Tgu2DMapLayerKey) => {
setLayers((current) => ({ ...current, [key]: !current[key] }));
};
const handleObjectSelect = useCallback((selection?: Tgu2DMapSelection) => {
if (selection?.type === "marker" && selection.layer === "stations") {
setStationPanel(selection as Tgu2DMapSelection & { type: "marker"; layer: "stations" });
setRequestsAtPoint([]);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
setSelectedObject(undefined);
} else {
setStationPanel(undefined);
setSelectedObject(selection);
}
}, []);
const handleRequestsAtPoint = useCallback(
(polygons: MapPolygon[]) => {
if (polygons.length === 0) {
setRequestsAtPoint([]);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
return;
}
const joined = polygons.flatMap((polygon) => {
const item = requestsState.items.find((r) => r.id === polygon.requestId);
return item ? [{ polygon, item }] : [];
});
setRequestsAtPoint(joined);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
setStationPanel(undefined);
},
[requestsState.items]
);
const closeRequestsPanel = useCallback(() => {
setRequestsAtPoint([]);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
}, []);
const closeStationPanel = useCallback(() => {
setStationPanel(undefined);
}, []);
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${(requestsAtPoint.length > 0 || stationPanel != null) ? " has-requests-panel" : ""}`}>
<Tgu2DMapSidebar
platforms={platforms}
selectedPlanId={selectedPlan?.planId}
hiddenIds={hiddenIds}
layers={layers}
search={search}
layerAvailability={layerAvailability}
requestsState={requestsState}
onSearchChange={setSearch}
onToggleSpacecraft={toggleSpacecraft}
onShowAll={showAll}
onToggleLayer={toggleLayer}
onRequestsFromChange={setRequestsFrom}
onRequestsToChange={setRequestsTo}
onRequestsApply={applyRequestsRange}
onRequestsQuickRange={setRequestsQuickRange}
/>
<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={handleObjectSelect}
onRequestsAtPoint={handleRequestsAtPoint}
hoveredRequestPolygonId={hoveredRequestPolygonId}
selectedRequestPolygonId={selectedRequestPolygonId}
/>
</section>
{requestsAtPoint.length > 0 && (
<Tgu2DMapRequestsPanel
requests={requestsAtPoint}
selectedPolygonId={selectedRequestPolygonId}
onHover={setHoveredRequestPolygonId}
onSelect={setSelectedRequestPolygonId}
onClose={closeRequestsPanel}
/>
)}
{stationPanel != null && (
<Tgu2DMapStationPanel selection={stationPanel} onClose={closeStationPanel} />
)}
</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 "complexPlan":
return "Комплексный план";
case "stations":
return "Станции";
case "spacecraftMarkers":
return "Маркеры КА";
case "requests":
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">Карта</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,762 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { drawBaseMap } from "./canvas/drawBaseMap";
import { drawComplexPlan } from "./canvas/drawComplexPlan";
import { drawPlanWorks } from "./canvas/drawPlanWorks";
import { drawRequests } from "./canvas/drawRequests";
import { drawStations } from "./canvas/drawStations";
import { drawSwath } from "./canvas/drawSwath";
import { drawTiles } from "./canvas/drawTiles";
import { drawTracks } from "./canvas/drawTracks";
import { createMapProjection, normalizeLon } from "./geometry/mapProjection";
import { createPolygonSpatialIndex, hitTestAllPolygons, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex";
import type { Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { GeoPoint, MapLine, MapMarker, 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;
onRequestsAtPoint?: (polygons: MapPolygon[]) => void;
hoveredRequestPolygonId?: string;
selectedRequestPolygonId?: string;
/** Внешняя подсветка объекта (контур съёмки или линия сброса) по его id — «как будто кликнули на карте». */
selectedObjectId?: string;
/** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */
focusPoint?: GeoPoint;
focusToken?: number;
/**
* Включает выбор объекта под курсором: если в точке клика лежит несколько объектов
* (например, заявка и маршрут съёмки), их список отдаётся через onObjectsAtPoint вместо
* «молчаливого» выбора верхнего по z-индексу. Также делает кликабельными линии сброса.
*/
enableObjectChooser?: boolean;
/** Несколько объектов под точкой клика — список передаётся наверх (выбор делает оператор справа). */
onObjectsAtPoint?: (selections: Tgu2DMapSelection[]) => void;
pickMode?: boolean;
onPickPoint?: (point: { lat: number; lon: number }) => void;
targets?: Tgu2DMapTarget[];
pickTarget?: Tgu2DMapTarget;
selectedTargetId?: string;
onTargetSelect?: (id: string) => void;
drawMode?: boolean;
draftPolygon?: GeoPoint[];
onDrawAddPoint?: (point: GeoPoint) => void;
};
export type Tgu2DMapTarget = {
id: string;
name: string;
lat: number;
lon: number;
};
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,
onRequestsAtPoint,
hoveredRequestPolygonId,
selectedRequestPolygonId,
selectedObjectId,
focusPoint,
focusToken,
enableObjectChooser = false,
onObjectsAtPoint,
pickMode = false,
onPickPoint,
targets = [],
pickTarget,
selectedTargetId,
onTargetSelect,
drawMode = false,
draftPolygon = [],
onDrawAddPoint
}: Tgu2DMapViewProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const dragRef = useRef<DragState | undefined>(undefined);
const hasDraggedRef = useRef(false);
const hoverRef = useRef<MapPolygon | undefined>(undefined);
const hoverMarkerRef = useRef<MapMarker | undefined>(undefined);
const selectedMarkerRef = useRef<MapMarker | undefined>(undefined);
const [size, setSize] = useState<MapSize>({ width: 1000, height: 560 });
const [view, setView] = useState<MapViewState>(INITIAL_VIEW);
const [tileVersion, setTileVersion] = useState(0);
const handleTileLoad = useCallback(() => setTileVersion((v) => v + 1), []);
const [tooltip, setTooltip] = useState<TooltipState>();
// Гео-координаты под курсором — показываем по центру внизу карты.
const [cursorGeo, setCursorGeo] = useState<GeoPoint>();
const selectedPolygonRef = useRef<MapPolygon | undefined>(undefined);
const selectedLineRef = useRef<MapLine | undefined>(undefined);
const activePolygons = useMemo(
() => scene.polygons.filter((polygon) => layers[polygon.layer]),
[scene.polygons, layers]
);
const polygonIndex = useMemo(() => createPolygonSpatialIndex(activePolygons), [activePolygons]);
const activeMarkers = useMemo(
() => scene.markers.filter((marker) => layers[marker.layer]),
[scene.markers, layers]
);
const projection = useMemo(() => createMapProjection(view, size), [view, size]);
const visiblePolygons = useMemo(() => {
// When the canvas is wider than the world tile, the entire globe is visible —
// bbox filtering would produce a nonsensical range, so skip it.
// When the viewport crosses the antimeridian, topLeft.lon ends up east of
// bottomRight.lon — same situation, show everything.
const topLeft = projection.unproject({ x: 0, y: 0 });
const bottomRight = projection.unproject({ x: size.width, y: size.height });
if (size.width >= projection.worldSize || topLeft.lon >= bottomRight.lon) {
return activePolygons;
}
return queryPolygonsByBBox(polygonIndex, {
minLon: topLeft.lon,
maxLon: bottomRight.lon,
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();
}, []);
// Перецентрирование по внешней команде (клик по включению в редакторе): центрируем карту
// на объекте и подтягиваем зум, чтобы он был хорошо виден. Триггер — смена focusToken,
// поэтому повторный клик по тому же включению снова возвращает карту к объекту.
useEffect(() => {
if (!focusPoint || focusToken === undefined) return;
setView((current) => ({
centerLon: focusPoint.lon,
centerLat: Math.max(-82, Math.min(82, focusPoint.lat)),
zoom: Math.max(current.zoom, 4.5)
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusToken]);
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);
drawTiles(context, view, projection, size, handleTileLoad);
drawBaseMap(context, projection);
if (layers.requests) {
drawRequests(context, visiblePolygons.filter((polygon) => polygon.layer === "requests"), projection, size);
}
if (layers.swath) {
drawSwath(context, visiblePolygons.filter((polygon) => polygon.layer === "swath"), projection, size);
}
if (layers.complexPlan) {
drawComplexPlan(context, visiblePolygons.filter((polygon) => polygon.layer === "complexPlan"), 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);
}, [handleTileLoad, layers, projection, redrawToken, scene.lines, scene.markers, size, tileVersion, view, 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);
setCursorGeo(projection.unproject(screenPoint));
const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
const polygon = marker ? undefined : hitTestPolygons(polygonIndex, projection.unproject(screenPoint));
if (marker?.id === hoverMarkerRef.current?.id && polygon?.id === hoverRef.current?.id) return;
hoverMarkerRef.current = marker;
hoverRef.current = polygon;
if (!marker && !polygon) {
setTooltip(undefined);
return;
}
setTooltip({
x: screenPoint.x + 12,
y: screenPoint.y + 12,
title: marker ? marker.label : polygon!.name,
subtitle: marker ? "Станция" : layerTitle(polygon!.layer)
});
},
[activeMarkers, polygonIndex, projection, screenPointFromEvent]
);
const onPointerDown = (event: React.PointerEvent) => {
const screenPoint = screenPointFromEvent(event);
hasDraggedRef.current = false;
dragRef.current = {
startX: screenPoint.x,
startY: screenPoint.y,
centerAtStart: { lon: view.centerLon, lat: view.centerLat }
};
wrapperRef.current?.setPointerCapture(event.pointerId);
};
const DRAG_THRESHOLD = 4;
const onPointerMove = (event: React.PointerEvent) => {
const drag = dragRef.current;
if (!drag) {
updateHover(event);
return;
}
const screenPoint = screenPointFromEvent(event);
setCursorGeo(projection.unproject(screenPoint));
const dx = screenPoint.x - drag.startX;
const dy = screenPoint.y - drag.startY;
if (!hasDraggedRef.current && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) {
hasDraggedRef.current = true;
}
const dragProjection = createMapProjection(
{
centerLon: drag.centerAtStart.lon,
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);
};
// Применить выбор конкретного объекта (из меню выбора либо при единственном попадании):
// выставляем внутреннюю подсветку и сообщаем наверх через onObjectSelect.
const applyEditorHit = useCallback((hit: EditorHit) => {
selectedPolygonRef.current = hit.kind === "polygon" ? hit.polygon : undefined;
selectedMarkerRef.current = hit.kind === "marker" ? hit.marker : undefined;
selectedLineRef.current = hit.kind === "line" ? hit.line : undefined;
onObjectSelect(hit.selection);
}, [onObjectSelect]);
// Клик в редакторе: собираем ВСЕ интерактивные объекты под точкой (заявки, маршруты съёмки,
// сбросы, станции). 0 → возможно полоса обзора под низом; 1 → выбираем сразу;
// ≥2 → отдаём список наверх (оператор выбирает нужный в правой панели).
const handleEditorClick = useCallback((screenPoint: ScreenPoint) => {
const geoPoint = projection.unproject(screenPoint);
const hits: EditorHit[] = [];
const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
if (marker) hits.push({ kind: "marker", marker, selection: markerToSelection(marker) });
for (const polygon of hitTestAllPolygons(polygonIndex, geoPoint)) {
if (polygon.layer !== "requests" && polygon.layer !== "planWorks") continue;
hits.push({ kind: "polygon", polygon, selection: polygonToSelection(polygon) });
}
const dropLines = scene.lines.filter((line) => line.kind === "drop" && layers[line.layer]);
for (const line of hitTestLines(dropLines, screenPoint, projection)) {
hits.push({ kind: "line", line, selection: lineToSelection(line) });
}
if (hits.length >= 2 && onObjectsAtPoint) {
// Несколько объектов — внутреннюю подсветку снимаем (её ведёт правая панель через selectedObjectId).
selectedPolygonRef.current = undefined;
selectedMarkerRef.current = undefined;
selectedLineRef.current = undefined;
onObjectsAtPoint(hits.map((hit) => hit.selection));
return;
}
if (hits.length >= 1) {
applyEditorHit(hits[0]);
return;
}
// Ничего «интерактивного» — выбираем верхний прочий полигон (напр. полосу обзора) либо снимаем выбор.
const fallback = hitTestPolygons(polygonIndex, geoPoint);
selectedPolygonRef.current = fallback;
selectedMarkerRef.current = undefined;
selectedLineRef.current = undefined;
onObjectSelect(fallback ? polygonToSelection(fallback) : undefined);
}, [activeMarkers, applyEditorHit, layers, onObjectSelect, onObjectsAtPoint, polygonIndex, projection, scene.lines]);
const onClick = (event: React.MouseEvent) => {
if (hasDraggedRef.current) return;
const screenPoint = screenPointFromEvent(event);
if (drawMode) {
onDrawAddPoint?.(projection.unproject(screenPoint));
return;
}
if (pickMode) {
onPickPoint?.(projection.unproject(screenPoint));
onObjectSelect(undefined);
return;
}
if (enableObjectChooser) {
handleEditorClick(screenPoint);
return;
}
const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
if (marker) {
selectedMarkerRef.current = marker;
selectedPolygonRef.current = undefined;
onRequestsAtPoint?.([]);
onObjectSelect({
type: "marker",
id: marker.id,
name: marker.label,
layer: marker.layer,
stationNumber: marker.stationNumber,
lat: marker.point.lat,
lon: marker.point.lon,
altitude: marker.altitude,
elevationMin: marker.elevationMin,
elevationMax: marker.elevationMax
});
return;
}
const geoPoint = projection.unproject(screenPoint);
const polygon = hitTestPolygons(polygonIndex, geoPoint);
if (polygon?.layer === "requests" && onRequestsAtPoint) {
const allRequests = hitTestAllPolygons(polygonIndex, geoPoint).filter((p) => p.layer === "requests");
onRequestsAtPoint(allRequests);
selectedPolygonRef.current = undefined;
selectedMarkerRef.current = undefined;
onObjectSelect(undefined);
return;
}
onRequestsAtPoint?.([]);
selectedPolygonRef.current = polygon;
selectedMarkerRef.current = undefined;
if (!polygon) {
onObjectSelect(undefined);
return;
}
onObjectSelect({
type: "polygon",
id: polygon.id,
name: polygon.name,
layer: polygon.layer,
planId: polygon.planId,
spacecraftId: polygon.spacecraftId,
requestId: polygon.requestId,
surveyType: polygon.surveyType,
status: polygon.status,
modeId: polygon.modeId
});
};
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 selectedMarker = selectedMarkerRef.current;
const externalSelectedPolygon = useMemo(() => {
const id = selectedRequestPolygonId ?? selectedObjectId;
return id ? activePolygons.find((p) => p.id === id) : undefined;
}, [selectedRequestPolygonId, selectedObjectId, activePolygons]);
// Подсветка линии (сброс/трасса) по тому же selectedObjectId — линии живут вне activePolygons.
const externalSelectedLine = useMemo(
() => (selectedObjectId ? scene.lines.find((line) => line.id === selectedObjectId) : undefined),
[selectedObjectId, scene.lines]
);
// Линии под подсветкой: выбранная кликом на карте (внутренняя) и заданная извне (по selectedObjectId).
const overlayLines = uniqueLines([selectedLineRef.current, externalSelectedLine]);
const externalHoveredPolygon = useMemo(
() => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined,
[hoveredRequestPolygonId, activePolygons]
);
const overlayPolygons = uniquePolygons([externalSelectedPolygon, selectedPolygon, externalHoveredPolygon, hoverRef.current]);
const overlayMarkers = uniqueMarkers([selectedMarker, hoverMarkerRef.current]);
return (
<div
className={`tgu-2d-map ${pickMode || drawMode ? "is-picking" : ""}`}
ref={wrapperRef}
onClick={onClick}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onPointerLeave={() => {
dragRef.current = undefined;
hoverRef.current = undefined;
hoverMarkerRef.current = undefined;
setTooltip(undefined);
setCursorGeo(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.flatMap((polygon) => {
const raw = polygon.points.map((point) => projection.project(point));
const pts = unwrapX(raw, projection.worldSize);
const cls =
polygon.id === selectedPolygon?.id || polygon.id === externalSelectedPolygon?.id
? "is-selected"
: "is-hovered";
return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => {
const visible = pts.some((p) => {
const x = p.x + dx;
return x >= -64 && x <= size.width + 64 && p.y >= -64 && p.y <= size.height + 64;
});
if (!visible) return [];
return (
<polygon
key={`${polygon.id}:${dx}`}
points={pts.map((p) => `${p.x + dx},${p.y}`).join(" ")}
className={cls}
/>
);
});
})}
{overlayLines.flatMap((line) => {
if (line.points.length < 2) return [];
const pts = unwrapX(line.points.map((point) => projection.project(point)), projection.worldSize);
return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => {
const visible = pts.some((p) => {
const x = p.x + dx;
return x >= -64 && x <= size.width + 64 && p.y >= -64 && p.y <= size.height + 64;
});
if (!visible) return [];
return (
<polyline
key={`line:${line.id}:${dx}`}
points={pts.map((p) => `${p.x + dx},${p.y}`).join(" ")}
className="is-selected"
fill="none"
/>
);
});
})}
{overlayMarkers.map((marker) => {
const pt = projection.project(marker.point);
const cls = marker.id === selectedMarker?.id ? "is-selected" : "is-hovered";
return <circle key={marker.id} cx={pt.x} cy={pt.y} r={10} className={cls} />;
})}
{targets.map((t) => {
const screen = projection.project(t);
const isSelected = t.id === selectedTargetId;
return (
<g
className={`tgu-2d-map__target tgu-2d-map__target--confirmed${isSelected ? " is-selected" : ""}`}
key={t.id}
transform={`translate(${screen.x} ${screen.y})`}
onClick={(e) => { e.stopPropagation(); onTargetSelect?.(t.id); }}
style={{ cursor: "pointer" }}
>
<circle r="6" />
<line x1="-10" x2="10" y1="0" y2="0" />
<line x1="0" x2="0" y1="-10" y2="10" />
<text x="10" y="-8">{t.name}</text>
</g>
);
})}
{pickTarget && (() => {
const screen = projection.project(pickTarget);
return (
<g className="tgu-2d-map__target tgu-2d-map__target--pending" key="pick" transform={`translate(${screen.x} ${screen.y})`}>
<circle r="6" />
<line x1="-10" x2="10" y1="0" y2="0" />
<line x1="0" x2="0" y1="-10" y2="10" />
</g>
);
})()}
{draftPolygon.length > 0 && (() => {
const pts = unwrapX(draftPolygon.map((point) => projection.project(point)), projection.worldSize);
const path = pts.map((p) => `${p.x},${p.y}`).join(" ");
return (
<g className="tgu-2d-map__draft">
{pts.length >= 2 && (
pts.length >= 3
? <polygon className="tgu-2d-map__draft-fill" points={path} />
: <polyline className="tgu-2d-map__draft-line" points={path} />
)}
{pts.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={4} className="tgu-2d-map__draft-vertex" />
))}
</g>
);
})()}
</svg>
{tooltip && (
<div className="tgu-2d-map__tooltip" style={{ left: tooltip.x, top: tooltip.y }}>
<div>{tooltip.title}</div>
<span>{tooltip.subtitle}</span>
</div>
)}
{cursorGeo && <div className="tgu-2d-map__coords">{formatLatLon(cursorGeo)}</div>}
<div className="tgu-2d-map__attribution">© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> contributors · © <a href="https://carto.com/attributions" target="_blank" rel="noopener noreferrer">CARTO</a></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 formatLatLon(point: GeoPoint): string {
const lat = point.lat;
const lon = normalizeLon(point.lon);
const latStr = `${Math.abs(lat).toFixed(4)}° ${lat >= 0 ? "с.ш." : "ю.ш."}`;
const lonStr = `${Math.abs(lon).toFixed(4)}° ${lon >= 0 ? "в.д." : "з.д."}`;
return `${latStr}, ${lonStr}`;
}
function layerTitle(layer: MapPolygon["layer"]): string {
switch (layer) {
case "planWorks":
return "Работы плана";
case "swath":
return "Полоса обзора";
case "complexPlan":
return "Комплексный план";
case "tracks":
return "Трасса КА";
case "requests":
return "Заявка на съёмку";
}
}
function unwrapX(points: { x: number; y: number }[], worldSize: number): { x: number; y: number }[] {
if (points.length === 0) return [];
const out: { x: number; y: number }[] = [points[0]];
let prevX = points[0].x;
for (let i = 1; i < points.length; i++) {
let x = points[i].x;
while (x - prevX > worldSize / 2) x -= worldSize;
while (prevX - x > worldSize / 2) x += worldSize;
out.push({ x, y: points[i].y });
prevX = x;
}
return out;
}
function hitTestMarkers(
markers: MapMarker[],
screenPoint: ScreenPoint,
projection: ReturnType<typeof createMapProjection>,
hitRadius = 10
): MapMarker | undefined {
let best: MapMarker | undefined;
let bestDist = hitRadius;
for (const marker of markers) {
const pt = projection.project(marker.point);
const dist = Math.sqrt((pt.x - screenPoint.x) ** 2 + (pt.y - screenPoint.y) ** 2);
if (dist <= bestDist) {
bestDist = dist;
best = marker;
}
}
return best;
}
function uniqueMarkers(markers: Array<MapMarker | undefined>): MapMarker[] {
const result: MapMarker[] = [];
const ids = new Set<string>();
for (const marker of markers) {
if (!marker || ids.has(marker.id)) continue;
ids.add(marker.id);
result.push(marker);
}
return result;
}
function uniquePolygons(polygons: Array<MapPolygon | undefined>): MapPolygon[] {
const result: MapPolygon[] = [];
const ids = new Set<string>();
for (const polygon of polygons) {
if (!polygon || ids.has(polygon.id)) continue;
ids.add(polygon.id);
result.push(polygon);
}
return result;
}
function uniqueLines(lines: Array<MapLine | undefined>): MapLine[] {
const result: MapLine[] = [];
const ids = new Set<string>();
for (const line of lines) {
if (!line || ids.has(line.id)) continue;
ids.add(line.id);
result.push(line);
}
return result;
}
/** Один объект-кандидат под курсором: несёт исходный объект (для подсветки) и его выделение. */
type EditorHit =
| { kind: "marker"; marker: MapMarker; selection: Tgu2DMapSelection }
| { kind: "polygon"; polygon: MapPolygon; selection: Tgu2DMapSelection }
| { kind: "line"; line: MapLine; selection: Tgu2DMapSelection };
function markerToSelection(marker: MapMarker): Tgu2DMapSelection {
return {
type: "marker",
id: marker.id,
name: marker.label,
layer: marker.layer,
stationNumber: marker.stationNumber,
lat: marker.point.lat,
lon: marker.point.lon,
altitude: marker.altitude,
elevationMin: marker.elevationMin,
elevationMax: marker.elevationMax
};
}
function polygonToSelection(polygon: MapPolygon): Tgu2DMapSelection {
return {
type: "polygon",
id: polygon.id,
name: polygon.name,
layer: polygon.layer,
planId: polygon.planId,
spacecraftId: polygon.spacecraftId,
requestId: polygon.requestId,
surveyType: polygon.surveyType,
status: polygon.status,
modeId: polygon.modeId
};
}
function lineToSelection(line: MapLine): Tgu2DMapSelection {
return {
type: "line",
id: line.id,
name: line.name ?? "Сброс",
layer: line.layer,
kind: line.kind,
modeId: line.modeId
};
}
/** Линии, проходящие в пределах допуска (px) от точки клика, отсортированные по близости. */
function hitTestLines(
lines: MapLine[],
screenPoint: ScreenPoint,
projection: ReturnType<typeof createMapProjection>,
tolerance = 7
): MapLine[] {
const worldSize = projection.worldSize;
const hits: Array<{ line: MapLine; dist: number }> = [];
for (const line of lines) {
if (line.points.length < 2) continue;
const pts = unwrapX(line.points.map((point) => projection.project(point)), worldSize);
let best = Number.POSITIVE_INFINITY;
for (const dx of [0, worldSize, -worldSize]) {
for (let i = 1; i < pts.length; i++) {
const d = distToSegment(
screenPoint,
{ x: pts[i - 1].x + dx, y: pts[i - 1].y },
{ x: pts[i].x + dx, y: pts[i].y }
);
if (d < best) best = d;
}
}
if (best <= tolerance) hits.push({ line, dist: best });
}
return hits.sort((a, b) => a.dist - b.dist).map((h) => h.line);
}
function distToSegment(p: ScreenPoint, a: ScreenPoint, b: ScreenPoint): number {
const dx = b.x - a.x;
const dy = b.y - a.y;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
}
@@ -0,0 +1,26 @@
import type { MapProjection } from "../geometry/mapProjection";
// Draws the lat/lon graticule (30° grid) over OSM tiles.
export function drawBaseMap(ctx: CanvasRenderingContext2D, projection: MapProjection): void {
ctx.save();
ctx.strokeStyle = "rgba(255, 255, 255, 0.10)";
ctx.lineWidth = 0.5;
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();
}
@@ -0,0 +1,61 @@
import type { MapProjection } from "../geometry/mapProjection";
import type { MapPolygon } from "../model/mapTypes";
type Pt = { x: number; y: number };
/**
* «Разворачивает» X-координаты так, чтобы соседние точки не расходились больше чем на
* полмира — иначе полигон, пересекающий антимеридиан (180°), рисуется через всю карту.
* Та же логика, что в drawSwath/drawPlanWorks — фикс антимеридиана.
*/
function unwrapX(points: Pt[], worldSize: number): Pt[] {
if (points.length === 0) return [];
const out: Pt[] = [points[0]];
let prevX = points[0].x;
for (let i = 1; i < points.length; i++) {
let x = points[i].x;
while (x - prevX > worldSize / 2) x -= worldSize;
while (prevX - x > worldSize / 2) x += worldSize;
out.push({ x, y: points[i].y });
prevX = x;
}
return out;
}
/**
* Контуры комплексного плана (этап 4). Отдельный слой со своим (янтарным) цветом, чтобы
* визуально не путать с «полосой обзора» (swath) и «работами плана» (planWorks). Рисуется
* в трёх копиях мира с отсечением по видимой области — как остальные полигональные слои.
*/
export function drawComplexPlan(
ctx: CanvasRenderingContext2D,
polygons: MapPolygon[],
projection: MapProjection,
viewport: { width: number; height: number }
) {
ctx.save();
ctx.fillStyle = "rgba(240, 170, 70, 0.14)";
ctx.strokeStyle = "rgba(240, 170, 70, 0.62)";
ctx.lineWidth = 1.2;
for (const polygon of polygons) {
if (polygon.points.length < 3) continue;
const raw = polygon.points.map((point) => projection.project(point));
const pts = unwrapX(raw, projection.worldSize);
for (const dx of [0, projection.worldSize, -projection.worldSize]) {
const visible = pts.some((point) => {
const x = point.x + dx;
return x >= -64 && x <= viewport.width + 64 && point.y >= -64 && point.y <= viewport.height + 64;
});
if (!visible) continue;
ctx.beginPath();
for (let i = 0; i < pts.length; i++) {
if (i === 0) ctx.moveTo(pts[i].x + dx, pts[i].y);
else ctx.lineTo(pts[i].x + dx, pts[i].y);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
}
}
ctx.restore();
}
@@ -0,0 +1,70 @@
import { statusStyle } from "../../tgu-planning/tguStatus";
import type { MapProjection } from "../geometry/mapProjection";
import type { MapPolygon } from "../model/mapTypes";
type Pt = { x: number; y: number };
/**
* «Разворачивает» X-координаты так, чтобы соседние точки не расходились больше чем на
* полмира — иначе полигон, пересекающий антимеридиан (180°), рисуется через всю карту.
*/
function unwrapX(points: Pt[], worldSize: number): Pt[] {
if (points.length === 0) return [];
const out: Pt[] = [points[0]];
let prevX = points[0].x;
for (let i = 1; i < points.length; i++) {
let x = points[i].x;
while (x - prevX > worldSize / 2) x -= worldSize;
while (prevX - x > worldSize / 2) x += worldSize;
out.push({ x, y: points[i].y });
prevX = x;
}
return out;
}
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 raw = polygon.points.map((point) => projection.project(point));
const pts = unwrapX(raw, projection.worldSize);
const color = statusStyle(polygon.status || "PLANNED").color;
ctx.fillStyle = hexToRgba(color, 0.2);
ctx.strokeStyle = hexToRgba(color, 0.72);
ctx.lineWidth = 1.2;
// Рисуем полигон в трёх копиях мира (текущей и соседних), чтобы он был виден
// независимо от того, какая копия попала в видимую область при зуме/панораме.
for (const dx of [0, projection.worldSize, -projection.worldSize]) {
const visible = pts.some((point) => {
const x = point.x + dx;
return x >= -32 && x <= viewport.width + 32 && point.y >= -32 && point.y <= viewport.height + 32;
});
if (!visible) continue;
ctx.beginPath();
for (let i = 0; i < pts.length; i++) {
if (i === 0) ctx.moveTo(pts[i].x + dx, pts[i].y);
else ctx.lineTo(pts[i].x + dx, pts[i].y);
}
ctx.closePath();
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,69 @@
import type { MapProjection } from "../geometry/mapProjection";
import type { MapPolygon } from "../model/mapTypes";
const SURVEY_COLORS: Record<string, string> = {
OPTICS: "#FFA040",
RSA: "#40AFFF",
COMBINED: "#C080FF"
};
const DEFAULT_COLOR = "#FFA040";
type Pt = { x: number; y: number };
function unwrapX(points: Pt[], worldSize: number): Pt[] {
if (points.length === 0) return [];
const out: Pt[] = [points[0]];
let prevX = points[0].x;
for (let i = 1; i < points.length; i++) {
let x = points[i].x;
while (x - prevX > worldSize / 2) x -= worldSize;
while (prevX - x > worldSize / 2) x += worldSize;
out.push({ x, y: points[i].y });
prevX = x;
}
return out;
}
export function drawRequests(
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 raw = polygon.points.map((p) => projection.project(p));
const pts = unwrapX(raw, projection.worldSize);
const color = SURVEY_COLORS[polygon.surveyType ?? ""] ?? DEFAULT_COLOR;
ctx.fillStyle = hexToRgba(color, 0.18);
ctx.strokeStyle = hexToRgba(color, 0.85);
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
for (const dx of [0, projection.worldSize, -projection.worldSize]) {
const visible = pts.some((p) => {
const x = p.x + dx;
return x >= -64 && x <= viewport.width + 64 && p.y >= -64 && p.y <= viewport.height + 64;
});
if (!visible) continue;
ctx.beginPath();
for (let i = 0; i < pts.length; i++) {
if (i === 0) ctx.moveTo(pts[i].x + dx, pts[i].y);
else ctx.lineTo(pts[i].x + dx, pts[i].y);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
}
ctx.setLineDash([]);
}
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,58 @@
import type { MapProjection } from "../geometry/mapProjection";
import type { MapPolygon } from "../model/mapTypes";
type Pt = { x: number; y: number };
function unwrapX(points: Pt[], worldSize: number): Pt[] {
if (points.length === 0) return [];
const out: Pt[] = [points[0]];
let prevX = points[0].x;
for (let i = 1; i < points.length; i++) {
let x = points[i].x;
while (x - prevX > worldSize / 2) x -= worldSize;
while (prevX - x > worldSize / 2) x += worldSize;
out.push({ x, y: points[i].y });
prevX = x;
}
return out;
}
function inView(points: Pt[], dx: number, vw: number, vh: number): boolean {
return points.some((p) => {
const x = p.x + dx;
return x >= -64 && x <= vw + 64 && p.y >= -64 && p.y <= vh + 64;
});
}
function tracePath(ctx: CanvasRenderingContext2D, points: Pt[], dx: number) {
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
if (i === 0) ctx.moveTo(points[i].x + dx, points[i].y);
else ctx.lineTo(points[i].x + dx, points[i].y);
}
ctx.closePath();
}
export function drawSwath(
ctx: CanvasRenderingContext2D,
polygons: MapPolygon[],
projection: MapProjection,
viewport: { width: number; height: number }
) {
ctx.save();
ctx.fillStyle = "rgba(104, 195, 189, 0.10)";
ctx.strokeStyle = "rgba(104, 195, 189, 0.40)";
ctx.lineWidth = 1;
for (const polygon of polygons) {
if (polygon.points.length < 3) continue;
const raw = polygon.points.map((p) => projection.project(p));
const pts = unwrapX(raw, projection.worldSize);
for (const dx of [0, projection.worldSize, -projection.worldSize]) {
if (!inView(pts, dx, viewport.width, viewport.height)) continue;
tracePath(ctx, pts, dx);
ctx.fill();
ctx.stroke();
}
}
ctx.restore();
}
@@ -0,0 +1,64 @@
import { latToWorldY, lonToWorldX, worldYToLat } from "../geometry/mapProjection";
import type { MapProjection } from "../geometry/mapProjection";
import type { MapSize, MapViewState } from "../model/mapTypes";
import { getTile } from "./tileLoader";
const TILE_PX = 256;
export function drawTiles(
ctx: CanvasRenderingContext2D,
view: MapViewState,
projection: MapProjection,
size: MapSize,
onLoad: () => void
): void {
// Snap fractional zoom to nearest integer for tile selection.
// Tiles are rendered at the correct scale via screenTileW.
const z = Math.max(1, Math.min(18, Math.round(view.zoom)));
const tileCount = 2 ** z;
const worldSize_z = TILE_PX * tileCount;
// Width of one tile in screen pixels at the current (possibly fractional) zoom
const screenTileW = TILE_PX * (projection.worldSize / worldSize_z);
// Center of the viewport in tile x-coordinates (float)
const centerTileX = lonToWorldX(view.centerLon, worldSize_z) / TILE_PX;
// Center in projection world pixels (used for screen x formula below)
const centerWorldX = lonToWorldX(view.centerLon, projection.worldSize);
// X tile range: continuously-indexed (can be negative or > tileCount).
// wrappedTx = ((tx % tileCount) + tileCount) % tileCount for the actual URL.
const tilesHalf = Math.ceil(size.width / (2 * screenTileW)) + 1;
const txMin = Math.floor(centerTileX) - tilesHalf;
const txMax = Math.ceil(centerTileX) + tilesHalf;
// Y tile range: clamped to [0, tileCount-1] (no world repeat in y)
const topLat = projection.unproject({ x: size.width / 2, y: 0 }).lat;
const bottomLat = projection.unproject({ x: size.width / 2, y: size.height }).lat;
const tyMin = Math.max(0, Math.floor(latToWorldY(topLat, worldSize_z) / TILE_PX) - 1);
const tyMax = Math.min(tileCount - 1, Math.ceil(latToWorldY(bottomLat, worldSize_z) / TILE_PX) + 1);
for (let ty = tyMin; ty <= tyMax; ty++) {
// Mercator-correct tile top/bottom screen positions
const tileTopLat = worldYToLat(ty * TILE_PX, worldSize_z);
const tileBottomLat = worldYToLat((ty + 1) * TILE_PX, worldSize_z);
// lon arg is irrelevant for y — use centerLon to avoid antimeridian edge cases
const screenY = projection.project({ lon: view.centerLon, lat: tileTopLat }).y;
const screenYBottom = projection.project({ lon: view.centerLon, lat: tileBottomLat }).y;
if (screenYBottom < 0 || screenY > size.height) continue;
const screenH = screenYBottom - screenY;
for (let tx = txMin; tx <= txMax; tx++) {
// Screen x is linear in tile x (Mercator is linear in longitude)
const screenX = size.width / 2 + tx * screenTileW - centerWorldX;
if (screenX + screenTileW < 0 || screenX > size.width) continue;
const wrappedTx = ((tx % tileCount) + tileCount) % tileCount;
const img = getTile(z, wrappedTx, ty, onLoad);
if (!img) continue;
ctx.drawImage(img, screenX, screenY, screenTileW, screenH);
}
}
}
@@ -0,0 +1,39 @@
import type { MapProjection } from "../geometry/mapProjection";
import type { MapLine } from "../model/mapTypes";
const TRACK_STYLE = { stroke: "rgba(104, 195, 189, 0.76)", width: 1.2 };
// Сброс — толстая линия поверх трассы, на отрезке от начала до конца сеанса.
// Цвет приглушённо-малиновый: отдельный тёплый оттенок, контрастный к бирюзовым
// виткам трассы (не теряется среди них) и не совпадающий с цветами заявок/маршрутов,
// но без кричащей яркости, чтобы не мешать просмотру контуров съёмки.
const DROP_STYLE = { stroke: "rgba(214, 96, 138, 0.82)", width: 5 };
export function drawTracks(ctx: CanvasRenderingContext2D, lines: MapLine[], projection: MapProjection) {
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
const wrapThreshold = projection.worldSize / 2;
// Сбросы рисуем после трасс, чтобы их яркая линия легла поверх.
const ordered = [...lines].sort((a, b) => Number(a.kind === "drop") - Number(b.kind === "drop"));
for (const line of ordered) {
if (line.points.length < 2) continue;
const style = line.kind === "drop" ? DROP_STYLE : TRACK_STYLE;
ctx.strokeStyle = style.stroke;
ctx.lineWidth = style.width;
ctx.beginPath();
let prevX: number | undefined;
for (const point of line.points) {
const screen = projection.project(point);
if (prevX === undefined || Math.abs(screen.x - prevX) > wrapThreshold) {
ctx.moveTo(screen.x, screen.y);
} else {
ctx.lineTo(screen.x, screen.y);
}
prevX = screen.x;
}
ctx.stroke();
}
ctx.restore();
}
@@ -0,0 +1,45 @@
// Module-level tile image cache — shared across all map instances and renders.
// Uses CartoDB dark basemap tiles (CC BY 3.0, no API key required for low traffic).
const SUBDOMAINS = "abcd";
const MAX_CACHE_SIZE = 512;
type CacheEntry = HTMLImageElement | null; // null = loading
const imageCache = new Map<string, CacheEntry>();
function tileUrl(z: number, x: number, y: number): string {
const s = SUBDOMAINS[(x + y) % SUBDOMAINS.length];
return `https://${s}.basemaps.cartocdn.com/dark_nolabels/${z}/${x}/${y}.png`;
}
export function getTile(
z: number,
x: number,
y: number,
onLoad: () => void
): HTMLImageElement | undefined {
const key = `${z}/${x}/${y}`;
const cached = imageCache.get(key);
if (cached instanceof HTMLImageElement) return cached;
if (cached === null) return undefined; // already loading
if (imageCache.size >= MAX_CACHE_SIZE) {
const firstKey = imageCache.keys().next().value;
if (firstKey !== undefined) imageCache.delete(firstKey);
}
imageCache.set(key, null);
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
imageCache.set(key, img);
onLoad();
};
img.onerror = () => {
imageCache.delete(key); // allow retry on next render
};
img.src = tileUrl(z, x, y);
return undefined;
}
@@ -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,34 @@
import { describe, expect, it } from "vitest";
import { groundTrack, nearestStation, stationPasses, subPoint, targetPasses } from "./passes";
const orbit = { spacecraftId: "56756" };
const centerMs = Date.UTC(2026, 4, 29, 12, 0, 0);
describe("passes", () => {
it("finds target and station visibility around the sub-satellite point", () => {
const point = subPoint(orbit, centerMs);
const range = { fromMs: centerMs - 15 * 60 * 1000, toMs: centerMs + 15 * 60 * 1000 };
expect(targetPasses(orbit, point, range).length).toBeGreaterThan(0);
expect(stationPasses(orbit, point, range).length).toBeGreaterThan(0);
});
it("selects the nearest station by great-circle distance", () => {
const point = { lat: 55.75, lon: 37.62 };
expect(nearestStation(point, [
{ id: "far", lat: -30, lon: -120 },
{ id: "near", lat: 55.8, lon: 37.7 }
])?.id).toBe("near");
});
it("builds ground track segments in the requested range", () => {
const segments = groundTrack(orbit, {
fromMs: centerMs,
toMs: centerMs + 90 * 60 * 1000
});
expect(segments.length).toBeGreaterThan(0);
expect(segments.every((segment) => segment.length > 1)).toBe(true);
});
});
@@ -0,0 +1,225 @@
import { normalizeLon } from "./mapProjection";
const DEG = Math.PI / 180;
const RAD = 180 / Math.PI;
const EARTH_RADIUS_KM = 6371;
const EARTH_MU_KM3_S2 = 398600.4418;
const SIDEREAL_DAY_SECONDS = 86164;
const DEFAULT_ALTITUDE_KM = 550;
const DEFAULT_TARGET_THRESHOLD_DEG = 6.5;
const TARGET_SCAN_STEP_MS = 20 * 1000;
const STATION_SCAN_STEP_MS = 30 * 1000;
const MIN_WINDOW_MS = 40 * 1000;
export type GroundPoint = {
lat: number;
lon: number;
};
export type GroundStationPoint = GroundPoint & {
id: string;
};
export type PassOrbit = {
spacecraftId?: string;
altitudeKm?: number;
};
export type PassRange = {
fromMs: number;
toMs: number;
};
export type PassWindow = {
startMs: number;
endMs: number;
quality: number;
};
export function subPoint(orbit: PassOrbit, timeMs: number): GroundPoint {
const params = orbitParams(orbit);
const dtSeconds = timeMs / 1000;
const argument = params.phaseRad + 2 * Math.PI * (dtSeconds / params.periodSeconds);
const lat = Math.asin(Math.sin(params.inclinationRad) * Math.sin(argument)) * RAD;
const relativeLon = Math.atan2(Math.cos(params.inclinationRad) * Math.sin(argument), Math.cos(argument));
const lon = normalizeLon((params.nodeRad + relativeLon) * RAD - (360 / SIDEREAL_DAY_SECONDS) * dtSeconds);
return { lat, lon };
}
export function targetPasses(orbit: PassOrbit, target: GroundPoint, range: PassRange): PassWindow[] {
return scanWindows(orbit, target, range, DEFAULT_TARGET_THRESHOLD_DEG, TARGET_SCAN_STEP_MS);
}
export function stationPasses(orbit: PassOrbit, station: GroundPoint, range: PassRange, minElevDeg = 5): PassWindow[] {
const thresholdDeg = accessAngleDeg(orbit.altitudeKm ?? DEFAULT_ALTITUDE_KM, minElevDeg);
return scanWindows(orbit, station, range, thresholdDeg, STATION_SCAN_STEP_MS);
}
export function nearestStation(point: GroundPoint, stations: GroundStationPoint[]): GroundStationPoint | undefined {
let bestStation: GroundStationPoint | undefined;
let bestDistance = Number.POSITIVE_INFINITY;
for (const station of stations) {
const distance = greatCircleDistanceDeg(point, station);
if (distance < bestDistance) {
bestDistance = distance;
bestStation = station;
}
}
return bestStation;
}
export type OrbitPassInfo = {
index: number;
startMs: number;
endMs: number;
revolution?: number;
};
export function orbitPeriodMs(orbit: PassOrbit): number {
return orbitParams(orbit).periodSeconds * 1000;
}
export function orbitPassList(orbit: PassOrbit, range: PassRange): OrbitPassInfo[] {
if (range.toMs <= range.fromMs) return [];
const periodMs = orbitPeriodMs(orbit);
const passes: OrbitPassInfo[] = [];
let t = range.fromMs;
let index = 1;
while (t < range.toMs) {
passes.push({ index, startMs: t, endMs: Math.min(t + periodMs, range.toMs) });
t += periodMs;
index++;
}
return passes;
}
export function groundTrack(orbit: PassOrbit, range: PassRange, maxPoints = 240): GroundPoint[][] {
if (range.toMs <= range.fromMs) {
return [];
}
const spanMs = range.toMs - range.fromMs;
const stepMs = Math.max(60 * 1000, Math.ceil(spanMs / Math.max(2, maxPoints)));
const segments: GroundPoint[][] = [];
let current: GroundPoint[] = [];
let previousLon: number | undefined;
for (let timeMs = range.fromMs; timeMs <= range.toMs; timeMs += stepMs) {
const point = subPoint(orbit, timeMs);
if (previousLon !== undefined && Math.abs(point.lon - previousLon) > 180) {
if (current.length > 1) {
segments.push(current);
}
current = [];
}
current.push(point);
previousLon = point.lon;
}
const endPoint = subPoint(orbit, range.toMs);
if (current.length === 0 || current[current.length - 1].lat !== endPoint.lat || current[current.length - 1].lon !== endPoint.lon) {
current.push(endPoint);
}
if (current.length > 1) {
segments.push(current);
}
return segments;
}
export function greatCircleDistanceDeg(first: GroundPoint, second: GroundPoint): number {
const firstLat = first.lat * DEG;
const secondLat = second.lat * DEG;
const deltaLon = (second.lon - first.lon) * DEG;
const cosine =
Math.sin(firstLat) * Math.sin(secondLat) +
Math.cos(firstLat) * Math.cos(secondLat) * Math.cos(deltaLon);
return Math.acos(Math.max(-1, Math.min(1, cosine))) * RAD;
}
export function accessAngleDeg(altitudeKm: number, minElevDeg: number): number {
const minElevRad = minElevDeg * DEG;
const ratio = EARTH_RADIUS_KM / (EARTH_RADIUS_KM + altitudeKm);
return (Math.acos(ratio * Math.cos(minElevRad)) - minElevRad) * RAD;
}
function scanWindows(
orbit: PassOrbit,
target: GroundPoint,
range: PassRange,
thresholdDeg: number,
stepMs: number
): PassWindow[] {
if (range.toMs <= range.fromMs) {
return [];
}
const windows: PassWindow[] = [];
let inside = false;
let startMs = range.fromMs;
let bestMargin = 0;
for (let timeMs = range.fromMs; timeMs <= range.toMs; timeMs += stepMs) {
const distance = greatCircleDistanceDeg(subPoint(orbit, timeMs), target);
const visible = distance <= thresholdDeg;
if (visible && !inside) {
inside = true;
startMs = timeMs;
bestMargin = thresholdDeg - distance;
} else if (visible) {
bestMargin = Math.max(bestMargin, thresholdDeg - distance);
} else if (inside) {
windows.push(toWindow(startMs, timeMs, bestMargin, thresholdDeg));
inside = false;
}
}
if (inside) {
windows.push(toWindow(startMs, range.toMs, bestMargin, thresholdDeg));
}
return windows.filter((window) => window.endMs - window.startMs >= MIN_WINDOW_MS);
}
function toWindow(startMs: number, endMs: number, bestMargin: number, thresholdDeg: number): PassWindow {
return {
startMs,
endMs,
quality: Math.max(0, Math.min(1, bestMargin / thresholdDeg))
};
}
function orbitParams(orbit: PassOrbit): {
altitudeKm: number;
periodSeconds: number;
inclinationRad: number;
nodeRad: number;
phaseRad: number;
} {
const altitudeKm = orbit.altitudeKm ?? DEFAULT_ALTITUDE_KM;
const semiMajorAxis = EARTH_RADIUS_KM + altitudeKm;
const periodSeconds = 2 * Math.PI * Math.sqrt((semiMajorAxis * semiMajorAxis * semiMajorAxis) / EARTH_MU_KM3_S2);
const key = orbit.spacecraftId ?? "default-spacecraft";
return {
altitudeKm,
periodSeconds,
inclinationRad: (97.4 + hash01(key, 7) * 1.6) * DEG,
nodeRad: hash01(key, 3) * 360 * DEG,
phaseRad: hash01(key, 11) * 2 * Math.PI
};
}
function hash01(value: string, salt: number): number {
let hash = 2166136261 ^ salt;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return ((hash >>> 0) % 100000) / 100000;
}
@@ -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,72 @@
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"]);
});
it("matches polygons whose longitudes are in a different 360° window (western hemisphere)", () => {
// Контур в долготах 0..360: западное полушарие ~234° вместо −126°.
const western: MapPolygon = {
id: "western",
name: "Western survey route",
kind: "planWork",
layer: "planWorks",
zIndex: 1,
points: [
{ lon: 233, lat: 48 },
{ lon: 235, lat: 48 },
{ lon: 235, lat: 50 },
{ lon: 233, lat: 50 }
]
};
const index = createPolygonSpatialIndex([western]);
// Видовой bbox в нормализованных долготах [−128, −124] — те же координаты, что и контур, со сдвигом −360°.
expect(
queryPolygonsByBBox(index, { minLon: -128, minLat: 47, maxLon: -124, maxLat: 51 }).map((polygon) => polygon.id)
).toEqual(["western"]);
});
});
@@ -0,0 +1,98 @@
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 hitTestAllPolygons(index: PolygonSpatialIndex, point: GeoPoint): MapPolygon[] {
const hits: MapPolygon[] = [];
for (const entry of index.entries) {
const inBbox =
bboxContainsPoint(entry.bbox, point) ||
bboxContainsPoint(entry.bbox, { lon: point.lon + 360, lat: point.lat }) ||
bboxContainsPoint(entry.bbox, { lon: point.lon - 360, lat: point.lat });
if (!inBbox) continue;
const normalized = unwrapLon(entry.polygon.points);
const hit =
pointInPolygon(point, normalized) ||
pointInPolygon({ lon: point.lon + 360, lat: point.lat }, normalized) ||
pointInPolygon({ lon: point.lon - 360, lat: point.lat }, normalized);
if (hit) hits.push(entry.polygon);
}
return hits.sort((a, b) => b.zIndex - a.zIndex);
}
export function hitTestPolygons(index: PolygonSpatialIndex, point: GeoPoint): MapPolygon | undefined {
let selected: MapPolygon | undefined;
for (const entry of index.entries) {
// Check bbox allowing ±360° shifts so antimeridian-crossing polygons are not skipped.
const inBbox =
bboxContainsPoint(entry.bbox, point) ||
bboxContainsPoint(entry.bbox, { lon: point.lon + 360, lat: point.lat }) ||
bboxContainsPoint(entry.bbox, { lon: point.lon - 360, lat: point.lat });
if (!inBbox) continue;
// Normalize polygon lons to be continuous (same idea as unwrapX for screen coords).
const normalized = unwrapLon(entry.polygon.points);
const hit =
pointInPolygon(point, normalized) ||
pointInPolygon({ lon: point.lon + 360, lat: point.lat }, normalized) ||
pointInPolygon({ lon: point.lon - 360, lat: point.lat }, normalized);
if (hit && (!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.minLat <= bbox.maxLat && entry.bbox.maxLat >= bbox.minLat)
// Долготу сверяем с учётом ±360°: контуры из бэкенда могут приходить в долготах 0..360
// (западное полушарие как ~234° вместо −126°). Без этого они выпадают из набора отрисовки,
// хотя остаются кликабельными (hit-test уже учитывает сдвиги), — контур «пропадает» на карте.
.filter((entry) =>
[0, 360, -360].some(
(shift) => entry.bbox.minLon + shift <= bbox.maxLon && entry.bbox.maxLon + shift >= bbox.minLon
)
)
.map((entry) => entry.polygon);
}
function unwrapLon(points: GeoPoint[]): GeoPoint[] {
if (points.length === 0) return points;
const out: GeoPoint[] = [points[0]];
let prevLon = points[0].lon;
for (let i = 1; i < points.length; i++) {
let lon = points[i].lon;
while (lon - prevLon > 180) lon -= 360;
while (prevLon - lon > 180) lon += 360;
out.push({ ...points[i], lon });
prevLon = lon;
}
return out;
}
@@ -0,0 +1,54 @@
import type { GeoPoint } from "../model/mapTypes";
export function parseWktPolygon(wkt: string): GeoPoint[][] {
const upper = wkt.trim().toUpperCase();
if (upper.startsWith("MULTIPOLYGON")) {
return parseMultiPolygon(wkt);
}
if (upper.startsWith("POLYGON")) {
const ring = parseSinglePolygon(wkt);
return ring ? [ring] : [];
}
return [];
}
function parseSinglePolygon(wkt: string): GeoPoint[] | null {
const match = wkt.match(/POLYGON\s*\(\s*\(([^)]+)\)/i);
if (!match) return null;
return parseRing(match[1]);
}
function parseMultiPolygon(wkt: string): GeoPoint[][] {
const rings: GeoPoint[][] = [];
const inner = wkt.replace(/MULTIPOLYGON\s*\(/i, "").replace(/\)\s*$/, "");
const polygonStrings = inner.match(/\(\s*\([^)]+\)\s*\)/g);
if (!polygonStrings) return rings;
for (const polygonStr of polygonStrings) {
const ringMatch = polygonStr.match(/\(\s*\(([^)]+)\)/);
if (ringMatch) {
const ring = parseRing(ringMatch[1]);
if (ring.length >= 3) {
rings.push(ring);
}
}
}
return rings;
}
function parseRing(coordStr: string): GeoPoint[] {
return coordStr
.split(",")
.map((pair) => {
const parts = pair.trim().split(/\s+/);
const lon = parseFloat(parts[0]);
const lat = parseFloat(parts[1]);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
return { lon, lat };
})
.filter((point): point is GeoPoint => point !== null);
}
@@ -0,0 +1,45 @@
export type Tgu2DMapLayerKey =
| "tracks"
| "swath"
| "planWorks"
| "complexPlan"
| "stations"
| "spacecraftMarkers"
| "requests";
export type Tgu2DMapLayersState = Record<Tgu2DMapLayerKey, boolean>;
export type Tgu2DMapLayerDefinition = {
key: Tgu2DMapLayerKey;
label: string;
description: string;
};
export const TGU_2D_MAP_LAYERS: Tgu2DMapLayerDefinition[] = [
{
key: "planWorks",
label: "Работы плана",
description: "Маршруты съёмки из выбранного плана."
},
{
key: "stations",
label: "Станции",
description: "Наземные станции приёма."
},
{
key: "requests",
label: "Заявки на съёмку",
description: "Полигоны заявок из pcp-request-service."
}
];
export const DEFAULT_TGU_2D_MAP_LAYERS: Tgu2DMapLayersState = {
tracks: false,
swath: false,
planWorks: true,
complexPlan: false,
stations: true,
spacecraftMarkers: false,
requests: true
};
@@ -0,0 +1,40 @@
import type { TguPlanStatus } from "../../../model/tguTypes";
export type Tgu2DMapSelection =
| {
type: "polygon";
id: string;
name: string;
layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan";
planId?: string;
spacecraftId?: string;
requestId?: string;
surveyType?: string;
status?: TguPlanStatus;
/** id режима плана (включения) для polygon-ов слоя planWorks — связь с работой таймлайна. */
modeId?: number;
}
| {
type: "line";
id: string;
name: string;
layer: "tracks";
/** Вид линии: трасса КА или сеанс сброса. */
kind?: "track" | "drop";
/** id режима плана (drop) — связь с работой таймлайна. */
modeId?: number;
}
| {
type: "marker";
id: string;
name: string;
layer: "stations" | "spacecraftMarkers";
spacecraftId?: string;
stationNumber?: number;
lat?: number;
lon?: number;
altitude?: number;
elevationMin?: number;
elevationMax?: number;
};
@@ -0,0 +1,72 @@
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" | "request" | "complexPlan";
layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan";
points: GeoPoint[];
status?: TguPlanStatus;
planId?: string;
spacecraftId?: string;
requestId?: string;
surveyType?: string;
/** id режима плана (survey), к которому относится контур — для связи «включение ↔ объект на карте». */
modeId?: number;
zIndex: number;
};
export type MapLine = {
id: string;
/** Человекочитаемое имя (для подсказок и выбора объекта под курсором), напр. «Сброс · виток N». */
name?: string;
layer: "tracks";
points: GeoPoint[];
spacecraftId?: string;
passIndex?: number;
zIndex: number;
/** Вид линии: трасса КА (по умолчанию) или сеанс сброса — яркая толстая линия на трассе. */
kind?: "track" | "drop";
/** id режима плана (survey/drop), к которому относится объект — для связи «включение ↔ объект на карте». */
modeId?: number;
};
export type MapMarker = {
id: string;
layer: "stations" | "spacecraftMarkers";
point: GeoPoint;
label: string;
zIndex: number;
stationNumber?: number;
altitude?: number;
elevationMin?: number;
elevationMax?: number;
};
export type Tgu2DMapScene = {
polygons: MapPolygon[];
lines: MapLine[];
markers: MapMarker[];
};
@@ -0,0 +1,61 @@
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";
import { parseLocal } from "../../utils/localTime";
function plan(startTime: string, endTime: string): TguPlanUi {
return {
planId: "plan-1",
spacecraftId: "56756",
startTime,
endTime,
kppId: "KPP-1",
status: "PLANNED",
startMs: parseLocal(startTime),
endMs: parseLocal(endTime)
};
}
describe("tgu2DMapInterval", () => {
it("marks common map interval longer than 7 days as too large", () => {
const state = getTgu2DMapIntervalState({
fromMs: parseLocal("2026-05-29T00:00:00"),
toMs: parseLocal("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: parseLocal("2026-05-29T00:00:00"),
toMs: parseLocal("2026-06-10T00:00:00")
},
selectedPlan
);
expect(state).toEqual({
range: {
fromMs: selectedPlan.startMs,
toMs: selectedPlan.endMs
},
tooLarge: false
});
});
it("enables plan, stations and requests layers by default", () => {
expect(DEFAULT_TGU_2D_MAP_LAYERS).toEqual({
tracks: false,
swath: false,
planWorks: true,
complexPlan: false,
stations: true,
spacecraftMarkers: false,
requests: 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
};
}