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:
@@ -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
|
||||
Reference in New Issue
Block a user