Files
dc-observatio/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorTimeline.tsx
T
Дмитрий Соловьев 294b17757d pcp: время — сквозной naive LocalDateTime как локальное настенное время (без UTC)
Отказ от остаточной UTC-семантики: naive LocalDateTime трактуется как
локальное время единой зоны всех сервисов.

Backend (границы внешних систем):
- VisibilityPayloadParser, SyncSpacecraftFromNsiUseCase: ZoneOffset.UTC →
  ZoneId.systemDefault() для проекции внешних Instant/ISO-с-Z в naive.
- тесты границ перевёрнуты на systemDefault (под Europe/Moscow ждут +3).
- чистка UTC-формулировок в DTO-комментариях, мёртвых ссылок на
  docs/standards/DATETIME_UTC.md.

Frontend (pcp-tgu-ops-ui):
- utils/utcTime.ts → utils/localTime.ts: parseLocal/formatLocal*/toLocalIso/
  toLocalInputValue с локальной семантикой (без дописывания Z и timeZone:UTC).
- замена getUTC*/Date.UTC/toISOString-сериализации на локальные аналоги.
- убраны пользовательские подписи «UTC» в редакторе/таймлайне.
- удалён utcTime.guard.test.ts (форсил обратный UTC-инвариант); тест-фикстуры
  сделаны зоно-независимыми. tsc + vitest зелёные под TZ=UTC и TZ=Europe/Moscow.

Включает также прочие незакоммиченные правки рабочего дерева.
2026-06-04 09:50:39 +03:00

216 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from "react";
import { formatDate, formatDateTime, formatTime, kindColor, kindLabel, niceTicks } from "./editorPresentation";
import type {
EditorContextLane,
EditorTimelineWindow,
EditorVisibilityTrack,
EditorWork
} from "./model/editorTypes";
import { VisibilityTracks } from "./VisibilityTracks";
type EditorTimelineProps = {
activeLabel: string;
works: EditorWork[];
selectedWorkId?: string;
window: EditorTimelineWindow;
planStartMs?: number;
planEndMs?: number;
conflicts: Record<string, string[]>;
contextLanes: EditorContextLane[];
visibilityTracks: EditorVisibilityTrack[];
onSelectWork: (workId: string) => void;
onClearSelection: () => void;
};
const LABEL_WIDTH = 168;
const AXIS_HEIGHT = 40;
/** Минимальная высота активной дорожки (два ряда: съёмка + сброс). */
const MIN_ACTIVE_HEIGHT = 84;
const VISIBILITY_HEIGHT = 24;
const OTHER_HEIGHT = 30;
export function EditorTimeline({
activeLabel,
works,
selectedWorkId,
window: timelineWindow,
planStartMs,
planEndMs,
conflicts,
contextLanes,
visibilityTracks,
onSelectWork,
onClearSelection
}: EditorTimelineProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const [bodyWidth, setBodyWidth] = useState(900);
const [bodyHeight, setBodyHeight] = useState(260);
const span = Math.max(1, timelineWindow.toMs - timelineWindow.fromMs);
const pxPerMs = bodyWidth / span;
const toX = (timeMs: number) => (timeMs - timelineWindow.fromMs) * pxPerMs;
const ticks = useMemo(() => niceTicks(timelineWindow.fromMs, timelineWindow.toMs, pxPerMs, 72), [pxPerMs, timelineWindow.fromMs, timelineWindow.toMs]);
const days = useMemo(() => buildDays(timelineWindow), [timelineWindow]);
// Фиксированная высота частей ниже активной дорожки (треки видимости + контекст КА).
const belowActiveHeight =
visibilityTracks.length * VISIBILITY_HEIGHT +
(contextLanes.length > 0 ? 22 : 0) +
contextLanes.length * OTHER_HEIGHT +
8;
// Активная дорожка резиновая: занимает всё свободное место панели, поэтому при
// подъёме панели вверх ряды съёмки/сброса расширяются, а низ не пустует.
const activeHeight = Math.max(MIN_ACTIVE_HEIGHT, bodyHeight - AXIS_HEIGHT - belowActiveHeight);
const visibilityTop = AXIS_HEIGHT + activeHeight;
const dividerTop = visibilityTop + visibilityTracks.length * VISIBILITY_HEIGHT;
const contextTop = dividerTop + (contextLanes.length > 0 ? 22 : 0);
const contentHeight = AXIS_HEIGHT + activeHeight + belowActiveHeight;
const surfaceHeight = Math.max(bodyHeight, contentHeight);
useEffect(() => {
const element = wrapperRef.current;
if (!element) return;
const measure = () => {
setBodyWidth(Math.max(560, element.clientWidth - LABEL_WIDTH));
setBodyHeight(element.clientHeight);
};
const observer = new ResizeObserver(measure);
observer.observe(element);
measure();
return () => observer.disconnect();
}, []);
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, surfaceHeight) }}>
<div className="tgu-editor-timeline__axis-label" style={{ width: LABEL_WIDTH }}>
Время
</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>
{planStartMs != null && planStartMs >= timelineWindow.fromMs && planStartMs <= timelineWindow.toMs && (
<div className="tgu-editor-timeline__bound tgu-editor-timeline__bound--start" style={{ left: LABEL_WIDTH + toX(planStartMs), top: AXIS_HEIGHT }}>
<span>Начало плана</span>
</div>
)}
{planEndMs != null && planEndMs >= timelineWindow.fromMs && planEndMs <= timelineWindow.toMs && (
<div className="tgu-editor-timeline__bound tgu-editor-timeline__bound--end" style={{ left: LABEL_WIDTH + toX(planEndMs), top: AXIS_HEIGHT }}>
<span>Конец плана</span>
</div>
)}
<div className="tgu-editor-timeline__lane" style={{ top: AXIS_HEIGHT, height: activeHeight }}>
<div className="tgu-editor-timeline__lane-label tgu-editor-timeline__lane-label--split" style={{ width: LABEL_WIDTH }}>
<span>{activeLabel}</span>
<small>редактируется</small>
<em className="tgu-editor-timeline__row-hint tgu-editor-timeline__row-hint--shoot">Съёмка</em>
<em className="tgu-editor-timeline__row-hint tgu-editor-timeline__row-hint--downlink">Сброс</em>
</div>
<div className="tgu-editor-timeline__lane-body" style={{ left: LABEL_WIDTH, width: bodyWidth }}>
{works.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 (
<button
className={`tgu-editor-work tgu-editor-work--${work.kind} ${selected ? "is-selected" : ""} ${hasConflict ? "has-conflict" : ""}`}
type="button"
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)}`}
onClick={(event) => {
event.stopPropagation();
onSelectWork(work.id);
}}
>
<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>}
</button>
);
})}
</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 = new Date(start.getFullYear(), start.getMonth(), start.getDate()).getTime();
while (dayMs <= window.toMs) {
result.push(dayMs);
dayMs += 24 * 60 * 60 * 1000;
}
return result;
}