Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 — 582 두 장 캔버스 · UI_Map 충돌(sub1 누름 잡기 · 놓음 + sub2 위만 다시 그림) 양쪽 살림

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 14:49:32 +09:00
co-authored by Claude Opus 5.5
7 changed files with 421 additions and 30 deletions
+61 -23
View File
@@ -8,6 +8,8 @@
* 회전 `createMapRotation`(B05 그대로 · 단추 `[data-act="rotate-ccw|cw"]` · Ctrl+휠).
* 왼쪽 단추 = 겹 몫(점 찍기 · 마크 · 재기) — 왼쪽 끌기 옮기기 없음(설정 창 규칙 = 가운데 단추 옮김 · 휠 방향).
* 겹은 돌리기 전 그림 좌표로 그리고 받음 — 누른 자리는 `unrotate` 로 되돌려 넘김.
* 캔버스 두 장(582) — 아래 = 위성 · 바탕 겹(`base`) · 위 = 그은 선 · 재기 · 누름 받음.
* 마우스 움직임 = 위만 다시 그림 · 화면이 바뀌면(옮김 · 확대 · 돌림 · 겹) 둘 다 · 한 화면 한 번(rAF).
* ========================================================================== */
import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools";
@@ -66,6 +68,8 @@ export interface ScreenMap {
/** 평면 위 겹 — 그리기 · 누름(참 = 받음 · 0 왼쪽 · 2 오른쪽) · 마우스 곁(왼쪽 끌기 중도) · 놓음 · 나감. */
export interface PlanMapLayer {
/** 바탕 겹(아래 캔버스 · 마우스 움직임엔 안 다시 그림). */
base?: boolean;
draw: (context: CanvasRenderingContext2D, map: ScreenMap) => void;
press?: (px: number, py: number, map: ScreenMap, button: 0 | 2) => boolean;
hover?: (px: number, py: number, map: ScreenMap) => void;
@@ -93,8 +97,10 @@ export interface PlanMap {
update: (patch: Partial<PlanMapScene>) => void;
setLayers: (layers: PlanMapLayer[]) => void;
setTools: (tools: Partial<PlanTools>) => void;
/** 다시 그림(겹 값이 바뀜). */
/** 위 겹 다시 그림(그은 선 · 재기 값이 바뀜). */
redraw: () => void;
/** 바탕 겹까지 다시 그림(겹 켬끔 · 받음). */
redrawBase: () => void;
/** 맞출 점이 들어오게 다시 맞춤. */
fit: () => void;
/** 검증 창구 — 작업 좌표계 점 → 화면에 보이는 px(돌린 뒤 · 그리기 전이면 null). */
@@ -131,6 +137,8 @@ function fakeMeta(points: Vertex[]): VWorldMeta | null {
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 => {
@@ -140,7 +148,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
onClick: () => {
marks = { ...marks, [act]: !marks[act] };
button.setAttribute("aria-pressed", String(marks[act]));
draw();
drawTop();
},
});
return button;
@@ -175,7 +183,8 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
});
const toolbar = tools.root;
toolbar.classList.add("d03-plan__tools");
root.append(canvas, toolbar);
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[] = [];
@@ -236,33 +245,58 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
fitted = true;
}
function draw(): void {
context.clearRect(0, 0, view.width, view.height);
/** 한 장 그림 — 아래 = 위성 + 바탕 겹 · 위 = 나머지 겹. */
function paint(ctx: CanvasRenderingContext2D, base: boolean): void {
ctx.clearRect(0, 0, view.width, view.height);
if (!meta) return;
context.save();
context.translate(view.width / 2, view.height / 2);
context.rotate(rotation.radians());
context.translate(-view.width / 2, -view.height / 2);
if (scene.image?.complete && scene.image.naturalWidth && scene.meta) {
ctx.save();
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) {
const [x0, y0] = normalizedToScreen(view, 0, 0);
const [x1, y1] = normalizedToScreen(view, 1, 1);
context.drawImage(scene.image, x0, y0, x1 - x0, y1 - y0);
ctx.drawImage(scene.image, x0, y0, x1 - x0, y1 - y0);
}
const map = screenMap();
for (const layer of layers) {
context.save();
layer.draw(context, map);
context.restore();
if (!layer.base !== !base) continue;
ctx.save();
layer.draw(ctx, map);
ctx.restore();
}
context.restore();
ctx.restore();
}
let frame = 0;
let baseDirty = false;
let topDirty = false;
/** 모인 다시 그림을 지금 그림. */
function flush(): void {
cancelAnimationFrame(frame);
frame = 0;
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();
draw();
// 크기를 바꾸면 캔버스가 비므로 같은 화면에 바로 그림(빈 화면 한 번 없음)
baseDirty = topDirty = true;
flush();
}
/** 누른 화면 자리 → 그림 px(돌리기 전). */
@@ -277,31 +311,31 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
const map = screenMap();
if (layers.some((layer) => layer.press?.(px, py, map, 0))) {
canvas.setPointerCapture(event.pointerId); // 끌기가 캔버스 밖으로 나가도 놓음을 받음
draw();
drawTop();
}
});
canvas.addEventListener("pointerup", (event) => {
if (event.button !== 0) return;
for (const layer of layers) layer.release?.();
draw();
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);
draw();
drawTop();
});
canvas.addEventListener("pointerleave", () => {
for (const layer of layers) layer.leave?.();
draw();
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))) draw();
if (layers.some((layer) => layer.press?.(px, py, map, 2))) drawTop();
});
bindRouteEditNavigation({
canvas,
@@ -335,7 +369,8 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
if (tools.measure !== undefined)
measureButton.setAttribute("aria-pressed", String(tools.measure));
},
redraw: draw,
redraw: drawTop,
redrawBase: draw,
fit() {
refit();
draw();
@@ -346,6 +381,9 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
return rotation.rerotate(x, y);
},
rotationDeg: () => (rotation.radians() * 180) / Math.PI,
dispose: () => observer.disconnect(),
dispose: () => {
observer.disconnect();
cancelAnimationFrame(frame);
},
};
}