pcp-tgu-ops-ui: вкладка Редактор — план, включения, нижняя панель
- Слева выводим даты начала/конца плана и корректные счётчики съёмки/сброса (из plan modes, а не из заглушки-черновика). - На таймлайне показываем реальные включения плана (read-only): убран фейковый «сброс на весь интервал»; вместо линии «сейчас» — маркеры начала и конца плана; клик по включению открывает его инфо справа. - Включения съёмки и сброса разнесены на два ряда (верх/низ) и различаются цветом, слева — подписи рядов. - Нижняя панель: активная дорожка резиновая — примыкает к низу и не пустует, при подъёме панели ряды расширяются; высота бара ограничена потолком и центрируется в ряду, чтобы узкие включения не вытягивались. - Время в редакторе парсится/форматируется через utils/utcTime.
This commit is contained in:
@@ -4,128 +4,86 @@ import type {
|
||||
EditorContextLane,
|
||||
EditorTimelineWindow,
|
||||
EditorVisibilityTrack,
|
||||
EditorWork,
|
||||
EditorWorkPatch
|
||||
EditorWork
|
||||
} from "./model/editorTypes";
|
||||
import { VisibilityTracks } from "./VisibilityTracks";
|
||||
|
||||
type EditorTimelineProps = {
|
||||
activeLabel: string;
|
||||
draft: EditorWork[];
|
||||
works: EditorWork[];
|
||||
selectedWorkId?: string;
|
||||
window: EditorTimelineWindow;
|
||||
nowMs: number;
|
||||
planStartMs?: number;
|
||||
planEndMs?: number;
|
||||
conflicts: Record<string, string[]>;
|
||||
contextLanes: EditorContextLane[];
|
||||
visibilityTracks: EditorVisibilityTrack[];
|
||||
onSelectWork: (workId: string) => void;
|
||||
onClearSelection: () => void;
|
||||
onChangeWork: (workId: string, patch: EditorWorkPatch) => void;
|
||||
};
|
||||
|
||||
const LABEL_WIDTH = 168;
|
||||
const AXIS_HEIGHT = 40;
|
||||
const ACTIVE_HEIGHT = 66;
|
||||
/** Минимальная высота активной дорожки (два ряда: съёмка + сброс). */
|
||||
const MIN_ACTIVE_HEIGHT = 84;
|
||||
const VISIBILITY_HEIGHT = 24;
|
||||
const OTHER_HEIGHT = 30;
|
||||
const SNAP_MS = 60 * 1000;
|
||||
|
||||
type DragState = {
|
||||
workId: string;
|
||||
mode: "move" | "left" | "right";
|
||||
startX: number;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
};
|
||||
|
||||
export function EditorTimeline({
|
||||
activeLabel,
|
||||
draft,
|
||||
works,
|
||||
selectedWorkId,
|
||||
window: timelineWindow,
|
||||
nowMs,
|
||||
planStartMs,
|
||||
planEndMs,
|
||||
conflicts,
|
||||
contextLanes,
|
||||
visibilityTracks,
|
||||
onSelectWork,
|
||||
onClearSelection,
|
||||
onChangeWork
|
||||
onClearSelection
|
||||
}: EditorTimelineProps) {
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragRef = useRef<DragState | undefined>(undefined);
|
||||
const [bodyWidth, setBodyWidth] = useState(900);
|
||||
const [bodyHeight, setBodyHeight] = useState(260);
|
||||
const span = Math.max(1, timelineWindow.toMs - timelineWindow.fromMs);
|
||||
const pxPerMs = bodyWidth / span;
|
||||
const toX = (timeMs: number) => (timeMs - timelineWindow.fromMs) * pxPerMs;
|
||||
const ticks = useMemo(() => niceTicks(timelineWindow.fromMs, timelineWindow.toMs, pxPerMs, 72), [pxPerMs, timelineWindow.fromMs, timelineWindow.toMs]);
|
||||
const days = useMemo(() => buildDays(timelineWindow), [timelineWindow]);
|
||||
const visibilityTop = AXIS_HEIGHT + ACTIVE_HEIGHT;
|
||||
const dividerTop = visibilityTop + visibilityTracks.length * VISIBILITY_HEIGHT;
|
||||
const contextTop = dividerTop + (contextLanes.length > 0 ? 22 : 0);
|
||||
const totalHeight =
|
||||
AXIS_HEIGHT +
|
||||
ACTIVE_HEIGHT +
|
||||
// Фиксированная высота частей ниже активной дорожки (треки видимости + контекст КА).
|
||||
const belowActiveHeight =
|
||||
visibilityTracks.length * VISIBILITY_HEIGHT +
|
||||
(contextLanes.length > 0 ? 22 : 0) +
|
||||
contextLanes.length * OTHER_HEIGHT +
|
||||
8;
|
||||
// Активная дорожка резиновая: занимает всё свободное место панели, поэтому при
|
||||
// подъёме панели вверх ряды съёмки/сброса расширяются, а низ не пустует.
|
||||
const activeHeight = Math.max(MIN_ACTIVE_HEIGHT, bodyHeight - AXIS_HEIGHT - belowActiveHeight);
|
||||
const visibilityTop = AXIS_HEIGHT + activeHeight;
|
||||
const dividerTop = visibilityTop + visibilityTracks.length * VISIBILITY_HEIGHT;
|
||||
const contextTop = dividerTop + (contextLanes.length > 0 ? 22 : 0);
|
||||
const contentHeight = AXIS_HEIGHT + activeHeight + belowActiveHeight;
|
||||
const surfaceHeight = Math.max(bodyHeight, contentHeight);
|
||||
|
||||
useEffect(() => {
|
||||
const element = wrapperRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
const measure = () => {
|
||||
setBodyWidth(Math.max(560, element.clientWidth - LABEL_WIDTH));
|
||||
});
|
||||
setBodyHeight(element.clientHeight);
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element);
|
||||
setBodyWidth(Math.max(560, element.clientWidth - LABEL_WIDTH));
|
||||
measure();
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (event: MouseEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
|
||||
const deltaMs = Math.round(((event.clientX - drag.startX) / pxPerMs) / SNAP_MS) * SNAP_MS;
|
||||
if (drag.mode === "move") {
|
||||
onChangeWork(drag.workId, { startMs: drag.startMs + deltaMs, endMs: drag.endMs + deltaMs });
|
||||
} else if (drag.mode === "left") {
|
||||
onChangeWork(drag.workId, { startMs: Math.min(drag.endMs - SNAP_MS, drag.startMs + deltaMs) });
|
||||
} else {
|
||||
onChangeWork(drag.workId, { endMs: Math.max(drag.startMs + SNAP_MS, drag.endMs + deltaMs) });
|
||||
}
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
dragRef.current = undefined;
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
}, [onChangeWork, pxPerMs]);
|
||||
|
||||
const startDrag = (event: React.MouseEvent, work: EditorWork, mode: DragState["mode"]) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dragRef.current = {
|
||||
workId: work.id,
|
||||
mode,
|
||||
startX: event.clientX,
|
||||
startMs: work.startMs,
|
||||
endMs: work.endMs
|
||||
};
|
||||
onSelectWork(work.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tgu-editor-timeline" ref={wrapperRef} onClick={onClearSelection}>
|
||||
<div className="tgu-editor-timeline__surface" style={{ width: LABEL_WIDTH + bodyWidth, height: Math.max(220, totalHeight) }}>
|
||||
<div className="tgu-editor-timeline__surface" style={{ width: LABEL_WIDTH + bodyWidth, height: Math.max(220, surfaceHeight) }}>
|
||||
<div className="tgu-editor-timeline__axis-label" style={{ width: LABEL_WIDTH }}>
|
||||
Время (UTC)
|
||||
</div>
|
||||
@@ -149,19 +107,27 @@ export function EditorTimeline({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{nowMs >= timelineWindow.fromMs && nowMs <= timelineWindow.toMs && (
|
||||
<div className="tgu-editor-timeline__now" style={{ left: LABEL_WIDTH + toX(nowMs), top: AXIS_HEIGHT }}>
|
||||
<span>Сейчас</span>
|
||||
{planStartMs != null && planStartMs >= timelineWindow.fromMs && planStartMs <= timelineWindow.toMs && (
|
||||
<div className="tgu-editor-timeline__bound tgu-editor-timeline__bound--start" style={{ left: LABEL_WIDTH + toX(planStartMs), top: AXIS_HEIGHT }}>
|
||||
<span>Начало плана</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="tgu-editor-timeline__lane" style={{ top: AXIS_HEIGHT, height: ACTIVE_HEIGHT }}>
|
||||
<div className="tgu-editor-timeline__lane-label" style={{ width: LABEL_WIDTH }}>
|
||||
{planEndMs != null && planEndMs >= timelineWindow.fromMs && planEndMs <= timelineWindow.toMs && (
|
||||
<div className="tgu-editor-timeline__bound tgu-editor-timeline__bound--end" style={{ left: LABEL_WIDTH + toX(planEndMs), top: AXIS_HEIGHT }}>
|
||||
<span>Конец плана</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="tgu-editor-timeline__lane" style={{ top: AXIS_HEIGHT, height: activeHeight }}>
|
||||
<div className="tgu-editor-timeline__lane-label tgu-editor-timeline__lane-label--split" style={{ width: LABEL_WIDTH }}>
|
||||
<span>{activeLabel}</span>
|
||||
<small>редактируется</small>
|
||||
<em className="tgu-editor-timeline__row-hint tgu-editor-timeline__row-hint--shoot">Съёмка</em>
|
||||
<em className="tgu-editor-timeline__row-hint tgu-editor-timeline__row-hint--downlink">Сброс</em>
|
||||
</div>
|
||||
<div className="tgu-editor-timeline__lane-body" style={{ left: LABEL_WIDTH, width: bodyWidth }}>
|
||||
{draft.map((work) => {
|
||||
{works.map((work) => {
|
||||
const left = toX(work.startMs);
|
||||
const width = Math.max(6, toX(work.endMs) - toX(work.startMs));
|
||||
const color = kindColor(work.kind);
|
||||
@@ -169,8 +135,9 @@ export function EditorTimeline({
|
||||
const hasConflict = Boolean(conflicts[work.id]?.length);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`tgu-editor-work ${selected ? "is-selected" : ""} ${hasConflict ? "has-conflict" : ""}`}
|
||||
<button
|
||||
className={`tgu-editor-work tgu-editor-work--${work.kind} ${selected ? "is-selected" : ""} ${hasConflict ? "has-conflict" : ""}`}
|
||||
type="button"
|
||||
key={work.id}
|
||||
style={{
|
||||
left,
|
||||
@@ -179,19 +146,16 @@ export function EditorTimeline({
|
||||
background: `color-mix(in srgb, ${color} 22%, var(--bg-1))`
|
||||
}}
|
||||
title={`${kindLabel(work.kind)} · ${formatDateTime(work.startMs)}-${formatTime(work.endMs)}`}
|
||||
onMouseDown={(event) => startDrag(event, work, "move")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelectWork(work.id);
|
||||
}}
|
||||
>
|
||||
<span className="tgu-editor-work__resize tgu-editor-work__resize--left" onMouseDown={(event) => startDrag(event, work, "left")} />
|
||||
<span className="tgu-editor-work__resize tgu-editor-work__resize--right" onMouseDown={(event) => startDrag(event, work, "right")} />
|
||||
<span className="tgu-editor-work__dot" style={{ background: color }} />
|
||||
{width > 54 && <span className="tgu-editor-work__label">{work.label}</span>}
|
||||
{work.isNew && <span className="tgu-editor-work__new">NEW</span>}
|
||||
{width > 82 && <small>{formatTime(work.startMs)}-{formatTime(work.endMs)}</small>}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user