Новый сервис pcp-tgu-ui-service: ТГУ-фронт на headless-auth nstart + платформенный CI/деплой

This commit is contained in:
Дмитрий Соловьев
2026-06-09 13:35:13 +03:00
parent 0b617594fd
commit 505bf1f31c
129 changed files with 21640 additions and 0 deletions
@@ -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();
}