pcp-tgu: редактор — таймлайн зум/драг/секундная шкала, подтверждения в деталях плана, без авто-кнопок

- детали плана: подтверждение (confirm) на «Отправить» и «Удалить» черновика
- редактор: убраны кнопки «Применить»/«Отмена», undo/redo и сдвиг ◀▶ из тулбара
- окно: предел отдаления план±1ч, дефолт тоже план±1ч; зум кнопками и колесом мыши (центр — курсор)
- таймлайн: drag по горизонтали как карту; минимум окна ~10с
- ось времени: адаптивные деления от секунд до недель, секунды в подписи при мелком масштабе
This commit is contained in:
Дмитрий Соловьев
2026-06-04 14:20:01 +03:00
parent 9e7cae4a56
commit 5235142fa6
6 changed files with 211 additions and 149 deletions
+2 -6
View File
@@ -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) => { // Масштабирование окна относительно точки-якоря (центр окна для кнопок, точка под
setWindow((current) => { // курсором для колеса мыши). Ширину зажимаем в [MIN_EDITOR_WINDOW_MS; ширина maxWindow],
const center = (current.fromMs + current.toMs) / 2; // затем двигаем окно так, чтобы оно не вышло за план ± час.
const half = ((current.toMs - current.fromMs) / 2) * factor; const zoomAt = useCallback(
return { fromMs: center - half, toMs: center + half }; (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 pan = (factor: number) => { const zoom = useCallback(
setWindow((current) => { (factor: number) => {
const delta = (current.toMs - current.fromMs) * factor; setWindow((current) => {
return { fromMs: current.fromMs + delta, toMs: current.toMs + delta }; const center = (current.fromMs + current.toMs) / 2;
}); 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);
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]
);
// Горизонтальный сдвиг окна перетаскиванием: ширину окна не меняем, левую границу зажимаем
// в [maxWindow.fromMs; maxWindow.toMs ширина], чтобы не выехать за план ± час.
const panTo = useCallback(
(fromMs: number) => {
setWindow((current) => {
const span = current.toMs - current.fromMs;
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>