까닭: 누를 때 쥔 offset 에 「처음부터 민 만큼」을 덮어써 끄는 중 휠 확대 · Ctrl+휠 회전 · 둘째 손가락이 바꾼 화면을 옛 값이 덮음(옛 코드 휠 섞음 27px · 둘째 손가락 224px · 캡처 잃음 90px 튐 → 0). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
225 lines
7.7 KiB
JavaScript
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(),
|
|
}),
|
|
);
|