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:
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user