diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index aa004dab2..2cd6d527a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -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 { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts index de2c38274..f292f93cd 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -54,6 +54,16 @@ export function centerDirectionOf( return length <= 1e-9 ? null : [sx / length, sy / length]; } +/** 패널 하단 글 — **내각만** 남긴다(2026-09-12 사용자 지시 ㉗) · 고정 여부는 단추 색, 하한은 + * 칸이 막음. 155° 이상은 L 하한을 안 거는 까닭이 보이게. 곡선 없는 교각점(삼각)도 내각(403-1). */ +export function curveInfoText(curveOn: boolean, inner: number | null, optional: boolean): string { + const angle = inner ? `내각 ${Math.round(inner)}°` : ""; + if (!curveOn) return [angle, "곡선 없음 — 직선이 그대로 꺾입니다"].filter(Boolean).join(" · "); + return angle && optional + ? `${angle} · ${CURVE_OPTIONAL_INNER_ANGLE_DEG}° 이상 — 곡선 생략 가능` + : angle; +} + /** 꺾임점과 패널 사이 여백(px) — 손잡이(접선점 네모)를 가리지 않을 만큼 띄운다. */ const GAP_PX = 40; /** 자동 자리를 고를 방위 수 — 16방위(22.5°마다). */ @@ -342,19 +352,7 @@ export function createCurveLabel( state.radiusShown === null ? "" : String(Math.round(state.radiusShown * 10) / 10); arc.value = state.arcLengthShown === null ? "" : String(Math.round(state.arcLengthShown * 10) / 10); - const inner = state.innerAngleDeg; - // 하단에는 **내각만** 남긴다(2026-09-12 사용자 지시 ㉗) — 고정 여부는 단추 색으로, - // 하한은 칸이 이미 막으므로 글로 또 적을 까닭이 없다. - // 내각 155° 이상은 법이 곡선을 안 둬도 된다고 한 자리다 — L 하한을 안 거는 까닭이 - // 여기서 보여야 한다(2026-09-12 사용자 확정). - info.textContent = state.curveOn - ? inner - ? `내각 ${Math.round(inner)}°` + - (state.curveOptional - ? ` · ${CURVE_OPTIONAL_INNER_ANGLE_DEG}° 이상 — 곡선 생략 가능` - : "") - : "" - : "곡선 없음 — 직선이 그대로 꺾입니다"; + info.textContent = curveInfoText(state.curveOn, state.innerAngleDeg, state.curveOptional); place(state); // 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다. requestAnimationFrame(() => { diff --git a/resources/tester/helper_401_routeedit_pan.cjs b/resources/tester/helper_401_routeedit_pan.cjs new file mode 100644 index 000000000..115b910f4 --- /dev/null +++ b/resources/tester/helper_401_routeedit_pan.cjs @@ -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(), + }), +); diff --git a/resources/tester/helper_403_triangle_angle.cjs b/resources/tester/helper_403_triangle_angle.cjs new file mode 100644 index 000000000..72e54682e --- /dev/null +++ b/resources/tester/helper_403_triangle_angle.cjs @@ -0,0 +1,34 @@ +/* 403 곡선 창 하단 글 헬퍼 — `_Label.curveInfoText` 를 트랜스파일해 경우별 글을 JSON 으로 낸다. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const B05 = path.join(__dirname, "..", "..", "B05_Profile"); + +const STUBS = { + "@ui/ui_template_elements": { createButton: () => null }, + "./B05_Profile_UI_Panel_Fields": { numberField: () => null, toggleButton: () => null }, +}; +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const 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); + return box.exports; +} + +const { curveInfoText } = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_Label.ts")); +process.stdout.write( + JSON.stringify({ + curve: curveInfoText(true, 132.4, false), + curveOptional: curveInfoText(true, 160.2, true), + triangle: curveInfoText(false, 132.6, false), + triangleOptional: curveInfoText(false, 168.4, true), + triangleUnknown: curveInfoText(false, null, false), + }), +); diff --git a/resources/tester/test_401_routeedit_pan.py b/resources/tester/test_401_routeedit_pan.py new file mode 100644 index 000000000..a28ac8b30 --- /dev/null +++ b/resources/tester/test_401_routeedit_pan.py @@ -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 diff --git a/resources/tester/test_403_triangle_angle.py b/resources/tester/test_403_triangle_angle.py new file mode 100644 index 000000000..21bcd1003 --- /dev/null +++ b/resources/tester/test_403_triangle_angle.py @@ -0,0 +1,47 @@ +"""403 곡선 없는 교각점(삼각) 각도 글(요구 250-5) — 곡선 창 하단에 곡선 있는 자리와 같은 「내각 N°」. + +Node 헬퍼(`helper_403_triangle_angle.cjs`)가 `_Label.curveInfoText` 를 트랜스파일해 돌림. +./venv/Scripts/python.exe -m pytest resources/tester/test_403_triangle_angle.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent + + +@pytest.fixture(scope="module") +def texts(): + proc = subprocess.run( + ["node", str(HERE / "helper_403_triangle_angle.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_곡선_있는_자리_그대로(texts): + assert texts["curve"] == "내각 132°" + assert texts["curveOptional"] == "내각 160° · 155° 이상 — 곡선 생략 가능" + + +def test_삼각_자리도_내각(texts): + assert texts["triangle"] == "내각 133° · 곡선 없음 — 직선이 그대로 꺾입니다" + assert texts["triangleOptional"].startswith("내각 168° · ") + + +def test_내각_모르면_곡선_없음만(texts): + assert texts["triangleUnknown"] == "곡선 없음 — 직선이 그대로 꺾입니다" + + +def test_화면이_같은_함수를_씀(): + label = (HERE.parents[1] / "B05_Profile" / "B05_Profile_UI_RouteEdit_Label.ts").read_text( + encoding="utf-8" + ) + assert "info.textContent = curveInfoText(state.curveOn, state.innerAngleDeg" in label