fix(B05 노선 편집 팬): 가운데 단추 팬 튐 — 걸음마다 지금 화면에 더함 · 시작 포인터만 · 단추 놓침 · 캡처 잃음에 팬 끝 (401)

까닭: 누를 때 쥔 offset 에 「처음부터 민 만큼」을 덮어써 끄는 중 휠 확대 · Ctrl+휠 회전 · 둘째 손가락이 바꾼 화면을 옛 값이 덮음(옛 코드 휠 섞음 27px · 둘째 손가락 224px · 캡처 잃음 90px 튐 → 0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-17 21:33:34 +09:00
co-authored by Claude Opus 5.5
parent 614f12bb88
commit 2360e60834
3 changed files with 304 additions and 11 deletions
+19 -11
View File
@@ -47,7 +47,9 @@ export interface RouteEditNavigationParams {
export function bindRouteEditNavigation(params: RouteEditNavigationParams): void {
const { canvas, getView, setView, getMeta, draw } = params;
const unrotateDelta = params.unrotateDelta ?? ((dx: number, dy: number) => [dx, dy]);
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
/** 팬 중인 포인터 — 직전 자리만 쥔다(401). 누를 때 offset 을 쥐고 「처음 + 민 만큼」으로 덮으면
* 끄는 중 휠 확대 · Ctrl+휠 회전이 바꾼 화면을 옛 값이 덮어 지도가 튄다 → 걸음마다 지금 화면에 더함. */
let drag: { id: number; x: number; y: number; mask: number } | null = null;
canvas.addEventListener(
"wheel",
@@ -92,31 +94,37 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
!(params.leftPan && event.button === 0)
)
return;
const view = getView();
dragStart = {
if (drag) return; // 둘째 손가락은 팬을 넘겨받지 않음(벌림 확대 몫)
// 쥔 단추(buttons 비트) — 가운데 4 · 왼쪽 1. 놓침(창 밖에서 놓고 들어옴)을 move 에서 알아챔.
drag = {
id: event.pointerId,
x: event.clientX,
y: event.clientY,
offsetX: view.offsetX,
offsetY: view.offsetY,
mask: event.button === 1 ? 4 : 1,
};
canvas.style.cursor = "grabbing";
canvas.setPointerCapture(event.pointerId);
});
canvas.addEventListener("pointermove", (event) => {
if (!dragStart) return;
const [dx, dy] = unrotateDelta(event.clientX - dragStart.x, event.clientY - dragStart.y);
setView({ ...getView(), offsetX: dragStart.offsetX + dx, offsetY: dragStart.offsetY + dy });
if (!drag || event.pointerId !== drag.id) return;
if (event.pointerType === "mouse" && !(event.buttons & drag.mask)) return stop();
const [dx, dy] = unrotateDelta(event.clientX - drag.x, event.clientY - drag.y);
drag.x = event.clientX;
drag.y = event.clientY;
const view = getView();
setView({ ...view, offsetX: view.offsetX + dx, offsetY: view.offsetY + dy });
draw();
});
const stop = (): void => {
if (!dragStart) return;
dragStart = null;
const stop = (event?: PointerEvent): void => {
if (!drag || (event && event.pointerId !== drag.id)) return;
drag = null;
canvas.style.removeProperty("cursor");
};
canvas.addEventListener("pointerup", stop);
canvas.addEventListener("pointercancel", stop);
canvas.addEventListener("lostpointercapture", stop);
}
export interface NodeSpliceParams {
@@ -0,0 +1,224 @@
/* 계획노선 편집 창 **가운데 단추 팬 튐**(401 · 요구 250-3) 단위검증 헬퍼 —
* `_RouteEdit_Input.ts` 의 `bindRouteEditNavigation` 에 가짜 캔버스를 붙이고 포인터 · 휠 이벤트를
* 흘려 보낸다. 회전은 진짜 `_RouteEdit_Rotate.ts` · 화면 셈은 진짜 B04 `metricToScreen`.
* 판정 = 「잡은 땅 점이 손(커서)을 따라가나」 — 보이는 자리 = rerotate(metricToScreen(...)).
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
function memoryStorage() {
const map = new Map();
return {
getItem: (key) => (map.has(key) ? map.get(key) : null),
setItem: (key, value) => map.set(key, String(value)),
removeItem: (key) => map.delete(key),
};
}
globalThis.window = { localStorage: memoryStorage(), sessionStorage: memoryStorage() };
globalThis.localStorage = globalThis.window.localStorage;
const STUBS = { "@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback } };
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const input = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Input.ts"));
const rotateMod = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Rotate.ts"));
const render = loadTs(path.join(ROOT, "B04_PreProcess", "B04_PreProcess_UI_MapRender.ts"));
const RECT = { left: 10, top: 20, width: 800, height: 600 };
const meta = { x_min: 0, y_min: 0, width_meters: 800, height_meters: 600 };
const W = [300, 250]; // 잡을 땅 점(m)
/** 창 하나 — 가짜 캔버스 · 진짜 회전 · 진짜 팬/휠. */
function rig(rotationDeg = 0) {
const listeners = {};
const canvas = {
addEventListener: (type, fn) => (listeners[type] ??= []).push(fn),
getBoundingClientRect: () => RECT,
style: { cursor: "", removeProperty() {} },
setPointerCapture() {},
};
let view = {
width: RECT.width,
height: RECT.height,
scale: 1,
offsetX: 0,
offsetY: 0,
mapRect: { x: 0, y: 0, width: RECT.width, height: RECT.height },
};
const rotation = rotateMod.createMapRotation({
overlay: { querySelector: () => null },
size: () => ({ width: RECT.width, height: RECT.height }),
onChange: () => {},
});
rotation.set((rotationDeg * Math.PI) / 180);
input.bindRouteEditNavigation({
canvas,
getView: () => view,
setView: (next) => (view = next),
getMeta: () => meta,
unrotateDelta: rotation.unrotateDelta,
rotateWheel: rotation.wheel,
draw: () => {},
});
const fire = (type, event) =>
(listeners[type] ?? []).forEach((fn) => fn({ preventDefault() {}, ...event }));
/** 땅 점이 화면(client)에 보이는 자리. */
const seen = ([x, y]) => {
const [px, py] = rotation.rerotate(...render.metricToScreen(meta, view, x, y));
return [px + RECT.left, py + RECT.top];
};
return { fire, seen, view: () => view };
}
const mouse = (x, y, extra = {}) => ({
pointerId: 1,
pointerType: "mouse",
clientX: x,
clientY: y,
button: 1,
buttons: 4,
...extra,
});
const gap = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
/** 가운데 끌기 여러 걸음 — 걸음마다 잡은 점과 손의 거리 중 가장 큰 값. */
function dragFollows(rotationDeg) {
const r = rig(rotationDeg);
let [x, y] = r.seen(W);
r.fire("pointerdown", mouse(x, y));
let worst = 0;
for (const [dx, dy] of [
[40, 0],
[0, -35],
[-120, 80],
[7, 3],
[300, -200],
]) {
x += dx;
y += dy;
r.fire("pointermove", mouse(x, y, { button: -1 }));
worst = Math.max(worst, gap(r.seen(W), [x, y]));
}
r.fire("pointerup", mouse(x, y));
return worst;
}
/** 끄는 중 휠 확대(커서 자리) 뒤 다시 끌기 — 잡은 점이 손 밑에 남나. */
function wheelMidDrag(rotationDeg) {
const r = rig(rotationDeg);
let [x, y] = r.seen(W);
r.fire("pointerdown", mouse(x, y));
x += 50;
y += 20;
r.fire("pointermove", mouse(x, y, { button: -1 }));
r.fire("wheel", { clientX: x, clientY: y, deltaY: 100, deltaMode: 0, ctrlKey: false });
r.fire("wheel", { clientX: x, clientY: y, deltaY: 100, deltaMode: 0, ctrlKey: false });
const afterWheel = gap(r.seen(W), [x, y]);
x += 30;
y -= 60;
r.fire("pointermove", mouse(x, y, { button: -1 }));
return { afterWheel, afterMove: gap(r.seen(W), [x, y]), scale: r.view().scale };
}
/** 끄는 중 Ctrl+휠 회전 뒤 다시 끌기 — 땅 점이 손이 민 만큼(같은 방향 · 같은 길이) 움직이나. */
function rotateMidDrag() {
const r = rig(15);
let [x, y] = r.seen(W);
r.fire("pointerdown", mouse(x, y));
x += 20;
r.fire("pointermove", mouse(x, y, { button: -1 }));
r.fire("wheel", { clientX: x, clientY: y, deltaY: 100, deltaMode: 0, ctrlKey: true });
const before = r.seen(W);
x += 60;
y += 45;
r.fire("pointermove", mouse(x, y, { button: -1 }));
const after = r.seen(W);
return gap([after[0] - before[0], after[1] - before[1]], [60, 45]);
}
/** 창 밖에서 가운데 단추를 놓고 들어옴 — buttons 0 move 는 팬 끝 · 그 뒤 move 는 화면 그대로. */
function releasedOutside() {
const r = rig(15);
const [x, y] = r.seen(W);
r.fire("pointerdown", mouse(x, y));
r.fire("pointermove", mouse(x + 30, y, { button: -1 }));
const held = { ...r.view() };
r.fire("pointermove", mouse(x + 400, y + 300, { button: -1, buttons: 0 }));
r.fire("pointermove", mouse(x + 10, y + 10, { button: -1, buttons: 0 }));
const lost = rig(0);
lost.fire("pointerdown", mouse(x, y));
lost.fire("lostpointercapture", mouse(x, y));
const lostBefore = { ...lost.view() };
lost.fire("pointermove", mouse(x + 90, y, { button: -1 }));
return {
moved: Math.hypot(r.view().offsetX - held.offsetX, r.view().offsetY - held.offsetY),
lostMoved: Math.hypot(
lost.view().offsetX - lostBefore.offsetX,
lost.view().offsetY - lostBefore.offsetY,
),
};
}
/** 손가락 둘 — 둘째 손가락이 팬을 넘겨받지 않음 · 첫 손가락은 계속 따라감. */
function secondTouch() {
const r = rig(0);
let [x, y] = r.seen(W);
const touch = (id, px, py) => ({
pointerId: id,
pointerType: "touch",
clientX: px,
clientY: py,
button: 0,
buttons: 1,
});
r.fire("pointerdown", touch(1, x, y));
r.fire("pointerdown", touch(2, x + 200, y + 100));
r.fire("pointermove", touch(2, x + 260, y + 130));
x += 25;
y += 15;
r.fire("pointermove", touch(1, x, y));
return gap(r.seen(W), [x, y]);
}
/** 왼쪽 단추(편집 창) — 팬 안 함. */
function leftDoesNotPan() {
const r = rig(0);
const [x, y] = r.seen(W);
r.fire("pointerdown", mouse(x, y, { button: 0, buttons: 1 }));
r.fire("pointermove", mouse(x + 80, y + 40, { button: -1, buttons: 1 }));
return Math.hypot(r.view().offsetX, r.view().offsetY);
}
process.stdout.write(
JSON.stringify({
follow0: dragFollows(0),
follow15: dragFollows(15),
wheel0: wheelMidDrag(0),
wheel15: wheelMidDrag(15),
rotateMid: rotateMidDrag(),
outside: releasedOutside(),
secondTouch: secondTouch(),
leftPan: leftDoesNotPan(),
}),
);
@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
"""계획노선 편집 창 — 가운데 단추 팬 튐(401 · 요구 250-3) 회귀검증.
까닭: 팬이 누를 때 offset 을 쥐고 「처음 + 민 만큼」으로 덮어써, 끄는 중 휠 확대 · Ctrl+휠 회전 ·
둘째 손가락이 바꾼 화면을 옛 값이 덮었다(지도가 튐). 고침 = 걸음마다 지금 화면에 더함 · 시작
포인터만 · 단추 놓침(buttons) · 캡처 잃음에 팬 끝.
Node 헬퍼(`helper_401_routeedit_pan.cjs`)가 진짜 `bindRouteEditNavigation` 에 가짜 캔버스를 붙여 돌린다.
판정 = 잡은 땅 점이 손(커서) 밑에 남나(px).
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
TOL = 1e-6
@pytest.fixture(scope="module")
def results():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_401_routeedit_pan.cjs")],
capture_output=True,
text=True,
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_가운데_끌기_여러_걸음_손_따라감(results):
assert results["follow0"] < TOL
assert results["follow15"] < TOL
def test_끄는_중_휠_확대_뒤에도_튐_없음(results):
for key in ("wheel0", "wheel15"):
case = results[key]
assert case["scale"] < 1 # 휠이 실제로 배율을 바꿨나
assert case["afterWheel"] < TOL
assert case["afterMove"] < TOL
def test_끄는_중_회전_뒤_손이_민_만큼_움직임(results):
assert results["rotateMid"] < TOL
def test_창_밖에서_놓고_들어오면_팬_끝(results):
assert results["outside"]["moved"] == pytest.approx(0, abs=TOL)
assert results["outside"]["lostMoved"] == pytest.approx(0, abs=TOL)
def test_둘째_손가락은_팬을_넘겨받지_않음(results):
assert results["secondTouch"] < TOL
def test_편집_창_왼쪽_단추는_팬_안_함(results):
assert results["leftPan"] == 0