/* 계획노선 편집 창 **측점 라벨 · 호 고르기**(계획서 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", "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); 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), }; process.stdout.write( JSON.stringify({ arcMatchesDrawn, pick, pick2, node: c.node_first, labelsMatchRects, label }), );