pcp-tgu: редактор/карта — выбор объекта (линии/сбросы) и проброс текста ошибок

- Карта 2D: клик по линии (трасса КА / сеанс сброса) выбирает объект — общий
  инспектор с таймлайном; MapLine.name, тип selection "line" (kind track/drop),
  связь с работой через modeId; spatialIndex учитывает линии.
- Редактор (TguEditorTab/EditorPanels): единый WorkInspector для клика по карте
  и по таймлайну, подсветка соответствующего включения, стили tgu-editor.css.
- config-repo: server.error.include-message=always для pcp-tgu-service и
  pcp-mission-planing-service — UI показывает причину 4xx (reason/exception),
  а не только HTTP-фразу.
This commit is contained in:
Дмитрий Соловьев
2026-06-04 13:08:08 +03:00
parent d9f8260120
commit 9e5460d646
10 changed files with 542 additions and 35 deletions
@@ -24,6 +24,12 @@ logging:
level:
root: INFO
# Возвращаем текст причины ошибки клиенту (reason/exception message в поле `message` тела ответа),
# иначе UI видит только HTTP-фразу и не понимает, почему сохранение съёмки/сброса отклонено.
server:
error:
include-message: always
management:
endpoints:
web:
+7
View File
@@ -25,6 +25,13 @@ management:
exposure:
include: health,info,metrics,prometheus
# Возвращаем текст причины ошибки клиенту (reason из ResponseStatusException попадает в поле
# `message` тела ответа). Без этого UI видит только HTTP-фразу «Unprocessable Content» и не
# понимает, почему действие отклонено (напр. «план заложен — используйте fork-forward»).
server:
error:
include-message: always
springdoc:
swagger-ui:
path: /swagger/ui
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation";
import { statusStyle } from "../tgu-planning/tguStatus";
import { formatLocalDateTime, parseLocal, toLocalIso } from "../../utils/localTime";
@@ -63,6 +64,8 @@ type ShootPanelProps = {
routeLoading?: boolean;
routeError?: string;
route?: SurveyRoute;
/** Почему нельзя добавить включение в этот план (заложен/стартовал) — гасит «Добавить». */
addBlockedReason?: string;
onRouteStartChange: (startMs: number) => void;
onRouteEndChange: (endMs: number) => void;
/** Сдвиг окна целиком вперёд/назад (мс), длительность сохраняется. */
@@ -114,6 +117,8 @@ type DropPanelProps = {
onToggleSurvey: (id: number) => void;
saving?: boolean;
saveError?: string;
/** Почему нельзя добавить включение в этот план (заложен/стартовал) — гасит «Добавить». */
addBlockedReason?: string;
onAdd: () => void;
onClose: () => void;
};
@@ -287,6 +292,7 @@ export function ShootPanel({
routeLoading,
routeError,
route,
addBlockedReason,
onRouteStartChange,
onRouteEndChange,
onRouteShift,
@@ -436,7 +442,14 @@ export function ShootPanel({
</section>
)}
<button className="tgu-button tgu-button--primary" type="button" onClick={onAdd} disabled={!target || !selectedPass || !modeId}>
{addBlockedReason && <div className="tgu-alert tgu-alert--warning tgu-alert--compact">{addBlockedReason}</div>}
<button
className="tgu-button tgu-button--primary"
type="button"
onClick={onAdd}
disabled={!target || !selectedPass || !modeId || !!addBlockedReason}
>
+ Добавить съёмку в план
</button>
</div>
@@ -499,10 +512,11 @@ export function DropPanel({
onToggleSurvey,
saving,
saveError,
addBlockedReason,
onAdd,
onClose
}: DropPanelProps) {
const canAdd = !!selectedZone && selectedSurveyIds.size > 0 && !saving;
const canAdd = !!selectedZone && selectedSurveyIds.size > 0 && !saving && !addBlockedReason;
return (
<div className="tgu-editor-panel">
@@ -572,6 +586,7 @@ export function DropPanel({
</section>
)}
{addBlockedReason && <div className="tgu-alert tgu-alert--warning tgu-alert--compact">{addBlockedReason}</div>}
{saveError && <div className="tgu-alert tgu-alert--danger">{saveError}</div>}
<button className="tgu-button tgu-button--primary" type="button" onClick={onAdd} disabled={!canAdd}>
@@ -665,6 +680,70 @@ export function MapInfoPanel({ selection, requestItems, onClose }: MapInfoPanelP
);
}
type MapPickPanelProps = {
/** Объекты под точкой клика на карте. */
hits: Tgu2DMapSelection[];
/** id раскрытого объекта (показывается его детальная карточка). */
activeId?: string;
onToggle: (selection: Tgu2DMapSelection) => void;
onClose: () => void;
/** Детальная карточка объекта (инспектор включения или карточка объекта карты). */
renderDetail: (selection: Tgu2DMapSelection) => ReactNode;
};
/**
* Список объектов под точкой клика на карте: оператор раскрывает нужный, и под ним показывается
* его детальная карточка (renderDetail). Заменяет всплывающее меню выбора у карты.
*/
export function MapPickPanel({ hits, activeId, onToggle, onClose, renderDetail }: MapPickPanelProps) {
return (
<div className="tgu-editor-panel">
<PanelHead title={`Объекты под курсором · ${hits.length}`} onClose={onClose} />
<div className="tgu-editor-panel__hint">Кликните объект в списке, чтобы раскрыть его данные.</div>
<div className="tgu-editor-pick">
{hits.map((hit, index) => {
const open = hit.id === activeId;
return (
<div className={`tgu-editor-pick__item${open ? " is-open" : ""}`} key={`${hit.id}:${index}`}>
<button type="button" className="tgu-editor-pick__row" onClick={() => onToggle(hit)}>
<i className={`tgu-editor-pick__swatch tgu-editor-pick__swatch--${selectionSwatch(hit)}`} />
<span className="tgu-editor-pick__title">{hit.name || selectionSubtitle(hit)}</span>
<small className="tgu-editor-pick__sub">{selectionSubtitle(hit)}</small>
<span className="tgu-editor-pick__chevron" aria-hidden="true">{open ? "▾" : "▸"}</span>
</button>
{open && <div className="tgu-editor-pick__detail">{renderDetail(hit)}</div>}
</div>
);
})}
</div>
</div>
);
}
/** Класс цветовой метки объекта в списке выбора. */
function selectionSwatch(selection: Tgu2DMapSelection): string {
if (selection.type === "marker") return "station";
if (selection.type === "line") return "drop";
return selection.layer;
}
/** Подпись типа объекта в списке выбора. */
function selectionSubtitle(selection: Tgu2DMapSelection): string {
if (selection.type === "marker") {
return selection.layer === "stations" ? "Станция" : "Маркер КА";
}
if (selection.type === "line") {
return selection.kind === "drop" ? "Сброс на НКПОР" : "Трасса КА";
}
switch (selection.layer) {
case "requests": return "Заявка на съёмку";
case "planWorks": return "Маршрут съёмки";
case "swath": return "Полоса обзора";
case "tracks": return "Трасса КА";
default: return "Объект карты";
}
}
function mapInfoTitle(selection: Tgu2DMapSelection): string {
if (selection.type === "marker") {
return selection.layer === "stations" ? "Станция" : "Маркер КА";
@@ -37,7 +37,7 @@ import {
} from "./editorDraft";
import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts";
import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation";
import { DropPanel, type DropCandidateSurvey, MapInfoPanel, ShootPanel, WorkInspector } from "./EditorPanels";
import { DropPanel, type DropCandidateSurvey, MapInfoPanel, MapPickPanel, ShootPanel, WorkInspector } from "./EditorPanels";
import { MapPassSelector } from "./MapPassSelector";
import { EditorRail } from "./EditorRail";
import { EditorTimeline } from "./EditorTimeline";
@@ -55,7 +55,7 @@ import type {
TguEditorTabProps
} from "./model/editorTypes";
type EditorRightMode = "none" | "inspect" | "shoot" | "downlink" | "mapInfo";
type EditorRightMode = "none" | "inspect" | "shoot" | "downlink" | "mapInfo" | "mapPick";
const HOUR_MS = 60 * 60 * 1000;
/** Фиксированная длительность окна съёмки, строится вокруг момента видимости. */
@@ -107,6 +107,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const [dropSaving, setDropSaving] = useState(false);
const [dropError, setDropError] = useState<string>();
const [mapInfoSelection, setMapInfoSelection] = useState<Tgu2DMapSelection>();
// Несколько объектов под точкой клика на карте: список для правой панели + раскрытый элемент.
const [mapPickHits, setMapPickHits] = useState<Tgu2DMapSelection[]>([]);
const [mapPickActiveId, setMapPickActiveId] = useState<string>();
const [stationDtos, setStationDtos] = useState<StationDto[]>([]);
const [serviceConflicts, setServiceConflicts] = useState<EditorConflictMap>({});
const [checking, setChecking] = useState(false);
@@ -240,6 +243,18 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const planSurveyCount = useMemo(() => planModes.filter((mode) => mode.type === "SURVEY").length, [planModes]);
const planDownlinkCount = useMemo(() => planModes.filter((mode) => mode.type === "DROP").length, [planModes]);
// Первая правка живого плана клонирует его в DRAFT (replace). Бэк это запрещает (422) для
// заложенного (LAID_IN) или уже стартовавшего плана — нужен fork-forward со вкладки «Планы».
// Предупреждаем заранее и гасим «Добавить», чтобы не доводить до ошибки сохранения. Если
// черновик уже создан (draftPlanId) или сам план — DRAFT, правки идут напрямую и блок не нужен.
const inPlaceEditBlockedReason = useMemo<string | undefined>(() => {
if (!selectedPlan || draftPlanId || selectedPlan.status === "DRAFT") return undefined;
const blocked = selectedPlan.status === "LAID_IN" || selectedPlan.startMs <= Date.now();
return blocked
? "План заложен или уже стартовал — править его на месте нельзя. Создайте исправленный план через fork-forward на вкладке «Планы»."
: undefined;
}, [selectedPlan, draftPlanId]);
useEffect(() => {
setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] });
setWindow(defaultEditorWindow(selectedPlan, appliedRange));
@@ -254,6 +269,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setDropError(undefined);
setRvaZones([]);
setMapInfoSelection(undefined);
setMapPickHits([]);
setMapPickActiveId(undefined);
setServiceConflicts({});
setNotice(undefined);
setApplyError(undefined);
@@ -747,20 +764,81 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
});
};
// Включение (съёмка/сброс), соответствующее объекту карты: контур маршрута (planWorks) или
// линия сброса (drop) → работа таймлайна по modeId ↔ planModeId. Прочие объекты → undefined.
const workForSelection = useCallback(
(selection: Tgu2DMapSelection): EditorWork | undefined => {
const modeId =
selection.type === "polygon" && selection.layer === "planWorks"
? selection.modeId
: selection.type === "line" && selection.kind === "drop"
? selection.modeId
: undefined;
if (modeId == null) return undefined;
return displayWorks.find((w) => w.planModeId === modeId);
},
[displayWorks]
);
const clearMapPick = useCallback(() => {
setMapPickHits([]);
setMapPickActiveId(undefined);
}, []);
const handleMapObjectSelect = useCallback((selection?: Tgu2DMapSelection) => {
if (rightMode === "shoot") return;
clearMapPick();
if (!selection) {
if (rightMode === "mapInfo") {
if (rightMode === "mapInfo" || rightMode === "mapPick") {
setRightMode("none");
setMapInfoSelection(undefined);
}
return;
}
// Клик по маршруту съёмки (контур) или сбросу (линия) на карте = клик по включению на
// таймлайне: открываем тот же инспектор включения (одинаковая информация в обе стороны),
// а не краткую карточку объекта.
const work = workForSelection(selection);
if (work) {
setMapInfoSelection(undefined);
setSelectedWorkId(work.id);
setRightMode("inspect");
setNotice(undefined);
return;
}
setMapInfoSelection(selection);
setRightMode("mapInfo");
setSelectedWorkId(undefined);
}, [rightMode, clearMapPick, workForSelection]);
// Несколько объектов под точкой клика — показываем их списком в правой панели (оператор
// раскрывает нужный, чтобы увидеть его данные). Здесь только наполняем список.
const handleObjectsAtPoint = useCallback((selections: Tgu2DMapSelection[]) => {
if (rightMode === "shoot") return;
setMapPickHits(selections);
setMapPickActiveId(undefined);
setRightMode("mapPick");
setSelectedWorkId(undefined);
setMapInfoSelection(undefined);
setNotice(undefined);
setMapFocus((prev) => ({ ...prev, objectId: undefined }));
}, [rightMode]);
// Раскрытие/сворачивание элемента списка: подсвечиваем объект на карте (без перецентрирования)
// и выделяем соответствующее включение на таймлайне.
const togglePickItem = useCallback((selection: Tgu2DMapSelection) => {
setMapPickActiveId((current) => {
if (current === selection.id) {
setSelectedWorkId(undefined);
setMapFocus((prev) => ({ ...prev, objectId: undefined }));
return undefined;
}
setSelectedWorkId(workForSelection(selection)?.id);
setMapFocus((prev) => ({ ...prev, objectId: selection.id }));
return selection.id;
});
}, [workForSelection]);
const handleResizerDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
const startY = e.clientY;
@@ -782,6 +860,41 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
document.addEventListener("mouseup", onUp);
}, [mapRatio]);
// Инспектор включения — общий и для одиночного выбора, и для раскрытого элемента в списке выбора.
const renderWorkInspector = (work: EditorWork, onClose: () => void) => (
<WorkInspector
work={work}
modes={modes}
stations={stations}
spacecraftLabel={activeLabel}
surveyModeLabel={surveyModeLabel}
conflicts={conflicts[work.id] ?? []}
readOnly={work.origin === "plan"}
onUpdate={onChangeWork}
onDelete={(workId) => { void handleDeleteWork(workId); }}
onDuplicate={(workId) => {
const id = nextWorkId(idCounterRef);
changeDraft((current) => duplicateWork(current, workId, id));
setSelectedWorkId(id);
}}
onClose={onClose}
/>
);
// Детальная карточка объекта из списка выбора: включение (съёмка/сброс) → инспектор,
// прочее (заявка/станция) → карточка объекта карты.
const renderPickDetail = (selection: Tgu2DMapSelection) => {
const work = workForSelection(selection);
if (work) return renderWorkInspector(work, () => togglePickItem(selection));
return (
<MapInfoPanel
selection={selection}
requestItems={requestItems}
onClose={() => togglePickItem(selection)}
/>
);
};
if (!selectedSpacecraftId && !selectedPlan) {
return (
<main className="tgu-editor-workspace">
@@ -810,6 +923,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setRightMode(mode);
setSelectedWorkId(undefined);
setMapInfoSelection(undefined);
clearMapPick();
setNotice(undefined);
}}
onUndo={() => setHistory((current) => undo(current))}
@@ -868,6 +982,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
scene={mapScene}
layers={EDITOR_MAP_LAYERS}
redrawToken={0}
enableObjectChooser
onObjectsAtPoint={handleObjectsAtPoint}
pickMode={rightMode === "shoot"}
pickTarget={target ?? undefined}
onPickPoint={(point) => {
@@ -919,28 +1035,11 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
{rightMode !== "none" && (
<aside className="tgu-editor-side-panel">
{rightMode === "inspect" && selectedWork && (
<WorkInspector
work={selectedWork}
modes={modes}
stations={stations}
spacecraftLabel={activeLabel}
surveyModeLabel={surveyModeLabel}
conflicts={conflicts[selectedWork.id] ?? []}
readOnly={selectedWork.origin === "plan"}
onUpdate={onChangeWork}
onDelete={(workId) => { void handleDeleteWork(workId); }}
onDuplicate={(workId) => {
const id = nextWorkId(idCounterRef);
changeDraft((current) => duplicateWork(current, workId, id));
setSelectedWorkId(id);
}}
onClose={() => {
{rightMode === "inspect" && selectedWork &&
renderWorkInspector(selectedWork, () => {
setSelectedWorkId(undefined);
setRightMode("none");
}}
/>
)}
})}
{rightMode === "inspect" && !selectedWork && <div className="tgu-empty tgu-empty--compact">Включение не выбрано.</div>}
{rightMode === "shoot" && (
<ShootPanel
@@ -960,6 +1059,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
routeLoading={routeLoading}
routeError={routeError}
route={surveyRoute}
addBlockedReason={inPlaceEditBlockedReason}
onRouteStartChange={(startMs) => setRouteWindow((current) => (current ? { ...current, startMs } : current))}
onRouteEndChange={(endMs) => setRouteWindow((current) => (current ? { ...current, endMs } : current))}
onRouteShift={(deltaMs) =>
@@ -1024,6 +1124,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
}
saving={dropSaving}
saveError={dropError}
addBlockedReason={inPlaceEditBlockedReason}
onAdd={addDropWork}
onClose={() => {
setRightMode("none");
@@ -1043,6 +1144,20 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
}}
/>
)}
{rightMode === "mapPick" && mapPickHits.length > 0 && (
<MapPickPanel
hits={mapPickHits}
activeId={mapPickActiveId}
onToggle={togglePickItem}
onClose={() => {
setRightMode("none");
clearMapPick();
setSelectedWorkId(undefined);
setMapFocus((prev) => ({ ...prev, objectId: undefined }));
}}
renderDetail={renderPickDetail}
/>
)}
</aside>
)}
</div>
@@ -1216,6 +1331,7 @@ function buildEditorMapScene(
if (pts.length < 2) continue;
lines.push({
id: `drop:${mode.id}`,
name: `Сброс · виток ${mode.revolution}`,
layer: "tracks",
kind: "drop",
modeId: mode.id,
@@ -6,10 +6,10 @@ import { drawStations } from "./canvas/drawStations";
import { drawSwath } from "./canvas/drawSwath";
import { drawTiles } from "./canvas/drawTiles";
import { drawTracks } from "./canvas/drawTracks";
import { createMapProjection } from "./geometry/mapProjection";
import { createMapProjection, normalizeLon } from "./geometry/mapProjection";
import { createPolygonSpatialIndex, hitTestAllPolygons, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex";
import type { Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { GeoPoint, MapMarker, MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes";
import type { GeoPoint, MapLine, MapMarker, MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes";
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
type Tgu2DMapViewProps = {
@@ -25,6 +25,14 @@ type Tgu2DMapViewProps = {
/** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */
focusPoint?: GeoPoint;
focusToken?: number;
/**
* Включает выбор объекта под курсором: если в точке клика лежит несколько объектов
* (например, заявка и маршрут съёмки), их список отдаётся через onObjectsAtPoint вместо
* «молчаливого» выбора верхнего по z-индексу. Также делает кликабельными линии сброса.
*/
enableObjectChooser?: boolean;
/** Несколько объектов под точкой клика — список передаётся наверх (выбор делает оператор справа). */
onObjectsAtPoint?: (selections: Tgu2DMapSelection[]) => void;
pickMode?: boolean;
onPickPoint?: (point: { lat: number; lon: number }) => void;
targets?: Tgu2DMapTarget[];
@@ -76,6 +84,8 @@ export function Tgu2DMapView({
selectedObjectId,
focusPoint,
focusToken,
enableObjectChooser = false,
onObjectsAtPoint,
pickMode = false,
onPickPoint,
targets = [],
@@ -98,7 +108,10 @@ export function Tgu2DMapView({
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]
@@ -210,6 +223,7 @@ export function Tgu2DMapView({
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));
@@ -254,6 +268,7 @@ export function Tgu2DMapView({
}
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) {
@@ -280,6 +295,56 @@ export function Tgu2DMapView({
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);
@@ -293,6 +358,11 @@ export function Tgu2DMapView({
return;
}
if (enableObjectChooser) {
handleEditorClick(screenPoint);
return;
}
const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
if (marker) {
selectedMarkerRef.current = marker;
@@ -343,7 +413,8 @@ export function Tgu2DMapView({
spacecraftId: polygon.spacecraftId,
requestId: polygon.requestId,
surveyType: polygon.surveyType,
status: polygon.status
status: polygon.status,
modeId: polygon.modeId
});
};
@@ -374,6 +445,8 @@ export function Tgu2DMapView({
() => (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]
@@ -395,6 +468,7 @@ export function Tgu2DMapView({
hoverRef.current = undefined;
hoverMarkerRef.current = undefined;
setTooltip(undefined);
setCursorGeo(undefined);
}}
onWheel={onWheel}
>
@@ -422,8 +496,9 @@ export function Tgu2DMapView({
);
});
})}
{externalSelectedLine && externalSelectedLine.points.length >= 2 && (() => {
const pts = unwrapX(externalSelectedLine.points.map((point) => projection.project(point)), projection.worldSize);
{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;
@@ -432,14 +507,14 @@ export function Tgu2DMapView({
if (!visible) return [];
return (
<polyline
key={`line:${externalSelectedLine.id}:${dx}`}
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";
@@ -496,6 +571,7 @@ export function Tgu2DMapView({
<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>
@@ -506,6 +582,15 @@ export function Tgu2DMapView({
);
}
/** Подпись координат под курсором: широта/долгота с полушариями. */
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":
@@ -573,3 +658,99 @@ function uniquePolygons(polygons: Array<MapPolygon | undefined>): MapPolygon[] {
}
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));
}
@@ -45,5 +45,28 @@ describe("polygon spatial index", () => {
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"]);
});
});
@@ -70,8 +70,15 @@ export function hitTestPolygons(index: PolygonSpatialIndex, point: GeoPoint): Ma
export function queryPolygonsByBBox(index: PolygonSpatialIndex, bbox: BBox): MapPolygon[] {
return index.entries
.filter((entry) => entry.bbox.minLon <= bbox.maxLon && entry.bbox.maxLon >= bbox.minLon)
.filter((entry) => entry.bbox.minLat <= bbox.maxLat && entry.bbox.maxLat >= bbox.minLat)
// Долготу сверяем с учётом ±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);
}
@@ -11,6 +11,18 @@ export type Tgu2DMapSelection =
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";
@@ -39,6 +39,8 @@ export type MapPolygon = {
export type MapLine = {
id: string;
/** Человекочитаемое имя (для подсказок и выбора объекта под курсором), напр. «Сброс · виток N». */
name?: string;
layer: "tracks";
points: GeoPoint[];
spacecraftId?: string;
@@ -1094,3 +1094,77 @@
max-height: 24rem;
}
}
/* Список объектов под точкой клика на карте (раскрывающиеся карточки). */
.tgu-editor-pick {
display: grid;
gap: 0.35rem;
}
.tgu-editor-pick__row {
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-rows: auto auto;
column-gap: 0.5rem;
align-items: center;
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.45rem 0.55rem;
background: var(--bg-panel-2);
color: var(--text);
text-align: left;
cursor: pointer;
}
.tgu-editor-pick__row:hover {
border-color: rgba(104, 195, 189, 0.45);
}
.tgu-editor-pick__item.is-open .tgu-editor-pick__row {
border-color: rgba(104, 195, 189, 0.58);
background: rgba(104, 195, 189, 0.14);
}
.tgu-editor-pick__swatch {
grid-row: 1 / span 2;
width: 10px;
height: 10px;
border-radius: 2px;
background: var(--text-dim);
}
.tgu-editor-pick__swatch--requests { background: #ffa040; }
.tgu-editor-pick__swatch--planWorks { background: #4fd2b0; }
.tgu-editor-pick__swatch--drop { background: #ffcf3f; }
.tgu-editor-pick__swatch--station { background: #d5e2e2; }
.tgu-editor-pick__swatch--swath { background: #6fb7ff; }
.tgu-editor-pick__title {
font-size: 0.74rem;
font-weight: 600;
}
.tgu-editor-pick__sub {
grid-column: 2;
color: var(--text-dim);
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.tgu-editor-pick__chevron {
grid-row: 1 / span 2;
color: var(--text-dim);
font-size: 0.7rem;
}
/* Вложенная карточка детали внутри раскрытого элемента — без двойной рамки/отступов. */
.tgu-editor-pick__detail {
padding: 0.35rem 0 0.1rem;
}
.tgu-editor-pick__detail .tgu-editor-panel {
border: 0;
padding: 0;
}