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: level:
root: INFO root: INFO
# Возвращаем текст причины ошибки клиенту (reason/exception message в поле `message` тела ответа),
# иначе UI видит только HTTP-фразу и не понимает, почему сохранение съёмки/сброса отклонено.
server:
error:
include-message: always
management: management:
endpoints: endpoints:
web: web:
+7
View File
@@ -25,6 +25,13 @@ management:
exposure: exposure:
include: health,info,metrics,prometheus include: health,info,metrics,prometheus
# Возвращаем текст причины ошибки клиенту (reason из ResponseStatusException попадает в поле
# `message` тела ответа). Без этого UI видит только HTTP-фразу «Unprocessable Content» и не
# понимает, почему действие отклонено (напр. «план заложен — используйте fork-forward»).
server:
error:
include-message: always
springdoc: springdoc:
swagger-ui: swagger-ui:
path: /swagger/ui path: /swagger/ui
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation"; import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation";
import { statusStyle } from "../tgu-planning/tguStatus"; import { statusStyle } from "../tgu-planning/tguStatus";
import { formatLocalDateTime, parseLocal, toLocalIso } from "../../utils/localTime"; import { formatLocalDateTime, parseLocal, toLocalIso } from "../../utils/localTime";
@@ -63,6 +64,8 @@ type ShootPanelProps = {
routeLoading?: boolean; routeLoading?: boolean;
routeError?: string; routeError?: string;
route?: SurveyRoute; route?: SurveyRoute;
/** Почему нельзя добавить включение в этот план (заложен/стартовал) — гасит «Добавить». */
addBlockedReason?: string;
onRouteStartChange: (startMs: number) => void; onRouteStartChange: (startMs: number) => void;
onRouteEndChange: (endMs: number) => void; onRouteEndChange: (endMs: number) => void;
/** Сдвиг окна целиком вперёд/назад (мс), длительность сохраняется. */ /** Сдвиг окна целиком вперёд/назад (мс), длительность сохраняется. */
@@ -114,6 +117,8 @@ type DropPanelProps = {
onToggleSurvey: (id: number) => void; onToggleSurvey: (id: number) => void;
saving?: boolean; saving?: boolean;
saveError?: string; saveError?: string;
/** Почему нельзя добавить включение в этот план (заложен/стартовал) — гасит «Добавить». */
addBlockedReason?: string;
onAdd: () => void; onAdd: () => void;
onClose: () => void; onClose: () => void;
}; };
@@ -287,6 +292,7 @@ export function ShootPanel({
routeLoading, routeLoading,
routeError, routeError,
route, route,
addBlockedReason,
onRouteStartChange, onRouteStartChange,
onRouteEndChange, onRouteEndChange,
onRouteShift, onRouteShift,
@@ -436,7 +442,14 @@ export function ShootPanel({
</section> </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> </button>
</div> </div>
@@ -499,10 +512,11 @@ export function DropPanel({
onToggleSurvey, onToggleSurvey,
saving, saving,
saveError, saveError,
addBlockedReason,
onAdd, onAdd,
onClose onClose
}: DropPanelProps) { }: DropPanelProps) {
const canAdd = !!selectedZone && selectedSurveyIds.size > 0 && !saving; const canAdd = !!selectedZone && selectedSurveyIds.size > 0 && !saving && !addBlockedReason;
return ( return (
<div className="tgu-editor-panel"> <div className="tgu-editor-panel">
@@ -572,6 +586,7 @@ export function DropPanel({
</section> </section>
)} )}
{addBlockedReason && <div className="tgu-alert tgu-alert--warning tgu-alert--compact">{addBlockedReason}</div>}
{saveError && <div className="tgu-alert tgu-alert--danger">{saveError}</div>} {saveError && <div className="tgu-alert tgu-alert--danger">{saveError}</div>}
<button className="tgu-button tgu-button--primary" type="button" onClick={onAdd} disabled={!canAdd}> <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 { function mapInfoTitle(selection: Tgu2DMapSelection): string {
if (selection.type === "marker") { if (selection.type === "marker") {
return selection.layer === "stations" ? "Станция" : "Маркер КА"; return selection.layer === "stations" ? "Станция" : "Маркер КА";
@@ -37,7 +37,7 @@ import {
} from "./editorDraft"; } from "./editorDraft";
import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts"; import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts";
import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation"; 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 { MapPassSelector } from "./MapPassSelector";
import { EditorRail } from "./EditorRail"; import { EditorRail } from "./EditorRail";
import { EditorTimeline } from "./EditorTimeline"; import { EditorTimeline } from "./EditorTimeline";
@@ -55,7 +55,7 @@ import type {
TguEditorTabProps TguEditorTabProps
} from "./model/editorTypes"; } from "./model/editorTypes";
type EditorRightMode = "none" | "inspect" | "shoot" | "downlink" | "mapInfo"; type EditorRightMode = "none" | "inspect" | "shoot" | "downlink" | "mapInfo" | "mapPick";
const HOUR_MS = 60 * 60 * 1000; const HOUR_MS = 60 * 60 * 1000;
/** Фиксированная длительность окна съёмки, строится вокруг момента видимости. */ /** Фиксированная длительность окна съёмки, строится вокруг момента видимости. */
@@ -107,6 +107,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const [dropSaving, setDropSaving] = useState(false); const [dropSaving, setDropSaving] = useState(false);
const [dropError, setDropError] = useState<string>(); const [dropError, setDropError] = useState<string>();
const [mapInfoSelection, setMapInfoSelection] = useState<Tgu2DMapSelection>(); const [mapInfoSelection, setMapInfoSelection] = useState<Tgu2DMapSelection>();
// Несколько объектов под точкой клика на карте: список для правой панели + раскрытый элемент.
const [mapPickHits, setMapPickHits] = useState<Tgu2DMapSelection[]>([]);
const [mapPickActiveId, setMapPickActiveId] = useState<string>();
const [stationDtos, setStationDtos] = useState<StationDto[]>([]); const [stationDtos, setStationDtos] = useState<StationDto[]>([]);
const [serviceConflicts, setServiceConflicts] = useState<EditorConflictMap>({}); const [serviceConflicts, setServiceConflicts] = useState<EditorConflictMap>({});
const [checking, setChecking] = useState(false); 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 planSurveyCount = useMemo(() => planModes.filter((mode) => mode.type === "SURVEY").length, [planModes]);
const planDownlinkCount = useMemo(() => planModes.filter((mode) => mode.type === "DROP").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(() => { useEffect(() => {
setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] }); setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] });
setWindow(defaultEditorWindow(selectedPlan, appliedRange)); setWindow(defaultEditorWindow(selectedPlan, appliedRange));
@@ -254,6 +269,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setDropError(undefined); setDropError(undefined);
setRvaZones([]); setRvaZones([]);
setMapInfoSelection(undefined); setMapInfoSelection(undefined);
setMapPickHits([]);
setMapPickActiveId(undefined);
setServiceConflicts({}); setServiceConflicts({});
setNotice(undefined); setNotice(undefined);
setApplyError(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) => { const handleMapObjectSelect = useCallback((selection?: Tgu2DMapSelection) => {
if (rightMode === "shoot") return; if (rightMode === "shoot") return;
clearMapPick();
if (!selection) { if (!selection) {
if (rightMode === "mapInfo") { if (rightMode === "mapInfo" || rightMode === "mapPick") {
setRightMode("none"); setRightMode("none");
setMapInfoSelection(undefined); setMapInfoSelection(undefined);
} }
return; return;
} }
// Клик по маршруту съёмки (контур) или сбросу (линия) на карте = клик по включению на
// таймлайне: открываем тот же инспектор включения (одинаковая информация в обе стороны),
// а не краткую карточку объекта.
const work = workForSelection(selection);
if (work) {
setMapInfoSelection(undefined);
setSelectedWorkId(work.id);
setRightMode("inspect");
setNotice(undefined);
return;
}
setMapInfoSelection(selection); setMapInfoSelection(selection);
setRightMode("mapInfo"); setRightMode("mapInfo");
setSelectedWorkId(undefined); 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]); }, [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) => { const handleResizerDown = useCallback((e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
const startY = e.clientY; const startY = e.clientY;
@@ -782,6 +860,41 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
document.addEventListener("mouseup", onUp); document.addEventListener("mouseup", onUp);
}, [mapRatio]); }, [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) { if (!selectedSpacecraftId && !selectedPlan) {
return ( return (
<main className="tgu-editor-workspace"> <main className="tgu-editor-workspace">
@@ -810,6 +923,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setRightMode(mode); setRightMode(mode);
setSelectedWorkId(undefined); setSelectedWorkId(undefined);
setMapInfoSelection(undefined); setMapInfoSelection(undefined);
clearMapPick();
setNotice(undefined); setNotice(undefined);
}} }}
onUndo={() => setHistory((current) => undo(current))} onUndo={() => setHistory((current) => undo(current))}
@@ -868,6 +982,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
scene={mapScene} scene={mapScene}
layers={EDITOR_MAP_LAYERS} layers={EDITOR_MAP_LAYERS}
redrawToken={0} redrawToken={0}
enableObjectChooser
onObjectsAtPoint={handleObjectsAtPoint}
pickMode={rightMode === "shoot"} pickMode={rightMode === "shoot"}
pickTarget={target ?? undefined} pickTarget={target ?? undefined}
onPickPoint={(point) => { onPickPoint={(point) => {
@@ -919,28 +1035,11 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
{rightMode !== "none" && ( {rightMode !== "none" && (
<aside className="tgu-editor-side-panel"> <aside className="tgu-editor-side-panel">
{rightMode === "inspect" && selectedWork && ( {rightMode === "inspect" && selectedWork &&
<WorkInspector renderWorkInspector(selectedWork, () => {
work={selectedWork} setSelectedWorkId(undefined);
modes={modes} setRightMode("none");
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={() => {
setSelectedWorkId(undefined);
setRightMode("none");
}}
/>
)}
{rightMode === "inspect" && !selectedWork && <div className="tgu-empty tgu-empty--compact">Включение не выбрано.</div>} {rightMode === "inspect" && !selectedWork && <div className="tgu-empty tgu-empty--compact">Включение не выбрано.</div>}
{rightMode === "shoot" && ( {rightMode === "shoot" && (
<ShootPanel <ShootPanel
@@ -960,6 +1059,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
routeLoading={routeLoading} routeLoading={routeLoading}
routeError={routeError} routeError={routeError}
route={surveyRoute} route={surveyRoute}
addBlockedReason={inPlaceEditBlockedReason}
onRouteStartChange={(startMs) => setRouteWindow((current) => (current ? { ...current, startMs } : current))} onRouteStartChange={(startMs) => setRouteWindow((current) => (current ? { ...current, startMs } : current))}
onRouteEndChange={(endMs) => setRouteWindow((current) => (current ? { ...current, endMs } : current))} onRouteEndChange={(endMs) => setRouteWindow((current) => (current ? { ...current, endMs } : current))}
onRouteShift={(deltaMs) => onRouteShift={(deltaMs) =>
@@ -1024,6 +1124,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
} }
saving={dropSaving} saving={dropSaving}
saveError={dropError} saveError={dropError}
addBlockedReason={inPlaceEditBlockedReason}
onAdd={addDropWork} onAdd={addDropWork}
onClose={() => { onClose={() => {
setRightMode("none"); 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> </aside>
)} )}
</div> </div>
@@ -1216,6 +1331,7 @@ function buildEditorMapScene(
if (pts.length < 2) continue; if (pts.length < 2) continue;
lines.push({ lines.push({
id: `drop:${mode.id}`, id: `drop:${mode.id}`,
name: `Сброс · виток ${mode.revolution}`,
layer: "tracks", layer: "tracks",
kind: "drop", kind: "drop",
modeId: mode.id, modeId: mode.id,
@@ -6,10 +6,10 @@ import { drawStations } from "./canvas/drawStations";
import { drawSwath } from "./canvas/drawSwath"; import { drawSwath } from "./canvas/drawSwath";
import { drawTiles } from "./canvas/drawTiles"; import { drawTiles } from "./canvas/drawTiles";
import { drawTracks } from "./canvas/drawTracks"; 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 { createPolygonSpatialIndex, hitTestAllPolygons, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex";
import type { Tgu2DMapLayersState } from "./model/mapLayerTypes"; 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"; import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
type Tgu2DMapViewProps = { type Tgu2DMapViewProps = {
@@ -25,6 +25,14 @@ type Tgu2DMapViewProps = {
/** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */ /** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */
focusPoint?: GeoPoint; focusPoint?: GeoPoint;
focusToken?: number; focusToken?: number;
/**
* Включает выбор объекта под курсором: если в точке клика лежит несколько объектов
* (например, заявка и маршрут съёмки), их список отдаётся через onObjectsAtPoint вместо
* «молчаливого» выбора верхнего по z-индексу. Также делает кликабельными линии сброса.
*/
enableObjectChooser?: boolean;
/** Несколько объектов под точкой клика — список передаётся наверх (выбор делает оператор справа). */
onObjectsAtPoint?: (selections: Tgu2DMapSelection[]) => void;
pickMode?: boolean; pickMode?: boolean;
onPickPoint?: (point: { lat: number; lon: number }) => void; onPickPoint?: (point: { lat: number; lon: number }) => void;
targets?: Tgu2DMapTarget[]; targets?: Tgu2DMapTarget[];
@@ -76,6 +84,8 @@ export function Tgu2DMapView({
selectedObjectId, selectedObjectId,
focusPoint, focusPoint,
focusToken, focusToken,
enableObjectChooser = false,
onObjectsAtPoint,
pickMode = false, pickMode = false,
onPickPoint, onPickPoint,
targets = [], targets = [],
@@ -98,7 +108,10 @@ export function Tgu2DMapView({
const [tileVersion, setTileVersion] = useState(0); const [tileVersion, setTileVersion] = useState(0);
const handleTileLoad = useCallback(() => setTileVersion((v) => v + 1), []); const handleTileLoad = useCallback(() => setTileVersion((v) => v + 1), []);
const [tooltip, setTooltip] = useState<TooltipState>(); const [tooltip, setTooltip] = useState<TooltipState>();
// Гео-координаты под курсором — показываем по центру внизу карты.
const [cursorGeo, setCursorGeo] = useState<GeoPoint>();
const selectedPolygonRef = useRef<MapPolygon | undefined>(undefined); const selectedPolygonRef = useRef<MapPolygon | undefined>(undefined);
const selectedLineRef = useRef<MapLine | undefined>(undefined);
const activePolygons = useMemo( const activePolygons = useMemo(
() => scene.polygons.filter((polygon) => layers[polygon.layer]), () => scene.polygons.filter((polygon) => layers[polygon.layer]),
[scene.polygons, layers] [scene.polygons, layers]
@@ -210,6 +223,7 @@ export function Tgu2DMapView({
const updateHover = useCallback( const updateHover = useCallback(
(event: React.PointerEvent) => { (event: React.PointerEvent) => {
const screenPoint = screenPointFromEvent(event); const screenPoint = screenPointFromEvent(event);
setCursorGeo(projection.unproject(screenPoint));
const marker = hitTestMarkers(activeMarkers, screenPoint, projection); const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
const polygon = marker ? undefined : hitTestPolygons(polygonIndex, projection.unproject(screenPoint)); const polygon = marker ? undefined : hitTestPolygons(polygonIndex, projection.unproject(screenPoint));
@@ -254,6 +268,7 @@ export function Tgu2DMapView({
} }
const screenPoint = screenPointFromEvent(event); const screenPoint = screenPointFromEvent(event);
setCursorGeo(projection.unproject(screenPoint));
const dx = screenPoint.x - drag.startX; const dx = screenPoint.x - drag.startX;
const dy = screenPoint.y - drag.startY; const dy = screenPoint.y - drag.startY;
if (!hasDraggedRef.current && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) { if (!hasDraggedRef.current && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) {
@@ -280,6 +295,56 @@ export function Tgu2DMapView({
wrapperRef.current?.releasePointerCapture(event.pointerId); 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) => { const onClick = (event: React.MouseEvent) => {
if (hasDraggedRef.current) return; if (hasDraggedRef.current) return;
const screenPoint = screenPointFromEvent(event); const screenPoint = screenPointFromEvent(event);
@@ -293,6 +358,11 @@ export function Tgu2DMapView({
return; return;
} }
if (enableObjectChooser) {
handleEditorClick(screenPoint);
return;
}
const marker = hitTestMarkers(activeMarkers, screenPoint, projection); const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
if (marker) { if (marker) {
selectedMarkerRef.current = marker; selectedMarkerRef.current = marker;
@@ -343,7 +413,8 @@ export function Tgu2DMapView({
spacecraftId: polygon.spacecraftId, spacecraftId: polygon.spacecraftId,
requestId: polygon.requestId, requestId: polygon.requestId,
surveyType: polygon.surveyType, 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.find((line) => line.id === selectedObjectId) : undefined),
[selectedObjectId, scene.lines] [selectedObjectId, scene.lines]
); );
// Линии под подсветкой: выбранная кликом на карте (внутренняя) и заданная извне (по selectedObjectId).
const overlayLines = uniqueLines([selectedLineRef.current, externalSelectedLine]);
const externalHoveredPolygon = useMemo( const externalHoveredPolygon = useMemo(
() => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined, () => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined,
[hoveredRequestPolygonId, activePolygons] [hoveredRequestPolygonId, activePolygons]
@@ -395,6 +468,7 @@ export function Tgu2DMapView({
hoverRef.current = undefined; hoverRef.current = undefined;
hoverMarkerRef.current = undefined; hoverMarkerRef.current = undefined;
setTooltip(undefined); setTooltip(undefined);
setCursorGeo(undefined);
}} }}
onWheel={onWheel} onWheel={onWheel}
> >
@@ -422,8 +496,9 @@ export function Tgu2DMapView({
); );
}); });
})} })}
{externalSelectedLine && externalSelectedLine.points.length >= 2 && (() => { {overlayLines.flatMap((line) => {
const pts = unwrapX(externalSelectedLine.points.map((point) => projection.project(point)), projection.worldSize); 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) => { return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => {
const visible = pts.some((p) => { const visible = pts.some((p) => {
const x = p.x + dx; const x = p.x + dx;
@@ -432,14 +507,14 @@ export function Tgu2DMapView({
if (!visible) return []; if (!visible) return [];
return ( return (
<polyline <polyline
key={`line:${externalSelectedLine.id}:${dx}`} key={`line:${line.id}:${dx}`}
points={pts.map((p) => `${p.x + dx},${p.y}`).join(" ")} points={pts.map((p) => `${p.x + dx},${p.y}`).join(" ")}
className="is-selected" className="is-selected"
fill="none" fill="none"
/> />
); );
}); });
})()} })}
{overlayMarkers.map((marker) => { {overlayMarkers.map((marker) => {
const pt = projection.project(marker.point); const pt = projection.project(marker.point);
const cls = marker.id === selectedMarker?.id ? "is-selected" : "is-hovered"; const cls = marker.id === selectedMarker?.id ? "is-selected" : "is-hovered";
@@ -496,6 +571,7 @@ export function Tgu2DMapView({
<span>{tooltip.subtitle}</span> <span>{tooltip.subtitle}</span>
</div> </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__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"> <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.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 { function layerTitle(layer: MapPolygon["layer"]): string {
switch (layer) { switch (layer) {
case "planWorks": case "planWorks":
@@ -573,3 +658,99 @@ function uniquePolygons(polygons: Array<MapPolygon | undefined>): MapPolygon[] {
} }
return result; 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)) expect(queryPolygonsByBBox(index, { minLon: 11, minLat: 11, maxLon: 12, maxLat: 12 }).map((polygon) => polygon.id))
.toEqual(["top"]); .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[] { export function queryPolygonsByBBox(index: PolygonSpatialIndex, bbox: BBox): MapPolygon[] {
return index.entries 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) .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); .map((entry) => entry.polygon);
} }
@@ -11,6 +11,18 @@ export type Tgu2DMapSelection =
requestId?: string; requestId?: string;
surveyType?: string; surveyType?: string;
status?: TguPlanStatus; status?: TguPlanStatus;
/** id режима плана (включения) для polygon-ов слоя planWorks — связь с работой таймлайна. */
modeId?: number;
}
| {
type: "line";
id: string;
name: string;
layer: "tracks";
/** Вид линии: трасса КА или сеанс сброса. */
kind?: "track" | "drop";
/** id режима плана (drop) — связь с работой таймлайна. */
modeId?: number;
} }
| { | {
type: "marker"; type: "marker";
@@ -39,6 +39,8 @@ export type MapPolygon = {
export type MapLine = { export type MapLine = {
id: string; id: string;
/** Человекочитаемое имя (для подсказок и выбора объекта под курсором), напр. «Сброс · виток N». */
name?: string;
layer: "tracks"; layer: "tracks";
points: GeoPoint[]; points: GeoPoint[];
spacecraftId?: string; spacecraftId?: string;
@@ -1094,3 +1094,77 @@
max-height: 24rem; 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;
}