pcp-tgu интерфейс
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes";
|
||||
import {
|
||||
buildSequentialLinks,
|
||||
buildTimeTicks,
|
||||
buildTimelineSegments,
|
||||
TIMELINE_AXIS_HEIGHT,
|
||||
TIMELINE_BOTTOM_PADDING,
|
||||
TIMELINE_LABEL_WIDTH,
|
||||
TIMELINE_ROW_HEIGHT,
|
||||
timeToX,
|
||||
timelineHeight
|
||||
} from "./tguTimelineLayout";
|
||||
import { platformLabel } from "./tguTimelineMapper";
|
||||
import { statusStyle } from "./tguStatus";
|
||||
|
||||
type TguTimelineProps = {
|
||||
rows: TimelineRow[];
|
||||
range: TimelineRange;
|
||||
selectedPlanId?: string;
|
||||
invalidRange?: boolean;
|
||||
onSelectPlan: (planId: string) => void;
|
||||
};
|
||||
|
||||
export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelectPlan }: TguTimelineProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(1200);
|
||||
const timelineWidth = Math.max(720, containerWidth - TIMELINE_LABEL_WIDTH - 28);
|
||||
const fullWidth = TIMELINE_LABEL_WIDTH + timelineWidth;
|
||||
const height = Math.max(260, timelineHeight(rows.length));
|
||||
const nowMs = Date.now();
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
setContainerWidth(Math.floor(entry.contentRect.width));
|
||||
});
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const segments = useMemo(
|
||||
() => buildTimelineSegments(rows, range, timelineWidth),
|
||||
[rows, range, timelineWidth]
|
||||
);
|
||||
const segmentByPlanId = useMemo(
|
||||
() => new Map(segments.map((segment) => [segment.plan.planId, segment])),
|
||||
[segments]
|
||||
);
|
||||
const links = useMemo(() => buildSequentialLinks(rows), [rows]);
|
||||
const ticks = useMemo(() => buildTimeTicks(range), [range]);
|
||||
const nowX =
|
||||
nowMs >= range.fromMs && nowMs <= range.toMs
|
||||
? TIMELINE_LABEL_WIDTH + timeToX(nowMs, range, timelineWidth)
|
||||
: undefined;
|
||||
|
||||
if (invalidRange) {
|
||||
return <div className="tgu-empty">Некорректный интервал: from должен быть меньше to.</div>;
|
||||
}
|
||||
|
||||
if (rows.length === 0 || segments.length === 0) {
|
||||
return <div className="tgu-empty">Нет планов в выбранном интервале.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tgu-timeline" ref={containerRef}>
|
||||
<svg className="tgu-timeline__svg" viewBox={`0 0 ${fullWidth} ${height}`} role="img" aria-label="Timeline планов ТГУ">
|
||||
<defs>
|
||||
<marker id="tgu-arrow-head" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
||||
<path d="M0 0 L8 4 L0 8 z" fill="#8aa2a6" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<rect x="0" y="0" width={fullWidth} height={height} className="tgu-timeline__background" />
|
||||
<rect x="0" y="0" width={TIMELINE_LABEL_WIDTH} height={height} className="tgu-timeline__labels-background" />
|
||||
|
||||
<line
|
||||
x1={TIMELINE_LABEL_WIDTH}
|
||||
x2={fullWidth}
|
||||
y1={TIMELINE_AXIS_HEIGHT}
|
||||
y2={TIMELINE_AXIS_HEIGHT}
|
||||
className="tgu-timeline__axis"
|
||||
/>
|
||||
|
||||
{ticks.map((tick) => {
|
||||
const x = TIMELINE_LABEL_WIDTH + timeToX(tick, range, timelineWidth);
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line x1={x} x2={x} y1="0" y2={height - TIMELINE_BOTTOM_PADDING} className="tgu-timeline__grid" />
|
||||
<text x={x + 6} y="18" className="tgu-timeline__tick-date">
|
||||
{formatTickDate(tick)}
|
||||
</text>
|
||||
<text x={x + 6} y="34" className="tgu-timeline__tick-time">
|
||||
{formatTickTime(tick)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{nowX !== undefined && (
|
||||
<g>
|
||||
<line x1={nowX} x2={nowX} y1="0" y2={height - TIMELINE_BOTTOM_PADDING} className="tgu-timeline__now" />
|
||||
<text x={nowX + 7} y="16" className="tgu-timeline__now-label">
|
||||
сейчас
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{rows.map((row, rowIndex) => {
|
||||
const y = TIMELINE_AXIS_HEIGHT + rowIndex * TIMELINE_ROW_HEIGHT;
|
||||
return (
|
||||
<g key={row.spacecraftId}>
|
||||
<rect
|
||||
x="0"
|
||||
y={y}
|
||||
width={fullWidth}
|
||||
height={TIMELINE_ROW_HEIGHT}
|
||||
className={rowIndex % 2 === 0 ? "tgu-timeline__row" : "tgu-timeline__row tgu-timeline__row--alt"}
|
||||
/>
|
||||
<text x="16" y={y + 24} className="tgu-timeline__row-title">
|
||||
{platformLabel(row.platform, row.spacecraftId)}
|
||||
</text>
|
||||
<text x="16" y={y + 42} className="tgu-timeline__row-meta">
|
||||
NORAD {row.platform?.noradId || row.spacecraftId}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{links.map((link) => {
|
||||
const from = segmentByPlanId.get(link.fromPlanId);
|
||||
const to = segmentByPlanId.get(link.toPlanId);
|
||||
if (!from || !to) return null;
|
||||
return <TimelineArrow key={`${link.fromPlanId}-${link.toPlanId}`} from={from} to={to} />;
|
||||
})}
|
||||
|
||||
{segments.map((segment) => {
|
||||
const y = TIMELINE_AXIS_HEIGHT + segment.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2;
|
||||
const x1 = TIMELINE_LABEL_WIDTH + segment.x;
|
||||
const x2 = TIMELINE_LABEL_WIDTH + segment.x + segment.width;
|
||||
const style = statusStyle(segment.plan.status);
|
||||
const selected = selectedPlanId === segment.plan.planId;
|
||||
const labelVisible = segment.width > 92;
|
||||
|
||||
return (
|
||||
<g
|
||||
key={segment.plan.planId}
|
||||
className={`tgu-timeline-plan ${selected ? "is-selected" : ""}`}
|
||||
onClick={() => onSelectPlan(segment.plan.planId)}
|
||||
>
|
||||
<title>
|
||||
{segment.plan.planId} · {segment.plan.status} · {formatDateTime(segment.plan.startTime)} -{" "}
|
||||
{formatDateTime(segment.plan.endTime)}
|
||||
</title>
|
||||
{selected && <line x1={x1} x2={x2} y1={y} y2={y} className="tgu-timeline-plan__selection" />}
|
||||
<line
|
||||
x1={x1}
|
||||
x2={x2}
|
||||
y1={y}
|
||||
y2={y}
|
||||
className={`tgu-timeline-plan__bar ${style.className}`}
|
||||
stroke={style.color}
|
||||
/>
|
||||
{labelVisible && (
|
||||
<text x={x1 + 10} y={y + 4} className="tgu-timeline-plan__label">
|
||||
{segment.plan.kppId} · {segment.plan.status}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineArrow({ from, to }: { from: TimelinePlanSegment; to: TimelinePlanSegment }) {
|
||||
const y1 = TIMELINE_AXIS_HEIGHT + from.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2;
|
||||
const y2 = TIMELINE_AXIS_HEIGHT + to.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2;
|
||||
const x1 = TIMELINE_LABEL_WIDTH + from.x + from.width + 4;
|
||||
const x2 = TIMELINE_LABEL_WIDTH + to.x - 6;
|
||||
const mid = x1 + Math.max(18, (x2 - x1) / 2);
|
||||
const path = x2 > x1
|
||||
? `M ${x1} ${y1} C ${mid} ${y1}, ${mid} ${y2}, ${x2} ${y2}`
|
||||
: `M ${x1} ${y1} C ${x1 + 20} ${y1 - 18}, ${x2 - 20} ${y2 - 18}, ${x2} ${y2}`;
|
||||
|
||||
return <path d={path} className="tgu-timeline__arrow" markerEnd="url(#tgu-arrow-head)" />;
|
||||
}
|
||||
|
||||
function formatTickDate(ms: number): string {
|
||||
return new Date(ms).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
|
||||
}
|
||||
|
||||
function formatTickTime(ms: number): string {
|
||||
return new Date(ms).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return new Date(value).toLocaleString("ru-RU");
|
||||
}
|
||||
Reference in New Issue
Block a user