Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
emelianov
2026-06-15 10:13:33 +03:00
95 changed files with 5608 additions and 555 deletions
+9
View File
@@ -0,0 +1,9 @@
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
FROM ${RUNTIME_IMAGE}
ENV JAVA_OPTS=""
ADD ./build/libs/*.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"]
@@ -0,0 +1,50 @@
group = "space.nstart.pcp"
plugins {
kotlin("jvm")
kotlin("plugin.spring")
id("org.springframework.boot")
id("io.spring.dependency-management")
}
version = "0.0.1"
java {
toolchain {
languageVersion = JavaLanguageVersion.of((property("versions.java") as String).toInt())
}
}
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
}
}
dependencies {
// Реактивный edge: Spring Cloud Gateway (WebFlux-вариант) + конфиг из config-server.
// Маршруты живут в config-repo/pcp-gateway-service.yaml — единый источник правды.
implementation("org.springframework.cloud:spring-cloud-starter-gateway-server-webflux")
implementation("org.springframework.cloud:spring-cloud-starter-config")
implementation("org.springframework.boot:spring-boot-starter-actuator")
// Валидация JWT на edge (Keycloak/new-start-id) — включается флагом gateway.security.jwt-enabled.
// Тянет reactive Spring Security (ServerHttpSecurity/@EnableWebFluxSecurity).
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.withType<Test> {
useJUnitPlatform()
}
dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}")
}
}
@@ -0,0 +1,16 @@
package space.nstart.pcp_gateway_service
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
/**
* Единая точка входа в бэкенд для фронта (pcp-tgu-ui-service): reverse-proxy поверх
* микросервисов. Маршрутная таблица — в config-repo/pcp-gateway-service.yaml и ссылается
* на реестр `pcp.services.*`, так что топологию знает только gateway, а не браузер.
*/
@SpringBootApplication
class GatewayApplication
fun main(args: Array<String>) {
runApplication<GatewayApplication>(*args)
}
@@ -0,0 +1,51 @@
package space.nstart.pcp_gateway_service.config
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
import org.springframework.security.config.web.server.ServerHttpSecurity
import org.springframework.security.web.server.SecurityWebFilterChain
/**
* Политика edge. Две взаимоисключающие цепочки, переключаемые флагом
* `gateway.security.jwt-enabled` (завязан на режим входа фронта):
* - выключено (anonymous, текущий дефолт) — пропускаем всё: поведение как при прямых вызовах;
* - включено (закрытый контур new-start-id) — валидируем Bearer JWT один раз здесь, дальше
* gateway по умолчанию пробрасывает заголовок Authorization вниз к сервисам.
*
* Когда JWT выключен, `spring.security.oauth2.resourceserver.jwt.issuer-uri` не задаётся,
* поэтому JwtDecoder не создаётся и старт не падает из-за пустого issuer.
*/
@Configuration
@EnableWebFluxSecurity
class SecurityConfig {
@Bean
@ConditionalOnProperty(prefix = "gateway.security", name = ["jwt-enabled"], havingValue = "true")
fun securedChain(http: ServerHttpSecurity): SecurityWebFilterChain =
http
.csrf { it.disable() }
.authorizeExchange { exchanges ->
exchanges
.pathMatchers(HttpMethod.OPTIONS).permitAll()
.pathMatchers("/healthz", "/actuator/**").permitAll()
.anyExchange().authenticated()
}
.oauth2ResourceServer { it.jwt {} }
.build()
@Bean
@ConditionalOnProperty(
prefix = "gateway.security",
name = ["jwt-enabled"],
havingValue = "false",
matchIfMissing = true,
)
fun openChain(http: ServerHttpSecurity): SecurityWebFilterChain =
http
.csrf { it.disable() }
.authorizeExchange { it.anyExchange().permitAll() }
.build()
}
@@ -0,0 +1,13 @@
spring:
application:
name: pcp-gateway-service
profiles:
default: local
config:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://localhost:8888}
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
label: ${SPRING_CLOUD_CONFIG_LABEL:master}
@@ -67,7 +67,10 @@ data class CreateRequestDto(
*/
@field:DecimalMin(value = "0", inclusive = false)
@field:DecimalMax("100")
val coverageRequiredPercent: Double = 100.0,
// Nullable с дефолтом: под Jackson 3 (MVC) FAIL_ON_NULL_FOR_PRIMITIVES включён по умолчанию,
// и явный null от клиента падал бы на примитиве Double. Опциональное поле с дефолтом трактуем
// null как «взять дефолт» — при маппинге в сущность коалесим к 100.0.
val coverageRequiredPercent: Double? = 100.0,
/** Параметры оптической съемки. */
@field:Valid
@@ -81,7 +81,7 @@ class RequestService(
remainingArea = geometryArea,
// Порог завершения по покрытию приходит из API (по умолчанию 100%); это прогресс съёмки,
// в отличие от importance — важности заявки для карты приоритетов ячеек.
coverageRequiredPercent = request.coverageRequiredPercent,
coverageRequiredPercent = request.coverageRequiredPercent ?: 100.0,
importance = importance,
beginDateTime = request.beginDateTime,
endDateTime = request.endDateTime,
@@ -112,6 +112,23 @@ class RequestServiceTest {
assertEquals(80.0, persistedRequests.single().coverageRequiredPercent)
}
@Test
fun `create request coalesces null coverage required percent to default`() {
val persistedRequests = mutableListOf<RequestEntity>()
doReturn(false)
.`when`(requestRepository)
.existsById(REQUEST_ID)
doAnswer { invocation ->
persistedRequests += invocation.getArgument<RequestEntity>(0)
null
}.`when`(entityManager).persist(any(RequestEntity::class.java))
// Явный null (клиент прислал coverageRequiredPercent: null) трактуем как «взять дефолт».
service.createRequest(createRequestDto(coverageRequiredPercent = null))
assertEquals(100.0, persistedRequests.single().coverageRequiredPercent)
}
@Test
fun `get request by id returns name and importance`() {
val request = existingRequest()
@@ -296,7 +313,7 @@ class RequestServiceTest {
verify(requestCellRepository).deleteByRequestId(REQUEST_ID)
}
private fun createRequestDto(coverageRequiredPercent: Double = 100.0): CreateRequestDto {
private fun createRequestDto(coverageRequiredPercent: Double? = 100.0): CreateRequestDto {
return CreateRequestDto(
id = REQUEST_ID,
name = REQUEST_NAME,
@@ -0,0 +1,9 @@
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
FROM ${RUNTIME_IMAGE}
ENV JAVA_OPTS=""
ADD ./build/libs/*.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"]
+7 -57
View File
@@ -21,64 +21,14 @@ server {
try_files $uri =404;
}
# --- ТГУ: маршрутизация на бэкенды (зеркало server.proxy из vite.config.ts).
# СЕМАНТИКА (важно повторить точно, иначе бэкенды получат неверный путь):
# - префиксы pcp-* СРЕЗАЮТСЯ: trailing-slash в proxy_pass отбрасывает префикс
# location (как rewrite в vite). Пример: /api/pcp-tgu/api/plans → бэкенд /api/plans.
# - /api/stations путь СОХРАНЯЕТ (в vite без rewrite): proxy_pass без URI-части.
# - общий /api/ — catch-all (tgu-planning, current-plans, auth) на ui-service/gateway.
# Порядок важен: специфичные префиксы идут РАНЬШЕ общего /api/.
# Цели (UPSTREAM_*) — заглушки: подставьте реальные адреса кластера ЛИБО перенесите
# маршрутизацию на gateway/ingress. Порты dev (vite) даны как ориентир в комментариях.
# Раскомментируйте после подстановки реальных upstream (нерезолвимый хост ломает старт nginx).
# Заголовки proxy_set_header в каждом блоке — те же, что в активном /api/ ниже.
#
# location /api/pcp-request/ { # dev :7005
# proxy_pass http://UPSTREAM_REQUEST/;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# location /api/pcp-ballistics/ { # dev :7003
# proxy_pass http://UPSTREAM_BALLISTICS/;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# location /api/pcp-mission/ { # dev :7010
# proxy_pass http://UPSTREAM_MISSION/;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# location /api/pcp-tgu/ { # dev :7011
# proxy_pass http://UPSTREAM_TGU/;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# location /api/pcp-complex/ { # dev :7002
# proxy_pass http://UPSTREAM_COMPLEX/;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# location /api/stations/ { # dev :7009 — путь СОХРАНЯЕМ (proxy_pass без / и без URI)
# proxy_pass http://UPSTREAM_STATIONS;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
#
# Общий catch-all. В деве это ui-service (:7008): /api/tgu-planning, /api/current-plans, /api/auth.
# --- ТГУ: вся маршрутизация /api/* уходит на gateway (Spring Cloud Gateway).
# Gateway сам срезает префиксы pcp-* (StripPrefix) и выбирает бэкенд —
# см. config-repo/pcp-gateway-service.yaml. Здесь прозрачный reverse-proxy:
# proxy_pass БЕЗ URI-части → путь /api/... передаётся gateway без изменений
# (он разбирает его сам). Это заменяет прежний набор per-service location'ов.
# Адрес gateway в кластере — pcp-gateway-service:8080 (dev/master-профиль).
location /api/ {
proxy_pass http://127.0.0.1:8000/api/; # ЗАГЛУШКА: замените на ui-service/gateway
proxy_pass http://pcp-gateway-service:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+1
View File
@@ -19,6 +19,7 @@
"@nstart/ui": "^0.4.0",
"@phosphor-icons/react": "^2.1.10",
"@tanstack/react-query": "^5.96.1",
"@tanstack/react-virtual": "^3.14.2",
"d3-geo": "^3.1.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -4,6 +4,64 @@ import { apiFetch } from './apiFetch'
const BASE = '/api/pcp-ballistics'
export type FlightLinePoint = {
time: string
revolution: number
lat: number
long: number
latLeft: number
longLeft: number
latInnerLeft: number
longInnerLeft: number
latInnerRight: number
longInnerRight: number
latRight: number
longRight: number
revSign: 'ASC' | 'DESC'
}
/**
* Трасса полёта КА (flight-line) на интервале для отрисовки на карте/таймлайне из
* pcp-ballistics-service (GET /api/satellites/{norad}/flight-line). Шаг трассы 60 с.
* @param noradId NORAD-идентификатор КА.
* @param fromMs Начало интервала, мс от эпохи.
* @param toMs Конец интервала, мс от эпохи.
*
* @returns Точки трассы полёта на интервале.
*/
export async function fetchFlightLine(
noradId: string,
fromMs: number,
toMs: number,
): Promise<FlightLinePoint[]> {
const params = new URLSearchParams({
time_start: toLocalDateTime(fromMs),
time_stop: toLocalDateTime(toMs),
})
const url = `${BASE}/api/satellites/${encodeURIComponent(noradId)}/flight-line?${params.toString()}`
let response: Response
try {
response = await apiFetch(url, { headers: { Accept: 'application/json' } })
} catch {
throw new TguApiError('Не удалось подключиться к сервису баллистики.', 'network')
}
const text = await response.text()
if (!response.ok) {
throw new TguApiError(
extractErrorText(text) || 'Ошибка сервиса баллистики.',
'http',
response.status,
)
}
if (!text.trim()) {
return []
}
return JSON.parse(text) as FlightLinePoint[]
}
export type PointVisibilityParam = {
/** NORAD-идентификатор КА. */
noradId: number
@@ -0,0 +1,85 @@
import { TguApiError } from '../model/tguTypes'
import { apiFetch } from './apiFetch'
/**
* Карта приоритетов ячеек сетки заданий (pcp-request-service, GET /v1/cells/priority-map).
* Это тот же источник, по которому комплексный план считает покрытие заявок — отдаёт только
* ячейки, на которые спроецированы заявки (с их важностью и контуром), а не весь каталог сетки.
* Используется на вкладке «Комплексный план» для тепловой карты приоритетов.
*/
const BASE = '/api/pcp-request/v1'
/**
* Минимальная важность ячейки, ниже которой ячейку не возвращаем — тот же порог, что и в
* комплексном сервисе (REQUEST_GRID_MIN_IMPORTANCE), чтобы тепловая карта совпадала с тем,
* что реально участвует в расчёте.
*/
export const HEATMAP_MIN_IMPORTANCE = 0.001
/** Фрагмент заявки внутри ячейки сетки приоритетов. */
export type PriorityCellRequest = {
requestId: string
/** Контур фрагмента заявки внутри ячейки, WKT POLYGON. */
contour: string
/** Процент покрытия ячейки фрагментом заявки. */
coveragePercent: number
/** Snapshot важности заявки из request_cells. */
importance: number
}
/** Ячейка сетки приоритетов: центр, важность, контур (WKT POLYGON) и фрагменты заявок. */
export type PriorityCell = {
cellNum: number
latitude: number
longitude: number
importance: number
/** Контур ячейки, WKT POLYGON (lon lat, ...). */
contour: string
/** Фрагменты заявок, пересекающие ячейку. */
requests: PriorityCellRequest[]
}
/**
* Загрузить карту приоритетов ячеек (ячейки с важностью и контуром) для тепловой карты.
* @param minImportance Порог важности (по умолчанию {@link HEATMAP_MIN_IMPORTANCE}).
*
* @returns Список ячеек сетки с важностью и контурами.
*/
export async function fetchPriorityCells(
minImportance: number = HEATMAP_MIN_IMPORTANCE,
): Promise<PriorityCell[]> {
const params = new URLSearchParams({ minImportance: String(minImportance) })
const url = `${BASE}/cells/priority-map?${params}`
let response: Response
try {
response = await apiFetch(url, { method: 'GET', headers: { Accept: 'application/json' } })
} catch {
throw new TguApiError('Не удалось подключиться к сервису заданий.', 'network')
}
const text = await response.text()
if (!response.ok) {
throw new TguApiError(
extractErrorText(text) || 'Не удалось загрузить карту приоритетов.',
'http',
response.status,
)
}
if (!text.trim()) return []
const parsed = JSON.parse(text) as { items?: PriorityCell[] }
return parsed.items ?? []
}
function extractErrorText(body: string): string {
const trimmed = body.trim()
if (!trimmed) return ''
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>
for (const field of ['message', 'error', 'reason', 'detail']) {
const value = parsed[field]
if (typeof value === 'string' && value.trim()) return value
}
} catch {
return trimmed
}
return trimmed
}
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { bookRequestSlots, fetchRequestSlots } from "./slotsApi";
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
const baseParams = { requestId: "r1", timeStart: "2026-01-01T00:00:00", timeStop: "2026-01-02T00:00:00" };
afterEach(() => vi.restoreAllMocks());
describe("fetchRequestSlots", () => {
it("прокидывает coverageStrategy в query", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
await fetchRequestSlots({ ...baseParams, coverageStrategy: "CONTINUOUS" });
const url = String(fetchMock.mock.calls[0][0]);
expect(url).toContain("request-cover");
expect(url).toContain("coverageStrategy=CONTINUOUS");
});
it("не добавляет coverageStrategy, если не задана", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
await fetchRequestSlots(baseParams);
expect(String(fetchMock.mock.calls[0][0])).not.toContain("coverageStrategy");
});
it("пробрасывает AbortSignal в fetch", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
const ctrl = new AbortController();
await fetchRequestSlots(baseParams, ctrl.signal);
expect(fetchMock.mock.calls[0][1]?.signal).toBe(ctrl.signal);
});
it("отменённый запрос пробрасывает ошибку отмены", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new DOMException("aborted", "AbortError"));
await expect(fetchRequestSlots(baseParams)).rejects.toThrow();
});
it("на не-ok бросает с текстом ошибки сервера", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("boom", { status: 500 }));
await expect(fetchRequestSlots(baseParams)).rejects.toThrow("boom");
});
});
describe("bookRequestSlots", () => {
it("POST с телом requestId+slots и возвращает забронированные", async () => {
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(jsonResponse([{ bookedSlotId: 1, satelliteId: 1, slotNum: 2, cycle: 0 }]));
const res = await bookRequestSlots("r1", [{ satelliteId: 1, slotNum: 2, cycle: 0 }]);
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toContain("booking/request");
expect(init?.method).toBe("POST");
expect(JSON.parse(String(init?.body))).toEqual({
requestId: "r1",
slots: [{ satelliteId: 1, slotNum: 2, cycle: 0 }],
});
expect(res).toHaveLength(1);
});
});
@@ -0,0 +1,123 @@
import type {
BookedSlotDTO,
RequestSlotsParams,
SlotBookingRequest,
SlotDTO,
} from '../model/slotTypes'
import { apiFetch } from './apiFetch'
// slots-service через gateway: /api/pcp-slots/** → StripPrefix=2 → slots-service /api/slots/**
const BASE = '/api/pcp-slots/api/slots'
async function readErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const text = await response.text()
if (!text) return `${fallback}: ${response.status}`
try {
const body = JSON.parse(text) as { error?: string; message?: string }
return body.error || body.message || text
} catch {
return text
}
} catch {
return `${fallback}: ${response.status}`
}
}
/**
* Рассчитывает слоты покрытия заявки (slots-service: GET /api/slots/request-cover).
* @param params Заявка, интервал и фильтры (ветвь, признак покрытия, КА, угол Солнца,
* стратегия автоформирования покрытия).
* @param signal Сигнал отмены (AbortController) — прерывает долгий расчёт.
*
* @returns Список слотов-кандидатов с контурами для карты. При заданной coverageStrategy —
* уже отобранный достаточный набор покрытия (преднабор для брони).
*/
export async function fetchRequestSlots(
params: RequestSlotsParams,
signal?: AbortSignal,
): Promise<SlotDTO[]> {
const query = new URLSearchParams()
query.set('requestId', params.requestId)
query.set('timeStart', params.timeStart)
query.set('timeStop', params.timeStop)
if (params.revSign) query.set('revSign', params.revSign)
if (params.cov != null) query.set('cov', String(params.cov))
if (params.sun != null) query.set('sun', String(params.sun))
if (params.coverageStrategy) query.set('coverageStrategy', params.coverageStrategy)
params.satellites?.forEach((id) => query.append('satellites', String(id)))
const response = await apiFetch(`${BASE}/request-cover?${query}`, {
headers: { Accept: 'application/json' },
signal,
})
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Не удалось рассчитать слоты'))
}
return response.json() as Promise<SlotDTO[]>
}
/**
* Бронирует выбранные слоты под заявку (slots-service: POST /api/slots/booking/request).
* @param requestId Идентификатор заявки.
* @param slots Список слотов (КА, номер слота, цикл).
*
* @returns Забронированные слоты.
*/
export async function bookRequestSlots(
requestId: string,
slots: SlotBookingRequest[],
): Promise<BookedSlotDTO[]> {
const response = await apiFetch(`${BASE}/booking/request`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ requestId, slots }),
})
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Не удалось забронировать слоты'))
}
return response.json() as Promise<BookedSlotDTO[]>
}
/**
* Снимает все брони заявки (slots-service: DELETE /api/slots/booking/request).
* @param requestId Идентификатор заявки.
*
* @returns Сколько броней снято.
*/
export async function cancelRequestBooking(requestId: string): Promise<number> {
const response = await apiFetch(
`${BASE}/booking/request?requestId=${encodeURIComponent(requestId)}`,
{ method: 'DELETE', headers: { Accept: 'application/json' } },
)
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Не удалось снять бронь'))
}
const value = Number(await response.text())
return Number.isFinite(value) ? value : 0
}
/**
* Загружает уже забронированные слоты заявки (slots-service: GET /api/slots/booking/by-request/{id}).
* @param requestId Идентификатор заявки.
*
* @returns Список забронированных слотов с контурами.
*/
export async function fetchBookedSlotsByRequest(requestId: string): Promise<SlotDTO[]> {
const response = await apiFetch(
`${BASE}/booking/by-request/${encodeURIComponent(requestId)}`,
{ headers: { Accept: 'application/json' } },
)
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Не удалось загрузить брони заявки'))
}
return response.json() as Promise<SlotDTO[]>
}
+11 -43
View File
@@ -5,53 +5,21 @@ import {
type TguPlanDecisionMessage,
type TguPlatform,
} from '../model/tguTypes'
import { toLocalIso } from '../utils/localTime'
import { apiFetch } from './apiFetch'
export type FlightLinePoint = {
time: string
revolution: number
lat: number
long: number
latLeft: number
longLeft: number
latInnerLeft: number
longInnerLeft: number
latInnerRight: number
longInnerRight: number
latRight: number
longRight: number
revSign: 'ASC' | 'DESC'
}
/**
* Трасса полёта КА (flight-line) на интервале для отрисовки на карте/таймлайне.
* @param noradId NORAD-идентификатор КА.
* @param fromMs Начало интервала, мс от эпохи.
* @param toMs Конец интервала, мс от эпохи.
*
* @returns Точки трассы полёта на интервале.
* Запросы планирования ТГУ идут напрямую в pcp-tgu-service через gateway (`/api/pcp-tgu` →
* :7011), как и остальные tgu-вызовы (см. tguPlanApi.ts). Решение/подтверждение закладки —
* на тест-контроллере ТГУ (`/test/tgu/*`, гейт `tgu.test-controller.enabled`, MVP-канал).
*/
export async function fetchFlightLine(
noradId: string,
fromMs: number,
toMs: number,
): Promise<FlightLinePoint[]> {
const query = new URLSearchParams({
time_start: toLocalIso(fromMs),
time_stop: toLocalIso(toMs),
})
return fetchJson<FlightLinePoint[]>(
`/api/tgu-planning/spacecraft/${encodeURIComponent(noradId)}/flight-line?${query}`,
)
}
const BASE = '/api/pcp-tgu'
/**
* Список платформ (КА) ТГУ.
* @returns Платформы планирования ТГУ.
*/
export async function fetchPlatforms(): Promise<TguPlatform[]> {
return fetchJson<TguPlatform[]>('/api/tgu-planning/platforms')
return fetchJson<TguPlatform[]>(`${BASE}/api/platforms`)
}
/**
@@ -64,8 +32,8 @@ export async function fetchPlatforms(): Promise<TguPlatform[]> {
export async function fetchPlans(historyDays: number, spacecraftId?: string): Promise<TguPlan[]> {
const query = new URLSearchParams({ historyDays: String(historyDays) })
const path = spacecraftId
? `/api/tgu-planning/spacecraft/${encodeURIComponent(spacecraftId)}/plans?${query}`
: `/api/tgu-planning/plans?${query}`
? `${BASE}/api/plans/${encodeURIComponent(spacecraftId)}?${query}`
: `${BASE}/api/plans?${query}`
return fetchJson<TguPlan[]>(path)
}
@@ -89,7 +57,7 @@ export async function sendPlanDecision(
}
return fetchJson<TguPlanDecisionMessage>(
`/api/tgu-planning/plans/${encodeURIComponent(planId)}/decision?${query}`,
`${BASE}/test/tgu/plans/${encodeURIComponent(planId)}/decision?${query}`,
{ method: 'POST' },
true,
)
@@ -101,7 +69,7 @@ export async function sendPlanDecision(
* @param planId Идентификатор плана.
*/
export async function dismissPlan(planId: string): Promise<void> {
await fetchJson<void>(`/api/tgu-planning/plans/${encodeURIComponent(planId)}/dismiss`, {
await fetchJson<void>(`${BASE}/api/plans/${encodeURIComponent(planId)}/dismiss`, {
method: 'POST',
})
}
@@ -111,7 +79,7 @@ export async function dismissPlan(planId: string): Promise<void> {
* @param planId Идентификатор плана.
*/
export async function restorePlan(planId: string): Promise<void> {
await fetchJson<void>(`/api/tgu-planning/plans/${encodeURIComponent(planId)}/restore`, {
await fetchJson<void>(`${BASE}/api/plans/${encodeURIComponent(planId)}/restore`, {
method: 'POST',
})
}
@@ -122,7 +90,7 @@ export async function restorePlan(planId: string): Promise<void> {
* @param planId Идентификатор плана.
*/
export async function confirmPlanLayin(planId: string): Promise<void> {
await fetchJson<void>(`/api/tgu-planning/plans/${encodeURIComponent(planId)}/confirm-layin`, {
await fetchJson<void>(`${BASE}/test/tgu/plans/${encodeURIComponent(planId)}/confirm-layin`, {
method: 'POST',
})
}
@@ -10,10 +10,10 @@
Возвращает { env, domain, sched } в том же виде, что buildEnv() прототипа,
так что ConstellationDashboard рендерит реальные данные без изменений разметки.
============================================================ */
import { fetchPlatforms, fetchFlightLine, fetchPlans, type FlightLinePoint } from "../../api/tguApi";
import { fetchPlatforms, fetchPlans } from "../../api/tguApi";
import { fetchStations, type StationDto } from "../../api/stationsApi";
import { fetchRequestsForMap } from "../../api/requestApi";
import { fetchSatelliteRva } from "../../api/ballisticsApi";
import { fetchSatelliteRva, fetchFlightLine, type FlightLinePoint } from "../../api/ballisticsApi";
import { fetchPlanModes, type PlanMode } from "../tgu-editor/editorApi";
import type { TguPlatform, TguPlan } from "../../model/tguTypes";
import type { RequestMapItem } from "../../model/requestTypes";
@@ -9,6 +9,7 @@ import {
type ComplexPlanRunSummary
} from "../../api/complexPlanApi";
import { fetchRequestsForMap } from "../../api/requestApi";
import { fetchPriorityCells, type PriorityCell } from "../../api/cellsApi";
import type { RequestMapItem } from "../../model/requestTypes";
import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
import { DateTime24Field } from "../../components/timeFields";
@@ -16,7 +17,9 @@ import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
import { buildTgu2DMapScene } from "../tgu-map-2d/Tgu2DMapLayers";
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
import type { MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { MapObjectInspector } from "../tgu-map-2d/MapObjectInspector";
import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
import { formatVolume } from "../../utils/dataVolume";
@@ -46,6 +49,18 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
const [requestsLoading, setRequestsLoading] = useState(false);
const [requestsError, setRequestsError] = useState<string>();
// Тепловая карта приоритетов ячеек сетки заданий (фоновый слой, тумблер-чекбокс).
const [showHeatmap, setShowHeatmap] = useState(false);
const [priorityCells, setPriorityCells] = useState<PriorityCell[]>([]);
const [heatmapLoading, setHeatmapLoading] = useState(false);
const [heatmapError, setHeatmapError] = useState<string>();
const [heatmapLoaded, setHeatmapLoaded] = useState(false);
// Инспектор объекта карты справа (по аналогии с редактором): выбранные под кликом объекты
// (1 — карточка сразу, ≥2 — список с раскрытием) и id раскрытого в списке.
const [mapHits, setMapHits] = useState<Tgu2DMapSelection[]>([]);
const [activeHitId, setActiveHitId] = useState<string>();
// NORAD-id всех эксплуатируемых КА «Планов» (spacecraftId == norad-id, см. tguTimelineMapper).
const satelliteIds = useMemo(
() =>
@@ -89,9 +104,14 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
[showRequests, requestItems, range, platforms]
);
const heatmapPolygons = useMemo<MapPolygon[]>(
() => (showHeatmap ? buildHeatmapPolygons(priorityCells) : []),
[showHeatmap, priorityCells]
);
const scene = useMemo<Tgu2DMapScene>(
() => ({ polygons: [...modePolygons, ...requestPolygons], lines: [], markers: [] }),
[modePolygons, requestPolygons]
() => ({ polygons: [...heatmapPolygons, ...modePolygons, ...requestPolygons], lines: [], markers: [] }),
[heatmapPolygons, modePolygons, requestPolygons]
);
const layers = useMemo<Tgu2DMapLayersState>(
@@ -100,11 +120,12 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
swath: false,
planWorks: false,
complexPlan: true,
heatmap: showHeatmap,
stations: false,
spacecraftMarkers: false,
requests: showRequests
}),
[showRequests]
[showHeatmap, showRequests]
);
const loadRuns = useCallback(async (): Promise<ComplexPlanRunSummary[]> => {
@@ -121,6 +142,8 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
const loadRunModes = useCallback(async (runId: number) => {
setSelectedRunId(runId);
setMapHits([]);
setActiveHitId(undefined);
setLoadingRun(true);
setError(undefined);
try {
@@ -165,6 +188,57 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
};
}, [showRequests, activeInterval]);
// Карта приоритетов не зависит от интервала — грузим один раз при первом включении чекбокса.
// heatmapLoading намеренно НЕ в зависимостях: иначе setHeatmapLoading(true) перезапустил бы
// эффект, а его cleanup отменил бы собственный незавершённый fetch (cancelled=true).
useEffect(() => {
if (!showHeatmap || heatmapLoaded) return;
let cancelled = false;
setHeatmapLoading(true);
setHeatmapError(undefined);
fetchPriorityCells()
.then((cells) => {
if (!cancelled) {
setPriorityCells(cells);
setHeatmapLoaded(true);
}
})
.catch((caught) => {
if (!cancelled) setHeatmapError(caught instanceof Error ? caught.message : "Не удалось загрузить карту приоритетов.");
})
.finally(() => {
if (!cancelled) setHeatmapLoading(false);
});
return () => {
cancelled = true;
};
}, [showHeatmap, heatmapLoaded]);
// Клик по карте: 0/1 объект — карточка сразу; ≥2 (наложение) — список под точкой клика.
const handleObjectSelect = useCallback((selection?: Tgu2DMapSelection) => {
setMapHits(selection ? [selection] : []);
setActiveHitId(selection?.id);
}, []);
const handleObjectsAtPoint = useCallback((selections: Tgu2DMapSelection[]) => {
// При включённой тепловой карте ячейка приоритета всегда идёт первой в списке и раскрыта
// по умолчанию — оператор сразу видит приоритет точки, а контуры/заявки ниже.
const ordered = showHeatmap
? [...selections].sort((a, b) => Number(b.layer === "heatmap") - Number(a.layer === "heatmap"))
: selections;
setMapHits(ordered);
setActiveHitId(ordered[0]?.id);
}, [showHeatmap]);
const toggleHit = useCallback((selection: Tgu2DMapSelection) => {
setActiveHitId((current) => (current === selection.id ? undefined : selection.id));
}, []);
const closeInfoPanel = useCallback(() => {
setMapHits([]);
setActiveHitId(undefined);
}, []);
const runCalculation = async () => {
if (running || rangeInvalid || rangeTooLong || satelliteIds.length === 0) return;
setRunning(true);
@@ -253,25 +327,56 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
>
{showRequests ? "Скрыть заявки" : "Показать заявки"}
</button>
<label className="tgu-checkbox" title="Тепловая карта приоритетов ячеек сетки заданий">
<input
type="checkbox"
checked={showHeatmap}
onChange={(event) => setShowHeatmap(event.target.checked)}
/>
<span>Тепловая карта</span>
</label>
<div className="tgu-map-toolbar__spacer" style={{ flex: 1 }} />
{displayRun && (
<div className="tgu-map-toolbar__subtitle">
run #{displayRun.runId} · режимов {displayRun.calculatedModesCount} · охват {formatPercent(displayRun.coveredAreaPercent)} · объём {formatVolume(totalVolumeMb)}
{displayRun.snapshotIds.length > 0 ? ` · snapshot ${displayRun.snapshotIds.join(", ")}` : ""} · на карте {contoursCount} контуров
{showRequests ? ` · заявок ${requestItems.length}` : ""}
{showHeatmap ? ` · ячеек ${heatmapPolygons.length}` : ""}
</div>
)}
</div>
<section className="tgu-map-stage">
{overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>}
<Tgu2DMapView
scene={scene}
layers={layers}
redrawToken={modes.length + requestPolygons.length}
onObjectSelect={() => undefined}
/>
</section>
<div
className="tgu-complex-body"
style={{ gridTemplateColumns: mapHits.length > 0 ? "minmax(0, 1fr) 22rem" : "minmax(0, 1fr) 0" }}
>
<section className="tgu-map-stage">
{overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>}
<Tgu2DMapView
scene={scene}
layers={layers}
redrawToken={modes.length + requestPolygons.length + heatmapPolygons.length}
enableObjectChooser
onObjectsAtPoint={handleObjectsAtPoint}
onObjectSelect={handleObjectSelect}
selectedObjectId={activeHitId}
/>
</section>
{mapHits.length > 0 && (
<aside className="tgu-editor-side-panel">
<MapObjectInspector
hits={mapHits}
activeId={activeHitId}
onToggle={toggleHit}
onClose={closeInfoPanel}
modes={modes}
requestItems={requestItems}
priorityCells={priorityCells}
/>
</aside>
)}
</div>
<div className="tgu-map-errors">
{satelliteIds.length === 0 && (
@@ -286,6 +391,14 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
)}
{requestsLoading && <div className="tgu-alert">Загрузка заявок</div>}
{requestsError && <div className="tgu-alert tgu-alert--danger">{requestsError}</div>}
{heatmapLoading && <div className="tgu-alert">Загрузка карты приоритетов</div>}
{heatmapError && <div className="tgu-alert tgu-alert--danger">{heatmapError}</div>}
{showHeatmap && heatmapLoaded && !heatmapError && priorityCells.length === 0 && (
<div className="tgu-alert tgu-alert--warning">
Карта приоритетов пуста на ячейки сетки заданий не спроецировано ни одной заявки
(нужна пересборка сетки в сервисе заданий).
</div>
)}
{runsError && <div className="tgu-alert tgu-alert--danger">{runsError}</div>}
{notice && <div className="tgu-alert">{notice}</div>}
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
@@ -294,6 +407,30 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
);
}
/** Ячейки карты приоритетов → полигоны слоя тепловой карты (контур из WKT + важность + № ячейки). */
function buildHeatmapPolygons(cells: PriorityCell[]): MapPolygon[] {
const result: MapPolygon[] = [];
let zIndex = 0;
for (const cell of cells) {
if (!cell.contour) continue;
const rings = parseWktPolygon(cell.contour);
for (const ring of rings) {
if (ring.length < 3) continue;
result.push({
id: `heatmap-cell:${cell.cellNum}`,
name: `Ячейка ${cell.cellNum}`,
kind: "heatmap",
layer: "heatmap",
points: ring,
importance: cell.importance,
cellNum: cell.cellNum,
zIndex: zIndex++
});
}
}
return result;
}
/** Контуры режимов (survey) → полигоны слоя комплексного плана. Сбросы без контура пропускаем. */
function buildModePolygons(modes: ComplexPlanMode[]): MapPolygon[] {
const polygons: MapPolygon[] = [];
@@ -672,6 +672,17 @@ export function MapInfoPanel({ selection, requestItems, onClose }: MapInfoPanelP
)}
</section>
)}
{selection.type === "polygon" && selection.layer === "swath" && (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
{selection.spacecraftId && (<><dt>КА</dt><dd className="mono">{selection.spacecraftId}</dd></>)}
<dt>Виток</dt>
<dd className="mono">{selection.revolution ?? "—"}</dd>
</dl>
</section>
)}
</div>
);
}
@@ -1,10 +1,9 @@
import { useEffect, useMemo, useRef, useState, useCallback, type MutableRefObject } from "react";
import { fetchRequestsForMap } from "../../api/requestApi";
import { fetchStations, type StationDto } from "../../api/stationsApi";
import { fetchFlightLine, type FlightLinePoint } from "../../api/tguApi";
import type { RequestMapItem } from "../../model/requestTypes";
import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes";
import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi";
import { fetchPointVisibility, fetchSatelliteRva, fetchFlightLine, type FlightLinePoint, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi";
import {
buildSurveyRoute,
calculateDrops,
@@ -16,7 +15,9 @@ import {
validateMission,
type SurveyRoute
} from "../../api/missionPlanningApi";
import { abandonDraft, createDraft } from "../../api/tguPlanApi";
import { abandonDraft, createDraft, fetchDraftPlans } from "../../api/tguPlanApi";
import { TguApiError } from "../../model/tguTypes";
import type { TguPlanUi } from "../../model/timelineTypes";
import { parseLocal, toLocalIso } from "../../utils/localTime";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
@@ -90,6 +91,7 @@ const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = {
swath: true,
planWorks: true,
complexPlan: false,
heatmap: false,
stations: true,
spacecraftMarkers: false,
requests: true
@@ -627,6 +629,19 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
// Гарантирует наличие черновика: при первой правке клонирует текущий план в DRAFT (replace, тот же
// слот) и возвращает его id. Дальше все правки/валидация/promote адресуются именно сюда. Создание
// плана на другую ЗРВ / новой цепочки вынесено во вкладку «Планы».
// Ищет уже открытый DRAFT этого КА с тем же окном, что у базового плана (replace 1:1 совпадает
// по окну). Используется при 409 от createDraft, чтобы подхватить осиротевший черновик.
const adoptExistingDraft = useCallback(async (basePlan: TguPlanUi): Promise<string | undefined> => {
if (!basePlan.spacecraftId) return undefined;
const drafts = await fetchDraftPlans(basePlan.spacecraftId).catch(() => [] as Awaited<ReturnType<typeof fetchDraftPlans>>);
const match = drafts.find(
(draft) =>
Math.abs(parseLocal(draft.startTime) - basePlan.startMs) < 2000 &&
Math.abs(parseLocal(draft.endTime) - basePlan.endMs) < 2000
);
return match?.planId;
}, []);
const ensureDraft = useCallback(async (): Promise<string> => {
if (draftPlanId) return draftPlanId;
if (!selectedPlan) throw new Error("План не выбран — нечего редактировать.");
@@ -635,11 +650,32 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setDraftPlanId(selectedPlan.planId);
return selectedPlan.planId;
}
const created = await createDraft(selectedPlan.planId, { mode: "replace" });
createdImplicitlyRef.current = true;
setDraftPlanId(created.draftPlanId);
return created.draftPlanId;
}, [draftPlanId, selectedPlan]);
try {
const created = await createDraft(selectedPlan.planId, { mode: "replace" });
createdImplicitlyRef.current = true;
setDraftPlanId(created.draftPlanId);
return created.draftPlanId;
} catch (error) {
// 409: по этому КА уже открыт черновик на это же окно (осиротевший после прошлой сессии —
// авто-отмена при уходе best-effort и могла не отработать). Вместо тупиковой ошибки
// «Conflict» подхватываем существующий черновик того же окна и правим его (replace 1:1 —
// окно совпадает с базой). Не нашли подходящий — пробрасываем читаемую ошибку.
if (error instanceof TguApiError && error.status === 409) {
const adopted = await adoptExistingDraft(selectedPlan);
if (adopted) {
createdImplicitlyRef.current = true;
setDraftPlanId(adopted);
return adopted;
}
throw new TguApiError(
"По этому КА уже есть открытый черновик на это окно. Отмените его во вкладке «Планы» (раздел «Черновики») или продолжите редактирование там.",
"http",
409,
);
}
throw error;
}
}, [draftPlanId, selectedPlan, adoptExistingDraft]);
// Бэкенд-валидация черновика (Ф4): авторитетные конфликты по режимам → карта по workId.
// MVP-заглушка возвращает пусто; ошибки валидации не эскалируем в основной поток правок.
@@ -719,7 +755,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
planEnd: toLocalIso(selectedPlan.endMs)
});
setSurveyRoute(savedRoute);
void refreshDraftModes(draftId);
// Ждём перечитывания planModes ДО снятия editingWorkId ниже: иначе на миг editingModeId
// перестаёт исключать старый режим из сцены, а свежие planModes ещё не пришли — и старый
// контур мелькает на карте, пока не сделаешь что-нибудь ещё.
await refreshDraftModes(draftId);
} catch (error) {
setRouteError(error instanceof Error ? error.message : "Не удалось сохранить маршрут съёмки.");
return;
@@ -758,6 +797,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setTarget(undefined);
setTargetPickActive(false);
setSelectedShootPass(undefined);
// Гасим превью окна построения сразу: сохранённый маршрут уже виден из planModes/черновика.
setSurveyRoute(undefined);
setRouteWindow(undefined);
};
const stationNameById = useCallback(
@@ -816,8 +858,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
});
savedDropId = savedDrop.id;
savedDropVolumeMb = savedDrop.volumeMb;
// Перечитываем включения DRAFT (источник истины) и ревалидируем.
void refreshDraftModes(draftId);
// Перечитываем включения DRAFT (источник истины) и ревалидируем. Ждём ДО снятия
// editingWorkId ниже — иначе старая трасса сброса мелькает, пока planModes не обновились.
await refreshDraftModes(draftId);
} catch (error) {
setDropError(error instanceof Error ? error.message : "Не удалось сохранить сброс.");
return;
@@ -1594,6 +1637,8 @@ function buildEditorMapScene(
kind: "swath",
layer: "swath",
points: [...leftEdge, ...rightEdge],
spacecraftId,
revolution,
zIndex: zIndex++
});
}
@@ -1,3 +1,5 @@
import { apiFetch } from "../../api/apiFetch";
export type PlanSurveyMode = {
id: number;
planId: number;
@@ -30,9 +32,10 @@ export type PlanDropMode = {
export type PlanMode = PlanSurveyMode | PlanDropMode;
export async function fetchPlanModes(planId: string): Promise<PlanMode[]> {
const response = await fetch(`/api/current-plans/missions/${encodeURIComponent(planId)}/modes`, {
headers: { Accept: "application/json" }
});
const response = await apiFetch(
`/api/pcp-mission/api/missions/${encodeURIComponent(planId)}/modes`,
{ headers: { Accept: "application/json" } }
);
if (!response.ok) {
throw new Error(`Ошибка загрузки включений плана: ${response.status}`);
}
@@ -0,0 +1,346 @@
import type { ReactNode } from "react";
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
import type { ComplexPlanMode } from "../../api/complexPlanApi";
import type { PriorityCell } from "../../api/cellsApi";
import type { RequestMapItem } from "../../model/requestTypes";
import { statusStyle } from "../tgu-planning/tguStatus";
import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
import { formatVolume } from "../../utils/dataVolume";
/**
* Единый инспектор объектов карты для всех вкладок (Карта, Комплексный план, Редактор):
* показывает данные объекта, выбранного на карте заявки, маршрута/включения, ячейки тепловой
* карты, станции, маркера КА. При наложении объектов (2 под точкой клика) даёт список с
* раскрытием карточки нужного. Данные-обогащение (modes/requestItems/priorityCells) опциональны:
* чего нет, то берётся из самого выбора (имя, статус).
*/
type MapObjectInspectorProps = {
/** Объекты под точкой клика (1 — карточка сразу, ≥2 — список с раскрытием). */
hits: Tgu2DMapSelection[];
/** id раскрытого объекта в списке. */
activeId?: string;
onToggle: (selection: Tgu2DMapSelection) => void;
onClose: () => void;
/** Режимы прогона комплексного плана — для карточки включения (layer "complexPlan"). */
modes?: ComplexPlanMode[];
/** Заявки активного интервала — для карточки заявки (layer "requests"). */
requestItems?: RequestMapItem[];
/** Ячейки карты приоритетов — для карточки ячейки тепловой карты (layer "heatmap"). */
priorityCells?: PriorityCell[];
/** Если задан — в карточке заявки появляется кнопка «Выбрать» (вкладка «Слоты»). */
onSelectRequest?: (requestId: string) => void;
};
export function MapObjectInspector({
hits,
activeId,
onToggle,
onClose,
modes = [],
requestItems = [],
priorityCells = [],
onSelectRequest,
}: MapObjectInspectorProps) {
const renderCard = (selection: Tgu2DMapSelection): ReactNode => {
if (selection.type === "marker") {
return selection.layer === "stations"
? <StationCard selection={selection} />
: <MarkerCard selection={selection} />;
}
if (selection.type === "line") return <LineCard selection={selection} />;
switch (selection.layer) {
case "complexPlan":
return <ModeCard selection={selection} modes={modes} />;
case "requests":
return <RequestCard selection={selection} requestItems={requestItems} onSelectRequest={onSelectRequest} />;
case "heatmap":
return <CellCard selection={selection} priorityCells={priorityCells} />;
case "planWorks":
return <PlanWorkCard selection={selection} />;
default:
return <div className="tgu-editor-panel__hint">{selection.name || selection.id}</div>;
}
};
if (hits.length <= 1) {
const selection = hits[0];
return (
<div className="tgu-editor-panel">
<PanelHead title={selection ? selectionTitle(selection) : "Объект"} onClose={onClose} />
{selection ? renderCard(selection) : <div className="tgu-editor-panel__hint">Объект не выбран.</div>}
</div>
);
}
return (
<div className="tgu-editor-panel">
<PanelHead title={`Объекты под курсором · ${hits.length}`} onClose={onClose} />
<div className="tgu-editor-panel__hint">Кликните объект в списке, чтобы раскрыть его данные.</div>
<div className="tgu-editor-pick">
{hits.map((hit, index) => {
const open = hit.id === activeId;
return (
<div className={`tgu-editor-pick__item${open ? " is-open" : ""}`} key={`${hit.id}:${index}`}>
<button type="button" className="tgu-editor-pick__row" onClick={() => onToggle(hit)}>
<i className={`tgu-editor-pick__swatch tgu-editor-pick__swatch--${swatchKind(hit)}`} />
<span className="tgu-editor-pick__title">{hit.name || selectionTitle(hit)}</span>
<small className="tgu-editor-pick__sub">{selectionTitle(hit)}</small>
<span className="tgu-editor-pick__chevron" aria-hidden="true">{open ? "▾" : "▸"}</span>
</button>
{open && <div className="tgu-editor-pick__detail">{renderCard(hit)}</div>}
</div>
);
})}
</div>
</div>
);
}
type PolygonSelection = Extract<Tgu2DMapSelection, { type: "polygon" }>;
type MarkerSelection = Extract<Tgu2DMapSelection, { type: "marker" }>;
type LineSelection = Extract<Tgu2DMapSelection, { type: "line" }>;
function ModeCard({ selection, modes }: { selection: PolygonSelection; modes: ComplexPlanMode[] }) {
const mode = modes.find((m) => m.id === selection.modeId);
if (!mode) return <div className="tgu-editor-panel__hint">{selection.name || selection.id}</div>;
return (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
<dt>КА</dt>
<dd className="mono">{mode.satelliteId}</dd>
<dt>Тип</dt>
<dd>{modeTypeLabel(mode.type)}</dd>
<dt>Виток</dt>
<dd className="mono">{mode.revolution}</dd>
<dt>Начало</dt>
<dd className="mono">{formatIso(mode.startTime)}</dd>
<dt>Конец</dt>
<dd className="mono">{formatIso(mode.endTime)}</dd>
<dt>Длительность, с</dt>
<dd className="mono">{mode.duration?.toFixed(1) ?? "—"}</dd>
<dt>Крен, °</dt>
<dd className="mono">{mode.roll != null ? mode.roll.toFixed(1) : "—"}</dd>
<dt>Объём</dt>
<dd className="mono">{mode.volumeMb != null ? formatVolume(mode.volumeMb) : "—"}</dd>
<dt> ячейки</dt>
<dd className="mono">{mode.cellNum || "—"}</dd>
<dt>Источник</dt>
<dd>{mode.source || "—"}</dd>
</dl>
</section>
);
}
function RequestCard({
selection,
requestItems,
onSelectRequest,
}: {
selection: PolygonSelection;
requestItems: RequestMapItem[];
onSelectRequest?: (requestId: string) => void;
}) {
const item = selection.requestId ? requestItems.find((r) => r.id === selection.requestId) : undefined;
if (!item) return <div className="tgu-editor-panel__hint">{selection.name || selection.id}</div>;
return (
<section className="tgu-editor-panel__section">
<dl className="tgu-map-request-details__grid">
<dt>ID</dt>
<dd className="mono">{item.id}</dd>
<dt>Название</dt>
<dd>{item.name || "—"}</dd>
<dt>Статус</dt>
<dd>{item.status}</dd>
<dt>Тип съёмки</dt>
<dd>{surveyTypeLabel(item.surveyType)}</dd>
<dt>Начало</dt>
<dd className="mono">{formatIso(item.beginDateTime)}</dd>
<dt>Конец</dt>
<dd className="mono">{formatIso(item.endDateTime)}</dd>
<dt>Важность</dt>
<dd className="mono">{item.importance}</dd>
<dt>Покрытие</dt>
<dd className="mono">{item.coveragePercent != null ? `${item.coveragePercent.toFixed(1)} %` : "—"}</dd>
<dt>КПП</dt>
<dd className="mono">{item.kpp?.join(", ") || "—"}</dd>
<dt>Приоритет передачи</dt>
<dd>{item.highPriorityTransmit ? "Да" : "Нет"}</dd>
</dl>
{onSelectRequest && (
<div className="tgu-editor-panel__actions">
<button
className="tgu-button tgu-button--primary"
type="button"
onClick={() => onSelectRequest(item.id)}
>
Выбрать
</button>
</div>
)}
</section>
);
}
function CellCard({ selection, priorityCells }: { selection: PolygonSelection; priorityCells: PriorityCell[] }) {
const cell = selection.cellNum != null ? priorityCells.find((c) => c.cellNum === selection.cellNum) : undefined;
const importance = cell?.importance ?? selection.importance;
return (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
<dt> ячейки</dt>
<dd className="mono">{cell?.cellNum ?? selection.cellNum ?? "—"}</dd>
<dt>Важность</dt>
<dd className="mono">{importance != null ? importance.toFixed(4) : "—"}</dd>
{cell && (
<>
<dt>Центр</dt>
<dd className="mono">{cell.latitude.toFixed(2)}°, {cell.longitude.toFixed(2)}°</dd>
<dt>Заявок</dt>
<dd className="mono">{cell.requests.length}</dd>
</>
)}
</dl>
{cell && cell.requests.length > 0 && (
<div className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">Заявки в ячейке</div>
<dl className="tgu-map-request-details__grid">
{cell.requests.map((req) => (
<ReqFragmentRow key={req.requestId} requestId={req.requestId} coveragePercent={req.coveragePercent} importance={req.importance} />
))}
</dl>
</div>
)}
</section>
);
}
function PlanWorkCard({ selection }: { selection: PolygonSelection }) {
const style = selection.status ? statusStyle(selection.status) : undefined;
return (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
{selection.spacecraftId && (<><dt>КА</dt><dd className="mono">{selection.spacecraftId}</dd></>)}
{selection.planId && (<><dt>План</dt><dd className="mono">{selection.planId}</dd></>)}
{selection.surveyType && (<><dt>Тип съёмки</dt><dd>{surveyTypeLabel(selection.surveyType)}</dd></>)}
</dl>
{style && (
<span className="tgu-map-status-pill">
<i style={{ backgroundColor: style.color }} />
{style.label}
</span>
)}
</section>
);
}
function StationCard({ selection }: { selection: MarkerSelection }) {
return (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{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>
</section>
);
}
function MarkerCard({ selection }: { selection: MarkerSelection }) {
return (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
{selection.spacecraftId && (<><dt>КА</dt><dd className="mono">{selection.spacecraftId}</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>
</dl>
</section>
);
}
function LineCard({ selection }: { selection: LineSelection }) {
return (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
<dt>Тип</dt>
<dd>{selection.kind === "drop" ? "Сброс на НКПОР" : "Трасса КА"}</dd>
</dl>
</section>
);
}
function ReqFragmentRow({ requestId, coveragePercent, importance }: { requestId: string; coveragePercent: number; importance: number }) {
return (
<>
<dt className="mono" title={requestId}>{requestId.slice(0, 8)}</dt>
<dd className="mono">покрытие {coveragePercent.toFixed(1)} % · важность {importance.toFixed(3)}</dd>
</>
);
}
function PanelHead({ title, onClose }: { title: string; onClose: () => void }) {
return (
<div className="tgu-editor-panel__head">
<h2>{title}</h2>
<button className="tgu-icon-button" type="button" onClick={onClose} title="Закрыть">×</button>
</div>
);
}
/** Заголовок/подпись типа объекта по слою выбора. */
function selectionTitle(selection: Tgu2DMapSelection): string {
if (selection.type === "marker") return selection.layer === "stations" ? "Станция" : "Маркер КА";
if (selection.type === "line") return selection.kind === "drop" ? "Сброс на НКПОР" : "Трасса КА";
switch (selection.layer) {
case "complexPlan": return "Включение комплексного плана";
case "heatmap": return "Ячейка приоритета";
case "requests": return "Заявка на съёмку";
case "planWorks": return "Маршрут съёмки";
case "swath": return "Полоса обзора";
default: return "Объект карты";
}
}
/** Класс цветовой метки в списке выбора (стили в tgu-editor.css). */
function swatchKind(selection: Tgu2DMapSelection): string {
if (selection.type === "marker") return selection.layer === "stations" ? "station" : "spacecraftMarkers";
if (selection.type === "line") return selection.kind === "drop" ? "drop" : "tracks";
return selection.layer;
}
function modeTypeLabel(type: string): string {
switch (type) {
case "SURVEY": return "Съёмка";
case "DROP": return "Сброс";
default: return type;
}
}
function surveyTypeLabel(surveyType: string): string {
switch (surveyType) {
case "OPTICS": return "Оптическая";
case "RSA": return "Радиолокационная (РСА)";
case "COMBINED": return "Комбинированная";
default: return surveyType;
}
}
function formatIso(iso: string): string {
return formatLocalDateTime(parseLocal(iso));
}
@@ -1,14 +1,16 @@
import type { TguPlanUi } from "../../model/timelineTypes";
import { platformLabel } from "../tgu-planning/tguTimelineMapper";
import { statusStyle } from "../tgu-planning/tguStatus";
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
type Tgu2DMapDetailsProps = {
selectedPlan?: TguPlanUi;
selectedObject?: Tgu2DMapSelection;
};
export function Tgu2DMapDetails({ selectedPlan, selectedObject }: Tgu2DMapDetailsProps) {
/**
* Нижняя плашка контекста вкладки «Карта»: какой план выбран на «Планах» (КА/статус).
* Информация по объектам карты живёт в правом инспекторе (MapObjectInspector), не здесь.
*/
export function Tgu2DMapDetails({ selectedPlan }: Tgu2DMapDetailsProps) {
const status = selectedPlan ? statusStyle(selectedPlan.status) : undefined;
return (
@@ -34,15 +36,6 @@ export function Tgu2DMapDetails({ selectedPlan, selectedObject }: Tgu2DMapDetail
) : "-"}
</div>
</div>
<div>
<div className="tgu-map-details__label">Объект карты</div>
<div className="tgu-map-details__value mono">{selectedObject?.name || selectedObject?.id || "-"}</div>
</div>
<div>
<div className="tgu-map-details__label">Работы</div>
<div className="tgu-map-details__value">Работы плана</div>
</div>
</section>
);
}
@@ -1,116 +0,0 @@
import type { RequestMapItem } from "../../model/requestTypes";
import type { MapPolygon } from "./model/mapTypes";
import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
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 formatLocalDateTime(parseLocal(value));
}
function formatDateRange(begin: string, end: string): string {
const b = new Date(parseLocal(begin));
const e = new Date(parseLocal(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}`;
}
@@ -1,43 +0,0 @@
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>
);
}
@@ -6,12 +6,10 @@ import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timeli
import { platformLabel } from "../tgu-planning/tguTimelineMapper";
import { DEFAULT_TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
import type { MapPolygon } from "./model/mapTypes";
import { buildTgu2DMapScene, getLayerAvailability } from "./Tgu2DMapLayers";
import { Tgu2DMapDetails } from "./Tgu2DMapDetails";
import { Tgu2DMapRequestsPanel, type RequestAtPoint } from "./Tgu2DMapRequestsPanel";
import { MapObjectInspector } from "./MapObjectInspector";
import { Tgu2DMapSidebar } from "./Tgu2DMapSidebar";
import { Tgu2DMapStationPanel } from "./Tgu2DMapStationPanel";
import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar";
import { Tgu2DMapView } from "./Tgu2DMapView";
import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval";
@@ -58,15 +56,13 @@ export function Tgu2DMapTab({
const [layers, setLayers] = useState<Tgu2DMapLayersState>(DEFAULT_TGU_2D_MAP_LAYERS);
const [search, setSearch] = useState("");
const [redrawToken, setRedrawToken] = useState(0);
const [selectedObject, setSelectedObject] = useState<Tgu2DMapSelection>();
const [hiddenIds, setHiddenIds] = useState<ReadonlySet<string>>(new Set());
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>();
// Инспектор объекта карты справа (единый для всех вкладок): объекты под кликом и раскрытый.
const [mapHits, setMapHits] = useState<Tgu2DMapSelection[]>([]);
const [activeHitId, setActiveHitId] = useState<string>();
useEffect(() => {
fetchStations()
@@ -183,47 +179,24 @@ export function Tgu2DMapTab({
setLayers((current) => ({ ...current, [key]: !current[key] }));
};
// Клик по карте: 0/1 объект — карточка сразу; ≥2 (наложение) — список под точкой клика.
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);
}
setMapHits(selection ? [selection] : []);
setActiveHitId(selection?.id);
}, []);
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 handleObjectsAtPoint = useCallback((selections: Tgu2DMapSelection[]) => {
setMapHits(selections);
setActiveHitId(selections[0]?.id);
}, []);
const closeStationPanel = useCallback(() => {
setStationPanel(undefined);
const toggleHit = useCallback((selection: Tgu2DMapSelection) => {
setActiveHitId((current) => (current === selection.id ? undefined : selection.id));
}, []);
const closeInfoPanel = useCallback(() => {
setMapHits([]);
setActiveHitId(undefined);
}, []);
return (
@@ -236,7 +209,7 @@ export function Tgu2DMapTab({
onToggleLayer={toggleLayer}
/>
<div className={`tgu-map-content${(requestsAtPoint.length > 0 || stationPanel != null) ? " has-requests-panel" : ""}`}>
<div className={`tgu-map-content${mapHits.length > 0 ? " has-side-panel" : ""}`}>
<Tgu2DMapSidebar
platforms={platforms}
selectedPlanId={selectedPlan?.planId}
@@ -260,23 +233,22 @@ export function Tgu2DMapTab({
scene={mapInvalidRange ? { polygons: [], lines: [], markers: [] } : scene}
layers={layers}
redrawToken={redrawToken}
enableObjectChooser
onObjectsAtPoint={handleObjectsAtPoint}
onObjectSelect={handleObjectSelect}
onRequestsAtPoint={handleRequestsAtPoint}
hoveredRequestPolygonId={hoveredRequestPolygonId}
selectedRequestPolygonId={selectedRequestPolygonId}
selectedObjectId={activeHitId}
/>
</section>
{requestsAtPoint.length > 0 && (
<Tgu2DMapRequestsPanel
requests={requestsAtPoint}
selectedPolygonId={selectedRequestPolygonId}
onHover={setHoveredRequestPolygonId}
onSelect={setSelectedRequestPolygonId}
onClose={closeRequestsPanel}
/>
)}
{stationPanel != null && (
<Tgu2DMapStationPanel selection={stationPanel} onClose={closeStationPanel} />
{mapHits.length > 0 && (
<aside className="tgu-editor-side-panel">
<MapObjectInspector
hits={mapHits}
activeId={activeHitId}
onToggle={toggleHit}
onClose={closeInfoPanel}
requestItems={requestsState.items}
/>
</aside>
)}
</div>
@@ -290,7 +262,7 @@ export function Tgu2DMapTab({
)}
</div>
<Tgu2DMapDetails selectedPlan={selectedPlan} selectedObject={selectedObject} />
<Tgu2DMapDetails selectedPlan={selectedPlan} />
</main>
);
}
@@ -314,6 +286,8 @@ function layerLabel(layer: Tgu2DMapLayerKey): string {
return "Работы плана";
case "complexPlan":
return "Комплексный план";
case "heatmap":
return "Тепловая карта";
case "stations":
return "Станции";
case "spacecraftMarkers":
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { drawBaseMap } from "./canvas/drawBaseMap";
import { drawComplexPlan } from "./canvas/drawComplexPlan";
import { drawHeatmap } from "./canvas/drawHeatmap";
import { drawPlanWorks } from "./canvas/drawPlanWorks";
import { drawRequests } from "./canvas/drawRequests";
import { drawStations } from "./canvas/drawStations";
@@ -23,6 +24,8 @@ type Tgu2DMapViewProps = {
selectedRequestPolygonId?: string;
/** Внешняя подсветка объекта (контур съёмки или линия сброса) по его id — «как будто кликнули на карте». */
selectedObjectId?: string;
/** Отмеченные объекты (постоянная подсветка независимо от выбора) — напр. слоты, отобранные под бронь. */
markedObjectIds?: ReadonlySet<string>;
/** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */
focusPoint?: GeoPoint;
focusToken?: number;
@@ -83,6 +86,7 @@ export function Tgu2DMapView({
hoveredRequestPolygonId,
selectedRequestPolygonId,
selectedObjectId,
markedObjectIds,
focusPoint,
focusToken,
enableObjectChooser = false,
@@ -111,6 +115,10 @@ export function Tgu2DMapView({
const [tooltip, setTooltip] = useState<TooltipState>();
// Гео-координаты под курсором — показываем по центру внизу карты.
const [cursorGeo, setCursorGeo] = useState<GeoPoint>();
// Линейка: включается кнопкой в правом нижнем углу; в активном режиме клик по карте
// добавляет вершину замера, а наложение рисует ломаную с накопленным расстоянием.
const [rulerActive, setRulerActive] = useState(false);
const [rulerPoints, setRulerPoints] = useState<GeoPoint[]>([]);
const selectedPolygonRef = useRef<MapPolygon | undefined>(undefined);
const selectedLineRef = useRef<MapLine | undefined>(undefined);
const activePolygons = useMemo(
@@ -193,6 +201,9 @@ export function Tgu2DMapView({
drawTiles(context, view, projection, size, handleTileLoad);
drawBaseMap(context, projection);
if (layers.heatmap) {
drawHeatmap(context, visiblePolygons.filter((polygon) => polygon.layer === "heatmap"), projection, size);
}
if (layers.requests) {
drawRequests(context, visiblePolygons.filter((polygon) => polygon.layer === "requests"), projection, size);
}
@@ -252,6 +263,10 @@ export function Tgu2DMapView({
);
const onPointerDown = (event: React.PointerEvent) => {
// Нажатие на кнопки управления картой (зум, линейка, «Сброс») не должно запускать перетаскивание
// и захват указателя: setPointerCapture на контейнере иначе перехватывает жест, и кнопки внутри
// карты перестают «кликаться» (родная причина того, что +/−/□ и «Сброс» не срабатывали).
if ((event.target as HTMLElement).closest("button")) return;
const screenPoint = screenPointFromEvent(event);
hasDraggedRef.current = false;
dragRef.current = {
@@ -299,6 +314,14 @@ export function Tgu2DMapView({
wrapperRef.current?.releasePointerCapture(event.pointerId);
};
// Линейка: переключаем режим; при выключении замер очищаем.
const toggleRuler = () => {
setRulerActive((active) => {
if (active) setRulerPoints([]);
return !active;
});
};
// Применить выбор конкретного объекта (из меню выбора либо при единственном попадании):
// выставляем внутреннюю подсветку и сообщаем наверх через onObjectSelect.
const applyEditorHit = useCallback((hit: EditorHit) => {
@@ -309,17 +332,32 @@ export function Tgu2DMapView({
}, [onObjectSelect]);
// Клик в редакторе: собираем ВСЕ интерактивные объекты под точкой (заявки, маршруты съёмки,
// сбросы, станции). 0 → возможно полоса обзора под низом; 1 → выбираем сразу;
// сбросы, станции). 0 → смотрим на полосы обзора; 1 → выбираем сразу;
// ≥2 → отдаём список наверх (оператор выбирает нужный в правой панели).
const handleEditorClick = useCallback((screenPoint: ScreenPoint) => {
const geoPoint = projection.unproject(screenPoint);
const hits: EditorHit[] = [];
// Полосы обзора собираем отдельно: они почти всегда лежат под кликом и не должны
// превращать выбор заявки/маршрута в список. Их список показываем, только когда под точкой
// нет другого интерактивного объекта, а самих полос несколько (наложение соседних витков).
const swathHits: EditorHit[] = [];
const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
if (marker) hits.push({ kind: "marker", marker, selection: markerToSelection(marker) });
for (const polygon of hitTestAllPolygons(polygonIndex, geoPoint)) {
if (polygon.layer !== "requests" && polygon.layer !== "planWorks") continue;
if (polygon.layer === "swath") {
swathHits.push({ kind: "polygon", polygon, selection: polygonToSelection(polygon) });
continue;
}
if (
polygon.layer !== "requests" &&
polygon.layer !== "planWorks" &&
polygon.layer !== "complexPlan" &&
polygon.layer !== "heatmap"
) {
continue;
}
hits.push({ kind: "polygon", polygon, selection: polygonToSelection(polygon) });
}
@@ -329,19 +367,38 @@ export function Tgu2DMapView({
}
if (hits.length >= 2 && onObjectsAtPoint) {
// Несколько объектов — внутреннюю подсветку снимаем (её ведёт правая панель через selectedObjectId).
// Несколько интерактивных объектов — показываем список. В него добавляем и полосы обзора,
// лежащие под точкой, чтобы их данные тоже были видны. Порядок: маршрут → заявка → полоса.
const combined = [...hits, ...swathHits].sort((a, b) => editorHitRank(a) - editorHitRank(b));
// Внутреннюю подсветку снимаем (её ведёт правая панель через selectedObjectId).
selectedPolygonRef.current = undefined;
selectedMarkerRef.current = undefined;
selectedLineRef.current = undefined;
onObjectsAtPoint(hits.map((hit) => hit.selection));
onObjectsAtPoint(combined.map((hit) => hit.selection));
return;
}
if (hits.length >= 1) {
if (hits.length === 1) {
// Единственный интерактивный объект — выбираем сразу (полосы обзора лежат под кликом почти
// всегда, поэтому ради одного объекта список не показываем — иначе любой клик стал бы списком).
applyEditorHit(hits[0]);
return;
}
// Ничего «интерактивного» — выбираем верхний прочий полигон (напр. полосу обзора) либо снимаем выбор.
// Другого интерактивного объекта под точкой нет — работаем с полосами обзора:
// ≥2 (наложение витков) → список справа; ровно одна → выбираем её.
if (swathHits.length >= 2 && onObjectsAtPoint) {
selectedPolygonRef.current = undefined;
selectedMarkerRef.current = undefined;
selectedLineRef.current = undefined;
onObjectsAtPoint(swathHits.map((hit) => hit.selection));
return;
}
if (swathHits.length === 1) {
applyEditorHit(swathHits[0]);
return;
}
// Совсем ничего интерактивного — выбираем верхний прочий полигон либо снимаем выбор.
const fallback = hitTestPolygons(polygonIndex, geoPoint);
selectedPolygonRef.current = fallback;
selectedMarkerRef.current = undefined;
@@ -352,6 +409,11 @@ export function Tgu2DMapView({
const onClick = (event: React.MouseEvent) => {
if (hasDraggedRef.current) return;
const screenPoint = screenPointFromEvent(event);
if (rulerActive) {
// Режим линейки: клик добавляет вершину замера, выбор объектов не трогаем.
setRulerPoints((points) => [...points, projection.unproject(screenPoint)]);
return;
}
if (drawMode) {
onDrawAddPoint?.(projection.unproject(screenPoint));
return;
@@ -438,8 +500,22 @@ export function Tgu2DMapView({
}));
};
const selectedPolygon = selectedPolygonRef.current;
const selectedMarker = selectedMarkerRef.current;
// Внутренние ref-выделения (клик по карте) могут указывать на объект, которого уже нет в сцене —
// например, контур съёмки, скрытый при входе в режим правки (sceneModes исключает редактируемый
// режим). SVG-оверлей рисуется на каждый рендер из ref, поэтому без сверки с живой сценой старая
// подсветка «висит» поверх карты, пока не кликнешь в другое место. Рисуем только то, что есть в сцене.
const selectedPolygon =
selectedPolygonRef.current && activePolygons.some((p) => p.id === selectedPolygonRef.current!.id)
? selectedPolygonRef.current
: undefined;
const selectedLine =
selectedLineRef.current && scene.lines.some((l) => l.id === selectedLineRef.current!.id)
? selectedLineRef.current
: undefined;
const selectedMarker =
selectedMarkerRef.current && activeMarkers.some((m) => m.id === selectedMarkerRef.current!.id)
? selectedMarkerRef.current
: undefined;
const externalSelectedPolygon = useMemo(() => {
const id = selectedRequestPolygonId ?? selectedObjectId;
return id ? activePolygons.find((p) => p.id === id) : undefined;
@@ -450,17 +526,37 @@ export function Tgu2DMapView({
[selectedObjectId, scene.lines]
);
// Линии под подсветкой: выбранная кликом на карте (внутренняя) и заданная извне (по selectedObjectId).
const overlayLines = uniqueLines([selectedLineRef.current, externalSelectedLine]);
const overlayLines = uniqueLines([selectedLine, externalSelectedLine]);
const externalHoveredPolygon = useMemo(
() => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined,
[hoveredRequestPolygonId, activePolygons]
);
const overlayPolygons = uniquePolygons([externalSelectedPolygon, selectedPolygon, externalHoveredPolygon, hoverRef.current]);
// Отмеченные объекты (напр. отобранные под бронь слоты) — постоянная подсветка под обычным выбором.
const markedPolygons = useMemo(
() =>
markedObjectIds && markedObjectIds.size > 0
? activePolygons.filter((p) => markedObjectIds.has(p.id))
: [],
[markedObjectIds, activePolygons]
);
const overlayMarkers = uniqueMarkers([selectedMarker, hoverMarkerRef.current]);
// Накопленное расстояние (км) от первой вершины линейки до каждой — по дуге большого круга.
const rulerCumKm = useMemo(() => {
const out: number[] = [];
let acc = 0;
rulerPoints.forEach((point, i) => {
if (i > 0) acc += haversineKm(rulerPoints[i - 1], point);
out.push(acc);
});
return out;
}, [rulerPoints]);
const rulerTotalKm = rulerCumKm.length > 0 ? rulerCumKm[rulerCumKm.length - 1] : 0;
return (
<div
className={`tgu-2d-map ${pickMode || drawMode ? "is-picking" : ""}`}
className={`tgu-2d-map ${pickMode || drawMode || rulerActive ? "is-picking" : ""}`}
ref={wrapperRef}
onClick={onClick}
onPointerDown={onPointerDown}
@@ -478,6 +574,24 @@ export function Tgu2DMapView({
>
<canvas className="tgu-2d-map__canvas" ref={canvasRef} />
<svg className="tgu-2d-map__overlay" width={size.width} height={size.height} aria-hidden="true">
{markedPolygons.flatMap((polygon) => {
const raw = polygon.points.map((point) => projection.project(point));
const pts = unwrapX(raw, projection.worldSize);
return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => {
const visible = pts.some((p) => {
const x = p.x + dx;
return x >= -64 && x <= size.width + 64 && p.y >= -64 && p.y <= size.height + 64;
});
if (!visible) return [];
return (
<polygon
key={`marked:${polygon.id}:${dx}`}
points={pts.map((p) => `${p.x + dx},${p.y}`).join(" ")}
className="is-marked"
/>
);
});
})}
{overlayPolygons.flatMap((polygon) => {
const raw = polygon.points.map((point) => projection.project(point));
const pts = unwrapX(raw, projection.worldSize);
@@ -568,6 +682,28 @@ export function Tgu2DMapView({
</g>
);
})()}
{rulerPoints.length > 0 && (() => {
const pts = unwrapX(rulerPoints.map((point) => projection.project(point)), projection.worldSize);
return (
<g className="tgu-2d-map__ruler">
{pts.length >= 2 && (
<polyline
className="tgu-2d-map__ruler-line"
points={pts.map((p) => `${p.x},${p.y}`).join(" ")}
fill="none"
/>
)}
{pts.map((p, i) => (
<g key={i}>
<circle cx={p.x} cy={p.y} r={4} className="tgu-2d-map__ruler-vertex" />
<text x={p.x + 8} y={p.y - 8} className="tgu-2d-map__ruler-label">
{i === 0 ? "0" : formatDistanceKm(rulerCumKm[i])}
</text>
</g>
))}
</g>
);
})()}
</svg>
{tooltip && (
<div className="tgu-2d-map__tooltip" style={{ left: tooltip.x, top: tooltip.y }}>
@@ -577,15 +713,69 @@ export function Tgu2DMapView({
)}
{cursorGeo && <div className="tgu-2d-map__coords">{formatLatLon(cursorGeo)}</div>}
<div className="tgu-2d-map__attribution">© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> contributors · © <a href="https://carto.com/attributions" target="_blank" rel="noopener noreferrer">CARTO</a></div>
{rulerActive && (
<div className="tgu-2d-map__ruler-total">
<span className="tgu-2d-map__ruler-total-label">Линейка</span>
<span className="tgu-2d-map__ruler-total-value">
{rulerPoints.length < 2 ? "кликайте по карте" : formatDistanceKm(rulerTotalKm)}
</span>
{rulerPoints.length > 0 && (
<button
type="button"
className="tgu-2d-map__ruler-reset"
onClick={(event) => {
event.stopPropagation();
setRulerPoints([]);
}}
>
Сброс
</button>
)}
</div>
)}
<div className="tgu-2d-map__zoom">
<button type="button" onClick={() => setView((current) => ({ ...current, zoom: Math.min(8, current.zoom + 0.5) }))}>+</button>
<button type="button" onClick={() => setView((current) => ({ ...current, zoom: Math.max(0.8, current.zoom - 0.5) }))}>-</button>
<button type="button" onClick={() => setView(INITIAL_VIEW)}></button>
<button
type="button"
className={`tgu-2d-map__zoom-ruler${rulerActive ? " is-active" : ""}`}
title="Линейка — измерение расстояния по карте"
aria-pressed={rulerActive}
onClick={(event) => {
event.stopPropagation();
toggleRuler();
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="2.5" y="8.5" width="19" height="7" rx="1" />
<path d="M6.5 8.5v3M10 8.5v4M13.5 8.5v3M17 8.5v4" />
</svg>
</button>
<button type="button" onClick={(event) => { event.stopPropagation(); setView((current) => ({ ...current, zoom: Math.min(8, current.zoom + 0.5) })); }}>+</button>
<button type="button" onClick={(event) => { event.stopPropagation(); setView((current) => ({ ...current, zoom: Math.max(0.8, current.zoom - 0.5) })); }}>-</button>
<button type="button" title="Вернуть обзор" onClick={(event) => { event.stopPropagation(); setView(INITIAL_VIEW); }}></button>
</div>
</div>
);
}
/** Расстояние между двумя гео-точками по дуге большого круга (haversine), км. */
function haversineKm(a: GeoPoint, b: GeoPoint): number {
const R = 6371;
const toRad = Math.PI / 180;
const dLat = (b.lat - a.lat) * toRad;
const dLon = (b.lon - a.lon) * toRad;
const lat1 = a.lat * toRad;
const lat2 = b.lat * toRad;
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
}
/** Человекочитаемое расстояние: метры до 1 км, иначе километры. */
function formatDistanceKm(km: number): string {
if (km < 1) return `${(km * 1000).toFixed(0)} м`;
if (km < 100) return `${km.toFixed(2)} км`;
return `${km.toFixed(1)} км`;
}
/** Подпись координат под курсором: широта/долгота с полушариями. */
function formatLatLon(point: GeoPoint): string {
const lat = point.lat;
@@ -603,6 +793,8 @@ function layerTitle(layer: MapPolygon["layer"]): string {
return "Полоса обзора";
case "complexPlan":
return "Комплексный план";
case "heatmap":
return "Ячейка приоритета";
case "tracks":
return "Трасса КА";
case "requests":
@@ -682,6 +874,26 @@ type EditorHit =
| { kind: "polygon"; polygon: MapPolygon; selection: Tgu2DMapSelection }
| { kind: "line"; line: MapLine; selection: Tgu2DMapSelection };
// Порядок объектов в списке выбора под точкой: сначала маршрут съёмки, затем заявка,
// в конце — полоса обзора (как фон). Остальные объекты — между ними.
function editorHitRank(hit: EditorHit): number {
if (hit.kind === "marker") return 0; // станции / маркеры КА
if (hit.kind === "line") return 3; // сбросы
switch (hit.polygon.layer) {
case "planWorks": // маршрут съёмки
case "complexPlan":
return 1;
case "requests": // заявка на съёмку
return 2;
case "heatmap":
return 4;
case "swath": // полоса обзора — последней
return 5;
default:
return 6;
}
}
function markerToSelection(marker: MapMarker): Tgu2DMapSelection {
return {
type: "marker",
@@ -708,7 +920,10 @@ function polygonToSelection(polygon: MapPolygon): Tgu2DMapSelection {
requestId: polygon.requestId,
surveyType: polygon.surveyType,
status: polygon.status,
modeId: polygon.modeId
modeId: polygon.modeId,
revolution: polygon.revolution,
importance: polygon.importance,
cellNum: polygon.cellNum
};
}
@@ -0,0 +1,86 @@
import type { MapProjection } from "../geometry/mapProjection";
import type { MapPolygon } from "../model/mapTypes";
type Pt = { x: number; y: number };
function unwrapX(points: Pt[], worldSize: number): Pt[] {
if (points.length === 0) return [];
const out: Pt[] = [points[0]];
let prevX = points[0].x;
for (let i = 1; i < points.length; i++) {
let x = points[i].x;
while (x - prevX > worldSize / 2) x -= worldSize;
while (prevX - x > worldSize / 2) x += worldSize;
out.push({ x, y: points[i].y });
prevX = x;
}
return out;
}
/**
* Фоновый слой тепловой карты приоритетов: заливка ячеек сетки заданий цветом по важности
* (coolhot). Важность нормируется к максимуму набора, поэтому шкала подстраивается под данные.
* Рисуется ПОД контурами плана/заявок, без обводки и hit-теста это фон, а не объекты.
*/
export function drawHeatmap(
ctx: CanvasRenderingContext2D,
cells: MapPolygon[],
projection: MapProjection,
viewport: { width: number; height: number }
) {
if (cells.length === 0) return;
const maxImportance = cells.reduce((max, cell) => Math.max(max, cell.importance ?? 0), 0);
if (maxImportance <= 0) return;
ctx.save();
for (const cell of cells) {
if (cell.points.length < 3) continue;
const raw = cell.points.map((p) => projection.project(p));
const pts = unwrapX(raw, projection.worldSize);
// sqrt-нормировка слегка поднимает слабые ячейки, чтобы они не сливались с фоном.
const t = Math.sqrt(Math.min(1, Math.max(0, (cell.importance ?? 0) / maxImportance)));
ctx.fillStyle = heatColor(t);
for (const dx of [0, projection.worldSize, -projection.worldSize]) {
const visible = pts.some((p) => {
const x = p.x + dx;
return x >= -64 && x <= viewport.width + 64 && p.y >= -64 && p.y <= viewport.height + 64;
});
if (!visible) continue;
ctx.beginPath();
for (let i = 0; i < pts.length; i++) {
if (i === 0) ctx.moveTo(pts[i].x + dx, pts[i].y);
else ctx.lineTo(pts[i].x + dx, pts[i].y);
}
ctx.closePath();
ctx.fill();
}
}
ctx.restore();
}
/**
* Цвет тепловой карты по нормированной важности t[0,1]: бирюзовый (низкая)
* жёлтый красный (высокая). Альфа растёт с важностью, чтобы «холодные» ячейки были легче.
*/
function heatColor(t: number): string {
// Кусочно-линейная палитра: 0 → #2BD0C4 (cool), 0.5 → #F5D020 (warm), 1 → #F03B20 (hot).
const cool = [43, 208, 196];
const warm = [245, 208, 32];
const hot = [240, 59, 32];
let rgb: number[];
if (t < 0.5) {
rgb = mix(cool, warm, t / 0.5);
} else {
rgb = mix(warm, hot, (t - 0.5) / 0.5);
}
const alpha = 0.35 + 0.4 * t;
return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha.toFixed(3)})`;
}
function mix(a: number[], b: number[], k: number): number[] {
return [
Math.round(a[0] + (b[0] - a[0]) * k),
Math.round(a[1] + (b[1] - a[1]) * k),
Math.round(a[2] + (b[2] - a[2]) * k),
];
}
@@ -24,6 +24,7 @@ export function hitTestAllPolygons(index: PolygonSpatialIndex, point: GeoPoint):
const hits: MapPolygon[] = [];
for (const entry of index.entries) {
if (entry.polygon.pickable === false) continue; // контур отрисовывается, но из выбора исключён
const inBbox =
bboxContainsPoint(entry.bbox, point) ||
bboxContainsPoint(entry.bbox, { lon: point.lon + 360, lat: point.lat }) ||
@@ -46,6 +47,7 @@ export function hitTestPolygons(index: PolygonSpatialIndex, point: GeoPoint): Ma
let selected: MapPolygon | undefined;
for (const entry of index.entries) {
if (entry.polygon.pickable === false) continue; // контур отрисовывается, но из выбора исключён
// Check bbox allowing ±360° shifts so antimeridian-crossing polygons are not skipped.
const inBbox =
bboxContainsPoint(entry.bbox, point) ||
@@ -3,6 +3,7 @@ export type Tgu2DMapLayerKey =
| "swath"
| "planWorks"
| "complexPlan"
| "heatmap"
| "stations"
| "spacecraftMarkers"
| "requests";
@@ -38,6 +39,7 @@ export const DEFAULT_TGU_2D_MAP_LAYERS: Tgu2DMapLayersState = {
swath: false,
planWorks: true,
complexPlan: false,
heatmap: false,
stations: true,
spacecraftMarkers: false,
requests: true
@@ -5,7 +5,7 @@ export type Tgu2DMapSelection =
type: "polygon";
id: string;
name: string;
layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan";
layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan" | "heatmap";
planId?: string;
spacecraftId?: string;
requestId?: string;
@@ -13,6 +13,12 @@ export type Tgu2DMapSelection =
status?: TguPlanStatus;
/** id режима плана (включения) для polygon-ов слоя planWorks — связь с работой таймлайна. */
modeId?: number;
/** Номер витка (layer "swath") — для карточки полосы обзора. */
revolution?: number;
/** Важность ячейки тепловой карты (layer "heatmap"). */
importance?: number;
/** Номер ячейки сетки заданий (layer "heatmap"). */
cellNum?: number;
}
| {
type: "line";
@@ -24,8 +24,8 @@ export type MapViewState = {
export type MapPolygon = {
id: string;
name: string;
kind: "planWork" | "swath" | "trackZone" | "request" | "complexPlan";
layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan";
kind: "planWork" | "swath" | "trackZone" | "request" | "complexPlan" | "heatmap";
layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan" | "heatmap";
points: GeoPoint[];
status?: TguPlanStatus;
planId?: string;
@@ -34,6 +34,14 @@ export type MapPolygon = {
surveyType?: string;
/** id режима плана (survey), к которому относится контур — для связи «включение ↔ объект на карте». */
modeId?: number;
/** Номер витка (layer "swath") — для карточки полосы обзора. */
revolution?: number;
/** Важность ячейки тепловой карты (layer "heatmap"): задаёт цвет заливки и показывается в инспекторе. */
importance?: number;
/** Номер ячейки сетки заданий (layer "heatmap") — ключ для поиска полной карточки ячейки. */
cellNum?: number;
/** false — контур рисуется, но исключён из hit-теста/выбора (напр. контур заявки на вкладке «Слоты»). */
pickable?: boolean;
zIndex: number;
};
@@ -52,6 +52,7 @@ describe("tgu2DMapInterval", () => {
swath: false,
planWorks: true,
complexPlan: false,
heatmap: false,
stations: true,
spacecraftMarkers: false,
requests: true
@@ -82,14 +82,20 @@ export function TguPlanDetails({
const duration = formatDuration(plan.endMs - plan.startMs);
// Окно решения закрылось, если с момента старта плана прошло больше grace-периода.
const decisionWindowClosed = Date.now() > plan.startMs + DECISION_GRACE_MINUTES * 60_000;
// Закладку нельзя подтверждать, пока начало плана ещё в будущем: план закладывается на КА
// по факту наступления своего старта, а не заранее.
const layinStartInFuture = plan.startMs > Date.now();
// Начало плана ещё в будущем. Нужно и для закладки (план закладывается по факту старта,
// не заранее), и для replace-черновика (после старта окно выдачи заморожено — бэк вернёт 422).
const startInFuture = plan.startMs > Date.now();
// «Создать черновик на основе плана» (replace, окно 1:1, все включения) доступно для
// персистентных правимых баз. DRAFT уже имеет свои действия; PLANNED — виртуальная проекция,
// не база; LAID_IN — replace 1:1 запрещён (слот зафиксирован, только fork-forward).
const canCreateDraftFromPlan =
// персистентных правимых баз, у которых старт ещё не наступил. DRAFT уже имеет свои действия;
// PLANNED — виртуальная проекция, не база; LAID_IN — replace 1:1 запрещён (слот зафиксирован);
// стартовавший план — окно выдачи заморожено (CreateDraftUseCase.createReplaceDraft → 422),
// продолжение возможно только fork-forward (отдельный поток). Гард зеркалит редактор, чтобы
// не предлагать кнопку, которая заведомо упадёт «Unprocessable Content».
const draftableStatus =
plan.status !== "DRAFT" && plan.status !== "PLANNED" && plan.status !== "LAID_IN";
const canCreateDraftFromPlan = draftableStatus && startInFuture;
// Статус позволял бы replace-черновик, но план уже стартовал — объясняем, почему кнопки нет.
const draftBlockedByStart = draftableStatus && !startInFuture;
const reject = () => {
const reason = window.prompt("Reason для REJECTED", "UI_TEST_REJECTED");
@@ -229,15 +235,23 @@ export function TguPlanDetails({
</div>
)}
{draftBlockedByStart && (
<div className="tgu-alert tgu-alert--warning">
Черновик-копию (replace, то же окно) можно создать только до старта плана окно
выдачи уже заморожено. Продолжение цепочки на будущую ЗРВ (fork-forward) выполняется
отдельно.
</div>
)}
{plan.status === "AWAITING_LAYIN" && onConfirmLayin && (
<div className="tgu-details__test-block">
<div className="tgu-details__test-label">ТЕСТОВЫЙ ФУНКЦИОНАЛ</div>
<button
className="tgu-button tgu-button--accept"
type="button"
disabled={decisionInFlight || layinConflict || layinStartInFuture}
disabled={decisionInFlight || layinConflict || startInFuture}
title={
layinStartInFuture
startInFuture
? "Начало плана ещё не наступило — подтвердить закладку можно только после его старта"
: layinConflict
? "По КА уже заложен план со стартом в пределах ±10 минут — закладка недоступна"
@@ -247,12 +261,12 @@ export function TguPlanDetails({
>
Подтвердить закладку
</button>
{layinStartInFuture && (
{startInFuture && (
<div className="tgu-details__test-hint">
Начало плана ещё в будущем подтвердить закладку можно только после наступления его старта.
</div>
)}
{!layinStartInFuture && layinConflict && (
{!startInFuture && layinConflict && (
<div className="tgu-details__test-hint">
По КА уже есть заложенный план со стартом в пределах ±10 минут подтвердить нельзя.
</div>
@@ -14,6 +14,7 @@ import { ConstellationTab } from "../constellation/ConstellationTab";
import { TguEditorTab } from "../tgu-editor/TguEditorTab";
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
import { TguRequestsTab } from "../tgu-requests/TguRequestsTab";
import { TguSlotsTab } from "../tgu-slots/TguSlotsTab";
import {
abandonDraft,
createDraft,
@@ -587,6 +588,12 @@ export function TguPlanningPage() {
</div>
)}
{visitedTabs.has("slots") && (
<div style={tabStyle("slots")}>
<TguSlotsTab range={appliedRange} />
</div>
)}
{visitedTabs.has("constellation") && (
<div style={tabStyle("constellation")}>
<ConstellationTab />
@@ -26,6 +26,11 @@ export function TguToolbar({
onApply,
onQuickRange
}: TguToolbarProps) {
// Глобальный диапазон «С … По» относится только к вкладке «Планы». Остальные вкладки имеют свой
// источник интервала: «Карта» и «Заявки» — фильтр дат в левой панели, «Комплексный план» — свой
// диапазон внутри вкладки, «Редактор» отражает выбранный на «Планах» план (его окно задаётся
// планом), «Группировка» диапазоном не пользуется. Поэтому панель показываем только на «Планах».
const showRange = activeTab === "timeline";
return (
<header className="tgu-toolbar">
<div className="tgu-toolbar__row">
@@ -83,6 +88,15 @@ export function TguToolbar({
>
Заявки
</button>
<button
className={activeTab === "slots" ? "is-active" : ""}
type="button"
role="tab"
aria-selected={activeTab === "slots"}
onClick={() => onTabChange("slots")}
>
Слоты
</button>
<button
className={activeTab === "constellation" ? "is-active" : ""}
type="button"
@@ -93,29 +107,33 @@ export function TguToolbar({
Группировка
</button>
</div>
<label className="tgu-field">
<span>С</span>
<DateTime24Field value={fromValue} onChange={onFromChange} />
</label>
<label className="tgu-field">
<span>По</span>
<DateTime24Field value={toValue} onChange={onToChange} />
</label>
<button className="tgu-button tgu-button--primary" type="button" onClick={onApply} disabled={loading}>
Показать
</button>
<button className="tgu-button" type="button" onClick={() => onQuickRange(24)} disabled={loading}>
24ч
</button>
<button className="tgu-button" type="button" onClick={() => onQuickRange(72)} disabled={loading}>
3 суток
</button>
<button className="tgu-button" type="button" onClick={() => onQuickRange(168)} disabled={loading}>
7 суток
</button>
</div>
{showRange && (
<div className="tgu-toolbar__row tgu-toolbar__row--range">
<label className="tgu-field">
<span>С</span>
<DateTime24Field value={fromValue} onChange={onFromChange} />
</label>
<label className="tgu-field">
<span>По</span>
<DateTime24Field value={toValue} onChange={onToChange} />
</label>
<button className="tgu-button tgu-button--primary" type="button" onClick={onApply} disabled={loading}>
Показать
</button>
<button className="tgu-button" type="button" onClick={() => onQuickRange(24)} disabled={loading}>
24ч
</button>
<button className="tgu-button" type="button" onClick={() => onQuickRange(72)} disabled={loading}>
3 суток
</button>
<button className="tgu-button" type="button" onClick={() => onQuickRange(168)} disabled={loading}>
7 суток
</button>
</div>
)}
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
</header>
);
@@ -1,7 +1,11 @@
import { useEffect, useState } from "react";
import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel";
import type { RequestMapItem } from "../../model/requestTypes";
import type { MapPolygon } from "../tgu-map-2d/model/mapTypes";
import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
/** Заявка под точкой клика на карте: полигон-проекция заявки и сама заявка. */
export type RequestAtPoint = { polygon: MapPolygon; item: RequestMapItem };
type Props = {
requests: RequestAtPoint[];
selectedPolygonId?: string;
@@ -5,7 +5,7 @@ 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 { RequestAtPoint } from "./RequestDetailsPanel";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import type { GeoPoint, MapPolygon } from "../tgu-map-2d/model/mapTypes";
import { generateUuid } from "../../utils/uuid";
@@ -123,7 +123,7 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa
);
const layers = useMemo(
() => ({ tracks: false, swath: false, planWorks: false, complexPlan: false, stations: showStations, spacecraftMarkers: false, requests: true }),
() => ({ tracks: false, swath: false, planWorks: false, complexPlan: false, heatmap: false, stations: showStations, spacecraftMarkers: false, requests: true }),
[showStations]
);
@@ -0,0 +1,115 @@
import { useRef } from "react";
/** Сутки расчёта слотов: индекс (1-based), начало суток (мс, локально), метка ДД.ММ и число слотов. */
export type SlotDay = { index: number; dayMs: number; label: string; count: number };
type SlotDaySelectorProps = {
days: SlotDay[];
fromIndex: number;
toIndex: number;
onFromChange: (idx: number) => void;
onToChange: (idx: number) => void;
};
/**
* Двуручейный фильтр отображения слотов по суткам визуально тот же ползунок, что «Витки» на
* вкладке «Редактор» (классы map-pass-selector*). Накладывается на карту; сужает и таблицу, и сцену.
*/
export function SlotDaySelector({ days, fromIndex, toIndex, onFromChange, onToChange }: SlotDaySelectorProps) {
const trackRef = useRef<HTMLDivElement>(null);
const n = days.length;
if (n === 0) return null;
const fromDay = days[Math.max(0, Math.min(fromIndex - 1, n - 1))];
const toDay = days[Math.max(0, Math.min(toIndex - 1, n - 1))];
const isFullRange = fromIndex === 1 && toIndex === n;
const countInRange = days
.slice(fromIndex - 1, toIndex)
.reduce((sum, day) => sum + day.count, 0);
const pct = (idx: number): number => (n <= 1 ? 0 : ((idx - 1) / (n - 1)) * 100);
const fromPct = pct(fromIndex);
const toPct = pct(toIndex);
const indexAt = (clientX: number): number => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect || n <= 1) return 1;
return Math.round(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * (n - 1)) + 1;
};
const makeHandlers = (isFrom: boolean) => ({
onPointerDown(e: React.PointerEvent<HTMLDivElement>) {
e.stopPropagation();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
},
onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!(e.currentTarget as HTMLDivElement).hasPointerCapture(e.pointerId)) return;
const idx = indexAt(e.clientX);
if (isFrom) {
onFromChange(Math.max(1, Math.min(idx, toIndex)));
} else {
onToChange(Math.min(n, Math.max(idx, fromIndex)));
}
},
onPointerUp(e: React.PointerEvent<HTMLDivElement>) {
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
},
});
const onTrackPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
const idx = indexAt(e.clientX);
if (Math.abs(idx - fromIndex) <= Math.abs(idx - toIndex)) {
onFromChange(Math.max(1, Math.min(idx, toIndex)));
} else {
onToChange(Math.min(n, Math.max(idx, fromIndex)));
}
};
return (
<div className="map-pass-selector">
<div className="map-pass-selector__header">
<span className="map-pass-selector__title">Сутки</span>
<span className="map-pass-selector__nums">
{fromDay.label === toDay.label ? fromDay.label : `${fromDay.label}${toDay.label}`}
</span>
<span className="map-pass-selector__times">слотов {countInRange}</span>
{!isFullRange && (
<button
type="button"
className="map-pass-selector__reset"
title="Все сутки"
onClick={() => { onFromChange(1); onToChange(n); }}
>
Все
</button>
)}
</div>
<div className="map-pass-selector__body" onPointerDown={onTrackPointerDown}>
<div className="map-pass-selector__track" ref={trackRef}>
<div
className="map-pass-selector__fill"
style={{ left: `${fromPct}%`, width: `${toPct - fromPct}%` }}
/>
{days.map((day) => (
<div
key={day.index}
className={`map-pass-selector__tick${day.index >= fromIndex && day.index <= toIndex ? " is-active" : ""}`}
style={{ left: `${pct(day.index)}%` }}
/>
))}
<div
className="map-pass-selector__thumb"
style={{ left: `${fromPct}%` }}
{...makeHandlers(true)}
/>
<div
className="map-pass-selector__thumb"
style={{ left: `${toPct}%` }}
{...makeHandlers(false)}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
import type { SlotDTO } from "../../model/slotTypes";
import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
/** Один слот в меню выбора: id полигона на карте, сам слот и признак брони. */
export type SlotPick = { id: string; slot: SlotDTO; booked: boolean };
type SlotPickMenuProps = {
picks: SlotPick[];
activeId?: string;
onPick: (id: string) => void;
onClose: () => void;
};
/**
* Меню выбора слота при плотном наложении: клик по карте, под которым лежит несколько слотов,
* раскрывает список (КА · слот · виток/ветвь · время), выбор подсвечивает строку в таблице.
*/
export function SlotPickMenu({ picks, activeId, onPick, onClose }: SlotPickMenuProps) {
return (
<aside className="tgu-slots-pick">
<div className="tgu-slots-pick__head">
<span>Слоты под курсором · {picks.length}</span>
<button type="button" className="tgu-icon-button" onClick={onClose} title="Закрыть">
×
</button>
</div>
<div className="tgu-slots-pick__list">
{picks.map(({ id, slot, booked }) => (
<button
key={id}
type="button"
className={`tgu-slots-pick__item${id === activeId ? " is-active" : ""}`}
onClick={() => onPick(id)}
>
<span className="tgu-slots-pick__title">
КА {slot.satelliteId} · слот {slot.slotNumber}
{booked && <span className="tgu-slots-badge tgu-slots-badge--booked">бронь</span>}
</span>
<span className="tgu-slots-pick__meta">
виток {slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} ·{" "}
{formatLocalDateTime(parseLocal(slot.tn))}
</span>
</button>
))}
</div>
</aside>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { chunk, selectionAfterCheck } from "./slotSelection";
const keyAt = (i: number) => `k${i}`;
describe("selectionAfterCheck", () => {
it("добавляет ключ при обычном клике по невыбранной строке", () => {
const next = selectionAfterCheck(new Set(), keyAt, 2, false, null);
expect([...next]).toEqual(["k2"]);
});
it("снимает ключ при обычном клике по выбранной строке", () => {
const next = selectionAfterCheck(new Set(["k2"]), keyAt, 2, false, 5);
expect(next.has("k2")).toBe(false);
});
it("Shift выбирает диапазон от якоря до индекса (целевая была не выбрана)", () => {
const next = selectionAfterCheck(new Set(), keyAt, 4, true, 1);
expect([...next].sort()).toEqual(["k1", "k2", "k3", "k4"]);
});
it("Shift снимает диапазон, если целевая строка уже выбрана", () => {
const current = new Set(["k1", "k2", "k3", "k4", "k9"]);
const next = selectionAfterCheck(current, keyAt, 4, true, 1);
expect([...next]).toEqual(["k9"]);
});
it("Shift работает при обратном порядке (якорь больше индекса)", () => {
const next = selectionAfterCheck(new Set(), keyAt, 1, true, 4);
expect([...next].sort()).toEqual(["k1", "k2", "k3", "k4"]);
});
it("Shift без якоря ведёт себя как обычное переключение", () => {
const next = selectionAfterCheck(new Set(), keyAt, 3, true, null);
expect([...next]).toEqual(["k3"]);
});
it("не мутирует исходное множество", () => {
const current = new Set(["k2"]);
selectionAfterCheck(current, keyAt, 5, false, null);
expect([...current]).toEqual(["k2"]);
});
});
describe("chunk", () => {
it("бьёт массив на части заданного размера, последняя — остаток", () => {
expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
});
it("размер >= длины — один чанк", () => {
expect(chunk([1, 2], 10)).toEqual([[1, 2]]);
});
it("пустой массив — нет чанков", () => {
expect(chunk([], 3)).toEqual([]);
});
it("size<=0 — единственный чанк со всем массивом", () => {
expect(chunk([1, 2, 3], 0)).toEqual([[1, 2, 3]]);
});
});
@@ -0,0 +1,52 @@
// Чистая логика выбора слотов и разбиения на чанки — выделена из TguSlotsTab,
// чтобы покрыть юнит-тестами без рендера компонента.
/**
* Новое множество выбора после клика по строке.
* Обычный клик переключает один ключ. Shift+клик при заданном якоре выбирает (или
* снимает) непрерывный диапазон от якоря до текущего индекса; направление задаётся новым
* состоянием текущей строки (если она была не выбрана диапазон выбираем, иначе снимаем).
*
* @param current Текущее множество выбранных ключей.
* @param keyAt Доступ к ключу слота по индексу в видимом (отфильтрованном) списке.
* @param index Индекс строки, по которой кликнули.
* @param shiftKey Был ли зажат Shift.
* @param anchorIndex Индекс предыдущего клика (якорь) либо null.
*/
export function selectionAfterCheck(
current: ReadonlySet<string>,
keyAt: (index: number) => string,
index: number,
shiftKey: boolean,
anchorIndex: number | null,
): Set<string> {
const next = new Set(current)
const targetKey = keyAt(index)
if (shiftKey && anchorIndex != null) {
const lo = Math.min(anchorIndex, index)
const hi = Math.max(anchorIndex, index)
const select = !current.has(targetKey)
for (let i = lo; i <= hi; i++) {
const k = keyAt(i)
if (select) next.add(k)
else next.delete(k)
}
} else if (next.has(targetKey)) {
next.delete(targetKey)
} else {
next.add(targetKey)
}
return next
}
/**
* Разбивает массив на чанки заданного размера (для чанкованной брони крупных наборов).
* @param items Исходный массив.
* @param size Размер чанка; <= 0 один чанк со всем массивом.
*/
export function chunk<T>(items: readonly T[], size: number): T[][] {
if (size <= 0) return items.length ? [items.slice()] : []
const out: T[][] = []
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
return out
}
@@ -0,0 +1,63 @@
// Типы слотов покрытия и бронирования. Зеркалят DTO из pcp-types-lib
// (space.nstart.pcp.pcp_types_lib.dto.requests.slots), которые отдаёт slots-service.
/** Признак ветви витка (восходящая/нисходящая). */
export type RevolutionSign = "ASC" | "DESC";
/**
* Стратегия автоформирования покрытия (slots-service: SlotCoverageStrategy).
* GREEDY жадный подбор; CONTINUOUS непрерывное покрытие. Не задано все слоты-кандидаты.
*/
export type SlotCoverageStrategy = "GREEDY" | "CONTINUOUS";
/** Статус слота из расчёта покрытия. */
export type SlotStatus = "AVAILABLE" | "BOOKED" | "OCCUPIED" | string;
/** Слот покрытия заявки — кандидат на бронирование (slots-service: SlotDTO). */
export type SlotDTO = {
cycle: number;
satelliteId: number;
tn: string;
tk: string;
roll: number;
/** Контур полосы слота в WKT (POLYGON ...) — рисуем на 2D-карте. */
contour: string;
revolution: number;
revolutionSign: RevolutionSign;
slotNumber: number;
state: SlotStatus;
latitude: number;
longitude: number;
};
/** Забронированный слот (slots-service: BookedSlotDTO) — ответ на POST booking/request. */
export type BookedSlotDTO = {
bookedSlotId: number;
satelliteId: number;
slotNum: number;
cycle: number;
};
/** Идентификатор слота для бронирования (slots-service: SlotBookingRequestDTO). */
export type SlotBookingRequest = {
satelliteId: number;
slotNum: number;
cycle: number;
};
/** Параметры расчёта слотов покрытия заявки (GET /api/slots/request-cover). */
export type RequestSlotsParams = {
requestId: string;
timeStart: string;
timeStop: string;
/** Ветвь витка; не задано — обе ветви. */
revSign?: RevolutionSign;
/** true — считать ещё и площадь покрытия (дольше); false — только слоты-кандидаты. */
cov?: boolean;
/** Ограничение по КА; пусто — все доступные. */
satellites?: number[];
/** Угол Солнца, град (для оптики); не задано — без ограничения. */
sun?: number;
/** Стратегия автоформирования покрытия; не задано — все слоты-кандидаты. */
coverageStrategy?: SlotCoverageStrategy;
};
@@ -22,7 +22,14 @@ export type TimelineRange = {
toMs: number;
};
export type TguActiveTab = "timeline" | "map" | "complex" | "editor" | "requests" | "constellation";
export type TguActiveTab =
| "timeline"
| "map"
| "complex"
| "editor"
| "requests"
| "slots"
| "constellation";
export type TimelineRow = {
spacecraftId: string;
@@ -1159,6 +1159,10 @@
.tgu-editor-pick__swatch--drop { background: #ffcf3f; }
.tgu-editor-pick__swatch--station { background: #d5e2e2; }
.tgu-editor-pick__swatch--swath { background: #6fb7ff; }
.tgu-editor-pick__swatch--complexPlan { background: #c080ff; }
.tgu-editor-pick__swatch--heatmap { background: #f5803e; }
.tgu-editor-pick__swatch--tracks { background: #8d99a6; }
.tgu-editor-pick__swatch--spacecraftMarkers { background: #d5e2e2; }
.tgu-editor-pick__title {
font-size: 0.74rem;
@@ -23,6 +23,14 @@
overflow-x: auto;
}
.tgu-toolbar__row--range {
min-height: 0;
padding-top: 0.55rem;
padding-bottom: 0.6rem;
gap: 0.5rem;
border-top: 1px solid var(--line);
}
.tgu-toolbar__brand {
display: flex;
align-items: center;
@@ -733,6 +741,22 @@
color: var(--accent-strong);
}
/* Чекбокс-тумблер в тулбаре карты (напр. тепловая карта на вкладке «Комплексный план»). */
.tgu-checkbox {
display: inline-flex;
gap: 0.4rem;
align-items: center;
color: var(--text-muted);
font-size: 0.8rem;
white-space: nowrap;
cursor: pointer;
}
.tgu-checkbox input {
accent-color: var(--accent);
cursor: pointer;
}
.tgu-map-content {
display: grid;
grid-template-columns: 19rem minmax(0, 1fr);
@@ -883,6 +907,255 @@
border-radius: 999px;
}
/* Тело вкладки «Комплексный план»: карта + правый инспектор объекта (как в редакторе). */
.tgu-complex-body {
display: grid;
min-width: 0;
min-height: 0;
}
/* Вкладка «Слоты»: слева список слотов, справа карта. */
.tgu-slots-body {
display: grid;
grid-template-columns: minmax(22rem, 32rem) minmax(0, 1fr);
min-width: 0;
min-height: 0;
}
.tgu-slots-list {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--line);
background: var(--bg-panel);
}
/* Прокручиваемая область таблицы слотов — scrollElement виртуализатора. */
.tgu-slots-scroll {
min-height: 0;
flex: 1;
overflow: auto;
}
/* Панель выбора над таблицей: счётчик охвата + действия (все сутки / снять). */
.tgu-slots-selbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.4rem 0.55rem;
border-bottom: 1px solid var(--line);
background: var(--bg-panel);
font-size: 0.78rem;
}
.tgu-slots-selbar__count {
color: var(--text);
}
.tgu-slots-selbar__sub {
color: var(--text-dim);
}
.tgu-slots-selbar__actions {
display: flex;
gap: 0.35rem;
}
.tgu-slots-empty {
padding: 1rem;
color: var(--text-dim);
font-size: 0.82rem;
}
/* Имя выбранной заявки в тулбаре (на месте прежнего выпадающего списка). */
.tgu-slots-request {
align-items: center;
}
.tgu-slots-request__name {
min-height: 2rem;
display: flex;
align-items: center;
max-width: 18rem;
padding: 0.38rem 0.55rem;
border: 1px solid var(--line);
border-radius: 6px;
background: #101214;
color: var(--text);
font-size: 0.78rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Левая панель в режиме «заявка не выбрана»: фильтр интервала заявок + подсказка. */
.tgu-slots-requests-panel {
display: grid;
gap: 0.6rem;
padding: 0.9rem;
}
/* Кнопка «Сброс» — справа над картой (поверх неё). */
.tgu-slots-reset {
position: absolute;
top: 0.55rem;
right: 0.55rem;
z-index: 21;
background: rgba(23, 26, 28, 0.93);
backdrop-filter: blur(6px);
}
/* Меню выбора слота при плотном наложении — плавающий список справа над картой (под «Сбросом»). */
.tgu-slots-pick {
position: absolute;
top: 3rem;
right: 0.55rem;
z-index: 24;
width: 22rem;
max-width: calc(100% - 1.1rem);
max-height: calc(100% - 3.6rem);
display: flex;
flex-direction: column;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(23, 26, 28, 0.96);
backdrop-filter: blur(6px);
overflow: hidden;
}
.tgu-slots-pick__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.65rem;
border-bottom: 1px solid var(--line);
font-size: 0.78rem;
font-weight: 600;
color: var(--text);
}
.tgu-slots-pick__list {
overflow: auto;
min-height: 0;
}
.tgu-slots-pick__item {
display: grid;
gap: 0.15rem;
width: 100%;
padding: 0.45rem 0.65rem;
border: 0;
border-bottom: 1px solid var(--line);
background: transparent;
text-align: left;
cursor: pointer;
color: var(--text);
}
.tgu-slots-pick__item:hover {
background: rgba(104, 195, 189, 0.1);
}
.tgu-slots-pick__item.is-active {
background: rgba(104, 195, 189, 0.22);
box-shadow: inset 3px 0 0 var(--accent);
}
.tgu-slots-pick__title {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
font-weight: 600;
}
.tgu-slots-pick__meta {
font-family: var(--mono);
font-size: 0.7rem;
color: var(--text-dim);
}
/* Инспектор заявки — плавающая панель справа над картой (до выбора заявки). */
.tgu-slots-inspector {
position: absolute;
top: 0.55rem;
right: 0.55rem;
z-index: 25;
width: 23rem;
max-width: calc(100% - 1.1rem);
max-height: calc(100% - 1.1rem);
overflow: auto;
padding: 0.9rem;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(23, 26, 28, 0.96);
backdrop-filter: blur(6px);
}
.tgu-slots-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.tgu-slots-table th,
.tgu-slots-table td {
padding: 0.35rem 0.5rem;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid var(--line);
}
.tgu-slots-table thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--bg-panel);
color: var(--text-dim);
font-weight: 600;
}
.tgu-slots-table tbody tr {
cursor: pointer;
}
.tgu-slots-table tbody tr:hover {
background: rgba(104, 195, 189, 0.08);
}
.tgu-slots-table tbody tr.is-active {
background: rgba(104, 195, 189, 0.3);
box-shadow: inset 3px 0 0 var(--accent);
}
.tgu-slots-table tbody tr.is-active td {
color: var(--text);
font-weight: 600;
}
.tgu-slots-table tbody tr.is-active:hover {
background: rgba(104, 195, 189, 0.36);
}
.tgu-slots-badge {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: #101214;
border: 1px solid var(--line);
color: var(--text-muted);
font-size: 0.72rem;
}
.tgu-slots-badge--booked {
border-color: rgba(104, 195, 189, 0.58);
background: rgba(104, 195, 189, 0.16);
color: var(--accent-strong);
}
.tgu-map-stage {
position: relative;
min-width: 0;
@@ -935,6 +1208,14 @@
stroke-width: 2;
}
/* Отобранные под бронь слоты — постоянная зелёная подсветка (отличается от оранжевого выбора). */
.tgu-2d-map__overlay polygon.is-marked {
fill: rgba(58, 209, 122, 0.22);
stroke: #3ad17a;
stroke-width: 2;
stroke-dasharray: 6 4;
}
.tgu-2d-map__overlay polyline.is-selected {
stroke: #f0ad2e;
stroke-width: 7;
@@ -1029,6 +1310,75 @@
background: var(--bg-panel);
color: var(--text);
font-weight: 700;
cursor: pointer;
}
.tgu-2d-map__zoom button:hover {
border-color: rgba(104, 195, 189, 0.5);
}
/* Кнопка-линейка: активный режим подсвечиваем как тумблер. */
.tgu-2d-map__zoom-ruler.is-active {
border-color: rgba(104, 195, 189, 0.58);
background: rgba(104, 195, 189, 0.18);
color: var(--accent-strong);
}
/* Замер линейки на оверлее: ломаная, вершины и подписи расстояния. */
.tgu-2d-map__ruler-line {
stroke: #f0ad2e;
stroke-width: 2;
stroke-dasharray: 6 4;
}
.tgu-2d-map__ruler-vertex {
fill: #f0ad2e;
stroke: #1a1305;
stroke-width: 1;
}
.tgu-2d-map__ruler-label {
fill: #ffe7b5;
font-size: 11px;
font-weight: 600;
paint-order: stroke;
stroke: rgba(10, 12, 14, 0.85);
stroke-width: 3;
}
/* Бейдж итоговой длины — в правом нижнем углу, над кнопками управления. */
.tgu-2d-map__ruler-total {
position: absolute;
right: 0.75rem;
bottom: 9rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.55rem;
border: 1px solid rgba(240, 173, 46, 0.42);
border-radius: 7px;
background: rgba(17, 19, 21, 0.9);
color: #ffe7b5;
font-size: 0.78rem;
backdrop-filter: blur(6px);
}
.tgu-2d-map__ruler-total-label {
color: var(--text-dim);
}
.tgu-2d-map__ruler-total-value {
font-weight: 700;
}
.tgu-2d-map__ruler-reset {
border: 1px solid var(--line);
border-radius: 5px;
padding: 0.1rem 0.45rem;
background: #101214;
color: var(--text-muted);
font-size: 0.72rem;
cursor: pointer;
}
.tgu-map-overlay {
@@ -1046,6 +1396,53 @@
backdrop-filter: blur(6px);
}
/* Кнопка отмены внутри оверлея расчёта/автоподбора — на отдельной строке под текстом. */
.tgu-map-overlay button {
display: block;
margin-top: 0.5rem;
}
/* Подсказка о перегрузе карты слотами (карту не блокируем). */
.tgu-slots-map-hint {
position: absolute;
z-index: 5;
left: 50%;
bottom: 0.85rem;
transform: translateX(-50%);
max-width: 90%;
padding: 0.4rem 0.7rem;
border: 1px solid rgba(240, 173, 46, 0.42);
border-radius: 7px;
background: rgba(17, 19, 21, 0.88);
color: #ffe7b5;
font-size: 0.78rem;
backdrop-filter: blur(6px);
}
/* Баннер подтверждения крупной брони. */
.tgu-slots-confirm {
position: absolute;
z-index: 6;
top: 0.85rem;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.8rem;
border: 1px solid var(--accent);
border-radius: 8px;
background: rgba(17, 19, 21, 0.94);
color: var(--text);
font-size: 0.82rem;
backdrop-filter: blur(6px);
}
.tgu-slots-confirm__actions {
display: flex;
gap: 0.4rem;
}
.tgu-map-errors {
display: grid;
gap: 0.4rem;
+4 -32
View File
@@ -21,40 +21,12 @@ export default defineConfig({
server: {
host: '0.0.0.0',
port: 5174,
// ТГУ ходит в несколько бэкендов через префиксы. В проде эти маршруты
// повторяются в nginx.conf (зеркало) либо на gateway/ingress.
// Всё /api/* уходит на gateway (Spring Cloud Gateway, dev :7080). Gateway сам
// срезает префиксы pcp-* (StripPrefix) и выбирает бэкенд — ровно то, что раньше
// делали правила-зеркала здесь. В проде то же делает nginx.conf.
proxy: {
'/api/pcp-request': {
target: 'http://localhost:7005',
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api\/pcp-request/, ''),
},
'/api/pcp-ballistics': {
target: 'http://localhost:7003',
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api\/pcp-ballistics/, ''),
},
'/api/pcp-mission': {
target: 'http://localhost:7010',
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api\/pcp-mission/, ''),
},
'/api/pcp-tgu': {
target: 'http://localhost:7011',
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api\/pcp-tgu/, ''),
},
'/api/pcp-complex': {
target: 'http://localhost:7002',
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api\/pcp-complex/, ''),
},
'/api/stations': {
target: 'http://localhost:7009',
changeOrigin: true,
},
'/api': {
target: 'http://localhost:7008',
target: 'http://localhost:7080',
changeOrigin: true,
},
},
@@ -1,62 +0,0 @@
package space.nstart.pcp.slots_service.configuration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
import org.springframework.web.filter.CorsFilter
import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Configuration
class WebConfig : WebMvcConfigurer {
override fun addCorsMappings(registry: CorsRegistry) {
// Внутренний ops-инструмент: UI открывают с разных хостов/IP (localhost,
// LAN-адрес рабочей станции, прод-сборка). Любой явный список origin'ов рано
// или поздно не совпадает с фактическим адресом UI → POST/PUT/DELETE отбивается
// как «Invalid CORS request» (same-origin GET проходит, т.к. идёт без Origin).
// Поэтому разрешаем любой origin. allowedOriginPatterns("*") совместим с
// allowCredentials=true (в отличие от allowedOrigins("*")).
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600)
}
// Альтернативный вариант через CorsFilter
@Bean
fun corsFilter(): CorsFilter {
val source = UrlBasedCorsConfigurationSource()
val config = CorsConfiguration().apply {
allowCredentials = true
// Любой origin — см. комментарий в addCorsMappings.
allowedOriginPatterns = listOf("*")
// Разрешенные заголовки
allowedHeaders = listOf(
"Origin",
"Content-Type",
"Accept",
"Authorization",
"X-Requested-With",
"Access-Control-Request-Method",
"Access-Control-Request-Headers"
)
// Разрешенные методы
allowedMethods = listOf(
"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD"
)
// Заголовки, доступные клиенту
exposedHeaders = listOf(
"Access-Control-Allow-Origin",
"Access-Control-Allow-Credentials"
)
maxAge = 3600L
}
source.registerCorsConfiguration("/**", config)
return CorsFilter(source)
}
}