Merge remote-tracking branch 'origin/dev' into sub_laptop_5

This commit is contained in:
2026-10-17 21:45:21 +09:00
6 changed files with 396 additions and 24 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 {
+11 -13
View File
@@ -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(() => {
@@ -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,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),
}),
);
@@ -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
@@ -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