From 82eddedc9238ad956c6c9687c994b6d3c78ccb73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Sat, 30 May 2026 16:30:53 +0300 Subject: [PATCH] feat(tgu-ops-ui): OSM tiles, editor layout fixes, map resizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карта: - Заменить схематичные контуры континентов на тайлы CartoDB Dark (dark_nolabels, CC BY 3.0, без API-ключа) через модульный кеш tileLoader.ts + отрисовщик drawTiles.ts на существующей Web Mercator проекции — без новых зависимостей - Сохранить сетку lat/lon 30° поверх тайлов (полезна для орбитального анализа), убрать нарисованные контуры и градиент океана - Обновить атрибуцию: © OpenStreetMap contributors · © CARTO Вкладка Редактор — раскладка: - Исправить цепочку высот: workspace переведён с display:grid на display:flex + flex-direction:column; layout получает flex:1 — единственный надёжный способ пробросить definite height через несколько вложенных grid-контейнеров в Chrome - Обернуть условные notice/applyError в постоянный div.tgu-editor-notices, чтобы .tgu-editor-layout всегда был третьим flex-ребёнком (раньше при отсутствии уведомлений попадал во вторую auto-строку grid и схлопывался) Вкладка Редактор — карта: - Добавить перетаскиваемый разделитель между картой и таймлайном: mapRatio state (по умолчанию 50/50), drag-хендлер через document events, ограничение 15–80%, визуальный индикатор .tgu-editor-map-resizer - Ограничить окно наземной трассы 24ч (720 точек, шаг 2 мин) независимо от зума таймлайна — при широком диапазоне (7 суток) шаг был 38 мин, трасса выглядела как хаотическая сетка через весь глобус Co-Authored-By: Claude Sonnet 4.6 --- .../src/features/tgu-editor/TguEditorTab.tsx | 48 ++++++++++++-- .../src/features/tgu-map-2d/Tgu2DMapView.tsx | 10 ++- .../features/tgu-map-2d/canvas/drawBaseMap.ts | 52 ++------------- .../features/tgu-map-2d/canvas/drawTiles.ts | 64 +++++++++++++++++++ .../features/tgu-map-2d/canvas/tileLoader.ts | 45 +++++++++++++ .../pcp-tgu-ops-ui/src/styles/tgu-editor.css | 49 ++++++++++++-- 6 files changed, 207 insertions(+), 61 deletions(-) create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTiles.ts create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/tileLoader.ts diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index 511811a..861e990 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; +import { useEffect, useMemo, useRef, useState, useCallback, type MutableRefObject } from "react"; import { groundTrack, stationPasses, targetPasses } from "../tgu-map-2d/geometry/passes"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes"; @@ -47,6 +47,8 @@ const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = { export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, platforms }: TguEditorTabProps) { const idCounterRef = useRef(0); const targetCounterRef = useRef(0); + const centerRef = useRef(null); + const [mapRatio, setMapRatio] = useState(0.5); const [history, setHistory] = useState(() => ({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], @@ -213,6 +215,27 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, }); }; + const handleResizerDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + const startY = e.clientY; + const startRatio = mapRatio; + + const onMove = (ev: MouseEvent) => { + const el = centerRef.current; + if (!el) return; + const dy = ev.clientY - startY; + setMapRatio(Math.max(0.15, Math.min(0.80, startRatio + dy / el.clientHeight))); + }; + + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, [mapRatio]); + if (!selectedSpacecraftId && !selectedPlan) { return (
@@ -249,8 +272,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, onPan={pan} /> - {notice &&
{notice}
} - {applyError &&
{applyError}
} +
+ {notice &&
{notice}
} + {applyError &&
{applyError}
} +
-
+
{rightMode === "shoot" &&
Кликните точку съёмки на карте.
} undefined} /> +
({ + ? groundTrack({ spacecraftId }, mapRange, MAP_TRACK_POINTS).map((points, index) => ({ id: `${spacecraftId}:track:${index}`, layer: "tracks" as const, points, diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx index 2d55dd4..21148a4 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx @@ -3,6 +3,7 @@ import { drawBaseMap } from "./canvas/drawBaseMap"; import { drawPlanWorks } from "./canvas/drawPlanWorks"; import { drawStations } from "./canvas/drawStations"; import { drawSwath } from "./canvas/drawSwath"; +import { drawTiles } from "./canvas/drawTiles"; import { drawTracks } from "./canvas/drawTracks"; import { createMapProjection } from "./geometry/mapProjection"; import { createPolygonSpatialIndex, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex"; @@ -64,6 +65,8 @@ export function Tgu2DMapView({ const hoverRef = useRef(undefined); const [size, setSize] = useState({ width: 1000, height: 560 }); const [view, setView] = useState(INITIAL_VIEW); + const [tileVersion, setTileVersion] = useState(0); + const handleTileLoad = useCallback(() => setTileVersion((v) => v + 1), []); const [tooltip, setTooltip] = useState(); const selectedPolygonRef = useRef(undefined); const activePolygons = useMemo( @@ -125,7 +128,8 @@ export function Tgu2DMapView({ context.setTransform(dpr, 0, 0, dpr, 0, 0); context.clearRect(0, 0, size.width, size.height); - drawBaseMap(context, projection, size); + drawTiles(context, view, projection, size, handleTileLoad); + drawBaseMap(context, projection); if (layers.swath) { drawSwath(context, visiblePolygons.filter((polygon) => polygon.layer === "swath"), projection, size); @@ -142,7 +146,7 @@ export function Tgu2DMapView({ }); return () => window.cancelAnimationFrame(frameId); - }, [layers, projection, redrawToken, scene.lines, scene.markers, size, visiblePolygons]); + }, [handleTileLoad, layers, projection, redrawToken, scene.lines, scene.markers, size, tileVersion, view, visiblePolygons]); const screenPointFromEvent = useCallback((event: React.PointerEvent | React.MouseEvent): ScreenPoint => { const bounds = wrapperRef.current?.getBoundingClientRect(); @@ -307,7 +311,7 @@ export function Tgu2DMapView({ {tooltip.subtitle}
)} -
Схематичная основа · Web Mercator
+
© OpenStreetMap contributors · © CARTO
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawBaseMap.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawBaseMap.ts index dce7fa4..059eedf 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawBaseMap.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawBaseMap.ts @@ -1,31 +1,10 @@ import type { MapProjection } from "../geometry/mapProjection"; -import type { MapSize } from "../model/mapTypes"; -const LAND: Record = { - "Сев. Америка": [[-166, 66], [-150, 70], [-125, 70], [-95, 72], [-70, 67], [-56, 53], [-52, 47], [-70, 41], [-81, 31], [-80, 25], [-97, 22], [-114, 31], [-124, 48], [-145, 60], [-164, 60]], - "Юж. Америка": [[-78, 9], [-60, 10], [-51, 4], [-43, -3], [-37, -13], [-55, -34], [-70, -52], [-74, -49], [-71, -18], [-80, -6], [-79, 3]], - "Африка": [[-16, 15], [-6, 27], [10, 37], [32, 31], [43, 12], [51, 12], [41, -3], [35, -18], [22, -34], [14, -24], [9, -1], [-9, 5], [-14, 9]], - "Европа": [[-10, 37], [-9, 43], [2, 51], [6, 62], [21, 70], [30, 67], [42, 63], [38, 58], [24, 56], [14, 54], [-4, 48], [-9, 40]], - "Азия": [[42, 63], [68, 72], [95, 77], [125, 73], [160, 70], [180, 66], [165, 60], [143, 53], [127, 38], [121, 31], [110, 21], [100, 7], [88, 22], [73, 17], [62, 25], [47, 38], [58, 55], [45, 60]], - "Австралия": [[114, -22], [114, -32], [129, -32], [147, -38], [153, -31], [146, -18], [136, -12], [125, -14], [122, -18]], - "Антарктида": [[-180, -72], [-150, -75], [-90, -72], [-30, -72], [30, -69], [90, -66], [150, -70], [180, -72], [180, -85], [-180, -85]] -}; - -export function drawBaseMap(ctx: CanvasRenderingContext2D, projection: MapProjection, size: MapSize) { - const gradient = ctx.createLinearGradient(0, 0, 0, size.height); - gradient.addColorStop(0, "#0b1717"); - gradient.addColorStop(1, "#08100f"); - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, size.width, size.height); - - drawGrid(ctx, projection); - drawLand(ctx, projection); -} - -function drawGrid(ctx: CanvasRenderingContext2D, projection: MapProjection) { +// Draws the lat/lon graticule (30° grid) over OSM tiles. +export function drawBaseMap(ctx: CanvasRenderingContext2D, projection: MapProjection): void { ctx.save(); - ctx.strokeStyle = "rgba(116, 160, 154, 0.14)"; - ctx.lineWidth = 1; + ctx.strokeStyle = "rgba(255, 255, 255, 0.10)"; + ctx.lineWidth = 0.5; ctx.beginPath(); for (let lon = -180; lon <= 180; lon += 30) { @@ -45,26 +24,3 @@ function drawGrid(ctx: CanvasRenderingContext2D, projection: MapProjection) { ctx.stroke(); ctx.restore(); } - -function drawLand(ctx: CanvasRenderingContext2D, projection: MapProjection) { - ctx.save(); - for (const polygon of Object.values(LAND)) { - ctx.beginPath(); - polygon.forEach(([lon, lat], index) => { - const point = projection.project({ lon, lat }); - if (index === 0) { - ctx.moveTo(point.x, point.y); - } else { - ctx.lineTo(point.x, point.y); - } - }); - ctx.closePath(); - ctx.fillStyle = "#173128"; - ctx.fill(); - ctx.strokeStyle = "rgba(139, 210, 180, 0.42)"; - ctx.lineWidth = 0.9; - ctx.stroke(); - } - ctx.restore(); -} - diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTiles.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTiles.ts new file mode 100644 index 0000000..96e7a1b --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTiles.ts @@ -0,0 +1,64 @@ +import { latToWorldY, lonToWorldX, worldYToLat } from "../geometry/mapProjection"; +import type { MapProjection } from "../geometry/mapProjection"; +import type { MapSize, MapViewState } from "../model/mapTypes"; +import { getTile } from "./tileLoader"; + +const TILE_PX = 256; + +export function drawTiles( + ctx: CanvasRenderingContext2D, + view: MapViewState, + projection: MapProjection, + size: MapSize, + onLoad: () => void +): void { + // Snap fractional zoom to nearest integer for tile selection. + // Tiles are rendered at the correct scale via screenTileW. + const z = Math.max(1, Math.min(18, Math.round(view.zoom))); + const tileCount = 2 ** z; + const worldSize_z = TILE_PX * tileCount; + + // Width of one tile in screen pixels at the current (possibly fractional) zoom + const screenTileW = TILE_PX * (projection.worldSize / worldSize_z); + + // Center of the viewport in tile x-coordinates (float) + const centerTileX = lonToWorldX(view.centerLon, worldSize_z) / TILE_PX; + // Center in projection world pixels (used for screen x formula below) + const centerWorldX = lonToWorldX(view.centerLon, projection.worldSize); + + // X tile range: continuously-indexed (can be negative or > tileCount). + // wrappedTx = ((tx % tileCount) + tileCount) % tileCount for the actual URL. + const tilesHalf = Math.ceil(size.width / (2 * screenTileW)) + 1; + const txMin = Math.floor(centerTileX) - tilesHalf; + const txMax = Math.ceil(centerTileX) + tilesHalf; + + // Y tile range: clamped to [0, tileCount-1] (no world repeat in y) + const topLat = projection.unproject({ x: size.width / 2, y: 0 }).lat; + const bottomLat = projection.unproject({ x: size.width / 2, y: size.height }).lat; + const tyMin = Math.max(0, Math.floor(latToWorldY(topLat, worldSize_z) / TILE_PX) - 1); + const tyMax = Math.min(tileCount - 1, Math.ceil(latToWorldY(bottomLat, worldSize_z) / TILE_PX) + 1); + + for (let ty = tyMin; ty <= tyMax; ty++) { + // Mercator-correct tile top/bottom screen positions + const tileTopLat = worldYToLat(ty * TILE_PX, worldSize_z); + const tileBottomLat = worldYToLat((ty + 1) * TILE_PX, worldSize_z); + // lon arg is irrelevant for y — use centerLon to avoid antimeridian edge cases + const screenY = projection.project({ lon: view.centerLon, lat: tileTopLat }).y; + const screenYBottom = projection.project({ lon: view.centerLon, lat: tileBottomLat }).y; + + if (screenYBottom < 0 || screenY > size.height) continue; + const screenH = screenYBottom - screenY; + + for (let tx = txMin; tx <= txMax; tx++) { + // Screen x is linear in tile x (Mercator is linear in longitude) + const screenX = size.width / 2 + tx * screenTileW - centerWorldX; + if (screenX + screenTileW < 0 || screenX > size.width) continue; + + const wrappedTx = ((tx % tileCount) + tileCount) % tileCount; + const img = getTile(z, wrappedTx, ty, onLoad); + if (!img) continue; + + ctx.drawImage(img, screenX, screenY, screenTileW, screenH); + } + } +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/tileLoader.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/tileLoader.ts new file mode 100644 index 0000000..4690c56 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/tileLoader.ts @@ -0,0 +1,45 @@ +// Module-level tile image cache — shared across all map instances and renders. +// Uses CartoDB dark basemap tiles (CC BY 3.0, no API key required for low traffic). +const SUBDOMAINS = "abcd"; +const MAX_CACHE_SIZE = 512; + +type CacheEntry = HTMLImageElement | null; // null = loading +const imageCache = new Map(); + +function tileUrl(z: number, x: number, y: number): string { + const s = SUBDOMAINS[(x + y) % SUBDOMAINS.length]; + return `https://${s}.basemaps.cartocdn.com/dark_nolabels/${z}/${x}/${y}.png`; +} + +export function getTile( + z: number, + x: number, + y: number, + onLoad: () => void +): HTMLImageElement | undefined { + const key = `${z}/${x}/${y}`; + const cached = imageCache.get(key); + + if (cached instanceof HTMLImageElement) return cached; + if (cached === null) return undefined; // already loading + + if (imageCache.size >= MAX_CACHE_SIZE) { + const firstKey = imageCache.keys().next().value; + if (firstKey !== undefined) imageCache.delete(firstKey); + } + + imageCache.set(key, null); + + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + imageCache.set(key, img); + onLoad(); + }; + img.onerror = () => { + imageCache.delete(key); // allow retry on next render + }; + img.src = tileUrl(z, x, y); + + return undefined; +} diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css index 87a0f69..fcd93b9 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css @@ -1,8 +1,11 @@ .tgu-editor-workspace { - display: grid; - grid-template-rows: auto auto minmax(0, 1fr); + display: flex; + flex-direction: column; + width: 100%; + height: 100%; min-width: 0; min-height: 0; + overflow: hidden; background: #121516; } @@ -95,12 +98,21 @@ color: #ffe7b5; } +.tgu-editor-notices:empty { + display: none; +} + +.tgu-editor-workspace > .tgu-empty { + flex: 1; +} + .tgu-editor-notice { margin: 0.55rem 0.8rem 0; } .tgu-editor-layout { display: grid; + flex: 1; min-width: 0; min-height: 0; } @@ -210,7 +222,7 @@ .tgu-editor-center { display: grid; - grid-template-rows: 42% minmax(0, 1fr); + /* grid-template-rows is set via inline style to support drag-resize */ min-width: 0; min-height: 0; border-right: 1px solid var(--line); @@ -218,14 +230,41 @@ .tgu-editor-map-stage { position: relative; - min-height: 13rem; + min-height: 6rem; overflow: hidden; border-bottom: 1px solid var(--line); background: #0b0d0e; } .tgu-editor-map-stage .tgu-2d-map { - min-height: 13rem; + min-height: 6rem; +} + +.tgu-editor-map-resizer { + position: absolute; + bottom: -4px; + left: 0; + right: 0; + height: 8px; + cursor: row-resize; + z-index: 10; +} + +.tgu-editor-map-resizer::after { + content: ""; + position: absolute; + top: 3px; + left: 50%; + width: 3rem; + height: 2px; + border-radius: 1px; + background: var(--line-soft); + transform: translateX(-50%); + transition: background 0.15s; +} + +.tgu-editor-map-resizer:hover::after { + background: var(--accent); } .tgu-2d-map.is-picking {