feat(tgu-ops-ui): OSM tiles, editor layout fixes, map resizer

Карта:
- Заменить схематичные контуры континентов на тайлы 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 <noreply@anthropic.com>
This commit is contained in:
Дмитрий Соловьев
2026-05-30 16:30:53 +03:00
parent f084775cbb
commit 82eddedc92
6 changed files with 207 additions and 61 deletions
@@ -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<HTMLElement>(null);
const [mapRatio, setMapRatio] = useState(0.5);
const [history, setHistory] = useState<DraftHistory>(() => ({
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 (
<main className="tgu-editor-workspace">
@@ -249,8 +272,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
onPan={pan}
/>
{notice && <div className="tgu-alert tgu-alert--success tgu-editor-notice">{notice}</div>}
{applyError && <div className="tgu-alert tgu-alert--danger tgu-editor-notice">{applyError}</div>}
<div className="tgu-editor-notices">
{notice && <div className="tgu-alert tgu-alert--success tgu-editor-notice">{notice}</div>}
{applyError && <div className="tgu-alert tgu-alert--danger tgu-editor-notice">{applyError}</div>}
</div>
<div className="tgu-editor-layout" style={{ gridTemplateColumns: rightMode === "none" ? "15.5rem minmax(0, 1fr) 0" : "15.5rem minmax(0, 1fr) 21rem" }}>
<EditorRail
@@ -273,7 +298,11 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
}}
/>
<section className="tgu-editor-center">
<section
className="tgu-editor-center"
ref={centerRef}
style={{ gridTemplateRows: `${(mapRatio * 100).toFixed(1)}% minmax(0, 1fr)` }}
>
<div className="tgu-editor-map-stage">
{rightMode === "shoot" && <div className="tgu-map-overlay">Кликните точку съёмки на карте.</div>}
<Tgu2DMapView
@@ -296,6 +325,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
}}
onObjectSelect={() => undefined}
/>
<div className="tgu-editor-map-resizer" onMouseDown={handleResizerDown} />
</div>
<EditorTimeline
activeLabel={activeLabel}
@@ -470,13 +500,21 @@ function buildVisibilityTracks(
return tracks;
}
const MAP_TRACK_WINDOW_MS = 24 * 60 * 60 * 1000;
const MAP_TRACK_POINTS = 720; // 2-minute step over 24h → ~45 points per LEO orbit
function buildEditorMapScene(
spacecraftId: string | undefined,
window: EditorTimelineWindow,
stations: EditorGroundStation[]
): Tgu2DMapScene {
// Always show 24h of ground track centred on the timeline window,
// regardless of how wide the timeline is zoomed out.
const center = (window.fromMs + window.toMs) / 2;
const mapRange = { fromMs: center - MAP_TRACK_WINDOW_MS / 2, toMs: center + MAP_TRACK_WINDOW_MS / 2 };
const lines = spacecraftId
? groundTrack({ spacecraftId }, { fromMs: window.fromMs, toMs: window.toMs }, 260).map((points, index) => ({
? groundTrack({ spacecraftId }, mapRange, MAP_TRACK_POINTS).map((points, index) => ({
id: `${spacecraftId}:track:${index}`,
layer: "tracks" as const,
points,
@@ -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<MapPolygon | undefined>(undefined);
const [size, setSize] = useState<MapSize>({ width: 1000, height: 560 });
const [view, setView] = useState<MapViewState>(INITIAL_VIEW);
const [tileVersion, setTileVersion] = useState(0);
const handleTileLoad = useCallback(() => setTileVersion((v) => v + 1), []);
const [tooltip, setTooltip] = useState<TooltipState>();
const selectedPolygonRef = useRef<MapPolygon | undefined>(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({
<span>{tooltip.subtitle}</span>
</div>
)}
<div className="tgu-2d-map__attribution">Схематичная основа · Web Mercator</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>
<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>
@@ -1,31 +1,10 @@
import type { MapProjection } from "../geometry/mapProjection";
import type { MapSize } from "../model/mapTypes";
const LAND: Record<string, [number, number][]> = {
"Сев. Америка": [[-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();
}
@@ -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);
}
}
}
@@ -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<string, CacheEntry>();
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;
}