Новый сервис pcp-tgu-ui-service: ТГУ-фронт на headless-auth nstart + платформенный CI/деплой
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,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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user