/* 130-1 · 140-2 [평면도 관리] 모달 헬퍼 — 좌측 「평면도」 묶음 · 모달(종류 추가 · 업데이트 · 지우기 · 장 미리보기 · * 드롭다운 따라가기 · 줄 끌어 순서)을 * Node 로 돌려 표준출력 JSON 한 벌로. TS 는 그 자리에서 트랜스파일 · 화면 그릇(DOM)은 쓰는 만큼만 흉내 · * 서버 길(`_Api_Fetch`)은 메모리 가짜 · 공용 모달 · CAD · 드롭다운 · 체크박스는 기록용 가짜. */ const fs = require("fs"); const path = require("path"); const { makeLoader } = require("./helper_ts_stub_loader.cjs"); const ROOT = path.join(__dirname, "..", ".."); const B07 = path.join(ROOT, "B07_DesignDetail"); // ── 화면 그릇 흉내 ───────────────────────────────────────────────────────────── class FakeNode { constructor(tag) { this.tagName = tag.toUpperCase(); this.children = []; this.dataset = {}; this.listeners = {}; this._text = ""; this._classes = new Set(); this.value = ""; this.hidden = false; const self = this; this.classList = { add: (...names) => names.forEach((n) => self._classes.add(n)), 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(""); } append(...kids) { kids.forEach((k) => this.children.push(k)); } replaceChildren(...kids) { this.children = [...kids]; } addEventListener(type, handler) { (this.listeners[type] ||= []).push(handler); } click() { const event = { stopPropagation() {}, preventDefault() {} }; (this.listeners.click || []).forEach((handler) => handler(event)); } fire(type, event = {}) { (this.listeners[type] || []).forEach((handler) => handler({ preventDefault() {}, ...event })); } getBoundingClientRect() { return { top: 0, height: 20, left: 0, width: 100 }; } /** `.a` · `.a .b`(자손) 만. */ querySelectorAll(selector) { const parts = selector .trim() .split(/\s+/) .map((p) => p.slice(1)); const found = []; const walk = (node, depth) => node.children.forEach((kid) => { let next = depth; if (kid.classList && kid.classList.contains(parts[depth])) { if (depth === parts.length - 1) found.push(kid); else next = depth + 1; } walk(kid, next); }); walk(this, 0); return found; } querySelector(selector) { return this.querySelectorAll(selector)[0] ?? null; } } const all = (node) => [node, ...node.children.flatMap(all)]; const byText = (node, tag, text) => all(node).filter((n) => n.tagName === tag && n.textContent === text); globalThis.document = { createElement: (tag) => new FakeNode(tag) }; const confirms = []; globalThis.window = { confirm: (message) => (confirms.push(message), true) }; // ── 공용 부품 가짜 ───────────────────────────────────────────────────────────── const toasts = []; const modals = []; const cads = []; const selects = []; const STUBS = { "@ui/ui_template_elements": { showToast: (message, kind) => toasts.push([kind, message]), createSelectField: (opts) => { const root = new FakeNode("select"); const select = { value: opts.options[0]?.value ?? "", disabled: false }; const field = { root, select, label: opts.ariaLabel, change: (value) => ((select.value = value), opts.onChange && opts.onChange()), setOptions: (options, value) => (select.value = value ?? options[0]?.value ?? ""), }; selects.push(field); return field; }, }, "@ui/ui_template_checkbox": { createCheckbox: (label, opts = {}) => { const root = new FakeNode("label"); root.textContent = label; return { root, input: { value: opts.value ?? "", checked: !!opts.checked } }; }, }, "@ui/ui_template_modal": { openModal: (opts) => { const body = new FakeNode("div"); const modal = { opts, body, open: true }; modals.push(modal); opts.mount(body, () => (modal.open = false)); return { close: () => (modal.open = false) }; }, }, "@ui/cad_host/cad_host": { createCadHost: (opts) => { const cad = { opts, element: new FakeNode("div"), loads: [], destroyed: false }; cad.beginLoading = () => {}; cad.fail = (detail) => toasts.push(["error", detail]); cad.load = (drawing, meta, frameEdit, fields, extra) => cad.loads.push({ drawing, meta, extra }); cad.destroy = () => (cad.destroyed = true); cads.push(cad); return cad; }, }, "@ui/ui_template_locale": { currentLanguageIndex: 0, ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }), }, }; // ── 서버 길 가짜 — 종류 파일 · 장 목록이 메모리에 ─────────────────────────────── const calls = []; const state = { pieces: [ { id: "terrain", name: "지형" }, { id: "route", name: "노선" }, ], scales: [1000, 1200], default_scale: 1200, watershed_scale: 6000, contour_intervals: [5, 10], // 512-1 유역도 등고선 간격(서버 `_state` 와 같은 칸) default_contour_interval: 5, kinds: [ { id: "k_terrain", name: "지형", pieces: ["terrain"], layout: "plan", scale: 1200 }, { id: "k_route", name: "노선배치도", pieces: ["route"], layout: "plan", scale: 1200 }, ], }; let confirmed = new Set(["plan_k_route"]); /** 종류 하나 = 장 하나 · 「지형」만 두 장. */ const sheets = () => state.kinds.flatMap((kind) => (kind.id === "k_terrain" ? [1, 2] : [1]).map((n) => { const id = n === 1 ? `plan_${kind.id}` : `plan_${kind.id}_${n}`; return { id, kind: "plan", label: kind.name, chainage_m: null, confirmed: confirmed.has(id), plan_kind: kind.id, plan_kind_name: kind.name, }; }), ); const copy = () => JSON.parse(JSON.stringify(state)); const API = { fetchPlanKinds: async () => (calls.push(["kinds"]), copy()), fetchDesignDrawingList: async () => ( calls.push(["list"]), { drawings: [{ id: "cover", kind: "cover" }, ...sheets()] } ), fetchDesignDrawing: async (_, id) => ( calls.push(["fetchDrawing", id]), { id, confirmed: confirmed.has(id), drawing: { entities: [{ id }], layers: [] } } ), addPlanKind: async (_, name, pieces, layout, scale) => { calls.push(["add", name, pieces, layout, scale]); state.kinds.push({ id: "k_new", name, pieces, layout, scale }); return copy(); }, updatePlanKind: async (_, id, name, pieces, scale) => { calls.push(["update", id, name, pieces, scale]); Object.assign( state.kinds.find((k) => k.id === id), { name, pieces, scale }, ); return copy(); }, deletePlanKind: async (_, id) => { calls.push(["delete", id]); state.kinds = state.kinds.filter((k) => k.id !== id); return copy(); }, savePlanMarks: async () => copy(), reorderPlanKinds: async (_, ids) => { calls.push(["reorder", ids]); state.kinds = ids.map((id) => state.kinds.find((k) => k.id === id)); confirmed.delete("plan_k_route"); // 번호 바뀐 장 확정 풂(서버 몫 흉내) return copy(); }, invalidateDesignDrawing: async (_, id) => { calls.push(["invalidate", id]); confirmed.delete(id); }, }; const { loadTs } = makeLoader({ root: ROOT, requireFor: (full) => (request) => { if (STUBS[request]) return STUBS[request]; // 공용 카드(269) = 진짜 모듈(가짜 그릇 위에서 돎) if (request === "@ui/ui_template_collapsible") return loadTs(path.join(ROOT, "ui_template", "ui_template_collapsible.ts")); if (request === "./B07_DesignDetail_Api_Fetch") return API; // 522-3 미리받기 — 장 받기는 `openDrawing` · 고친 뒤 목록 맞춤은 `syncDrawings` if (request === "./B07_DesignDetail_Prefetch") return { openDrawing: (item) => API.fetchDesignDrawing("p1", item.id), syncDrawings: (_, list) => calls.push(["sync", list.length]), }; if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); throw new Error(`stub 없음: ${request}`); }, }); const settle = () => new Promise((done) => setTimeout(done, 0)); const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Panels.ts")); const { openPlanManager } = loadTs(path.join(B07, "B07_DesignDetail_UI_PlanManage.ts")); (async () => { const out = {}; const closes = []; const selected = []; // 페이지가 받은 목록을 넘김(`openDrawingSettings` 와 같은 길 · 522-3) const pageList = [{ id: "cover", kind: "cover", label: "표지", confirmed: false }, ...sheets()]; const plan = { projectId: "p1", drawings: pageList, onClosed: (changed, openId) => closes.push([changed, openId]), }; const panel = buildDrawingSidePanel( pageList, (drawing) => selected.push(drawing.id), undefined, false, // 280-5 평면도 ⚙ = 평면도 관리 모달(화면 조립 `openDrawingSettings` 와 같은 길) (kind) => kind === "plan" && openPlanManager(plan), ); const gear = () => section.querySelectorAll(".ui-collapsible__settings")[0]; const section = panel.querySelector(".b07-plan"); out.left = { manage: section.querySelectorAll(".ui-collapsible__settings").length, manageButton: byText(section, "BUTTON", "평면도 관리").length, sheetButtons: section.querySelectorAll(".b07-drawing-button").map((b) => b.dataset.drawingId), formFields: all(section).filter((n) => ["SELECT", "INPUT", "LABEL"].includes(n.tagName)).length, remove: byText(section, "BUTTON", "×").length, texts: section.textContent, }; // 단추 → 모달 gear().click(); await settle(); const modal = modals[0]; const cad = cads[0]; const rows = () => modal.body.querySelectorAll(".b07-plan-manage__sheets .b07-plan-manage__row"); const kindRows = () => rows().map((row) => row.dataset.planKind); const sheetIds = () => modal.body.querySelectorAll(".b07-plan-manage__sheet").map((b) => b.dataset.drawingId); const kindPick = () => selects.filter((s) => s.label === "종류").at(-1); const action = () => all(modal.body).find( (n) => n.tagName === "BUTTON" && ["추가", "업데이트"].includes(n.textContent), ); out.open = { title: modal.opts.title, dialogClass: modal.opts.dialogClass, kinds: kindRows(), sheets: sheetIds(), hasKindForm: !!kindPick(), hasMarks: all(modal.body).some((n) => n.textContent === "대피소 · 사토장 자리"), preview: cad.loads.at(-1).drawing.entities[0].id, extra: cad.loads.at(-1).extra, bare: cad.opts.bare, calls: calls.map((c) => c[0]), }; // 종류 추가 — 「새 종류」 · 이름 · 조각 하나 kindPick().change(""); all(modal.body).find((n) => n.tagName === "INPUT" && n.placeholder === "종류 이름").value = "sub6임시"; action().click(); await settle(); await settle(); out.add = { kinds: kindRows(), sheets: sheetIds(), call: calls.find((c) => c[0] === "add"), after: calls.slice(calls.findIndex((c) => c[0] === "add")).map((c) => c[0]), }; // 업데이트 — 새 종류를 골라 이름 · 축척 바꿈 kindPick().change("k_new"); out.updateLabel = action().textContent; all(modal.body).find((n) => n.tagName === "INPUT" && n.placeholder === "종류 이름").value = "sub6임시2"; selects.filter((s) => s.label === "축척").at(-1).select.value = "1000"; action().click(); await settle(); await settle(); out.update = { call: calls.find((c) => c[0] === "update"), rowText: rows().find((row) => row.dataset.planKind === "k_new").textContent, }; // 지우기 — 드롭다운에서 그 종류를 고르고 폼 안 [지우기] kindPick().change("k_new"); out.removeHiddenForNew = (() => { kindPick().change(""); return byText(modal.body, "BUTTON", "지우기")[0].hidden; })(); kindPick().change("k_new"); byText(modal.body, "BUTTON", "지우기")[0].click(); await settle(); await settle(); out.remove = { kinds: kindRows(), call: calls.find((c) => c[0] === "delete"), confirms: confirms.length, }; // 미리보기 장 바꾸기 — 「지형 2장」 → 「노선배치도」(확정 배지만 · [확정 풀기] 없음 140-1) const pickSheet = async (id) => { modal.body .querySelectorAll(".b07-plan-manage__sheet") .find((b) => b.dataset.drawingId === id) .click(); await settle(); }; await pickSheet("plan_k_terrain_2"); const head = () => modal.body.querySelector(".b07-plan-manage__head"); out.sheet2 = { preview: cad.loads.at(-1).drawing.entities[0].id, head: head().textContent, active: modal.body .querySelectorAll(".b07-plan-manage__sheet") .filter((b) => b.dataset.active === "true") .map((b) => b.dataset.drawingId), sheetTexts: rows()[0] .querySelectorAll(".b07-plan-manage__sheet") .map((b) => b.textContent), dropdown: kindPick().select.value, activeRow: rows() .filter((r) => r.dataset.active === "true") .map((r) => r.dataset.planKind), }; await pickSheet("plan_k_route"); out.routeHead = head().textContent; out.routeDropdown = kindPick().select.value; // 드롭다운으로 종류 고름 → 그 종류 첫 장 미리보기 + 줄 강조 kindPick().change("k_terrain"); await settle(); out.dropdownPick = { preview: cad.loads.at(-1).drawing.entities[0].id, activeRow: rows() .filter((r) => r.dataset.active === "true") .map((r) => r.dataset.planKind), }; // 줄 끌어 놓기 — 제자리면 부르지 않음 · 「노선배치도」를 「지형」 위 반에 놓으면 앞으로(놓는 즉시) const dragTo = async (fromId, toId, clientY) => { const from = rows().find((r) => r.dataset.planKind === fromId); const to = rows().find((r) => r.dataset.planKind === toId); from.fire("dragstart", {}); to.fire("dragover", { clientY }); to.fire("drop", {}); from.fire("dragend"); await settle(); await settle(); }; await dragTo("k_route", "k_terrain", 15); // 아래 반 = 뒤 = 제자리 out.dragSame = calls.filter((c) => c[0] === "reorder").length; await dragTo("k_route", "k_terrain", 5); out.drag = { call: calls.find((c) => c[0] === "reorder"), kinds: kindRows(), sheets: sheetIds(), }; // 닫기 — 고친 게 있으니 changed = true · 미리보기 듣기 멈춤 modal.opts.beforeClose(); out.close = { closes: [...closes], destroyed: cad.destroyed, open: modal.open }; // 다시 열어 고치지 않고 [CAD 에서 열기] → changed = false · 그 장 id const callsBefore = calls.length; gear().click(); await settle(); await settle(); const modal2 = modals[1]; out.reopen = { calls: calls.slice(callsBefore).map((c) => c[0]), kinds: modal2.body .querySelectorAll(".b07-plan-manage__sheets .b07-plan-manage__row") .map((row) => row.dataset.planKind), }; modal2.body .querySelectorAll(".b07-plan-manage__sheet") .find((b) => b.dataset.drawingId === "plan_k_route") .click(); await settle(); byText(modal2.body.querySelector(".b07-plan-manage__head"), "BUTTON", "CAD 에서 열기")[0].click(); out.openInCad = { closes: closes.slice(1), destroyed: cads[1].destroyed, open: modal2.open }; out.toasts = toasts; process.stdout.write(JSON.stringify(out)); })().catch((error) => { process.stderr.write(String(error && error.stack ? error.stack : error)); process.exit(1); });