Новый сервис pcp-tgu-ui-service: ТГУ-фронт на headless-auth nstart + платформенный CI/деплой
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
OpticsParams,
|
||||
OpticsResultType,
|
||||
Polarisation,
|
||||
RsaParams,
|
||||
RsaResultType
|
||||
} from "../../model/requestTypes";
|
||||
import { parseLocal } from "../../utils/localTime";
|
||||
import { DateTime24Field } from "../../components/timeFields";
|
||||
|
||||
export type RequestFormSubmit = {
|
||||
name: string;
|
||||
importance: number;
|
||||
beginDateTime: string;
|
||||
endDateTime: string;
|
||||
kpp: number[];
|
||||
highPriorityTransmit: boolean;
|
||||
optics?: OpticsParams;
|
||||
rsa?: RsaParams;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
vertexCount: number;
|
||||
drawing: boolean;
|
||||
defaultBegin: string;
|
||||
defaultEnd: string;
|
||||
submitting: boolean;
|
||||
error?: string;
|
||||
onUndoPoint: () => void;
|
||||
onClear: () => void;
|
||||
onFinishDrawing: () => void;
|
||||
onRestartDrawing: () => void;
|
||||
onSubmit: (form: RequestFormSubmit) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const OPTICS_RESULT_TYPES: { value: OpticsResultType; label: string }[] = [
|
||||
{ value: "PANCHROMATIC", label: "Панхроматическая" },
|
||||
{ value: "MULTISPECTRAL", label: "Мультиспектральная" },
|
||||
{ value: "PANSHARPENING", label: "Паншарпенинг" }
|
||||
];
|
||||
|
||||
const RSA_RESULT_TYPES: { value: RsaResultType; label: string }[] = [
|
||||
{ value: "SPOTLIGHT", label: "Spotlight" },
|
||||
{ value: "STRIPMAP", label: "Stripmap" },
|
||||
{ value: "SCANSAR", label: "ScanSAR" }
|
||||
];
|
||||
|
||||
const POLARISATIONS: { value: Polarisation; label: string }[] = [
|
||||
{ value: "HH", label: "HH" },
|
||||
{ value: "VV", label: "VV" }
|
||||
];
|
||||
|
||||
export function RequestCreatePanel({
|
||||
vertexCount,
|
||||
drawing,
|
||||
defaultBegin,
|
||||
defaultEnd,
|
||||
submitting,
|
||||
error,
|
||||
onUndoPoint,
|
||||
onClear,
|
||||
onFinishDrawing,
|
||||
onRestartDrawing,
|
||||
onSubmit,
|
||||
onCancel
|
||||
}: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [importance, setImportance] = useState("1");
|
||||
const [begin, setBegin] = useState(defaultBegin);
|
||||
const [end, setEnd] = useState(defaultEnd);
|
||||
const [kpp, setKpp] = useState("");
|
||||
const [highPriority, setHighPriority] = useState(false);
|
||||
|
||||
const [opticsEnabled, setOpticsEnabled] = useState(true);
|
||||
const [opticsResultType, setOpticsResultType] = useState<OpticsResultType>("PANCHROMATIC");
|
||||
const [opticsResolution, setOpticsResolution] = useState("1");
|
||||
const [sunAngleMin, setSunAngleMin] = useState("10");
|
||||
const [sunAngleMax, setSunAngleMax] = useState("90");
|
||||
const [clouds, setClouds] = useState("100");
|
||||
|
||||
const [rsaEnabled, setRsaEnabled] = useState(false);
|
||||
const [rsaResultType, setRsaResultType] = useState<RsaResultType>("STRIPMAP");
|
||||
const [interferometry, setInterferometry] = useState(false);
|
||||
const [polarisation, setPolarisation] = useState<Polarisation>("HH");
|
||||
const [rsaResolution, setRsaResolution] = useState("1");
|
||||
|
||||
const importanceNum = Number(importance);
|
||||
const beginMs = parseLocal(begin);
|
||||
const endMs = parseLocal(end);
|
||||
|
||||
const kppList = parseKpp(kpp);
|
||||
const kppValid = kppList !== null;
|
||||
|
||||
const geometryReady = vertexCount >= 3 && !drawing;
|
||||
|
||||
const validationError = buildValidationError({
|
||||
geometryReady,
|
||||
drawing,
|
||||
vertexCount,
|
||||
name,
|
||||
importance,
|
||||
importanceNum,
|
||||
beginMs,
|
||||
endMs,
|
||||
kppValid,
|
||||
opticsEnabled,
|
||||
opticsResolution,
|
||||
rsaEnabled,
|
||||
rsaResolution
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (validationError || !kppList) return;
|
||||
const optics: OpticsParams | undefined = opticsEnabled
|
||||
? {
|
||||
resultType: opticsResultType,
|
||||
resolution: Number(opticsResolution),
|
||||
sunAngleMin: Number(sunAngleMin),
|
||||
sunAngleMax: Number(sunAngleMax),
|
||||
clouds: Number(clouds)
|
||||
}
|
||||
: undefined;
|
||||
const rsa: RsaParams | undefined = rsaEnabled
|
||||
? {
|
||||
resultType: rsaResultType,
|
||||
interferometry,
|
||||
polarisation,
|
||||
resolution: Number(rsaResolution)
|
||||
}
|
||||
: undefined;
|
||||
|
||||
onSubmit({
|
||||
name: name.trim(),
|
||||
importance: importanceNum,
|
||||
beginDateTime: begin,
|
||||
endDateTime: end,
|
||||
kpp: kppList,
|
||||
highPriorityTransmit: highPriority,
|
||||
optics,
|
||||
rsa
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="tgu-map-requests-panel tgu-requests-form">
|
||||
<div className="tgu-panel-heading">
|
||||
<span>Новая заявка</span>
|
||||
<button className="tgu-link-button" type="button" onClick={onCancel}>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tgu-requests-form__body">
|
||||
<section className="tgu-requests-form__section">
|
||||
<div className="tgu-map-panel__label">Геометрия (полигон)</div>
|
||||
<div className="tgu-requests-form__hint">
|
||||
{drawing
|
||||
? "Кликайте по карте, чтобы добавить вершины. Нажмите «Завершить» (≥3 вершин)."
|
||||
: geometryReady
|
||||
? `Полигон задан: ${vertexCount} вершин.`
|
||||
: "Полигон не задан."}
|
||||
</div>
|
||||
<div className="tgu-requests-form__geo-status">Вершин: {vertexCount}</div>
|
||||
<div className="tgu-requests-form__actions">
|
||||
{drawing ? (
|
||||
<>
|
||||
<button className="tgu-button tgu-button--sm" type="button" onClick={onUndoPoint} disabled={vertexCount === 0}>
|
||||
Отменить точку
|
||||
</button>
|
||||
<button className="tgu-button tgu-button--sm" type="button" onClick={onClear} disabled={vertexCount === 0}>
|
||||
Очистить
|
||||
</button>
|
||||
<button
|
||||
className="tgu-button tgu-button--primary tgu-button--sm"
|
||||
type="button"
|
||||
onClick={onFinishDrawing}
|
||||
disabled={vertexCount < 3}
|
||||
>
|
||||
Завершить
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="tgu-button tgu-button--sm" type="button" onClick={onRestartDrawing}>
|
||||
Перерисовать
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="tgu-requests-form__section">
|
||||
<div className="tgu-map-panel__label">Параметры заявки</div>
|
||||
<label className="tgu-requests-field">
|
||||
<span>Название</span>
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Название задания" />
|
||||
</label>
|
||||
<label className="tgu-requests-field">
|
||||
<span>Важность</span>
|
||||
<input type="number" min="0" step="0.1" value={importance} onChange={(e) => setImportance(e.target.value)} />
|
||||
</label>
|
||||
<label className="tgu-requests-field">
|
||||
<span>Начало</span>
|
||||
<DateTime24Field value={begin} onChange={setBegin} />
|
||||
</label>
|
||||
<label className="tgu-requests-field">
|
||||
<span>Конец</span>
|
||||
<DateTime24Field value={end} onChange={setEnd} />
|
||||
</label>
|
||||
<label className="tgu-requests-field">
|
||||
<span>КПП</span>
|
||||
<input type="text" value={kpp} onChange={(e) => setKpp(e.target.value)} placeholder="напр. 1, 2, 5" />
|
||||
</label>
|
||||
<label className="tgu-requests-checkbox">
|
||||
<input type="checkbox" checked={highPriority} onChange={(e) => setHighPriority(e.target.checked)} />
|
||||
<span>Высокоприоритетная передача</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="tgu-requests-form__section">
|
||||
<label className="tgu-requests-checkbox tgu-requests-form__block-toggle">
|
||||
<input type="checkbox" checked={opticsEnabled} onChange={(e) => setOpticsEnabled(e.target.checked)} />
|
||||
<span>Оптическая съёмка</span>
|
||||
</label>
|
||||
{opticsEnabled && (
|
||||
<div className="tgu-requests-form__block">
|
||||
<label className="tgu-requests-field">
|
||||
<span>Результат</span>
|
||||
<select value={opticsResultType} onChange={(e) => setOpticsResultType(e.target.value as OpticsResultType)}>
|
||||
{OPTICS_RESULT_TYPES.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="tgu-requests-field">
|
||||
<span>Разрешение, м</span>
|
||||
<input type="number" min="0.1" step="0.1" value={opticsResolution} onChange={(e) => setOpticsResolution(e.target.value)} />
|
||||
</label>
|
||||
<label className="tgu-requests-field">
|
||||
<span>Высота Солнца мин, °</span>
|
||||
<input type="number" min="0" max="90" step="1" value={sunAngleMin} onChange={(e) => setSunAngleMin(e.target.value)} />
|
||||
</label>
|
||||
<label className="tgu-requests-field">
|
||||
<span>Высота Солнца макс, °</span>
|
||||
<input type="number" min="0" max="90" step="1" value={sunAngleMax} onChange={(e) => setSunAngleMax(e.target.value)} />
|
||||
</label>
|
||||
<label className="tgu-requests-field">
|
||||
<span>Облачность макс, %</span>
|
||||
<input type="number" min="0" max="100" step="1" value={clouds} onChange={(e) => setClouds(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="tgu-requests-form__section">
|
||||
<label className="tgu-requests-checkbox tgu-requests-form__block-toggle">
|
||||
<input type="checkbox" checked={rsaEnabled} onChange={(e) => setRsaEnabled(e.target.checked)} />
|
||||
<span>Радиолокационная съёмка (РСА)</span>
|
||||
</label>
|
||||
{rsaEnabled && (
|
||||
<div className="tgu-requests-form__block">
|
||||
<label className="tgu-requests-field">
|
||||
<span>Тип аппаратуры</span>
|
||||
<select value={rsaResultType} onChange={(e) => setRsaResultType(e.target.value as RsaResultType)}>
|
||||
{RSA_RESULT_TYPES.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="tgu-requests-field">
|
||||
<span>Поляризация</span>
|
||||
<select value={polarisation} onChange={(e) => setPolarisation(e.target.value as Polarisation)}>
|
||||
{POLARISATIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="tgu-requests-field">
|
||||
<span>Разрешение, м</span>
|
||||
<input type="number" min="0.1" step="0.1" value={rsaResolution} onChange={(e) => setRsaResolution(e.target.value)} />
|
||||
</label>
|
||||
<label className="tgu-requests-checkbox">
|
||||
<input type="checkbox" checked={interferometry} onChange={(e) => setInterferometry(e.target.checked)} />
|
||||
<span>Интерферометрия</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{(validationError || error) && (
|
||||
<div className="tgu-alert tgu-alert--danger tgu-alert--compact">{error ?? validationError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tgu-requests-form__footer">
|
||||
<button
|
||||
className="tgu-button tgu-button--primary"
|
||||
type="button"
|
||||
disabled={submitting || validationError != null}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting ? "Создание..." : "Создать заявку"}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function parseKpp(raw: string): number[] | null {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "") return [];
|
||||
const parts = trimmed.split(/[,\s]+/).filter(Boolean);
|
||||
const result: number[] = [];
|
||||
for (const part of parts) {
|
||||
const n = Number(part);
|
||||
if (!Number.isInteger(n) || n < 0) return null;
|
||||
result.push(n);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildValidationError(input: {
|
||||
geometryReady: boolean;
|
||||
drawing: boolean;
|
||||
vertexCount: number;
|
||||
name: string;
|
||||
importance: string;
|
||||
importanceNum: number;
|
||||
beginMs: number;
|
||||
endMs: number;
|
||||
kppValid: boolean;
|
||||
opticsEnabled: boolean;
|
||||
opticsResolution: string;
|
||||
rsaEnabled: boolean;
|
||||
rsaResolution: string;
|
||||
}): string | null {
|
||||
if (input.drawing) return "Завершите рисование полигона (≥3 вершин).";
|
||||
if (!input.geometryReady) return "Задайте полигон минимум из 3 вершин.";
|
||||
if (input.name.trim() === "") return "Укажите название заявки.";
|
||||
if (input.importance.trim() === "" || !Number.isFinite(input.importanceNum) || input.importanceNum < 0) {
|
||||
return "Важность должна быть числом ≥ 0.";
|
||||
}
|
||||
if (!Number.isFinite(input.beginMs) || !Number.isFinite(input.endMs) || input.beginMs >= input.endMs) {
|
||||
return "Начало должно быть раньше конца.";
|
||||
}
|
||||
if (!input.kppValid) return "КПП — список целых чисел через запятую.";
|
||||
if (!input.opticsEnabled && !input.rsaEnabled) {
|
||||
return "Включите хотя бы один блок съёмки: Оптика или РСА.";
|
||||
}
|
||||
if (input.opticsEnabled && !(Number(input.opticsResolution) >= 0.1)) {
|
||||
return "Разрешение оптики должно быть ≥ 0.1 м.";
|
||||
}
|
||||
if (input.rsaEnabled && !(Number(input.rsaResolution) >= 0.1)) {
|
||||
return "Разрешение РСА должно быть ≥ 0.1 м.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel";
|
||||
import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
|
||||
|
||||
type Props = {
|
||||
requests: RequestAtPoint[];
|
||||
selectedPolygonId?: string;
|
||||
deleting: boolean;
|
||||
deleteError?: string;
|
||||
onHover: (polygonId: string | undefined) => void;
|
||||
onSelect: (polygonId: string) => void;
|
||||
onDelete: (requestId: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function RequestDetailsPanel({
|
||||
requests,
|
||||
selectedPolygonId,
|
||||
deleting,
|
||||
deleteError,
|
||||
onHover,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onClose
|
||||
}: Props) {
|
||||
const selectedEntry = selectedPolygonId
|
||||
? requests.find((r) => r.polygon.id === selectedPolygonId)
|
||||
: requests.length === 1
|
||||
? requests[0]
|
||||
: undefined;
|
||||
|
||||
// Второй шаг подтверждения удаления. Сбрасываем при смене выбранной заявки,
|
||||
// чтобы открытый запрос на удаление не «переезжал» на другую заявку.
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
useEffect(() => {
|
||||
setConfirmingDelete(false);
|
||||
}, [selectedEntry?.item.id]);
|
||||
|
||||
return (
|
||||
<aside className="tgu-map-requests-panel">
|
||||
<div className="tgu-panel-heading">
|
||||
<span>Заявки в точке ({requests.length})</span>
|
||||
<button className="tgu-link-button" type="button" onClick={onClose}>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{requests.length > 1 && (
|
||||
<div className="tgu-map-requests-list">
|
||||
{requests.map(({ polygon, item }) => (
|
||||
<button
|
||||
key={polygon.id}
|
||||
className={`tgu-map-request-row ${polygon.id === selectedEntry?.polygon.id ? "is-selected" : ""}`}
|
||||
type="button"
|
||||
onMouseEnter={() => onHover(polygon.id)}
|
||||
onMouseLeave={() => onHover(undefined)}
|
||||
onClick={() => onSelect(polygon.id)}
|
||||
>
|
||||
<i className={`tgu-map-request-row__type req-${item.surveyType.toLowerCase()}`} />
|
||||
<span className="tgu-map-request-row__body">
|
||||
<span className="tgu-map-request-row__name">{item.name || item.id}</span>
|
||||
<span className="tgu-map-request-row__meta">
|
||||
{formatDateTime(item.beginDateTime)} · {item.surveyType} · {item.status}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedEntry && (
|
||||
<div className="tgu-map-request-details" style={{ flex: 1 }}>
|
||||
<div className="tgu-map-panel__label">Детали заявки</div>
|
||||
<dl className="tgu-map-request-details__grid">
|
||||
<dt>ID</dt>
|
||||
<dd className="mono">{selectedEntry.item.id}</dd>
|
||||
|
||||
<dt>Название</dt>
|
||||
<dd>{selectedEntry.item.name || "—"}</dd>
|
||||
|
||||
<dt>Статус</dt>
|
||||
<dd>{selectedEntry.item.status}</dd>
|
||||
|
||||
<dt>Тип съёмки</dt>
|
||||
<dd>{surveyTypeLabel(selectedEntry.item.surveyType)}</dd>
|
||||
|
||||
<dt>Начало</dt>
|
||||
<dd className="mono">{formatDateTime(selectedEntry.item.beginDateTime)}</dd>
|
||||
|
||||
<dt>Конец</dt>
|
||||
<dd className="mono">{formatDateTime(selectedEntry.item.endDateTime)}</dd>
|
||||
|
||||
<dt>Важность</dt>
|
||||
<dd className="mono">{selectedEntry.item.importance}</dd>
|
||||
|
||||
<dt>Покрытие</dt>
|
||||
<dd className="mono">
|
||||
{selectedEntry.item.coveragePercent != null ? `${selectedEntry.item.coveragePercent.toFixed(1)} %` : "—"}
|
||||
</dd>
|
||||
|
||||
<dt>КПП</dt>
|
||||
<dd className="mono">{selectedEntry.item.kpp?.join(", ") || "—"}</dd>
|
||||
|
||||
<dt>Приоритет передачи</dt>
|
||||
<dd>{selectedEntry.item.highPriorityTransmit ? "Да" : "Нет"}</dd>
|
||||
|
||||
<dt>Создано</dt>
|
||||
<dd className="mono">{formatDateTime(selectedEntry.item.createdAt)}</dd>
|
||||
|
||||
<dt>Изменено</dt>
|
||||
<dd className="mono">{formatDateTime(selectedEntry.item.updatedAt)}</dd>
|
||||
</dl>
|
||||
|
||||
{deleteError && <div className="tgu-alert tgu-alert--danger tgu-alert--compact">{deleteError}</div>}
|
||||
|
||||
{confirmingDelete ? (
|
||||
<div className="tgu-requests-confirm">
|
||||
<div className="tgu-requests-confirm__title">Удалить заявку?</div>
|
||||
<div className="tgu-requests-confirm__subtitle">{selectedEntry.item.name || selectedEntry.item.id}</div>
|
||||
<div className="tgu-requests-form__actions">
|
||||
<button
|
||||
className="tgu-button tgu-button--reject"
|
||||
type="button"
|
||||
disabled={deleting}
|
||||
onClick={() => onDelete(selectedEntry.item.id)}
|
||||
>
|
||||
{deleting ? "Удаление..." : "Да, удалить"}
|
||||
</button>
|
||||
<button
|
||||
className="tgu-button"
|
||||
type="button"
|
||||
disabled={deleting}
|
||||
onClick={() => setConfirmingDelete(false)}
|
||||
>
|
||||
Нет, не удалять
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tgu-requests-form__actions">
|
||||
<button
|
||||
className="tgu-button tgu-button--reject"
|
||||
type="button"
|
||||
disabled={deleting}
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
>
|
||||
Удалить заявку
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return formatLocalDateTime(parseLocal(value));
|
||||
}
|
||||
|
||||
function surveyTypeLabel(surveyType: string): string {
|
||||
switch (surveyType) {
|
||||
case "OPTICS":
|
||||
return "Оптическая";
|
||||
case "RSA":
|
||||
return "Радиолокационная (РСА)";
|
||||
case "COMBINED":
|
||||
return "Комбинированная";
|
||||
default:
|
||||
return surveyType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createRequest, deleteRequest, fetchRequestsForMap } from "../../api/requestApi";
|
||||
import { fetchStations, type StationDto } from "../../api/stationsApi";
|
||||
import type { RequestMapItem } from "../../model/requestTypes";
|
||||
import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
|
||||
import { buildTgu2DMapScene } from "../tgu-map-2d/Tgu2DMapLayers";
|
||||
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
||||
import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel";
|
||||
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
|
||||
import type { GeoPoint, MapPolygon } from "../tgu-map-2d/model/mapTypes";
|
||||
import { generateUuid } from "../../utils/uuid";
|
||||
import { parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
|
||||
import { DateTime24Field } from "../../components/timeFields";
|
||||
import { RequestCreatePanel, type RequestFormSubmit } from "./RequestCreatePanel";
|
||||
import { RequestDetailsPanel } from "./RequestDetailsPanel";
|
||||
import { polygonToWkt } from "./requestGeometry";
|
||||
|
||||
type TguRequestsTabProps = {
|
||||
range: TimelineRange;
|
||||
invalidRange: boolean;
|
||||
platforms: TguPlatformUi[];
|
||||
};
|
||||
|
||||
type RequestsLoadState = {
|
||||
fromValue: string;
|
||||
toValue: string;
|
||||
items: RequestMapItem[];
|
||||
loading: boolean;
|
||||
loadedCount: number;
|
||||
totalItems: number;
|
||||
truncated: boolean;
|
||||
error?: string;
|
||||
loaded: boolean;
|
||||
};
|
||||
|
||||
type PanelMode = "none" | "details" | "create";
|
||||
|
||||
function buildInitialLoadState(range: TimelineRange): RequestsLoadState {
|
||||
return {
|
||||
fromValue: toLocalInputValue(range.fromMs),
|
||||
toValue: toLocalInputValue(range.toMs),
|
||||
items: [],
|
||||
loading: false,
|
||||
loadedCount: 0,
|
||||
totalItems: 0,
|
||||
truncated: false,
|
||||
loaded: false
|
||||
};
|
||||
}
|
||||
|
||||
export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTabProps) {
|
||||
const [requestsState, setRequestsState] = useState<RequestsLoadState>(() => buildInitialLoadState(range));
|
||||
const [stations, setStations] = useState<StationDto[]>([]);
|
||||
const [showStations, setShowStations] = useState(true);
|
||||
const [redrawToken, setRedrawToken] = useState(0);
|
||||
|
||||
const [panelMode, setPanelMode] = useState<PanelMode>("none");
|
||||
const [requestsAtPoint, setRequestsAtPoint] = useState<RequestAtPoint[]>([]);
|
||||
const [selectedRequestPolygonId, setSelectedRequestPolygonId] = useState<string | undefined>();
|
||||
const [hoveredRequestPolygonId, setHoveredRequestPolygonId] = useState<string | undefined>();
|
||||
|
||||
const [draftPolygon, setDraftPolygon] = useState<GeoPoint[]>([]);
|
||||
const [drawing, setDrawing] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string>();
|
||||
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string>();
|
||||
const [notice, setNotice] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
fetchStations()
|
||||
.then((items) => setStations(items))
|
||||
.catch(() => setStations([]));
|
||||
}, []);
|
||||
|
||||
const loadRequests = useCallback(async (fromValue: string, toValue: string) => {
|
||||
const fromMs = parseLocal(fromValue);
|
||||
const toMs = parseLocal(toValue);
|
||||
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return;
|
||||
|
||||
setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 }));
|
||||
try {
|
||||
const result = await fetchRequestsForMap(
|
||||
{ endFrom: toLocalIso(fromMs), beginTo: toLocalIso(toMs) },
|
||||
(loaded, total) => setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total }))
|
||||
);
|
||||
setRequestsState((current) => ({
|
||||
...current,
|
||||
items: result.items,
|
||||
loadedCount: result.items.length,
|
||||
totalItems: result.totalItems,
|
||||
truncated: result.truncated,
|
||||
loading: false,
|
||||
loaded: true
|
||||
}));
|
||||
} catch (error) {
|
||||
setRequestsState((current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : "Не удалось загрузить заявки."
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setRequestsState(buildInitialLoadState(range));
|
||||
}, [range]);
|
||||
|
||||
const applyRange = useCallback(() => {
|
||||
void loadRequests(requestsState.fromValue, requestsState.toValue);
|
||||
}, [loadRequests, requestsState.fromValue, requestsState.toValue]);
|
||||
|
||||
const scene = useMemo(
|
||||
() =>
|
||||
buildTgu2DMapScene({
|
||||
range,
|
||||
platforms,
|
||||
requestItems: requestsState.items,
|
||||
stationItems: showStations ? stations : []
|
||||
}),
|
||||
[range, platforms, requestsState.items, showStations, stations]
|
||||
);
|
||||
|
||||
const layers = useMemo(
|
||||
() => ({ tracks: false, swath: false, planWorks: false, complexPlan: false, stations: showStations, spacecraftMarkers: false, requests: true }),
|
||||
[showStations]
|
||||
);
|
||||
|
||||
const closeAll = useCallback(() => {
|
||||
setRequestsAtPoint([]);
|
||||
setSelectedRequestPolygonId(undefined);
|
||||
setHoveredRequestPolygonId(undefined);
|
||||
setDeleteError(undefined);
|
||||
}, []);
|
||||
|
||||
const handleRequestsAtPoint = useCallback(
|
||||
(polygons: MapPolygon[]) => {
|
||||
if (panelMode === "create") return;
|
||||
if (polygons.length === 0) {
|
||||
closeAll();
|
||||
setPanelMode("none");
|
||||
return;
|
||||
}
|
||||
const joined = polygons.flatMap((polygon) => {
|
||||
const item = requestsState.items.find((r) => r.id === polygon.requestId);
|
||||
return item ? [{ polygon, item }] : [];
|
||||
});
|
||||
setRequestsAtPoint(joined);
|
||||
setSelectedRequestPolygonId(joined.length === 1 ? joined[0].polygon.id : undefined);
|
||||
setHoveredRequestPolygonId(undefined);
|
||||
setDeleteError(undefined);
|
||||
setPanelMode(joined.length > 0 ? "details" : "none");
|
||||
},
|
||||
[panelMode, requestsState.items, closeAll]
|
||||
);
|
||||
|
||||
// Детали заявок открываются через onRequestsAtPoint. onObjectSelect карта
|
||||
// вызывает с undefined одновременно с onRequestsAtPoint, поэтому здесь no-op,
|
||||
// чтобы не затирать только что открытую панель (станции/пустой клик уже
|
||||
// закрывают панель через onRequestsAtPoint([])).
|
||||
const handleObjectSelect = useCallback((_selection?: Tgu2DMapSelection) => {}, []);
|
||||
|
||||
const startCreate = useCallback(() => {
|
||||
closeAll();
|
||||
setDraftPolygon([]);
|
||||
setDrawing(true);
|
||||
setCreateError(undefined);
|
||||
setNotice(undefined);
|
||||
setPanelMode("create");
|
||||
}, [closeAll]);
|
||||
|
||||
const cancelCreate = useCallback(() => {
|
||||
setDraftPolygon([]);
|
||||
setDrawing(false);
|
||||
setCreateError(undefined);
|
||||
setPanelMode("none");
|
||||
}, []);
|
||||
|
||||
const handleDrawAddPoint = useCallback(
|
||||
(point: GeoPoint) => {
|
||||
if (panelMode !== "create" || !drawing) return;
|
||||
setDraftPolygon((current) => [...current, point]);
|
||||
},
|
||||
[panelMode, drawing]
|
||||
);
|
||||
|
||||
const submitCreate = useCallback(
|
||||
async (form: RequestFormSubmit) => {
|
||||
let geometry: string;
|
||||
try {
|
||||
geometry = polygonToWkt(draftPolygon);
|
||||
} catch (error) {
|
||||
setCreateError(error instanceof Error ? error.message : "Некорректная геометрия.");
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setCreateError(undefined);
|
||||
try {
|
||||
const id = generateUuid();
|
||||
await createRequest({
|
||||
id,
|
||||
name: form.name,
|
||||
geometry,
|
||||
importance: form.importance,
|
||||
beginDateTime: toLocalIso(parseLocal(form.beginDateTime)),
|
||||
endDateTime: toLocalIso(parseLocal(form.endDateTime)),
|
||||
kpp: form.kpp,
|
||||
highPriorityTransmit: form.highPriorityTransmit,
|
||||
optics: form.optics,
|
||||
rsa: form.rsa
|
||||
});
|
||||
setDraftPolygon([]);
|
||||
setDrawing(false);
|
||||
setPanelMode("none");
|
||||
setNotice(`Заявка «${form.name}» создана.`);
|
||||
void loadRequests(requestsState.fromValue, requestsState.toValue);
|
||||
} catch (error) {
|
||||
setCreateError(error instanceof Error ? error.message : "Не удалось создать заявку.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
},
|
||||
[draftPolygon, loadRequests, requestsState.fromValue, requestsState.toValue]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (requestId: string) => {
|
||||
setDeleting(true);
|
||||
setDeleteError(undefined);
|
||||
try {
|
||||
await deleteRequest(requestId);
|
||||
closeAll();
|
||||
setPanelMode("none");
|
||||
setNotice("Заявка удалена.");
|
||||
void loadRequests(requestsState.fromValue, requestsState.toValue);
|
||||
} catch (error) {
|
||||
setDeleteError(error instanceof Error ? error.message : "Не удалось удалить заявку.");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
},
|
||||
[closeAll, loadRequests, requestsState.fromValue, requestsState.toValue]
|
||||
);
|
||||
|
||||
const fromMs = parseLocal(requestsState.fromValue);
|
||||
const toMs = parseLocal(requestsState.toValue);
|
||||
const rangeInvalid = !Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs;
|
||||
|
||||
const hasPanel = panelMode !== "none";
|
||||
|
||||
return (
|
||||
<main className="tgu-map-workspace">
|
||||
<div className="tgu-map-toolbar">
|
||||
<div>
|
||||
<div className="tgu-map-toolbar__title">Заявки</div>
|
||||
<div className="tgu-map-toolbar__subtitle">
|
||||
{requestsState.loaded ? `Загружено: ${requestsState.items.length}` : "Заявки не загружены"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tgu-map-toolbar__toggles">
|
||||
<button className="is-active" type="button" disabled>
|
||||
Заявки на съёмку
|
||||
</button>
|
||||
<button className={showStations ? "is-active" : ""} type="button" onClick={() => setShowStations((v) => !v)}>
|
||||
Станции
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="tgu-button tgu-button--primary"
|
||||
type="button"
|
||||
onClick={startCreate}
|
||||
disabled={panelMode === "create"}
|
||||
>
|
||||
+ Создать заявку
|
||||
</button>
|
||||
<button className="tgu-button" type="button" onClick={() => setRedrawToken((v) => v + 1)}>
|
||||
Перерисовать
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`tgu-map-content${hasPanel ? " has-side-panel" : ""}`}>
|
||||
<aside className="tgu-map-panel">
|
||||
<div className="tgu-panel-heading">
|
||||
<span>Заявки за интервал</span>
|
||||
</div>
|
||||
<div className="tgu-map-panel__section">
|
||||
<div className="tgu-map-requests-filter">
|
||||
<label className="tgu-field">
|
||||
<span>С</span>
|
||||
<DateTime24Field
|
||||
value={requestsState.fromValue}
|
||||
onChange={(value) => setRequestsState((cur) => ({ ...cur, fromValue: value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="tgu-field">
|
||||
<span>По</span>
|
||||
<DateTime24Field
|
||||
value={requestsState.toValue}
|
||||
onChange={(value) => setRequestsState((cur) => ({ ...cur, toValue: value }))}
|
||||
/>
|
||||
</label>
|
||||
<div className="tgu-map-requests-filter__actions">
|
||||
<button
|
||||
className="tgu-button tgu-button--primary tgu-button--sm"
|
||||
type="button"
|
||||
onClick={applyRange}
|
||||
disabled={requestsState.loading || rangeInvalid}
|
||||
>
|
||||
{requestsState.loading ? "Загрузка..." : "Загрузить"}
|
||||
</button>
|
||||
</div>
|
||||
{requestsState.error && (
|
||||
<div className="tgu-alert tgu-alert--danger tgu-alert--compact">{requestsState.error}</div>
|
||||
)}
|
||||
{requestsState.loading && requestsState.totalItems > 0 && (
|
||||
<div className="tgu-map-requests-filter__status">
|
||||
Загрузка: {requestsState.loadedCount} / {requestsState.totalItems}...
|
||||
</div>
|
||||
)}
|
||||
{requestsState.loaded && !requestsState.error && (
|
||||
<div className="tgu-map-requests-filter__status">
|
||||
{requestsState.items.length === 0
|
||||
? "Заявок не найдено."
|
||||
: `Загружено: ${requestsState.items.length} из ${requestsState.totalItems}`}
|
||||
{requestsState.truncated && (
|
||||
<span className="tgu-map-requests-filter__truncated"> (показаны первые 5000 — уточните интервал)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="tgu-map-requests-legend">
|
||||
<span><i className="req-optics" />OPTICS</span>
|
||||
<span><i className="req-rsa" />RSA</span>
|
||||
<span><i className="req-combined" />COMBINED</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{notice && (
|
||||
<div className="tgu-map-panel__section">
|
||||
<div className="tgu-alert tgu-alert--success tgu-alert--compact">{notice}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="tgu-map-panel__section">
|
||||
<div className="tgu-map-panel__label">Создание заявки</div>
|
||||
<div className="tgu-map-panel__value">
|
||||
Нажмите «+ Создать заявку», затем нарисуйте полигон на карте кликами и заполните параметры справа.
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="tgu-map-stage">
|
||||
{invalidRange && <div className="tgu-map-overlay">Некорректный интервал: from должен быть меньше to.</div>}
|
||||
{panelMode === "create" && drawing && (
|
||||
<div className="tgu-map-overlay tgu-map-overlay--hint">
|
||||
Кликайте по карте, чтобы добавить вершины полигона. Нажмите «Завершить» (≥3 вершин).
|
||||
</div>
|
||||
)}
|
||||
<Tgu2DMapView
|
||||
scene={scene}
|
||||
layers={layers}
|
||||
redrawToken={redrawToken}
|
||||
onObjectSelect={handleObjectSelect}
|
||||
onRequestsAtPoint={handleRequestsAtPoint}
|
||||
hoveredRequestPolygonId={hoveredRequestPolygonId}
|
||||
selectedRequestPolygonId={selectedRequestPolygonId}
|
||||
drawMode={panelMode === "create"}
|
||||
draftPolygon={draftPolygon}
|
||||
onDrawAddPoint={handleDrawAddPoint}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{panelMode === "details" && (
|
||||
<RequestDetailsPanel
|
||||
requests={requestsAtPoint}
|
||||
selectedPolygonId={selectedRequestPolygonId}
|
||||
deleting={deleting}
|
||||
deleteError={deleteError}
|
||||
onHover={setHoveredRequestPolygonId}
|
||||
onSelect={setSelectedRequestPolygonId}
|
||||
onDelete={handleDelete}
|
||||
onClose={() => {
|
||||
closeAll();
|
||||
setPanelMode("none");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{panelMode === "create" && (
|
||||
<RequestCreatePanel
|
||||
vertexCount={draftPolygon.length}
|
||||
drawing={drawing}
|
||||
defaultBegin={requestsState.fromValue}
|
||||
defaultEnd={requestsState.toValue}
|
||||
submitting={creating}
|
||||
error={createError}
|
||||
onUndoPoint={() => setDraftPolygon((cur) => cur.slice(0, -1))}
|
||||
onClear={() => setDraftPolygon([])}
|
||||
onFinishDrawing={() => setDrawing(false)}
|
||||
onRestartDrawing={() => {
|
||||
setDraftPolygon([]);
|
||||
setDrawing(true);
|
||||
}}
|
||||
onSubmit={submitCreate}
|
||||
onCancel={cancelCreate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { polygonToWkt } from "./requestGeometry";
|
||||
|
||||
describe("polygonToWkt", () => {
|
||||
it("формирует WKT POLYGON с замыканием кольца", () => {
|
||||
const wkt = polygonToWkt([
|
||||
{ lon: 10, lat: 20 },
|
||||
{ lon: 30, lat: 20 },
|
||||
{ lon: 30, lat: 40 }
|
||||
]);
|
||||
expect(wkt).toBe("POLYGON((10 20, 30 20, 30 40, 10 20))");
|
||||
});
|
||||
|
||||
it("не дублирует замыкающую точку, если кольцо уже замкнуто", () => {
|
||||
const wkt = polygonToWkt([
|
||||
{ lon: 0, lat: 0 },
|
||||
{ lon: 5, lat: 0 },
|
||||
{ lon: 5, lat: 5 },
|
||||
{ lon: 0, lat: 0 }
|
||||
]);
|
||||
expect(wkt).toBe("POLYGON((0 0, 5 0, 5 5, 0 0))");
|
||||
});
|
||||
|
||||
it("бросает ошибку при менее чем 3 вершинах", () => {
|
||||
expect(() => polygonToWkt([{ lon: 0, lat: 0 }, { lon: 1, lat: 1 }])).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { GeoPoint } from "../tgu-map-2d/model/mapTypes";
|
||||
|
||||
/**
|
||||
* Преобразует набор вершин полигона в WKT POLYGON.
|
||||
* Порядок координат WKT — "lon lat". Кольцо замыкается первой вершиной,
|
||||
* если оно ещё не замкнуто. Требуется минимум 3 вершины.
|
||||
*/
|
||||
export function polygonToWkt(points: GeoPoint[]): string {
|
||||
if (points.length < 3) {
|
||||
throw new Error("Полигон должен содержать минимум 3 вершины.");
|
||||
}
|
||||
|
||||
const ring = [...points];
|
||||
const first = ring[0];
|
||||
const last = ring[ring.length - 1];
|
||||
if (first.lon !== last.lon || first.lat !== last.lat) {
|
||||
ring.push(first);
|
||||
}
|
||||
|
||||
const coords = ring.map((p) => `${formatCoord(p.lon)} ${formatCoord(p.lat)}`).join(", ");
|
||||
return `POLYGON((${coords}))`;
|
||||
}
|
||||
|
||||
function formatCoord(value: number): string {
|
||||
// ограничиваем разрядность, убираем хвостовые нули
|
||||
return Number(value.toFixed(6)).toString();
|
||||
}
|
||||
Reference in New Issue
Block a user