pcp-tgu: редактор — таймлайн зум/драг/секундная шкала, подтверждения в деталях плана, без авто-кнопок
- детали плана: подтверждение (confirm) на «Отправить» и «Удалить» черновика - редактор: убраны кнопки «Применить»/«Отмена», undo/redo и сдвиг ◀▶ из тулбара - окно: предел отдаления план±1ч, дефолт тоже план±1ч; зум кнопками и колесом мыши (центр — курсор) - таймлайн: drag по горизонтали как карту; минимум окна ~10с - ось времени: адаптивные деления от секунд до недель, секунды в подписи при мелком масштабе
This commit is contained in:
@@ -1,15 +1,11 @@
|
|||||||
1. Перевод сервиса станций на ordinis по примеру получения аппаратов в сервисе tgu
|
1. Перевод сервиса станций на ordinis по примеру получения аппаратов в сервисе tgu
|
||||||
2. Ошибка при попытке принять план во вкладке планы
|
2. Ошибка при попытке принять план во вкладке планы
|
||||||
|
|
||||||
|
|
||||||
4. Необходимо обновить статусы во вкладке планы (слева внизу), чтобы они были актуальные. И нужно пусть для меня хотя бы пояснение по каждому из них в какой момент какой статус наступает и что он означает
|
|
||||||
Эту информацию лучше положить в docs, чтобы заново не выискивать потом
|
|
||||||
5. Пропадают витки с вкладки редактор
|
|
||||||
|
|
||||||
6. Когда мы кликаем по полосам обзора, если там пересекаются несколько, то их надо выводить списком на панели справа. И при нажатии на полосу обзора на панели выделять объект на карте.
|
6. Когда мы кликаем по полосам обзора, если там пересекаются несколько, то их надо выводить списком на панели справа. И при нажатии на полосу обзора на панели выделять объект на карте.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
TODO:
|
TODO:
|
||||||
- Отображение трасс полос исходя их возможности съемки КА. Для оптики нужно отображать только части, которые находятся в допустимом угле солнца.
|
- Отображение трасс полос исходя их возможности съемки КА. Для оптики нужно отображать только части, которые находятся в допустимом угле солнца.
|
||||||
-
|
- Отображение заявок на вкладке редактор только тех, которые может удовлетворить аппарат (не показывать только локацию для оптики например)
|
||||||
|
- Чуть расширить вправо левую панель с информацией на вкладке Редактор ибо цифры на границе смотрятся плохо
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { formatDate, formatDateTime, formatTime, kindColor, kindLabel, niceTicks } from "./editorPresentation";
|
import { formatDate, formatDateTime, formatTick, formatTime, kindColor, kindLabel, niceTicks } from "./editorPresentation";
|
||||||
import type {
|
import type {
|
||||||
EditorContextLane,
|
EditorContextLane,
|
||||||
EditorTimelineWindow,
|
EditorTimelineWindow,
|
||||||
@@ -20,6 +20,10 @@ type EditorTimelineProps = {
|
|||||||
visibilityTracks: EditorVisibilityTrack[];
|
visibilityTracks: EditorVisibilityTrack[];
|
||||||
onSelectWork: (workId: string) => void;
|
onSelectWork: (workId: string) => void;
|
||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
|
/** Масштаб колесом мыши: factor (>1 — отдалить, <1 — приблизить) и время под курсором. */
|
||||||
|
onWheelZoom?: (factor: number, anchorMs: number) => void;
|
||||||
|
/** Сдвиг окна перетаскиванием: новое значение левой границы окна (мс). */
|
||||||
|
onPanTo?: (fromMs: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LABEL_WIDTH = 168;
|
const LABEL_WIDTH = 168;
|
||||||
@@ -40,15 +44,21 @@ export function EditorTimeline({
|
|||||||
contextLanes,
|
contextLanes,
|
||||||
visibilityTracks,
|
visibilityTracks,
|
||||||
onSelectWork,
|
onSelectWork,
|
||||||
onClearSelection
|
onClearSelection,
|
||||||
|
onWheelZoom,
|
||||||
|
onPanTo
|
||||||
}: EditorTimelineProps) {
|
}: EditorTimelineProps) {
|
||||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [bodyWidth, setBodyWidth] = useState(900);
|
const [bodyWidth, setBodyWidth] = useState(900);
|
||||||
const [bodyHeight, setBodyHeight] = useState(260);
|
const [bodyHeight, setBodyHeight] = useState(260);
|
||||||
|
const [grabbing, setGrabbing] = useState(false);
|
||||||
const span = Math.max(1, timelineWindow.toMs - timelineWindow.fromMs);
|
const span = Math.max(1, timelineWindow.toMs - timelineWindow.fromMs);
|
||||||
const pxPerMs = bodyWidth / span;
|
const pxPerMs = bodyWidth / span;
|
||||||
const toX = (timeMs: number) => (timeMs - timelineWindow.fromMs) * pxPerMs;
|
const toX = (timeMs: number) => (timeMs - timelineWindow.fromMs) * pxPerMs;
|
||||||
const ticks = useMemo(() => niceTicks(timelineWindow.fromMs, timelineWindow.toMs, pxPerMs, 72), [pxPerMs, timelineWindow.fromMs, timelineWindow.toMs]);
|
const { ticks, step: tickStep } = useMemo(
|
||||||
|
() => niceTicks(timelineWindow.fromMs, timelineWindow.toMs, pxPerMs, 72),
|
||||||
|
[pxPerMs, timelineWindow.fromMs, timelineWindow.toMs]
|
||||||
|
);
|
||||||
const days = useMemo(() => buildDays(timelineWindow), [timelineWindow]);
|
const days = useMemo(() => buildDays(timelineWindow), [timelineWindow]);
|
||||||
// Фиксированная высота частей ниже активной дорожки (треки видимости + контекст КА).
|
// Фиксированная высота частей ниже активной дорожки (треки видимости + контекст КА).
|
||||||
const belowActiveHeight =
|
const belowActiveHeight =
|
||||||
@@ -81,8 +91,81 @@ export function EditorTimeline({
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Свежие значения окна/геометрии для нативного wheel-слушателя (он навешивается один раз).
|
||||||
|
const wheelStateRef = useRef({ fromMs: timelineWindow.fromMs, pxPerMs, bodyWidth, onWheelZoom });
|
||||||
|
wheelStateRef.current = { fromMs: timelineWindow.fromMs, pxPerMs, bodyWidth, onWheelZoom };
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = wrapperRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
// Нативный слушатель с passive: false — иначе preventDefault не сработает и страница
|
||||||
|
// прокрутится вместо масштабирования таймлайна.
|
||||||
|
const handleWheel = (event: WheelEvent) => {
|
||||||
|
const state = wheelStateRef.current;
|
||||||
|
if (!state.onWheelZoom) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const x = event.clientX - rect.left - LABEL_WIDTH;
|
||||||
|
const clampedX = Math.min(Math.max(x, 0), state.bodyWidth);
|
||||||
|
const anchorMs = state.fromMs + clampedX / state.pxPerMs;
|
||||||
|
const factor = event.deltaY > 0 ? 1.1 : 0.9;
|
||||||
|
state.onWheelZoom(factor, anchorMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
element.addEventListener("wheel", handleWheel, { passive: false });
|
||||||
|
return () => element.removeEventListener("wheel", handleWheel);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Перетаскивание окна по горизонтали (как карту). На mousedown фиксируем точку и левую
|
||||||
|
// границу окна, дальше двигаем абсолютно (без накопления дрейфа). Сдвиг > порога помечаем
|
||||||
|
// как drag, чтобы погасить последующий click (иначе выделение/сброс включения).
|
||||||
|
const dragRef = useRef<{ startX: number; startFromMs: number; pxPerMs: number; moved: boolean } | null>(null);
|
||||||
|
const suppressClickRef = useRef(false);
|
||||||
|
|
||||||
|
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (event.button !== 0 || !onPanTo || pxPerMs <= 0) return;
|
||||||
|
const drag = { startX: event.clientX, startFromMs: timelineWindow.fromMs, pxPerMs, moved: false };
|
||||||
|
dragRef.current = drag;
|
||||||
|
|
||||||
|
const onMove = (move: MouseEvent) => {
|
||||||
|
const deltaPx = move.clientX - drag.startX;
|
||||||
|
if (Math.abs(deltaPx) > 3) {
|
||||||
|
drag.moved = true;
|
||||||
|
setGrabbing(true);
|
||||||
|
}
|
||||||
|
// Тянем содержимое вправо → показываем более раннее время (окно едет влево).
|
||||||
|
onPanTo(drag.startFromMs - deltaPx / drag.pxPerMs);
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
if (drag.moved) suppressClickRef.current = true;
|
||||||
|
dragRef.current = null;
|
||||||
|
setGrabbing(false);
|
||||||
|
window.removeEventListener("mousemove", onMove);
|
||||||
|
window.removeEventListener("mouseup", onUp);
|
||||||
|
};
|
||||||
|
window.addEventListener("mousemove", onMove);
|
||||||
|
window.addEventListener("mouseup", onUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Капче-фаза: если только что был drag — гасим click до того, как он дойдёт до полос/фона.
|
||||||
|
const handleClickCapture = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (suppressClickRef.current) {
|
||||||
|
suppressClickRef.current = false;
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tgu-editor-timeline" ref={wrapperRef} onClick={onClearSelection}>
|
<div
|
||||||
|
className="tgu-editor-timeline"
|
||||||
|
ref={wrapperRef}
|
||||||
|
onClick={onClearSelection}
|
||||||
|
onClickCapture={handleClickCapture}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
style={onPanTo ? { cursor: grabbing ? "grabbing" : "grab" } : undefined}
|
||||||
|
>
|
||||||
<div className="tgu-editor-timeline__surface" style={{ width: LABEL_WIDTH + bodyWidth, height: Math.max(220, surfaceHeight) }}>
|
<div className="tgu-editor-timeline__surface" style={{ width: LABEL_WIDTH + bodyWidth, height: Math.max(220, surfaceHeight) }}>
|
||||||
<div className="tgu-editor-timeline__axis-label" style={{ width: LABEL_WIDTH }}>
|
<div className="tgu-editor-timeline__axis-label" style={{ width: LABEL_WIDTH }}>
|
||||||
Время
|
Время
|
||||||
@@ -96,7 +179,7 @@ export function EditorTimeline({
|
|||||||
))}
|
))}
|
||||||
{ticks.map((tick) => (
|
{ticks.map((tick) => (
|
||||||
<div className="tgu-editor-timeline__tick-label" key={tick} style={{ left: toX(tick) }}>
|
<div className="tgu-editor-timeline__tick-label" key={tick} style={{ left: toX(tick) }}>
|
||||||
{formatTime(tick)}
|
{formatTick(tick, tickStep)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,48 +5,26 @@ type EditorToolbarProps = {
|
|||||||
selectedSpacecraftId?: string;
|
selectedSpacecraftId?: string;
|
||||||
selectedPlan?: TguPlanUi;
|
selectedPlan?: TguPlanUi;
|
||||||
platforms: TguPlatformUi[];
|
platforms: TguPlatformUi[];
|
||||||
canUndo: boolean;
|
|
||||||
canRedo: boolean;
|
|
||||||
checking: boolean;
|
checking: boolean;
|
||||||
conflictCount: number;
|
conflictCount: number;
|
||||||
addedCount: number;
|
|
||||||
dirty: boolean;
|
|
||||||
applying: boolean;
|
|
||||||
canceling: boolean;
|
|
||||||
windowLabel: string;
|
windowLabel: string;
|
||||||
rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo";
|
rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo";
|
||||||
onSetRightMode: (mode: "shoot" | "downlink") => void;
|
onSetRightMode: (mode: "shoot" | "downlink") => void;
|
||||||
onUndo: () => void;
|
|
||||||
onRedo: () => void;
|
|
||||||
onCheck: () => void;
|
onCheck: () => void;
|
||||||
onApply: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
onZoom: (factor: number) => void;
|
onZoom: (factor: number) => void;
|
||||||
onPan: (factor: number) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EditorToolbar({
|
export function EditorToolbar({
|
||||||
selectedSpacecraftId,
|
selectedSpacecraftId,
|
||||||
selectedPlan,
|
selectedPlan,
|
||||||
platforms,
|
platforms,
|
||||||
canUndo,
|
|
||||||
canRedo,
|
|
||||||
checking,
|
checking,
|
||||||
conflictCount,
|
conflictCount,
|
||||||
addedCount,
|
|
||||||
dirty,
|
|
||||||
applying,
|
|
||||||
canceling,
|
|
||||||
windowLabel,
|
windowLabel,
|
||||||
rightMode,
|
rightMode,
|
||||||
onSetRightMode,
|
onSetRightMode,
|
||||||
onUndo,
|
|
||||||
onRedo,
|
|
||||||
onCheck,
|
onCheck,
|
||||||
onApply,
|
onZoom
|
||||||
onCancel,
|
|
||||||
onZoom,
|
|
||||||
onPan
|
|
||||||
}: EditorToolbarProps) {
|
}: EditorToolbarProps) {
|
||||||
const platformLabel = selectedSpacecraftId
|
const platformLabel = selectedSpacecraftId
|
||||||
? platforms.find((platform) => platform.spacecraftId === selectedSpacecraftId)?.name ?? selectedSpacecraftId
|
? platforms.find((platform) => platform.spacecraftId === selectedSpacecraftId)?.name ?? selectedSpacecraftId
|
||||||
@@ -85,21 +63,10 @@ export function EditorToolbar({
|
|||||||
|
|
||||||
<div className="tgu-editor-toolbar__spacer" />
|
<div className="tgu-editor-toolbar__spacer" />
|
||||||
|
|
||||||
<div className="tgu-editor-window-control" aria-label="Окно редактора">
|
<div className="tgu-editor-window-control" aria-label="Масштаб окна" title="Колесо мыши над таймлайном — тоже масштаб">
|
||||||
<button type="button" onClick={() => onPan(-0.25)} title="Назад по времени">◀</button>
|
|
||||||
<button type="button" onClick={() => onZoom(1.4)} title="Отдалить">−</button>
|
<button type="button" onClick={() => onZoom(1.4)} title="Отдалить">−</button>
|
||||||
<span>{windowLabel || formatDuration(0)}</span>
|
<span>{windowLabel || formatDuration(0)}</span>
|
||||||
<button type="button" onClick={() => onZoom(0.7)} title="Приблизить">+</button>
|
<button type="button" onClick={() => onZoom(0.7)} title="Приблизить">+</button>
|
||||||
<button type="button" onClick={() => onPan(0.25)} title="Вперёд по времени">▶</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="tgu-editor-toolbar__group">
|
|
||||||
<button className="tgu-icon-button" type="button" onClick={onUndo} disabled={!canUndo} title="Отменить">
|
|
||||||
↶
|
|
||||||
</button>
|
|
||||||
<button className="tgu-icon-button" type="button" onClick={onRedo} disabled={!canRedo} title="Вернуть">
|
|
||||||
↷
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -110,21 +77,6 @@ export function EditorToolbar({
|
|||||||
>
|
>
|
||||||
{checking ? "Проверка..." : conflictCount > 0 ? `Конфликты · ${conflictCount}` : "Проверить"}
|
{checking ? "Проверка..." : conflictCount > 0 ? `Конфликты · ${conflictCount}` : "Проверить"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{dirty && (
|
|
||||||
<button
|
|
||||||
className="tgu-button tgu-button--reject"
|
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={canceling || applying}
|
|
||||||
>
|
|
||||||
{canceling ? "Отмена..." : "Отмена"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button className="tgu-button tgu-button--primary" type="button" onClick={onApply} disabled={!dirty || applying || canceling}>
|
|
||||||
{applying ? "Применение..." : `Применить${addedCount > 0 ? ` · +${addedCount}` : ""}`}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
validateMission,
|
validateMission,
|
||||||
type SurveyRoute
|
type SurveyRoute
|
||||||
} from "../../api/missionPlanningApi";
|
} from "../../api/missionPlanningApi";
|
||||||
import { abandonDraft, createDraft, promoteDraft } from "../../api/tguPlanApi";
|
import { abandonDraft, createDraft } from "../../api/tguPlanApi";
|
||||||
import { parseLocal, toLocalIso } from "../../utils/localTime";
|
import { parseLocal, toLocalIso } from "../../utils/localTime";
|
||||||
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
||||||
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
|
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
|
||||||
@@ -30,9 +30,7 @@ import {
|
|||||||
duplicateWork,
|
duplicateWork,
|
||||||
mutate,
|
mutate,
|
||||||
planModesToWorks,
|
planModesToWorks,
|
||||||
redo,
|
|
||||||
seedDraft,
|
seedDraft,
|
||||||
undo,
|
|
||||||
updateWork
|
updateWork
|
||||||
} from "./editorDraft";
|
} from "./editorDraft";
|
||||||
import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts";
|
import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts";
|
||||||
@@ -58,6 +56,14 @@ import type {
|
|||||||
type EditorRightMode = "none" | "inspect" | "shoot" | "downlink" | "mapInfo" | "mapPick";
|
type EditorRightMode = "none" | "inspect" | "shoot" | "downlink" | "mapInfo" | "mapPick";
|
||||||
|
|
||||||
const HOUR_MS = 60 * 60 * 1000;
|
const HOUR_MS = 60 * 60 * 1000;
|
||||||
|
/**
|
||||||
|
* Отступ окна редактора от границ плана при самом отдалённом масштабе: начало и конец
|
||||||
|
* плана отстоят на час от краёв видимого окна. Это же — предел «отдаления» (zoom out).
|
||||||
|
*/
|
||||||
|
const EDITOR_WINDOW_PAD_MS = HOUR_MS;
|
||||||
|
/** Минимальная ширина окна редактора при «приближении» (zoom in): ~10 с, чтобы на оси
|
||||||
|
* появлялись секундные деления. */
|
||||||
|
const MIN_EDITOR_WINDOW_MS = 10 * 1000;
|
||||||
/** Фиксированная длительность окна съёмки, строится вокруг момента видимости. */
|
/** Фиксированная длительность окна съёмки, строится вокруг момента видимости. */
|
||||||
const SHOOT_WINDOW_MS = 6 * 60 * 1000;
|
const SHOOT_WINDOW_MS = 6 * 60 * 1000;
|
||||||
/**
|
/**
|
||||||
@@ -94,6 +100,14 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
future: []
|
future: []
|
||||||
}));
|
}));
|
||||||
const [window, setWindow] = useState<EditorTimelineWindow>(() => defaultEditorWindow(selectedPlan, appliedRange));
|
const [window, setWindow] = useState<EditorTimelineWindow>(() => defaultEditorWindow(selectedPlan, appliedRange));
|
||||||
|
// Предел отдаления: окно не шире, чем план ± час (тот же диапазон, что и дефолтное окно).
|
||||||
|
const maxWindow = useMemo<EditorTimelineWindow>(
|
||||||
|
() =>
|
||||||
|
selectedPlan
|
||||||
|
? { fromMs: selectedPlan.startMs - EDITOR_WINDOW_PAD_MS, toMs: selectedPlan.endMs + EDITOR_WINDOW_PAD_MS }
|
||||||
|
: { fromMs: appliedRange.fromMs, toMs: appliedRange.toMs },
|
||||||
|
[selectedPlan, appliedRange]
|
||||||
|
);
|
||||||
const [rightMode, setRightMode] = useState<EditorRightMode>("none");
|
const [rightMode, setRightMode] = useState<EditorRightMode>("none");
|
||||||
const [selectedWorkId, setSelectedWorkId] = useState<string>();
|
const [selectedWorkId, setSelectedWorkId] = useState<string>();
|
||||||
const [target, setTarget] = useState<EditorTarget>();
|
const [target, setTarget] = useState<EditorTarget>();
|
||||||
@@ -113,8 +127,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
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);
|
||||||
const [applying, setApplying] = useState(false);
|
|
||||||
const [canceling, setCanceling] = useState(false);
|
|
||||||
const [notice, setNotice] = useState<string>();
|
const [notice, setNotice] = useState<string>();
|
||||||
const [applyError, setApplyError] = useState<string>();
|
const [applyError, setApplyError] = useState<string>();
|
||||||
const [contextSpacecraftIds, setContextSpacecraftIds] = useState<Set<string>>(new Set());
|
const [contextSpacecraftIds, setContextSpacecraftIds] = useState<Set<string>>(new Set());
|
||||||
@@ -122,7 +134,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
const [planModes, setPlanModes] = useState<PlanMode[]>([]);
|
const [planModes, setPlanModes] = useState<PlanMode[]>([]);
|
||||||
// Ленивый черновик (Ф5/Ф6): создаётся клоном базового плана на ПЕРВУЮ правку (replace).
|
// Ленивый черновик (Ф5/Ф6): создаётся клоном базового плана на ПЕРВУЮ правку (replace).
|
||||||
// Пока null — редактор показывает живой план read-only; после создания все правки и валидация
|
// Пока null — редактор показывает живой план read-only; после создания все правки и валидация
|
||||||
// адресуются в этот DRAFT, а «Применить» делает его promote в живую цепочку.
|
// адресуются в этот DRAFT. Отправка (promote) и удаление черновика делаются на вкладке «Планы».
|
||||||
const [draftPlanId, setDraftPlanId] = useState<string>();
|
const [draftPlanId, setDraftPlanId] = useState<string>();
|
||||||
const [passFrom, setPassFrom] = useState(1);
|
const [passFrom, setPassFrom] = useState(1);
|
||||||
const [passTo, setPassTo] = useState(999);
|
const [passTo, setPassTo] = useState(999);
|
||||||
@@ -237,10 +249,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
.sort((a, b) => a.startMs - b.startMs);
|
.sort((a, b) => a.startMs - b.startMs);
|
||||||
}, [planModes, selectedZone]);
|
}, [planModes, selectedZone]);
|
||||||
|
|
||||||
// «Применить» доступно, когда на сервере есть черновик (создаётся на первую сохранённую правку);
|
|
||||||
// локальные несохранённые твики без DRAFT применять нечего.
|
|
||||||
const dirty = Boolean(draftPlanId);
|
|
||||||
const addedCount = draft.works.filter((work) => work.isNew).length;
|
|
||||||
// Реальный состав плана берём из включений (plan modes), а не из черновика:
|
// Реальный состав плана берём из включений (plan modes), а не из черновика:
|
||||||
// черновик сидируется заглушкой, когда у плана нет встроенных works.
|
// черновик сидируется заглушкой, когда у плана нет встроенных works.
|
||||||
const planSurveyCount = useMemo(() => planModes.filter((mode) => mode.type === "SURVEY").length, [planModes]);
|
const planSurveyCount = useMemo(() => planModes.filter((mode) => mode.type === "SURVEY").length, [planModes]);
|
||||||
@@ -704,53 +712,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// «Применить» = promote черновика (Ф5): DRAFT уходит в живую цепочку и выдаётся. Под
|
|
||||||
// оптимистичным локом базы и гардом точки невозврата (409/422 с пояснением приходят с бэка).
|
|
||||||
const applyDraft = async () => {
|
|
||||||
if (!draftPlanId) {
|
|
||||||
setApplyError("Нет правок для применения — черновик ещё не создан.");
|
|
||||||
setNotice(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setApplying(true);
|
|
||||||
setApplyError(undefined);
|
|
||||||
setNotice(undefined);
|
|
||||||
try {
|
|
||||||
const result = await promoteDraft(draftPlanId);
|
|
||||||
setNotice(`Черновик применён: план ${result.planId} отправлен на выдачу.`);
|
|
||||||
setDraftPlanId(undefined);
|
|
||||||
setServiceConflicts({});
|
|
||||||
setHistory((current) => ({ ...current, past: [], future: [] }));
|
|
||||||
} catch (error) {
|
|
||||||
setApplyError(error instanceof Error ? error.message : "Не удалось применить правки плана.");
|
|
||||||
} finally {
|
|
||||||
setApplying(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// «Отмена» = отказ от черновика (Ф6): удаляет DRAFT и клон-миссию на сервере, возвращает
|
|
||||||
// редактор к живому плану (read-only) и сбрасывает локальные правки.
|
|
||||||
const cancelDraft = async () => {
|
|
||||||
if (!draftPlanId) return;
|
|
||||||
setCanceling(true);
|
|
||||||
setApplyError(undefined);
|
|
||||||
setNotice(undefined);
|
|
||||||
try {
|
|
||||||
await abandonDraft(draftPlanId);
|
|
||||||
setDraftPlanId(undefined);
|
|
||||||
setServiceConflicts({});
|
|
||||||
setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] });
|
|
||||||
setSelectedWorkId(undefined);
|
|
||||||
setRightMode("none");
|
|
||||||
setNotice("Черновик отменён — правки отброшены.");
|
|
||||||
} catch (error) {
|
|
||||||
setApplyError(error instanceof Error ? error.message : "Не удалось отменить черновик.");
|
|
||||||
} finally {
|
|
||||||
setCanceling(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Удаление включения: персистнутые режимы (с planModeId) удаляются в БД черновика (Ф3,
|
// Удаление включения: персистнутые режимы (с planModeId) удаляются в БД черновика (Ф3,
|
||||||
// с каскадами drop↔survey), затем убираются из локального представления. Несохранённые
|
// с каскадами drop↔survey), затем убираются из локального представления. Несохранённые
|
||||||
// (planModeId нет) — только локально.
|
// (planModeId нет) — только локально.
|
||||||
@@ -770,20 +731,68 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
setRightMode("none");
|
setRightMode("none");
|
||||||
};
|
};
|
||||||
|
|
||||||
const zoom = (factor: number) => {
|
// Масштабирование окна относительно точки-якоря (центр окна для кнопок, точка под
|
||||||
|
// курсором для колеса мыши). Ширину зажимаем в [MIN_EDITOR_WINDOW_MS; ширина maxWindow],
|
||||||
|
// затем двигаем окно так, чтобы оно не вышло за план ± час.
|
||||||
|
const zoomAt = useCallback(
|
||||||
|
(factor: number, anchorMs: number) => {
|
||||||
|
setWindow((current) => {
|
||||||
|
const span = current.toMs - current.fromMs;
|
||||||
|
const maxSpan = maxWindow.toMs - maxWindow.fromMs;
|
||||||
|
const nextSpan = Math.min(Math.max(span * factor, MIN_EDITOR_WINDOW_MS), maxSpan);
|
||||||
|
const ratio = span > 0 ? (anchorMs - current.fromMs) / span : 0.5;
|
||||||
|
let fromMs = anchorMs - ratio * nextSpan;
|
||||||
|
let toMs = fromMs + nextSpan;
|
||||||
|
if (fromMs < maxWindow.fromMs) {
|
||||||
|
fromMs = maxWindow.fromMs;
|
||||||
|
toMs = fromMs + nextSpan;
|
||||||
|
}
|
||||||
|
if (toMs > maxWindow.toMs) {
|
||||||
|
toMs = maxWindow.toMs;
|
||||||
|
fromMs = toMs - nextSpan;
|
||||||
|
}
|
||||||
|
return { fromMs: Math.max(fromMs, maxWindow.fromMs), toMs };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[maxWindow]
|
||||||
|
);
|
||||||
|
|
||||||
|
const zoom = useCallback(
|
||||||
|
(factor: number) => {
|
||||||
setWindow((current) => {
|
setWindow((current) => {
|
||||||
const center = (current.fromMs + current.toMs) / 2;
|
const center = (current.fromMs + current.toMs) / 2;
|
||||||
const half = ((current.toMs - current.fromMs) / 2) * factor;
|
const span = current.toMs - current.fromMs;
|
||||||
return { fromMs: center - half, toMs: center + half };
|
const maxSpan = maxWindow.toMs - maxWindow.fromMs;
|
||||||
|
const nextSpan = Math.min(Math.max(span * factor, MIN_EDITOR_WINDOW_MS), maxSpan);
|
||||||
|
let fromMs = center - nextSpan / 2;
|
||||||
|
let toMs = center + nextSpan / 2;
|
||||||
|
if (fromMs < maxWindow.fromMs) {
|
||||||
|
fromMs = maxWindow.fromMs;
|
||||||
|
toMs = fromMs + nextSpan;
|
||||||
|
}
|
||||||
|
if (toMs > maxWindow.toMs) {
|
||||||
|
toMs = maxWindow.toMs;
|
||||||
|
fromMs = toMs - nextSpan;
|
||||||
|
}
|
||||||
|
return { fromMs: Math.max(fromMs, maxWindow.fromMs), toMs };
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
[maxWindow]
|
||||||
|
);
|
||||||
|
|
||||||
const pan = (factor: number) => {
|
// Горизонтальный сдвиг окна перетаскиванием: ширину окна не меняем, левую границу зажимаем
|
||||||
|
// в [maxWindow.fromMs; maxWindow.toMs − ширина], чтобы не выехать за план ± час.
|
||||||
|
const panTo = useCallback(
|
||||||
|
(fromMs: number) => {
|
||||||
setWindow((current) => {
|
setWindow((current) => {
|
||||||
const delta = (current.toMs - current.fromMs) * factor;
|
const span = current.toMs - current.fromMs;
|
||||||
return { fromMs: current.fromMs + delta, toMs: current.toMs + delta };
|
let from = Math.min(Math.max(fromMs, maxWindow.fromMs), maxWindow.toMs - span);
|
||||||
|
if (from < maxWindow.fromMs) from = maxWindow.fromMs;
|
||||||
|
return { fromMs: from, toMs: from + span };
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
[maxWindow]
|
||||||
|
);
|
||||||
|
|
||||||
// Включение (съёмка/сброс), соответствующее объекту карты: контур маршрута (planWorks) или
|
// Включение (съёмка/сброс), соответствующее объекту карты: контур маршрута (planWorks) или
|
||||||
// линия сброса (drop) → работа таймлайна по modeId ↔ planModeId. Прочие объекты → undefined.
|
// линия сброса (drop) → работа таймлайна по modeId ↔ planModeId. Прочие объекты → undefined.
|
||||||
@@ -933,14 +942,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
selectedSpacecraftId={selectedSpacecraftId}
|
selectedSpacecraftId={selectedSpacecraftId}
|
||||||
selectedPlan={selectedPlan}
|
selectedPlan={selectedPlan}
|
||||||
platforms={platforms}
|
platforms={platforms}
|
||||||
canUndo={history.past.length > 0}
|
|
||||||
canRedo={history.future.length > 0}
|
|
||||||
checking={checking}
|
checking={checking}
|
||||||
conflictCount={conflictCount(conflicts)}
|
conflictCount={conflictCount(conflicts)}
|
||||||
addedCount={addedCount}
|
|
||||||
dirty={dirty}
|
|
||||||
applying={applying}
|
|
||||||
canceling={canceling}
|
|
||||||
windowLabel={formatDuration(window.toMs - window.fromMs)}
|
windowLabel={formatDuration(window.toMs - window.fromMs)}
|
||||||
rightMode={rightMode}
|
rightMode={rightMode}
|
||||||
onSetRightMode={(mode) => {
|
onSetRightMode={(mode) => {
|
||||||
@@ -950,13 +953,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
clearMapPick();
|
clearMapPick();
|
||||||
setNotice(undefined);
|
setNotice(undefined);
|
||||||
}}
|
}}
|
||||||
onUndo={() => setHistory((current) => undo(current))}
|
|
||||||
onRedo={() => setHistory((current) => redo(current))}
|
|
||||||
onCheck={runCheck}
|
onCheck={runCheck}
|
||||||
onApply={applyDraft}
|
|
||||||
onCancel={cancelDraft}
|
|
||||||
onZoom={zoom}
|
onZoom={zoom}
|
||||||
onPan={pan}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="tgu-editor-notices">
|
<div className="tgu-editor-notices">
|
||||||
@@ -1061,6 +1059,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
setMapFocus((prev) => ({ ...prev, objectId: undefined }));
|
setMapFocus((prev) => ({ ...prev, objectId: undefined }));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onWheelZoom={zoomAt}
|
||||||
|
onPanTo={panTo}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1217,8 +1217,8 @@ function defaultEditorWindow(
|
|||||||
): EditorTimelineWindow {
|
): EditorTimelineWindow {
|
||||||
if (selectedPlan) {
|
if (selectedPlan) {
|
||||||
return {
|
return {
|
||||||
fromMs: selectedPlan.startMs - 3 * HOUR_MS,
|
fromMs: selectedPlan.startMs - EDITOR_WINDOW_PAD_MS,
|
||||||
toMs: selectedPlan.endMs + 3 * HOUR_MS
|
toMs: selectedPlan.endMs + EDITOR_WINDOW_PAD_MS
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ export function formatTime(ms: number): string {
|
|||||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подпись деления оси с учётом шага: при шаге меньше минуты показываем секунды
|
||||||
|
* (ЧЧ:ММ:СС), иначе — ЧЧ:ММ. Так при сильном зуме видно более мелкий масштаб времени.
|
||||||
|
*/
|
||||||
|
export function formatTick(ms: number, stepMs: number): string {
|
||||||
|
const date = new Date(ms);
|
||||||
|
if (stepMs < 60_000) {
|
||||||
|
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||||
|
}
|
||||||
|
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDate(ms: number): string {
|
export function formatDate(ms: number): string {
|
||||||
const date = new Date(ms);
|
const date = new Date(ms);
|
||||||
return `${pad(date.getDate())} ${EDITOR_MONTHS[date.getMonth()]}`;
|
return `${pad(date.getDate())} ${EDITOR_MONTHS[date.getMonth()]}`;
|
||||||
@@ -57,10 +69,19 @@ export function formatDuration(ms: number): string {
|
|||||||
return restHours > 0 ? `${days} сут ${restHours} ч` : `${days} сут`;
|
return restHours > 0 ? `${days} сут ${restHours} ч` : `${days} сут`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function niceTicks(fromMs: number, toMs: number, pxPerMs: number, minPx: number): number[] {
|
/** Деления оси времени: {@link ticks} — отметки, {@link step} — выбранный шаг (мс). */
|
||||||
|
export type TimelineTicks = { ticks: number[]; step: number };
|
||||||
|
|
||||||
|
export function niceTicks(fromMs: number, toMs: number, pxPerMs: number, minPx: number): TimelineTicks {
|
||||||
|
const minuteMs = 60_000;
|
||||||
const hourMs = 3_600_000;
|
const hourMs = 3_600_000;
|
||||||
const dayMs = 24 * hourMs;
|
const dayMs = 24 * hourMs;
|
||||||
const steps = [hourMs, 2 * hourMs, 3 * hourMs, 6 * hourMs, 12 * hourMs, dayMs, 2 * dayMs, 7 * dayMs];
|
// От секунд до недели — чем сильнее зум, тем мельче шаг, который влезает по minPx.
|
||||||
|
const steps = [
|
||||||
|
1_000, 2_000, 5_000, 10_000, 15_000, 30_000,
|
||||||
|
minuteMs, 2 * minuteMs, 5 * minuteMs, 10 * minuteMs, 15 * minuteMs, 30 * minuteMs,
|
||||||
|
hourMs, 2 * hourMs, 3 * hourMs, 6 * hourMs, 12 * hourMs, dayMs, 2 * dayMs, 7 * dayMs
|
||||||
|
];
|
||||||
const step = steps.find((item) => item * pxPerMs >= minPx) ?? steps[steps.length - 1];
|
const step = steps.find((item) => item * pxPerMs >= minPx) ?? steps[steps.length - 1];
|
||||||
const ticks: number[] = [];
|
const ticks: number[] = [];
|
||||||
const firstTick = Math.ceil(fromMs / step) * step;
|
const firstTick = Math.ceil(fromMs / step) * step;
|
||||||
@@ -69,7 +90,7 @@ export function niceTicks(fromMs: number, toMs: number, pxPerMs: number, minPx:
|
|||||||
ticks.push(timeMs);
|
ticks.push(timeMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ticks;
|
return { ticks, step };
|
||||||
}
|
}
|
||||||
|
|
||||||
function pad(value: number): string {
|
function pad(value: number): string {
|
||||||
|
|||||||
@@ -79,6 +79,16 @@ export function TguPlanDetails({
|
|||||||
onDecision(plan.planId, "REJECTED", reason);
|
onDecision(plan.planId, "REJECTED", reason);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const promoteDraft = () => {
|
||||||
|
if (!window.confirm("Отправить черновик на выдачу? Он уйдёт в цепочку и станет рабочим после приёмки.")) return;
|
||||||
|
onPromoteDraft?.(plan.planId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteDraft = () => {
|
||||||
|
if (!window.confirm("Удалить черновик? Его нельзя будет восстановить.")) return;
|
||||||
|
onDeleteDraft?.(plan.planId);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="tgu-details">
|
<aside className="tgu-details">
|
||||||
<div className="tgu-details__header">
|
<div className="tgu-details__header">
|
||||||
@@ -135,7 +145,7 @@ export function TguPlanDetails({
|
|||||||
className="tgu-button tgu-button--accept"
|
className="tgu-button tgu-button--accept"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={draftActionInFlight}
|
disabled={draftActionInFlight}
|
||||||
onClick={() => onPromoteDraft?.(plan.planId)}
|
onClick={promoteDraft}
|
||||||
>
|
>
|
||||||
Отправить
|
Отправить
|
||||||
</button>
|
</button>
|
||||||
@@ -143,7 +153,7 @@ export function TguPlanDetails({
|
|||||||
className="tgu-button tgu-button--reject"
|
className="tgu-button tgu-button--reject"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={draftActionInFlight}
|
disabled={draftActionInFlight}
|
||||||
onClick={() => onDeleteDraft?.(plan.planId)}
|
onClick={deleteDraft}
|
||||||
>
|
>
|
||||||
Удалить
|
Удалить
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user