/* 399 B05 평면 구조물 측점 마커(요구 250-2) — `_RouteEdit_StructureMarks` 를 Node 로 돌려 JSON 한 줄 * (`test_399_plan_structure_marks.py` 가 판정). 알약 목록 · 벌룬 · 고름 · 가게는 가짜 · 노선 셈 · 법선 · * 걸음 · 방향키 부호는 진짜(`_Input` · `_Hover` · `logic/_Frame` · `logic/_KeyNudge` · `logic/_StructureMarks`). * node helper_399_plan_structure_marks.cjs */ 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 out = {}; let selection = null; const laneInputs = []; const balloonsMade = []; /* 가짜 DOM — 벌룬 요소 · 창 열쇠 듣기 */ function fakeElement() { const element = { style: {}, classList: { add: (...names) => element.classes.push(...names) }, classes: [], offsetHeight: 30, removed: false, remove: () => (element.removed = true), replaceWith: (next) => ((element.removed = true), parent.children.push(next)), }; return element; } const parent = { children: [], append: (child) => parent.children.push(child) }; const listeners = {}; global.window = { addEventListener: (type, fn) => (listeners[type] = listeners[type] || []).push(fn), removeEventListener: () => {}, setTimeout: () => 0, setInterval: () => 0, clearTimeout: () => {}, clearInterval: () => {}, }; let prevented = 0; const key = (type, name) => (listeners[type] || []).forEach((fn) => fn({ key: name, repeat: false, target: null, preventDefault: () => (prevented += 1), stopPropagation() {}, }), ); /** 공용 고름에 적은 것(409 풀기) — [값, 낸 쪽]. */ const written = []; const STUBS = { "../A00_Common/touch_bridge": { pickPx: (v) => v, isPinchWheel: () => false, wheelZoomsIn: () => true, }, "../A00_Common/input_prefs": { isTrackpadPinch: () => false }, "../B04_PreProcess/B04_PreProcess_UI_MapRender": { computeMaxScale: () => 16 }, "../A00_Common/design/design_store": { draftProjectId: () => "p1" }, "../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51, NO_SELECTION: { chainageM: null, structureId: null }, readSelection: () => selection, selectDesign: (projectId, value, options) => { written.push([value.chainageM, options.from]); selection = value.chainageM === null ? null : value; }, }, "../A00_Common/design/design_pick_escape": { PLAN_PICK_FROM: "b05-plan" }, "../B06_Section/B06_Section_UI_Cross_Panel_Base": { lastPointerWithin: () => true }, "./B05_Profile_UI_Profile_Edit": { HOLD_DELAY_MS: 500, HOLD_INTERVAL_MS: 100 }, "./B05_Profile_Api_Structures": { structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0, }, "./B05_Profile_UI_Structures_Marks": { laneStructures: (list) => (laneInputs.push(list), list), structurePillTexts: (list) => new Map(list.map((s) => [s.structure_id, { name: `이름 ${s.type_id}`, pill: s.type_id }])), positionText: (s) => `pos ${s.chainage_m}`, balloonElement: (lines, type) => { const element = fakeElement(); element.lines = lines; element.color = type?.style?.color; balloonsMade.push(element); return element; }, }, "./logic/B05_Profile_UI_Structures_Balloon": { balloonLines: (structure, type, head) => [ `${head.name} ${head.position}`, `옵션 ${type?.name}`, ], }, }; const cache = new Map(); function loadTs(full) { 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; } /** 그린 것만 적는 가짜 캔버스 — 획 = 색 · 굵기 · 그때 옮긴 자리(translate) · 선 점. */ function fakeContext(canvas) { const log = []; let at = [0, 0]; let points = []; const ctx = { canvas, save() {}, restore: () => (at = [0, 0]), setLineDash() {}, translate: (x, y) => (at = [at[0] + x, at[1] + y]), rotate() {}, beginPath: () => (points = []), moveTo: (x, y) => points.push([x, y]), lineTo: (x, y) => points.push([x, y]), arc() {}, fill() {}, stroke: () => log.push({ color: ctx.strokeStyle, width: ctx.lineWidth, at: [...at], points }), }; return { ctx, log }; } const marks = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_StructureMarks.ts")); const logic = loadTs(path.join(B05, "logic", "B05_Profile_RouteEdit_StructureMarks.ts")); // 노선 = 동서 200 m · 화면 2 px/m(위 = 북) · 구조물 A 40 m(색 #e11d48) · 관 80.17 m(옛 소수) const EAST = [ [0, 0], [200, 0], ]; const toScreen = ([x, y]) => [x * 2, -y * 2]; const toMetric = (px, py) => [px / 2, -py / 2]; const structures = [ { structure_id: "A", type_id: "box", chainage_m: 40, start_m: null, end_m: null }, { structure_id: "pipe-80.17", type_id: "pipe", chainage_m: 80.17, start_m: null, end_m: null }, ]; const types = [ { type_id: "box", name: "BOX", style: { color: "#e11d48" } }, { type_id: "pipe", name: "배수관", style: { color: "#2563eb" } }, ]; let lag = false; const selects = []; const moves = []; let redrawn = 0; marks.setPlanMarkSource("p1", { marks: () => structures, types: () => types, intervalM: () => 20, select: (id) => selects.push(id), // 옮김 = 알약 목록이 그 자리로(`lag` 면 늦게 옴 — 관 목록) move: (id, to) => { moves.push([id, to]); if (!lag) structures.find((s) => s.structure_id === id).chainage_m = to; }, }); const canvas = { isConnected: true, parentElement: parent }; let line = EAST; let picked = -1; const status = { textContent: "" }; const scene = () => ({ toScreen, pxPerMeter: 2, view: { width: 400, height: 300 }, rotationRad: 0, }); const style = { getPropertyValue: () => "" }; const draw = () => { const fake = fakeContext(canvas); marks.drawStructureMarks(fake.ctx, scene(), line, style); return fake.log; }; /** 한 걸음 판정 뒤 — 그려서 기다림을 풀고 처음 자리로. */ const settle = () => { draw(); structures[0].chainage_m = 40; structures[1].chainage_m = 80.17; }; const input = marks.bindStructureMarks( { canvas, status, stationIntervalM: 20, state: () => ({ planned: line, plannedLine: line, picked }), toScreen, toMetric, unrotate: (x, y) => [x, y], draw: () => (redrawn += 1), routeHead: () => "노선", measureMode: () => false, }, () => false, ); // ① 그리기 — 알약 목록 그대로 · 종류 색 · 고름 없으면 법선 · 창 없음 const plain = draw(); out.laneGotSource = laneInputs[laneInputs.length - 1] === structures; const fills = (log) => log.filter((s) => s.width === 8).map((s) => ({ color: s.color, at: s.at })); out.plain = { marks: fills(plain), normals: plain.filter((s) => s.color === "#0ea5e9").length }; out.balloonsAfterPlain = parent.children.filter((c) => !c.removed).length; // ② 공용 고름 = 관(누가거리만 · 382-2) → 그 마커 하늘 테 · 법선 · 창(알약 벌룬 줄) selection = { chainageM: 80.17, structureId: null }; const pickedPipe = draw(); const ring = pickedPipe.filter((s) => s.color === "#0ea5e9" && s.width === 15); const normal = pickedPipe.find((s) => s.color === "#0ea5e9" && s.width === 2.4); const shown = parent.children.filter((c) => !c.removed); out.pickedPipe = { ringAt: ring.map((s) => s.at), normal: normal && normal.points, balloon: shown.length === 1 && { lines: shown[0].lines, left: shown[0].style.left, top: shown[0].style.top, color: shown[0].color, }, }; selection = { chainageM: 40, structureId: "A" }; draw(); const shownA = parent.children.filter((c) => !c.removed); out.pickedStructureBalloon = shownA.length === 1 && shownA[0].lines; selection = null; draw(); out.balloonsAfterRelease = parent.children.filter((c) => !c.removed).length; // ③ 끌기 — A(화면 80, 0) 누름 = 고름 · 3 m 끌기(86 px) = 43 · 놓을 때 한 번 out.pressMiss = input.press(300, 100); out.pressA = input.press(80, 3); out.selects = [...selects]; input.move(83, 10); // 41.5 → 42 input.move(86, -20); // 43 out.dragStatus = status.textContent; out.movesBeforeRelease = moves.length; input.release(); out.dragMove = moves[moves.length - 1]; settle(); out.dragDoneStatus = status.textContent; // 노선 밖 — 끝(200) · 시점(0)에 붙음 input.press(80, 0); input.move(9999, 0); input.release(); out.pastEnd = moves[moves.length - 1]; settle(); input.press(80, 0); input.move(-500, 0); input.release(); out.beforeStart = moves[moves.length - 1]; settle(); // 옛 소수 관(80.17) — 눌렀다 반올림 자리에서 떼면 안 옮김 · 1 m 끌면 정수 const count = moves.length; input.press(160.34, 0); input.move(160.5, 1); input.release(); out.oldDecimalStay = moves.length === count; input.press(160.34, 0); input.move(162.2, 0); input.release(); out.oldDecimalMoved = moves[moves.length - 1]; settle(); // ④ 방향키 — 고른 마커(A) · 교각점 안 고름 = 노선 따라 1 m · 뗄 때 한 번 selection = { chainageM: 40, structureId: "A" }; key("keydown", "ArrowRight"); key("keyup", "ArrowRight"); out.keyRight = moves[moves.length - 1]; settle(); key("keydown", "ArrowLeft"); key("keyup", "ArrowLeft"); out.keyLeft = moves[moves.length - 1]; settle(); key("keydown", "ArrowUp"); // 노선과 직각 = → · ↑ 는 + key("keyup", "ArrowUp"); out.keyUpPerpendicular = moves[moves.length - 1]; settle(); // 서→동이 아니라 동→서 노선 — 오른쪽(동) = 뒤로 line = [ [200, 0], [0, 0], ]; key("keydown", "ArrowRight"); key("keyup", "ArrowRight"); out.keyRightWestward = moves[moves.length - 1]; settle(); line = EAST; // 관 목록이 늦게 옴 — 연달아 두 번 눌러도 두 걸음(옮긴 자리 · 고름을 목록이 따라올 때까지 쥠) lag = true; selection = { chainageM: 80.17, structureId: null }; key("keydown", "ArrowRight"); key("keyup", "ArrowRight"); selection = { chainageM: 81, structureId: null }; // 관 고름이 새 자리로(followMovedPipe) const heldLog = draw(); out.lagHeldAt = heldLog.filter((s) => s.width === 15).map((s) => s.at); key("keydown", "ArrowRight"); key("keyup", "ArrowRight"); out.lagTwoSteps = moves.slice(-2); lag = false; structures[1].chainage_m = 82; // 목록이 따라옴 selection = { chainageM: 82, structureId: null }; out.lagCaught = draw() .filter((s) => s.width === 15) .map((s) => s.at); settle(); selection = { chainageM: 40, structureId: "A" }; // 교각점 고름 = 교각점 방향키 몫(마커 안 옮김) picked = 2; const before = moves.length; key("keydown", "ArrowRight"); key("keyup", "ArrowRight"); out.keyWithNodePicked = moves.length - before; picked = -1; // 고름 없음 = 안 옮김 selection = null; key("keydown", "ArrowRight"); key("keyup", "ArrowRight"); out.keyNoPick = moves.length - before; // ⑤ 목록 바뀜 알림 = 그 편집 창 다시 그림 const was = redrawn; marks.planMarksChanged("p1"); out.redrawnOnChange = redrawn - was; // ⑥ 고름 풀기(409) — 고른 것 없으면 아무 일 없음 · 있으면 공용 고름을 b05-plan 으로 비움 · Esc 도 selection = null; out.deselectNone = [input.deselect(), written.length]; selection = { chainageM: 40, structureId: "A" }; out.deselectPicked = [input.deselect(), ...written.slice(-1), selection]; selection = { chainageM: 80.17, structureId: null }; // 관 고름(누가거리만) prevented = 0; key("keydown", "Escape"); out.escapePicked = [...written.slice(-1), selection, prevented]; const writes = written.length; key("keydown", "Escape"); // 고른 것 없음 = 공용 Esc 몫(안 막음) out.escapeNone = [written.length - writes, prevented]; // 마커 누름 = 고름(공용 고름 길) · 법선 · 창 — 그린 차례는 `_Render` 맨 끝(시험이 원본으로 봄) input.press(80, 0); input.release(); out.pressAfterRelease = selects[selects.length - 1]; // ⑦ 순수 셈 out.logic = { round: [logic.markChainage(41.4, 200), logic.markChainage(41.6, 200)], clamp: [logic.markChainage(-3, 200), logic.markChainage(250, 199.6)], step: [logic.markStep(80.17, 1, 200), logic.markStep(0, -1, 200), logic.markStep(199, 1, 199.6)], pickId: logic.pickedMarkId( [{ id: "A", chainageM: 40 }], { chainageM: 1, structureId: "A" }, 0.51, ), pickNear: logic.pickedMarkId( [{ id: "A", chainageM: 40 }], { chainageM: 40.3, structureId: null }, 0.51, ), pickFar: logic.pickedMarkId( [{ id: "A", chainageM: 40 }], { chainageM: 41, structureId: null }, 0.51, ), pickOther: logic.pickedMarkId( [{ id: "A", chainageM: 40 }], { chainageM: 40, structureId: "Z" }, 0.51, ), }; console.log(JSON.stringify(out));