pcp-tgu интерфейс
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { TguPlanningPage } from "./features/tgu-planning/TguPlanningPage";
|
||||
|
||||
export default function App() {
|
||||
return <TguPlanningPage />;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
TguApiError,
|
||||
type TguPlan,
|
||||
type TguPlanDecision,
|
||||
type TguPlanDecisionMessage,
|
||||
type TguPlatform
|
||||
} from "../model/tguTypes";
|
||||
|
||||
export async function fetchPlatforms(): Promise<TguPlatform[]> {
|
||||
return fetchJson<TguPlatform[]>("/api/tgu-planning/platforms");
|
||||
}
|
||||
|
||||
export async function fetchPlans(historyDays: number, spacecraftId?: string): Promise<TguPlan[]> {
|
||||
const query = new URLSearchParams({ historyDays: String(historyDays) });
|
||||
const path = spacecraftId
|
||||
? `/api/tgu-planning/spacecraft/${encodeURIComponent(spacecraftId)}/plans?${query}`
|
||||
: `/api/tgu-planning/plans?${query}`;
|
||||
|
||||
return fetchJson<TguPlan[]>(path);
|
||||
}
|
||||
|
||||
export async function sendPlanDecision(
|
||||
planId: string,
|
||||
decision: TguPlanDecision,
|
||||
reason?: string
|
||||
): Promise<TguPlanDecisionMessage> {
|
||||
const query = new URLSearchParams({ decision });
|
||||
if (reason?.trim()) {
|
||||
query.set("reason", reason.trim());
|
||||
}
|
||||
|
||||
return fetchJson<TguPlanDecisionMessage>(
|
||||
`/api/tgu-planning/plans/${encodeURIComponent(planId)}/decision?${query}`,
|
||||
{ method: "POST" },
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, init?: RequestInit, decisionRequest = false): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
...init
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TguApiError("Не удалось подключиться к backend API.", "network");
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new TguApiError(errorMessage(response.status, text, decisionRequest), "http", response.status);
|
||||
}
|
||||
|
||||
if (!text.trim()) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
function errorMessage(status: number, body: string, decisionRequest: boolean): string {
|
||||
if (decisionRequest && status === 404) {
|
||||
return "План не найден.";
|
||||
}
|
||||
if (decisionRequest && status === 409) {
|
||||
return "План не является активным планом, ожидающим решения, или для него нет active attempt.";
|
||||
}
|
||||
if (decisionRequest && status >= 500) {
|
||||
return "Ошибка сервиса при отправке решения.";
|
||||
}
|
||||
|
||||
return extractErrorText(body) || "Ошибка backend API.";
|
||||
}
|
||||
|
||||
function extractErrorText(body: string): string {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return "";
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
for (const field of ["reason", "message", "error", "detail"]) {
|
||||
const value = parsed[field];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { STATUS_STYLES } from "./tguStatus";
|
||||
|
||||
export function TguLegend() {
|
||||
return (
|
||||
<div className="tgu-legend" aria-label="Легенда статусов">
|
||||
{STATUS_STYLES.map((item) => (
|
||||
<span className="tgu-legend__item" key={item.label}>
|
||||
<span className={`tgu-legend__swatch ${item.className}`} />
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
<span className="tgu-legend__note">Стрелки показывают следующий план того же КА по времени.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { TguPlanDecision } from "../../model/tguTypes";
|
||||
import type { TguPlanUi } from "../../model/timelineTypes";
|
||||
import { platformLabel } from "./tguTimelineMapper";
|
||||
import { statusStyle } from "./tguStatus";
|
||||
|
||||
type TguPlanDetailsProps = {
|
||||
plan?: TguPlanUi;
|
||||
decisionInFlight: boolean;
|
||||
notice?: string;
|
||||
error?: string;
|
||||
onDecision: (planId: string, decision: TguPlanDecision, reason?: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function TguPlanDetails({ plan, decisionInFlight, notice, error, onDecision, onClose }: TguPlanDetailsProps) {
|
||||
if (!plan) {
|
||||
return (
|
||||
<aside className="tgu-details tgu-details--empty">
|
||||
<div className="tgu-details__placeholder">
|
||||
<span className="tgu-details__placeholder-icon" aria-hidden="true" />
|
||||
<span>Выберите plan bar на timeline.</span>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const style = statusStyle(plan.status);
|
||||
const duration = formatDuration(plan.endMs - plan.startMs);
|
||||
|
||||
const reject = () => {
|
||||
const reason = window.prompt("Reason для REJECTED", "UI_TEST_REJECTED");
|
||||
if (reason === null) return;
|
||||
onDecision(plan.planId, "REJECTED", reason);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="tgu-details">
|
||||
<div className="tgu-details__header">
|
||||
<div>
|
||||
<div className="tgu-details__eyebrow">Выбранный план</div>
|
||||
<h2>{plan.planId}</h2>
|
||||
</div>
|
||||
<button className="tgu-icon-button" type="button" onClick={onClose} aria-label="Закрыть детали">
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`tgu-status-pill ${style.className}`}>{style.label}</div>
|
||||
|
||||
{notice && <div className="tgu-alert tgu-alert--success">{notice}</div>}
|
||||
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
|
||||
|
||||
<dl className="tgu-details-grid">
|
||||
<Detail label="planId" value={plan.planId} mono />
|
||||
<Detail label="spacecraftId" value={plan.spacecraftId} mono />
|
||||
<Detail label="КА" value={platformLabel(plan.platform, plan.spacecraftId)} />
|
||||
<Detail label="startTime" value={formatDateTime(plan.startTime)} mono />
|
||||
<Detail label="endTime" value={formatDateTime(plan.endTime)} mono />
|
||||
<Detail label="duration" value={duration} mono />
|
||||
<Detail label="kppId" value={plan.kppId} mono />
|
||||
<Detail label="status" value={plan.status} mono />
|
||||
</dl>
|
||||
|
||||
{plan.status === "WAITING_DECISION" && (
|
||||
<div className="tgu-details__actions">
|
||||
<button
|
||||
className="tgu-button tgu-button--accept"
|
||||
type="button"
|
||||
disabled={decisionInFlight}
|
||||
onClick={() => onDecision(plan.planId, "ACCEPTED", "UI_TEST_ACCEPTED")}
|
||||
>
|
||||
Принять
|
||||
</button>
|
||||
<button className="tgu-button tgu-button--reject" type="button" disabled={decisionInFlight} onClick={reject}>
|
||||
Отклонить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ label, value, mono }: { label: string; value?: string; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<dt>{label}</dt>
|
||||
<dd className={mono ? "mono" : undefined}>{value || "-"}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return new Date(value).toLocaleString("ru-RU");
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const minutes = Math.max(0, Math.round(ms / 60_000));
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const restMinutes = minutes % 60;
|
||||
if (hours === 0) return `${restMinutes} мин`;
|
||||
return `${hours} ч ${restMinutes} мин`;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type TguPlanningLayoutProps = {
|
||||
toolbar: ReactNode;
|
||||
sidebar: ReactNode;
|
||||
timeline: ReactNode;
|
||||
details: ReactNode;
|
||||
};
|
||||
|
||||
export function TguPlanningLayout({ toolbar, sidebar, timeline, details }: TguPlanningLayoutProps) {
|
||||
return (
|
||||
<div className="tgu-app-shell">
|
||||
{toolbar}
|
||||
<main className="tgu-workspace">
|
||||
{sidebar}
|
||||
<section className="tgu-center">{timeline}</section>
|
||||
{details}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
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 { TguPlanDetails } from "./TguPlanDetails";
|
||||
import { TguPlanningLayout } from "./TguPlanningLayout";
|
||||
import { TguSidebar } from "./TguSidebar";
|
||||
import { TguTimeline } from "./TguTimeline";
|
||||
import { TguToolbar } from "./TguToolbar";
|
||||
import { buildPlatformsForInterval, buildTimelineRows, mapPlans } from "./tguTimelineMapper";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function TguPlanningPage() {
|
||||
const initialRange = useMemo(defaultRange, []);
|
||||
const [fromValue, setFromValue] = useState(formatDateTimeLocal(initialRange.fromMs));
|
||||
const [toValue, setToValue] = useState(formatDateTimeLocal(initialRange.toMs));
|
||||
const [appliedRange, setAppliedRange] = useState<TimelineRange>(initialRange);
|
||||
const [rawPlatforms, setRawPlatforms] = useState<TguPlatform[]>([]);
|
||||
const [plans, setPlans] = useState<TguPlanUi[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pageError, setPageError] = useState<string>();
|
||||
const [platformLoadFailed, setPlatformLoadFailed] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedSpacecraftId, setSelectedSpacecraftId] = useState<string>();
|
||||
const [selectedPlanId, setSelectedPlanId] = useState<string>();
|
||||
const [decisionInFlight, setDecisionInFlight] = useState(false);
|
||||
const [decisionNotice, setDecisionNotice] = useState<string>();
|
||||
const [decisionError, setDecisionError] = useState<string>();
|
||||
|
||||
const parsedInputRange = useMemo(
|
||||
() => ({
|
||||
fromMs: new Date(fromValue).getTime(),
|
||||
toMs: new Date(toValue).getTime()
|
||||
}),
|
||||
[fromValue, toValue]
|
||||
);
|
||||
const invalidRange =
|
||||
!Number.isFinite(parsedInputRange.fromMs) ||
|
||||
!Number.isFinite(parsedInputRange.toMs) ||
|
||||
parsedInputRange.fromMs >= parsedInputRange.toMs;
|
||||
|
||||
const loadData = useCallback(
|
||||
async (range: TimelineRange) => {
|
||||
setLoading(true);
|
||||
setPageError(undefined);
|
||||
setDecisionError(undefined);
|
||||
|
||||
const historyDays = historyDaysForRange(range);
|
||||
const [platformResult, planResult] = await Promise.allSettled([
|
||||
fetchPlatforms(),
|
||||
fetchPlans(historyDays)
|
||||
]);
|
||||
|
||||
if (platformResult.status === "rejected") {
|
||||
setPlatformLoadFailed(true);
|
||||
} else {
|
||||
setPlatformLoadFailed(false);
|
||||
setRawPlatforms(platformResult.value);
|
||||
}
|
||||
|
||||
if (planResult.status === "rejected") {
|
||||
setPlans([]);
|
||||
setPageError(planResult.reason instanceof Error ? planResult.reason.message : "Не удалось загрузить планы.");
|
||||
} else {
|
||||
const platforms = platformResult.status === "fulfilled" ? platformResult.value : [];
|
||||
setPlans(mapPlans(planResult.value, platforms));
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData(appliedRange);
|
||||
}, [appliedRange, loadData]);
|
||||
|
||||
const platforms = useMemo(
|
||||
() => buildPlatformsForInterval(rawPlatforms, plans, appliedRange),
|
||||
[rawPlatforms, plans, appliedRange]
|
||||
);
|
||||
const rows = useMemo(
|
||||
() => buildTimelineRows(plans, platforms, appliedRange, selectedSpacecraftId),
|
||||
[plans, platforms, appliedRange, selectedSpacecraftId]
|
||||
);
|
||||
const selectedPlan = useMemo(() => {
|
||||
const plan = plans.find((item) => item.planId === selectedPlanId);
|
||||
if (!plan) return undefined;
|
||||
const platform = platforms.find((item) => item.spacecraftId === plan.spacecraftId);
|
||||
return { ...plan, platform };
|
||||
}, [plans, platforms, selectedPlanId]);
|
||||
|
||||
const applyRange = () => {
|
||||
setDecisionNotice(undefined);
|
||||
if (invalidRange) {
|
||||
setPageError("Некорректный интервал: from должен быть меньше to.");
|
||||
return;
|
||||
}
|
||||
setPageError(undefined);
|
||||
setAppliedRange(parsedInputRange);
|
||||
};
|
||||
|
||||
const quickRange = (hours: number) => {
|
||||
const fromMs = startOfCurrentDayMs();
|
||||
const toMs = fromMs + hours * 60 * 60 * 1000;
|
||||
setFromValue(formatDateTimeLocal(fromMs));
|
||||
setToValue(formatDateTimeLocal(toMs));
|
||||
setAppliedRange({ fromMs, toMs });
|
||||
setPageError(undefined);
|
||||
setDecisionNotice(undefined);
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
if (invalidRange) {
|
||||
setPageError("Некорректный интервал: from должен быть меньше to.");
|
||||
return;
|
||||
}
|
||||
void loadData(appliedRange);
|
||||
};
|
||||
|
||||
const handleDecision = async (planId: string, decision: TguPlanDecision, reason?: string) => {
|
||||
setDecisionInFlight(true);
|
||||
setDecisionError(undefined);
|
||||
setDecisionNotice(undefined);
|
||||
|
||||
try {
|
||||
await sendPlanDecision(planId, decision, reason);
|
||||
setDecisionNotice(
|
||||
decision === "REJECTED"
|
||||
? "Решение REJECTED отправлено. План может вернуться в PLANNED для повторной выдачи."
|
||||
: "Решение ACCEPTED отправлено."
|
||||
);
|
||||
await loadData(appliedRange);
|
||||
} catch (error) {
|
||||
setDecisionError(error instanceof Error ? error.message : "Не удалось отправить решение.");
|
||||
} finally {
|
||||
setDecisionInFlight(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TguPlanningLayout
|
||||
toolbar={
|
||||
<TguToolbar
|
||||
fromValue={fromValue}
|
||||
toValue={toValue}
|
||||
loading={loading}
|
||||
error={pageError}
|
||||
onFromChange={setFromValue}
|
||||
onToChange={setToValue}
|
||||
onApply={applyRange}
|
||||
onQuickRange={quickRange}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
}
|
||||
sidebar={
|
||||
<TguSidebar
|
||||
platforms={platforms}
|
||||
selectedSpacecraftId={selectedSpacecraftId}
|
||||
search={search}
|
||||
platformLoadFailed={platformLoadFailed}
|
||||
onSearchChange={setSearch}
|
||||
onSelectSpacecraft={(spacecraftId) => {
|
||||
setSelectedSpacecraftId(spacecraftId);
|
||||
setSelectedPlanId(undefined);
|
||||
setDecisionNotice(undefined);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
timeline={
|
||||
<TguTimeline
|
||||
rows={rows}
|
||||
range={appliedRange}
|
||||
invalidRange={invalidRange}
|
||||
selectedPlanId={selectedPlanId}
|
||||
onSelectPlan={setSelectedPlanId}
|
||||
/>
|
||||
}
|
||||
details={
|
||||
<TguPlanDetails
|
||||
plan={selectedPlan}
|
||||
decisionInFlight={decisionInFlight}
|
||||
notice={decisionNotice}
|
||||
error={decisionError}
|
||||
onDecision={handleDecision}
|
||||
onClose={() => setSelectedPlanId(undefined)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultRange(): TimelineRange {
|
||||
const fromMs = startOfCurrentDayMs();
|
||||
return { fromMs, toMs: fromMs + DAY_MS };
|
||||
}
|
||||
|
||||
function startOfCurrentDayMs(): number {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
}
|
||||
|
||||
function formatDateTimeLocal(ms: number): string {
|
||||
const date = new Date(ms);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function historyDaysForRange(range: TimelineRange): number {
|
||||
const daysBack = Math.ceil(Math.max(0, Date.now() - range.fromMs) / DAY_MS);
|
||||
return Math.max(1, daysBack + 1);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { TguPlatformUi } from "../../model/timelineTypes";
|
||||
import { filterPlatforms, platformLabel } from "./tguTimelineMapper";
|
||||
|
||||
type TguSidebarProps = {
|
||||
platforms: TguPlatformUi[];
|
||||
selectedSpacecraftId?: string;
|
||||
search: string;
|
||||
platformLoadFailed: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSelectSpacecraft: (spacecraftId?: string) => void;
|
||||
};
|
||||
|
||||
export function TguSidebar({
|
||||
platforms,
|
||||
selectedSpacecraftId,
|
||||
search,
|
||||
platformLoadFailed,
|
||||
onSearchChange,
|
||||
onSelectSpacecraft
|
||||
}: TguSidebarProps) {
|
||||
const filtered = filterPlatforms(platforms, search);
|
||||
|
||||
return (
|
||||
<aside className="tgu-sidebar">
|
||||
<div className="tgu-panel-heading">
|
||||
<span>Космические аппараты</span>
|
||||
<button className="tgu-link-button" type="button" onClick={() => onSelectSpacecraft(undefined)}>
|
||||
Все
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tgu-search">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Поиск name / NORAD / mission"
|
||||
value={search}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{platformLoadFailed && (
|
||||
<div className="tgu-alert tgu-alert--warning">
|
||||
Список платформ не загрузился. Timeline построен по spacecraftId из планов.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="tgu-spacecraft-list">
|
||||
{filtered.map((platform) => {
|
||||
const selected = selectedSpacecraftId === platform.spacecraftId;
|
||||
return (
|
||||
<button
|
||||
className={`tgu-spacecraft ${selected ? "is-selected" : ""}`}
|
||||
type="button"
|
||||
key={platform.spacecraftId}
|
||||
onClick={() => onSelectSpacecraft(platform.spacecraftId)}
|
||||
>
|
||||
<span className={`tgu-spacecraft__problem ${platform.hasProblemStatus ? "is-problem" : ""}`} />
|
||||
<span className="tgu-spacecraft__body">
|
||||
<span className="tgu-spacecraft__name">{platformLabel(platform, platform.spacecraftId)}</span>
|
||||
<span className="tgu-spacecraft__meta">
|
||||
NORAD {platform.noradId || platform.spacecraftId}
|
||||
{platform.mission ? ` · ${platform.mission}` : ""}
|
||||
</span>
|
||||
{platform.status && <span className="tgu-spacecraft__status">{platform.status}</span>}
|
||||
</span>
|
||||
<span className="tgu-spacecraft__count">{platform.planCount}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && <div className="tgu-empty tgu-empty--compact">Нет КА по текущему поиску.</div>}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { TguLegend } from "./TguLegend";
|
||||
|
||||
type TguToolbarProps = {
|
||||
fromValue: string;
|
||||
toValue: string;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
onFromChange: (value: string) => void;
|
||||
onToChange: (value: string) => void;
|
||||
onApply: () => void;
|
||||
onQuickRange: (hours: number) => void;
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
export function TguToolbar({
|
||||
fromValue,
|
||||
toValue,
|
||||
loading,
|
||||
error,
|
||||
onFromChange,
|
||||
onToChange,
|
||||
onApply,
|
||||
onQuickRange,
|
||||
onRefresh
|
||||
}: TguToolbarProps) {
|
||||
return (
|
||||
<header className="tgu-toolbar">
|
||||
<div className="tgu-toolbar__row">
|
||||
<div className="tgu-toolbar__brand">
|
||||
<span className="tgu-toolbar__mark" aria-hidden="true" />
|
||||
<div>
|
||||
<div className="tgu-toolbar__title">Планирование ТГУ</div>
|
||||
<div className="tgu-toolbar__subtitle">Mission Ops</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="tgu-field">
|
||||
<span>С</span>
|
||||
<input type="datetime-local" value={fromValue} onChange={(event) => onFromChange(event.target.value)} />
|
||||
</label>
|
||||
<label className="tgu-field">
|
||||
<span>По</span>
|
||||
<input type="datetime-local" value={toValue} onChange={(event) => onToChange(event.target.value)} />
|
||||
</label>
|
||||
|
||||
<button className="tgu-button tgu-button--primary" type="button" onClick={onApply} disabled={loading}>
|
||||
Показать
|
||||
</button>
|
||||
<button className="tgu-button" type="button" onClick={() => onQuickRange(24)} disabled={loading}>
|
||||
24ч
|
||||
</button>
|
||||
<button className="tgu-button" type="button" onClick={() => onQuickRange(72)} disabled={loading}>
|
||||
3 суток
|
||||
</button>
|
||||
<button className="tgu-button" type="button" onClick={() => onQuickRange(168)} disabled={loading}>
|
||||
7 суток
|
||||
</button>
|
||||
<button className="tgu-button" type="button" onClick={onRefresh} disabled={loading}>
|
||||
{loading ? "Обновление..." : "Обновить"}
|
||||
</button>
|
||||
</div>
|
||||
<TguLegend />
|
||||
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { TguPlanStatus } from "../../model/tguTypes";
|
||||
import type { TimelineStatusStyle } from "../../model/timelineTypes";
|
||||
|
||||
export const STATUS_STYLES: TimelineStatusStyle[] = [
|
||||
{ status: "ACCEPTED", label: "ACCEPTED", color: "#2f9e44", className: "status-accepted" },
|
||||
{ status: "REJECTED", label: "REJECTED", color: "#d64545", className: "status-rejected" },
|
||||
{ status: "WAITING_DECISION", label: "WAITING_DECISION", color: "#f0ad2e", className: "status-waiting" },
|
||||
{ status: "PLANNED", label: "PLANNED", color: "#8d99a6", className: "status-planned" },
|
||||
{ status: "ISSUING", label: "ISSUING", color: "#2f80ed", className: "status-issuing" },
|
||||
{ status: "EXPIRED", label: "EXPIRED", color: "#495057", className: "status-expired" },
|
||||
{ status: "SUPERSEDED", label: "SUPERSEDED", color: "#c7ced6", className: "status-superseded" },
|
||||
{ status: "START_AMBIGUOUS", label: "START_AMBIGUOUS", color: "#b42318", className: "status-ambiguous" }
|
||||
];
|
||||
|
||||
const STATUS_BY_NAME = new Map(STATUS_STYLES.map((style) => [style.status, style]));
|
||||
|
||||
export function statusStyle(status: TguPlanStatus): TimelineStatusStyle {
|
||||
return (
|
||||
STATUS_BY_NAME.get(status) ?? {
|
||||
status,
|
||||
label: status || "UNKNOWN",
|
||||
color: "#79828d",
|
||||
className: "status-unknown"
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TguPlanUi, TimelineRow } from "../../model/timelineTypes";
|
||||
import { buildSequentialLinks, clipPlanToRange, timeToX } from "./tguTimelineLayout";
|
||||
|
||||
const range = {
|
||||
fromMs: Date.parse("2026-05-29T00:00:00"),
|
||||
toMs: Date.parse("2026-05-30T00:00:00")
|
||||
};
|
||||
|
||||
function plan(id: string, startTime: string, endTime: string, spacecraftId = "56756"): TguPlanUi {
|
||||
return {
|
||||
planId: id,
|
||||
spacecraftId,
|
||||
startTime,
|
||||
endTime,
|
||||
kppId: "KPP-1",
|
||||
status: "PLANNED",
|
||||
startMs: Date.parse(startTime),
|
||||
endMs: Date.parse(endTime)
|
||||
};
|
||||
}
|
||||
|
||||
describe("tguTimelineLayout", () => {
|
||||
it("maps time to x inside the selected interval", () => {
|
||||
expect(timeToX(Date.parse("2026-05-29T12:00:00"), range, 1200)).toBe(600);
|
||||
});
|
||||
|
||||
it("clips partially visible plans to the selected interval", () => {
|
||||
const clipped = clipPlanToRange(plan("p1", "2026-05-28T23:00:00", "2026-05-29T02:00:00"), range);
|
||||
|
||||
expect(clipped).toEqual({
|
||||
startMs: range.fromMs,
|
||||
endMs: Date.parse("2026-05-29T02:00:00")
|
||||
});
|
||||
});
|
||||
|
||||
it("builds links only between sequential plans of the same spacecraft", () => {
|
||||
const rows: TimelineRow[] = [
|
||||
{
|
||||
spacecraftId: "56756",
|
||||
plans: [
|
||||
plan("p2", "2026-05-29T04:00:00", "2026-05-29T05:00:00"),
|
||||
plan("p1", "2026-05-29T01:00:00", "2026-05-29T02:00:00")
|
||||
]
|
||||
},
|
||||
{
|
||||
spacecraftId: "777",
|
||||
plans: [
|
||||
plan("p3", "2026-05-29T01:00:00", "2026-05-29T02:00:00", "777")
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
expect(buildSequentialLinks(rows)).toEqual([
|
||||
{ spacecraftId: "56756", fromPlanId: "p1", toPlanId: "p2" }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { TguPlanUi, TimelineLink, TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes";
|
||||
|
||||
export const TIMELINE_LABEL_WIDTH = 220;
|
||||
export const TIMELINE_ROW_HEIGHT = 58;
|
||||
export const TIMELINE_AXIS_HEIGHT = 46;
|
||||
export const TIMELINE_BOTTOM_PADDING = 18;
|
||||
|
||||
export function timeToX(timeMs: number, range: TimelineRange, timelineWidth: number): number {
|
||||
return ((timeMs - range.fromMs) / (range.toMs - range.fromMs)) * timelineWidth;
|
||||
}
|
||||
|
||||
export function clipPlanToRange(plan: TguPlanUi, range: TimelineRange): { startMs: number; endMs: number } | null {
|
||||
if (plan.endMs < range.fromMs || plan.startMs > range.toMs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
startMs: Math.max(plan.startMs, range.fromMs),
|
||||
endMs: Math.min(plan.endMs, range.toMs)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTimelineSegments(
|
||||
rows: TimelineRow[],
|
||||
range: TimelineRange,
|
||||
timelineWidth: number
|
||||
): TimelinePlanSegment[] {
|
||||
const segments: TimelinePlanSegment[] = [];
|
||||
|
||||
rows.forEach((row, rowIndex) => {
|
||||
for (const plan of row.plans) {
|
||||
const clipped = clipPlanToRange(plan, range);
|
||||
if (!clipped) continue;
|
||||
|
||||
const x = timeToX(clipped.startMs, range, timelineWidth);
|
||||
const x2 = timeToX(clipped.endMs, range, timelineWidth);
|
||||
segments.push({
|
||||
plan,
|
||||
rowIndex,
|
||||
x,
|
||||
x2,
|
||||
width: Math.max(3, x2 - x)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function buildSequentialLinks(rows: TimelineRow[]): TimelineLink[] {
|
||||
const links: TimelineLink[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const sorted = [...row.plans].sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
|
||||
for (let index = 0; index < sorted.length - 1; index += 1) {
|
||||
links.push({
|
||||
spacecraftId: row.spacecraftId,
|
||||
fromPlanId: sorted[index].planId,
|
||||
toPlanId: sorted[index + 1].planId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
export function buildTimeTicks(range: TimelineRange, targetCount = 8): number[] {
|
||||
const span = range.toMs - range.fromMs;
|
||||
const roughStep = span / targetCount;
|
||||
const candidates = [
|
||||
15 * 60_000,
|
||||
30 * 60_000,
|
||||
60 * 60_000,
|
||||
2 * 60 * 60_000,
|
||||
3 * 60 * 60_000,
|
||||
6 * 60 * 60_000,
|
||||
12 * 60 * 60_000,
|
||||
24 * 60 * 60_000
|
||||
];
|
||||
const step = candidates.find((candidate) => candidate >= roughStep) ?? 24 * 60 * 60_000;
|
||||
const first = Math.ceil(range.fromMs / step) * step;
|
||||
const ticks: number[] = [];
|
||||
|
||||
for (let tick = first; tick <= range.toMs; tick += step) {
|
||||
ticks.push(tick);
|
||||
}
|
||||
|
||||
return ticks;
|
||||
}
|
||||
|
||||
export function timelineHeight(rowCount: number): number {
|
||||
return TIMELINE_AXIS_HEIGHT + rowCount * TIMELINE_ROW_HEIGHT + TIMELINE_BOTTOM_PADDING;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { TguPlan, TguPlatform } from "../../model/tguTypes";
|
||||
import type { TguPlanUi, TguPlatformUi, TimelineRange, TimelineRow } from "../../model/timelineTypes";
|
||||
|
||||
const PROBLEM_STATUSES = new Set(["REJECTED", "EXPIRED", "START_AMBIGUOUS"]);
|
||||
|
||||
export function mapPlans(plans: TguPlan[], platforms: TguPlatform[]): TguPlanUi[] {
|
||||
const platformByNorad = buildPlatformByNorad(platforms);
|
||||
|
||||
return plans
|
||||
.map((plan) => {
|
||||
const startMs = new Date(plan.startTime).getTime();
|
||||
const endMs = new Date(plan.endTime).getTime();
|
||||
const platform = platformByNorad.get(plan.spacecraftId);
|
||||
return {
|
||||
...plan,
|
||||
startMs,
|
||||
endMs,
|
||||
platform: platform ? toPlatformUi(plan.spacecraftId, platform, 0, false) : undefined
|
||||
};
|
||||
})
|
||||
.filter((plan) => Number.isFinite(plan.startMs) && Number.isFinite(plan.endMs));
|
||||
}
|
||||
|
||||
export function buildPlatformsForInterval(
|
||||
platforms: TguPlatform[],
|
||||
plans: TguPlanUi[],
|
||||
range: TimelineRange
|
||||
): TguPlatformUi[] {
|
||||
const intervalPlans = filterPlansByRange(plans, range);
|
||||
const platformByNorad = buildPlatformByNorad(platforms);
|
||||
const planStats = new Map<string, { count: number; hasProblemStatus: boolean }>();
|
||||
|
||||
for (const plan of intervalPlans) {
|
||||
const stats = planStats.get(plan.spacecraftId) ?? { count: 0, hasProblemStatus: false };
|
||||
stats.count += 1;
|
||||
stats.hasProblemStatus = stats.hasProblemStatus || PROBLEM_STATUSES.has(plan.status);
|
||||
planStats.set(plan.spacecraftId, stats);
|
||||
}
|
||||
|
||||
const platformItems = Array.from(platformByNorad.entries()).map(([spacecraftId, platform]) => {
|
||||
const stats = planStats.get(spacecraftId) ?? { count: 0, hasProblemStatus: false };
|
||||
return toPlatformUi(spacecraftId, platform, stats.count, stats.hasProblemStatus);
|
||||
});
|
||||
|
||||
const knownSpacecraft = new Set(platformItems.map((platform) => platform.spacecraftId));
|
||||
for (const [spacecraftId, stats] of planStats.entries()) {
|
||||
if (!knownSpacecraft.has(spacecraftId)) {
|
||||
platformItems.push({
|
||||
spacecraftId,
|
||||
noradId: spacecraftId,
|
||||
planCount: stats.count,
|
||||
hasProblemStatus: stats.hasProblemStatus
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return platformItems.sort((a, b) => platformLabel(a).localeCompare(platformLabel(b), "ru"));
|
||||
}
|
||||
|
||||
export function buildTimelineRows(
|
||||
plans: TguPlanUi[],
|
||||
platforms: TguPlatformUi[],
|
||||
range: TimelineRange,
|
||||
selectedSpacecraftId?: string
|
||||
): TimelineRow[] {
|
||||
const platformBySpacecraft = new Map(platforms.map((platform) => [platform.spacecraftId, platform]));
|
||||
const rowsBySpacecraft = new Map<string, TimelineRow>();
|
||||
|
||||
for (const plan of filterPlansByRange(plans, range)) {
|
||||
if (selectedSpacecraftId && plan.spacecraftId !== selectedSpacecraftId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const row =
|
||||
rowsBySpacecraft.get(plan.spacecraftId) ??
|
||||
{
|
||||
spacecraftId: plan.spacecraftId,
|
||||
platform: platformBySpacecraft.get(plan.spacecraftId),
|
||||
plans: []
|
||||
};
|
||||
|
||||
row.plans.push(plan);
|
||||
rowsBySpacecraft.set(plan.spacecraftId, row);
|
||||
}
|
||||
|
||||
for (const platform of platforms) {
|
||||
if (selectedSpacecraftId && platform.spacecraftId !== selectedSpacecraftId) {
|
||||
continue;
|
||||
}
|
||||
if (!rowsBySpacecraft.has(platform.spacecraftId) && platform.planCount > 0) {
|
||||
rowsBySpacecraft.set(platform.spacecraftId, {
|
||||
spacecraftId: platform.spacecraftId,
|
||||
platform,
|
||||
plans: []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(rowsBySpacecraft.values())
|
||||
.map((row) => ({
|
||||
...row,
|
||||
platform: row.platform ?? platformBySpacecraft.get(row.spacecraftId),
|
||||
plans: [...row.plans].sort((a, b) => a.startMs - b.startMs)
|
||||
}))
|
||||
.sort((a, b) => platformLabel(a.platform, a.spacecraftId).localeCompare(platformLabel(b.platform, b.spacecraftId), "ru"));
|
||||
}
|
||||
|
||||
export function filterPlatforms(platforms: TguPlatformUi[], query: string): TguPlatformUi[] {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return platforms;
|
||||
|
||||
return platforms.filter((platform) => {
|
||||
const searchText = [
|
||||
platform.name,
|
||||
platform.noradId,
|
||||
platform.spacecraftId,
|
||||
platform.mission,
|
||||
platform.status
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
return searchText.includes(normalized);
|
||||
});
|
||||
}
|
||||
|
||||
export function filterPlansByRange(plans: TguPlanUi[], range: TimelineRange): TguPlanUi[] {
|
||||
return plans.filter((plan) => plan.endMs >= range.fromMs && plan.startMs <= range.toMs);
|
||||
}
|
||||
|
||||
export function platformLabel(platform?: TguPlatformUi, fallback?: string): string {
|
||||
return platform?.name || platform?.noradId || fallback || platform?.spacecraftId || "КА";
|
||||
}
|
||||
|
||||
function buildPlatformByNorad(platforms: TguPlatform[]): Map<string, TguPlatform> {
|
||||
const result = new Map<string, TguPlatform>();
|
||||
for (const platform of platforms) {
|
||||
if (platform.noradId !== null && platform.noradId !== undefined) {
|
||||
result.set(String(platform.noradId), platform);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toPlatformUi(
|
||||
spacecraftId: string,
|
||||
platform: TguPlatform,
|
||||
planCount: number,
|
||||
hasProblemStatus: boolean
|
||||
): TguPlatformUi {
|
||||
return {
|
||||
spacecraftId,
|
||||
name: platform.name ?? undefined,
|
||||
noradId: platform.noradId === null || platform.noradId === undefined ? undefined : String(platform.noradId),
|
||||
status: platform.status ?? undefined,
|
||||
mission: platform.mission ?? undefined,
|
||||
planCount,
|
||||
hasProblemStatus,
|
||||
platform
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles/theme.css";
|
||||
import "./styles/tgu-planning.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,56 @@
|
||||
export type TguPlanStatus =
|
||||
| "ACCEPTED"
|
||||
| "REJECTED"
|
||||
| "WAITING_DECISION"
|
||||
| "PLANNED"
|
||||
| "ISSUING"
|
||||
| "EXPIRED"
|
||||
| "SUPERSEDED"
|
||||
| "START_AMBIGUOUS"
|
||||
| string;
|
||||
|
||||
export type TguPlanDecision = "ACCEPTED" | "REJECTED";
|
||||
|
||||
export type TguPlan = {
|
||||
planId: string;
|
||||
spacecraftId: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
kppId: string;
|
||||
status: TguPlanStatus;
|
||||
};
|
||||
|
||||
export type TguPlatform = {
|
||||
id?: string | null;
|
||||
businessKey?: string | null;
|
||||
name?: string | null;
|
||||
status?: string | null;
|
||||
noradId?: number | string | null;
|
||||
mission?: string | null;
|
||||
validFrom?: string | null;
|
||||
validTo?: string | null;
|
||||
};
|
||||
|
||||
export type TguPlanDecisionMessage = {
|
||||
eventId: string;
|
||||
spacecraftId: string;
|
||||
planId: string;
|
||||
attemptId: string;
|
||||
decision: TguPlanDecision;
|
||||
decisionTime: string;
|
||||
reason?: string | null;
|
||||
};
|
||||
|
||||
export type TguApiErrorKind = "network" | "http";
|
||||
|
||||
export class TguApiError extends Error {
|
||||
readonly kind: TguApiErrorKind;
|
||||
readonly status?: number;
|
||||
|
||||
constructor(message: string, kind: TguApiErrorKind, status?: number) {
|
||||
super(message);
|
||||
this.name = "TguApiError";
|
||||
this.kind = kind;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { TguPlan, TguPlanStatus, TguPlatform } from "./tguTypes";
|
||||
|
||||
export type TguPlatformUi = {
|
||||
spacecraftId: string;
|
||||
name?: string;
|
||||
noradId?: string;
|
||||
status?: string;
|
||||
mission?: string;
|
||||
planCount: number;
|
||||
hasProblemStatus: boolean;
|
||||
platform?: TguPlatform;
|
||||
};
|
||||
|
||||
export type TguPlanUi = TguPlan & {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
platform?: TguPlatformUi;
|
||||
};
|
||||
|
||||
export type TimelineRange = {
|
||||
fromMs: number;
|
||||
toMs: number;
|
||||
};
|
||||
|
||||
export type TimelineRow = {
|
||||
spacecraftId: string;
|
||||
platform?: TguPlatformUi;
|
||||
plans: TguPlanUi[];
|
||||
};
|
||||
|
||||
export type TimelinePlanSegment = {
|
||||
plan: TguPlanUi;
|
||||
rowIndex: number;
|
||||
x: number;
|
||||
x2: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
export type TimelineLink = {
|
||||
spacecraftId: string;
|
||||
fromPlanId: string;
|
||||
toPlanId: string;
|
||||
};
|
||||
|
||||
export type TimelineStatusStyle = {
|
||||
status: TguPlanStatus;
|
||||
label: string;
|
||||
color: string;
|
||||
className: string;
|
||||
};
|
||||
@@ -0,0 +1,569 @@
|
||||
.tgu-app-shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
height: 100%;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(104, 195, 189, 0.06), transparent 22rem),
|
||||
var(--bg-base);
|
||||
}
|
||||
|
||||
.tgu-toolbar {
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(23, 26, 28, 0.96);
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tgu-toolbar__row {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 0.75rem;
|
||||
min-height: 4.4rem;
|
||||
padding: 0.75rem 1rem 0.65rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tgu-toolbar__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
min-width: 13.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.tgu-toolbar__mark {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 18px rgba(104, 195, 189, 0.42);
|
||||
}
|
||||
|
||||
.tgu-toolbar__title {
|
||||
font-size: 0.96rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tgu-toolbar__subtitle {
|
||||
margin-top: 0.1rem;
|
||||
color: var(--text-dim);
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tgu-field {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.tgu-field input,
|
||||
.tgu-search input {
|
||||
min-height: 2rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #101214;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tgu-field input {
|
||||
width: 12.8rem;
|
||||
padding: 0.38rem 0.55rem;
|
||||
}
|
||||
|
||||
.tgu-field input:focus,
|
||||
.tgu-search input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(104, 195, 189, 0.16);
|
||||
}
|
||||
|
||||
.tgu-button {
|
||||
min-height: 2rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 0.38rem 0.72rem;
|
||||
background: var(--bg-panel-2);
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tgu-button:hover:not(:disabled) {
|
||||
border-color: #4e5a5d;
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.tgu-button--primary {
|
||||
border-color: rgba(104, 195, 189, 0.58);
|
||||
background: #214341;
|
||||
color: #eafffd;
|
||||
}
|
||||
|
||||
.tgu-button--accept {
|
||||
border-color: rgba(47, 158, 68, 0.65);
|
||||
background: rgba(47, 158, 68, 0.16);
|
||||
}
|
||||
|
||||
.tgu-button--reject {
|
||||
border-color: rgba(214, 69, 69, 0.7);
|
||||
background: rgba(214, 69, 69, 0.14);
|
||||
}
|
||||
|
||||
.tgu-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.48rem 0.82rem;
|
||||
padding: 0 1rem 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.tgu-legend__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tgu-legend__swatch {
|
||||
width: 1.05rem;
|
||||
height: 0.46rem;
|
||||
border-radius: 999px;
|
||||
background: #79828d;
|
||||
}
|
||||
|
||||
.tgu-legend__note {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.status-accepted {
|
||||
background-color: #2f9e44;
|
||||
}
|
||||
|
||||
.status-rejected {
|
||||
background-color: #d64545;
|
||||
}
|
||||
|
||||
.status-waiting {
|
||||
background-color: #f0ad2e;
|
||||
}
|
||||
|
||||
.status-planned {
|
||||
background-color: #8d99a6;
|
||||
}
|
||||
|
||||
.status-issuing {
|
||||
background-color: #2f80ed;
|
||||
}
|
||||
|
||||
.status-expired {
|
||||
background-color: #495057;
|
||||
}
|
||||
|
||||
.status-superseded {
|
||||
background-color: #c7ced6;
|
||||
}
|
||||
|
||||
.status-ambiguous {
|
||||
background-color: #b42318;
|
||||
outline: 1px dashed #ffd1d1;
|
||||
}
|
||||
|
||||
.status-unknown {
|
||||
background-color: #79828d;
|
||||
}
|
||||
|
||||
.tgu-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 18.5rem minmax(0, 1fr) 22rem;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tgu-sidebar,
|
||||
.tgu-details {
|
||||
min-height: 0;
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
.tgu-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tgu-panel-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-height: 3rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tgu-link-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.tgu-search {
|
||||
padding: 0.75rem 0.8rem;
|
||||
}
|
||||
|
||||
.tgu-search input {
|
||||
width: 100%;
|
||||
padding: 0.42rem 0.65rem;
|
||||
}
|
||||
|
||||
.tgu-spacecraft-list {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.35rem;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0 0.55rem 0.9rem;
|
||||
}
|
||||
|
||||
.tgu-spacecraft {
|
||||
display: grid;
|
||||
grid-template-columns: 0.58rem minmax(0, 1fr) auto;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 4.35rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
padding: 0.55rem 0.62rem;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tgu-spacecraft:hover,
|
||||
.tgu-spacecraft.is-selected {
|
||||
border-color: var(--line);
|
||||
background: var(--bg-panel-2);
|
||||
}
|
||||
|
||||
.tgu-spacecraft.is-selected {
|
||||
border-color: rgba(104, 195, 189, 0.6);
|
||||
}
|
||||
|
||||
.tgu-spacecraft__problem {
|
||||
width: 0.48rem;
|
||||
height: 0.48rem;
|
||||
border-radius: 50%;
|
||||
background: #465154;
|
||||
}
|
||||
|
||||
.tgu-spacecraft__problem.is-problem {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 12px rgba(214, 69, 69, 0.55);
|
||||
}
|
||||
|
||||
.tgu-spacecraft__body {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.tgu-spacecraft__name {
|
||||
overflow: hidden;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tgu-spacecraft__meta,
|
||||
.tgu-spacecraft__status {
|
||||
overflow: hidden;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.72rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tgu-spacecraft__count {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 1.75rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 5px;
|
||||
background: #101214;
|
||||
color: var(--accent-strong);
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.tgu-center {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: #121516;
|
||||
}
|
||||
|
||||
.tgu-timeline {
|
||||
min-width: 48rem;
|
||||
min-height: 100%;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.tgu-timeline__svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 26rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #151819;
|
||||
}
|
||||
|
||||
.tgu-timeline__background {
|
||||
fill: #151819;
|
||||
}
|
||||
|
||||
.tgu-timeline__labels-background {
|
||||
fill: #181c1e;
|
||||
}
|
||||
|
||||
.tgu-timeline__axis,
|
||||
.tgu-timeline__grid {
|
||||
stroke: #343c3f;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.tgu-timeline__grid {
|
||||
stroke-dasharray: 3 5;
|
||||
}
|
||||
|
||||
.tgu-timeline__tick-date {
|
||||
fill: #d2dddd;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.tgu-timeline__tick-time,
|
||||
.tgu-timeline__row-meta {
|
||||
fill: #79878a;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tgu-timeline__now {
|
||||
stroke: #f46e4f;
|
||||
stroke-width: 1.7;
|
||||
stroke-dasharray: 5 5;
|
||||
}
|
||||
|
||||
.tgu-timeline__now-label {
|
||||
fill: #f46e4f;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tgu-timeline__row {
|
||||
fill: #151819;
|
||||
stroke: #22282a;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.tgu-timeline__row--alt {
|
||||
fill: #171b1d;
|
||||
}
|
||||
|
||||
.tgu-timeline__row-title {
|
||||
fill: #e2e9e9;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tgu-timeline__arrow {
|
||||
fill: none;
|
||||
stroke: #8aa2a6;
|
||||
stroke-width: 1.5;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.tgu-timeline-plan {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tgu-timeline-plan__bar {
|
||||
stroke-linecap: round;
|
||||
stroke-width: 13;
|
||||
}
|
||||
|
||||
.tgu-timeline-plan__selection {
|
||||
stroke: #efffff;
|
||||
stroke-linecap: round;
|
||||
stroke-width: 19;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.tgu-timeline-plan__label {
|
||||
fill: #101214;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tgu-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
overflow: auto;
|
||||
border-left: 1px solid var(--line);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.tgu-details--empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tgu-details__placeholder {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tgu-details__placeholder-icon {
|
||||
width: 2.2rem;
|
||||
height: 1rem;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.tgu-details__header {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.tgu-details__eyebrow {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tgu-details h2 {
|
||||
margin: 0.25rem 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 1rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.tgu-icon-button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-panel-2);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tgu-status-pill {
|
||||
align-self: flex-start;
|
||||
border-radius: 999px;
|
||||
padding: 0.22rem 0.65rem;
|
||||
color: #101214;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tgu-details-grid {
|
||||
display: grid;
|
||||
gap: 0.72rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tgu-details-grid dt {
|
||||
margin-bottom: 0.18rem;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tgu-details-grid dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.tgu-details__actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
padding-top: 0.35rem;
|
||||
}
|
||||
|
||||
.tgu-alert {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
padding: 0.62rem 0.72rem;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.tgu-toolbar > .tgu-alert {
|
||||
margin: 0 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.tgu-alert--danger {
|
||||
border-color: rgba(214, 69, 69, 0.48);
|
||||
background: rgba(214, 69, 69, 0.12);
|
||||
color: #ffd9d9;
|
||||
}
|
||||
|
||||
.tgu-alert--warning {
|
||||
border-color: rgba(240, 173, 46, 0.48);
|
||||
background: rgba(240, 173, 46, 0.12);
|
||||
color: #ffe7b5;
|
||||
}
|
||||
|
||||
.tgu-alert--success {
|
||||
border-color: rgba(47, 158, 68, 0.48);
|
||||
background: rgba(47, 158, 68, 0.12);
|
||||
color: #c9f7d2;
|
||||
}
|
||||
|
||||
.tgu-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 18rem;
|
||||
padding: 2rem;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tgu-empty--compact {
|
||||
min-height: 5rem;
|
||||
padding: 1rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.tgu-workspace {
|
||||
grid-template-columns: 16rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.tgu-details {
|
||||
grid-column: 1 / -1;
|
||||
max-height: 18rem;
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
:root {
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #e7ecec;
|
||||
background: #111315;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
||||
--bg-base: #111315;
|
||||
--bg-panel: #171a1c;
|
||||
--bg-panel-2: #1f2325;
|
||||
--bg-elevated: #262b2e;
|
||||
--line: #30373a;
|
||||
--line-soft: #252b2d;
|
||||
--text: #e7ecec;
|
||||
--text-muted: #a4b0b1;
|
||||
--text-dim: #748183;
|
||||
--accent: #68c3bd;
|
||||
--accent-strong: #82ded8;
|
||||
--danger: #d64545;
|
||||
--warning: #f0ad2e;
|
||||
--success: #2f9e44;
|
||||
--shadow: 0 16px 42px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
Reference in New Issue
Block a user