Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
@@ -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")}")
|
||||
}
|
||||
}
|
||||
+16
@@ -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)
|
||||
}
|
||||
+51
@@ -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}
|
||||
+4
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+18
-1
@@ -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"]
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Фоновый слой тепловой карты приоритетов: заливка ячеек сетки заданий цветом по важности
|
||||
* (cool→hot). Важность нормируется к максимуму набора, поэтому шкала подстраивается под данные.
|
||||
* Рисуется ПОД контурами плана/заявок, без обводки и 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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
-62
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user