/* PLAN 318 단위검증 헬퍼 — B06 횡단 카드: 단추 줄 빈자리 고름 · 중심고 Y축 상자 · 빈 아래 띠 · * 관 종류 바꿈 = 옛 측점 조정값 버림(세션 · 미리 뜬 횡단 · [저장] patch). * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림(서버 · 무거운 화면 모듈은 흉내) · 결과 = 표준출력 JSON 한 벌. * 짝 = `test_318_cross_card.py`. */ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const B06 = (name) => path.join(ROOT, "B06_Section", `${name}.ts`); // ── 화면 그릇 흉내 — 쓰는 만큼만 ───────────────────────────────────────────── class FakeNode { constructor(tag) { this.tagName = String(tag).toLowerCase(); this.children = []; this.attrs = {}; this.listeners = {}; this.parent = null; this.dataset = {}; this.style = { setProperty: () => {} }; this._text = ""; this._classes = new Set(); const self = this; this.classList = { add: (...names) => names.forEach((n) => self._classes.add(n)), remove: (...names) => names.forEach((n) => self._classes.delete(n)), toggle: (name, force) => { const on = force === undefined ? !self._classes.has(name) : !!force; on ? self._classes.add(name) : self._classes.delete(name); return on; }, contains: (name) => self._classes.has(name), }; } set className(value) { this._classes = new Set(String(value).split(/\s+/).filter(Boolean)); } get className() { return [...this._classes].join(" "); } set textContent(value) { this._text = String(value); this.children = []; } get textContent() { return this._text + this.children.map((c) => c.textContent).join(""); } get childElementCount() { return this.children.length; } append(...kids) { kids.forEach((kid) => { if (typeof kid === "string") { const text = new FakeNode("#text"); text._text = kid; kid = text; } if (kid.parent) kid.parent.children = kid.parent.children.filter((k) => k !== kid); kid.parent = this; this.children.push(kid); }); } appendChild(kid) { this.append(kid); return kid; } prepend(...kids) { const rest = this.children; this.children = []; this.append(...kids, ...rest); } replaceChildren(...kids) { this.children.forEach((k) => (k.parent = null)); this.children = []; this.append(...kids); } remove() { if (this.parent) this.parent.children = this.parent.children.filter((k) => k !== this); this.parent = null; } setAttribute(name, value) { this.attrs[name] = String(value); if (name === "class") this.className = value; } getAttribute(name) { return name === "class" ? this.className : (this.attrs[name] ?? null); } removeAttribute(name) { delete this.attrs[name]; } hasAttribute(name) { return name in this.attrs; } addEventListener(type, fn) { (this.listeners[type] ??= []).push(fn); } removeEventListener() {} matches(selector) { return selector .split(",") .map((part) => part.trim()) .some((part) => part.startsWith(".") ? this._classes.has(part.slice(1)) : this.tagName === part, ); } closest(selector) { for (let node = this; node; node = node.parent) if (node.matches(selector)) return node; return null; } querySelectorAll(selector) { const out = []; const walk = (node) => node.children.forEach((kid) => { if (kid.matches(selector)) out.push(kid); walk(kid); }); walk(this); return out; } querySelector(selector) { return this.querySelectorAll(selector)[0] ?? null; } /** 붙기 전 = 0 — 상자 폭은 어림으로 감. */ getComputedTextLength() { return 0; } } /** 누름 — 눌린 자리에서 위로 올라가며 듣개를 부름(stopPropagation 이면 멈춤). */ function click(target) { const event = { type: "click", target, stopped: false, stopPropagation() { this.stopped = true; }, preventDefault() {}, }; for (let node = target; node && !event.stopped; node = node.parent) (node.listeners.click ?? []).forEach((fn) => fn(event)); return event; } class FakeStorage { constructor() { this.map = new Map(); } get length() { return this.map.size; } key(index) { return [...this.map.keys()][index] ?? null; } getItem(key) { return this.map.has(key) ? this.map.get(key) : null; } setItem(key, value) { this.map.set(key, String(value)); } removeItem(key) { this.map.delete(key); } } globalThis.window = { sessionStorage: new FakeStorage(), localStorage: new FakeStorage(), addEventListener: () => {}, removeEventListener: () => {}, setTimeout, clearTimeout, }; globalThis.sessionStorage = window.sessionStorage; globalThis.localStorage = window.localStorage; globalThis.requestAnimationFrame = () => 0; globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} }; globalThis.document = { createElement: (tag) => new FakeNode(tag), createElementNS: (_ns, tag) => new FakeNode(tag), addEventListener: () => {}, removeEventListener: () => {}, documentElement: new FakeNode("html"), }; const anything = () => new Proxy({}, { get: (_t, key) => (key === "__esModule" ? false : () => undefined) }); /** * 로더 하나 — `real` 에 든 파일만 진짜로 읽고, 그 밖 상대 경로는 흉내(무거운 화면 사슬을 안 끌어옴). * `stubs` 는 지정자 그대로 갈음. */ function makeLoader(real, stubs = {}) { const cache = new Map(); const realSet = new Set(real.map((file) => path.resolve(file))); const load = (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 need = (spec) => { if (CATALOG.isCatalogRequest(spec)) return CATALOG; if (spec in stubs) return stubs[spec]; let target = null; if (spec.startsWith("@util/")) target = path.join(ROOT, "common_util", `${spec.slice(6)}.ts`); else if (spec.startsWith(".")) target = path.join(path.dirname(full), `${spec}.ts`); if (target && realSet.has(path.resolve(target))) return load(target); return anything(); }; new Function("exports", "module", "require", js)(box.exports, box, need); return box.exports; }; return load; } const out = {}; // ── 1. 중심고 Y축 상자(318-2) ────────────────────────────────────────────── { const load = makeLoader( [ B06("B06_Section_UI_Cross_Axes"), B06("B06_Section_UI_Section_Common"), path.join(ROOT, "common_util", "common_util_svg.ts"), ], { "@config/config_frontend": { PIPE_TYPES: [] }, "../B05_Profile/B05_Profile_UI_IrregularStations": { PIPE_DISPLAY_NAME: {} }, }, ); const { createCrossAxes, IDENTITY_VIEW } = load(B06("B06_Section_UI_Cross_Axes")); const { CROSS_PAD } = load(B06("B06_Section_UI_Section_Common")); const base = { widthPx: 480, heightPx: 300, pixelsPerMeter: 10, maxOffset: 20, displayMax: 890, elevationMid: 880, exaggeration: 1, }; const draw = (centerZ) => { const layer = new FakeNode("g"); createCrossAxes(layer, { ...base, centerZ })(IDENTITY_VIEW); return layer; }; const layer = draw(876.57); const box = layer.querySelector(".b06-chart__center-box"); const tag = layer.querySelector(".b06-chart__center-text"); const centerY = CROSS_PAD.top + (base.displayMax - 876.57) * base.pixelsPerMeter; const ticks = layer .querySelectorAll("text") .filter( (t) => !t.classList.contains("b06-chart__center-text") && t.attrs["text-anchor"] === "end", ); out.center = { axis_x: CROSS_PAD.left, box: box && { x: Number(box.attrs.x), width: Number(box.attrs.width), y: Number(box.attrs.y), height: Number(box.attrs.height), rx: Number(box.attrs.rx), }, center_y: centerY, text: tag?.textContent ?? null, text_classes: tag?.className ?? null, text_x: tag ? Number(tag.attrs.x) : null, tick_x: ticks.length ? Number(ticks[0].attrs.x) : null, tick_near_center: ticks.filter((t) => Math.abs(Number(t.attrs.y) - 3 - centerY) < 14).length, tick_count: ticks.length, on_top: layer.children.slice(-2).map((node) => node.className), // 319-1 상자 · 글 = 맨 뒤(위) hidden_when_out_of_view: draw(800).querySelector(".b06-chart__center-box") === null, absent_without_center: draw(null).querySelector(".b06-chart__center-box") === null, }; // 379-3 상자 둘 — 원지반(녹) 먼저 · 계획(보라) 나중 · 16 px 안이면 원지반 상자만 비킴 const yOf = (z) => CROSS_PAD.top + (base.displayMax - z) * base.pixelsPerMeter; const tagsOf = (centerZ, designZ) => { const layer = new FakeNode("g"); createCrossAxes(layer, { ...base, centerZ, designZ })(IDENTITY_VIEW); const pick = (kind) => { const all = layer.querySelectorAll(`.b06-chart__center-tag--${kind}`); const box = all.find((n) => n.classList.contains("b06-chart__center-box")); const text = all.find((n) => n.classList.contains("b06-chart__center-text")); return ( box && { mid: Number(box.attrs.y) + Number(box.attrs.height) / 2, text: text?.textContent ?? null, } ); }; return { ground: pick("ground"), design: pick("design"), order: layer.children.slice(-4).map((node) => node.className), groundY: yOf(centerZ), designY: yOf(designZ), }; }; const bottomZ = base.displayMax - (base.heightPx - CROSS_PAD.bottom - CROSS_PAD.top) / base.pixelsPerMeter; out.tags = { apart: tagsOf(876.57, 878.5), groundLow: tagsOf(876.57, 876.9), groundHigh: tagsOf(876.9, 876.57), edge: tagsOf(bottomZ + 0.4, bottomZ + 0.5), // 아래로 비키면 칸 밖 → 위로 designOnly: tagsOf(null, 876.9), }; } // ── 1b. 십자선 둘(413) — 원지반 먼저 · 계획 나중 · 배율 되돌림 둘 다 ───────────── { const { appendCardFrame } = makeLoader( [ B06("B06_Section_UI_Cross_View_Frame"), B06("B06_Section_UI_Section_Common"), path.join(ROOT, "common_util", "common_util_svg.ts"), ], { "@config/config_frontend": { PIPE_TYPES: [] }, "../B05_Profile/B05_Profile_UI_IrregularStations": { PIPE_DISPLAY_NAME: {} }, "@ui/ui_template_locale": { currentLanguageIndex: 0, ui_locales: new Proxy({}, { get: (_t, key) => [String(key)] }), }, }, )(B06("B06_Section_UI_Cross_View_Frame")); const toY = (z) => (900 - z) * 10; const frame = (designElevation, groundElevation) => { const plot = new FakeNode("g"); const scale = appendCardFrame(new FakeNode("svg"), plot, { valid: [{ offset_m: 0.4, elevation_m: 870, valid: true }], centerX: 50, designElevation, groundElevation, toY, widthPx: 480, heightPx: 300, }); return { plot, scale }; }; const midY = (group) => Number(group.querySelector("line").attrs.y1) + 18; const { plot, scale } = frame(878.5, 876.57); scale(2); const [groundG, designG] = plot.children; const bare = frame(null, null).plot; out.crosshair = { order: plot.children.map((node) => node.className), design_y: midY(designG), want_design_y: toY(878.5), ground_y: midY(groundG), want_ground_y: toY(876.57), design_only_ground: bare.querySelector(".b06-chart__ground-center"), scaled: [groundG.attrs.transform, designG.attrs.transform], want_scaled: [toY(876.57), toY(878.5)].map( (y) => `translate(50 ${y}) scale(0.5) translate(-50 ${-y})`, ), }; } // ── 1c. 평면 초안 상세 = 계획선 표본도 초안 종단(413 · 십자선 대체 높이) ───────────── { const B05 = (name) => path.join(ROOT, "B05_Profile", "logic", `${name}.ts`); const { draftSectionDetail } = makeLoader([ B05("B05_Profile_Pipeline_Detail"), B05("B05_Profile_UI_Profile_Data"), ])(B05("B05_Profile_Pipeline_Detail")); const draftSamples = [ { chainage_m: 0, elevation_m: 110 }, { chainage_m: 20, elevation_m: 112 }, ]; const saved = { longitudinal: { design_profiles: [ { id: "old", name: "계획선", samples: [{ chainage_m: 0, elevation_m: 100 }] }, ], }, cross_sections: [], balloon_offsets: {}, }; const detail = draftSectionDetail( saved, { stations: [], longitudinal: { chainageM: [0, 20], z: [109, 111] } }, { samples: draftSamples, balance: { cut_area_m2: 0, fill_area_m2: 0, net_area_m2: 0 } }, null, new Map(), 20, ); const profile = detail.longitudinal.design_profiles[0]; out.draftProfile = { samples: profile.samples, id: profile.id }; } // ── 2. 아래 띠(318-2 · 318-3) — 중심고 글 없음 · 선 칸 없으면 암 절토만(479) · 토사도 띠 남음 ─ { const control = (cls) => () => { const node = new FakeNode("div"); node.className = cls; return node; }; const load = makeLoader([B06("B06_Section_UI_Cross_Card_Chrome")], { "./B06_Section_UI_Cross_CutSlope": { buildCutSlopeControl: control("cutslope") }, "./B06_Section_UI_Cross_Design": { buildDesignControls: () => ({ bar: new FakeNode("div"), groundSegment: new FakeNode("div") }), sectionModeLabel: () => "", }, "./B06_Section_UI_Cross_Fit": { fillSlopeLengths: () => [] }, "./B06_Section_UI_Section_Common": { L: (key) => (key === "B06_Profile_View_CenterElevation" ? "중심고" : key), stationLabel: () => "", structureDisplayName: () => "", }, }); const { appendCardFooter } = load(B06("B06_Section_UI_Cross_Card_Chrome")); const footerOf = (preset) => { const card = new FakeNode("article"); appendCardFooter(card, { center_z: 876.57, design: { geometry_preset: preset } }, {}); return card.querySelector("footer"); }; const rock = footerOf("rock"); const soil = footerOf("soil"); out.footer = { rock_first: rock?.children[0]?.className ?? null, rock_classes: rock?.children.map((kid) => kid.className) ?? null, rock_text: rock?.textContent ?? null, soil_exists: soil !== null, soil_children: soil?.childElementCount ?? null, }; // 370-3 — 설계선 고름 공용 칸이 오면 그 칸이 왼쪽(암 제어를 따로 안 만듦) const cell = new FakeNode("div"); cell.className = "linecell"; const cellCard = new FakeNode("article"); appendCardFooter(cellCard, { design: { geometry_preset: "rock" } }, {}, cell); out.footer.cell_classes = cellCard.querySelector("footer").children.map((kid) => kid.className); } // ── 3. 단추 줄(318-1) — 빈자리 누름 = 카드 고름 · 조작 누름 = 안 넘김 ─────────── { const load = makeLoader([B06("B06_Section_UI_Cross_Design")], { "@ui/ui_template_locale": { currentLanguageIndex: 0, ui_locales: new Proxy({}, { get: (_t, key) => [String(key)] }), }, }); const { buildDesignControls } = load(B06("B06_Section_UI_Cross_Design")); const { bar } = buildDesignControls( { chainage_m: 100, uphill_side: "left", design: { ground_type: "soil", section_mode: "left_cut" }, }, () => {}, ); const card = new FakeNode("article"); let picked = 0; card.addEventListener("click", () => (picked += 1)); card.append(bar); const pickedBy = (node) => { const before = picked; click(node); return picked - before; }; const button = bar.querySelector("button"); const label = bar.querySelectorAll("div").find((node) => !node.closest("button, details")) ?? null; const summary = bar.querySelector("summary"); out.bar = { empty: pickedBy(bar), button: button ? pickedBy(button) : null, legend: label ? pickedBy(label) : null, summary: summary ? pickedBy(summary) : null, }; } // ── 4. 종류 바꿈 = 그 측점 세션 조정값 버림 · 비울 자리 적음(318-4) ───────────── { const previewCalls = []; const load = makeLoader( [ B06("B06_Section_Api_Culvert_Options"), B06("logic/B06_Section_Design_Keys"), // 칸 목록 한 벌(472-6) path.join(ROOT, "A00_Common", "b_page_state.ts"), path.join(ROOT, "A00_Common", "design", "design_selection.ts"), path.join(ROOT, "common_util", "common_util_pipe_match.ts"), // 472-6 ], { "./B06_Section_Section_Drafts": { forgetPreviewAdjusts: (...args) => previewCalls.push([args[0], args[1], [...args[2]]]), }, }, ); const mod = load(B06("B06_Section_Api_Culvert_Options")); const store = window.sessionStorage; const put = (key, value) => store.setItem(key, JSON.stringify(value)); const get = (key) => JSON.parse(store.getItem(key) ?? "null"); put("aislo:draft:culvertopt:P:7", { "100.00": { inlet_revet_length_m: 3 }, "140.00": { a: 1 } }); put("aislo:draft:culvertopt:P:8", { "100.00": { a: 1 } }); put("aislo:draft:culvertopt:Q:7", { "100.00": { a: 1 } }); put("aislo:draft:revetx:P:7", { "100.00:inlet": { x: 1 }, "100.00:extra0": { x: 2 }, "140.00:inlet": { x: 3 }, }); put("aislo:draft:extrawall:P:7", { "100.00": 2, "b100.00": 1, "140.00": 1 }); put("aislo:draft:extraspan:P:7", { "100.00:extra0": { length_m: 1 } }); put("aislo:draft:revetlink:P:7", { detached: { "100.00": true, "140.00": true }, grade: { "100.00": true }, }); put("aislo:draft:fordadjust:P:7", { "100.00": { w: 1 } }); put("aislo:draft:boxadjust:P:7", { "100.30": { w: 1 } }); put("aislo:draft:basinadjust:P:7", { "100.00": { d: 1 } }); put("aislo:draft:inletstruct:P:7", { "100.00": "basin" }); put("aislo:draft:crossw:P:7", { "100.00": 5 }); mod.forgetStationAdjusts("P", 100, "pipe", "box_culvert"); out.forget = { culvertopt: get("aislo:draft:culvertopt:P:7"), culvertopt_other_route: get("aislo:draft:culvertopt:P:8"), culvertopt_other_project: get("aislo:draft:culvertopt:Q:7"), revetx: get("aislo:draft:revetx:P:7"), extrawall: get("aislo:draft:extrawall:P:7"), extraspan: get("aislo:draft:extraspan:P:7"), revetlink: get("aislo:draft:revetlink:P:7"), fordadjust: get("aislo:draft:fordadjust:P:7"), boxadjust: get("aislo:draft:boxadjust:P:7"), basinadjust: get("aislo:draft:basinadjust:P:7"), inletstruct: get("aislo:draft:inletstruct:P:7"), crossw: get("aislo:draft:crossw:P:7"), kindreset: get("aislo:draft:kindreset:P"), changed: mod.kindChangedChainages("P"), preview_calls: previewCalls, reload_hook: typeof mod.setStationAdjustReload, }; mod.forgetStationAdjusts("P", 100, "box_culvert", "ford_bridge"); out.forget.second = get("aislo:draft:kindreset:P"); mod.forgetStationAdjusts("P", 100, "ford_bridge", "pipe"); out.forget.reverted = get("aislo:draft:kindreset:P"); const before = store.length; mod.forgetStationAdjusts("P", 140, "pipe", "pipe"); out.forget.same_kind_untouched = store.length === before && get("aislo:draft:culvertopt:P:7")["140.00"] !== undefined; out.forget.keys = [...mod.STRUCTURE_ADJUST_KEYS]; } // ── 5. 미리 뜬 횡단 사본 걷음(318-4) — 그 자리 조정값만 · 다른 칸 그대로 ───────── const state = { draftPipes: null, savedPipes: [] }; /** 서버 미리보기 요청(415 M4 — 0 이어야 함). */ const asked = []; const loadDrafts = makeLoader( [ B06("B06_Section_Section_Drafts"), path.join(ROOT, "A00_Common", "design", "design_page_data.ts"), // 자료 줄(386-2) path.join(ROOT, "common_util", "common_util_structure_station.ts"), path.join(ROOT, "common_util", "common_util_culvert_sets.ts"), ], { "../B04_PreProcess/B04_PreProcess_Api_Fetch": { fetchDetailPipePoints: async () => ({ pipe_points: state.savedPipes }), }, "../B05_Profile/B05_Profile_Api_Structures": { fetchStructures: async () => ({ structures: [] }), fetchStructureTypes: async () => [], readPendingStructures: () => null, }, "../../B04_PreProcess/B04_PreProcess_Api_Fetch": { fetchDetailPipePoints: async () => ({ pipe_points: state.savedPipes }), }, "../../B05_Profile/B05_Profile_Api_Structures": { fetchStructures: async () => ({ structures: [] }), fetchStructureTypes: async () => [], readPendingStructures: () => null, }, "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => state.draftPipes }, "./B06_Section_Api_Fetch": { previewStructureSections: async (_p, chainages) => (asked.push(chainages), chainages).map((at) => ({ chainage_m: at, kind: "irregular", design: { ground_type: "soil", ford_adjust: { w: 1 }, revet_adjust: { inlet: { x: 1 } } }, })), }, }, ); // ── 6. B05 [저장](캐시 patch) — 종류 바꾼 측점 = structure_reset · 옛 조정값 안 실음 ───── const DESIGN_KEYS = { USER_TOUCHED_KEYS: ["display_half_width_m", "ford_adjust", "revet_adjust", "berm"], STRUCTURE_ADJUST_KEYS: makeLoader([B06("logic/B06_Section_Design_Keys")])( B06("logic/B06_Section_Design_Keys"), ).STRUCTURE_ADJUST_KEYS, }; const loadStore = makeLoader( [ B06("B06_Section_Section_Store"), B06("logic/B06_Section_Route_Carry_Patch"), // patch 바탕 한 벌(472-6) B06("B06_Section_Api_Culvert_Options"), path.join(ROOT, "A00_Common", "b_page_state.ts"), path.join(ROOT, "A00_Common", "design", "design_selection.ts"), path.join(ROOT, "common_util", "common_util_pipe_match.ts"), // 472-6 ], { "./B06_Section_Cross_Refresh": { USER_TOUCHED_KEYS: ["display_half_width_m", "ford_adjust", "revet_adjust", "berm"], }, "./logic/B06_Section_Design_Keys": DESIGN_KEYS, // patch 바탕(logic/Route_Carry_Patch · 472-3)이 부르는 이름 "./B06_Section_Design_Keys": DESIGN_KEYS, }, ); (async () => { // 415 M4 — 새 자리 서버 미리보기 걷음 · 조정값 사본도 없음(새 자리는 계산 묶음 갈아끼움이 얹음) const { withDraftStations, forgetPreviewAdjusts } = loadDrafts(B06("B06_Section_Section_Drafts")); const detail = { cross_sections: [{ chainage_m: 0, kind: "regular", design: {} }] }; state.draftPipes = [{ chainage_m: 50, facility: "box_culvert" }]; await withDraftStations(detail, "P", 7); out.preview = { asked: asked.splice(0), chainages: detail.cross_sections.map((s) => s.chainage_m), forget: typeof forgetPreviewAdjusts, }; const { crossPatchesFromCache } = loadStore(B06("B06_Section_Section_Store")); out.cache_patches = crossPatchesFromCache( { cross_sections: [ { chainage_m: 100, design: { ford_adjust: { w: 1 }, display_half_width_m: 6 } }, { chainage_m: 140, design: { ford_adjust: { w: 2 } } }, ], }, [100], ); out.cache_patches_plain = crossPatchesFromCache({ cross_sections: [{ chainage_m: 140, design: { ford_adjust: { w: 2 } } }], }); process.stdout.write(JSON.stringify(out)); })().catch((error) => { process.stderr.write(String(error?.stack ?? error)); process.exit(1); });