Files
Aislo/resources/tester/helper_130_b07_plan_manage.cjs
T
eomsangdonandClaude Sonnet 5.5 a96a60fb86 feat(524-1): B07 평면도 설정 모달 정리 — 기본 항목 지형도 하나 · 양식 칸 숨김 · 종류 척도 2열 · 항목 그룹 구분선 · 대피소 사토장 자리 지움
- 기본 종류 = 지형도 하나(옛 기본 이관 더하기 지움) · 대피소 조각 · 자리 폼 · plan-marks 끝점 지움
- 모달 양식 칸 = 평면도면 숨김 · 종류 척도 영역 2열 · 항목 3열 유지 + 그룹 구분선(PIECE_GROUPS 한 곳)
- 낡은 시험 수선: 기본 종류가 하나로 줄어 route · layout · landuse · watershed 기본 전제를 add_kind 로 세움 · 대피소 자리 시험 지움

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 14:23:59 +09:00

437 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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);
});