/* PLAN 164-1 단위검증 헬퍼 — [상세] 그림 줌 · 팬(`B06_Section_UI_Cross_Detail.ts` `attachDetailZoom`). * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 가짜 svg 에 휠 · 가운데 단추 · 더블클릭을 쏨. * 결과 = 표준출력 JSON 한 벌. 짝 = `test_164_detail_zoom.py`. */ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); const { makeLoader } = require("./helper_ts_stub_loader.cjs"); const ROOT = path.join(__dirname, "..", ".."); const B06 = path.join(ROOT, "B06_Section"); const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); // 카드 여백은 원본에서 읽음(화면과 같은 값) const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); // 축 눈금 간격도 원본 함수 그대로(179-3 · 축 다시 그리기) const niceSrc = /export function niceTickStep[\s\S]*?\n\}/.exec(common.replace(/\r/g, ""))[0]; const niceTickStep = ts .transpileModule(niceSrc.replace("export ", ""), { compilerOptions: {} }) .outputText.concat("\nreturn niceTickStep;"); const svgElement = (tag, attrs = {}) => { const node = el(tag); for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value); return node; }; const svgText = (text, attrs) => Object.assign(svgElement("text", attrs), { textContent: text }); const STUBS = { "@ui/ui_template_modal": { openModal: () => {} }, "@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) }, "../A00_Common/b_page_state": {}, "./B06_Section_Api_Fetch": {}, "./B06_Section_UI_Section_Common": { CROSS_PAD, L: (key) => key, niceTickStep: Function(niceTickStep)(), svgElement, svgText, }, "./B06_Section_UI_Cross_Detail_Pick": { DETAIL_TARGETS: {} }, // 선 조각 고르기(209-1)는 줌과 무관 — 따로 시험(`test_209_1_detail_select.py`) "./B06_Section_UI_Cross_Detail_Select": { attachDetailSelect: () => ({}), PIECE_HIT_CLASS: "" }, // 표 위 CAD 칸(260)도 줌과 무관 — 따로 시험(`test_260_note_cad.py`) "./B06_Section_UI_Cross_Detail_Cad": { mountNoteCad: () => {}, stationNoLabel: () => "" }, }; const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); const { loadTs } = makeLoader({ root: ROOT, cache: false, requireFor: (full) => (request) => { if (request.endsWith(".css")) return {}; if (STUBS[request]) return STUBS[request]; return loadTs(resolve(path.join(path.dirname(full), request))); }, }); function el(tag) { const attrs = {}; const listeners = {}; const self = { tag, parent: null, childNodes: [], attrs, style: { setProperty: (name, value) => (attrs[`style:${name}`] = value) }, classList: { add: () => {}, remove: () => {} }, setAttribute: (name, value) => (attrs[name] = String(value)), getAttribute: (name) => attrs[name] ?? null, // 진짜 DOM 처럼 옛 부모에서 옮겨 옴 append: (...nodes) => nodes.forEach((node) => { if (node.parent) node.parent.childNodes.splice(node.parent.childNodes.indexOf(node), 1); node.parent = self; self.childNodes.push(node); }), replaceChildren: (...nodes) => { self.childNodes.splice(0).forEach((node) => (node.parent = null)); self.append(...nodes); }, // 「tag.class」 · 「tag」 만 — 깊이 우선 첫 것 querySelector: (selector) => { const [tagName, cls] = selector.split("."); const walk = (node) => { for (const child of node.childNodes) { const classes = (child.attrs.class ?? "").split(" "); if (child.tag === tagName && (!cls || classes.includes(cls))) return child; const found = walk(child); if (found) return found; } return null; }; return walk(self); }, // 첫 보기 꼭짓점(295-2) — 이 그림엔 꺾은선 · 다각형 없음 querySelectorAll: () => [], addEventListener: (type, fn) => (listeners[type] ??= []).push(fn), fire: (type, event) => { const full = { preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event }; (listeners[type] ?? []).forEach((fn) => fn(full)); return full; }, setPointerCapture: () => {}, releasePointerCapture: () => {}, // 칸 = viewBox 의 2 배(비율 같음) getBoundingClientRect: () => ({ left: 0, top: 0, width: 1200, height: 600 }), }; return self; } globalThis.document = { createElementNS: (_ns, tag) => el(tag), createElement: el }; const { attachDetailZoom } = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail.ts")); // 카드 svg 꼴 — 바탕 · 축 층 · clip 그룹 > 도형 층 > 십자(중심 319,142 = 칸 가운데 · 295-2 첫 보기 = 1배) const AXES = { widthPx: 600, heightPx: 300, pixelsPerMeter: 10, maxOffset: 24, displayMax: 120, elevationMid: 110, exaggeration: 1, }; function chart() { const svg = el("svg"); svg.setAttribute("viewBox", "0 0 600 300"); const axes = svgElement("g", { class: "b06-chart__axes" }); const clipGroup = svgElement("g", { "clip-path": "url(#c)" }); const plot = svgElement("g", { class: "b06-chart__plot-layer" }); const center = svgElement("g", { class: "b06-chart__center" }); center.append(svgElement("line", { x1: 319, y1: 124, x2: 319, y2: 160 })); // 원지반 십자(413) — 계획 십자보다 먼저 · 중심 319,200 const ground = svgElement("g", { class: "b06-chart__ground-center" }); ground.append(svgElement("line", { x1: 319, y1: 182, x2: 319, y2: 218 })); plot.append(ground, center); clipGroup.append(plot); svg.append(svgElement("rect"), axes, clipGroup, svgElement("line", { class: "b06-chart__axis" })); return { svg, axes, plot, center, ground }; } const ticks = (axes) => axes.childNodes.filter((node) => node.tag === "text").map((node) => node.textContent); const { svg, axes, plot: layer, center, ground } = chart(); let saved; const handle = attachDetailZoom(svg, AXES, undefined, (next) => (saved = next)); const ticksBefore = ticks(axes); const out = { // 179-3 — 감싸지 않음 · 자식 넷 그대로 wrapped: svg.childNodes.length === 1 && svg.childNodes[0].tag === "g", children: svg.childNodes.length, ratio: svg.attrs["style:--ratio"], before: layer.getAttribute("transform"), ticksBefore: ticksBefore.length, }; // 휠 밂(deltaY < 0 · 기본 337) = 확대 · 커서(화면 600,300 = svg 300,150) 고정 svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: -100 }); out.wheelIn = { ...saved }; out.cursorFixed = saved.tx + saved.scale * 300; svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 }); out.wheelOut = saved.scale; // 원배율에서 좌클릭 끌기 = 팬 아님 handle.reset(); svg.fire("pointerdown", { button: 0, clientX: 100, clientY: 100 }); svg.fire("pointermove", { clientX: 300, clientY: 100 }); svg.fire("pointerup", {}); out.leftDrag = { ...saved }; // 가운데 단추 끌기 = 팬(1배에서도 · 화면 200px = svg 100) svg.fire("pointerdown", { button: 1, clientX: 100, clientY: 100 }); svg.fire("pointermove", { clientX: 300, clientY: 140 }); svg.fire("pointerup", {}); out.middlePan = { ...saved }; // 팬 뒤 — 도형 층만 transform · 축 층 · 바깥 그룹 transform 없음 · 눈금 글 바뀜 out.plotTransform = layer.getAttribute("transform"); out.axesTransform = axes.getAttribute("transform"); out.clipTransform = svg.childNodes[2].getAttribute("transform"); out.ticksChanged = JSON.stringify(ticks(axes)) !== JSON.stringify(ticksBefore); out.ticksAfter = ticks(axes).length; out.panClickStopped = svg.fire("click", { stopPropagation() { this.stopped = true; }, }).stopped; // 다시 떠도 배율 이어 감 const again = chart(); attachDetailZoom(again.svg, AXES, saved, () => {}); out.restored = again.plot.getAttribute("transform"); // 다시 떠도 축은 이어 간 범위로(1배 원래 눈금과 다름) out.restoredTicksShifted = JSON.stringify(ticks(again.axes)) !== JSON.stringify(ticksBefore); // 축 옵션 없음 · 도형 층 없음 — 줌은 걸고 축은 그대로 / 줌 없음 const bare = chart(); out.noAxesHandle = attachDetailZoom(bare.svg, null, undefined, () => {}) !== null; const flat = el("svg"); flat.setAttribute("viewBox", "0 0 600 300"); out.noLayer = attachDetailZoom(flat, AXES, undefined, () => {}); // 더블클릭 원복 svg.fire("dblclick", {}); out.reset = layer.getAttribute("transform"); // 확대 — 십자는 중심에서 배율을 되돌림(크기 그대로) svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: -100 }); out.centerTransform = center.getAttribute("transform"); out.groundTransform = ground.getAttribute("transform"); out.max = (() => { for (let i = 0; i < 60; i++) svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: -100 }); return saved.scale; })(); process.stdout.write(JSON.stringify(out));