Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts
T
eomsangdonandClaude Opus 5.5 8793b4f6b2 feat(D03): 노선분석 바탕 MapLibre 전체 적용 — 겹 스물 · 등고 글 · 지명 GL · 처음 = 켬 · WebGL 못 쓰면 옛 캔버스 (PLAN 586-2 ①②③⑤ · 요구 448)
- GL 겹 스물: 위성 조각 · 음영 · 급경사 · 산사태위험(image) · 산림입지 · 보호구역 · 띠 · 물 면 · 건물(fill + 테두리)
  · 지적 · 등고선 · 도로 · 임도 짐작 · 올린 임도망도 · 세류 · 단층 · 지질구조선 · 0차곡 · 산사태취약 필지(line · 점선 px 그대로)
  · 산사태취약 점 · 지명 점(circle) — 색 · 굵기 · 투명도 · 차례 = 옛 캔버스 그대로
- 음영 = 위성 위 「곱하기 0.75」와 같은 셈의 검은 반투명 그림(밝기 g → 투명도 0.75·(1−g)) · 위성 끔 = 그대로
- 등고 수치 · 지명 = symbol(겹치면 뺌 · 등고 글은 선 따라) · 글꼴 = 화면 Noto Sans KR(OFL · 자체 보관)을 그 자리에서 그림
  (꼴에 글꼴 주소 없음 = 글꼴 조각 파일 없음 · 받기 전에 그 글자 글꼴 미리 받음)
- 카메라 주인 = D03 화면 view · 돌림 하나 — 휠 · 가운데 끌기 · 돌림 · 맞춤 · 단축키 모두 draw() → glCamera
- 처음 = GL(aislo:pref:d03.canvas = "1" 이면 옛 캔버스) · 겹 단추줄 끝 단추로 바꿈 · WebGL 못 열거나 잃음 = 저절로 옛 캔버스 + 알림
- GL 이면 캔버스 바탕 겹은 쉼(등고 주곡선 간격 셈만) · GL 이면 위성 한 장 그림 안 받음
- 산사태위험 그림도 경위도 귀퉁이(lonlat_corners 한 곳) · 묶음 꼴 표시 없는 합친 자료도 GL 이 받게 다시 쌈
- 캔버스 그리기 목록 = 새 D03_PlanAnalysis_UI_LayerPaint.ts(700줄 규칙) · 검증 창구 layers().gl = GL 겹별 그린 지물 수
- 잼(headless Edge 실 GPU · 검증_sub2_D · 겹 18 켬 = 자료 있는 겹 전부): 줌아웃 화면 간격 중앙 58.4 → 4.2 ms · 끊김 27 → 0/30
  · 끌기 62.5 → 4.2 ms · 끊김 37 → 0/40 · 마우스 4.2 ms 끊김 0 그대로 · 그은 선 대 GL 0.019 px(돌림 30° 0.019 · 확대 0.077)
