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:
@@ -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={() => {
|
||||
setSelectedWorkId(undefined);
|
||||
setRightMode("none");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user