2D-карта (станции, заявки) и вкладка «Заявки»

Новая вкладка «Заявки»: показ заявок за интервал на 2D-карте,
создание заявки рисованием полигона на карте с формой параметров
(Оптика/РСА) и удаление выбранной заявки (createRequest/deleteRequest,
RequestCreatePanel/RequestDetailsPanel, polygonToWkt). В Tgu2DMapView
добавлен изолированный режим рисования полигона.

2D-карта: панели информации о станции и о заявках, слой станций
(stationsApi), поддержка выбора объектов через спатиальный индекс.
Список слоёв упрощён — трасса/полоса/маркеры КА убраны из переключателя,
по умолчанию включены работы плана, станции и заявки.
This commit is contained in:
Дмитрий Соловьев
2026-06-01 22:17:50 +03:00
parent 715b5108d6
commit 0128c5569c
26 changed files with 1851 additions and 96 deletions
@@ -0,0 +1,355 @@
import { useState } from "react";
import type {
OpticsParams,
OpticsResultType,
Polarisation,
RsaParams,
RsaResultType
} from "../../model/requestTypes";
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 = new Date(begin).getTime();
const endMs = new Date(end).getTime();
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>
<input type="datetime-local" value={begin} onChange={(e) => setBegin(e.target.value)} />
</label>
<label className="tgu-requests-field">
<span>Конец</span>
<input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} />
</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;
}