- 시험: 캔버스 그리기를 옮긴 자리로 지킴 시험 넷 고침(581-2 · 582 · 584-1 · 584-3 — 그리기 차례 · 색 값이 LayerPaint 로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:28:13 +09:00

558 lines
21 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_UI_Map.ts
* D03 메인 평면(PLAN 530-3 · 562-3) — 위성 바탕 · 위에 겹(바탕 겹 569-2 · 그리기 · 거리재기) · 도구줄 아홉
* (공용 `createMapTools` 568-3 — [?] · 되돌리기 · 다시하기 · 3D · 좌회전 · 우회전 · 측점 · 기울기 · 거리재기).
*
* 셈 · 그리기는 B 부품 그대로(import 만) — 좌표 `createMetricProjector` · `screenToMetric` · 맞춤
* `computeRouteView` · 휠 확대 · 가운데 단추 옮기기 `bindRouteEditNavigation` ·
* 회전 `createMapRotation`(B05 그대로 · 단추 `[data-act="rotate-ccw|cw"]` · Ctrl+휠).
* 왼쪽 단추 = 겹 몫(점 찍기 · 마크 · 재기) — 왼쪽 끌기 옮기기 없음(설정 창 규칙 = 가운데 단추 옮김 · 휠 방향).
* 겹은 돌리기 전 그림 좌표로 그리고 받음 — 누른 자리는 `unrotate` 로 되돌려 넘김.
* 캔버스 두 장(582) — 아래 = 위성 · 바탕 겹(`base`) · 위 = 그은 선 · 재기 · 누름 받음.
* 마우스 움직임 = 위만 다시 그림 · 화면이 바뀌면(옮김 · 확대 · 돌림 · 겹) 둘 다 · 한 화면 한 번(rAF).
* TM 겹(그은 선 · 재기 · 그림 겹)은 경위도로 바꿔 바탕과 같은 자리(586-0 · `localTmFrame`) — 좌표계를 모르면 옛 꼴.
* GL 바탕(586 · `setGl`) — 맨 아래 MapLibre 가 이 화면을 따라감 · 캔버스 겹은 그 투영으로 그림.
* 카메라 주인 = 이 화면 `view` · 돌림 하나 — 휠 · 가운데 끌기 · 돌림 단추 · 맞춤 · 앞으로 붙을 팬 단추 · 단축키는
* 모두 `view` 를 바꾸고 `draw()` → 한 화면 한 번 `glCamera` 로 MapLibre 를 맞춘 뒤 캔버스를 그림.
* ========================================================================== */
import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools";
import { themeColor } from "@ui/ui_template_palette";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
computeMapRect,
computeRouteView,
createMetricProjector,
createNormalizer,
lonLatToScreen,
normalizedToScreen,
routeLineColor,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { bindRouteEditNavigation } from "../B05_Profile/B05_Profile_UI_RouteEdit_Input";
import { createMapRotation } from "../B05_Profile/B05_Profile_UI_RouteEdit_Rotate";
import { fitCanvas, screenToMetric } from "../B05_Profile/B05_Profile_UI_RouteEdit_View";
import type { GlBase } from "./D03_PlanAnalysis_UI_MapGl";
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { localTmFrame, tmParams } from "./logic/D03_PlanAnalysis_Tm";
/** 도구줄 「연도 마커」 끌어 놓기 자료 꼴(585-3). */
export const YEAR_MARK_DRAG = "application/x-aislo-year-mark";
/** 측점 간격(m) — B 규칙 측점과 같음. */
export const PLAN_STATION_INTERVAL_M = 20;
/** 겹 그림 색(그림 전용 값 `--draw-*` · 밝음 · 어두움 같음) — 선 · 그리는 중 · 경고 · 글 테두리. */
export function planColors(): { line: string; active: string; warn: string; halo: string } {
return {
line: routeLineColor(),
active: themeColor("--draw-primary", "#6d28d9"),
warn: themeColor("--draw-danger", "#dc2626"),
halo: "rgba(0, 0, 0, 0.65)",
};
}
/** 위성이 없을 때 맞출 점 둘레로 둘 가짜 도엽 여유(m). */
const FAKE_MARGIN_M = 600;
export interface PlanMapScene {
meta: VWorldMeta | null;
image: HTMLImageElement | null;
/** 처음 맞출 점(시점 · 종점 · 그은 선). */
fit: Vertex[];
/** 작업 좌표계 — TM 겹을 경위도 바탕에 맞춤(586-0). */
epsg?: number | null;
}
/** 겹이 쓰는 좌표 — 그림 px(돌리기 전) ↔ 작업 좌표계 m · 글자 세우기. */
export interface ScreenMap {
toScreen: (point: Vertex) => [number, number];
toMetric: (px: number, py: number) => Vertex;
pxPerMeter: number;
/** B04 그리기 틀(`drawPreparedLayer` 등이 받음). */
view: ViewState;
/** 측점 눈금 · 구간 길이 · 기울기 글 켬(도구줄 측점 · 기울기). */
stations: boolean;
slopes: boolean;
/** 글자를 세울 각(라디안 · 안 돌렸으면 0). */
uprightRad: number;
/** (x, y) 둘레로 글자를 눈높이로 세워 그림(돌린 지도). */
upright: (context: CanvasRenderingContext2D, x: number, y: number, paint: () => void) => void;
/** 그림 px(돌리기 전) ↔ 보이는 화면 px(585-2 끌어 고르기 상자). */
rerotate: (px: number, py: number) => [number, number];
unrotate: (px: number, py: number) => [number, number];
}
/** 평면 위 겹 — 그리기 · 누름(참 = 받음 · 0 왼쪽 · 2 오른쪽 · shift = Shift 누름) · 마우스 곁(왼쪽 끌기 중도) ·
* 놓음 · 나감. */
export interface PlanMapLayer {
/** 바탕 겹(아래 캔버스 · 마우스 움직임엔 안 다시 그림). */
base?: boolean;
draw: (context: CanvasRenderingContext2D, map: ScreenMap) => void;
press?: (px: number, py: number, map: ScreenMap, button: 0 | 2, shift: boolean) => boolean;
hover?: (px: number, py: number, map: ScreenMap) => void;
release?: (map: ScreenMap) => void;
/** 「연도 마커」를 끌고 지나감(참 = 받을 겹) · 놓음(참 = 받음) — 585-3. */
dragOver?: (px: number, py: number, map: ScreenMap) => boolean;
drop?: (px: number, py: number, map: ScreenMap) => boolean;
leave?: () => void;
}
/** 경위도 ↔ 그림 px(돌리기 전). */
interface LonLatFrame {
project: (lon: number, lat: number) => [number, number];
unproject: (px: number, py: number) => [number, number];
}
export interface PlanMapEvents {
onUndo: () => void;
onRedo: () => void;
on3d: () => void;
onMeasure: () => void;
}
export interface PlanTools {
undo: boolean;
redo: boolean;
view3d: boolean;
/** 거리재기 켜짐(눌림). */
measure: boolean;
/** 「연도 마커」 끌 수 있음(고칠 수 있고 선 있음). */
yearMark: boolean;
}
export interface PlanMap {
root: HTMLElement;
update: (patch: Partial<PlanMapScene>) => void;
setLayers: (layers: PlanMapLayer[]) => void;
setTools: (tools: Partial<PlanTools>) => void;
/** 위 겹 다시 그림(그은 선 · 재기 값이 바뀜). */
redraw: () => void;
/** 바탕 겹까지 다시 그림(겹 켬끔 · 받음). */
redrawBase: () => void;
/** GL 바탕 켬(MapLibre 를 맨 아래에 · 참 = 섬 · 거짓 = WebGL 못 씀) · null = 끔(옛 캔버스). */
setGl: (gl: GlBase | null) => boolean;
/** 맞출 점이 들어오게 다시 맞춤. */
fit: () => void;
/** 검증 창구 — 작업 좌표계 점 → 화면에 보이는 px(돌린 뒤 · 그리기 전이면 null). */
screenOf: (point: Vertex) => [number, number] | null;
/** 검증 창구 — 경위도 → 화면 px(바탕 겹 꼴 · 586-0 맞춤 대조). */
screenOfLonLat: (lon: number, lat: number) => [number, number] | null;
/** 검증 창구 — 지금 돌린 각(도). */
rotationDeg: () => number;
dispose: () => void;
}
function fakeMeta(points: Vertex[]): VWorldMeta | null {
if (!points.length) return null;
const xs = points.map(([x]) => x);
const ys = points.map(([, y]) => y);
const x_min = Math.min(...xs) - FAKE_MARGIN_M;
const y_min = Math.min(...ys) - FAKE_MARGIN_M;
const x_max = Math.max(...xs) + FAKE_MARGIN_M;
const y_max = Math.max(...ys) + FAKE_MARGIN_M;
return {
x_min,
x_max,
y_min,
y_max,
width_meters: x_max - x_min,
height_meters: y_max - y_min,
center_x: (x_min + x_max) / 2,
center_y: (y_min + y_max) / 2,
lon_min: 0,
lon_max: 1,
lat_min: 0,
lat_max: 1,
};
}
export function createPlanMap(events: PlanMapEvents): PlanMap {
const root = document.createElement("div");
root.className = "d03-plan__map";
const baseCanvas = document.createElement("canvas");
baseCanvas.className = "d03-plan__base";
const canvas = document.createElement("canvas");
let marks = { stations: true, slopes: true };
const markButton = (act: "stations" | "slopes", title: string): HTMLButtonElement => {
const button = mapToolButton(act, {
title,
pressed: true,
onClick: () => {
marks = { ...marks, [act]: !marks[act] };
button.setAttribute("aria-pressed", String(marks[act]));
drawTop();
},
});
return button;
};
const undoButton = mapToolButton("undo", { onClick: events.onUndo });
const redoButton = mapToolButton("redo", { onClick: events.onRedo });
const viewButton = mapToolButton("view3d", { onClick: events.on3d });
const measureButton = mapToolButton("measure", { onClick: events.onMeasure });
const yearMarkButton = mapToolButton("year-mark", { disabled: true });
yearMarkButton.draggable = true;
yearMarkButton.addEventListener("dragstart", (event) => {
event.dataTransfer?.setData(YEAR_MARK_DRAG, "1");
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy";
});
const tools = createMapTools({
label: "평면 도구",
hint: {
lines: [
"[긋기] 뒤 지점 · 선 끝 · 선 위 누름 = 시작",
"누름 = 점 찍기",
"Enter = 긋기 끝",
"Esc = 긋기 취소",
"Backspace = 마지막 점 빼기",
"선 누름 = 구간 고름 · 다시 누름 = 선 → 같은 해로 넓힘",
"Shift+누름 = 구간 더함 · 뺌",
"빈 곳 오른쪽으로 끌기 = 창 안 구간 · 왼쪽으로 = 걸친 구간까지",
"고른 선 위 끌기 = 꺾임점 더해 옮김",
"「연도 마커」 선 위로 끌어 놓기 = 구역 나눔",
"꺾임점 · 마크 끌기 = 옮김 · 마크를 선 밖으로 = 지움(앞 구간과 합침)",
"오른쪽 누름 = 꺾임점 · 마크 지움",
"Delete = 고른 선 · 마크 지움",
"가운데 끌기 = 옮기기",
"휠 = 확대 · 축소",
"Ctrl+휠 = 돌리기",
],
},
groups: [
[undoButton, redoButton],
[viewButton, "rotate-ccw", "rotate-cw"],
[markButton("stations", "측점"), markButton("slopes", "구간 길이 · 기울기"), measureButton],
[yearMarkButton],
],
});
const toolbar = tools.root;
toolbar.classList.add("d03-plan__tools");
root.append(baseCanvas, canvas, toolbar);
const baseContext = baseCanvas.getContext("2d")!;
const context = canvas.getContext("2d")!;
let scene: PlanMapScene = { meta: null, image: null, fit: [] };
let layers: PlanMapLayer[] = [];
let meta: VWorldMeta | null = null;
let fitted = false;
let gl: GlBase | null = null;
let view: ViewState = {
width: 0,
height: 0,
scale: 1,
offsetX: 0,
offsetY: 0,
mapRect: computeMapRect(null, 0, 0),
};
const rotation = createMapRotation({
overlay: toolbar,
size: () => view,
onChange: () => draw(),
});
/** 경위도 ↔ 그림 px(돌리기 전) — 캔버스 = 위성 직사각형에 폄. */
const canvasLonLat = (meta: VWorldMeta): LonLatFrame => {
const normalizer = createNormalizer(meta);
const [x0, y0] = normalizedToScreen(view, 0, 0);
const [x1, y1] = normalizedToScreen(view, 1, 1);
return {
project: (lon, lat) => lonLatToScreen(normalizer, view, lon, lat),
unproject: (px, py) => [
normalizer.lonMin + ((px - x0) / (x1 - x0)) * normalizer.lonRange,
normalizer.latMin + (1 - (py - y0) / (y1 - y0)) * normalizer.latRange,
],
};
};
/** 지금 바탕의 경위도 ↔ 그림 px — GL 이면 MapLibre 투영(돌림 되돌림). */
const lonLat = (meta: VWorldMeta): LonLatFrame => {
const glMap = gl?.map();
if (!glMap) return canvasLonLat(meta);
return {
project: (lon, lat) => {
const point = glMap.project([lon, lat]);
return rotation.unrotate(point.x, point.y);
},
unproject: (px, py) => {
const at = glMap.unproject(rotation.rerotate(px, py));
return [at.lng, at.lat];
},
};
};
/** GL 바탕일 때 캔버스 경위도 겹이 쓸 틀 — MapLibre 투영을 화면 가운데에서 1차로 맞춤. */
const glView = (meta: VWorldMeta): ViewState => {
const frame = lonLat(meta);
const normalizer = createNormalizer(meta);
const [lon, lat] = frame.unproject(view.width / 2, view.height / 2);
const step = 1e-4;
const [cx, cy] = frame.project(lon, lat);
const [ex] = frame.project(lon + step, lat);
const [wx] = frame.project(lon - step, lat);
const [, ny] = frame.project(lon, lat + step);
const [, sy] = frame.project(lon, lat - step);
const ax = ((ex - wx) / (2 * step)) * normalizer.lonRange;
const ay = -((ny - sy) / (2 * step)) * normalizer.latRange;
const u = (lon - normalizer.lonMin) / normalizer.lonRange;
const v = 1 - (lat - normalizer.latMin) / normalizer.latRange;
return {
...view,
scale: 1,
offsetX: cx - ax * u,
offsetY: cy - ay * v,
mapRect: { x: 0, y: 0, width: ax, height: ay },
};
};
/** GL 카메라를 지금 화면(가운데 · 확대 · 돌림)에 맞춤. */
function glCamera(): void {
if (!gl?.map() || !scene.meta || !view.width) return;
const frame = canvasLonLat(scene.meta);
const [x0] = normalizedToScreen(view, 0, 0);
const [x1] = normalizedToScreen(view, 1, 1);
const pxPerDegree = (x1 - x0) / (scene.meta.lon_max - scene.meta.lon_min || 1);
gl.camera(
frame.unproject(view.width / 2, view.height / 2),
Math.log2((pxPerDegree * 360) / 512),
-(rotation.radians() * 180) / Math.PI,
);
}
/** TM ↔ 그림 px — 경위도 바탕에 맞춤(586-0) · 가짜 도엽 · 모르는 좌표계 = 옛 「같은 직사각형」 꼴. */
const metricFrame = (): Pick<ScreenMap, "toScreen" | "toMetric" | "pxPerMeter"> => {
const params = tmParams(scene.epsg);
if (!params || !scene.meta || meta !== scene.meta) {
const projector = createMetricProjector(meta!, view);
return {
toScreen: (point) => projector.toScreen(point[0], point[1]),
toMetric: (px, py) => screenToMetric(meta!, view, px, py),
pxPerMeter: projector.pxPerMeter,
};
}
const { project, unproject } = lonLat(scene.meta);
const frame = localTmFrame(params, project, unproject, view.width / 2, view.height / 2);
return {
toScreen: (point) => frame.toScreen(point[0], point[1]),
toMetric: (px, py) => frame.toMetric(px, py),
pxPerMeter: frame.pxPerMeter,
};
};
const screenMap = (): ScreenMap => {
return {
...metricFrame(),
view: gl?.map() && scene.meta ? glView(scene.meta) : view,
...marks,
uprightRad: rotation.uprightRad(),
upright: (ctx, x, y, paint) => {
const angle = rotation.uprightRad();
if (!angle) return paint();
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.translate(-x, -y);
paint();
ctx.restore();
},
rerotate: rotation.rerotate,
unrotate: rotation.unrotate,
};
};
function refit(): void {
meta = scene.meta ?? fakeMeta(scene.fit);
if (!meta || !view.width || !scene.fit.length) return;
const xs = scene.fit.map(([x]) => x);
const ys = scene.fit.map(([, y]) => y);
const box = {
x_min: Math.min(...xs),
x_max: Math.max(...xs),
y_min: Math.min(...ys),
y_max: Math.max(...ys),
};
view = {
...view,
mapRect: computeMapRect(meta, view.width, view.height),
...computeRouteView(meta, box, view.width, view.height, 150),
};
fitted = true;
}
/** 한 장 그림 — 아래 = 위성 + 바탕 겹 · 위 = 나머지 겹. */
function paint(ctx: CanvasRenderingContext2D, base: boolean): void {
ctx.clearRect(0, 0, view.width, view.height);
if (!meta) return;
ctx.save();
ctx.translate(view.width / 2, view.height / 2);
ctx.rotate(rotation.radians());
ctx.translate(-view.width / 2, -view.height / 2);
if (base && !gl && scene.image?.complete && scene.image.naturalWidth && scene.meta) {
const [x0, y0] = normalizedToScreen(view, 0, 0);
const [x1, y1] = normalizedToScreen(view, 1, 1);
ctx.drawImage(scene.image, x0, y0, x1 - x0, y1 - y0);
}
const map = screenMap();
for (const layer of layers) {
if (!layer.base !== !base) continue;
ctx.save();
layer.draw(ctx, map);
ctx.restore();
}
ctx.restore();
}
let frame = 0;
let baseDirty = false;
let topDirty = false;
/** 모인 다시 그림을 지금 그림. */
function flush(): void {
cancelAnimationFrame(frame);
frame = 0;
if (baseDirty) glCamera();
if (baseDirty) paint(baseContext, true);
if (topDirty) paint(context, false);
baseDirty = topDirty = false;
}
/** 다음 화면에 한 번 — 바탕까지(화면 · 겹이 바뀜) 또는 위만(마우스). */
function schedule(base: boolean): void {
baseDirty ||= base;
topDirty = true;
if (!frame) frame = requestAnimationFrame(flush);
}
const draw = (): void => schedule(true);
const drawTop = (): void => schedule(false);
function resize(): void {
fitCanvas(baseCanvas, baseContext);
const { width, height } = fitCanvas(canvas, context);
if (!width || !height) return;
view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) };
if (!fitted) refit();
gl?.resize();
// 크기를 바꾸면 캔버스가 비므로 같은 화면에 바로 그림(빈 화면 한 번 없음)
baseDirty = topDirty = true;
flush();
}
/** 누른 화면 자리 → 그림 px(돌리기 전). */
const at = (event: PointerEvent | MouseEvent): [number, number] => {
const rect = canvas.getBoundingClientRect();
return rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
};
canvas.addEventListener("pointerdown", (event) => {
if (!meta || event.button !== 0) return;
const [px, py] = at(event);
const map = screenMap();
if (layers.some((layer) => layer.press?.(px, py, map, 0, event.shiftKey))) {
canvas.setPointerCapture(event.pointerId); // 끌기가 캔버스 밖으로 나가도 놓음을 받음
drawTop();
}
});
canvas.addEventListener("pointerup", (event) => {
if (event.button !== 0 || !meta) return;
const map = screenMap();
for (const layer of layers) layer.release?.(map);
drawTop();
});
canvas.addEventListener("pointermove", (event) => {
if (!meta || event.buttons & 4) return; // 가운데 단추 옮기는 중
const [px, py] = at(event);
const map = screenMap();
for (const layer of layers) layer.hover?.(px, py, map);
drawTop();
});
canvas.addEventListener("pointerleave", () => {
for (const layer of layers) layer.leave?.();
drawTop();
});
const markDrag = (event: DragEvent): boolean =>
!!meta && !!event.dataTransfer?.types.includes(YEAR_MARK_DRAG);
canvas.addEventListener("dragover", (event) => {
if (!markDrag(event)) return;
const [px, py] = at(event);
const map = screenMap();
if (layers.some((layer) => layer.dragOver?.(px, py, map))) {
event.preventDefault();
event.dataTransfer!.dropEffect = "copy";
}
drawTop();
});
canvas.addEventListener("drop", (event) => {
if (!markDrag(event)) return;
event.preventDefault();
const [px, py] = at(event);
const map = screenMap();
layers.some((layer) => layer.drop?.(px, py, map));
drawTop();
});
canvas.addEventListener("dragleave", () => {
for (const layer of layers) layer.leave?.();
drawTop();
});
canvas.addEventListener("contextmenu", (event) => {
event.preventDefault();
if (!meta) return;
const [px, py] = at(event);
const map = screenMap();
if (layers.some((layer) => layer.press?.(px, py, map, 2, event.shiftKey))) drawTop();
});
bindRouteEditNavigation({
canvas,
getView: () => view,
setView: (next) => (view = next),
getMeta: () => meta,
unrotateDelta: rotation.unrotateDelta,
rotateWheel: rotation.wheel,
draw,
});
const observer = new ResizeObserver(() => resize());
observer.observe(root);
return {
root,
update(patch) {
const metaChanged = "meta" in patch && patch.meta !== scene.meta;
scene = { ...scene, ...patch };
if (metaChanged || !fitted) refit();
draw();
},
setLayers(next) {
layers = next;
draw();
},
setTools(tools) {
if (tools.undo !== undefined) undoButton.disabled = !tools.undo;
if (tools.redo !== undefined) redoButton.disabled = !tools.redo;
if (tools.view3d !== undefined) viewButton.disabled = !tools.view3d;
if (tools.measure !== undefined)
measureButton.setAttribute("aria-pressed", String(tools.measure));
if (tools.yearMark !== undefined) yearMarkButton.disabled = !tools.yearMark;
},
redraw: drawTop,
redrawBase: draw,
setGl(next) {
if (next === gl) return !!gl;
gl?.enable(null);
gl = null;
if (next?.enable(root)) gl = next;
gl?.resize();
draw();
return !!gl;
},
fit() {
refit();
draw();
},
screenOf: (point) => {
if (!meta) return null;
const [x, y] = screenMap().toScreen(point);
return rotation.rerotate(x, y);
},
screenOfLonLat: (lon, lat) =>
scene.meta ? rotation.rerotate(...lonLat(scene.meta).project(lon, lat)) : null,
rotationDeg: () => (rotation.radians() * 180) / Math.PI,
dispose: () => {
gl?.enable(null);
observer.disconnect();
cancelAnimationFrame(frame);
},
};
}