feat(tgu-ops-ui): add spacecraft work editor tab
Port the TGU editor prototype into pcp-tgu-ops-ui as a new editor tab, add draft/conflict/pass logic with tests, extend the 2D map picking API, and include editor styles/theme aliases and task/prototype docs. Validation: npm run test; npm run build in services/pcp-tgu-ops-ui.
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation";
|
||||
import type {
|
||||
EditorGroundStation,
|
||||
EditorMode,
|
||||
EditorTarget,
|
||||
EditorVisibilityWindow,
|
||||
EditorWork,
|
||||
EditorWorkPatch
|
||||
} from "./model/editorTypes";
|
||||
|
||||
export type DownlinkSelection = {
|
||||
station: EditorGroundStation;
|
||||
pass: EditorVisibilityWindow;
|
||||
};
|
||||
|
||||
export type DownlinkStationOption = {
|
||||
station: EditorGroundStation;
|
||||
passes: EditorVisibilityWindow[];
|
||||
};
|
||||
|
||||
type WorkInspectorProps = {
|
||||
work: EditorWork;
|
||||
modes: EditorMode[];
|
||||
stations: EditorGroundStation[];
|
||||
conflicts: string[];
|
||||
onUpdate: (workId: string, patch: EditorWorkPatch) => void;
|
||||
onDelete: (workId: string) => void;
|
||||
onDuplicate: (workId: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type ShootPanelProps = {
|
||||
target?: EditorTarget;
|
||||
passes: EditorVisibilityWindow[];
|
||||
selectedPass?: EditorVisibilityWindow;
|
||||
modeId?: string;
|
||||
modes: EditorMode[];
|
||||
onSelectPass: (pass?: EditorVisibilityWindow) => void;
|
||||
onModeChange: (modeId: string) => void;
|
||||
onAdd: () => void;
|
||||
onClose: () => void;
|
||||
onClearTarget: () => void;
|
||||
};
|
||||
|
||||
type DownlinkPanelProps = {
|
||||
stationOptions: DownlinkStationOption[];
|
||||
selected?: DownlinkSelection;
|
||||
onSelect: (selection?: DownlinkSelection) => void;
|
||||
onAdd: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function WorkInspector({
|
||||
work,
|
||||
modes,
|
||||
stations,
|
||||
conflicts,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onClose
|
||||
}: WorkInspectorProps) {
|
||||
const color = kindColor(work.kind);
|
||||
|
||||
return (
|
||||
<div className="tgu-editor-panel">
|
||||
<PanelHead title={kindLabel(work.kind)} onClose={onClose} />
|
||||
<div className="tgu-editor-panel__id">{work.id}</div>
|
||||
|
||||
{conflicts.length > 0 && (
|
||||
<div className="tgu-alert tgu-alert--warning">
|
||||
{conflicts.map((conflict) => (
|
||||
<div key={conflict}>{conflict}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</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>
|
||||
{work.targetName && <div className="tgu-editor-panel__hint">цель: {work.targetName}</div>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{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>
|
||||
</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>
|
||||
|
||||
<span className="tgu-editor-panel__color" style={{ background: color }} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShootPanel({
|
||||
target,
|
||||
passes,
|
||||
selectedPass,
|
||||
modeId,
|
||||
modes,
|
||||
onSelectPass,
|
||||
onModeChange,
|
||||
onAdd,
|
||||
onClose,
|
||||
onClearTarget
|
||||
}: ShootPanelProps) {
|
||||
return (
|
||||
<div className="tgu-editor-panel">
|
||||
<PanelHead title="Новая съёмка" onClose={onClose} />
|
||||
|
||||
<section className="tgu-editor-panel__section">
|
||||
<div className="tgu-editor-panel__section-head">
|
||||
<div className="tgu-editor-eyebrow">1 · цель съёмки</div>
|
||||
{target && (
|
||||
<button className="tgu-link-button" type="button" onClick={onClearTarget}>
|
||||
сбросить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!target ? (
|
||||
<div className="tgu-editor-panel__placeholder">Кликните точку на карте слева, чтобы выбрать цель съёмки.</div>
|
||||
) : (
|
||||
<div className="tgu-editor-target">
|
||||
<i />
|
||||
<span>{target.name}</span>
|
||||
<small>
|
||||
{target.lat.toFixed(2)}, {target.lon.toFixed(2)}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{target && (
|
||||
<section className="tgu-editor-panel__section">
|
||||
<div className="tgu-editor-eyebrow">2 · окно видимости</div>
|
||||
{passes.length === 0 ? (
|
||||
<div className="tgu-editor-panel__hint">В окне планирования проходов над целью нет.</div>
|
||||
) : (
|
||||
<div className="tgu-editor-pass-list">
|
||||
{passes.map((pass) => {
|
||||
const selected = selectedPass?.startMs === pass.startMs && selectedPass.endMs === pass.endMs;
|
||||
return (
|
||||
<button
|
||||
className={selected ? "is-selected" : ""}
|
||||
key={`${pass.startMs}-${pass.endMs}`}
|
||||
type="button"
|
||||
onClick={() => onSelectPass(pass)}
|
||||
>
|
||||
<span>{formatDate(pass.startMs)} {formatTime(pass.startMs)}</span>
|
||||
<small>{formatDuration(pass.endMs - pass.startMs)}</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{target && selectedPass && (
|
||||
<section className="tgu-editor-panel__section">
|
||||
<div className="tgu-editor-eyebrow">3 · режим съёмки</div>
|
||||
<div className="tgu-editor-chip-row">
|
||||
{modes.map((mode) => (
|
||||
<button className={modeId === mode.id ? "is-selected" : ""} key={mode.id} type="button" onClick={() => onModeChange(mode.id)}>
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<button className="tgu-button tgu-button--primary" type="button" onClick={onAdd} disabled={!target || !selectedPass || !modeId}>
|
||||
+ Добавить съёмку в план
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownlinkPanel({ stationOptions, selected, onSelect, onAdd, onClose }: DownlinkPanelProps) {
|
||||
return (
|
||||
<div className="tgu-editor-panel">
|
||||
<PanelHead title="Новый сброс на НКПОР" onClose={onClose} />
|
||||
<div className="tgu-editor-panel__hint">Выберите станцию и сеанс связи для добавления сброса в черновик.</div>
|
||||
|
||||
<div className="tgu-editor-station-list">
|
||||
{stationOptions.map((option) => (
|
||||
<div className="tgu-editor-station" key={option.station.id}>
|
||||
<div className="tgu-editor-station__head">
|
||||
<span>{option.station.name}</span>
|
||||
<small>{option.passes.length} сеанс.</small>
|
||||
</div>
|
||||
{option.passes.length === 0 ? (
|
||||
<div className="tgu-editor-panel__hint">нет сеансов в текущем окне</div>
|
||||
) : (
|
||||
option.passes.slice(0, 5).map((pass) => {
|
||||
const isSelected = selected?.station.id === option.station.id && selected.pass.startMs === pass.startMs;
|
||||
return (
|
||||
<button
|
||||
className={isSelected ? "is-selected" : ""}
|
||||
key={`${option.station.id}-${pass.startMs}`}
|
||||
type="button"
|
||||
onClick={() => onSelect({ station: option.station, pass })}
|
||||
>
|
||||
<span>{formatDateTime(pass.startMs)}-{formatTime(pass.endMs)}</span>
|
||||
<small>{formatDuration(pass.endMs - pass.startMs)}</small>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="tgu-button tgu-button--primary" type="button" onClick={onAdd} disabled={!selected}>
|
||||
+ Добавить сброс в план
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelHead({ title, onClose }: { title: string; onClose: () => void }) {
|
||||
return (
|
||||
<div className="tgu-editor-panel__head">
|
||||
<h2>{title}</h2>
|
||||
<button className="tgu-icon-button" type="button" onClick={onClose} title="Закрыть">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeStepper({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
const change = (deltaMs: number) => {
|
||||
const next = Math.max(min ?? Number.NEGATIVE_INFINITY, Math.min(max ?? Number.POSITIVE_INFINITY, value + deltaMs));
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="tgu-editor-time-stepper">
|
||||
<span>{label}</span>
|
||||
<div>
|
||||
<button type="button" onClick={() => change(-3_600_000)}>−ч</button>
|
||||
<button type="button" onClick={() => change(-5 * 60_000)}>−5м</button>
|
||||
<output>{formatDateTime(value)}</output>
|
||||
<button type="button" onClick={() => change(5 * 60_000)}>+5м</button>
|
||||
<button type="button" onClick={() => change(3_600_000)}>+ч</button>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { TguPlanUi, TguPlatformUi } from "../../model/timelineTypes";
|
||||
import type { Draft, EditorWork } from "./model/editorTypes";
|
||||
|
||||
type EditorRailProps = {
|
||||
selectedSpacecraftId?: string;
|
||||
selectedPlan?: TguPlanUi;
|
||||
platforms: TguPlatformUi[];
|
||||
draft: Draft;
|
||||
conflictCount: number;
|
||||
contextSpacecraftIds: Set<string>;
|
||||
onToggleContext: (spacecraftId: string) => void;
|
||||
};
|
||||
|
||||
export function EditorRail({
|
||||
selectedSpacecraftId,
|
||||
selectedPlan,
|
||||
platforms,
|
||||
draft,
|
||||
conflictCount,
|
||||
contextSpacecraftIds,
|
||||
onToggleContext
|
||||
}: EditorRailProps) {
|
||||
const currentPlatform = platforms.find((platform) => platform.spacecraftId === selectedSpacecraftId);
|
||||
const contextPlatforms = platforms.filter((platform) => platform.spacecraftId !== selectedSpacecraftId).slice(0, 40);
|
||||
|
||||
return (
|
||||
<aside className="tgu-editor-rail">
|
||||
<section className="tgu-editor-rail__section">
|
||||
<div className="tgu-editor-eyebrow">Редактируется</div>
|
||||
<div className="tgu-editor-rail__title">{currentPlatform?.name ?? selectedSpacecraftId ?? "КА не выбран"}</div>
|
||||
<div className="tgu-editor-rail__meta">
|
||||
{selectedPlan ? `${selectedPlan.planId} · КПП ${selectedPlan.kppId}` : "План не выбран"}
|
||||
</div>
|
||||
|
||||
<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={conflictCount} color={conflictCount > 0 ? "var(--now)" : undefined} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="tgu-editor-rail__section">
|
||||
<div className="tgu-editor-eyebrow">Контекст других КА</div>
|
||||
<div className="tgu-editor-context-list">
|
||||
{contextPlatforms.length === 0 ? (
|
||||
<div className="tgu-empty tgu-empty--compact">Нет других КА в выбранном интервале.</div>
|
||||
) : (
|
||||
contextPlatforms.map((platform) => {
|
||||
const enabled = contextSpacecraftIds.has(platform.spacecraftId);
|
||||
return (
|
||||
<button
|
||||
className={`tgu-editor-context-item ${enabled ? "is-enabled" : ""}`}
|
||||
key={platform.spacecraftId}
|
||||
type="button"
|
||||
onClick={() => onToggleContext(platform.spacecraftId)}
|
||||
>
|
||||
<span className="tgu-editor-context-item__check">{enabled ? "✓" : ""}</span>
|
||||
<span>{platform.name ?? platform.noradId ?? platform.spacecraftId}</span>
|
||||
<small>{platform.planCount}</small>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function RailStat({ label, value, color }: { label: string; value: number; color?: string }) {
|
||||
return (
|
||||
<div className="tgu-editor-stat">
|
||||
<span style={{ color }}>{value}</span>
|
||||
<small>{label}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function countWorks(works: EditorWork[], kind: EditorWork["kind"]): number {
|
||||
return works.filter((work) => work.kind === kind).length;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { formatDate, formatDateTime, formatTime, kindColor, kindLabel, niceTicks } from "./editorPresentation";
|
||||
import type {
|
||||
EditorContextLane,
|
||||
EditorTimelineWindow,
|
||||
EditorVisibilityTrack,
|
||||
EditorWork,
|
||||
EditorWorkPatch
|
||||
} from "./model/editorTypes";
|
||||
import { VisibilityTracks } from "./VisibilityTracks";
|
||||
|
||||
type EditorTimelineProps = {
|
||||
activeLabel: string;
|
||||
draft: EditorWork[];
|
||||
selectedWorkId?: string;
|
||||
window: EditorTimelineWindow;
|
||||
nowMs: 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 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,
|
||||
selectedWorkId,
|
||||
window: timelineWindow,
|
||||
nowMs,
|
||||
conflicts,
|
||||
contextLanes,
|
||||
visibilityTracks,
|
||||
onSelectWork,
|
||||
onClearSelection,
|
||||
onChangeWork
|
||||
}: EditorTimelineProps) {
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragRef = useRef<DragState | undefined>(undefined);
|
||||
const [bodyWidth, setBodyWidth] = useState(900);
|
||||
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 +
|
||||
visibilityTracks.length * VISIBILITY_HEIGHT +
|
||||
(contextLanes.length > 0 ? 22 : 0) +
|
||||
contextLanes.length * OTHER_HEIGHT +
|
||||
8;
|
||||
|
||||
useEffect(() => {
|
||||
const element = wrapperRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
setBodyWidth(Math.max(560, element.clientWidth - LABEL_WIDTH));
|
||||
});
|
||||
observer.observe(element);
|
||||
setBodyWidth(Math.max(560, element.clientWidth - LABEL_WIDTH));
|
||||
|
||||
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__axis-label" style={{ width: LABEL_WIDTH }}>
|
||||
Время (UTC)
|
||||
</div>
|
||||
<div className="tgu-editor-timeline__axis" style={{ left: LABEL_WIDTH, width: bodyWidth }}>
|
||||
{days.map((day) => (
|
||||
<div className="tgu-editor-timeline__day" key={day} style={{ left: toX(day) }}>
|
||||
<strong>{formatDate(day).split(" ")[0]}</strong>
|
||||
<span>{formatDate(day).split(" ")[1]}</span>
|
||||
</div>
|
||||
))}
|
||||
{ticks.map((tick) => (
|
||||
<div className="tgu-editor-timeline__tick-label" key={tick} style={{ left: toX(tick) }}>
|
||||
{formatTime(tick)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="tgu-editor-timeline__grid" style={{ left: LABEL_WIDTH, top: AXIS_HEIGHT, width: bodyWidth }}>
|
||||
{ticks.map((tick) => (
|
||||
<div key={tick} style={{ left: toX(tick) }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{nowMs >= timelineWindow.fromMs && nowMs <= timelineWindow.toMs && (
|
||||
<div className="tgu-editor-timeline__now" style={{ left: LABEL_WIDTH + toX(nowMs), 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 }}>
|
||||
<span>{activeLabel}</span>
|
||||
<small>редактируется</small>
|
||||
</div>
|
||||
<div className="tgu-editor-timeline__lane-body" style={{ left: LABEL_WIDTH, width: bodyWidth }}>
|
||||
{draft.map((work) => {
|
||||
const left = toX(work.startMs);
|
||||
const width = Math.max(6, toX(work.endMs) - toX(work.startMs));
|
||||
const color = kindColor(work.kind);
|
||||
const selected = selectedWorkId === work.id;
|
||||
const hasConflict = Boolean(conflicts[work.id]?.length);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`tgu-editor-work ${selected ? "is-selected" : ""} ${hasConflict ? "has-conflict" : ""}`}
|
||||
key={work.id}
|
||||
style={{
|
||||
left,
|
||||
width,
|
||||
borderLeftColor: color,
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VisibilityTracks
|
||||
tracks={visibilityTracks}
|
||||
window={timelineWindow}
|
||||
width={bodyWidth}
|
||||
labelWidth={LABEL_WIDTH}
|
||||
top={visibilityTop}
|
||||
rowHeight={VISIBILITY_HEIGHT}
|
||||
/>
|
||||
|
||||
{contextLanes.length > 0 && (
|
||||
<div className="tgu-editor-context-divider" style={{ top: dividerTop, width: LABEL_WIDTH + bodyWidth }}>
|
||||
Другие КА · контекст (read-only)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contextLanes.map((lane, index) => (
|
||||
<div className="tgu-editor-timeline__lane" key={lane.spacecraftId} style={{ top: contextTop + index * OTHER_HEIGHT, height: OTHER_HEIGHT }}>
|
||||
<div className="tgu-editor-timeline__lane-label" style={{ width: LABEL_WIDTH }}>
|
||||
<span>{lane.label}</span>
|
||||
</div>
|
||||
<div className="tgu-editor-timeline__lane-body" style={{ left: LABEL_WIDTH, width: bodyWidth }}>
|
||||
{lane.works.map((work) => {
|
||||
if (work.endMs < timelineWindow.fromMs || work.startMs > timelineWindow.toMs) return null;
|
||||
const left = toX(work.startMs);
|
||||
const width = Math.max(3, toX(work.endMs) - toX(work.startMs));
|
||||
return (
|
||||
<div
|
||||
className="tgu-editor-work tgu-editor-work--context"
|
||||
key={work.id}
|
||||
style={{ left, width, borderLeftColor: kindColor(work.kind) }}
|
||||
title={`${lane.label} · ${kindLabel(work.kind)} · ${formatTime(work.startMs)}-${formatTime(work.endMs)}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildDays(window: EditorTimelineWindow): number[] {
|
||||
const result: number[] = [];
|
||||
const start = new Date(window.fromMs);
|
||||
let dayMs = Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate());
|
||||
while (dayMs <= window.toMs) {
|
||||
result.push(dayMs);
|
||||
dayMs += 24 * 60 * 60 * 1000;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { TguPlanUi, TguPlatformUi } from "../../model/timelineTypes";
|
||||
import { formatDuration } from "./editorPresentation";
|
||||
|
||||
type EditorToolbarProps = {
|
||||
selectedSpacecraftId?: string;
|
||||
selectedPlan?: TguPlanUi;
|
||||
platforms: TguPlatformUi[];
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
checking: boolean;
|
||||
conflictCount: number;
|
||||
addedCount: number;
|
||||
dirty: boolean;
|
||||
applying: boolean;
|
||||
windowLabel: string;
|
||||
rightMode: "none" | "inspect" | "shoot" | "downlink";
|
||||
onSetRightMode: (mode: "shoot" | "downlink") => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onCheck: () => void;
|
||||
onApply: () => void;
|
||||
onZoom: (factor: number) => void;
|
||||
onPan: (factor: number) => void;
|
||||
};
|
||||
|
||||
export function EditorToolbar({
|
||||
selectedSpacecraftId,
|
||||
selectedPlan,
|
||||
platforms,
|
||||
canUndo,
|
||||
canRedo,
|
||||
checking,
|
||||
conflictCount,
|
||||
addedCount,
|
||||
dirty,
|
||||
applying,
|
||||
windowLabel,
|
||||
rightMode,
|
||||
onSetRightMode,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onCheck,
|
||||
onApply,
|
||||
onZoom,
|
||||
onPan
|
||||
}: EditorToolbarProps) {
|
||||
const platformLabel = selectedSpacecraftId
|
||||
? platforms.find((platform) => platform.spacecraftId === selectedSpacecraftId)?.name ?? selectedSpacecraftId
|
||||
: "КА не выбран";
|
||||
|
||||
return (
|
||||
<div className="tgu-editor-toolbar">
|
||||
<div className="tgu-editor-toolbar__identity">
|
||||
<span className="tgu-editor-type-dot" aria-hidden="true" />
|
||||
<div>
|
||||
<div className="tgu-editor-toolbar__title">{platformLabel}</div>
|
||||
<div className="tgu-editor-toolbar__meta">
|
||||
{selectedPlan ? `${selectedPlan.planId} · ${selectedPlan.status}` : "Выберите план на timeline"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tgu-editor-toolbar__group">
|
||||
<button
|
||||
className={`tgu-button ${rightMode === "shoot" ? "tgu-button--primary" : ""}`}
|
||||
type="button"
|
||||
onClick={() => onSetRightMode("shoot")}
|
||||
disabled={!selectedSpacecraftId}
|
||||
>
|
||||
+ Съёмка
|
||||
</button>
|
||||
<button
|
||||
className={`tgu-button ${rightMode === "downlink" ? "tgu-button--primary" : ""}`}
|
||||
type="button"
|
||||
onClick={() => onSetRightMode("downlink")}
|
||||
disabled={!selectedSpacecraftId}
|
||||
>
|
||||
+ Сброс
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tgu-editor-toolbar__spacer" />
|
||||
|
||||
<div className="tgu-editor-window-control" aria-label="Окно редактора">
|
||||
<button type="button" onClick={() => onPan(-0.25)} title="Назад по времени">◀</button>
|
||||
<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
|
||||
className={`tgu-button ${conflictCount > 0 ? "tgu-editor-button--warning" : ""}`}
|
||||
type="button"
|
||||
onClick={onCheck}
|
||||
disabled={checking || !selectedSpacecraftId}
|
||||
>
|
||||
{checking ? "Проверка..." : conflictCount > 0 ? `Конфликты · ${conflictCount}` : "Проверить"}
|
||||
</button>
|
||||
|
||||
<button className="tgu-button tgu-button--primary" type="button" onClick={onApply} disabled={!dirty || applying}>
|
||||
{applying ? "Применение..." : `Применить${addedCount > 0 ? ` · +${addedCount}` : ""}`}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
|
||||
import { groundTrack, stationPasses, targetPasses } from "../tgu-map-2d/geometry/passes";
|
||||
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
||||
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
|
||||
import type { Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
|
||||
import { saveEditedPlan } from "./editorApi";
|
||||
import {
|
||||
addDownlink,
|
||||
addShoot,
|
||||
deleteWork,
|
||||
duplicateWork,
|
||||
mutate,
|
||||
redo,
|
||||
seedDraft,
|
||||
undo,
|
||||
updateWork
|
||||
} from "./editorDraft";
|
||||
import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts";
|
||||
import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation";
|
||||
import { DownlinkPanel, type DownlinkSelection, type DownlinkStationOption, ShootPanel, WorkInspector } from "./EditorPanels";
|
||||
import { EditorRail } from "./EditorRail";
|
||||
import { EditorTimeline } from "./EditorTimeline";
|
||||
import { EditorToolbar } from "./EditorToolbar";
|
||||
import type {
|
||||
DraftHistory,
|
||||
EditorGroundStation,
|
||||
EditorMode,
|
||||
EditorTarget,
|
||||
EditorTimelineWindow,
|
||||
EditorVisibilityTrack,
|
||||
EditorVisibilityWindow,
|
||||
EditorWorkPatch,
|
||||
TguEditorTabProps
|
||||
} from "./model/editorTypes";
|
||||
|
||||
type EditorRightMode = "none" | "inspect" | "shoot" | "downlink";
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = {
|
||||
tracks: true,
|
||||
swath: false,
|
||||
planWorks: false,
|
||||
stations: true,
|
||||
spacecraftMarkers: false
|
||||
};
|
||||
|
||||
export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, platforms }: TguEditorTabProps) {
|
||||
const idCounterRef = useRef(0);
|
||||
const targetCounterRef = useRef(0);
|
||||
const [history, setHistory] = useState<DraftHistory>(() => ({
|
||||
draft: seedDraft(selectedPlan, selectedSpacecraftId),
|
||||
past: [],
|
||||
future: []
|
||||
}));
|
||||
const [window, setWindow] = useState<EditorTimelineWindow>(() => defaultEditorWindow(selectedPlan, appliedRange));
|
||||
const [rightMode, setRightMode] = useState<EditorRightMode>("none");
|
||||
const [selectedWorkId, setSelectedWorkId] = useState<string>();
|
||||
const [target, setTarget] = useState<EditorTarget>();
|
||||
const [selectedShootPass, setSelectedShootPass] = useState<EditorVisibilityWindow>();
|
||||
const [modeId, setModeId] = useState(DEFAULT_EDITOR_MODES[0].id);
|
||||
const [selectedDownlink, setSelectedDownlink] = useState<DownlinkSelection>();
|
||||
const [serviceConflicts, setServiceConflicts] = useState<EditorConflictMap>({});
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [notice, setNotice] = useState<string>();
|
||||
const [applyError, setApplyError] = useState<string>();
|
||||
const [contextSpacecraftIds, setContextSpacecraftIds] = useState<Set<string>>(new Set());
|
||||
const draft = history.draft;
|
||||
const stations = useMemo(() => buildStations(selectedPlan), [selectedPlan]);
|
||||
const modes: EditorMode[] = DEFAULT_EDITOR_MODES;
|
||||
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 activeLabel = platformLabel(platforms, selectedSpacecraftId);
|
||||
const shootPasses = useMemo(
|
||||
() => target ? targetPasses({ spacecraftId: selectedSpacecraftId }, target, { fromMs: window.fromMs, toMs: window.toMs }) : [],
|
||||
[selectedSpacecraftId, target, window.fromMs, window.toMs]
|
||||
);
|
||||
const stationOptions = useMemo(
|
||||
() => buildStationOptions(stations, selectedSpacecraftId, window, selectedPlan),
|
||||
[selectedPlan, selectedSpacecraftId, stations, window]
|
||||
);
|
||||
const visibilityTracks = useMemo(
|
||||
() => buildVisibilityTracks(target, shootPasses, selectedWork, stations, selectedSpacecraftId, window),
|
||||
[selectedSpacecraftId, selectedWork, shootPasses, stations, target, window]
|
||||
);
|
||||
const mapScene = useMemo(
|
||||
() => buildEditorMapScene(selectedSpacecraftId, window, stations),
|
||||
[selectedSpacecraftId, stations, window]
|
||||
);
|
||||
const contextLanes = useMemo(
|
||||
() =>
|
||||
Array.from(contextSpacecraftIds).map((spacecraftId) => ({
|
||||
spacecraftId,
|
||||
label: platformLabel(platforms, spacecraftId),
|
||||
works: []
|
||||
})),
|
||||
[contextSpacecraftIds, platforms]
|
||||
);
|
||||
const dirty = history.past.length > 0 || draft.works.some((work) => work.isNew);
|
||||
const addedCount = draft.works.filter((work) => work.isNew).length;
|
||||
|
||||
useEffect(() => {
|
||||
setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] });
|
||||
setWindow(defaultEditorWindow(selectedPlan, appliedRange));
|
||||
setSelectedWorkId(undefined);
|
||||
setRightMode("none");
|
||||
setTarget(undefined);
|
||||
setSelectedShootPass(undefined);
|
||||
setSelectedDownlink(undefined);
|
||||
setServiceConflicts({});
|
||||
setNotice(undefined);
|
||||
setApplyError(undefined);
|
||||
}, [appliedRange, selectedPlan, selectedSpacecraftId]);
|
||||
|
||||
const changeDraft = (transform: (draft: DraftHistory["draft"]) => DraftHistory["draft"]) => {
|
||||
setHistory((current) => mutate(current, transform));
|
||||
setServiceConflicts({});
|
||||
setNotice(undefined);
|
||||
setApplyError(undefined);
|
||||
};
|
||||
|
||||
const onChangeWork = (workId: string, patch: EditorWorkPatch) => {
|
||||
changeDraft((current) => updateWork(current, workId, patch));
|
||||
};
|
||||
|
||||
const addShootWork = () => {
|
||||
if (!target || !selectedShootPass || !modeId) return;
|
||||
|
||||
const durationMs = Math.min(selectedShootPass.endMs - selectedShootPass.startMs, 30 * 60 * 1000);
|
||||
const startMs = selectedShootPass.startMs + Math.floor((selectedShootPass.endMs - selectedShootPass.startMs - durationMs) / 2);
|
||||
const mode = modes.find((item) => item.id === modeId);
|
||||
const id = nextWorkId(idCounterRef);
|
||||
|
||||
changeDraft((current) =>
|
||||
addShoot(current, {
|
||||
id,
|
||||
startMs,
|
||||
endMs: startMs + durationMs,
|
||||
label: mode?.label ?? "Съёмка",
|
||||
modeId,
|
||||
target
|
||||
})
|
||||
);
|
||||
setSelectedWorkId(id);
|
||||
setRightMode("inspect");
|
||||
setTarget(undefined);
|
||||
setSelectedShootPass(undefined);
|
||||
};
|
||||
|
||||
const addDownlinkWork = () => {
|
||||
if (!selectedDownlink) return;
|
||||
|
||||
const durationMs = Math.min(selectedDownlink.pass.endMs - selectedDownlink.pass.startMs, 12 * 60 * 1000);
|
||||
const startMs = selectedDownlink.pass.startMs + Math.floor((selectedDownlink.pass.endMs - selectedDownlink.pass.startMs - durationMs) / 2);
|
||||
const id = nextWorkId(idCounterRef);
|
||||
|
||||
changeDraft((current) =>
|
||||
addDownlink(current, {
|
||||
id,
|
||||
startMs,
|
||||
endMs: startMs + durationMs,
|
||||
label: "Сброс на НКПОР",
|
||||
station: selectedDownlink.station
|
||||
})
|
||||
);
|
||||
setSelectedWorkId(id);
|
||||
setRightMode("inspect");
|
||||
setSelectedDownlink(undefined);
|
||||
};
|
||||
|
||||
const runCheck = () => {
|
||||
setChecking(true);
|
||||
setServiceConflicts(checkVisibility(draft, { spacecraftId: selectedSpacecraftId, stations }));
|
||||
setChecking(false);
|
||||
setNotice("Проверка видимости выполнена для текущего черновика.");
|
||||
};
|
||||
|
||||
const applyDraft = async () => {
|
||||
const planId = selectedPlan?.planId ?? draft.planId;
|
||||
if (!planId) {
|
||||
setApplyError("Нельзя применить черновик: план не выбран.");
|
||||
setNotice(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setApplying(true);
|
||||
setApplyError(undefined);
|
||||
setNotice(undefined);
|
||||
try {
|
||||
const result = await saveEditedPlan({ planId, draft });
|
||||
setNotice(result.message);
|
||||
setHistory((current) => ({ ...current, past: [], future: [] }));
|
||||
} catch (error) {
|
||||
setApplyError(error instanceof Error ? error.message : "Не удалось применить правки плана.");
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const zoom = (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 pan = (factor: number) => {
|
||||
setWindow((current) => {
|
||||
const delta = (current.toMs - current.fromMs) * factor;
|
||||
return { fromMs: current.fromMs + delta, toMs: current.toMs + delta };
|
||||
});
|
||||
};
|
||||
|
||||
if (!selectedSpacecraftId && !selectedPlan) {
|
||||
return (
|
||||
<main className="tgu-editor-workspace">
|
||||
<div className="tgu-empty">Выберите КА или план, чтобы открыть редактор включений.</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="tgu-editor-workspace">
|
||||
<EditorToolbar
|
||||
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}
|
||||
windowLabel={formatDuration(window.toMs - window.fromMs)}
|
||||
rightMode={rightMode}
|
||||
onSetRightMode={(mode) => {
|
||||
setRightMode(mode);
|
||||
setSelectedWorkId(undefined);
|
||||
setNotice(undefined);
|
||||
}}
|
||||
onUndo={() => setHistory((current) => undo(current))}
|
||||
onRedo={() => setHistory((current) => redo(current))}
|
||||
onCheck={runCheck}
|
||||
onApply={applyDraft}
|
||||
onZoom={zoom}
|
||||
onPan={pan}
|
||||
/>
|
||||
|
||||
{notice && <div className="tgu-alert tgu-alert--success tgu-editor-notice">{notice}</div>}
|
||||
{applyError && <div className="tgu-alert tgu-alert--danger tgu-editor-notice">{applyError}</div>}
|
||||
|
||||
<div className="tgu-editor-layout" style={{ gridTemplateColumns: rightMode === "none" ? "15.5rem minmax(0, 1fr) 0" : "15.5rem minmax(0, 1fr) 21rem" }}>
|
||||
<EditorRail
|
||||
selectedSpacecraftId={selectedSpacecraftId}
|
||||
selectedPlan={selectedPlan}
|
||||
platforms={platforms}
|
||||
draft={draft}
|
||||
conflictCount={conflictCount(conflicts)}
|
||||
contextSpacecraftIds={contextSpacecraftIds}
|
||||
onToggleContext={(spacecraftId) => {
|
||||
setContextSpacecraftIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(spacecraftId)) {
|
||||
next.delete(spacecraftId);
|
||||
} else {
|
||||
next.add(spacecraftId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<section className="tgu-editor-center">
|
||||
<div className="tgu-editor-map-stage">
|
||||
{rightMode === "shoot" && <div className="tgu-map-overlay">Кликните точку съёмки на карте.</div>}
|
||||
<Tgu2DMapView
|
||||
scene={mapScene}
|
||||
layers={EDITOR_MAP_LAYERS}
|
||||
redrawToken={0}
|
||||
pickMode={rightMode === "shoot"}
|
||||
targets={target ? [target] : []}
|
||||
onPickPoint={(point) => {
|
||||
targetCounterRef.current += 1;
|
||||
setTarget({
|
||||
id: `target-${targetCounterRef.current}`,
|
||||
name: `Цель ${targetCounterRef.current}`,
|
||||
lat: point.lat,
|
||||
lon: point.lon
|
||||
});
|
||||
setSelectedShootPass(undefined);
|
||||
setModeId(DEFAULT_EDITOR_MODES[0].id);
|
||||
setNotice(undefined);
|
||||
}}
|
||||
onObjectSelect={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
<EditorTimeline
|
||||
activeLabel={activeLabel}
|
||||
draft={draft.works}
|
||||
selectedWorkId={selectedWorkId}
|
||||
window={window}
|
||||
nowMs={Date.now()}
|
||||
conflicts={conflicts}
|
||||
contextLanes={contextLanes}
|
||||
visibilityTracks={visibilityTracks}
|
||||
onSelectWork={(workId) => {
|
||||
setSelectedWorkId(workId);
|
||||
setRightMode("inspect");
|
||||
setNotice(undefined);
|
||||
}}
|
||||
onClearSelection={() => {
|
||||
if (rightMode === "inspect") {
|
||||
setSelectedWorkId(undefined);
|
||||
setRightMode("none");
|
||||
}
|
||||
}}
|
||||
onChangeWork={onChangeWork}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{rightMode !== "none" && (
|
||||
<aside className="tgu-editor-side-panel">
|
||||
{rightMode === "inspect" && selectedWork && (
|
||||
<WorkInspector
|
||||
work={selectedWork}
|
||||
modes={modes}
|
||||
stations={stations}
|
||||
conflicts={conflicts[selectedWork.id] ?? []}
|
||||
onUpdate={onChangeWork}
|
||||
onDelete={(workId) => {
|
||||
changeDraft((current) => deleteWork(current, workId));
|
||||
setSelectedWorkId(undefined);
|
||||
setRightMode("none");
|
||||
}}
|
||||
onDuplicate={(workId) => {
|
||||
const id = nextWorkId(idCounterRef);
|
||||
changeDraft((current) => duplicateWork(current, workId, id));
|
||||
setSelectedWorkId(id);
|
||||
}}
|
||||
onClose={() => {
|
||||
setSelectedWorkId(undefined);
|
||||
setRightMode("none");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{rightMode === "inspect" && !selectedWork && <div className="tgu-empty tgu-empty--compact">Включение не выбрано.</div>}
|
||||
{rightMode === "shoot" && (
|
||||
<ShootPanel
|
||||
target={target}
|
||||
passes={shootPasses}
|
||||
selectedPass={selectedShootPass}
|
||||
modeId={modeId}
|
||||
modes={modes}
|
||||
onSelectPass={setSelectedShootPass}
|
||||
onModeChange={setModeId}
|
||||
onAdd={addShootWork}
|
||||
onClose={() => {
|
||||
setRightMode("none");
|
||||
setTarget(undefined);
|
||||
setSelectedShootPass(undefined);
|
||||
}}
|
||||
onClearTarget={() => {
|
||||
setTarget(undefined);
|
||||
setSelectedShootPass(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{rightMode === "downlink" && (
|
||||
<DownlinkPanel
|
||||
stationOptions={stationOptions}
|
||||
selected={selectedDownlink}
|
||||
onSelect={setSelectedDownlink}
|
||||
onAdd={addDownlinkWork}
|
||||
onClose={() => {
|
||||
setRightMode("none");
|
||||
setSelectedDownlink(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultEditorWindow(
|
||||
selectedPlan: TguEditorTabProps["selectedPlan"],
|
||||
appliedRange: TguEditorTabProps["appliedRange"]
|
||||
): EditorTimelineWindow {
|
||||
if (selectedPlan) {
|
||||
return {
|
||||
fromMs: selectedPlan.startMs - 3 * HOUR_MS,
|
||||
toMs: selectedPlan.endMs + 3 * HOUR_MS
|
||||
};
|
||||
}
|
||||
|
||||
return { fromMs: appliedRange.fromMs, toMs: appliedRange.toMs };
|
||||
}
|
||||
|
||||
function buildStations(selectedPlan: TguEditorTabProps["selectedPlan"]): EditorGroundStation[] {
|
||||
if (!selectedPlan?.kppId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ id: selectedPlan.kppId, name: selectedPlan.kppId }];
|
||||
}
|
||||
|
||||
function buildStationOptions(
|
||||
stations: EditorGroundStation[],
|
||||
spacecraftId: string | undefined,
|
||||
window: EditorTimelineWindow,
|
||||
selectedPlan: TguEditorTabProps["selectedPlan"]
|
||||
): DownlinkStationOption[] {
|
||||
return stations.map((station) => {
|
||||
const computedPasses =
|
||||
station.lat !== undefined && station.lon !== undefined
|
||||
? stationPasses({ spacecraftId }, { lat: station.lat, lon: station.lon }, { fromMs: window.fromMs, toMs: window.toMs })
|
||||
: [];
|
||||
const fallbackPass = selectedPlan && selectedPlan.kppId === station.id
|
||||
? [{
|
||||
startMs: Math.max(window.fromMs, selectedPlan.startMs),
|
||||
endMs: Math.min(window.toMs, selectedPlan.endMs),
|
||||
quality: 0
|
||||
}]
|
||||
: [];
|
||||
|
||||
return {
|
||||
station,
|
||||
passes: computedPasses.length > 0 ? computedPasses : fallbackPass.filter((pass) => pass.endMs > pass.startMs)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildVisibilityTracks(
|
||||
target: EditorTarget | undefined,
|
||||
shootPasses: EditorVisibilityWindow[],
|
||||
selectedWork: DraftHistory["draft"]["works"][number] | undefined,
|
||||
stations: EditorGroundStation[],
|
||||
spacecraftId: string | undefined,
|
||||
window: EditorTimelineWindow
|
||||
): EditorVisibilityTrack[] {
|
||||
const tracks: EditorVisibilityTrack[] = [];
|
||||
if (target) {
|
||||
tracks.push({
|
||||
id: "target",
|
||||
label: target.name,
|
||||
kind: "target",
|
||||
color: "var(--now)",
|
||||
windows: shootPasses
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedWork?.kind === "downlink" && selectedWork.stationId) {
|
||||
const station = stations.find((item) => item.id === selectedWork.stationId);
|
||||
if (station?.lat !== undefined && station.lon !== undefined) {
|
||||
tracks.push({
|
||||
id: "station",
|
||||
label: station.name,
|
||||
kind: "station",
|
||||
color: "var(--accent)",
|
||||
windows: stationPasses({ spacecraftId }, { lat: station.lat, lon: station.lon }, { fromMs: window.fromMs, toMs: window.toMs })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tracks;
|
||||
}
|
||||
|
||||
function buildEditorMapScene(
|
||||
spacecraftId: string | undefined,
|
||||
window: EditorTimelineWindow,
|
||||
stations: EditorGroundStation[]
|
||||
): Tgu2DMapScene {
|
||||
const lines = spacecraftId
|
||||
? groundTrack({ spacecraftId }, { fromMs: window.fromMs, toMs: window.toMs }, 260).map((points, index) => ({
|
||||
id: `${spacecraftId}:track:${index}`,
|
||||
layer: "tracks" as const,
|
||||
points,
|
||||
spacecraftId,
|
||||
zIndex: index
|
||||
}))
|
||||
: [];
|
||||
const markers = stations.flatMap((station) => {
|
||||
if (station.lat === undefined || station.lon === undefined) {
|
||||
return [];
|
||||
}
|
||||
return [{
|
||||
id: station.id,
|
||||
layer: "stations" as const,
|
||||
point: { lat: station.lat, lon: station.lon },
|
||||
label: station.name,
|
||||
zIndex: 10
|
||||
}];
|
||||
});
|
||||
|
||||
return { polygons: [], lines, markers };
|
||||
}
|
||||
|
||||
function platformLabel(platforms: TguEditorTabProps["platforms"], spacecraftId?: string): string {
|
||||
if (!spacecraftId) {
|
||||
return "КА не выбран";
|
||||
}
|
||||
const platform = platforms.find((item) => item.spacecraftId === spacecraftId);
|
||||
return platform?.name ?? platform?.noradId ?? spacecraftId;
|
||||
}
|
||||
|
||||
function nextWorkId(ref: MutableRefObject<number>): string {
|
||||
ref.current += 1;
|
||||
return `NW-${String(ref.current).padStart(4, "0")}`;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { formatTime } from "./editorPresentation";
|
||||
import type { EditorTimelineWindow, EditorVisibilityTrack } from "./model/editorTypes";
|
||||
|
||||
type VisibilityTracksProps = {
|
||||
tracks: EditorVisibilityTrack[];
|
||||
window: EditorTimelineWindow;
|
||||
width: number;
|
||||
labelWidth: number;
|
||||
top: number;
|
||||
rowHeight: number;
|
||||
};
|
||||
|
||||
export function VisibilityTracks({ tracks, window, width, labelWidth, top, rowHeight }: VisibilityTracksProps) {
|
||||
const span = Math.max(1, window.toMs - window.fromMs);
|
||||
const toX = (timeMs: number) => ((timeMs - window.fromMs) / span) * width;
|
||||
|
||||
return (
|
||||
<>
|
||||
{tracks.map((track, index) => {
|
||||
const rowTop = top + index * rowHeight;
|
||||
return (
|
||||
<div className="tgu-editor-timeline__lane" key={track.id} style={{ top: rowTop, height: rowHeight }}>
|
||||
<div className="tgu-editor-timeline__lane-label" style={{ width: labelWidth }}>
|
||||
<span className="tgu-editor-visibility-label">
|
||||
<i style={{ background: track.color }} />
|
||||
{track.label}
|
||||
</span>
|
||||
<small>{track.kind === "target" ? "цель · видимость" : "станция · видимость"}</small>
|
||||
</div>
|
||||
<div className="tgu-editor-timeline__lane-body" style={{ left: labelWidth, width }}>
|
||||
{track.windows.map((visibilityWindow) => {
|
||||
if (visibilityWindow.endMs < window.fromMs || visibilityWindow.startMs > window.toMs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const left = toX(Math.max(visibilityWindow.startMs, window.fromMs));
|
||||
const right = toX(Math.min(visibilityWindow.endMs, window.toMs));
|
||||
return (
|
||||
<div
|
||||
className="tgu-editor-visibility-window"
|
||||
key={`${visibilityWindow.startMs}-${visibilityWindow.endMs}`}
|
||||
style={{
|
||||
left,
|
||||
width: Math.max(2, right - left),
|
||||
borderColor: track.color,
|
||||
background: `color-mix(in srgb, ${track.color} 22%, transparent)`
|
||||
}}
|
||||
title={`${track.label}: ${formatTime(visibilityWindow.startMs)}-${formatTime(visibilityWindow.endMs)}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Draft } from "./model/editorTypes";
|
||||
|
||||
export type SaveEditedPlanRequest = {
|
||||
planId: string;
|
||||
draft: Draft;
|
||||
};
|
||||
|
||||
export type SaveEditedPlanResult = {
|
||||
saved: false;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export async function saveEditedPlan(_request: SaveEditedPlanRequest): Promise<SaveEditedPlanResult> {
|
||||
// TODO(backend): эндпоинт сохранения правок плана отсутствует в pcp-tgu-service.
|
||||
return {
|
||||
saved: false,
|
||||
message: "Сохранение правок плана во внешний сервис ещё не реализовано в backend."
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { subPoint } from "../tgu-map-2d/geometry/passes";
|
||||
import { checkVisibility, mergeConflictMaps, overlaps } from "./editorConflicts";
|
||||
import type { Draft } from "./model/editorTypes";
|
||||
|
||||
const orbit = { spacecraftId: "56756" };
|
||||
const centerMs = Date.UTC(2026, 4, 29, 12, 0, 0);
|
||||
const visiblePoint = subPoint(orbit, centerMs);
|
||||
|
||||
describe("editorConflicts", () => {
|
||||
it("detects overlapping works and keeps non-overlapping works clean", () => {
|
||||
const conflicts = overlaps([
|
||||
{ id: "shoot-1", kind: "shoot", label: "Съёмка", startMs: 1000, endMs: 5000, origin: "plan" },
|
||||
{ id: "downlink-1", kind: "downlink", label: "Сброс", startMs: 4000, endMs: 7000, origin: "plan" },
|
||||
{ id: "shoot-2", kind: "shoot", label: "Съёмка", startMs: 8000, endMs: 9000, origin: "plan" }
|
||||
]);
|
||||
|
||||
expect(conflicts).toEqual({
|
||||
"shoot-1": ['Пересечение по времени с "Сброс"'],
|
||||
"downlink-1": ['Пересечение по времени с "Съёмка"']
|
||||
});
|
||||
});
|
||||
|
||||
it("reports target visibility conflicts only when the selected interval is not covered", () => {
|
||||
const draft: Draft = {
|
||||
spacecraftId: orbit.spacecraftId,
|
||||
works: [
|
||||
{
|
||||
id: "visible-shoot",
|
||||
kind: "shoot",
|
||||
label: "Съёмка",
|
||||
startMs: centerMs - 10_000,
|
||||
endMs: centerMs + 10_000,
|
||||
targetLat: visiblePoint.lat,
|
||||
targetLon: visiblePoint.lon,
|
||||
origin: "added"
|
||||
},
|
||||
{
|
||||
id: "hidden-shoot",
|
||||
kind: "shoot",
|
||||
label: "Съёмка",
|
||||
startMs: centerMs - 10_000,
|
||||
endMs: centerMs + 10_000,
|
||||
targetLat: -visiblePoint.lat,
|
||||
targetLon: visiblePoint.lon + 150,
|
||||
origin: "added"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
expect(checkVisibility(draft, { stations: [] })).toEqual({
|
||||
"hidden-shoot": ["Съёмка вне зоны видимости цели"]
|
||||
});
|
||||
});
|
||||
|
||||
it("checks downlink station visibility and missing station coordinates", () => {
|
||||
const draft: Draft = {
|
||||
spacecraftId: orbit.spacecraftId,
|
||||
works: [
|
||||
{
|
||||
id: "visible-downlink",
|
||||
kind: "downlink",
|
||||
label: "Сброс",
|
||||
startMs: centerMs - 10_000,
|
||||
endMs: centerMs + 10_000,
|
||||
stationId: "KPP-1",
|
||||
origin: "added"
|
||||
},
|
||||
{
|
||||
id: "unknown-downlink",
|
||||
kind: "downlink",
|
||||
label: "Сброс",
|
||||
startMs: centerMs - 10_000,
|
||||
endMs: centerMs + 10_000,
|
||||
stationId: "KPP-2",
|
||||
origin: "added"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
expect(checkVisibility(draft, {
|
||||
stations: [
|
||||
{ id: "KPP-1", name: "KPP-1", lat: visiblePoint.lat, lon: visiblePoint.lon },
|
||||
{ id: "KPP-2", name: "KPP-2" }
|
||||
]
|
||||
})).toEqual({
|
||||
"unknown-downlink": ["Нет координат станции для проверки видимости"]
|
||||
});
|
||||
});
|
||||
|
||||
it("merges live and visibility conflict maps", () => {
|
||||
expect(mergeConflictMaps({ "work-1": ["A"] }, { "work-1": ["B"], "work-2": ["C"] })).toEqual({
|
||||
"work-1": ["A", "B"],
|
||||
"work-2": ["C"]
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
stationPasses,
|
||||
targetPasses,
|
||||
type GroundPoint,
|
||||
type PassOrbit
|
||||
} from "../tgu-map-2d/geometry/passes";
|
||||
import type { Draft, EditorCheckContext, EditorGroundStation, EditorWork } from "./model/editorTypes";
|
||||
|
||||
export type EditorConflictMap = Record<string, string[]>;
|
||||
|
||||
const DEFAULT_VISIBILITY_MARGIN_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
export function overlaps(works: EditorWork[]): EditorConflictMap {
|
||||
const result: EditorConflictMap = {};
|
||||
|
||||
for (let firstIndex = 0; firstIndex < works.length; firstIndex += 1) {
|
||||
for (let secondIndex = firstIndex + 1; secondIndex < works.length; secondIndex += 1) {
|
||||
const first = works[firstIndex];
|
||||
const second = works[secondIndex];
|
||||
if (Math.max(first.startMs, second.startMs) < Math.min(first.endMs, second.endMs)) {
|
||||
addConflict(result, first.id, `Пересечение по времени с "${kindLabel(second.kind)}"`);
|
||||
addConflict(result, second.id, `Пересечение по времени с "${kindLabel(first.kind)}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function checkVisibility(draft: Draft, context: EditorCheckContext): EditorConflictMap {
|
||||
const result: EditorConflictMap = {};
|
||||
const orbit: PassOrbit = { spacecraftId: context.spacecraftId ?? draft.spacecraftId };
|
||||
const marginMs = context.marginMs ?? DEFAULT_VISIBILITY_MARGIN_MS;
|
||||
|
||||
for (const work of draft.works) {
|
||||
if (work.kind === "shoot" && work.targetLat !== undefined && work.targetLon !== undefined) {
|
||||
const windows = targetPasses(orbit, { lat: work.targetLat, lon: work.targetLon }, {
|
||||
fromMs: work.startMs - marginMs,
|
||||
toMs: work.endMs + marginMs
|
||||
});
|
||||
if (!isCovered(work, windows)) {
|
||||
addConflict(result, work.id, "Съёмка вне зоны видимости цели");
|
||||
}
|
||||
}
|
||||
|
||||
if (work.kind === "downlink" && work.stationId) {
|
||||
const station = context.stations.find((item) => item.id === work.stationId);
|
||||
const point = stationPoint(station);
|
||||
if (!point) {
|
||||
addConflict(result, work.id, "Нет координат станции для проверки видимости");
|
||||
continue;
|
||||
}
|
||||
|
||||
const windows = stationPasses(orbit, point, {
|
||||
fromMs: work.startMs - marginMs,
|
||||
toMs: work.endMs + marginMs
|
||||
});
|
||||
if (!isCovered(work, windows)) {
|
||||
addConflict(result, work.id, "Сброс вне сеанса связи со станцией");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mergeConflictMaps(...maps: EditorConflictMap[]): EditorConflictMap {
|
||||
const result: EditorConflictMap = {};
|
||||
for (const map of maps) {
|
||||
for (const [workId, messages] of Object.entries(map)) {
|
||||
result[workId] = [...(result[workId] ?? []), ...messages];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function conflictCount(map: EditorConflictMap): number {
|
||||
return Object.keys(map).length;
|
||||
}
|
||||
|
||||
function isCovered(work: EditorWork, windows: Array<{ startMs: number; endMs: number }>): boolean {
|
||||
return windows.some((window) => work.startMs >= window.startMs - 1 && work.endMs <= window.endMs + 1);
|
||||
}
|
||||
|
||||
function stationPoint(station?: EditorGroundStation): GroundPoint | undefined {
|
||||
if (station?.lat === undefined || station.lon === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return { lat: station.lat, lon: station.lon };
|
||||
}
|
||||
|
||||
export function kindLabel(kind: EditorWork["kind"]): string {
|
||||
switch (kind) {
|
||||
case "shoot":
|
||||
return "Съёмка";
|
||||
case "downlink":
|
||||
return "Сброс";
|
||||
}
|
||||
}
|
||||
|
||||
function addConflict(map: EditorConflictMap, workId: string, message: string): void {
|
||||
map[workId] = [...(map[workId] ?? []), message];
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TguPlanUi } from "../../model/timelineTypes";
|
||||
import {
|
||||
addDownlink,
|
||||
addShoot,
|
||||
deleteWork,
|
||||
duplicateWork,
|
||||
mutate,
|
||||
redo,
|
||||
seedDraft,
|
||||
undo,
|
||||
updateWork
|
||||
} from "./editorDraft";
|
||||
import type { DraftHistory } from "./model/editorTypes";
|
||||
|
||||
const plan: TguPlanUi = {
|
||||
planId: "plan-1",
|
||||
spacecraftId: "56756",
|
||||
startTime: "2026-05-29T01:00:00Z",
|
||||
endTime: "2026-05-29T02:00:00Z",
|
||||
kppId: "KPP-1",
|
||||
status: "PLANNED",
|
||||
startMs: Date.parse("2026-05-29T01:00:00Z"),
|
||||
endMs: Date.parse("2026-05-29T02:00:00Z")
|
||||
};
|
||||
|
||||
describe("editorDraft", () => {
|
||||
it("seeds a draft from the current API plan envelope when embedded works are absent", () => {
|
||||
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"
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it("adds shoot and downlink works with deterministic ids", () => {
|
||||
const draft = seedDraft(undefined, "56756");
|
||||
const withShoot = addShoot(draft, {
|
||||
id: "shoot-1",
|
||||
label: "Панхром. съёмка",
|
||||
modeId: "pan",
|
||||
startMs: 1000,
|
||||
endMs: 2000,
|
||||
target: { id: "target-1", name: "Цель A", lat: 55.7, lon: 37.6 }
|
||||
});
|
||||
const withDownlink = addDownlink(withShoot, {
|
||||
id: "downlink-1",
|
||||
label: "Сброс на НКПОР",
|
||||
startMs: 3000,
|
||||
endMs: 4000,
|
||||
station: { id: "KPP-1", name: "KPP-1" }
|
||||
});
|
||||
|
||||
expect(withDownlink.works.map((work) => work.id)).toEqual(["shoot-1", "downlink-1"]);
|
||||
expect(withDownlink.works.every((work) => work.origin === "added" && work.isNew)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps undo and redo stacks consistent across mutations", () => {
|
||||
const initial: DraftHistory = { draft: seedDraft(undefined, "56756"), past: [], future: [] };
|
||||
const changed = mutate(initial, (draft) =>
|
||||
addDownlink(draft, {
|
||||
id: "downlink-1",
|
||||
label: "Сброс",
|
||||
startMs: 1000,
|
||||
endMs: 2000,
|
||||
station: { id: "KPP-1", name: "KPP-1" }
|
||||
})
|
||||
);
|
||||
|
||||
const undone = undo(changed);
|
||||
const redone = redo(undone);
|
||||
|
||||
expect(changed.past).toHaveLength(1);
|
||||
expect(undone.draft.works).toHaveLength(0);
|
||||
expect(undone.future).toHaveLength(1);
|
||||
expect(redone.draft.works).toHaveLength(1);
|
||||
expect(redone.future).toHaveLength(0);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
expect(updated.works[0].label).toBe("Сеанс");
|
||||
expect(duplicated.works[1]).toMatchObject({
|
||||
id: "copy-1",
|
||||
startMs: updated.works[0].startMs + 120_000,
|
||||
endMs: updated.works[0].endMs + 120_000,
|
||||
origin: "added",
|
||||
isNew: true
|
||||
});
|
||||
expect(deleted.works.map((work) => work.id)).toEqual(["copy-1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import type { TguPlanUi } from "../../model/timelineTypes";
|
||||
import type {
|
||||
Draft,
|
||||
DraftHistory,
|
||||
EditorDownlinkWork,
|
||||
EditorGroundStation,
|
||||
EditorShootWork,
|
||||
EditorTarget,
|
||||
EditorWork,
|
||||
EditorWorkPatch
|
||||
} from "./model/editorTypes";
|
||||
|
||||
const DEFAULT_DUPLICATE_OFFSET_MS = 20 * 60 * 1000;
|
||||
const DEFAULT_HISTORY_LIMIT = 50;
|
||||
|
||||
export type AddShootInput = {
|
||||
id: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
label: string;
|
||||
modeId?: string;
|
||||
target: EditorTarget;
|
||||
};
|
||||
|
||||
export type AddDownlinkInput = {
|
||||
id: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
label: string;
|
||||
station: EditorGroundStation;
|
||||
};
|
||||
|
||||
export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft {
|
||||
if (!plan) {
|
||||
return { spacecraftId, works: [] };
|
||||
}
|
||||
|
||||
const embeddedWorks = readEmbeddedWorks(plan);
|
||||
return {
|
||||
planId: plan.planId,
|
||||
spacecraftId: plan.spacecraftId,
|
||||
works: embeddedWorks.length > 0 ? embeddedWorks : [fallbackPlanWork(plan)]
|
||||
};
|
||||
}
|
||||
|
||||
export function mutate(
|
||||
history: DraftHistory,
|
||||
transform: (draft: Draft) => Draft,
|
||||
historyLimit = DEFAULT_HISTORY_LIMIT
|
||||
): DraftHistory {
|
||||
const nextDraft = transform(history.draft);
|
||||
if (nextDraft === history.draft) {
|
||||
return history;
|
||||
}
|
||||
|
||||
return {
|
||||
draft: nextDraft,
|
||||
past: [...history.past, history.draft].slice(-historyLimit),
|
||||
future: []
|
||||
};
|
||||
}
|
||||
|
||||
export function undo(history: DraftHistory): DraftHistory {
|
||||
if (history.past.length === 0) {
|
||||
return history;
|
||||
}
|
||||
|
||||
return {
|
||||
draft: history.past[history.past.length - 1],
|
||||
past: history.past.slice(0, -1),
|
||||
future: [history.draft, ...history.future]
|
||||
};
|
||||
}
|
||||
|
||||
export function redo(history: DraftHistory): DraftHistory {
|
||||
if (history.future.length === 0) {
|
||||
return history;
|
||||
}
|
||||
|
||||
return {
|
||||
draft: history.future[0],
|
||||
past: [...history.past, history.draft],
|
||||
future: history.future.slice(1)
|
||||
};
|
||||
}
|
||||
|
||||
export function addShoot(draft: Draft, input: AddShootInput): Draft {
|
||||
const work: EditorShootWork = {
|
||||
id: input.id,
|
||||
kind: "shoot",
|
||||
label: input.label,
|
||||
startMs: input.startMs,
|
||||
endMs: input.endMs,
|
||||
modeId: input.modeId,
|
||||
targetName: input.target.name,
|
||||
targetLat: input.target.lat,
|
||||
targetLon: input.target.lon,
|
||||
origin: "added",
|
||||
isNew: true
|
||||
};
|
||||
|
||||
return { ...draft, works: [...draft.works, work] };
|
||||
}
|
||||
|
||||
export function addDownlink(draft: Draft, input: AddDownlinkInput): Draft {
|
||||
const work: EditorDownlinkWork = {
|
||||
id: input.id,
|
||||
kind: "downlink",
|
||||
label: input.label,
|
||||
startMs: input.startMs,
|
||||
endMs: input.endMs,
|
||||
stationId: input.station.id,
|
||||
origin: "added",
|
||||
isNew: true
|
||||
};
|
||||
|
||||
return { ...draft, works: [...draft.works, work] };
|
||||
}
|
||||
|
||||
export function updateWork(draft: Draft, workId: string, patch: EditorWorkPatch): Draft {
|
||||
return {
|
||||
...draft,
|
||||
works: draft.works.map((work) => (work.id === workId ? ({ ...work, ...patch } as EditorWork) : work))
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteWork(draft: Draft, workId: string): Draft {
|
||||
return {
|
||||
...draft,
|
||||
works: draft.works.filter((work) => work.id !== workId)
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateWork(
|
||||
draft: Draft,
|
||||
workId: string,
|
||||
newId: string,
|
||||
offsetMs = DEFAULT_DUPLICATE_OFFSET_MS
|
||||
): Draft {
|
||||
const source = draft.works.find((work) => work.id === workId);
|
||||
if (!source) {
|
||||
return draft;
|
||||
}
|
||||
|
||||
const duplicated: EditorWork = {
|
||||
...source,
|
||||
id: newId,
|
||||
startMs: source.startMs + offsetMs,
|
||||
endMs: source.endMs + offsetMs,
|
||||
origin: "added",
|
||||
isNew: true
|
||||
};
|
||||
|
||||
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)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rawWorks.flatMap((value, index): EditorWork[] => {
|
||||
if (!isRecord(value)) return [];
|
||||
const kind = value.kind === "shoot" || value.kind === "downlink" ? value.kind : undefined;
|
||||
const startMs = readTime(value, "startMs") ?? readTime(value, "start");
|
||||
const endMs = readTime(value, "endMs") ?? readTime(value, "end");
|
||||
if (!kind || startMs === undefined || endMs === undefined || startMs >= endMs) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const id = readString(value.id) ?? `${plan.planId}:work-${index + 1}`;
|
||||
const label = readString(value.label) ?? (kind === "shoot" ? "Съёмка" : "Сброс на НКПОР");
|
||||
const base = {
|
||||
id,
|
||||
kind,
|
||||
label,
|
||||
startMs,
|
||||
endMs,
|
||||
origin: "plan" as const
|
||||
};
|
||||
|
||||
if (kind === "shoot") {
|
||||
return [{
|
||||
...base,
|
||||
kind,
|
||||
modeId: readString(value.modeId),
|
||||
targetName: readString(value.targetName),
|
||||
targetLat: readNumber(value.targetLat),
|
||||
targetLon: readNumber(value.targetLon)
|
||||
}];
|
||||
}
|
||||
|
||||
return [{
|
||||
...base,
|
||||
kind,
|
||||
stationId: readString(value.stationId) ?? readString(value.kppId)
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function readTime(record: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = record[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value : undefined;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { EditorWorkKind } from "./model/editorTypes";
|
||||
|
||||
export const EDITOR_MONTHS = ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"];
|
||||
|
||||
export const DEFAULT_EDITOR_MODES = [
|
||||
{ id: "pan", label: "Панхром." },
|
||||
{ id: "ms", label: "Мультиспектр." },
|
||||
{ id: "stereo", label: "Стерео" },
|
||||
{ id: "scan", label: "Скан" }
|
||||
];
|
||||
|
||||
export function kindLabel(kind: EditorWorkKind): string {
|
||||
switch (kind) {
|
||||
case "shoot":
|
||||
return "Съёмка";
|
||||
case "downlink":
|
||||
return "Сброс";
|
||||
}
|
||||
}
|
||||
|
||||
export function kindColor(kind: EditorWorkKind): string {
|
||||
return kind === "shoot" ? "var(--t-optical)" : "var(--accent)";
|
||||
}
|
||||
|
||||
export function formatTime(ms: number): string {
|
||||
const date = new Date(ms);
|
||||
return `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
export function formatDate(ms: number): string {
|
||||
const date = new Date(ms);
|
||||
return `${pad(date.getUTCDate())} ${EDITOR_MONTHS[date.getUTCMonth()]}`;
|
||||
}
|
||||
|
||||
export function formatDateTime(ms: number): string {
|
||||
return `${formatDate(ms)} ${formatTime(ms)}`;
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
const hours = ms / 3_600_000;
|
||||
if (hours < 24) {
|
||||
return `${Number.isInteger(hours) ? hours : hours.toFixed(1)} ч`;
|
||||
}
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
const restHours = Math.round(hours - days * 24);
|
||||
return restHours > 0 ? `${days} сут ${restHours} ч` : `${days} сут`;
|
||||
}
|
||||
|
||||
export function niceTicks(fromMs: number, toMs: number, pxPerMs: number, minPx: number): number[] {
|
||||
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];
|
||||
const step = steps.find((item) => item * pxPerMs >= minPx) ?? steps[steps.length - 1];
|
||||
const ticks: number[] = [];
|
||||
const firstTick = Math.ceil(fromMs / step) * step;
|
||||
|
||||
for (let timeMs = firstTick; timeMs <= toMs; timeMs += step) {
|
||||
ticks.push(timeMs);
|
||||
}
|
||||
|
||||
return ticks;
|
||||
}
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../../model/timelineTypes";
|
||||
|
||||
export type EditorWorkKind = "shoot" | "downlink";
|
||||
|
||||
export type EditorWorkOrigin = "plan" | "added";
|
||||
|
||||
export type EditorWorkBase = {
|
||||
id: string;
|
||||
kind: EditorWorkKind;
|
||||
label: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
origin: EditorWorkOrigin;
|
||||
isNew?: boolean;
|
||||
};
|
||||
|
||||
export type EditorShootWork = EditorWorkBase & {
|
||||
kind: "shoot";
|
||||
modeId?: string;
|
||||
targetName?: string;
|
||||
targetLat?: number;
|
||||
targetLon?: number;
|
||||
};
|
||||
|
||||
export type EditorDownlinkWork = EditorWorkBase & {
|
||||
kind: "downlink";
|
||||
stationId?: string;
|
||||
};
|
||||
|
||||
export type EditorWork = EditorShootWork | EditorDownlinkWork;
|
||||
|
||||
export type EditorWorkPatch =
|
||||
Partial<Omit<EditorWorkBase, "id" | "kind">> &
|
||||
Partial<Pick<EditorShootWork, "modeId" | "targetLat" | "targetLon" | "targetName">> &
|
||||
Partial<Pick<EditorDownlinkWork, "stationId">>;
|
||||
|
||||
export type Draft = {
|
||||
planId?: string;
|
||||
spacecraftId?: string;
|
||||
works: EditorWork[];
|
||||
};
|
||||
|
||||
export type DraftHistory = {
|
||||
draft: Draft;
|
||||
past: Draft[];
|
||||
future: Draft[];
|
||||
};
|
||||
|
||||
export type EditorTarget = {
|
||||
id: string;
|
||||
name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
};
|
||||
|
||||
export type EditorGroundStation = {
|
||||
id: string;
|
||||
name: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
};
|
||||
|
||||
export type EditorVisibilityWindow = {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
quality: number;
|
||||
};
|
||||
|
||||
export type EditorVisibilityTrack = {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: "target" | "station";
|
||||
color: string;
|
||||
windows: EditorVisibilityWindow[];
|
||||
};
|
||||
|
||||
export type EditorTimelineWindow = {
|
||||
fromMs: number;
|
||||
toMs: number;
|
||||
};
|
||||
|
||||
export type EditorContextLane = {
|
||||
spacecraftId: string;
|
||||
label: string;
|
||||
works: EditorWork[];
|
||||
};
|
||||
|
||||
export type EditorMode = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type EditorCheckContext = {
|
||||
spacecraftId?: string;
|
||||
stations: EditorGroundStation[];
|
||||
marginMs?: number;
|
||||
};
|
||||
|
||||
export type TguEditorTabProps = {
|
||||
selectedSpacecraftId?: string;
|
||||
selectedPlan?: TguPlanUi;
|
||||
appliedRange: TimelineRange;
|
||||
platforms: TguPlatformUi[];
|
||||
};
|
||||
@@ -15,6 +15,16 @@ type Tgu2DMapViewProps = {
|
||||
layers: Tgu2DMapLayersState;
|
||||
redrawToken: number;
|
||||
onObjectSelect: (selection?: Tgu2DMapSelection) => void;
|
||||
pickMode?: boolean;
|
||||
onPickPoint?: (point: { lat: number; lon: number }) => void;
|
||||
targets?: Tgu2DMapTarget[];
|
||||
};
|
||||
|
||||
export type Tgu2DMapTarget = {
|
||||
id: string;
|
||||
name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
};
|
||||
|
||||
type DragState = {
|
||||
@@ -39,7 +49,15 @@ const INITIAL_VIEW: MapViewState = {
|
||||
zoom: 2
|
||||
};
|
||||
|
||||
export function Tgu2DMapView({ scene, layers, redrawToken, onObjectSelect }: Tgu2DMapViewProps) {
|
||||
export function Tgu2DMapView({
|
||||
scene,
|
||||
layers,
|
||||
redrawToken,
|
||||
onObjectSelect,
|
||||
pickMode = false,
|
||||
onPickPoint,
|
||||
targets = []
|
||||
}: Tgu2DMapViewProps) {
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const dragRef = useRef<DragState | undefined>(undefined);
|
||||
@@ -199,6 +217,12 @@ export function Tgu2DMapView({ scene, layers, redrawToken, onObjectSelect }: Tgu
|
||||
|
||||
const onClick = (event: React.MouseEvent) => {
|
||||
const screenPoint = screenPointFromEvent(event);
|
||||
if (pickMode) {
|
||||
onPickPoint?.(projection.unproject(screenPoint));
|
||||
onObjectSelect(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint));
|
||||
selectedPolygonRef.current = polygon;
|
||||
|
||||
@@ -239,7 +263,7 @@ export function Tgu2DMapView({ scene, layers, redrawToken, onObjectSelect }: Tgu
|
||||
|
||||
return (
|
||||
<div
|
||||
className="tgu-2d-map"
|
||||
className={`tgu-2d-map ${pickMode ? "is-picking" : ""}`}
|
||||
ref={wrapperRef}
|
||||
onClick={onClick}
|
||||
onPointerDown={onPointerDown}
|
||||
@@ -265,6 +289,17 @@ export function Tgu2DMapView({ scene, layers, redrawToken, onObjectSelect }: Tgu
|
||||
className={polygon.id === selectedPolygon?.id ? "is-selected" : "is-hovered"}
|
||||
/>
|
||||
))}
|
||||
{targets.map((target) => {
|
||||
const screen = projection.project(target);
|
||||
return (
|
||||
<g className="tgu-2d-map__target" key={target.id} transform={`translate(${screen.x} ${screen.y})`}>
|
||||
<circle r="6" />
|
||||
<line x1="-10" x2="10" y1="0" y2="0" />
|
||||
<line x1="0" x2="0" y1="-10" y2="10" />
|
||||
<text x="10" y="-8">{target.name}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
{tooltip && (
|
||||
<div className="tgu-2d-map__tooltip" style={{ left: tooltip.x, top: tooltip.y }}>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { groundTrack, nearestStation, stationPasses, subPoint, targetPasses } from "./passes";
|
||||
|
||||
const orbit = { spacecraftId: "56756" };
|
||||
const centerMs = Date.UTC(2026, 4, 29, 12, 0, 0);
|
||||
|
||||
describe("passes", () => {
|
||||
it("finds target and station visibility around the sub-satellite point", () => {
|
||||
const point = subPoint(orbit, centerMs);
|
||||
const range = { fromMs: centerMs - 15 * 60 * 1000, toMs: centerMs + 15 * 60 * 1000 };
|
||||
|
||||
expect(targetPasses(orbit, point, range).length).toBeGreaterThan(0);
|
||||
expect(stationPasses(orbit, point, range).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("selects the nearest station by great-circle distance", () => {
|
||||
const point = { lat: 55.75, lon: 37.62 };
|
||||
|
||||
expect(nearestStation(point, [
|
||||
{ id: "far", lat: -30, lon: -120 },
|
||||
{ id: "near", lat: 55.8, lon: 37.7 }
|
||||
])?.id).toBe("near");
|
||||
});
|
||||
|
||||
it("builds ground track segments in the requested range", () => {
|
||||
const segments = groundTrack(orbit, {
|
||||
fromMs: centerMs,
|
||||
toMs: centerMs + 90 * 60 * 1000
|
||||
});
|
||||
|
||||
expect(segments.length).toBeGreaterThan(0);
|
||||
expect(segments.every((segment) => segment.length > 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import { normalizeLon } from "./mapProjection";
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
const RAD = 180 / Math.PI;
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
const EARTH_MU_KM3_S2 = 398600.4418;
|
||||
const SIDEREAL_DAY_SECONDS = 86164;
|
||||
const DEFAULT_ALTITUDE_KM = 550;
|
||||
const DEFAULT_TARGET_THRESHOLD_DEG = 6.5;
|
||||
const TARGET_SCAN_STEP_MS = 20 * 1000;
|
||||
const STATION_SCAN_STEP_MS = 30 * 1000;
|
||||
const MIN_WINDOW_MS = 40 * 1000;
|
||||
|
||||
export type GroundPoint = {
|
||||
lat: number;
|
||||
lon: number;
|
||||
};
|
||||
|
||||
export type GroundStationPoint = GroundPoint & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type PassOrbit = {
|
||||
spacecraftId?: string;
|
||||
altitudeKm?: number;
|
||||
};
|
||||
|
||||
export type PassRange = {
|
||||
fromMs: number;
|
||||
toMs: number;
|
||||
};
|
||||
|
||||
export type PassWindow = {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
quality: number;
|
||||
};
|
||||
|
||||
export function subPoint(orbit: PassOrbit, timeMs: number): GroundPoint {
|
||||
const params = orbitParams(orbit);
|
||||
const dtSeconds = timeMs / 1000;
|
||||
const argument = params.phaseRad + 2 * Math.PI * (dtSeconds / params.periodSeconds);
|
||||
const lat = Math.asin(Math.sin(params.inclinationRad) * Math.sin(argument)) * RAD;
|
||||
const relativeLon = Math.atan2(Math.cos(params.inclinationRad) * Math.sin(argument), Math.cos(argument));
|
||||
const lon = normalizeLon((params.nodeRad + relativeLon) * RAD - (360 / SIDEREAL_DAY_SECONDS) * dtSeconds);
|
||||
|
||||
return { lat, lon };
|
||||
}
|
||||
|
||||
export function targetPasses(orbit: PassOrbit, target: GroundPoint, range: PassRange): PassWindow[] {
|
||||
return scanWindows(orbit, target, range, DEFAULT_TARGET_THRESHOLD_DEG, TARGET_SCAN_STEP_MS);
|
||||
}
|
||||
|
||||
export function stationPasses(orbit: PassOrbit, station: GroundPoint, range: PassRange, minElevDeg = 5): PassWindow[] {
|
||||
const thresholdDeg = accessAngleDeg(orbit.altitudeKm ?? DEFAULT_ALTITUDE_KM, minElevDeg);
|
||||
return scanWindows(orbit, station, range, thresholdDeg, STATION_SCAN_STEP_MS);
|
||||
}
|
||||
|
||||
export function nearestStation(point: GroundPoint, stations: GroundStationPoint[]): GroundStationPoint | undefined {
|
||||
let bestStation: GroundStationPoint | undefined;
|
||||
let bestDistance = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const station of stations) {
|
||||
const distance = greatCircleDistanceDeg(point, station);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestStation = station;
|
||||
}
|
||||
}
|
||||
|
||||
return bestStation;
|
||||
}
|
||||
|
||||
export function groundTrack(orbit: PassOrbit, range: PassRange, maxPoints = 240): GroundPoint[][] {
|
||||
if (range.toMs <= range.fromMs) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const spanMs = range.toMs - range.fromMs;
|
||||
const stepMs = Math.max(60 * 1000, Math.ceil(spanMs / Math.max(2, maxPoints)));
|
||||
const segments: GroundPoint[][] = [];
|
||||
let current: GroundPoint[] = [];
|
||||
let previousLon: number | undefined;
|
||||
|
||||
for (let timeMs = range.fromMs; timeMs <= range.toMs; timeMs += stepMs) {
|
||||
const point = subPoint(orbit, timeMs);
|
||||
if (previousLon !== undefined && Math.abs(point.lon - previousLon) > 180) {
|
||||
if (current.length > 1) {
|
||||
segments.push(current);
|
||||
}
|
||||
current = [];
|
||||
}
|
||||
current.push(point);
|
||||
previousLon = point.lon;
|
||||
}
|
||||
|
||||
const endPoint = subPoint(orbit, range.toMs);
|
||||
if (current.length === 0 || current[current.length - 1].lat !== endPoint.lat || current[current.length - 1].lon !== endPoint.lon) {
|
||||
current.push(endPoint);
|
||||
}
|
||||
if (current.length > 1) {
|
||||
segments.push(current);
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function greatCircleDistanceDeg(first: GroundPoint, second: GroundPoint): number {
|
||||
const firstLat = first.lat * DEG;
|
||||
const secondLat = second.lat * DEG;
|
||||
const deltaLon = (second.lon - first.lon) * DEG;
|
||||
const cosine =
|
||||
Math.sin(firstLat) * Math.sin(secondLat) +
|
||||
Math.cos(firstLat) * Math.cos(secondLat) * Math.cos(deltaLon);
|
||||
|
||||
return Math.acos(Math.max(-1, Math.min(1, cosine))) * RAD;
|
||||
}
|
||||
|
||||
export function accessAngleDeg(altitudeKm: number, minElevDeg: number): number {
|
||||
const minElevRad = minElevDeg * DEG;
|
||||
const ratio = EARTH_RADIUS_KM / (EARTH_RADIUS_KM + altitudeKm);
|
||||
return (Math.acos(ratio * Math.cos(minElevRad)) - minElevRad) * RAD;
|
||||
}
|
||||
|
||||
function scanWindows(
|
||||
orbit: PassOrbit,
|
||||
target: GroundPoint,
|
||||
range: PassRange,
|
||||
thresholdDeg: number,
|
||||
stepMs: number
|
||||
): PassWindow[] {
|
||||
if (range.toMs <= range.fromMs) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const windows: PassWindow[] = [];
|
||||
let inside = false;
|
||||
let startMs = range.fromMs;
|
||||
let bestMargin = 0;
|
||||
|
||||
for (let timeMs = range.fromMs; timeMs <= range.toMs; timeMs += stepMs) {
|
||||
const distance = greatCircleDistanceDeg(subPoint(orbit, timeMs), target);
|
||||
const visible = distance <= thresholdDeg;
|
||||
|
||||
if (visible && !inside) {
|
||||
inside = true;
|
||||
startMs = timeMs;
|
||||
bestMargin = thresholdDeg - distance;
|
||||
} else if (visible) {
|
||||
bestMargin = Math.max(bestMargin, thresholdDeg - distance);
|
||||
} else if (inside) {
|
||||
windows.push(toWindow(startMs, timeMs, bestMargin, thresholdDeg));
|
||||
inside = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (inside) {
|
||||
windows.push(toWindow(startMs, range.toMs, bestMargin, thresholdDeg));
|
||||
}
|
||||
|
||||
return windows.filter((window) => window.endMs - window.startMs >= MIN_WINDOW_MS);
|
||||
}
|
||||
|
||||
function toWindow(startMs: number, endMs: number, bestMargin: number, thresholdDeg: number): PassWindow {
|
||||
return {
|
||||
startMs,
|
||||
endMs,
|
||||
quality: Math.max(0, Math.min(1, bestMargin / thresholdDeg))
|
||||
};
|
||||
}
|
||||
|
||||
function orbitParams(orbit: PassOrbit): {
|
||||
altitudeKm: number;
|
||||
periodSeconds: number;
|
||||
inclinationRad: number;
|
||||
nodeRad: number;
|
||||
phaseRad: number;
|
||||
} {
|
||||
const altitudeKm = orbit.altitudeKm ?? DEFAULT_ALTITUDE_KM;
|
||||
const semiMajorAxis = EARTH_RADIUS_KM + altitudeKm;
|
||||
const periodSeconds = 2 * Math.PI * Math.sqrt((semiMajorAxis * semiMajorAxis * semiMajorAxis) / EARTH_MU_KM3_S2);
|
||||
const key = orbit.spacecraftId ?? "default-spacecraft";
|
||||
|
||||
return {
|
||||
altitudeKm,
|
||||
periodSeconds,
|
||||
inclinationRad: (97.4 + hash01(key, 7) * 1.6) * DEG,
|
||||
nodeRad: hash01(key, 3) * 360 * DEG,
|
||||
phaseRad: hash01(key, 11) * 2 * Math.PI
|
||||
};
|
||||
}
|
||||
|
||||
function hash01(value: string, salt: number): number {
|
||||
let hash = 2166136261 ^ salt;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return ((hash >>> 0) % 100000) / 100000;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { fetchPlans, fetchPlatforms, sendPlanDecision } from "../../api/tguApi";
|
||||
import type { TguPlanDecision, TguPlatform } from "../../model/tguTypes";
|
||||
import type { TguPlanUi, TimelineRange } from "../../model/timelineTypes";
|
||||
import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes";
|
||||
import { TguEditorTab } from "../tgu-editor/TguEditorTab";
|
||||
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
|
||||
import { TguPlanDetails } from "./TguPlanDetails";
|
||||
import { TguPlanningLayout } from "./TguPlanningLayout";
|
||||
@@ -25,7 +26,7 @@ export function TguPlanningPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedSpacecraftId, setSelectedSpacecraftId] = useState<string>();
|
||||
const [selectedPlanId, setSelectedPlanId] = useState<string>();
|
||||
const [activeTab, setActiveTab] = useState<"timeline" | "map">("timeline");
|
||||
const [activeTab, setActiveTab] = useState<TguActiveTab>("timeline");
|
||||
const [decisionInFlight, setDecisionInFlight] = useState(false);
|
||||
const [decisionNotice, setDecisionNotice] = useState<string>();
|
||||
const [decisionError, setDecisionError] = useState<string>();
|
||||
@@ -197,7 +198,7 @@ export function TguPlanningPage() {
|
||||
onClose={() => setSelectedPlanId(undefined)}
|
||||
/>
|
||||
</main>
|
||||
) : (
|
||||
) : activeTab === "map" ? (
|
||||
<Tgu2DMapTab
|
||||
range={appliedRange}
|
||||
invalidRange={invalidRange}
|
||||
@@ -210,6 +211,13 @@ export function TguPlanningPage() {
|
||||
setDecisionNotice(undefined);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TguEditorTab
|
||||
selectedSpacecraftId={selectedPlan?.spacecraftId ?? selectedSpacecraftId}
|
||||
selectedPlan={selectedPlan}
|
||||
appliedRange={appliedRange}
|
||||
platforms={platforms}
|
||||
/>
|
||||
)}
|
||||
</TguPlanningLayout>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { TguLegend } from "./TguLegend";
|
||||
import type { TguActiveTab } from "../../model/timelineTypes";
|
||||
|
||||
type TguToolbarProps = {
|
||||
fromValue: string;
|
||||
toValue: string;
|
||||
loading: boolean;
|
||||
activeTab: "timeline" | "map";
|
||||
activeTab: TguActiveTab;
|
||||
error?: string;
|
||||
onTabChange: (tab: "timeline" | "map") => void;
|
||||
onTabChange: (tab: TguActiveTab) => void;
|
||||
onFromChange: (value: string) => void;
|
||||
onToChange: (value: string) => void;
|
||||
onApply: () => void;
|
||||
@@ -57,6 +58,15 @@ export function TguToolbar({
|
||||
>
|
||||
Карта 2D
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === "editor" ? "is-active" : ""}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === "editor"}
|
||||
onClick={() => onTabChange("editor")}
|
||||
>
|
||||
Редактор
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="tgu-field">
|
||||
|
||||
@@ -2,7 +2,9 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles/theme.css";
|
||||
import "./styles/editor-tokens.css";
|
||||
import "./styles/tgu-planning.css";
|
||||
import "./styles/tgu-editor.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -22,6 +22,8 @@ export type TimelineRange = {
|
||||
toMs: number;
|
||||
};
|
||||
|
||||
export type TguActiveTab = "timeline" | "map" | "editor";
|
||||
|
||||
export type TimelineRow = {
|
||||
spacecraftId: string;
|
||||
platform?: TguPlatformUi;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
:root {
|
||||
--bg-0: var(--bg-base);
|
||||
--bg-1: var(--bg-panel);
|
||||
--bg-2: var(--bg-panel-2);
|
||||
--bg-3: var(--bg-elevated);
|
||||
--ink: var(--text);
|
||||
--ink-1: var(--text);
|
||||
--ink-2: var(--text-muted);
|
||||
--ink-3: var(--text-dim);
|
||||
--grid: var(--line-soft);
|
||||
--now: var(--warning);
|
||||
--warn: var(--warning);
|
||||
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
--t-optical: var(--accent);
|
||||
--t-optical-dim: rgba(104, 195, 189, 0.18);
|
||||
--t-radar: var(--warning);
|
||||
--t-radar-dim: rgba(240, 173, 46, 0.16);
|
||||
--t-combo: var(--danger);
|
||||
--t-combo-dim: rgba(214, 69, 69, 0.16);
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
.tgu-editor-workspace {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: #121516;
|
||||
}
|
||||
|
||||
.tgu-editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
min-width: 0;
|
||||
min-height: 3.55rem;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0.65rem 0.8rem;
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
.tgu-editor-toolbar__identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.tgu-editor-toolbar__title {
|
||||
overflow: hidden;
|
||||
max-width: 14rem;
|
||||
color: var(--text);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tgu-editor-toolbar__meta,
|
||||
.tgu-editor-rail__meta,
|
||||
.tgu-editor-panel__hint,
|
||||
.tgu-editor-panel__id {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.tgu-editor-toolbar__group {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tgu-editor-toolbar__spacer {
|
||||
flex: 1;
|
||||
min-width: 1rem;
|
||||
}
|
||||
|
||||
.tgu-editor-type-dot {
|
||||
width: 0.62rem;
|
||||
height: 0.62rem;
|
||||
border-radius: 3px;
|
||||
background: var(--t-optical);
|
||||
box-shadow: 0 0 12px rgba(104, 195, 189, 0.4);
|
||||
}
|
||||
|
||||
.tgu-editor-window-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.12rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
padding: 0.12rem;
|
||||
background: #101214;
|
||||
}
|
||||
|
||||
.tgu-editor-window-control button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tgu-editor-window-control span {
|
||||
min-width: 4.2rem;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.64rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tgu-editor-button--warning {
|
||||
color: #ffe7b5;
|
||||
}
|
||||
|
||||
.tgu-editor-notice {
|
||||
margin: 0.55rem 0.8rem 0;
|
||||
}
|
||||
|
||||
.tgu-editor-layout {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tgu-editor-rail {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
.tgu-editor-rail__section {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
padding: 0.8rem;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.tgu-editor-eyebrow {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tgu-editor-rail__title {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tgu-editor-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.tgu-editor-stat {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.tgu-editor-stat span {
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tgu-editor-stat small {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tgu-editor-context-list {
|
||||
display: grid;
|
||||
gap: 0.28rem;
|
||||
max-height: 21rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.tgu-editor-context-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 0.42rem 0.45rem;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tgu-editor-context-item.is-enabled {
|
||||
background: var(--bg-panel-2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tgu-editor-context-item__check {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
color: var(--accent);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.tgu-editor-context-item span:nth-child(2) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tgu-editor-context-item small {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.tgu-editor-center {
|
||||
display: grid;
|
||||
grid-template-rows: 42% minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tgu-editor-map-stage {
|
||||
position: relative;
|
||||
min-height: 13rem;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #0b0d0e;
|
||||
}
|
||||
|
||||
.tgu-editor-map-stage .tgu-2d-map {
|
||||
min-height: 13rem;
|
||||
}
|
||||
|
||||
.tgu-2d-map.is-picking {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.tgu-2d-map__target circle {
|
||||
fill: rgba(240, 173, 46, 0.18);
|
||||
stroke: var(--now);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.tgu-2d-map__target line {
|
||||
stroke: var(--now);
|
||||
stroke-width: 1.4;
|
||||
}
|
||||
|
||||
.tgu-2d-map__target text {
|
||||
fill: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
paint-order: stroke;
|
||||
stroke: rgba(8, 16, 15, 0.85);
|
||||
stroke-width: 3px;
|
||||
}
|
||||
|
||||
.tgu-editor-timeline {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__surface {
|
||||
position: relative;
|
||||
min-width: 46rem;
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__axis-label,
|
||||
.tgu-editor-timeline__axis,
|
||||
.tgu-editor-timeline__lane-label {
|
||||
position: absolute;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__axis-label {
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 40px;
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 1.05rem 0.65rem 0;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__axis {
|
||||
top: 0;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__day,
|
||||
.tgu-editor-timeline__tick-label {
|
||||
position: absolute;
|
||||
border-left: 1px solid var(--line);
|
||||
padding-left: 0.28rem;
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__day {
|
||||
top: 0.18rem;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__day strong {
|
||||
color: var(--text);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__day span,
|
||||
.tgu-editor-timeline__tick-label {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__tick-label {
|
||||
top: 1.2rem;
|
||||
height: 1.15rem;
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__grid {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__grid > div {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-left: 1px solid var(--grid);
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__now {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
z-index: 8;
|
||||
border-left: 1.5px dashed var(--now);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__now span {
|
||||
position: absolute;
|
||||
left: 0.18rem;
|
||||
top: 0;
|
||||
border-radius: 3px;
|
||||
padding: 0 0.24rem;
|
||||
background: var(--now);
|
||||
color: var(--bg-base);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__lane {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__lane-label {
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.12rem;
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 0 0.65rem;
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__lane-label span {
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__lane-label small {
|
||||
color: var(--accent);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.tgu-editor-timeline__lane-body {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.tgu-editor-work {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 0.28rem;
|
||||
height: calc(100% - 1.5rem);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 5px;
|
||||
padding: 0.25rem 0.45rem;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tgu-editor-work.is-selected {
|
||||
outline: 1.5px solid var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(104, 195, 189, 0.18);
|
||||
}
|
||||
|
||||
.tgu-editor-work.has-conflict {
|
||||
border-color: var(--now);
|
||||
}
|
||||
|
||||
.tgu-editor-work--context {
|
||||
top: 0.45rem;
|
||||
height: calc(100% - 0.9rem);
|
||||
border-top: 0;
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
opacity: 0.62;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tgu-editor-work__resize {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 0.45rem;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.tgu-editor-work__resize--left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.tgu-editor-work__resize--right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.tgu-editor-work__dot {
|
||||
flex: none;
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
margin-top: 0.2rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.tgu-editor-work__label {
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tgu-editor-work small {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
bottom: 0.2rem;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.56rem;
|
||||
}
|
||||
|
||||
.tgu-editor-work__new {
|
||||
flex: none;
|
||||
border: 1px solid var(--now);
|
||||
border-radius: 3px;
|
||||
padding: 0 0.12rem;
|
||||
color: var(--now);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.48rem;
|
||||
}
|
||||
|
||||
.tgu-editor-visibility-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tgu-editor-visibility-label i {
|
||||
flex: none;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.tgu-editor-visibility-window {
|
||||
position: absolute;
|
||||
top: 0.32rem;
|
||||
height: calc(100% - 0.64rem);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tgu-editor-context-divider {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
height: 22px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0.34rem 0.65rem 0;
|
||||
background: var(--bg-base);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.56rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tgu-editor-side-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: var(--bg-panel);
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.tgu-editor-panel {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.tgu-editor-panel__head,
|
||||
.tgu-editor-panel__section-head,
|
||||
.tgu-editor-panel__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.tgu-editor-panel__head h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tgu-editor-panel__section {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.tgu-editor-panel__placeholder,
|
||||
.tgu-editor-target {
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 7px;
|
||||
padding: 0.65rem;
|
||||
background: var(--bg-panel-2);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tgu-editor-target {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.tgu-editor-target i {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border: 2px solid var(--now);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.tgu-editor-target span {
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tgu-editor-target small {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.tgu-editor-chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tgu-editor-chip-row button,
|
||||
.tgu-editor-pass-list button,
|
||||
.tgu-editor-station button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-panel-2);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tgu-editor-chip-row button {
|
||||
padding: 0.38rem 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tgu-editor-chip-row button.is-selected,
|
||||
.tgu-editor-pass-list button.is-selected,
|
||||
.tgu-editor-station button.is-selected {
|
||||
border-color: rgba(104, 195, 189, 0.58);
|
||||
background: rgba(104, 195, 189, 0.14);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tgu-editor-pass-list,
|
||||
.tgu-editor-station-list {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.tgu-editor-pass-list button,
|
||||
.tgu-editor-station button {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tgu-editor-pass-list span,
|
||||
.tgu-editor-station span {
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.tgu-editor-pass-list small,
|
||||
.tgu-editor-station small {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.tgu-editor-station {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.tgu-editor-station__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: var(--bg-panel-2);
|
||||
}
|
||||
|
||||
.tgu-editor-panel select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.5rem;
|
||||
background: #101214;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tgu-editor-time-stepper {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tgu-editor-time-stepper > span {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.tgu-editor-time-stepper div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tgu-editor-time-stepper button {
|
||||
flex: none;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
padding: 0.32rem 0.38rem;
|
||||
background: var(--bg-panel-2);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.tgu-editor-time-stepper output {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
padding: 0.34rem 0.4rem;
|
||||
background: #101214;
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.66rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tgu-editor-panel__color {
|
||||
position: absolute;
|
||||
top: 0.2rem;
|
||||
left: -0.9rem;
|
||||
width: 0.18rem;
|
||||
height: 1.4rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.tgu-editor-layout {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
.tgu-editor-rail,
|
||||
.tgu-editor-center,
|
||||
.tgu-editor-side-panel {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tgu-editor-rail {
|
||||
max-height: 16rem;
|
||||
}
|
||||
|
||||
.tgu-editor-side-panel {
|
||||
max-height: 24rem;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user