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:
@@ -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 { groundTrack, stationPasses, targetPasses } from "../tgu-map-2d/geometry/passes";
|
||||||
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
||||||
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
|
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) {
|
export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, platforms }: TguEditorTabProps) {
|
||||||
const idCounterRef = useRef(0);
|
const idCounterRef = useRef(0);
|
||||||
const targetCounterRef = useRef(0);
|
const targetCounterRef = useRef(0);
|
||||||
|
const centerRef = useRef<HTMLElement>(null);
|
||||||
|
const [mapRatio, setMapRatio] = useState(0.5);
|
||||||
const [history, setHistory] = useState<DraftHistory>(() => ({
|
const [history, setHistory] = useState<DraftHistory>(() => ({
|
||||||
draft: seedDraft(selectedPlan, selectedSpacecraftId),
|
draft: seedDraft(selectedPlan, selectedSpacecraftId),
|
||||||
past: [],
|
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) {
|
if (!selectedSpacecraftId && !selectedPlan) {
|
||||||
return (
|
return (
|
||||||
<main className="tgu-editor-workspace">
|
<main className="tgu-editor-workspace">
|
||||||
@@ -249,8 +272,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
onPan={pan}
|
onPan={pan}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{notice && <div className="tgu-alert tgu-alert--success tgu-editor-notice">{notice}</div>}
|
<div className="tgu-editor-notices">
|
||||||
{applyError && <div className="tgu-alert tgu-alert--danger tgu-editor-notice">{applyError}</div>}
|
{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" }}>
|
<div className="tgu-editor-layout" style={{ gridTemplateColumns: rightMode === "none" ? "15.5rem minmax(0, 1fr) 0" : "15.5rem minmax(0, 1fr) 21rem" }}>
|
||||||
<EditorRail
|
<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">
|
<div className="tgu-editor-map-stage">
|
||||||
{rightMode === "shoot" && <div className="tgu-map-overlay">Кликните точку съёмки на карте.</div>}
|
{rightMode === "shoot" && <div className="tgu-map-overlay">Кликните точку съёмки на карте.</div>}
|
||||||
<Tgu2DMapView
|
<Tgu2DMapView
|
||||||
@@ -296,6 +325,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
}}
|
}}
|
||||||
onObjectSelect={() => undefined}
|
onObjectSelect={() => undefined}
|
||||||
/>
|
/>
|
||||||
|
<div className="tgu-editor-map-resizer" onMouseDown={handleResizerDown} />
|
||||||
</div>
|
</div>
|
||||||
<EditorTimeline
|
<EditorTimeline
|
||||||
activeLabel={activeLabel}
|
activeLabel={activeLabel}
|
||||||
@@ -470,13 +500,21 @@ function buildVisibilityTracks(
|
|||||||
return tracks;
|
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(
|
function buildEditorMapScene(
|
||||||
spacecraftId: string | undefined,
|
spacecraftId: string | undefined,
|
||||||
window: EditorTimelineWindow,
|
window: EditorTimelineWindow,
|
||||||
stations: EditorGroundStation[]
|
stations: EditorGroundStation[]
|
||||||
): Tgu2DMapScene {
|
): 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
|
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}`,
|
id: `${spacecraftId}:track:${index}`,
|
||||||
layer: "tracks" as const,
|
layer: "tracks" as const,
|
||||||
points,
|
points,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { drawBaseMap } from "./canvas/drawBaseMap";
|
|||||||
import { drawPlanWorks } from "./canvas/drawPlanWorks";
|
import { drawPlanWorks } from "./canvas/drawPlanWorks";
|
||||||
import { drawStations } from "./canvas/drawStations";
|
import { drawStations } from "./canvas/drawStations";
|
||||||
import { drawSwath } from "./canvas/drawSwath";
|
import { drawSwath } from "./canvas/drawSwath";
|
||||||
|
import { drawTiles } from "./canvas/drawTiles";
|
||||||
import { drawTracks } from "./canvas/drawTracks";
|
import { drawTracks } from "./canvas/drawTracks";
|
||||||
import { createMapProjection } from "./geometry/mapProjection";
|
import { createMapProjection } from "./geometry/mapProjection";
|
||||||
import { createPolygonSpatialIndex, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex";
|
import { createPolygonSpatialIndex, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex";
|
||||||
@@ -64,6 +65,8 @@ export function Tgu2DMapView({
|
|||||||
const hoverRef = useRef<MapPolygon | undefined>(undefined);
|
const hoverRef = useRef<MapPolygon | undefined>(undefined);
|
||||||
const [size, setSize] = useState<MapSize>({ width: 1000, height: 560 });
|
const [size, setSize] = useState<MapSize>({ width: 1000, height: 560 });
|
||||||
const [view, setView] = useState<MapViewState>(INITIAL_VIEW);
|
const [view, setView] = useState<MapViewState>(INITIAL_VIEW);
|
||||||
|
const [tileVersion, setTileVersion] = useState(0);
|
||||||
|
const handleTileLoad = useCallback(() => setTileVersion((v) => v + 1), []);
|
||||||
const [tooltip, setTooltip] = useState<TooltipState>();
|
const [tooltip, setTooltip] = useState<TooltipState>();
|
||||||
const selectedPolygonRef = useRef<MapPolygon | undefined>(undefined);
|
const selectedPolygonRef = useRef<MapPolygon | undefined>(undefined);
|
||||||
const activePolygons = useMemo(
|
const activePolygons = useMemo(
|
||||||
@@ -125,7 +128,8 @@ export function Tgu2DMapView({
|
|||||||
|
|
||||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
context.clearRect(0, 0, size.width, size.height);
|
context.clearRect(0, 0, size.width, size.height);
|
||||||
drawBaseMap(context, projection, size);
|
drawTiles(context, view, projection, size, handleTileLoad);
|
||||||
|
drawBaseMap(context, projection);
|
||||||
|
|
||||||
if (layers.swath) {
|
if (layers.swath) {
|
||||||
drawSwath(context, visiblePolygons.filter((polygon) => polygon.layer === "swath"), projection, size);
|
drawSwath(context, visiblePolygons.filter((polygon) => polygon.layer === "swath"), projection, size);
|
||||||
@@ -142,7 +146,7 @@ export function Tgu2DMapView({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => window.cancelAnimationFrame(frameId);
|
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 screenPointFromEvent = useCallback((event: React.PointerEvent | React.MouseEvent): ScreenPoint => {
|
||||||
const bounds = wrapperRef.current?.getBoundingClientRect();
|
const bounds = wrapperRef.current?.getBoundingClientRect();
|
||||||
@@ -307,7 +311,7 @@ export function Tgu2DMapView({
|
|||||||
<span>{tooltip.subtitle}</span>
|
<span>{tooltip.subtitle}</span>
|
||||||
</div>
|
</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">
|
<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.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((current) => ({ ...current, zoom: Math.max(0.8, current.zoom - 0.5) }))}>-</button>
|
||||||
|
|||||||
@@ -1,31 +1,10 @@
|
|||||||
import type { MapProjection } from "../geometry/mapProjection";
|
import type { MapProjection } from "../geometry/mapProjection";
|
||||||
import type { MapSize } from "../model/mapTypes";
|
|
||||||
|
|
||||||
const LAND: Record<string, [number, number][]> = {
|
// Draws the lat/lon graticule (30° grid) over OSM tiles.
|
||||||
"Сев. Америка": [[-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]],
|
export function drawBaseMap(ctx: CanvasRenderingContext2D, projection: MapProjection): void {
|
||||||
"Юж. Америка": [[-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) {
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.strokeStyle = "rgba(116, 160, 154, 0.14)";
|
ctx.strokeStyle = "rgba(255, 255, 255, 0.10)";
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 0.5;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
|
|
||||||
for (let lon = -180; lon <= 180; lon += 30) {
|
for (let lon = -180; lon <= 180; lon += 30) {
|
||||||
@@ -45,26 +24,3 @@ function drawGrid(ctx: CanvasRenderingContext2D, projection: MapProjection) {
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.restore();
|
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;
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
.tgu-editor-workspace {
|
.tgu-editor-workspace {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-rows: auto auto minmax(0, 1fr);
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
background: #121516;
|
background: #121516;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,12 +98,21 @@
|
|||||||
color: #ffe7b5;
|
color: #ffe7b5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tgu-editor-notices:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-editor-workspace > .tgu-empty {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.tgu-editor-notice {
|
.tgu-editor-notice {
|
||||||
margin: 0.55rem 0.8rem 0;
|
margin: 0.55rem 0.8rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tgu-editor-layout {
|
.tgu-editor-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
@@ -210,7 +222,7 @@
|
|||||||
|
|
||||||
.tgu-editor-center {
|
.tgu-editor-center {
|
||||||
display: grid;
|
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-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
@@ -218,14 +230,41 @@
|
|||||||
|
|
||||||
.tgu-editor-map-stage {
|
.tgu-editor-map-stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 13rem;
|
min-height: 6rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
background: #0b0d0e;
|
background: #0b0d0e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tgu-editor-map-stage .tgu-2d-map {
|
.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 {
|
.tgu-2d-map.is-picking {
|
||||||
|
|||||||
Reference in New Issue
Block a user