pcp-tgu-ops-ui: вкладка Редактор — план, включения, нижняя панель
- Слева выводим даты начала/конца плана и корректные счётчики съёмки/сброса (из plan modes, а не из заглушки-черновика). - На таймлайне показываем реальные включения плана (read-only): убран фейковый «сброс на весь интервал»; вместо линии «сейчас» — маркеры начала и конца плана; клик по включению открывает его инфо справа. - Включения съёмки и сброса разнесены на два ряда (верх/низ) и различаются цветом, слева — подписи рядов. - Нижняя панель: активная дорожка резиновая — примыкает к низу и не пустует, при подъёме панели ряды расширяются; высота бара ограничена потолком и центрируется в ряду, чтобы узкие включения не вытягивались. - Время в редакторе парсится/форматируется через utils/utcTime.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation";
|
||||
import { statusStyle } from "../tgu-planning/tguStatus";
|
||||
import { formatUtcDateTime, parseUtc } from "../../utils/utcTime";
|
||||
import type { RvaZone } from "../../api/ballisticsApi";
|
||||
import type { SurveyRoute, SurveyRouteAnchor } from "../../api/missionPlanningApi";
|
||||
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
|
||||
@@ -28,6 +29,8 @@ type WorkInspectorProps = {
|
||||
modes: EditorMode[];
|
||||
stations: EditorGroundStation[];
|
||||
conflicts: string[];
|
||||
/** Включение существующего плана — показываем только информацию, без правок. */
|
||||
readOnly?: boolean;
|
||||
onUpdate: (workId: string, patch: EditorWorkPatch) => void;
|
||||
onDelete: (workId: string) => void;
|
||||
onDuplicate: (workId: string) => void;
|
||||
@@ -96,6 +99,7 @@ export function WorkInspector({
|
||||
modes,
|
||||
stations,
|
||||
conflicts,
|
||||
readOnly,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
@@ -118,26 +122,43 @@ export function WorkInspector({
|
||||
|
||||
<section className="tgu-editor-panel__section">
|
||||
<div className="tgu-editor-eyebrow">Время</div>
|
||||
<TimeStepper label="начало" value={work.startMs} max={work.endMs - 60_000} onChange={(value) => onUpdate(work.id, { startMs: value })} />
|
||||
<TimeStepper label="конец" value={work.endMs} min={work.startMs + 60_000} onChange={(value) => onUpdate(work.id, { endMs: value })} />
|
||||
<div className="tgu-editor-panel__hint">длительность: {formatDuration(work.endMs - work.startMs)}</div>
|
||||
{readOnly ? (
|
||||
<dl className="tgu-map-request-details__grid">
|
||||
<dt>Начало</dt>
|
||||
<dd className="mono">{formatDateTime(work.startMs)}</dd>
|
||||
<dt>Конец</dt>
|
||||
<dd className="mono">{formatDateTime(work.endMs)}</dd>
|
||||
<dt>Длительность</dt>
|
||||
<dd className="mono">{formatDuration(work.endMs - work.startMs)}</dd>
|
||||
</dl>
|
||||
) : (
|
||||
<>
|
||||
<TimeStepper label="начало" value={work.startMs} max={work.endMs - 60_000} onChange={(value) => onUpdate(work.id, { startMs: value })} />
|
||||
<TimeStepper label="конец" value={work.endMs} min={work.startMs + 60_000} onChange={(value) => onUpdate(work.id, { endMs: value })} />
|
||||
<div className="tgu-editor-panel__hint">длительность: {formatDuration(work.endMs - work.startMs)}</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{work.kind === "shoot" && (
|
||||
<section className="tgu-editor-panel__section">
|
||||
<div className="tgu-editor-eyebrow">Режим аппаратуры</div>
|
||||
<div className="tgu-editor-chip-row">
|
||||
{modes.map((mode) => (
|
||||
<button
|
||||
className={work.modeId === mode.id ? "is-selected" : ""}
|
||||
key={mode.id}
|
||||
type="button"
|
||||
onClick={() => onUpdate(work.id, { modeId: mode.id, label: mode.label })}
|
||||
>
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{readOnly ? (
|
||||
<div className="tgu-editor-panel__hint">{modes.find((mode) => mode.id === work.modeId)?.label ?? "—"}</div>
|
||||
) : (
|
||||
<div className="tgu-editor-chip-row">
|
||||
{modes.map((mode) => (
|
||||
<button
|
||||
className={work.modeId === mode.id ? "is-selected" : ""}
|
||||
key={mode.id}
|
||||
type="button"
|
||||
onClick={() => onUpdate(work.id, { modeId: mode.id, label: mode.label })}
|
||||
>
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{work.targetName && <div className="tgu-editor-panel__hint">цель: {work.targetName}</div>}
|
||||
</section>
|
||||
)}
|
||||
@@ -145,24 +166,30 @@ export function WorkInspector({
|
||||
{work.kind === "downlink" && (
|
||||
<section className="tgu-editor-panel__section">
|
||||
<div className="tgu-editor-eyebrow">Станция приёма</div>
|
||||
<select value={work.stationId ?? ""} onChange={(event) => onUpdate(work.id, { stationId: event.target.value })}>
|
||||
{stations.map((station) => (
|
||||
<option key={station.id} value={station.id}>
|
||||
{station.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{readOnly ? (
|
||||
<div className="tgu-editor-panel__hint">{stations.find((station) => station.id === work.stationId)?.name ?? work.stationId ?? "—"}</div>
|
||||
) : (
|
||||
<select value={work.stationId ?? ""} onChange={(event) => onUpdate(work.id, { stationId: event.target.value })}>
|
||||
{stations.map((station) => (
|
||||
<option key={station.id} value={station.id}>
|
||||
{station.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="tgu-editor-panel__actions">
|
||||
<button className="tgu-button" type="button" onClick={() => onDuplicate(work.id)}>
|
||||
Дублировать
|
||||
</button>
|
||||
<button className="tgu-button tgu-button--reject" type="button" onClick={() => onDelete(work.id)}>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="tgu-editor-panel__actions">
|
||||
<button className="tgu-button" type="button" onClick={() => onDuplicate(work.id)}>
|
||||
Дублировать
|
||||
</button>
|
||||
<button className="tgu-button tgu-button--reject" type="button" onClick={() => onDelete(work.id)}>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="tgu-editor-panel__color" style={{ background: color }} aria-hidden="true" />
|
||||
</div>
|
||||
@@ -556,7 +583,7 @@ function surveyTypeLabel(surveyType: string): string {
|
||||
}
|
||||
|
||||
function formatIso(iso: string): string {
|
||||
return new Date(iso).toLocaleString("ru-RU");
|
||||
return formatUtcDateTime(parseUtc(iso));
|
||||
}
|
||||
|
||||
function PanelHead({ title, onClose }: { title: string; onClose: () => void }) {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { TguPlanUi, TguPlatformUi } from "../../model/timelineTypes";
|
||||
import type { Draft, EditorWork } from "./model/editorTypes";
|
||||
import { formatDateTime } from "./editorPresentation";
|
||||
|
||||
type EditorRailProps = {
|
||||
selectedSpacecraftId?: string;
|
||||
selectedPlan?: TguPlanUi;
|
||||
platforms: TguPlatformUi[];
|
||||
draft: Draft;
|
||||
surveyCount: number;
|
||||
downlinkCount: number;
|
||||
conflictCount: number;
|
||||
contextSpacecraftIds: Set<string>;
|
||||
onToggleContext: (spacecraftId: string) => void;
|
||||
@@ -15,7 +16,8 @@ export function EditorRail({
|
||||
selectedSpacecraftId,
|
||||
selectedPlan,
|
||||
platforms,
|
||||
draft,
|
||||
surveyCount,
|
||||
downlinkCount,
|
||||
conflictCount,
|
||||
contextSpacecraftIds,
|
||||
onToggleContext
|
||||
@@ -32,10 +34,19 @@ export function EditorRail({
|
||||
{selectedPlan ? `${selectedPlan.planId} · КПП ${selectedPlan.kppId}` : "План не выбран"}
|
||||
</div>
|
||||
|
||||
{selectedPlan && (
|
||||
<dl className="tgu-editor-rail__plan-dates">
|
||||
<dt>Начало</dt>
|
||||
<dd className="mono">{formatDateTime(selectedPlan.startMs)}</dd>
|
||||
<dt>Окончание</dt>
|
||||
<dd className="mono">{formatDateTime(selectedPlan.endMs)}</dd>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<div className="tgu-editor-stats">
|
||||
<RailStat label="Включений" value={draft.works.length} />
|
||||
<RailStat label="Съёмка" value={countWorks(draft.works, "shoot")} color="var(--t-optical)" />
|
||||
<RailStat label="Сброс" value={countWorks(draft.works, "downlink")} color="var(--accent)" />
|
||||
<RailStat label="Включений" value={surveyCount + downlinkCount} />
|
||||
<RailStat label="Съёмка" value={surveyCount} color="var(--t-optical)" />
|
||||
<RailStat label="Сброс" value={downlinkCount} color="var(--accent)" />
|
||||
<RailStat label="Конфл." value={conflictCount} color={conflictCount > 0 ? "var(--now)" : undefined} />
|
||||
</div>
|
||||
</section>
|
||||
@@ -76,7 +87,3 @@ function RailStat({ label, value, color }: { label: string; value: number; color
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function countWorks(works: EditorWork[], kind: EditorWork["kind"]): number {
|
||||
return works.filter((work) => work.kind === kind).length;
|
||||
}
|
||||
|
||||
@@ -4,128 +4,86 @@ import type {
|
||||
EditorContextLane,
|
||||
EditorTimelineWindow,
|
||||
EditorVisibilityTrack,
|
||||
EditorWork,
|
||||
EditorWorkPatch
|
||||
EditorWork
|
||||
} from "./model/editorTypes";
|
||||
import { VisibilityTracks } from "./VisibilityTracks";
|
||||
|
||||
type EditorTimelineProps = {
|
||||
activeLabel: string;
|
||||
draft: EditorWork[];
|
||||
works: EditorWork[];
|
||||
selectedWorkId?: string;
|
||||
window: EditorTimelineWindow;
|
||||
nowMs: number;
|
||||
planStartMs?: number;
|
||||
planEndMs?: number;
|
||||
conflicts: Record<string, string[]>;
|
||||
contextLanes: EditorContextLane[];
|
||||
visibilityTracks: EditorVisibilityTrack[];
|
||||
onSelectWork: (workId: string) => void;
|
||||
onClearSelection: () => void;
|
||||
onChangeWork: (workId: string, patch: EditorWorkPatch) => void;
|
||||
};
|
||||
|
||||
const LABEL_WIDTH = 168;
|
||||
const AXIS_HEIGHT = 40;
|
||||
const ACTIVE_HEIGHT = 66;
|
||||
/** Минимальная высота активной дорожки (два ряда: съёмка + сброс). */
|
||||
const MIN_ACTIVE_HEIGHT = 84;
|
||||
const VISIBILITY_HEIGHT = 24;
|
||||
const OTHER_HEIGHT = 30;
|
||||
const SNAP_MS = 60 * 1000;
|
||||
|
||||
type DragState = {
|
||||
workId: string;
|
||||
mode: "move" | "left" | "right";
|
||||
startX: number;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
};
|
||||
|
||||
export function EditorTimeline({
|
||||
activeLabel,
|
||||
draft,
|
||||
works,
|
||||
selectedWorkId,
|
||||
window: timelineWindow,
|
||||
nowMs,
|
||||
planStartMs,
|
||||
planEndMs,
|
||||
conflicts,
|
||||
contextLanes,
|
||||
visibilityTracks,
|
||||
onSelectWork,
|
||||
onClearSelection,
|
||||
onChangeWork
|
||||
onClearSelection
|
||||
}: EditorTimelineProps) {
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragRef = useRef<DragState | undefined>(undefined);
|
||||
const [bodyWidth, setBodyWidth] = useState(900);
|
||||
const [bodyHeight, setBodyHeight] = useState(260);
|
||||
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 days = useMemo(() => buildDays(timelineWindow), [timelineWindow]);
|
||||
const visibilityTop = AXIS_HEIGHT + ACTIVE_HEIGHT;
|
||||
const dividerTop = visibilityTop + visibilityTracks.length * VISIBILITY_HEIGHT;
|
||||
const contextTop = dividerTop + (contextLanes.length > 0 ? 22 : 0);
|
||||
const totalHeight =
|
||||
AXIS_HEIGHT +
|
||||
ACTIVE_HEIGHT +
|
||||
// Фиксированная высота частей ниже активной дорожки (треки видимости + контекст КА).
|
||||
const belowActiveHeight =
|
||||
visibilityTracks.length * VISIBILITY_HEIGHT +
|
||||
(contextLanes.length > 0 ? 22 : 0) +
|
||||
contextLanes.length * OTHER_HEIGHT +
|
||||
8;
|
||||
// Активная дорожка резиновая: занимает всё свободное место панели, поэтому при
|
||||
// подъёме панели вверх ряды съёмки/сброса расширяются, а низ не пустует.
|
||||
const activeHeight = Math.max(MIN_ACTIVE_HEIGHT, bodyHeight - AXIS_HEIGHT - belowActiveHeight);
|
||||
const visibilityTop = AXIS_HEIGHT + activeHeight;
|
||||
const dividerTop = visibilityTop + visibilityTracks.length * VISIBILITY_HEIGHT;
|
||||
const contextTop = dividerTop + (contextLanes.length > 0 ? 22 : 0);
|
||||
const contentHeight = AXIS_HEIGHT + activeHeight + belowActiveHeight;
|
||||
const surfaceHeight = Math.max(bodyHeight, contentHeight);
|
||||
|
||||
useEffect(() => {
|
||||
const element = wrapperRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
const measure = () => {
|
||||
setBodyWidth(Math.max(560, element.clientWidth - LABEL_WIDTH));
|
||||
});
|
||||
setBodyHeight(element.clientHeight);
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element);
|
||||
setBodyWidth(Math.max(560, element.clientWidth - LABEL_WIDTH));
|
||||
measure();
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (event: MouseEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
|
||||
const deltaMs = Math.round(((event.clientX - drag.startX) / pxPerMs) / SNAP_MS) * SNAP_MS;
|
||||
if (drag.mode === "move") {
|
||||
onChangeWork(drag.workId, { startMs: drag.startMs + deltaMs, endMs: drag.endMs + deltaMs });
|
||||
} else if (drag.mode === "left") {
|
||||
onChangeWork(drag.workId, { startMs: Math.min(drag.endMs - SNAP_MS, drag.startMs + deltaMs) });
|
||||
} else {
|
||||
onChangeWork(drag.workId, { endMs: Math.max(drag.startMs + SNAP_MS, drag.endMs + deltaMs) });
|
||||
}
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
dragRef.current = undefined;
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
}, [onChangeWork, pxPerMs]);
|
||||
|
||||
const startDrag = (event: React.MouseEvent, work: EditorWork, mode: DragState["mode"]) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dragRef.current = {
|
||||
workId: work.id,
|
||||
mode,
|
||||
startX: event.clientX,
|
||||
startMs: work.startMs,
|
||||
endMs: work.endMs
|
||||
};
|
||||
onSelectWork(work.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tgu-editor-timeline" ref={wrapperRef} onClick={onClearSelection}>
|
||||
<div className="tgu-editor-timeline__surface" style={{ width: LABEL_WIDTH + bodyWidth, height: Math.max(220, totalHeight) }}>
|
||||
<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 }}>
|
||||
Время (UTC)
|
||||
</div>
|
||||
@@ -149,19 +107,27 @@ export function EditorTimeline({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{nowMs >= timelineWindow.fromMs && nowMs <= timelineWindow.toMs && (
|
||||
<div className="tgu-editor-timeline__now" style={{ left: LABEL_WIDTH + toX(nowMs), top: AXIS_HEIGHT }}>
|
||||
<span>Сейчас</span>
|
||||
{planStartMs != null && planStartMs >= timelineWindow.fromMs && planStartMs <= timelineWindow.toMs && (
|
||||
<div className="tgu-editor-timeline__bound tgu-editor-timeline__bound--start" style={{ left: LABEL_WIDTH + toX(planStartMs), top: AXIS_HEIGHT }}>
|
||||
<span>Начало плана</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="tgu-editor-timeline__lane" style={{ top: AXIS_HEIGHT, height: ACTIVE_HEIGHT }}>
|
||||
<div className="tgu-editor-timeline__lane-label" style={{ width: LABEL_WIDTH }}>
|
||||
{planEndMs != null && planEndMs >= timelineWindow.fromMs && planEndMs <= timelineWindow.toMs && (
|
||||
<div className="tgu-editor-timeline__bound tgu-editor-timeline__bound--end" style={{ left: LABEL_WIDTH + toX(planEndMs), top: AXIS_HEIGHT }}>
|
||||
<span>Конец плана</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="tgu-editor-timeline__lane" style={{ top: AXIS_HEIGHT, height: activeHeight }}>
|
||||
<div className="tgu-editor-timeline__lane-label tgu-editor-timeline__lane-label--split" style={{ width: LABEL_WIDTH }}>
|
||||
<span>{activeLabel}</span>
|
||||
<small>редактируется</small>
|
||||
<em className="tgu-editor-timeline__row-hint tgu-editor-timeline__row-hint--shoot">Съёмка</em>
|
||||
<em className="tgu-editor-timeline__row-hint tgu-editor-timeline__row-hint--downlink">Сброс</em>
|
||||
</div>
|
||||
<div className="tgu-editor-timeline__lane-body" style={{ left: LABEL_WIDTH, width: bodyWidth }}>
|
||||
{draft.map((work) => {
|
||||
{works.map((work) => {
|
||||
const left = toX(work.startMs);
|
||||
const width = Math.max(6, toX(work.endMs) - toX(work.startMs));
|
||||
const color = kindColor(work.kind);
|
||||
@@ -169,8 +135,9 @@ export function EditorTimeline({
|
||||
const hasConflict = Boolean(conflicts[work.id]?.length);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`tgu-editor-work ${selected ? "is-selected" : ""} ${hasConflict ? "has-conflict" : ""}`}
|
||||
<button
|
||||
className={`tgu-editor-work tgu-editor-work--${work.kind} ${selected ? "is-selected" : ""} ${hasConflict ? "has-conflict" : ""}`}
|
||||
type="button"
|
||||
key={work.id}
|
||||
style={{
|
||||
left,
|
||||
@@ -179,19 +146,16 @@ export function EditorTimeline({
|
||||
background: `color-mix(in srgb, ${color} 22%, var(--bg-1))`
|
||||
}}
|
||||
title={`${kindLabel(work.kind)} · ${formatDateTime(work.startMs)}-${formatTime(work.endMs)}`}
|
||||
onMouseDown={(event) => startDrag(event, work, "move")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelectWork(work.id);
|
||||
}}
|
||||
>
|
||||
<span className="tgu-editor-work__resize tgu-editor-work__resize--left" onMouseDown={(event) => startDrag(event, work, "left")} />
|
||||
<span className="tgu-editor-work__resize tgu-editor-work__resize--right" onMouseDown={(event) => startDrag(event, work, "right")} />
|
||||
<span className="tgu-editor-work__dot" style={{ background: color }} />
|
||||
{width > 54 && <span className="tgu-editor-work__label">{work.label}</span>}
|
||||
{work.isNew && <span className="tgu-editor-work__new">NEW</span>}
|
||||
{width > 82 && <small>{formatTime(work.startMs)}-{formatTime(work.endMs)}</small>}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { RequestMapItem } from "../../model/requestTypes";
|
||||
import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes";
|
||||
import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi";
|
||||
import { buildSurveyRoute, saveDropRoute, saveSurveyRoute, type SurveyRoute, type SurveyRouteAnchor } from "../../api/missionPlanningApi";
|
||||
import { parseUtc } from "../../utils/utcTime";
|
||||
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
||||
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
|
||||
import { buildStationMarkers } from "../tgu-map-2d/Tgu2DMapLayers";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
deleteWork,
|
||||
duplicateWork,
|
||||
mutate,
|
||||
planModesToWorks,
|
||||
redo,
|
||||
seedDraft,
|
||||
undo,
|
||||
@@ -40,6 +42,7 @@ import type {
|
||||
EditorTimelineWindow,
|
||||
EditorVisibilityTrack,
|
||||
EditorVisibilityWindow,
|
||||
EditorWork,
|
||||
EditorWorkPatch,
|
||||
TguEditorTabProps
|
||||
} from "./model/editorTypes";
|
||||
@@ -68,7 +71,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
const idCounterRef = useRef(0);
|
||||
const targetCounterRef = useRef(0);
|
||||
const centerRef = useRef<HTMLElement>(null);
|
||||
const [mapRatio, setMapRatio] = useState(0.72);
|
||||
// Доля высоты под карту: по умолчанию карта крупная, а нижняя панель компактно
|
||||
// примыкает к низу. Подъём резайзера вверх уменьшает долю и расширяет панель.
|
||||
const [mapRatio, setMapRatio] = useState(0.8);
|
||||
const [history, setHistory] = useState<DraftHistory>(() => ({
|
||||
draft: seedDraft(selectedPlan, selectedSpacecraftId),
|
||||
past: [],
|
||||
@@ -124,14 +129,17 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
.map(([revolution, pts], idx) => ({
|
||||
index: idx + 1,
|
||||
revolution,
|
||||
startMs: Date.parse(pts[0].time),
|
||||
endMs: Date.parse(pts[pts.length - 1].time),
|
||||
startMs: parseUtc(pts[0].time),
|
||||
endMs: parseUtc(pts[pts.length - 1].time),
|
||||
}));
|
||||
}, [flightLineData]);
|
||||
const modes: EditorMode[] = DEFAULT_EDITOR_MODES;
|
||||
// Существующие включения плана (read-only) + правки из черновика — то, что видно на таймлайне.
|
||||
const planWorks = useMemo<EditorWork[]>(() => planModesToWorks(planModes), [planModes]);
|
||||
const displayWorks = useMemo<EditorWork[]>(() => [...planWorks, ...draft.works], [planWorks, draft.works]);
|
||||
const liveConflicts = useMemo(() => overlaps(draft.works), [draft.works]);
|
||||
const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]);
|
||||
const selectedWork = draft.works.find((work) => work.id === selectedWorkId);
|
||||
const selectedWork = displayWorks.find((work) => work.id === selectedWorkId);
|
||||
const activeLabel = platformLabel(platforms, selectedSpacecraftId);
|
||||
const [shootPasses, setShootPasses] = useState<EditorVisibilityWindow[]>([]);
|
||||
const [shootPassesLoading, setShootPassesLoading] = useState(false);
|
||||
@@ -187,6 +195,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
|
||||
const dirty = history.past.length > 0 || draft.works.some((work) => work.isNew);
|
||||
const addedCount = draft.works.filter((work) => work.isNew).length;
|
||||
// Реальный состав плана берём из включений (plan modes), а не из черновика:
|
||||
// черновик сидируется заглушкой, когда у плана нет встроенных works.
|
||||
const planSurveyCount = useMemo(() => planModes.filter((mode) => mode.type === "SURVEY").length, [planModes]);
|
||||
const planDownlinkCount = useMemo(() => planModes.filter((mode) => mode.type === "DROP").length, [planModes]);
|
||||
|
||||
useEffect(() => {
|
||||
setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] });
|
||||
@@ -559,7 +571,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
const el = centerRef.current;
|
||||
if (!el) return;
|
||||
const dy = ev.clientY - startY;
|
||||
setMapRatio(Math.max(0.15, Math.min(0.80, startRatio + dy / el.clientHeight)));
|
||||
setMapRatio(Math.max(0.15, Math.min(0.86, startRatio + dy / el.clientHeight)));
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
@@ -618,7 +630,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
selectedSpacecraftId={selectedSpacecraftId}
|
||||
selectedPlan={selectedPlan}
|
||||
platforms={platforms}
|
||||
draft={draft}
|
||||
surveyCount={planSurveyCount}
|
||||
downlinkCount={planDownlinkCount}
|
||||
conflictCount={conflictCount(conflicts)}
|
||||
contextSpacecraftIds={contextSpacecraftIds}
|
||||
onToggleContext={(spacecraftId) => {
|
||||
@@ -681,10 +694,11 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
</div>
|
||||
<EditorTimeline
|
||||
activeLabel={activeLabel}
|
||||
draft={draft.works}
|
||||
works={displayWorks}
|
||||
selectedWorkId={selectedWorkId}
|
||||
window={window}
|
||||
nowMs={Date.now()}
|
||||
planStartMs={selectedPlan?.startMs}
|
||||
planEndMs={selectedPlan?.endMs}
|
||||
conflicts={conflicts}
|
||||
contextLanes={contextLanes}
|
||||
visibilityTracks={visibilityTracks}
|
||||
@@ -699,7 +713,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
setRightMode("none");
|
||||
}
|
||||
}}
|
||||
onChangeWork={onChangeWork}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -711,6 +724,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
modes={modes}
|
||||
stations={stations}
|
||||
conflicts={conflicts[selectedWork.id] ?? []}
|
||||
readOnly={selectedWork.origin === "plan"}
|
||||
onUpdate={onChangeWork}
|
||||
onDelete={(workId) => {
|
||||
changeDraft((current) => deleteWork(current, workId));
|
||||
@@ -810,7 +824,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
}
|
||||
|
||||
function visibilityWindowFromParam(param: PointVisibilityParam): EditorVisibilityWindow {
|
||||
const timeMs = Date.parse(param.time);
|
||||
const timeMs = parseUtc(param.time);
|
||||
const half = SHOOT_WINDOW_MS / 2;
|
||||
return {
|
||||
startMs: timeMs - half,
|
||||
@@ -851,8 +865,7 @@ function buildStations(selectedPlan: TguEditorTabProps["selectedPlan"]): EditorG
|
||||
* зоны ЗРВ приходят без таймзоны, а времена съёмок — с offset; приводим к одной шкале.
|
||||
*/
|
||||
function localDateTimeMs(s: string): number {
|
||||
const hasZone = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(s);
|
||||
return Date.parse(hasZone ? s : `${s}Z`);
|
||||
return parseUtc(s);
|
||||
}
|
||||
|
||||
function buildVisibilityTracks(
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
deleteWork,
|
||||
duplicateWork,
|
||||
mutate,
|
||||
planModesToWorks,
|
||||
redo,
|
||||
seedDraft,
|
||||
undo,
|
||||
updateWork
|
||||
} from "./editorDraft";
|
||||
import type { PlanMode } from "./editorApi";
|
||||
import type { DraftHistory } from "./model/editorTypes";
|
||||
|
||||
const plan: TguPlanUi = {
|
||||
@@ -25,24 +27,81 @@ const plan: TguPlanUi = {
|
||||
};
|
||||
|
||||
describe("editorDraft", () => {
|
||||
it("seeds a draft from the current API plan envelope when embedded works are absent", () => {
|
||||
it("seeds an empty draft when the plan has no embedded works", () => {
|
||||
// Существующие включения грузятся отдельно (planModesToWorks); черновик хранит только правки.
|
||||
expect(seedDraft(plan)).toEqual({
|
||||
planId: "plan-1",
|
||||
spacecraftId: "56756",
|
||||
works: [
|
||||
{
|
||||
id: "plan-1:plan-window",
|
||||
kind: "downlink",
|
||||
label: "План ТГУ · KPP-1",
|
||||
startMs: plan.startMs,
|
||||
endMs: plan.endMs,
|
||||
stationId: "KPP-1",
|
||||
origin: "plan"
|
||||
}
|
||||
]
|
||||
works: []
|
||||
});
|
||||
});
|
||||
|
||||
it("maps plan modes to read-only timeline works", () => {
|
||||
const modes: PlanMode[] = [
|
||||
{
|
||||
id: 11,
|
||||
planId: 1,
|
||||
timeStart: "2026-05-29T01:10:00",
|
||||
revolution: 4321,
|
||||
type: "SURVEY",
|
||||
status: "PLANNED",
|
||||
lat: 55.7,
|
||||
longitude: 37.6,
|
||||
duration: 180,
|
||||
contourWkt: null,
|
||||
roll: 0
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
planId: 1,
|
||||
timeStart: "2026-05-29T01:40:00",
|
||||
revolution: 4322,
|
||||
type: "DROP",
|
||||
station: "102",
|
||||
duration: 120,
|
||||
surveys: [11]
|
||||
},
|
||||
{
|
||||
id: 33,
|
||||
planId: 1,
|
||||
timeStart: null,
|
||||
revolution: 4323,
|
||||
type: "SURVEY",
|
||||
status: "PLANNED",
|
||||
lat: 0,
|
||||
longitude: 0,
|
||||
duration: 60,
|
||||
contourWkt: null,
|
||||
roll: 0
|
||||
}
|
||||
];
|
||||
|
||||
const works = planModesToWorks(modes);
|
||||
|
||||
// Включение без времени старта пропускается, оба валидных — read-only (origin: "plan").
|
||||
expect(works).toEqual([
|
||||
{
|
||||
id: "survey-11",
|
||||
kind: "shoot",
|
||||
label: "Съёмка · виток 4321",
|
||||
startMs: Date.parse("2026-05-29T01:10:00Z"),
|
||||
endMs: Date.parse("2026-05-29T01:10:00Z") + 180_000,
|
||||
origin: "plan",
|
||||
targetLat: 55.7,
|
||||
targetLon: 37.6
|
||||
},
|
||||
{
|
||||
id: "drop-22",
|
||||
kind: "downlink",
|
||||
label: "Сброс · 102",
|
||||
startMs: Date.parse("2026-05-29T01:40:00Z"),
|
||||
endMs: Date.parse("2026-05-29T01:40:00Z") + 120_000,
|
||||
origin: "plan",
|
||||
stationId: "102"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds shoot and downlink works with deterministic ids", () => {
|
||||
const draft = seedDraft(undefined, "56756");
|
||||
const withShoot = addShoot(draft, {
|
||||
@@ -88,10 +147,16 @@ describe("editorDraft", () => {
|
||||
});
|
||||
|
||||
it("updates, duplicates and deletes works without generating ids internally", () => {
|
||||
const draft = seedDraft(plan);
|
||||
const updated = updateWork(draft, "plan-1:plan-window", { label: "Сеанс", startMs: plan.startMs + 60_000 });
|
||||
const duplicated = duplicateWork(updated, "plan-1:plan-window", "copy-1", 120_000);
|
||||
const deleted = deleteWork(duplicated, "plan-1:plan-window");
|
||||
const draft = addDownlink(seedDraft(plan), {
|
||||
id: "downlink-1",
|
||||
label: "Сброс на НКПОР",
|
||||
startMs: plan.startMs,
|
||||
endMs: plan.startMs + 120_000,
|
||||
station: { id: "KPP-1", name: "KPP-1" }
|
||||
});
|
||||
const updated = updateWork(draft, "downlink-1", { label: "Сеанс", startMs: plan.startMs + 60_000 });
|
||||
const duplicated = duplicateWork(updated, "downlink-1", "copy-1", 120_000);
|
||||
const deleted = deleteWork(duplicated, "downlink-1");
|
||||
|
||||
expect(updated.works[0].label).toBe("Сеанс");
|
||||
expect(duplicated.works[1]).toMatchObject({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { TguPlanUi } from "../../model/timelineTypes";
|
||||
import { parseUtc } from "../../utils/utcTime";
|
||||
import type { PlanMode } from "./editorApi";
|
||||
import type {
|
||||
Draft,
|
||||
DraftHistory,
|
||||
@@ -35,14 +37,53 @@ export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft {
|
||||
return { spacecraftId, works: [] };
|
||||
}
|
||||
|
||||
const embeddedWorks = readEmbeddedWorks(plan);
|
||||
return {
|
||||
planId: plan.planId,
|
||||
spacecraftId: plan.spacecraftId,
|
||||
works: embeddedWorks.length > 0 ? embeddedWorks : [fallbackPlanWork(plan)]
|
||||
// Черновик содержит только правки, внесённые в редакторе. Существующие включения
|
||||
// плана грузятся отдельно (fetchPlanModes) и показываются как read-only.
|
||||
works: readEmbeddedWorks(plan)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразует включения плана (plan modes из pcp-tgu-service) в работы для отображения
|
||||
* на таймлайне редактора. Это read-only представление фактического состава плана:
|
||||
* SURVEY → съёмка, DROP → сброс на НКПОР.
|
||||
*/
|
||||
export function planModesToWorks(modes: PlanMode[]): EditorWork[] {
|
||||
return modes.flatMap((mode): EditorWork[] => {
|
||||
if (mode.timeStart == null) return [];
|
||||
const startMs = parseUtc(mode.timeStart);
|
||||
if (!Number.isFinite(startMs)) return [];
|
||||
const endMs = startMs + Math.max(1, mode.duration) * 1000;
|
||||
|
||||
if (mode.type === "SURVEY") {
|
||||
return [{
|
||||
id: `survey-${mode.id}`,
|
||||
kind: "shoot",
|
||||
label: `Съёмка · виток ${mode.revolution}`,
|
||||
startMs,
|
||||
endMs,
|
||||
origin: "plan",
|
||||
targetLat: mode.lat,
|
||||
targetLon: mode.longitude
|
||||
}];
|
||||
}
|
||||
|
||||
return [{
|
||||
id: `drop-${mode.id}`,
|
||||
kind: "downlink",
|
||||
label: mode.station ? `Сброс · ${mode.station}` : "Сброс на НКПОР",
|
||||
startMs,
|
||||
endMs,
|
||||
origin: "plan",
|
||||
stationId: mode.station ?? undefined
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function mutate(
|
||||
history: DraftHistory,
|
||||
transform: (draft: Draft) => Draft,
|
||||
@@ -154,18 +195,6 @@ export function duplicateWork(
|
||||
return { ...draft, works: [...draft.works, duplicated] };
|
||||
}
|
||||
|
||||
function fallbackPlanWork(plan: TguPlanUi): EditorDownlinkWork {
|
||||
return {
|
||||
id: `${plan.planId}:plan-window`,
|
||||
kind: "downlink",
|
||||
label: `План ТГУ · ${plan.kppId}`,
|
||||
startMs: plan.startMs,
|
||||
endMs: plan.endMs,
|
||||
stationId: plan.kppId,
|
||||
origin: "plan"
|
||||
};
|
||||
}
|
||||
|
||||
function readEmbeddedWorks(plan: TguPlanUi): EditorWork[] {
|
||||
const rawWorks = (plan as TguPlanUi & { works?: unknown }).works;
|
||||
if (!Array.isArray(rawWorks)) {
|
||||
@@ -217,7 +246,7 @@ function readTime(record: Record<string, unknown>, key: string): number | undefi
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = new Date(value).getTime();
|
||||
const parsed = parseUtc(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -147,6 +147,25 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tgu-editor-rail__plan-dates {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.2rem 0.6rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tgu-editor-rail__plan-dates dt {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.tgu-editor-rail__plan-dates dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 0.7rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tgu-editor-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -553,26 +572,34 @@
|
||||
border-left: 1px solid var(--grid);
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__now {
|
||||
.tgu-editor-timeline__bound {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
z-index: 8;
|
||||
border-left: 1.5px dashed var(--now);
|
||||
border-left: 1.5px solid var(--accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__now span {
|
||||
.tgu-editor-timeline__bound span {
|
||||
position: absolute;
|
||||
left: 0.18rem;
|
||||
top: 0;
|
||||
border-radius: 3px;
|
||||
padding: 0 0.24rem;
|
||||
background: var(--now);
|
||||
background: var(--accent);
|
||||
color: var(--bg-base);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__bound--start span {
|
||||
left: 0.18rem;
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__bound--end span {
|
||||
right: 0.18rem;
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__lane {
|
||||
@@ -622,11 +649,15 @@
|
||||
gap: 0.28rem;
|
||||
height: calc(100% - 1.5rem);
|
||||
overflow: hidden;
|
||||
appearance: none;
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 5px;
|
||||
padding: 0.25rem 0.45rem;
|
||||
cursor: grab;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -649,20 +680,43 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tgu-editor-work__resize {
|
||||
/* Активная дорожка разбита на два ряда: верхний — съёмка, нижний — сброс.
|
||||
Бар центрируется по своему ряду и растёт вместе с ним, но не выше потолка —
|
||||
чтобы узкие включения не растягивались в непропорционально высокие полоски. */
|
||||
.tgu-editor-work--shoot,
|
||||
.tgu-editor-work--downlink {
|
||||
height: min(56px, calc(50% - 0.55rem));
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.tgu-editor-work--shoot {
|
||||
top: 25%;
|
||||
}
|
||||
|
||||
.tgu-editor-work--downlink {
|
||||
top: 75%;
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__row-hint {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 0.45rem;
|
||||
cursor: ew-resize;
|
||||
right: 0.65rem;
|
||||
font-style: normal;
|
||||
font-size: 0.56rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tgu-editor-work__resize--left {
|
||||
left: 0;
|
||||
.tgu-editor-timeline__row-hint--shoot {
|
||||
top: 25%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--t-optical);
|
||||
}
|
||||
|
||||
.tgu-editor-work__resize--right {
|
||||
right: 0;
|
||||
.tgu-editor-timeline__row-hint--downlink {
|
||||
top: 75%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tgu-editor-work__dot {
|
||||
|
||||
Reference in New Issue
Block a user