Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 — 586-1 GL 바탕 시험 · test_582 같은 줄 양쪽 같음(주석 살림)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 19:38:23 +09:00
co-authored by Claude Opus 5.5
7 changed files with 444 additions and 28 deletions
+93 -19
View File
@@ -11,6 +11,7 @@
* 캔버스 두 장(582) — 아래 = 위성 · 바탕 겹(`base`) · 위 = 그은 선 · 재기 · 누름 받음.
* 마우스 움직임 = 위만 다시 그림 · 화면이 바뀌면(옮김 · 확대 · 돌림 · 겹) 둘 다 · 한 화면 한 번(rAF).
* TM 겹(그은 선 · 재기 · 그림 겹)은 경위도로 바꿔 바탕과 같은 자리(586-0 · `localTmFrame`) — 좌표계를 모르면 옛 꼴.
* GL 바탕 시험(586-1 · `setGl`) — 맨 아래 MapLibre 가 이 화면을 따라감 · 캔버스 겹은 그 투영으로 그림.
* ========================================================================== */
import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools";
@@ -29,6 +30,7 @@ import {
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";
@@ -93,6 +95,12 @@ export interface PlanMapLayer {
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;
@@ -119,6 +127,8 @@ export interface PlanMap {
redraw: () => void;
/** 바탕 겹까지 다시 그림(겹 켬끔 · 받음). */
redrawBase: () => void;
/** GL 바탕 켬(MapLibre 를 맨 아래에) · null = 끔(지금 캔버스). */
setGl: (gl: GlBase | null) => void;
/** 맞출 점이 들어오게 다시 맞춤. */
fit: () => void;
/** 검증 창구 — 작업 좌표계 점 → 화면에 보이는 px(돌린 뒤 · 그리기 전이면 null). */
@@ -221,6 +231,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
let layers: PlanMapLayer[] = [];
let meta: VWorldMeta | null = null;
let fitted = false;
let gl: GlBase | null = null;
let view: ViewState = {
width: 0,
height: 0,
@@ -235,6 +246,71 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
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);
@@ -246,19 +322,8 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
pxPerMeter: projector.pxPerMeter,
};
}
const normalizer = createNormalizer(scene.meta);
const [x0, y0] = normalizedToScreen(view, 0, 0);
const [x1, y1] = normalizedToScreen(view, 1, 1);
const frame = localTmFrame(
params,
(lon, lat) => lonLatToScreen(normalizer, view, lon, lat),
(px, py) => [
normalizer.lonMin + ((px - x0) / (x1 - x0)) * normalizer.lonRange,
normalizer.latMin + (1 - (py - y0) / (y1 - y0)) * normalizer.latRange,
],
view.width / 2,
view.height / 2,
);
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),
@@ -269,7 +334,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
const screenMap = (): ScreenMap => {
return {
...metricFrame(),
view,
view: gl?.map() && scene.meta ? glView(scene.meta) : view,
...marks,
uprightRad: rotation.uprightRad(),
upright: (ctx, x, y, paint) => {
@@ -314,7 +379,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
ctx.translate(view.width / 2, view.height / 2);
ctx.rotate(rotation.radians());
ctx.translate(-view.width / 2, -view.height / 2);
if (base && scene.image?.complete && scene.image.naturalWidth && scene.meta) {
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);
@@ -336,6 +401,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
function flush(): void {
cancelAnimationFrame(frame);
frame = 0;
if (baseDirty) glCamera();
if (baseDirty) paint(baseContext, true);
if (topDirty) paint(context, false);
baseDirty = topDirty = false;
@@ -355,6 +421,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
if (!width || !height) return;
view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) };
if (!fitted) refit();
gl?.resize();
// 크기를 바꾸면 캔버스가 비므로 같은 화면에 바로 그림(빈 화면 한 번 없음)
baseDirty = topDirty = true;
flush();
@@ -458,6 +525,14 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
},
redraw: drawTop,
redrawBase: draw,
setGl(next) {
if (next === gl) return;
gl?.enable(null);
gl = next;
gl?.enable(root);
gl?.resize();
draw();
},
fit() {
refit();
draw();
@@ -467,12 +542,11 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
const [x, y] = screenMap().toScreen(point);
return rotation.rerotate(x, y);
},
screenOfLonLat: (lon, lat) => {
if (!scene.meta) return null;
return rotation.rerotate(...lonLatToScreen(createNormalizer(scene.meta), view, lon, lat));
},
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);
},