/* 280-5 · 281 B07 ⚙ 설정 · 저장 후 이동 헬퍼 — 좌측 목록 ⚙ 자리 · 누름(접힘 · 도면 열기 안 감) · * ⚙ 모달(평면도 = 평면도 관리 · 횡단 = 배치 칸 + 안내선 · 구조물 = 축척 없음 줄 · [원본에서 다시 그리기]) · * 저장 묶음(`_UI_Save` — 고친 탭만 · 실패 = 거짓 · 원본 되돌림)을 Node 로 돌려 표준출력 JSON 한 벌로. * TS 는 그 자리에서 트랜스파일 · 화면 그릇은 쓰는 만큼만 흉내 · 서버 길 · 모달 · 단추는 기록용 가짜. */ const fs = require("fs"); const path = require("path"); const { makeLoader } = require("./helper_ts_stub_loader.cjs"); const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const B07 = path.join(ROOT, "B07_DesignDetail"); const UI = path.join(ROOT, "ui_template"); class FakeNode { constructor(tag) { this.tagName = tag.toUpperCase(); this.children = []; this.parent = null; this.dataset = {}; this.attrs = {}; this.listeners = {}; this._text = ""; this._classes = new Set(); this.value = ""; this.disabled = false; 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) => self._classes.has(name) ? self._classes.delete(name) : self._classes.add(name), 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(""); } setAttribute(key, value) { this.attrs[key] = String(value); } append(...kids) { kids.forEach((k) => { if (k.parent) k.parent.children = k.parent.children.filter((c) => c !== k); k.parent = this; this.children.push(k); }); } prepend(...kids) { kids.forEach((k) => (k.parent = this)); this.children.unshift(...kids); } remove() { if (this.parent) this.parent.children = this.parent.children.filter((c) => c !== this); this.parent = null; } replaceWith(node) { const at = this.parent.children.indexOf(this); node.parent = this.parent; this.parent.children[at] = node; } addEventListener(type, handler) { (this.listeners[type] ||= []).push(handler); } /** 누름 — 올라가기(bubble)까지 · stopPropagation 이면 멈춤 */ click() { if (this.disabled) return; let stopped = false; const event = { target: this, stopPropagation: () => (stopped = true), preventDefault() {} }; for (let node = this; node && !stopped; node = node.parent) (node.listeners.click || []).forEach((handler) => handler(event)); } closest(selector) { const name = selector.slice(1); for (let node = this; node; node = node.parent) if (node.classList.contains(name)) return node; return null; } } const all = (node) => [node, ...node.children.flatMap(all)]; const byClass = (node, name) => all(node).filter((n) => n.classList.contains(name)); globalThis.document = { createElement: (tag) => new FakeNode(tag), querySelectorAll: () => [], }; const toasts = []; const modals = []; const plans = []; const calls = []; let failConfirm = false; const SCALES = { scales: { longitudinal: { x: 1000, y: 200, plan: 5000 }, cross: { x: 100, y: 100 }, mass_haul: { x: 2000, y: 50000 }, development: { x: 50, y: 50 }, }, defaults: { longitudinal: { x: 1000, y: 200, plan: 5000 }, cross: { x: 100, y: 100 }, mass_haul: { x: 2000, y: 50000 }, development: { x: 50, y: 50 }, }, ranges: { longitudinal: { x: [100, 10000], y: [10, 2000], plan: [500, 50000] }, cross: { x: [10, 1000], y: [10, 1000] }, mass_haul: { x: [100, 20000], y: [1000, 1000000] }, development: { x: [10, 10000], y: [10, 10000] }, }, saved: { longitudinal: 2, cross: 0, mass_haul: 0, development: 0 }, }; let answer = true; const button = (opts) => { const node = new FakeNode("button"); node.className = "ui-btn"; node.textContent = opts.label; node.disabled = !!opts.disabled; if (opts.onClick) node.addEventListener("click", opts.onClick); return node; }; const STUBS = { "@ui/ui_template_elements": { createButton: button, createInputField: (opts) => { const root = new FakeNode("label"); const input = new FakeNode("input"); input.value = opts.value ?? ""; input.setAttribute("type", opts.type); const label = new FakeNode("span"); label.className = "ui-field__label"; label.textContent = opts.label; root.append(label, input); const setError = (msg) => (input.error = msg ?? null); return { root, input, setError }; }, createSelectField: (opts) => { const root = new FakeNode("div"); const label = new FakeNode("span"); label.className = "ui-field__label"; label.textContent = opts.label; const select = new FakeNode("select"); opts.options.forEach((o) => { const option = new FakeNode("option"); option.textContent = o.text; option.value = o.value; select.append(option); }); select.value = opts.value ?? opts.options[0]?.value; root.append(label, select); return { root, select, setOptions() {} }; }, showToast: (message, kind) => toasts.push([kind, message]), showLoadingOverlay: () => calls.push(["overlay+"]), hideLoadingOverlay: () => calls.push(["overlay-"]), showConfirmDialog: async (message) => (calls.push(["ask", message]), answer), }, "@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), new FakeNode("div")); return { close: () => (modal.open = false) }; }, }, "@ui/ui_template_locale": { currentLanguageIndex: 0, ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }), }, "./B07_DesignDetail_UI_PlanManage": { openPlanManager: (controls) => { const plan = { controls, open: true }; plans.push(plan); return { close: () => (plan.open = false) }; }, }, "./B07_DesignDetail_Api_Fetch": { confirmDesignDrawing: async (projectId, id, drawing, table) => { calls.push(["confirm", id, drawing.tag, table]); if (failConfirm) throw new Error("서버 오류"); return { all_confirmed: true }; }, invalidateDesignDrawing: async (projectId, id) => calls.push(["invalidate", id]), // 485-2 축척 — 범위 · 기본은 서버(`_Router_Support_Scale.py`)와 같은 값 fetchDrawingScales: async () => JSON.parse(JSON.stringify(SCALES)), saveDrawingScales: async (projectId, scales) => calls.push(["scales", scales]), }, }; const { loadTs } = makeLoader({ root: ROOT, requireFor: (full) => (request) => { if (CATALOG.isCatalogRequest(request)) return CATALOG; if (STUBS[request]) return STUBS[request]; if (request === "@ui/ui_template_collapsible") return loadTs(path.join(UI, "ui_template_collapsible.ts")); if (request.startsWith(".")) { const target = path.join(path.dirname(full), `${request}.ts`); return fs.existsSync(target) ? loadTs(target) : {}; } return {}; }, }); const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Panels.ts")); const { attachCollapsible } = loadTs(path.join(UI, "ui_template_collapsible.ts")); const { openDrawingSettings } = loadTs(path.join(B07, "B07_DesignDetail_UI_Settings.ts")); const Save = loadTs(path.join(B07, "B07_DesignDetail_UI_Save.ts")); const item = (id, kind, extra = {}) => ({ id, kind, label: id, confirmed: false, ...extra }); const drawings = [ item("cover", "cover"), item("plan_k", "plan", { plan_kind: "k", plan_kind_name: "지형" }), item("longitudinal", "longitudinal"), item("cross_standard", "cross_standard"), item("cross_s1", "cross"), item("cross_s2", "cross"), item("mass_haul", "mass_haul"), item("structure_k", "structure"), item("dev_k", "development", { plan_kind: "dk", plan_kind_name: "옹벽" }), ]; (async () => { const out = {}; // ── 좌측 ⚙ 자리 ─────────────────────────────────────────────────────────── const settings = []; const selected = []; const panel = buildDrawingSidePanel( drawings, (d) => selected.push(d.id), undefined, false, (kind) => settings.push(kind), ); const cards = byClass(panel, "b07-drawing-group"); out.cardGears = cards .filter((c) => byClass(c, "ui-collapsible__settings").length) .map((c) => c.dataset.kind); out.cardGearInTitle = cards.every((c) => byClass(c, "ui-collapsible__settings").every((g) => g.parent.classList.contains("ui-collapsible__title"), ), ); const buttonGears = byClass(panel, "b07-drawing-button__settings"); out.buttonGears = buttonGears.map((g) => g.parent.dataset.drawingId); out.buttonGearRole = buttonGears.map((g) => [g.tagName, g.attrs.role, g.tabIndex]); out.coverGear = byClass(panel, "b07-drawing-button") .filter((b) => b.dataset.drawingId === "cover") .flatMap((b) => byClass(b, "b07-drawing-button__settings")).length; // 누름 — 카드 ⚙ = 접힘 그대로 · 단추 ⚙ = 도면 안 엶 · 제목 누름은 여전히 접음 const before = cards.map((c) => c.classList.contains("is-collapsed")); cards.forEach((c) => byClass(c, "ui-collapsible__settings")[0]?.click()); out.collapsedSame = cards.every((c, i) => c.classList.contains("is-collapsed") === before[i]); buttonGears.forEach((g) => g.click()); out.settingsCalls = [...settings]; out.selectedAfterGear = [...selected]; const crossCard = cards.find((c) => c.dataset.kind === "cross"); const crossTitle = byClass(crossCard, "ui-collapsible__title")[0]; const wasCollapsed = crossCard.classList.contains("is-collapsed"); crossTitle.click(); out.titleClickToggles = crossCard.classList.contains("is-collapsed") !== wasCollapsed; byClass(panel, "b07-drawing-button") .find((b) => b.dataset.drawingId === "mass_haul") .click(); out.buttonClickOpens = selected.at(-1) === "mass_haul"; // Enter 도 도면 안 엶 const enterBefore = selected.length; const gear = buttonGears[0]; let stopped = false; gear.listeners.keydown.forEach((h) => h({ key: "Enter", preventDefault() {}, stopPropagation: () => (stopped = true) }), ); out.enterStops = stopped && selected.length === enterBefore; // ── ⚙ 모달 ────────────────────────────────────────────────────────────── let current = drawings[4]; // cross_s1 let redraws = 0; const saved = []; const guideToggle = button({ label: "안내선 숨기기" }); const closedCalls = []; const deps = { drawings, current: () => current, canRedraw: () => true, redraw: async () => (redraws++, true), plan: { projectId: "p1", onClosed: (...a) => closedCalls.push(a) }, cross: { layout: async () => ({ default: [4, 4], sheets: { 1: [3, 2] }, table_scale: 1.2 }), save: (next, sheet) => saved.push([next, sheet]), guideToggle, }, }; const tick = () => new Promise((r) => setTimeout(r, 0)); const texts = (node) => all(node) .map((n) => n._text) .filter(Boolean); const legends = (node) => byClass(node, "b07-settings__group").map((g) => g.children[0].textContent); const footerOf = (modal) => byClass(modal.body, "b07-settings__actions"); const footerBtn = (modal, label) => all(footerOf(modal)[0]).find((n) => n.tagName === "BUTTON" && n.textContent === label); const type = (input, value) => { input.value = value; (input.listeners.input || []).forEach((h) => h()); }; let autoBoxes = 0; // 「자동」 체크(523-1 걷음) — 모든 모달에서 셈 const countAuto = (modal) => (autoBoxes += all(modal.body).filter((n) => n.attrs.type === "checkbox").length); openDrawingSettings("cross", deps); await tick(); await tick(); const cross = modals.at(-1); countAuto(cross); const layoutBoxes = byClass(cross.body, "b07-cross-layout"); const crossInputs = layoutBoxes.flatMap((n) => all(n).filter((k) => k.tagName === "INPUT")); out.crossModal = { title: cross.opts.title, legends: legends(cross.body), labels: layoutBoxes.flatMap((n) => byClass(n, "ui-field__label")).map((n) => n.textContent), values: crossInputs.map((n) => n.value), footers: footerOf(cross).length, footerButtons: footerOf(cross)[0].children.map((n) => n.textContent), buttonsInGroups: byClass(cross.body, "b07-settings__group").flatMap((g) => all(g).filter((n) => n.tagName === "BUTTON"), ).length, head: cross.opts.actions.map((n) => [n.textContent, n.disabled]), notes: texts(cross.body).filter((t) => t.startsWith("지금 도면")), }; type(crossInputs[0], "2"); footerBtn(cross, "확인").click(); await tick(); out.crossSave = { saved, closed: !cross.open, scaleCalls: calls.filter((c) => c[0] === "scales").length, }; openDrawingSettings("structure", deps); const structure = modals.at(-1); countAuto(structure); out.structureModal = { title: structure.opts.title, notes: texts(structure.body), legends: legends(structure.body), footerButtons: footerOf(structure)[0].children.map((n) => n.textContent), head: structure.opts.actions.map((n) => [n.textContent, n.disabled, n.title]), }; footerBtn(structure, "확인").click(); out.structureModal.closed = !structure.open; openDrawingSettings("longitudinal", deps); out.longModal = { title: modals.at(-1).opts.title, legends: legends(modals.at(-1).body), footers: footerOf(modals.at(-1)).length, }; current = drawings[1]; // plan_k openDrawingSettings("plan", deps); const plan = plans.at(-1); out.planModal = { viaManager: plans.length === 1 && modals.length === 3, head: plan.controls.actions.map((n) => [n.textContent, n.disabled]), }; plan.controls.actions[0].click(); await tick(); out.planRedraw = { redraws, closed: !plan.open }; current = drawings[3]; // 표준 횡단면도도 횡단 묶음 openDrawingSettings("cross", deps); out.standardInCross = !modals.at(-1).opts.actions[0].disabled; // ── 축척 칸(485-2 · 523-1 자동 없음 · 저장 = 모달 [확인]) ───────────────── closedCalls.length = 0; const scaleOf = async (kind) => { openDrawingSettings(kind, deps); await tick(); const modal = modals.at(-1); countAuto(modal); const section = byClass(modal.body, "b07-scale")[0]; const inputs = all(section).filter((n) => n.tagName === "INPUT"); const btn = (label) => footerBtn(modal, label); const state = () => ({ values: inputs.map((n) => n.value), errors: inputs.map((n) => n.error ?? null), disabled: inputs.map((n) => !!n.disabled), save: !btn("확인").disabled, }); return { modal, section, inputs, btn, state }; }; const scale = {}; current = drawings[2]; // longitudinal let s = await scaleOf("longitudinal"); scale.longLabels = byClass(s.section, "ui-field__label").map((n) => n.textContent); scale.longLegend = legends(s.modal.body); scale.longFooter = footerOf(s.modal)[0].children.map((n) => n.textContent); scale.longStart = s.state(); type(s.inputs[0], "50"); scale.longLow = s.state(); type(s.inputs[0], "500.5"); scale.longFrac = s.state(); type(s.inputs[0], "500"); scale.longOk = s.state(); calls.length = 0; answer = false; s.btn("확인").click(); await tick(); scale.cancel = { calls: calls.map((c) => c[0]), open: s.modal.open, closed: closedCalls.length }; answer = true; calls.length = 0; s.btn("확인").click(); await tick(); await tick(); scale.save = { ask: calls.find((c) => c[0] === "ask")?.[1], sent: calls.find((c) => c[0] === "scales")?.[1], open: s.modal.open, closed: [...closedCalls], }; s = await scaleOf("longitudinal"); type(s.inputs[1], "300"); s.btn("기본값").click(); scale.reset = s.state(); calls.length = 0; s.btn("확인").click(); await tick(); scale.same = { calls: calls.length, open: s.modal.open }; // 평면 위치도 칸(521-2) — 처음 1/5,000 · 1/500 밑 = 붉음 · 1/3,000 저장 s = await scaleOf("longitudinal"); type(s.inputs[2], "400"); scale.planLow = s.state(); type(s.inputs[2], "3000"); calls.length = 0; s.btn("확인").click(); await tick(); await tick(); scale.planSent = calls.find((c) => c[0] === "scales")?.[1]; // [취소] = 저장 없이 닫음 s = await scaleOf("longitudinal"); type(s.inputs[0], "500"); calls.length = 0; s.btn("취소").click(); scale.cancelButton = { calls: calls.length, open: s.modal.open }; s = await scaleOf("mass_haul"); scale.massStart = s.state(); type(s.inputs[0], "3000"); calls.length = 0; s.btn("확인").click(); await tick(); await tick(); scale.massSent = { ask: calls.filter((c) => c[0] === "ask").length, sent: calls.find((c) => c[0] === "scales")?.[1], }; s = await scaleOf("development"); scale.dev = s.state(); openDrawingSettings("structure", deps); scale.structure = byClass(modals.at(-1).body, "b07-scale").length; scale.autoBoxes = autoBoxes; out.scale = scale; const Check = loadTs(path.join(B07, "logic", "B07_DesignDetail_Scale_Check.ts")); out.readAxis = ["1,000", " 99 ", "10001", "12.5", "", "abc"].map((raw) => Check.readScaleAxis(raw, [100, 10000]), ); out.toSend = [Check.scaleToSend(200, 200), Check.scaleToSend(500, 200)]; // ── 저장 묶음 ──────────────────────────────────────────────────────────── const tabsOf = (list, activeId) => ({ tabs: () => list, active: () => list.find((t) => t.id === activeId), markClean: (tab) => { tab.dirty = false; delete tab.drawing; }, }); const storeOf = (over = {}) => { let dirty = over.cadDirty ?? false; const store = { projectId: "p1", drawings, tabs: over.tabs, current: () => over.current, cadDirty: () => dirty, setCadClean: () => (dirty = false), requestCad: async () => ({ drawing: { tag: "live" }, quantityTable: { q: 1 } }), isFrameEditing: () => !!over.frame, forget: (id) => calls.push(["forget", id]), reload: async (d) => calls.push(["reload", d.id]), }; return store; }; const reset = () => { calls.length = 0; drawings.forEach((d) => (d.confirmed = false)); }; // 고친 것 없음 = 요청 0 · 참 reset(); let ok = await Save.saveEditedDrawings( storeOf({ current: drawings[4], tabs: tabsOf([{ id: "cross_s1" }], "cross_s1") }), ); out.noEdits = { ok, calls: [...calls] }; // 지금 탭(CAD 편집) + 다른 고친 탭 = confirm 둘 · 안 고친 탭은 안 보냄 reset(); const list = [ { id: "cross_s1", dirty: true }, { id: "longitudinal", dirty: true, drawing: { tag: "stash" } }, { id: "mass_haul" }, ]; ok = await Save.saveEditedDrawings( storeOf({ current: drawings[4], cadDirty: true, tabs: tabsOf(list, "cross_s1") }), ); out.saveEdited = { ok, confirms: calls.filter((c) => c[0] === "confirm"), clean: list.map((t) => !!t.dirty), confirmed: drawings.filter((d) => d.confirmed).map((d) => d.id), }; // 실패 = 거짓 · 알림 reset(); failConfirm = true; const toastStart = toasts.length; ok = await Save.saveEditedDrawings( storeOf({ current: drawings[4], cadDirty: true, tabs: tabsOf([{ id: "cross_s1", dirty: true }], "cross_s1"), }), ); failConfirm = false; out.saveFail = { ok, toasts: toasts.slice(toastStart).map((t) => t[0]) }; // 도각 편집 중 = 거짓 · 요청 0 reset(); ok = await Save.saveEditedDrawings( storeOf({ current: drawings[4], cadDirty: true, frame: true, tabs: tabsOf([], "") }), ); out.frameEditing = { ok, calls: calls.filter((c) => c[0] === "confirm").length }; // 원본에서 다시 그리기 — 저장본 = invalidate + 다시 실음 · 원본 그대로 = 꺼짐 · 물음 [취소] = 그대로 reset(); drawings[6].confirmed = true; const mh = storeOf({ current: drawings[6], tabs: tabsOf([{ id: "mass_haul" }], "mass_haul") }); out.redrawable = Save.hasRedrawable(mh); ok = await Save.redrawFromSource(mh); out.redraw = { ok, calls: calls.filter((c) => c[0] !== "overlay+" && c[0] !== "overlay-").map((c) => c[0]), confirmed: drawings[6].confirmed, }; out.pristine = Save.hasRedrawable(mh); answer = false; reset(); drawings[6].confirmed = true; ok = await Save.redrawFromSource(mh); answer = true; out.redrawCancel = { ok, invalidates: calls.filter((c) => c[0] === "invalidate").length }; process.stdout.write(JSON.stringify(out)); })().catch((error) => { process.stderr.write(String(error && error.stack ? error.stack : error)); process.exit(1); });