Files
Aislo/resources/tester/helper_401_routeedit_pan.cjs
T
eomsangdonandClaude Opus 5.5 2360e60834 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
2026-10-17 21:33:34 +09:00

225 lines
7.7 KiB
JavaScript

/* 계획노선 편집 창 **가운데 단추 팬 튐**(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(),
}),
);