Files
Aislo/resources/tester/helper_b05_routeedit_pick.cjs
T
eomsangdonandClaude Opus 5.5 8a9ae2113b feat(381-3 B05 평면): 교각점 크게 · 라벨 위 · 맴돔 강조 · 노드와 횡단 눈금 집기 한 길
- 노드 반지름 4 → 6(고름 +2) · 노드 · 손잡이를 라벨 뒤 맨 위로
- 집기 문턱 노드 9 → 12 · pickAtScreen: 노드 가운데 6px 안 = 노드 · 밖 = 노드와 눈금 중 가까운 쪽
- stationAtScreen → {chainage, distance} · 맴돔은 집기와 같은 셈 · 노드 = 고리 · 눈금 = 파랑(새 _Hover)
- 누를 때 · 끄는 동안 · 재는 중 · 캔버스 밖 = 맴돔 끔 · [측점] 끔이면 눈금 안 봄
- 시험 test_b05_routeedit_pick 겹침 경우 · test_381_3_routeedit_node_pick

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-17 14:50:03 +09:00

198 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 계획노선 편집 창 **측점 라벨 · 호 고르기**(계획서 22-2 · 22-3) 단위검증 헬퍼 —
* `_RouteEdit_Input.ts` · `_RouteEdit_Curve.ts` · B04 `drawStationTicks` 를 트랜스파일해 Node 로 돌린다.
* 브라우저 전용 팔레트·표기 함수는 가짜로 채운다 — 셈만 본다.
*/
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();
const STUBS = {
"@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback },
"@util/common_util_svg": {
stationLabel: (chainage, interval) => `No.${Math.round(chainage / interval)}`,
},
};
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 curve = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_RouteEdit_Curve.ts"));
const overlays = loadTs(path.join(ROOT, "B04_PreProcess", "B04_PreProcess_UI_MapOverlays.ts"));
// 화면 = 사업지 m × 2, y 뒤집음(실제 `metricToScreen` 처럼 y 가 화면에서 뒤집힌다).
const toScreen = ([x, y]) => [x * 2, 400 - y * 2];
const near = (a, b, tol) => Math.hypot(a[0] - b[0], a[1] - b[1]) <= tol;
// 노드 세 개 · 가운데 R=30 곡선 하나.
const nodes = [
[0, 0],
[100, 0],
[100, 100],
];
const built = curve.buildEditedPolyline(nodes, [true, true, true], [null, 30, null], 12);
const c = built.curves[0];
// ① 호 점열 == 그려지는 선에서 시작~끝점 사이 조각(거울).
const drawn = built.vertices;
const iStart = drawn.findIndex((v) => near(v, c.start, 1e-9));
const iEnd = drawn.findIndex((v) => near(v, c.end, 1e-9));
const arc = curve.curveArcVertices(c);
const arcMatchesDrawn =
arc.length === iEnd - iStart + 1 && arc.every((v, i) => near(v, drawn[iStart + i], 1e-9));
// ② 호 누름 — 호 가운데 · 호에서 5px 비킨 곳(잡힘) · 곧은 선 · 빈 곳.
const mid = drawn[Math.floor((iStart + iEnd) / 2)];
const [mx, my] = toScreen(mid);
const arcAt = (px, py) => input.arcAtScreen(built.curves, toScreen, px, py);
const pick = {
onArc: arcAt(mx, my),
nearArc: arcAt(mx + 3, my + 3),
farFromArc: arcAt(mx - 60, my + 60),
onStraight: arcAt(...toScreen([50, 0])),
empty: arcAt(5, 5),
noCurves: input.arcAtScreen([], toScreen, mx, my),
};
// ③ 반대로 도는(오른쪽 꺾임) 곡선도 같은 판정.
const built2 = curve.buildEditedPolyline(
[
[0, 0],
[100, 0],
[100, -100],
],
[true, true, true],
[null, 30, null],
12,
);
const mid2 = built2.vertices[Math.floor(built2.vertices.length / 2)];
const pick2 = input.arcAtScreen(built2.curves, toScreen, ...toScreen(mid2));
// ④ 측점 라벨 — 가짜 캔버스로 `drawStationTicks` 를 돌려 상자를 받고, 그린 사각형과 같은지 본다.
const rects = [];
const fakeContext = {
save() {},
restore() {},
beginPath() {},
moveTo() {},
lineTo() {},
stroke() {},
translate() {},
rotate() {},
fillText() {},
fillRect: (x, y, w, h) => rects.push([x, y, w, h]),
measureText: (text) => ({ width: text.length * 6 }),
};
const line = [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
];
const boxes = [];
overlays.drawStationTicks(fakeContext, line, {
intervalM: 20,
pxPerMeter: 4, // 간격 80px → 두 칸에 하나(step 2)
toScreen: (x, y) => toScreen([x, y]),
uprightRad: 0.5,
onLabel: (box) => boxes.push(box),
});
const labelsMatchRects =
boxes.length === rects.length &&
boxes.every(
(box, i) =>
Math.abs(rects[i][0] - (box.x - box.half)) < 1e-9 &&
Math.abs(rects[i][1] - (box.y - 8)) < 1e-9 &&
Math.abs(rects[i][2] - box.half * 2) < 1e-9,
);
// ⑤ 라벨 누름 — 라벨 가운데 · 라벨 밖 · 돌린 상자 모서리 안팎 · 눈금 우선.
const target = boxes[1];
const stationLine = [
[0, 0],
[100, 0],
];
const stationAt = (px, py, labels) =>
input.stationAtScreen(stationLine, toScreen, 20, px, py, 11, labels)?.chainage ?? null;
const rot = (dx, dy, rad) => [
dx * Math.cos(rad) - dy * Math.sin(rad),
dx * Math.sin(rad) + dy * Math.cos(rad),
];
// 돌린 상자의 오른쪽 위 모서리 안쪽(-1px) · 바깥쪽(+3px) — 세워 본 좌표(half, 8)를 상자 각만큼 돌린 자리.
const insideCorner = rot(target.half - 1, 7, target.uprightRad);
const outsideCorner = rot(target.half + 3, 11, target.uprightRad);
const tickScreen = toScreen([40, 0]); // 라벨이 그려진 측점(0 · 40 · 80)의 눈금
const hiddenTickScreen = toScreen([20, 0]); // 솎여 라벨이 없는 측점의 눈금
const label = {
count: boxes.length,
chainages: boxes.map((b) => b.chainage),
onLabel: stationAt(target.x, target.y, boxes),
insideCorner: stationAt(target.x + insideCorner[0], target.y + insideCorner[1], boxes),
outsideCorner: stationAt(target.x + outsideCorner[0], target.y + outsideCorner[1], boxes),
// 눈금 자리를 누르면 다른 측점 라벨 상자와 겹쳐도 눈금 몫이다.
tickBeatsLabel: stationAt(tickScreen[0], tickScreen[1], [
target,
{ chainage: 80, x: tickScreen[0], y: tickScreen[1], half: 30, uprightRad: 0 },
]),
// 안 보이는(솎인) 눈금은 누름을 가로채지 않는다 — 라벨 정보가 없을 때만 전부 본다.
hiddenTick: stationAt(hiddenTickScreen[0], hiddenTickScreen[1], boxes),
hiddenTickNoInfo: stationAt(hiddenTickScreen[0], hiddenTickScreen[1], []),
emptyNoLabels: stationAt(target.x, target.y, []),
emptyElsewhere: stationAt(5, 5, boxes),
};
// ⑥ 381-3 노드 · 횡단 눈금 겹침 — 노드 [41,0] = 화면 (82,400) · 눈금 40 = 화면 (80,400).
const overlapNodes = [
[0, 0],
[41, 0],
[100, 100],
];
const overlapAt = (px, py, stationOn = true) =>
input.pickAtScreen(
overlapNodes,
toScreen,
px,
py,
12,
6,
stationOn ? () => input.stationAtScreen(stationLine, toScreen, 20, px, py, 11, []) : null,
);
const overlap = {
center: overlapAt(82, 400),
coreNearTick: overlapAt(79, 400), // 노드 3px · 눈금 1px — 가운데 6px 안이라 노드
tickCloser: overlapAt(74, 400), // 노드 8px · 눈금 6px — 눈금
nodeCloser: overlapAt(90, 400), // 노드 8px · 눈금 10px — 노드
edgeTick: overlapAt(70, 400), // 노드 12px(문턱 끝) · 눈금 10px — 눈금
stationsOff: overlapAt(74, 400, false), // [측점] 끔 — 노드
far: overlapAt(82, 430),
tickOnly: overlapAt(160, 400), // 눈금 80 위 · 노드 멀리
distance: input.stationAtScreen(stationLine, toScreen, 20, 74, 400, 11, []),
};
process.stdout.write(
JSON.stringify({
arcMatchesDrawn,
pick,
pick2,
node: c.node_first,
labelsMatchRects,
label,
overlap,
}),
);