2D-карта (станции, заявки) и вкладка «Заявки»
Новая вкладка «Заявки»: показ заявок за интервал на 2D-карте, создание заявки рисованием полигона на карте с формой параметров (Оптика/РСА) и удаление выбранной заявки (createRequest/deleteRequest, RequestCreatePanel/RequestDetailsPanel, polygonToWkt). В Tgu2DMapView добавлен изолированный режим рисования полигона. 2D-карта: панели информации о станции и о заявках, слой станций (stationsApi), поддержка выбора объектов через спатиальный индекс. Список слоёв упрощён — трасса/полоса/маркеры КА убраны из переключателя, по умолчанию включены работы плана, станции и заявки.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import type { RequestFilter, RequestMapItem } from "../model/requestTypes";
|
import type { CreateRequestPayload, CreateRequestResult, RequestFilter, RequestMapItem } from "../model/requestTypes";
|
||||||
|
|
||||||
const BASE = "/api/pcp-request/v1";
|
const BASE = "/api/pcp-request/v1";
|
||||||
const PAGE_SIZE = 500;
|
const PAGE_SIZE = 500;
|
||||||
@@ -60,3 +60,43 @@ export async function fetchRequestsForMap(
|
|||||||
truncated: first.totalPages > MAX_PAGES
|
truncated: first.totalPages > MAX_PAGES
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ErrorDetail = { field?: string; message?: string };
|
||||||
|
type ErrorBody = { code?: string; message?: string; details?: ErrorDetail[] };
|
||||||
|
|
||||||
|
async function readErrorMessage(response: Response, fallback: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as ErrorBody;
|
||||||
|
const details = body.details?.map((d) => (d.field ? `${d.field}: ${d.message}` : d.message)).filter(Boolean);
|
||||||
|
const detailText = details && details.length > 0 ? ` (${details.join("; ")})` : "";
|
||||||
|
if (body.message) return `${body.message}${detailText}`;
|
||||||
|
} catch {
|
||||||
|
// тело ответа не JSON — используем fallback
|
||||||
|
}
|
||||||
|
return `${fallback}: ${response.status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRequest(payload: CreateRequestPayload): Promise<CreateRequestResult> {
|
||||||
|
const response = await fetch(`${BASE}/requests`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response, "Не удалось создать заявку"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<CreateRequestResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRequest(id: string): Promise<void> {
|
||||||
|
const response = await fetch(`${BASE}/requests/${encodeURIComponent(id)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Accept: "application/json" }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response, "Не удалось удалить заявку"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export type StationDto = {
|
||||||
|
id: string | null;
|
||||||
|
number: number;
|
||||||
|
name: string;
|
||||||
|
position: { lat: number; long: number; height: number };
|
||||||
|
elevationMin: number;
|
||||||
|
elevationMax: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchStations(): Promise<StationDto[]> {
|
||||||
|
const response = await fetch("/api/stations", {
|
||||||
|
headers: { Accept: "application/json" }
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Ошибка загрузки станций: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json() as Promise<StationDto[]>;
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import type { StationDto } from "../../api/stationsApi";
|
||||||
import type { RequestMapItem } from "../../model/requestTypes";
|
import type { RequestMapItem } from "../../model/requestTypes";
|
||||||
import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
|
import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
|
||||||
import { parseWktPolygon } from "./geometry/wktParser";
|
import { parseWktPolygon } from "./geometry/wktParser";
|
||||||
import type { Tgu2DMapLayersState } from "./model/mapLayerTypes";
|
import type { Tgu2DMapLayersState } from "./model/mapLayerTypes";
|
||||||
import type { MapPolygon, Tgu2DMapScene } from "./model/mapTypes";
|
import type { MapMarker, MapPolygon, Tgu2DMapScene } from "./model/mapTypes";
|
||||||
|
|
||||||
export type Tgu2DMapLayerAvailability = Partial<Record<keyof Tgu2DMapLayersState, string>>;
|
export type Tgu2DMapLayerAvailability = Partial<Record<keyof Tgu2DMapLayersState, string>>;
|
||||||
|
|
||||||
@@ -12,17 +13,19 @@ export type Tgu2DMapSceneInput = {
|
|||||||
platforms: TguPlatformUi[];
|
platforms: TguPlatformUi[];
|
||||||
hiddenSpacecraftIds?: ReadonlySet<string>;
|
hiddenSpacecraftIds?: ReadonlySet<string>;
|
||||||
requestItems?: RequestMapItem[];
|
requestItems?: RequestMapItem[];
|
||||||
|
stationItems?: StationDto[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const UNAVAILABLE = "Данные слоя недоступны в текущем API.";
|
const UNAVAILABLE = "Данные слоя недоступны в текущем API.";
|
||||||
|
|
||||||
export function buildTgu2DMapScene(input: Tgu2DMapSceneInput): Tgu2DMapScene {
|
export function buildTgu2DMapScene(input: Tgu2DMapSceneInput): Tgu2DMapScene {
|
||||||
const requestPolygons = buildRequestPolygons(input.requestItems ?? []);
|
const requestPolygons = buildRequestPolygons(input.requestItems ?? []);
|
||||||
|
const stationMarkers = buildStationMarkers(input.stationItems ?? []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
polygons: requestPolygons,
|
polygons: requestPolygons,
|
||||||
lines: [],
|
lines: [],
|
||||||
markers: []
|
markers: stationMarkers
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +53,25 @@ function buildRequestPolygons(items: RequestMapItem[]): MapPolygon[] {
|
|||||||
return polygons;
|
return polygons;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLayerAvailability(layers: Tgu2DMapLayersState, scene: Tgu2DMapScene): Tgu2DMapLayerAvailability {
|
export function buildStationMarkers(items: StationDto[]): MapMarker[] {
|
||||||
|
return items.map((item, index) => ({
|
||||||
|
id: item.id ?? `station:${index}`,
|
||||||
|
layer: "stations" as const,
|
||||||
|
point: { lon: item.position.long, lat: item.position.lat },
|
||||||
|
label: item.name,
|
||||||
|
zIndex: index,
|
||||||
|
stationNumber: item.number,
|
||||||
|
altitude: item.position.height,
|
||||||
|
elevationMin: item.elevationMin,
|
||||||
|
elevationMax: item.elevationMax
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLayerAvailability(
|
||||||
|
layers: Tgu2DMapLayersState,
|
||||||
|
scene: Tgu2DMapScene,
|
||||||
|
stationsFetchFailed?: boolean
|
||||||
|
): Tgu2DMapLayerAvailability {
|
||||||
const availability: Tgu2DMapLayerAvailability = {};
|
const availability: Tgu2DMapLayerAvailability = {};
|
||||||
if (layers.tracks && scene.lines.filter((line) => line.layer === "tracks").length === 0) {
|
if (layers.tracks && scene.lines.filter((line) => line.layer === "tracks").length === 0) {
|
||||||
availability.tracks = UNAVAILABLE;
|
availability.tracks = UNAVAILABLE;
|
||||||
@@ -61,7 +82,7 @@ export function getLayerAvailability(layers: Tgu2DMapLayersState, scene: Tgu2DMa
|
|||||||
if (layers.planWorks && scene.polygons.filter((polygon) => polygon.layer === "planWorks").length === 0) {
|
if (layers.planWorks && scene.polygons.filter((polygon) => polygon.layer === "planWorks").length === 0) {
|
||||||
availability.planWorks = UNAVAILABLE;
|
availability.planWorks = UNAVAILABLE;
|
||||||
}
|
}
|
||||||
if (layers.stations && scene.markers.filter((marker) => marker.layer === "stations").length === 0) {
|
if (layers.stations && stationsFetchFailed) {
|
||||||
availability.stations = UNAVAILABLE;
|
availability.stations = UNAVAILABLE;
|
||||||
}
|
}
|
||||||
if (layers.spacecraftMarkers && scene.markers.filter((marker) => marker.layer === "spacecraftMarkers").length === 0) {
|
if (layers.spacecraftMarkers && scene.markers.filter((marker) => marker.layer === "spacecraftMarkers").length === 0) {
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import type { RequestMapItem } from "../../model/requestTypes";
|
||||||
|
import type { MapPolygon } from "./model/mapTypes";
|
||||||
|
|
||||||
|
export type RequestAtPoint = { polygon: MapPolygon; item: RequestMapItem };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
requests: RequestAtPoint[];
|
||||||
|
selectedPolygonId?: string;
|
||||||
|
onHover: (polygonId: string | undefined) => void;
|
||||||
|
onSelect: (polygonId: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Tgu2DMapRequestsPanel({ requests, selectedPolygonId, onHover, onSelect, onClose }: Props) {
|
||||||
|
const selectedEntry = selectedPolygonId ? requests.find((r) => r.polygon.id === selectedPolygonId) : undefined;
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div className="tgu-map-requests-list">
|
||||||
|
{requests.map(({ polygon, item }) => (
|
||||||
|
<button
|
||||||
|
key={polygon.id}
|
||||||
|
className={`tgu-map-request-row ${polygon.id === selectedPolygonId ? "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">
|
||||||
|
{formatDateRange(item.beginDateTime, item.endDateTime)}
|
||||||
|
{" · "}
|
||||||
|
{item.surveyType}
|
||||||
|
{" · "}
|
||||||
|
{item.status}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedEntry && (
|
||||||
|
<div className="tgu-map-request-details">
|
||||||
|
<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>{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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string): string {
|
||||||
|
return new Date(value).toLocaleString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateRange(begin: string, end: string): string {
|
||||||
|
const b = new Date(begin);
|
||||||
|
const e = new Date(end);
|
||||||
|
const sameDay =
|
||||||
|
b.getFullYear() === e.getFullYear() &&
|
||||||
|
b.getMonth() === e.getMonth() &&
|
||||||
|
b.getDate() === e.getDate();
|
||||||
|
const dateOpts: Intl.DateTimeFormatOptions = { day: "2-digit", month: "2-digit" };
|
||||||
|
const timeOpts: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit" };
|
||||||
|
const beginStr = `${b.toLocaleDateString("ru-RU", dateOpts)} ${b.toLocaleTimeString("ru-RU", timeOpts)}`;
|
||||||
|
const endStr = sameDay
|
||||||
|
? b.toLocaleTimeString("ru-RU", timeOpts)
|
||||||
|
: `${e.toLocaleDateString("ru-RU", dateOpts)} ${e.toLocaleTimeString("ru-RU", timeOpts)}`;
|
||||||
|
return `${beginStr} – ${endStr}`;
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ export function Tgu2DMapSidebar({
|
|||||||
return (
|
return (
|
||||||
<aside className="tgu-map-panel">
|
<aside className="tgu-map-panel">
|
||||||
<div className="tgu-panel-heading">
|
<div className="tgu-panel-heading">
|
||||||
<span>Карта 2D</span>
|
<span>Карта</span>
|
||||||
{hiddenCount > 0 && (
|
{hiddenCount > 0 && (
|
||||||
<button className="tgu-link-button" type="button" onClick={onShowAll}>
|
<button className="tgu-link-button" type="button" onClick={onShowAll}>
|
||||||
Показать все ({hiddenCount} скрыто)
|
Показать все ({hiddenCount} скрыто)
|
||||||
@@ -202,19 +202,7 @@ export function Tgu2DMapSidebar({
|
|||||||
{filteredPlatforms.length === 0 && <div className="tgu-empty tgu-empty--compact">Нет КА по текущему поиску.</div>}
|
{filteredPlatforms.length === 0 && <div className="tgu-empty tgu-empty--compact">Нет КА по текущему поиску.</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tgu-map-panel__section">
|
|
||||||
<div className="tgu-map-panel__label">Статусы планов</div>
|
|
||||||
<div className="tgu-map-status-legend">
|
|
||||||
<span><i className="status-accepted" />ACCEPTED</span>
|
|
||||||
<span><i className="status-rejected" />REJECTED</span>
|
|
||||||
<span><i className="status-waiting" />WAITING_DECISION</span>
|
|
||||||
<span><i className="status-planned" />PLANNED</span>
|
|
||||||
<span><i className="status-issuing" />ISSUING</span>
|
|
||||||
<span><i className="status-expired" />EXPIRED</span>
|
|
||||||
<span><i className="status-superseded" />SUPERSEDED</span>
|
|
||||||
<span><i className="status-ambiguous" />START_AMBIGUOUS</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
|
||||||
|
|
||||||
|
type StationSelection = Extract<Tgu2DMapSelection, { type: "marker" }> & { layer: "stations" };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
selection: StationSelection;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Tgu2DMapStationPanel({ selection, onClose }: Props) {
|
||||||
|
return (
|
||||||
|
<aside className="tgu-map-requests-panel">
|
||||||
|
<div className="tgu-panel-heading">
|
||||||
|
<span>Станция</span>
|
||||||
|
<button className="tgu-link-button" type="button" onClick={onClose}>
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="tgu-map-request-details" style={{ flex: 1 }}>
|
||||||
|
<div className="tgu-map-panel__label">{selection.name}</div>
|
||||||
|
<dl className="tgu-map-request-details__grid">
|
||||||
|
<dt>Номер</dt>
|
||||||
|
<dd className="mono">{selection.stationNumber ?? "—"}</dd>
|
||||||
|
|
||||||
|
<dt>Широта</dt>
|
||||||
|
<dd className="mono">{selection.lat != null ? `${selection.lat.toFixed(4)}°` : "—"}</dd>
|
||||||
|
|
||||||
|
<dt>Долгота</dt>
|
||||||
|
<dd className="mono">{selection.lon != null ? `${selection.lon.toFixed(4)}°` : "—"}</dd>
|
||||||
|
|
||||||
|
<dt>Высота, м</dt>
|
||||||
|
<dd className="mono">{selection.altitude != null ? selection.altitude.toFixed(1) : "—"}</dd>
|
||||||
|
|
||||||
|
<dt>Угол места мин, °</dt>
|
||||||
|
<dd className="mono">{selection.elevationMin != null ? selection.elevationMin.toFixed(1) : "—"}</dd>
|
||||||
|
|
||||||
|
<dt>Угол места макс, °</dt>
|
||||||
|
<dd className="mono">{selection.elevationMax != null ? selection.elevationMax.toFixed(1) : "—"}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { fetchRequestsForMap } from "../../api/requestApi";
|
import { fetchRequestsForMap } from "../../api/requestApi";
|
||||||
|
import { fetchStations, type StationDto } from "../../api/stationsApi";
|
||||||
import type { RequestMapItem } from "../../model/requestTypes";
|
import type { RequestMapItem } from "../../model/requestTypes";
|
||||||
import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
|
import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
|
||||||
import { platformLabel } from "../tgu-planning/tguTimelineMapper";
|
import { platformLabel } from "../tgu-planning/tguTimelineMapper";
|
||||||
import { DEFAULT_TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes";
|
import { DEFAULT_TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes";
|
||||||
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
|
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
|
||||||
|
import type { MapPolygon } from "./model/mapTypes";
|
||||||
import { buildTgu2DMapScene, getLayerAvailability } from "./Tgu2DMapLayers";
|
import { buildTgu2DMapScene, getLayerAvailability } from "./Tgu2DMapLayers";
|
||||||
import { Tgu2DMapDetails } from "./Tgu2DMapDetails";
|
import { Tgu2DMapDetails } from "./Tgu2DMapDetails";
|
||||||
|
import { Tgu2DMapRequestsPanel, type RequestAtPoint } from "./Tgu2DMapRequestsPanel";
|
||||||
import { Tgu2DMapSidebar } from "./Tgu2DMapSidebar";
|
import { Tgu2DMapSidebar } from "./Tgu2DMapSidebar";
|
||||||
|
import { Tgu2DMapStationPanel } from "./Tgu2DMapStationPanel";
|
||||||
import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar";
|
import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar";
|
||||||
import { Tgu2DMapView } from "./Tgu2DMapView";
|
import { Tgu2DMapView } from "./Tgu2DMapView";
|
||||||
import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval";
|
import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval";
|
||||||
@@ -56,6 +60,18 @@ export function Tgu2DMapTab({
|
|||||||
const [selectedObject, setSelectedObject] = useState<Tgu2DMapSelection>();
|
const [selectedObject, setSelectedObject] = useState<Tgu2DMapSelection>();
|
||||||
const [hiddenIds, setHiddenIds] = useState<ReadonlySet<string>>(new Set());
|
const [hiddenIds, setHiddenIds] = useState<ReadonlySet<string>>(new Set());
|
||||||
const [requestsState, setRequestsState] = useState<RequestsState>(() => buildInitialRequestsState(range));
|
const [requestsState, setRequestsState] = useState<RequestsState>(() => buildInitialRequestsState(range));
|
||||||
|
const [stations, setStations] = useState<StationDto[]>([]);
|
||||||
|
const [stationsFetchFailed, setStationsFetchFailed] = useState(false);
|
||||||
|
const [requestsAtPoint, setRequestsAtPoint] = useState<RequestAtPoint[]>([]);
|
||||||
|
const [selectedRequestPolygonId, setSelectedRequestPolygonId] = useState<string | undefined>();
|
||||||
|
const [hoveredRequestPolygonId, setHoveredRequestPolygonId] = useState<string | undefined>();
|
||||||
|
const [stationPanel, setStationPanel] = useState<(Tgu2DMapSelection & { type: "marker"; layer: "stations" }) | undefined>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStations()
|
||||||
|
.then((items) => { setStations(items); setStationsFetchFailed(false); })
|
||||||
|
.catch(() => setStationsFetchFailed(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const mapInterval = getTgu2DMapIntervalState(range, selectedPlan);
|
const mapInterval = getTgu2DMapIntervalState(range, selectedPlan);
|
||||||
const mapInvalidRange = invalidRange || mapInterval.tooLarge;
|
const mapInvalidRange = invalidRange || mapInterval.tooLarge;
|
||||||
@@ -146,11 +162,15 @@ export function Tgu2DMapTab({
|
|||||||
selectedPlan,
|
selectedPlan,
|
||||||
platforms,
|
platforms,
|
||||||
hiddenSpacecraftIds: hiddenIds,
|
hiddenSpacecraftIds: hiddenIds,
|
||||||
requestItems: layers.requests ? requestsState.items : []
|
requestItems: layers.requests ? requestsState.items : [],
|
||||||
|
stationItems: layers.stations ? stations : []
|
||||||
}),
|
}),
|
||||||
[mapInterval.range, platforms, selectedPlan, hiddenIds, requestsState.items, layers.requests]
|
[mapInterval.range, platforms, selectedPlan, hiddenIds, requestsState.items, layers.requests, stations, layers.stations]
|
||||||
|
);
|
||||||
|
const layerAvailability = useMemo(
|
||||||
|
() => getLayerAvailability(layers, scene, stationsFetchFailed),
|
||||||
|
[layers, scene, stationsFetchFailed]
|
||||||
);
|
);
|
||||||
const layerAvailability = useMemo(() => getLayerAvailability(layers, scene), [layers, scene]);
|
|
||||||
|
|
||||||
const overlayMessage = invalidRange
|
const overlayMessage = invalidRange
|
||||||
? "Некорректный интервал: from должен быть меньше to."
|
? "Некорректный интервал: from должен быть меньше to."
|
||||||
@@ -162,6 +182,49 @@ export function Tgu2DMapTab({
|
|||||||
setLayers((current) => ({ ...current, [key]: !current[key] }));
|
setLayers((current) => ({ ...current, [key]: !current[key] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleObjectSelect = useCallback((selection?: Tgu2DMapSelection) => {
|
||||||
|
if (selection?.type === "marker" && selection.layer === "stations") {
|
||||||
|
setStationPanel(selection as Tgu2DMapSelection & { type: "marker"; layer: "stations" });
|
||||||
|
setRequestsAtPoint([]);
|
||||||
|
setSelectedRequestPolygonId(undefined);
|
||||||
|
setHoveredRequestPolygonId(undefined);
|
||||||
|
setSelectedObject(undefined);
|
||||||
|
} else {
|
||||||
|
setStationPanel(undefined);
|
||||||
|
setSelectedObject(selection);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRequestsAtPoint = useCallback(
|
||||||
|
(polygons: MapPolygon[]) => {
|
||||||
|
if (polygons.length === 0) {
|
||||||
|
setRequestsAtPoint([]);
|
||||||
|
setSelectedRequestPolygonId(undefined);
|
||||||
|
setHoveredRequestPolygonId(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const joined = polygons.flatMap((polygon) => {
|
||||||
|
const item = requestsState.items.find((r) => r.id === polygon.requestId);
|
||||||
|
return item ? [{ polygon, item }] : [];
|
||||||
|
});
|
||||||
|
setRequestsAtPoint(joined);
|
||||||
|
setSelectedRequestPolygonId(undefined);
|
||||||
|
setHoveredRequestPolygonId(undefined);
|
||||||
|
setStationPanel(undefined);
|
||||||
|
},
|
||||||
|
[requestsState.items]
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeRequestsPanel = useCallback(() => {
|
||||||
|
setRequestsAtPoint([]);
|
||||||
|
setSelectedRequestPolygonId(undefined);
|
||||||
|
setHoveredRequestPolygonId(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeStationPanel = useCallback(() => {
|
||||||
|
setStationPanel(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="tgu-map-workspace">
|
<main className="tgu-map-workspace">
|
||||||
<Tgu2DMapToolbar
|
<Tgu2DMapToolbar
|
||||||
@@ -172,7 +235,7 @@ export function Tgu2DMapTab({
|
|||||||
onToggleLayer={toggleLayer}
|
onToggleLayer={toggleLayer}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="tgu-map-content">
|
<div className={`tgu-map-content${(requestsAtPoint.length > 0 || stationPanel != null) ? " has-requests-panel" : ""}`}>
|
||||||
<Tgu2DMapSidebar
|
<Tgu2DMapSidebar
|
||||||
platforms={platforms}
|
platforms={platforms}
|
||||||
selectedPlanId={selectedPlan?.planId}
|
selectedPlanId={selectedPlan?.planId}
|
||||||
@@ -196,9 +259,24 @@ export function Tgu2DMapTab({
|
|||||||
scene={mapInvalidRange ? { polygons: [], lines: [], markers: [] } : scene}
|
scene={mapInvalidRange ? { polygons: [], lines: [], markers: [] } : scene}
|
||||||
layers={layers}
|
layers={layers}
|
||||||
redrawToken={redrawToken}
|
redrawToken={redrawToken}
|
||||||
onObjectSelect={setSelectedObject}
|
onObjectSelect={handleObjectSelect}
|
||||||
|
onRequestsAtPoint={handleRequestsAtPoint}
|
||||||
|
hoveredRequestPolygonId={hoveredRequestPolygonId}
|
||||||
|
selectedRequestPolygonId={selectedRequestPolygonId}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
{requestsAtPoint.length > 0 && (
|
||||||
|
<Tgu2DMapRequestsPanel
|
||||||
|
requests={requestsAtPoint}
|
||||||
|
selectedPolygonId={selectedRequestPolygonId}
|
||||||
|
onHover={setHoveredRequestPolygonId}
|
||||||
|
onSelect={setSelectedRequestPolygonId}
|
||||||
|
onClose={closeRequestsPanel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{stationPanel != null && (
|
||||||
|
<Tgu2DMapStationPanel selection={stationPanel} onClose={closeStationPanel} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tgu-map-errors">
|
<div className="tgu-map-errors">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function Tgu2DMapToolbar({
|
|||||||
return (
|
return (
|
||||||
<div className="tgu-map-toolbar">
|
<div className="tgu-map-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<div className="tgu-map-toolbar__title">Карта 2D</div>
|
<div className="tgu-map-toolbar__title">Карта</div>
|
||||||
<div className="tgu-map-toolbar__subtitle">
|
<div className="tgu-map-toolbar__subtitle">
|
||||||
{satelliteLabel} · {intervalLabel}
|
{satelliteLabel} · {intervalLabel}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { drawSwath } from "./canvas/drawSwath";
|
|||||||
import { drawTiles } from "./canvas/drawTiles";
|
import { drawTiles } from "./canvas/drawTiles";
|
||||||
import { drawTracks } from "./canvas/drawTracks";
|
import { drawTracks } from "./canvas/drawTracks";
|
||||||
import { createMapProjection } from "./geometry/mapProjection";
|
import { createMapProjection } from "./geometry/mapProjection";
|
||||||
import { createPolygonSpatialIndex, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex";
|
import { createPolygonSpatialIndex, hitTestAllPolygons, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex";
|
||||||
import type { Tgu2DMapLayersState } from "./model/mapLayerTypes";
|
import type { Tgu2DMapLayersState } from "./model/mapLayerTypes";
|
||||||
import type { MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes";
|
import type { GeoPoint, MapMarker, MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes";
|
||||||
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
|
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
|
||||||
|
|
||||||
type Tgu2DMapViewProps = {
|
type Tgu2DMapViewProps = {
|
||||||
@@ -17,12 +17,18 @@ type Tgu2DMapViewProps = {
|
|||||||
layers: Tgu2DMapLayersState;
|
layers: Tgu2DMapLayersState;
|
||||||
redrawToken: number;
|
redrawToken: number;
|
||||||
onObjectSelect: (selection?: Tgu2DMapSelection) => void;
|
onObjectSelect: (selection?: Tgu2DMapSelection) => void;
|
||||||
|
onRequestsAtPoint?: (polygons: MapPolygon[]) => void;
|
||||||
|
hoveredRequestPolygonId?: string;
|
||||||
|
selectedRequestPolygonId?: string;
|
||||||
pickMode?: boolean;
|
pickMode?: boolean;
|
||||||
onPickPoint?: (point: { lat: number; lon: number }) => void;
|
onPickPoint?: (point: { lat: number; lon: number }) => void;
|
||||||
targets?: Tgu2DMapTarget[];
|
targets?: Tgu2DMapTarget[];
|
||||||
pickTarget?: Tgu2DMapTarget;
|
pickTarget?: Tgu2DMapTarget;
|
||||||
selectedTargetId?: string;
|
selectedTargetId?: string;
|
||||||
onTargetSelect?: (id: string) => void;
|
onTargetSelect?: (id: string) => void;
|
||||||
|
drawMode?: boolean;
|
||||||
|
draftPolygon?: GeoPoint[];
|
||||||
|
onDrawAddPoint?: (point: GeoPoint) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Tgu2DMapTarget = {
|
export type Tgu2DMapTarget = {
|
||||||
@@ -59,17 +65,26 @@ export function Tgu2DMapView({
|
|||||||
layers,
|
layers,
|
||||||
redrawToken,
|
redrawToken,
|
||||||
onObjectSelect,
|
onObjectSelect,
|
||||||
|
onRequestsAtPoint,
|
||||||
|
hoveredRequestPolygonId,
|
||||||
|
selectedRequestPolygonId,
|
||||||
pickMode = false,
|
pickMode = false,
|
||||||
onPickPoint,
|
onPickPoint,
|
||||||
targets = [],
|
targets = [],
|
||||||
pickTarget,
|
pickTarget,
|
||||||
selectedTargetId,
|
selectedTargetId,
|
||||||
onTargetSelect
|
onTargetSelect,
|
||||||
|
drawMode = false,
|
||||||
|
draftPolygon = [],
|
||||||
|
onDrawAddPoint
|
||||||
}: Tgu2DMapViewProps) {
|
}: Tgu2DMapViewProps) {
|
||||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const dragRef = useRef<DragState | undefined>(undefined);
|
const dragRef = useRef<DragState | undefined>(undefined);
|
||||||
|
const hasDraggedRef = useRef(false);
|
||||||
const hoverRef = useRef<MapPolygon | undefined>(undefined);
|
const hoverRef = useRef<MapPolygon | undefined>(undefined);
|
||||||
|
const hoverMarkerRef = useRef<MapMarker | undefined>(undefined);
|
||||||
|
const selectedMarkerRef = useRef<MapMarker | undefined>(undefined);
|
||||||
const [size, setSize] = useState<MapSize>({ width: 1000, height: 560 });
|
const [size, setSize] = useState<MapSize>({ width: 1000, height: 560 });
|
||||||
const [view, setView] = useState<MapViewState>(INITIAL_VIEW);
|
const [view, setView] = useState<MapViewState>(INITIAL_VIEW);
|
||||||
const [tileVersion, setTileVersion] = useState(0);
|
const [tileVersion, setTileVersion] = useState(0);
|
||||||
@@ -81,6 +96,10 @@ export function Tgu2DMapView({
|
|||||||
[scene.polygons, layers]
|
[scene.polygons, layers]
|
||||||
);
|
);
|
||||||
const polygonIndex = useMemo(() => createPolygonSpatialIndex(activePolygons), [activePolygons]);
|
const polygonIndex = useMemo(() => createPolygonSpatialIndex(activePolygons), [activePolygons]);
|
||||||
|
const activeMarkers = useMemo(
|
||||||
|
() => scene.markers.filter((marker) => layers[marker.layer]),
|
||||||
|
[scene.markers, layers]
|
||||||
|
);
|
||||||
const projection = useMemo(() => createMapProjection(view, size), [view, size]);
|
const projection = useMemo(() => createMapProjection(view, size), [view, size]);
|
||||||
const visiblePolygons = useMemo(() => {
|
const visiblePolygons = useMemo(() => {
|
||||||
// When the canvas is wider than the world tile, the entire globe is visible —
|
// When the canvas is wider than the world tile, the entire globe is visible —
|
||||||
@@ -170,11 +189,15 @@ export function Tgu2DMapView({
|
|||||||
const updateHover = useCallback(
|
const updateHover = useCallback(
|
||||||
(event: React.PointerEvent) => {
|
(event: React.PointerEvent) => {
|
||||||
const screenPoint = screenPointFromEvent(event);
|
const screenPoint = screenPointFromEvent(event);
|
||||||
const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint));
|
const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
|
||||||
if (polygon?.id === hoverRef.current?.id) return;
|
const polygon = marker ? undefined : hitTestPolygons(polygonIndex, projection.unproject(screenPoint));
|
||||||
|
|
||||||
|
if (marker?.id === hoverMarkerRef.current?.id && polygon?.id === hoverRef.current?.id) return;
|
||||||
|
|
||||||
|
hoverMarkerRef.current = marker;
|
||||||
hoverRef.current = polygon;
|
hoverRef.current = polygon;
|
||||||
if (!polygon) {
|
|
||||||
|
if (!marker && !polygon) {
|
||||||
setTooltip(undefined);
|
setTooltip(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -182,15 +205,16 @@ export function Tgu2DMapView({
|
|||||||
setTooltip({
|
setTooltip({
|
||||||
x: screenPoint.x + 12,
|
x: screenPoint.x + 12,
|
||||||
y: screenPoint.y + 12,
|
y: screenPoint.y + 12,
|
||||||
title: polygon.name,
|
title: marker ? marker.label : polygon!.name,
|
||||||
subtitle: layerTitle(polygon.layer)
|
subtitle: marker ? "Станция" : layerTitle(polygon!.layer)
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[polygonIndex, projection, screenPointFromEvent]
|
[activeMarkers, polygonIndex, projection, screenPointFromEvent]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPointerDown = (event: React.PointerEvent) => {
|
const onPointerDown = (event: React.PointerEvent) => {
|
||||||
const screenPoint = screenPointFromEvent(event);
|
const screenPoint = screenPointFromEvent(event);
|
||||||
|
hasDraggedRef.current = false;
|
||||||
dragRef.current = {
|
dragRef.current = {
|
||||||
startX: screenPoint.x,
|
startX: screenPoint.x,
|
||||||
startY: screenPoint.y,
|
startY: screenPoint.y,
|
||||||
@@ -199,6 +223,8 @@ export function Tgu2DMapView({
|
|||||||
wrapperRef.current?.setPointerCapture(event.pointerId);
|
wrapperRef.current?.setPointerCapture(event.pointerId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DRAG_THRESHOLD = 4;
|
||||||
|
|
||||||
const onPointerMove = (event: React.PointerEvent) => {
|
const onPointerMove = (event: React.PointerEvent) => {
|
||||||
const drag = dragRef.current;
|
const drag = dragRef.current;
|
||||||
if (!drag) {
|
if (!drag) {
|
||||||
@@ -209,6 +235,9 @@ export function Tgu2DMapView({
|
|||||||
const screenPoint = screenPointFromEvent(event);
|
const screenPoint = screenPointFromEvent(event);
|
||||||
const dx = screenPoint.x - drag.startX;
|
const dx = screenPoint.x - drag.startX;
|
||||||
const dy = screenPoint.y - drag.startY;
|
const dy = screenPoint.y - drag.startY;
|
||||||
|
if (!hasDraggedRef.current && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) {
|
||||||
|
hasDraggedRef.current = true;
|
||||||
|
}
|
||||||
const dragProjection = createMapProjection(
|
const dragProjection = createMapProjection(
|
||||||
{
|
{
|
||||||
centerLon: drag.centerAtStart.lon,
|
centerLon: drag.centerAtStart.lon,
|
||||||
@@ -231,15 +260,53 @@ export function Tgu2DMapView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onClick = (event: React.MouseEvent) => {
|
const onClick = (event: React.MouseEvent) => {
|
||||||
|
if (hasDraggedRef.current) return;
|
||||||
const screenPoint = screenPointFromEvent(event);
|
const screenPoint = screenPointFromEvent(event);
|
||||||
|
if (drawMode) {
|
||||||
|
onDrawAddPoint?.(projection.unproject(screenPoint));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (pickMode) {
|
if (pickMode) {
|
||||||
onPickPoint?.(projection.unproject(screenPoint));
|
onPickPoint?.(projection.unproject(screenPoint));
|
||||||
onObjectSelect(undefined);
|
onObjectSelect(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint));
|
const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
|
||||||
|
if (marker) {
|
||||||
|
selectedMarkerRef.current = marker;
|
||||||
|
selectedPolygonRef.current = undefined;
|
||||||
|
onRequestsAtPoint?.([]);
|
||||||
|
onObjectSelect({
|
||||||
|
type: "marker",
|
||||||
|
id: marker.id,
|
||||||
|
name: marker.label,
|
||||||
|
layer: marker.layer,
|
||||||
|
stationNumber: marker.stationNumber,
|
||||||
|
lat: marker.point.lat,
|
||||||
|
lon: marker.point.lon,
|
||||||
|
altitude: marker.altitude,
|
||||||
|
elevationMin: marker.elevationMin,
|
||||||
|
elevationMax: marker.elevationMax
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const geoPoint = projection.unproject(screenPoint);
|
||||||
|
const polygon = hitTestPolygons(polygonIndex, geoPoint);
|
||||||
|
|
||||||
|
if (polygon?.layer === "requests" && onRequestsAtPoint) {
|
||||||
|
const allRequests = hitTestAllPolygons(polygonIndex, geoPoint).filter((p) => p.layer === "requests");
|
||||||
|
onRequestsAtPoint(allRequests);
|
||||||
|
selectedPolygonRef.current = undefined;
|
||||||
|
selectedMarkerRef.current = undefined;
|
||||||
|
onObjectSelect(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onRequestsAtPoint?.([]);
|
||||||
selectedPolygonRef.current = polygon;
|
selectedPolygonRef.current = polygon;
|
||||||
|
selectedMarkerRef.current = undefined;
|
||||||
|
|
||||||
if (!polygon) {
|
if (!polygon) {
|
||||||
onObjectSelect(undefined);
|
onObjectSelect(undefined);
|
||||||
@@ -276,11 +343,21 @@ export function Tgu2DMapView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectedPolygon = selectedPolygonRef.current;
|
const selectedPolygon = selectedPolygonRef.current;
|
||||||
const overlayPolygons = uniquePolygons([selectedPolygon, hoverRef.current]);
|
const selectedMarker = selectedMarkerRef.current;
|
||||||
|
const externalSelectedPolygon = useMemo(
|
||||||
|
() => selectedRequestPolygonId ? activePolygons.find((p) => p.id === selectedRequestPolygonId) : undefined,
|
||||||
|
[selectedRequestPolygonId, activePolygons]
|
||||||
|
);
|
||||||
|
const externalHoveredPolygon = useMemo(
|
||||||
|
() => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined,
|
||||||
|
[hoveredRequestPolygonId, activePolygons]
|
||||||
|
);
|
||||||
|
const overlayPolygons = uniquePolygons([externalSelectedPolygon, selectedPolygon, externalHoveredPolygon, hoverRef.current]);
|
||||||
|
const overlayMarkers = uniqueMarkers([selectedMarker, hoverMarkerRef.current]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`tgu-2d-map ${pickMode ? "is-picking" : ""}`}
|
className={`tgu-2d-map ${pickMode || drawMode ? "is-picking" : ""}`}
|
||||||
ref={wrapperRef}
|
ref={wrapperRef}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
@@ -290,6 +367,7 @@ export function Tgu2DMapView({
|
|||||||
onPointerLeave={() => {
|
onPointerLeave={() => {
|
||||||
dragRef.current = undefined;
|
dragRef.current = undefined;
|
||||||
hoverRef.current = undefined;
|
hoverRef.current = undefined;
|
||||||
|
hoverMarkerRef.current = undefined;
|
||||||
setTooltip(undefined);
|
setTooltip(undefined);
|
||||||
}}
|
}}
|
||||||
onWheel={onWheel}
|
onWheel={onWheel}
|
||||||
@@ -299,7 +377,10 @@ export function Tgu2DMapView({
|
|||||||
{overlayPolygons.flatMap((polygon) => {
|
{overlayPolygons.flatMap((polygon) => {
|
||||||
const raw = polygon.points.map((point) => projection.project(point));
|
const raw = polygon.points.map((point) => projection.project(point));
|
||||||
const pts = unwrapX(raw, projection.worldSize);
|
const pts = unwrapX(raw, projection.worldSize);
|
||||||
const cls = polygon.id === selectedPolygon?.id ? "is-selected" : "is-hovered";
|
const cls =
|
||||||
|
polygon.id === selectedPolygon?.id || polygon.id === externalSelectedPolygon?.id
|
||||||
|
? "is-selected"
|
||||||
|
: "is-hovered";
|
||||||
return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => {
|
return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => {
|
||||||
const visible = pts.some((p) => {
|
const visible = pts.some((p) => {
|
||||||
const x = p.x + dx;
|
const x = p.x + dx;
|
||||||
@@ -315,6 +396,11 @@ export function Tgu2DMapView({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
})}
|
})}
|
||||||
|
{overlayMarkers.map((marker) => {
|
||||||
|
const pt = projection.project(marker.point);
|
||||||
|
const cls = marker.id === selectedMarker?.id ? "is-selected" : "is-hovered";
|
||||||
|
return <circle key={marker.id} cx={pt.x} cy={pt.y} r={10} className={cls} />;
|
||||||
|
})}
|
||||||
{targets.map((t) => {
|
{targets.map((t) => {
|
||||||
const screen = projection.project(t);
|
const screen = projection.project(t);
|
||||||
const isSelected = t.id === selectedTargetId;
|
const isSelected = t.id === selectedTargetId;
|
||||||
@@ -344,6 +430,22 @@ export function Tgu2DMapView({
|
|||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
{draftPolygon.length > 0 && (() => {
|
||||||
|
const pts = unwrapX(draftPolygon.map((point) => projection.project(point)), projection.worldSize);
|
||||||
|
const path = pts.map((p) => `${p.x},${p.y}`).join(" ");
|
||||||
|
return (
|
||||||
|
<g className="tgu-2d-map__draft">
|
||||||
|
{pts.length >= 2 && (
|
||||||
|
pts.length >= 3
|
||||||
|
? <polygon className="tgu-2d-map__draft-fill" points={path} />
|
||||||
|
: <polyline className="tgu-2d-map__draft-line" points={path} />
|
||||||
|
)}
|
||||||
|
{pts.map((p, i) => (
|
||||||
|
<circle key={i} cx={p.x} cy={p.y} r={4} className="tgu-2d-map__draft-vertex" />
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</svg>
|
</svg>
|
||||||
{tooltip && (
|
{tooltip && (
|
||||||
<div className="tgu-2d-map__tooltip" style={{ left: tooltip.x, top: tooltip.y }}>
|
<div className="tgu-2d-map__tooltip" style={{ left: tooltip.x, top: tooltip.y }}>
|
||||||
@@ -388,6 +490,36 @@ function unwrapX(points: { x: number; y: number }[], worldSize: number): { x: nu
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hitTestMarkers(
|
||||||
|
markers: MapMarker[],
|
||||||
|
screenPoint: ScreenPoint,
|
||||||
|
projection: ReturnType<typeof createMapProjection>,
|
||||||
|
hitRadius = 10
|
||||||
|
): MapMarker | undefined {
|
||||||
|
let best: MapMarker | undefined;
|
||||||
|
let bestDist = hitRadius;
|
||||||
|
for (const marker of markers) {
|
||||||
|
const pt = projection.project(marker.point);
|
||||||
|
const dist = Math.sqrt((pt.x - screenPoint.x) ** 2 + (pt.y - screenPoint.y) ** 2);
|
||||||
|
if (dist <= bestDist) {
|
||||||
|
bestDist = dist;
|
||||||
|
best = marker;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueMarkers(markers: Array<MapMarker | undefined>): MapMarker[] {
|
||||||
|
const result: MapMarker[] = [];
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const marker of markers) {
|
||||||
|
if (!marker || ids.has(marker.id)) continue;
|
||||||
|
ids.add(marker.id);
|
||||||
|
result.push(marker);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function uniquePolygons(polygons: Array<MapPolygon | undefined>): MapPolygon[] {
|
function uniquePolygons(polygons: Array<MapPolygon | undefined>): MapPolygon[] {
|
||||||
const result: MapPolygon[] = [];
|
const result: MapPolygon[] = [];
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
|
|||||||
@@ -20,6 +20,28 @@ export function createPolygonSpatialIndex(polygons: MapPolygon[]): PolygonSpatia
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hitTestAllPolygons(index: PolygonSpatialIndex, point: GeoPoint): MapPolygon[] {
|
||||||
|
const hits: MapPolygon[] = [];
|
||||||
|
|
||||||
|
for (const entry of index.entries) {
|
||||||
|
const inBbox =
|
||||||
|
bboxContainsPoint(entry.bbox, point) ||
|
||||||
|
bboxContainsPoint(entry.bbox, { lon: point.lon + 360, lat: point.lat }) ||
|
||||||
|
bboxContainsPoint(entry.bbox, { lon: point.lon - 360, lat: point.lat });
|
||||||
|
if (!inBbox) continue;
|
||||||
|
|
||||||
|
const normalized = unwrapLon(entry.polygon.points);
|
||||||
|
const hit =
|
||||||
|
pointInPolygon(point, normalized) ||
|
||||||
|
pointInPolygon({ lon: point.lon + 360, lat: point.lat }, normalized) ||
|
||||||
|
pointInPolygon({ lon: point.lon - 360, lat: point.lat }, normalized);
|
||||||
|
|
||||||
|
if (hit) hits.push(entry.polygon);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hits.sort((a, b) => b.zIndex - a.zIndex);
|
||||||
|
}
|
||||||
|
|
||||||
export function hitTestPolygons(index: PolygonSpatialIndex, point: GeoPoint): MapPolygon | undefined {
|
export function hitTestPolygons(index: PolygonSpatialIndex, point: GeoPoint): MapPolygon | undefined {
|
||||||
let selected: MapPolygon | undefined;
|
let selected: MapPolygon | undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -9,31 +9,16 @@ export type Tgu2DMapLayerDefinition = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const TGU_2D_MAP_LAYERS: Tgu2DMapLayerDefinition[] = [
|
export const TGU_2D_MAP_LAYERS: Tgu2DMapLayerDefinition[] = [
|
||||||
{
|
|
||||||
key: "tracks",
|
|
||||||
label: "Трасса КА",
|
|
||||||
description: "Проекция движения КА на поверхность."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "swath",
|
|
||||||
label: "Полоса обзора",
|
|
||||||
description: "Массовые полосы обзора через canvas."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "planWorks",
|
key: "planWorks",
|
||||||
label: "Работы плана",
|
label: "Работы плана",
|
||||||
description: "Полигоны работ выбранного плана."
|
description: "Маршруты съёмки из выбранного плана."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "stations",
|
key: "stations",
|
||||||
label: "Станции",
|
label: "Станции",
|
||||||
description: "Наземные станции приёма."
|
description: "Наземные станции приёма."
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "spacecraftMarkers",
|
|
||||||
label: "Маркеры КА",
|
|
||||||
description: "Текущие или расчётные позиции КА."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "requests",
|
key: "requests",
|
||||||
label: "Заявки на съёмку",
|
label: "Заявки на съёмку",
|
||||||
@@ -42,11 +27,11 @@ export const TGU_2D_MAP_LAYERS: Tgu2DMapLayerDefinition[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const DEFAULT_TGU_2D_MAP_LAYERS: Tgu2DMapLayersState = {
|
export const DEFAULT_TGU_2D_MAP_LAYERS: Tgu2DMapLayersState = {
|
||||||
tracks: true,
|
tracks: false,
|
||||||
swath: true,
|
swath: false,
|
||||||
planWorks: true,
|
planWorks: true,
|
||||||
stations: true,
|
stations: true,
|
||||||
spacecraftMarkers: true,
|
spacecraftMarkers: false,
|
||||||
requests: true
|
requests: true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,5 +18,11 @@ export type Tgu2DMapSelection =
|
|||||||
name: string;
|
name: string;
|
||||||
layer: "stations" | "spacecraftMarkers";
|
layer: "stations" | "spacecraftMarkers";
|
||||||
spacecraftId?: string;
|
spacecraftId?: string;
|
||||||
|
stationNumber?: number;
|
||||||
|
lat?: number;
|
||||||
|
lon?: number;
|
||||||
|
altitude?: number;
|
||||||
|
elevationMin?: number;
|
||||||
|
elevationMax?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ export type MapMarker = {
|
|||||||
point: GeoPoint;
|
point: GeoPoint;
|
||||||
label: string;
|
label: string;
|
||||||
zIndex: number;
|
zIndex: number;
|
||||||
|
stationNumber?: number;
|
||||||
|
altitude?: number;
|
||||||
|
elevationMin?: number;
|
||||||
|
elevationMax?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Tgu2DMapScene = {
|
export type Tgu2DMapScene = {
|
||||||
|
|||||||
@@ -45,13 +45,13 @@ describe("tgu2DMapInterval", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enables all requested 2D map layers by default", () => {
|
it("enables plan, stations and requests layers by default", () => {
|
||||||
expect(DEFAULT_TGU_2D_MAP_LAYERS).toEqual({
|
expect(DEFAULT_TGU_2D_MAP_LAYERS).toEqual({
|
||||||
tracks: true,
|
tracks: false,
|
||||||
swath: true,
|
swath: false,
|
||||||
planWorks: true,
|
planWorks: true,
|
||||||
stations: true,
|
stations: true,
|
||||||
spacecraftMarkers: true,
|
spacecraftMarkers: false,
|
||||||
requests: true
|
requests: true
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { TguPlanDecision, TguPlatform } from "../../model/tguTypes";
|
|||||||
import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes";
|
import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes";
|
||||||
import { TguEditorTab } from "../tgu-editor/TguEditorTab";
|
import { TguEditorTab } from "../tgu-editor/TguEditorTab";
|
||||||
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
|
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
|
||||||
|
import { TguRequestsTab } from "../tgu-requests/TguRequestsTab";
|
||||||
import { TguPlanDetails } from "./TguPlanDetails";
|
import { TguPlanDetails } from "./TguPlanDetails";
|
||||||
import { TguPlanningLayout } from "./TguPlanningLayout";
|
import { TguPlanningLayout } from "./TguPlanningLayout";
|
||||||
import { TguSidebar } from "./TguSidebar";
|
import { TguSidebar } from "./TguSidebar";
|
||||||
@@ -114,14 +115,6 @@ export function TguPlanningPage() {
|
|||||||
setDecisionNotice(undefined);
|
setDecisionNotice(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const refresh = () => {
|
|
||||||
if (invalidRange) {
|
|
||||||
setPageError("Некорректный интервал: from должен быть меньше to.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void loadData(appliedRange);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDecision = async (planId: string, decision: TguPlanDecision, reason?: string) => {
|
const handleDecision = async (planId: string, decision: TguPlanDecision, reason?: string) => {
|
||||||
setDecisionInFlight(true);
|
setDecisionInFlight(true);
|
||||||
setDecisionError(undefined);
|
setDecisionError(undefined);
|
||||||
@@ -156,7 +149,6 @@ export function TguPlanningPage() {
|
|||||||
onToChange={setToValue}
|
onToChange={setToValue}
|
||||||
onApply={applyRange}
|
onApply={applyRange}
|
||||||
onQuickRange={quickRange}
|
onQuickRange={quickRange}
|
||||||
onRefresh={refresh}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -205,6 +197,12 @@ export function TguPlanningPage() {
|
|||||||
selectedPlan={selectedPlan}
|
selectedPlan={selectedPlan}
|
||||||
platforms={platforms}
|
platforms={platforms}
|
||||||
/>
|
/>
|
||||||
|
) : activeTab === "requests" ? (
|
||||||
|
<TguRequestsTab
|
||||||
|
range={appliedRange}
|
||||||
|
invalidRange={invalidRange}
|
||||||
|
platforms={platforms}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TguEditorTab
|
<TguEditorTab
|
||||||
selectedSpacecraftId={selectedPlan?.spacecraftId ?? selectedSpacecraftId}
|
selectedSpacecraftId={selectedPlan?.spacecraftId ?? selectedSpacecraftId}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { TguPlatformUi } from "../../model/timelineTypes";
|
import type { TguPlatformUi } from "../../model/timelineTypes";
|
||||||
import { filterPlatforms, platformLabel } from "./tguTimelineMapper";
|
import { filterPlatforms, platformLabel } from "./tguTimelineMapper";
|
||||||
|
import { TguLegend } from "./TguLegend";
|
||||||
|
|
||||||
type TguSidebarProps = {
|
type TguSidebarProps = {
|
||||||
platforms: TguPlatformUi[];
|
platforms: TguPlatformUi[];
|
||||||
@@ -69,6 +70,10 @@ export function TguSidebar({
|
|||||||
})}
|
})}
|
||||||
{filtered.length === 0 && <div className="tgu-empty tgu-empty--compact">Нет КА по текущему поиску.</div>}
|
{filtered.length === 0 && <div className="tgu-empty tgu-empty--compact">Нет КА по текущему поиску.</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="tgu-sidebar__legend">
|
||||||
|
<TguLegend />
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes";
|
import type { TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes";
|
||||||
import {
|
import {
|
||||||
buildSequentialLinks,
|
buildSequentialLinks,
|
||||||
@@ -30,9 +30,11 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec
|
|||||||
const height = Math.max(260, timelineHeight(rows.length));
|
const height = Math.max(260, timelineHeight(rows.length));
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
setContainerWidth(Math.floor(containerRef.current.getBoundingClientRect().width));
|
||||||
|
|
||||||
const observer = new ResizeObserver(([entry]) => {
|
const observer = new ResizeObserver(([entry]) => {
|
||||||
setContainerWidth(Math.floor(entry.contentRect.width));
|
setContainerWidth(Math.floor(entry.contentRect.width));
|
||||||
});
|
});
|
||||||
@@ -55,17 +57,14 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec
|
|||||||
? TIMELINE_LABEL_WIDTH + timeToX(nowMs, range, timelineWidth)
|
? TIMELINE_LABEL_WIDTH + timeToX(nowMs, range, timelineWidth)
|
||||||
: undefined;
|
: 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 (
|
return (
|
||||||
<div className="tgu-timeline" ref={containerRef}>
|
<div className="tgu-timeline" ref={containerRef}>
|
||||||
<svg className="tgu-timeline__svg" viewBox={`0 0 ${fullWidth} ${height}`} role="img" aria-label="Timeline планов ТГУ">
|
{invalidRange ? (
|
||||||
|
<div className="tgu-empty">Некорректный интервал: from должен быть меньше to.</div>
|
||||||
|
) : rows.length === 0 || segments.length === 0 ? (
|
||||||
|
<div className="tgu-empty">Нет планов в выбранном интервале.</div>
|
||||||
|
) : (
|
||||||
|
<svg className="tgu-timeline__svg" viewBox={`0 0 ${fullWidth} ${height}`} style={{ height }} role="img" aria-label="Timeline планов ТГУ">
|
||||||
<defs>
|
<defs>
|
||||||
<marker id="tgu-arrow-head" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
<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" />
|
<path d="M0 0 L8 4 L0 8 z" fill="#8aa2a6" />
|
||||||
@@ -175,6 +174,7 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { TguLegend } from "./TguLegend";
|
|
||||||
import type { TguActiveTab } from "../../model/timelineTypes";
|
import type { TguActiveTab } from "../../model/timelineTypes";
|
||||||
|
|
||||||
type TguToolbarProps = {
|
type TguToolbarProps = {
|
||||||
@@ -12,7 +11,6 @@ type TguToolbarProps = {
|
|||||||
onToChange: (value: string) => void;
|
onToChange: (value: string) => void;
|
||||||
onApply: () => void;
|
onApply: () => void;
|
||||||
onQuickRange: (hours: number) => void;
|
onQuickRange: (hours: number) => void;
|
||||||
onRefresh: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TguToolbar({
|
export function TguToolbar({
|
||||||
@@ -25,8 +23,7 @@ export function TguToolbar({
|
|||||||
onFromChange,
|
onFromChange,
|
||||||
onToChange,
|
onToChange,
|
||||||
onApply,
|
onApply,
|
||||||
onQuickRange,
|
onQuickRange
|
||||||
onRefresh
|
|
||||||
}: TguToolbarProps) {
|
}: TguToolbarProps) {
|
||||||
return (
|
return (
|
||||||
<header className="tgu-toolbar">
|
<header className="tgu-toolbar">
|
||||||
@@ -56,7 +53,7 @@ export function TguToolbar({
|
|||||||
aria-selected={activeTab === "map"}
|
aria-selected={activeTab === "map"}
|
||||||
onClick={() => onTabChange("map")}
|
onClick={() => onTabChange("map")}
|
||||||
>
|
>
|
||||||
Карта 2D
|
Карта
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={activeTab === "editor" ? "is-active" : ""}
|
className={activeTab === "editor" ? "is-active" : ""}
|
||||||
@@ -67,6 +64,15 @@ export function TguToolbar({
|
|||||||
>
|
>
|
||||||
Редактор
|
Редактор
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={activeTab === "requests" ? "is-active" : ""}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === "requests"}
|
||||||
|
onClick={() => onTabChange("requests")}
|
||||||
|
>
|
||||||
|
Заявки
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="tgu-field">
|
<label className="tgu-field">
|
||||||
@@ -90,11 +96,7 @@ export function TguToolbar({
|
|||||||
<button className="tgu-button" type="button" onClick={() => onQuickRange(168)} disabled={loading}>
|
<button className="tgu-button" type="button" onClick={() => onQuickRange(168)} disabled={loading}>
|
||||||
7 суток
|
7 суток
|
||||||
</button>
|
</button>
|
||||||
<button className="tgu-button" type="button" onClick={onRefresh} disabled={loading}>
|
|
||||||
{loading ? "Обновление..." : "Обновить"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<TguLegend />
|
|
||||||
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
|
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel";
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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>}
|
||||||
|
|
||||||
|
<div className="tgu-requests-form__actions">
|
||||||
|
<button
|
||||||
|
className="tgu-button tgu-button--reject"
|
||||||
|
type="button"
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Удалить заявку «${selectedEntry.item.name || selectedEntry.item.id}»?`)) {
|
||||||
|
onDelete(selectedEntry.item.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleting ? "Удаление..." : "Удалить заявку"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string): string {
|
||||||
|
return new Date(value).toLocaleString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
|
function surveyTypeLabel(surveyType: string): string {
|
||||||
|
switch (surveyType) {
|
||||||
|
case "OPTICS":
|
||||||
|
return "Оптическая";
|
||||||
|
case "RSA":
|
||||||
|
return "Радиолокационная (РСА)";
|
||||||
|
case "COMBINED":
|
||||||
|
return "Комбинированная";
|
||||||
|
default:
|
||||||
|
return surveyType;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
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 { 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: formatDateTimeLocal(range.fromMs),
|
||||||
|
toValue: formatDateTimeLocal(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 = new Date(fromValue).getTime();
|
||||||
|
const toMs = new Date(toValue).getTime();
|
||||||
|
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: new Date(fromMs).toISOString(), beginTo: new Date(toMs).toISOString() },
|
||||||
|
(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, 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 = crypto.randomUUID();
|
||||||
|
await createRequest({
|
||||||
|
id,
|
||||||
|
name: form.name,
|
||||||
|
geometry,
|
||||||
|
importance: form.importance,
|
||||||
|
beginDateTime: new Date(form.beginDateTime).toISOString(),
|
||||||
|
endDateTime: new Date(form.endDateTime).toISOString(),
|
||||||
|
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 = new Date(requestsState.fromValue).getTime();
|
||||||
|
const toMs = new Date(requestsState.toValue).getTime();
|
||||||
|
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>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={requestsState.fromValue}
|
||||||
|
onChange={(e) => setRequestsState((cur) => ({ ...cur, fromValue: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="tgu-field">
|
||||||
|
<span>По</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={requestsState.toValue}
|
||||||
|
onChange={(e) => setRequestsState((cur) => ({ ...cur, toValue: e.target.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -63,3 +63,44 @@ export type RequestMapItem = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OpticsResultType = "PANCHROMATIC" | "MULTISPECTRAL" | "PANSHARPENING";
|
||||||
|
export type RsaResultType = "SPOTLIGHT" | "STRIPMAP" | "SCANSAR";
|
||||||
|
export type Polarisation = "HH" | "VV";
|
||||||
|
|
||||||
|
export type OpticsParams = {
|
||||||
|
resultType: OpticsResultType;
|
||||||
|
resolution: number;
|
||||||
|
sunAngleMin: number;
|
||||||
|
sunAngleMax: number;
|
||||||
|
clouds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RsaParams = {
|
||||||
|
resultType: RsaResultType;
|
||||||
|
interferometry: boolean;
|
||||||
|
polarisation: Polarisation;
|
||||||
|
resolution: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateRequestPayload = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
geometry: string;
|
||||||
|
importance: number;
|
||||||
|
beginDateTime: string;
|
||||||
|
endDateTime: string;
|
||||||
|
kpp: number[];
|
||||||
|
highPriorityTransmit: boolean;
|
||||||
|
optics?: OpticsParams;
|
||||||
|
rsa?: RsaParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateRequestResult = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: RequestStatus;
|
||||||
|
surveyType: SurveyType;
|
||||||
|
importance: number;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export type TimelineRange = {
|
|||||||
toMs: number;
|
toMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TguActiveTab = "timeline" | "map" | "editor";
|
export type TguActiveTab = "timeline" | "map" | "editor" | "requests";
|
||||||
|
|
||||||
export type TimelineRow = {
|
export type TimelineRow = {
|
||||||
spacecraftId: string;
|
spacecraftId: string;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
.tgu-toolbar__row {
|
.tgu-toolbar__row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: end;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
min-height: 4.4rem;
|
min-height: 4.4rem;
|
||||||
padding: 0.75rem 1rem 0.65rem;
|
padding: 0.75rem 1rem 0.65rem;
|
||||||
@@ -53,8 +53,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tgu-field {
|
.tgu-field {
|
||||||
display: grid;
|
display: flex;
|
||||||
gap: 0.25rem;
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
}
|
}
|
||||||
@@ -114,7 +115,6 @@
|
|||||||
.tgu-tabs {
|
.tgu-tabs {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
align-self: center;
|
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
padding: 0.18rem;
|
padding: 0.18rem;
|
||||||
@@ -219,6 +219,17 @@
|
|||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tgu-sidebar__legend {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-sidebar__legend .tgu-legend {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.tgu-panel-heading {
|
.tgu-panel-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -344,7 +355,6 @@
|
|||||||
.tgu-timeline__svg {
|
.tgu-timeline__svg {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 26rem;
|
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #151819;
|
background: #151819;
|
||||||
@@ -832,6 +842,18 @@
|
|||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tgu-2d-map__overlay circle {
|
||||||
|
fill: rgba(104, 195, 189, 0.12);
|
||||||
|
stroke: rgba(130, 222, 216, 0.9);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-2d-map__overlay circle.is-selected {
|
||||||
|
fill: rgba(240, 173, 46, 0.15);
|
||||||
|
stroke: #f0ad2e;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
.tgu-2d-map__tooltip {
|
.tgu-2d-map__tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 8;
|
z-index: 8;
|
||||||
@@ -998,6 +1020,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tgu-map-requests-filter .tgu-field {
|
.tgu-map-requests-filter .tgu-field {
|
||||||
|
display: grid;
|
||||||
grid-template-columns: 2rem minmax(0, 1fr);
|
grid-template-columns: 2rem minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -1059,3 +1082,266 @@
|
|||||||
background: rgba(192, 128, 255, 0.25);
|
background: rgba(192, 128, 255, 0.25);
|
||||||
border-color: rgba(192, 128, 255, 0.85);
|
border-color: rgba(192, 128, 255, 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Requests-at-point panel ───────────────────────────── */
|
||||||
|
|
||||||
|
.tgu-map-content.has-requests-panel {
|
||||||
|
grid-template-columns: 19rem minmax(0, 1fr) 20rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-map-requests-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-map-requests-list {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-map-request-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.65rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-map-request-row:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-map-request-row.is-selected {
|
||||||
|
background: rgba(240, 173, 46, 0.1);
|
||||||
|
outline: 1px solid rgba(240, 173, 46, 0.4);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-map-request-row__type {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
width: 1rem;
|
||||||
|
height: 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1.5px dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-map-request-row__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-map-request-row__name {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-map-request-row__meta {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-map-request-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-map-request-details .tgu-map-panel__label {
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-map-request-details__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.3rem 0.8rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-map-request-details__grid dt {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-map-request-details__grid dd {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.tgu-map-content.has-requests-panel {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-map-requests-panel {
|
||||||
|
max-height: 18rem;
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Вкладка «Заявки» ───────────────────────────────────── */
|
||||||
|
|
||||||
|
.tgu-map-content.has-side-panel {
|
||||||
|
grid-template-columns: 19rem minmax(0, 1fr) 24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-map-overlay--hint {
|
||||||
|
border-color: rgba(104, 195, 189, 0.5);
|
||||||
|
color: #bdeee9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Черновой полигон при рисовании заявки */
|
||||||
|
.tgu-2d-map__overlay .tgu-2d-map__draft-fill {
|
||||||
|
fill: rgba(240, 173, 46, 0.16);
|
||||||
|
stroke: #f0ad2e;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-dasharray: 6 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-2d-map__overlay .tgu-2d-map__draft-line {
|
||||||
|
fill: none;
|
||||||
|
stroke: #f0ad2e;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-dasharray: 6 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-2d-map__overlay .tgu-2d-map__draft-vertex {
|
||||||
|
fill: #f0ad2e;
|
||||||
|
stroke: #1a1c1e;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Панель-форма создания заявки */
|
||||||
|
.tgu-requests-form__body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-requests-form__section {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-requests-field {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 9rem minmax(0, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-requests-field > span {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-requests-field input,
|
||||||
|
.tgu-requests-field select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 1.9rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.25rem 0.45rem;
|
||||||
|
background: #101214;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-requests-field input:focus,
|
||||||
|
.tgu-requests-field select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(104, 195, 189, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-requests-checkbox {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-requests-checkbox input {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-requests-form__block-toggle {
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-requests-form__block {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.55rem 0.6rem;
|
||||||
|
border: 1px solid var(--line-soft);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #111416;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-requests-form__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-requests-form__hint {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-requests-form__geo-status {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-requests-form__footer {
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-requests-form__footer .tgu-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.tgu-map-content.has-side-panel {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user