pcp-tgu: редактор — таймлайн зум/драг/секундная шкала, подтверждения в деталях плана, без авто-кнопок
- детали плана: подтверждение (confirm) на «Отправить» и «Удалить» черновика - редактор: убраны кнопки «Применить»/«Отмена», undo/redo и сдвиг ◀▶ из тулбара - окно: предел отдаления план±1ч, дефолт тоже план±1ч; зум кнопками и колесом мыши (центр — курсор) - таймлайн: drag по горизонтали как карту; минимум окна ~10с - ось времени: адаптивные деления от секунд до недель, секунды в подписи при мелком масштабе
This commit is contained in:
@@ -1,15 +1,11 @@
|
||||
1. Перевод сервиса станций на ordinis по примеру получения аппаратов в сервисе tgu
|
||||
2. Ошибка при попытке принять план во вкладке планы
|
||||
|
||||
|
||||
4. Необходимо обновить статусы во вкладке планы (слева внизу), чтобы они были актуальные. И нужно пусть для меня хотя бы пояснение по каждому из них в какой момент какой статус наступает и что он означает
|
||||
Эту информацию лучше положить в docs, чтобы заново не выискивать потом
|
||||
5. Пропадают витки с вкладки редактор
|
||||
|
||||
6. Когда мы кликаем по полосам обзора, если там пересекаются несколько, то их надо выводить списком на панели справа. И при нажатии на полосу обзора на панели выделять объект на карте.
|
||||
|
||||
|
||||
|
||||
TODO:
|
||||
- Отображение трасс полос исходя их возможности съемки КА. Для оптики нужно отображать только части, которые находятся в допустимом угле солнца.
|
||||
-
|
||||
- Отображение заявок на вкладке редактор только тех, которые может удовлетворить аппарат (не показывать только локацию для оптики например)
|
||||
- Чуть расширить вправо левую панель с информацией на вкладке Редактор ибо цифры на границе смотрятся плохо
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
EditorContextLane,
|
||||
EditorTimelineWindow,
|
||||
@@ -20,6 +20,10 @@ type EditorTimelineProps = {
|
||||
visibilityTracks: EditorVisibilityTrack[];
|
||||
onSelectWork: (workId: string) => void;
|
||||
onClearSelection: () => void;
|
||||
/** Масштаб колесом мыши: factor (>1 — отдалить, <1 — приблизить) и время под курсором. */
|
||||
onWheelZoom?: (factor: number, anchorMs: number) => void;
|
||||
/** Сдвиг окна перетаскиванием: новое значение левой границы окна (мс). */
|
||||
onPanTo?: (fromMs: number) => void;
|
||||
};
|
||||
|
||||
const LABEL_WIDTH = 168;
|
||||
@@ -40,15 +44,21 @@ export function EditorTimeline({
|
||||
contextLanes,
|
||||
visibilityTracks,
|
||||
onSelectWork,
|
||||
onClearSelection
|
||||
onClearSelection,
|
||||
onWheelZoom,
|
||||
onPanTo
|
||||
}: EditorTimelineProps) {
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const [bodyWidth, setBodyWidth] = useState(900);
|
||||
const [bodyHeight, setBodyHeight] = useState(260);
|
||||
const [grabbing, setGrabbing] = useState(false);
|
||||
const span = Math.max(1, timelineWindow.toMs - timelineWindow.fromMs);
|
||||
const pxPerMs = bodyWidth / span;
|
||||
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 belowActiveHeight =
|
||||
@@ -81,8 +91,81 @@ export function EditorTimeline({
|
||||
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 (
|
||||
<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__axis-label" style={{ width: LABEL_WIDTH }}>
|
||||
Время
|
||||
@@ -96,7 +179,7 @@ export function EditorTimeline({
|
||||
))}
|
||||
{ticks.map((tick) => (
|
||||
<div className="tgu-editor-timeline__tick-label" key={tick} style={{ left: toX(tick) }}>
|
||||
{formatTime(tick)}
|
||||
{formatTick(tick, tickStep)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,48 +5,26 @@ type EditorToolbarProps = {
|
||||
selectedSpacecraftId?: string;
|
||||
selectedPlan?: TguPlanUi;
|
||||
platforms: TguPlatformUi[];
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
checking: boolean;
|
||||
conflictCount: number;
|
||||
addedCount: number;
|
||||
dirty: boolean;
|
||||
applying: boolean;
|
||||
canceling: boolean;
|
||||
windowLabel: string;
|
||||
rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo";
|
||||
onSetRightMode: (mode: "shoot" | "downlink") => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onCheck: () => void;
|
||||
onApply: () => void;
|
||||
onCancel: () => void;
|
||||
onZoom: (factor: number) => void;
|
||||
onPan: (factor: number) => void;
|
||||
};
|
||||
|
||||
export function EditorToolbar({
|
||||
selectedSpacecraftId,
|
||||
selectedPlan,
|
||||
platforms,
|
||||
canUndo,
|
||||
canRedo,
|
||||
checking,
|
||||
conflictCount,
|
||||
addedCount,
|
||||
dirty,
|
||||
applying,
|
||||
canceling,
|
||||
windowLabel,
|
||||
rightMode,
|
||||
onSetRightMode,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onCheck,
|
||||
onApply,
|
||||
onCancel,
|
||||
onZoom,
|
||||
onPan
|
||||
onZoom
|
||||
}: EditorToolbarProps) {
|
||||
const platformLabel = 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-window-control" aria-label="Окно редактора">
|
||||
<button type="button" onClick={() => onPan(-0.25)} title="Назад по времени">◀</button>
|
||||
<div className="tgu-editor-window-control" aria-label="Масштаб окна" title="Колесо мыши над таймлайном — тоже масштаб">
|
||||
<button type="button" onClick={() => onZoom(1.4)} title="Отдалить">−</button>
|
||||
<span>{windowLabel || formatDuration(0)}</span>
|
||||
<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>
|
||||
|
||||
<button
|
||||
@@ -110,21 +77,6 @@ export function EditorToolbar({
|
||||
>
|
||||
{checking ? "Проверка..." : conflictCount > 0 ? `Конфликты · ${conflictCount}` : "Проверить"}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
validateMission,
|
||||
type SurveyRoute
|
||||
} from "../../api/missionPlanningApi";
|
||||
import { abandonDraft, createDraft, promoteDraft } from "../../api/tguPlanApi";
|
||||
import { abandonDraft, createDraft } from "../../api/tguPlanApi";
|
||||
import { parseLocal, toLocalIso } from "../../utils/localTime";
|
||||
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
||||
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
|
||||
@@ -30,9 +30,7 @@ import {
|
||||
duplicateWork,
|
||||
mutate,
|
||||
planModesToWorks,
|
||||
redo,
|
||||
seedDraft,
|
||||
undo,
|
||||
updateWork
|
||||
} from "./editorDraft";
|
||||
import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts";
|
||||
@@ -58,6 +56,14 @@ import type {
|
||||
type EditorRightMode = "none" | "inspect" | "shoot" | "downlink" | "mapInfo" | "mapPick";
|
||||
|
||||
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;
|
||||
/**
|
||||
@@ -94,6 +100,14 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
future: []
|
||||
}));
|
||||
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 [selectedWorkId, setSelectedWorkId] = useState<string>();
|
||||
const [target, setTarget] = useState<EditorTarget>();
|
||||
@@ -113,8 +127,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
const [stationDtos, setStationDtos] = useState<StationDto[]>([]);
|
||||
const [serviceConflicts, setServiceConflicts] = useState<EditorConflictMap>({});
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [canceling, setCanceling] = useState(false);
|
||||
const [notice, setNotice] = useState<string>();
|
||||
const [applyError, setApplyError] = useState<string>();
|
||||
const [contextSpacecraftIds, setContextSpacecraftIds] = useState<Set<string>>(new Set());
|
||||
@@ -122,7 +134,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
const [planModes, setPlanModes] = useState<PlanMode[]>([]);
|
||||
// Ленивый черновик (Ф5/Ф6): создаётся клоном базового плана на ПЕРВУЮ правку (replace).
|
||||
// Пока null — редактор показывает живой план read-only; после создания все правки и валидация
|
||||
// адресуются в этот DRAFT, а «Применить» делает его promote в живую цепочку.
|
||||
// адресуются в этот DRAFT. Отправка (promote) и удаление черновика делаются на вкладке «Планы».
|
||||
const [draftPlanId, setDraftPlanId] = useState<string>();
|
||||
const [passFrom, setPassFrom] = useState(1);
|
||||
const [passTo, setPassTo] = useState(999);
|
||||
@@ -237,10 +249,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
.sort((a, b) => a.startMs - b.startMs);
|
||||
}, [planModes, selectedZone]);
|
||||
|
||||
// «Применить» доступно, когда на сервере есть черновик (создаётся на первую сохранённую правку);
|
||||
// локальные несохранённые твики без DRAFT применять нечего.
|
||||
const dirty = Boolean(draftPlanId);
|
||||
const addedCount = draft.works.filter((work) => work.isNew).length;
|
||||
// Реальный состав плана берём из включений (plan modes), а не из черновика:
|
||||
// черновик сидируется заглушкой, когда у плана нет встроенных works.
|
||||
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,
|
||||
// с каскадами drop↔survey), затем убираются из локального представления. Несохранённые
|
||||
// (planModeId нет) — только локально.
|
||||
@@ -770,20 +731,68 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
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) => {
|
||||
const center = (current.fromMs + current.toMs) / 2;
|
||||
const half = ((current.toMs - current.fromMs) / 2) * factor;
|
||||
return { fromMs: center - half, toMs: center + half };
|
||||
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]
|
||||
);
|
||||
|
||||
const pan = (factor: number) => {
|
||||
// Горизонтальный сдвиг окна перетаскиванием: ширину окна не меняем, левую границу зажимаем
|
||||
// в [maxWindow.fromMs; maxWindow.toMs − ширина], чтобы не выехать за план ± час.
|
||||
const panTo = useCallback(
|
||||
(fromMs: number) => {
|
||||
setWindow((current) => {
|
||||
const delta = (current.toMs - current.fromMs) * factor;
|
||||
return { fromMs: current.fromMs + delta, toMs: current.toMs + delta };
|
||||
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) или
|
||||
// линия сброса (drop) → работа таймлайна по modeId ↔ planModeId. Прочие объекты → undefined.
|
||||
@@ -933,14 +942,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
selectedSpacecraftId={selectedSpacecraftId}
|
||||
selectedPlan={selectedPlan}
|
||||
platforms={platforms}
|
||||
canUndo={history.past.length > 0}
|
||||
canRedo={history.future.length > 0}
|
||||
checking={checking}
|
||||
conflictCount={conflictCount(conflicts)}
|
||||
addedCount={addedCount}
|
||||
dirty={dirty}
|
||||
applying={applying}
|
||||
canceling={canceling}
|
||||
windowLabel={formatDuration(window.toMs - window.fromMs)}
|
||||
rightMode={rightMode}
|
||||
onSetRightMode={(mode) => {
|
||||
@@ -950,13 +953,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
clearMapPick();
|
||||
setNotice(undefined);
|
||||
}}
|
||||
onUndo={() => setHistory((current) => undo(current))}
|
||||
onRedo={() => setHistory((current) => redo(current))}
|
||||
onCheck={runCheck}
|
||||
onApply={applyDraft}
|
||||
onCancel={cancelDraft}
|
||||
onZoom={zoom}
|
||||
onPan={pan}
|
||||
/>
|
||||
|
||||
<div className="tgu-editor-notices">
|
||||
@@ -1061,6 +1059,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
setMapFocus((prev) => ({ ...prev, objectId: undefined }));
|
||||
}
|
||||
}}
|
||||
onWheelZoom={zoomAt}
|
||||
onPanTo={panTo}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -1217,8 +1217,8 @@ function defaultEditorWindow(
|
||||
): EditorTimelineWindow {
|
||||
if (selectedPlan) {
|
||||
return {
|
||||
fromMs: selectedPlan.startMs - 3 * HOUR_MS,
|
||||
toMs: selectedPlan.endMs + 3 * HOUR_MS
|
||||
fromMs: selectedPlan.startMs - EDITOR_WINDOW_PAD_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())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подпись деления оси с учётом шага: при шаге меньше минуты показываем секунды
|
||||
* (ЧЧ:ММ:СС), иначе — ЧЧ:ММ. Так при сильном зуме видно более мелкий масштаб времени.
|
||||
*/
|
||||
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 {
|
||||
const date = new Date(ms);
|
||||
return `${pad(date.getDate())} ${EDITOR_MONTHS[date.getMonth()]}`;
|
||||
@@ -57,10 +69,19 @@ export function formatDuration(ms: number): string {
|
||||
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 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 ticks: number[] = [];
|
||||
const firstTick = Math.ceil(fromMs / step) * step;
|
||||
@@ -69,7 +90,7 @@ export function niceTicks(fromMs: number, toMs: number, pxPerMs: number, minPx:
|
||||
ticks.push(timeMs);
|
||||
}
|
||||
|
||||
return ticks;
|
||||
return { ticks, step };
|
||||
}
|
||||
|
||||
function pad(value: number): string {
|
||||
|
||||
@@ -79,6 +79,16 @@ export function TguPlanDetails({
|
||||
onDecision(plan.planId, "REJECTED", reason);
|
||||
};
|
||||
|
||||
const promoteDraft = () => {
|
||||
if (!window.confirm("Отправить черновик на выдачу? Он уйдёт в цепочку и станет рабочим после приёмки.")) return;
|
||||
onPromoteDraft?.(plan.planId);
|
||||
};
|
||||
|
||||
const deleteDraft = () => {
|
||||
if (!window.confirm("Удалить черновик? Его нельзя будет восстановить.")) return;
|
||||
onDeleteDraft?.(plan.planId);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="tgu-details">
|
||||
<div className="tgu-details__header">
|
||||
@@ -135,7 +145,7 @@ export function TguPlanDetails({
|
||||
className="tgu-button tgu-button--accept"
|
||||
type="button"
|
||||
disabled={draftActionInFlight}
|
||||
onClick={() => onPromoteDraft?.(plan.planId)}
|
||||
onClick={promoteDraft}
|
||||
>
|
||||
Отправить
|
||||
</button>
|
||||
@@ -143,7 +153,7 @@ export function TguPlanDetails({
|
||||
className="tgu-button tgu-button--reject"
|
||||
type="button"
|
||||
disabled={draftActionInFlight}
|
||||
onClick={() => onDeleteDraft?.(plan.planId)}
|
||||
onClick={deleteDraft}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user