pcp-tgu-ops-ui: вкладка Редактор — шаг времени маршрута и формат длительности
- RouteTimeStepper и доработки EditorPanels/TguEditorTab по вводу времени; - formatDuration: секунды/минуты для коротких интервалов; - стили tgu-editor.css.
This commit is contained in:
@@ -2,7 +2,8 @@ import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kind
|
|||||||
import { statusStyle } from "../tgu-planning/tguStatus";
|
import { statusStyle } from "../tgu-planning/tguStatus";
|
||||||
import { formatUtcDateTime, parseUtc } from "../../utils/utcTime";
|
import { formatUtcDateTime, parseUtc } from "../../utils/utcTime";
|
||||||
import type { RvaZone } from "../../api/ballisticsApi";
|
import type { RvaZone } from "../../api/ballisticsApi";
|
||||||
import type { SurveyRoute, SurveyRouteAnchor } from "../../api/missionPlanningApi";
|
import type { ChangeEvent } from "react";
|
||||||
|
import type { SurveyRoute } from "../../api/missionPlanningApi";
|
||||||
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
|
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
|
||||||
import type { RequestMapItem } from "../../model/requestTypes";
|
import type { RequestMapItem } from "../../model/requestTypes";
|
||||||
import type {
|
import type {
|
||||||
@@ -45,11 +46,27 @@ type ShootPanelProps = {
|
|||||||
selectedPass?: EditorVisibilityWindow;
|
selectedPass?: EditorVisibilityWindow;
|
||||||
modeId?: string;
|
modeId?: string;
|
||||||
modes: EditorMode[];
|
modes: EditorMode[];
|
||||||
routeAnchor: SurveyRouteAnchor;
|
/** Окно строящегося маршрута (UTC, мс). undefined пока не выбран проход. */
|
||||||
|
routeWindow?: { startMs: number; endMs: number };
|
||||||
|
/** Текущий крен (gamma) маршрута, градусы. */
|
||||||
|
routeRoll?: number;
|
||||||
|
/** Окно видимости выбранного прохода — для подписи длительности. */
|
||||||
|
passStartMs?: number;
|
||||||
|
passEndMs?: number;
|
||||||
|
/** Допустимый диапазон времени окна маршрута (±30 мин от окна видимости), мс. */
|
||||||
|
routeMinMs?: number;
|
||||||
|
routeMaxMs?: number;
|
||||||
routeLoading?: boolean;
|
routeLoading?: boolean;
|
||||||
routeError?: string;
|
routeError?: string;
|
||||||
route?: SurveyRoute;
|
route?: SurveyRoute;
|
||||||
onAnchorChange: (anchor: SurveyRouteAnchor) => void;
|
onRouteStartChange: (startMs: number) => void;
|
||||||
|
onRouteEndChange: (endMs: number) => void;
|
||||||
|
/** Сдвиг окна целиком вперёд/назад (мс), длительность сохраняется. */
|
||||||
|
onRouteShift: (deltaMs: number) => void;
|
||||||
|
/** Изменение крена влево/вправо (градусы, дельта от кнопок). */
|
||||||
|
onRouteRollChange: (deltaDeg: number) => void;
|
||||||
|
/** Установка крена напрямую (ручной ввод), градусы. */
|
||||||
|
onRouteRollSet: (deg: number) => void;
|
||||||
onSelectPass: (pass?: EditorVisibilityWindow) => void;
|
onSelectPass: (pass?: EditorVisibilityWindow) => void;
|
||||||
onModeChange: (modeId: string) => void;
|
onModeChange: (modeId: string) => void;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
@@ -57,6 +74,9 @@ type ShootPanelProps = {
|
|||||||
onClearTarget: () => void;
|
onClearTarget: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Минимальная длительность маршрута, мс (нижняя граница окна). */
|
||||||
|
const MIN_ROUTE_DURATION_MS = 5_000;
|
||||||
|
|
||||||
type DownlinkPanelProps = {
|
type DownlinkPanelProps = {
|
||||||
stationOptions: DownlinkStationOption[];
|
stationOptions: DownlinkStationOption[];
|
||||||
selected?: DownlinkSelection;
|
selected?: DownlinkSelection;
|
||||||
@@ -204,11 +224,20 @@ export function ShootPanel({
|
|||||||
selectedPass,
|
selectedPass,
|
||||||
modeId,
|
modeId,
|
||||||
modes,
|
modes,
|
||||||
routeAnchor,
|
routeWindow,
|
||||||
|
routeRoll,
|
||||||
|
passStartMs,
|
||||||
|
passEndMs,
|
||||||
|
routeMinMs,
|
||||||
|
routeMaxMs,
|
||||||
routeLoading,
|
routeLoading,
|
||||||
routeError,
|
routeError,
|
||||||
route,
|
route,
|
||||||
onAnchorChange,
|
onRouteStartChange,
|
||||||
|
onRouteEndChange,
|
||||||
|
onRouteShift,
|
||||||
|
onRouteRollChange,
|
||||||
|
onRouteRollSet,
|
||||||
onSelectPass,
|
onSelectPass,
|
||||||
onModeChange,
|
onModeChange,
|
||||||
onAdd,
|
onAdd,
|
||||||
@@ -287,25 +316,57 @@ export function ShootPanel({
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{target && selectedPass && (
|
{target && selectedPass && routeWindow && (
|
||||||
<section className="tgu-editor-panel__section">
|
<section className="tgu-editor-panel__section">
|
||||||
<div className="tgu-editor-eyebrow">4 · маршрут съёмки</div>
|
<div className="tgu-editor-eyebrow">4 · окно маршрута</div>
|
||||||
<div className="tgu-editor-chip-row">
|
<RouteTimeStepper
|
||||||
<button
|
label="начало"
|
||||||
className={routeAnchor === "CENTERED" ? "is-selected" : ""}
|
value={routeWindow.startMs}
|
||||||
type="button"
|
min={routeMinMs}
|
||||||
onClick={() => onAnchorChange("CENTERED")}
|
max={routeWindow.endMs - MIN_ROUTE_DURATION_MS}
|
||||||
>
|
onChange={onRouteStartChange}
|
||||||
вокруг момента
|
/>
|
||||||
</button>
|
<RouteTimeStepper
|
||||||
<button
|
label="конец"
|
||||||
className={routeAnchor === "FORWARD" ? "is-selected" : ""}
|
value={routeWindow.endMs}
|
||||||
type="button"
|
min={routeWindow.startMs + MIN_ROUTE_DURATION_MS}
|
||||||
onClick={() => onAnchorChange("FORWARD")}
|
max={routeMaxMs}
|
||||||
>
|
onChange={onRouteEndChange}
|
||||||
от момента вперёд
|
/>
|
||||||
</button>
|
<div className="tgu-editor-panel__hint">
|
||||||
|
длительность: {formatDuration(routeWindow.endMs - routeWindow.startMs)}
|
||||||
|
{passStartMs != null && passEndMs != null && ` · окно видимости: ${formatDuration(passEndMs - passStartMs)}`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="tgu-editor-time-stepper">
|
||||||
|
<span>сдвиг — ◀ назад · вперёд ▶</span>
|
||||||
|
<div className="tgu-editor-time-stepper__steps">
|
||||||
|
<button type="button" onClick={() => onRouteShift(-30_000)}>−30с</button>
|
||||||
|
<button type="button" onClick={() => onRouteShift(-5_000)}>−5с</button>
|
||||||
|
<button type="button" onClick={() => onRouteShift(5_000)}>+5с</button>
|
||||||
|
<button type="button" onClick={() => onRouteShift(30_000)}>+30с</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tgu-editor-time-stepper">
|
||||||
|
<span>крен — ◀ влево · вправо ▶</span>
|
||||||
|
<div>
|
||||||
|
<button type="button" onClick={() => onRouteRollChange(-5)}>−5°</button>
|
||||||
|
<button type="button" onClick={() => onRouteRollChange(-1)}>−1°</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={0.5}
|
||||||
|
value={routeRoll ?? 0}
|
||||||
|
onChange={(event) => {
|
||||||
|
const deg = Number.parseFloat(event.target.value);
|
||||||
|
if (Number.isFinite(deg)) onRouteRollSet(deg);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => onRouteRollChange(1)}>+1°</button>
|
||||||
|
<button type="button" onClick={() => onRouteRollChange(5)}>+5°</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{routeLoading ? (
|
{routeLoading ? (
|
||||||
<div className="tgu-editor-panel__hint">Построение маршрута…</div>
|
<div className="tgu-editor-panel__hint">Построение маршрута…</div>
|
||||||
) : routeError ? (
|
) : routeError ? (
|
||||||
@@ -628,3 +689,47 @@ function TimeStepper({
|
|||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Степпер времени окна маршрута с секундной точностью: кнопки ±5 c / ±30 c и ручной
|
||||||
|
* ввод времени `чч:мм:сс` (UTC, без даты — дата берётся из текущего значения).
|
||||||
|
* Значение зажимается в [min, max].
|
||||||
|
*/
|
||||||
|
function RouteTimeStepper({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
}) {
|
||||||
|
const clamp = (next: number) => Math.max(min ?? Number.NEGATIVE_INFINITY, Math.min(max ?? Number.POSITIVE_INFINITY, next));
|
||||||
|
const change = (deltaMs: number) => onChange(clamp(value + deltaMs));
|
||||||
|
const onInput = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const raw = event.target.value;
|
||||||
|
if (!raw) return;
|
||||||
|
// Ввод — только время суток; день оставляем прежним (UTC).
|
||||||
|
const datePart = new Date(value).toISOString().slice(0, 10);
|
||||||
|
const ms = parseUtc(`${datePart}T${raw}`);
|
||||||
|
if (Number.isFinite(ms)) onChange(clamp(ms));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="tgu-editor-time-stepper tgu-editor-time-stepper--seconds">
|
||||||
|
<span>{label}</span>
|
||||||
|
{/* lang=ru-RU — чтобы поле показывало время в 24-часовом формате, а не AM/PM. */}
|
||||||
|
<input lang="ru-RU" type="time" step={1} value={new Date(value).toISOString().slice(11, 19)} onChange={onInput} />
|
||||||
|
<div className="tgu-editor-time-stepper__steps">
|
||||||
|
<button type="button" onClick={() => change(-30_000)}>−30с</button>
|
||||||
|
<button type="button" onClick={() => change(-5_000)}>−5с</button>
|
||||||
|
<button type="button" onClick={() => change(5_000)}>+5с</button>
|
||||||
|
<button type="button" onClick={() => change(30_000)}>+30с</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fetchFlightLine, type FlightLinePoint } from "../../api/tguApi";
|
|||||||
import type { RequestMapItem } from "../../model/requestTypes";
|
import type { RequestMapItem } from "../../model/requestTypes";
|
||||||
import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes";
|
import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes";
|
||||||
import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi";
|
import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi";
|
||||||
import { buildSurveyRoute, saveDropRoute, saveSurveyRoute, type SurveyRoute, type SurveyRouteAnchor } from "../../api/missionPlanningApi";
|
import { buildSurveyRoute, saveDropRoute, saveSurveyRoute, type SurveyRoute } from "../../api/missionPlanningApi";
|
||||||
import { parseUtc } from "../../utils/utcTime";
|
import { parseUtc } from "../../utils/utcTime";
|
||||||
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
||||||
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
|
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
|
||||||
@@ -58,6 +58,12 @@ const SHOOT_WINDOW_MS = 6 * 60 * 1000;
|
|||||||
* это значение будет определять бэкенд по профилю наблюдения КА.
|
* это значение будет определять бэкенд по профилю наблюдения КА.
|
||||||
*/
|
*/
|
||||||
const DEFAULT_CAPTURE_ANGLE_DEG = 1.5;
|
const DEFAULT_CAPTURE_ANGLE_DEG = 1.5;
|
||||||
|
/** Дефолтная длительность строящегося маршрута съёмки (мс). */
|
||||||
|
const DEFAULT_ROUTE_DURATION_MS = 30_000;
|
||||||
|
/** Предел крена (gamma) маршрута при ручной правке, градусы. */
|
||||||
|
const MAX_ROUTE_ROLL_DEG = 89;
|
||||||
|
/** Допустимый выход окна маршрута за пределы окна видимости, мс (±30 минут). */
|
||||||
|
const ROUTE_TIME_MARGIN_MS = 30 * 60_000;
|
||||||
const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = {
|
const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = {
|
||||||
tracks: true,
|
tracks: true,
|
||||||
swath: true,
|
swath: true,
|
||||||
@@ -144,7 +150,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
const [shootPasses, setShootPasses] = useState<EditorVisibilityWindow[]>([]);
|
const [shootPasses, setShootPasses] = useState<EditorVisibilityWindow[]>([]);
|
||||||
const [shootPassesLoading, setShootPassesLoading] = useState(false);
|
const [shootPassesLoading, setShootPassesLoading] = useState(false);
|
||||||
const [shootPassesError, setShootPassesError] = useState<string>();
|
const [shootPassesError, setShootPassesError] = useState<string>();
|
||||||
const [routeAnchor, setRouteAnchor] = useState<SurveyRouteAnchor>("CENTERED");
|
// Окно строящегося маршрута съёмки: задаётся вручную в правой панели (начало/конец, UTC).
|
||||||
|
const [routeWindow, setRouteWindow] = useState<{ startMs: number; endMs: number }>();
|
||||||
|
// Крен (gamma) строящегося маршрута, градусы. По умолчанию — из прохода, дальше правится вручную.
|
||||||
|
const [routeRoll, setRouteRoll] = useState<number>();
|
||||||
const [surveyRoute, setSurveyRoute] = useState<SurveyRoute>();
|
const [surveyRoute, setSurveyRoute] = useState<SurveyRoute>();
|
||||||
const [routeLoading, setRouteLoading] = useState(false);
|
const [routeLoading, setRouteLoading] = useState(false);
|
||||||
const [routeError, setRouteError] = useState<string>();
|
const [routeError, setRouteError] = useState<string>();
|
||||||
@@ -292,29 +301,44 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
};
|
};
|
||||||
}, [rightMode, target, noradId, window.fromMs, window.toMs]);
|
}, [rightMode, target, noradId, window.fromMs, window.toMs]);
|
||||||
|
|
||||||
|
// При выборе нового прохода задаём дефолтное окно маршрута: 30 c вокруг момента
|
||||||
|
// видимости, обрезанное окном видимости прохода. Дальше пользователь правит вручную.
|
||||||
|
useEffect(() => {
|
||||||
|
if (rightMode !== "shoot" || !selectedShootPass) {
|
||||||
|
setRouteWindow(undefined);
|
||||||
|
setRouteRoll(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const passDurationMs = selectedShootPass.endMs - selectedShootPass.startMs;
|
||||||
|
const half = Math.min(DEFAULT_ROUTE_DURATION_MS, passDurationMs) / 2;
|
||||||
|
const mid = (selectedShootPass.startMs + selectedShootPass.endMs) / 2;
|
||||||
|
setRouteWindow({ startMs: mid - half, endMs: mid + half });
|
||||||
|
setRouteRoll(selectedShootPass.rollDeg ?? 0);
|
||||||
|
}, [rightMode, selectedShootPass]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const numericNorad = noradId ? Number(noradId) : NaN;
|
const numericNorad = noradId ? Number(noradId) : NaN;
|
||||||
if (rightMode !== "shoot" || !target || !selectedShootPass || !Number.isFinite(numericNorad)) {
|
if (rightMode !== "shoot" || !target || !selectedShootPass || !routeWindow || routeRoll == null || !Number.isFinite(numericNorad)) {
|
||||||
setSurveyRoute(undefined);
|
setSurveyRoute(undefined);
|
||||||
setRouteError(undefined);
|
setRouteError(undefined);
|
||||||
setRouteLoading(false);
|
setRouteLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const momentMs = (selectedShootPass.startMs + selectedShootPass.endMs) / 2;
|
const durationSeconds = Math.max(1, (routeWindow.endMs - routeWindow.startMs) / 1000);
|
||||||
const durationSeconds = Math.max(1, (selectedShootPass.endMs - selectedShootPass.startMs) / 1000);
|
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setRouteLoading(true);
|
setRouteLoading(true);
|
||||||
setRouteError(undefined);
|
setRouteError(undefined);
|
||||||
void buildSurveyRoute({
|
void buildSurveyRoute({
|
||||||
satelliteId: numericNorad,
|
satelliteId: numericNorad,
|
||||||
time: new Date(momentMs).toISOString().slice(0, 19),
|
// Окно задано явно (начало/конец) — строим вперёд от начала на выбранную длительность.
|
||||||
|
time: new Date(routeWindow.startMs).toISOString().slice(0, 19),
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
roll: selectedShootPass.rollDeg ?? 0,
|
roll: routeRoll,
|
||||||
captureAngle: DEFAULT_CAPTURE_ANGLE_DEG,
|
captureAngle: DEFAULT_CAPTURE_ANGLE_DEG,
|
||||||
revolution: selectedShootPass.revolution ?? 0,
|
revolution: selectedShootPass.revolution ?? 0,
|
||||||
anchor: routeAnchor
|
anchor: "FORWARD"
|
||||||
})
|
})
|
||||||
.then((route) => {
|
.then((route) => {
|
||||||
if (!cancelled) setSurveyRoute(route);
|
if (!cancelled) setSurveyRoute(route);
|
||||||
@@ -330,7 +354,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [rightMode, target, selectedShootPass, noradId, routeAnchor]);
|
}, [rightMode, target, selectedShootPass, noradId, routeWindow, routeRoll]);
|
||||||
|
|
||||||
// Зоны радиовидимости (ЗРВ) для ручного сброса — грузим при входе в режим «Сброс».
|
// Зоны радиовидимости (ЗРВ) для ручного сброса — грузим при входе в режим «Сброс».
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -396,21 +420,20 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
// выбранного плана (planId == mission_id). Сохраняем только если план выбран и маршрут
|
// выбранного плана (planId == mission_id). Сохраняем только если план выбран и маршрут
|
||||||
// уже построен; иначе (открыт только КА без плана) добавляем включение без сохранения.
|
// уже построен; иначе (открыт только КА без плана) добавляем включение без сохранения.
|
||||||
const numericNorad = noradId ? Number(noradId) : NaN;
|
const numericNorad = noradId ? Number(noradId) : NaN;
|
||||||
if (selectedPlan && surveyRoute && Number.isFinite(numericNorad)) {
|
if (selectedPlan && surveyRoute && routeWindow && routeRoll != null && Number.isFinite(numericNorad)) {
|
||||||
const planId = selectedPlan.planId;
|
const planId = selectedPlan.planId;
|
||||||
const momentMs = (selectedShootPass.startMs + selectedShootPass.endMs) / 2;
|
const routeDurationSeconds = Math.max(1, (routeWindow.endMs - routeWindow.startMs) / 1000);
|
||||||
const routeDurationSeconds = Math.max(1, (selectedShootPass.endMs - selectedShootPass.startMs) / 1000);
|
|
||||||
setRouteLoading(true);
|
setRouteLoading(true);
|
||||||
setRouteError(undefined);
|
setRouteError(undefined);
|
||||||
try {
|
try {
|
||||||
const saved = await saveSurveyRoute(planId, {
|
const saved = await saveSurveyRoute(planId, {
|
||||||
satelliteId: numericNorad,
|
satelliteId: numericNorad,
|
||||||
time: new Date(momentMs).toISOString().slice(0, 19),
|
time: new Date(routeWindow.startMs).toISOString().slice(0, 19),
|
||||||
durationSeconds: routeDurationSeconds,
|
durationSeconds: routeDurationSeconds,
|
||||||
roll: selectedShootPass.rollDeg ?? 0,
|
roll: routeRoll,
|
||||||
captureAngle: DEFAULT_CAPTURE_ANGLE_DEG,
|
captureAngle: DEFAULT_CAPTURE_ANGLE_DEG,
|
||||||
revolution: selectedShootPass.revolution ?? 0,
|
revolution: selectedShootPass.revolution ?? 0,
|
||||||
anchor: routeAnchor,
|
anchor: "FORWARD",
|
||||||
// Окно выбранного плана — чтобы при автосоздании миссии/плана задать корректный интервал.
|
// Окно выбранного плана — чтобы при автосоздании миссии/плана задать корректный интервал.
|
||||||
planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19),
|
planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19),
|
||||||
planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19)
|
planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19)
|
||||||
@@ -424,8 +447,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const durationMs = Math.min(selectedShootPass.endMs - selectedShootPass.startMs, 30 * 60 * 1000);
|
// Окно включения в черновике — заданное вручную окно маршрута (фолбэк — окно прохода).
|
||||||
const startMs = selectedShootPass.startMs + Math.floor((selectedShootPass.endMs - selectedShootPass.startMs - durationMs) / 2);
|
const startMs = routeWindow?.startMs ?? selectedShootPass.startMs;
|
||||||
|
const endMs = routeWindow?.endMs ?? selectedShootPass.endMs;
|
||||||
const mode = modes.find((item) => item.id === modeId);
|
const mode = modes.find((item) => item.id === modeId);
|
||||||
const id = nextWorkId(idCounterRef);
|
const id = nextWorkId(idCounterRef);
|
||||||
|
|
||||||
@@ -433,7 +457,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
addShoot(current, {
|
addShoot(current, {
|
||||||
id,
|
id,
|
||||||
startMs,
|
startMs,
|
||||||
endMs: startMs + durationMs,
|
endMs,
|
||||||
label: mode?.label ?? "Съёмка",
|
label: mode?.label ?? "Съёмка",
|
||||||
modeId,
|
modeId,
|
||||||
target
|
target
|
||||||
@@ -752,11 +776,39 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
selectedPass={selectedShootPass}
|
selectedPass={selectedShootPass}
|
||||||
modeId={modeId}
|
modeId={modeId}
|
||||||
modes={modes}
|
modes={modes}
|
||||||
routeAnchor={routeAnchor}
|
routeWindow={routeWindow}
|
||||||
|
routeRoll={routeRoll}
|
||||||
|
passStartMs={selectedShootPass?.startMs}
|
||||||
|
passEndMs={selectedShootPass?.endMs}
|
||||||
|
routeMinMs={selectedShootPass != null ? selectedShootPass.startMs - ROUTE_TIME_MARGIN_MS : undefined}
|
||||||
|
routeMaxMs={selectedShootPass != null ? selectedShootPass.endMs + ROUTE_TIME_MARGIN_MS : undefined}
|
||||||
routeLoading={routeLoading}
|
routeLoading={routeLoading}
|
||||||
routeError={routeError}
|
routeError={routeError}
|
||||||
route={surveyRoute}
|
route={surveyRoute}
|
||||||
onAnchorChange={setRouteAnchor}
|
onRouteStartChange={(startMs) => setRouteWindow((current) => (current ? { ...current, startMs } : current))}
|
||||||
|
onRouteEndChange={(endMs) => setRouteWindow((current) => (current ? { ...current, endMs } : current))}
|
||||||
|
onRouteShift={(deltaMs) =>
|
||||||
|
setRouteWindow((current) => {
|
||||||
|
if (!current || selectedShootPass == null) return current;
|
||||||
|
// Двигаем окно целиком, сохраняя длительность; не выходим за ±30 мин от окна видимости.
|
||||||
|
const minMs = selectedShootPass.startMs - ROUTE_TIME_MARGIN_MS;
|
||||||
|
const maxMs = selectedShootPass.endMs + ROUTE_TIME_MARGIN_MS;
|
||||||
|
let shift = deltaMs;
|
||||||
|
if (current.endMs + shift > maxMs) shift = maxMs - current.endMs;
|
||||||
|
if (current.startMs + shift < minMs) shift = minMs - current.startMs;
|
||||||
|
return { startMs: current.startMs + shift, endMs: current.endMs + shift };
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onRouteRollChange={(deltaDeg) =>
|
||||||
|
setRouteRoll((current) =>
|
||||||
|
Math.max(-MAX_ROUTE_ROLL_DEG, Math.min(MAX_ROUTE_ROLL_DEG, (current ?? 0) + deltaDeg))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onRouteRollSet={(deg) =>
|
||||||
|
setRouteRoll((current) =>
|
||||||
|
Number.isFinite(deg) ? Math.max(-MAX_ROUTE_ROLL_DEG, Math.min(MAX_ROUTE_ROLL_DEG, deg)) : current
|
||||||
|
)
|
||||||
|
}
|
||||||
onSelectPass={setSelectedShootPass}
|
onSelectPass={setSelectedShootPass}
|
||||||
onModeChange={setModeId}
|
onModeChange={setModeId}
|
||||||
onAdd={addShootWork}
|
onAdd={addShootWork}
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ export function formatDateTime(ms: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatDuration(ms: number): string {
|
export function formatDuration(ms: number): string {
|
||||||
|
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
||||||
|
if (totalSeconds < 60) {
|
||||||
|
return `${totalSeconds} с`;
|
||||||
|
}
|
||||||
|
if (totalSeconds < 3600) {
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return seconds > 0 ? `${minutes} мин ${seconds} с` : `${minutes} мин`;
|
||||||
|
}
|
||||||
|
|
||||||
const hours = ms / 3_600_000;
|
const hours = ms / 3_600_000;
|
||||||
if (hours < 24) {
|
if (hours < 24) {
|
||||||
return `${Number.isInteger(hours) ? hours : hours.toFixed(1)} ч`;
|
return `${Number.isInteger(hours) ? hours : hours.toFixed(1)} ч`;
|
||||||
|
|||||||
@@ -910,6 +910,18 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Узкая правая панель: строка прохода не помещается в один ряд — раскладываем
|
||||||
|
подпись и параметры видимости в столбец, чтобы текст не обрезался. */
|
||||||
|
.tgu-editor-pass-list button {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-editor-pass-list small {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.tgu-editor-pass-list span,
|
.tgu-editor-pass-list span,
|
||||||
.tgu-editor-station span {
|
.tgu-editor-station span {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -975,7 +987,8 @@
|
|||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tgu-editor-time-stepper output {
|
.tgu-editor-time-stepper output,
|
||||||
|
.tgu-editor-time-stepper input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -988,6 +1001,31 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tgu-editor-time-stepper input::-webkit-calendar-picker-indicator {
|
||||||
|
filter: invert(1);
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Секундный степпер окна маршрута: поле ввода на своей строке, кнопки ± — ниже,
|
||||||
|
чтобы помещаться в узкую правую панель. */
|
||||||
|
.tgu-editor-time-stepper--seconds {
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-editor-time-stepper--seconds > input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-editor-time-stepper__steps {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-editor-time-stepper__steps button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.tgu-editor-panel__color {
|
.tgu-editor-panel__color {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.2rem;
|
top: 0.2rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user