- 맨 위 [종류 ▼] 상세 폼 · × 대신 폼 안 [지우기] - 평면도 장 = 종류마다 한 줄 [≡] 이름 [1][2] · 장 단추 누르면 미리보기 + 드롭다운 따라감 - 줄 끌어 놓기 = 종류 순서 · 놓는 즉시 PUT plan-kinds-order(reorder_kinds) · 번호 바뀐 장 확정 풂 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
414 lines
15 KiB
JavaScript
414 lines
15 KiB
JavaScript
/* 130-1 · 140-2 [평면도 관리] 모달 헬퍼 — 좌측 「평면도」 묶음 · 모달(종류 추가 · 업데이트 · 지우기 · 장 미리보기 ·
|
||
* 드롭다운 따라가기 · 줄 끌어 순서)을
|
||
* Node 로 돌려 표준출력 JSON 한 벌로. TS 는 그 자리에서 트랜스파일 · 화면 그릇(DOM)은 쓰는 만큼만 흉내 ·
|
||
* 서버 길(`_Api_Fetch`)은 메모리 가짜 · 공용 모달 · CAD · 드롭다운 · 체크박스는 기록용 가짜.
|
||
*/
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||
|
||
const ROOT = path.join(__dirname, "..", "..");
|
||
const B07 = path.join(ROOT, "B07_DesignDetail");
|
||
const cache = new Map();
|
||
|
||
// ── 화면 그릇 흉내 ─────────────────────────────────────────────────────────────
|
||
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() {
|
||
(this.listeners.click || []).forEach((handler) => handler({}));
|
||
}
|
||
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_collapsible": { attachCollapsible: () => {} },
|
||
"@ui/ui_template_locale": {
|
||
currentLanguageIndex: 0,
|
||
ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }),
|
||
},
|
||
"../B05_Profile/B05_Profile_UI_Structures_Fields": {
|
||
stationFields: () => ({ wrap: new FakeNode("div"), write: () => {}, read: () => 0 }),
|
||
},
|
||
};
|
||
|
||
// ── 서버 길 가짜 — 종류 파일 · 장 목록이 메모리에 ───────────────────────────────
|
||
const calls = [];
|
||
const state = {
|
||
pieces: [
|
||
{ id: "terrain", name: "지형" },
|
||
{ id: "route", name: "노선" },
|
||
],
|
||
scales: [1000, 1200],
|
||
default_scale: 1200,
|
||
watershed_scale: 6000,
|
||
kinds: [
|
||
{ id: "k_terrain", name: "지형", pieces: ["terrain"], layout: "plan", scale: 1200 },
|
||
{ id: "k_route", name: "노선배치도", pieces: ["route"], layout: "plan", scale: 1200 },
|
||
],
|
||
marks: [],
|
||
station_interval_m: 20,
|
||
};
|
||
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 () => copy(),
|
||
fetchDesignDrawingList: async () => ({ 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);
|
||
},
|
||
};
|
||
|
||
function loadTs(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 localRequire = (request) => {
|
||
if (STUBS[request]) return STUBS[request];
|
||
if (request === "./B07_DesignDetail_Api_Fetch") return API;
|
||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||
throw new Error(`stub 없음: ${request}`);
|
||
};
|
||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||
cache.set(full, box.exports);
|
||
return box.exports;
|
||
}
|
||
|
||
const settle = () => new Promise((done) => setTimeout(done, 0));
|
||
const { buildDrawingSidePanel } = loadTs(path.join(B07, "B07_DesignDetail_UI_Panels.ts"));
|
||
|
||
(async () => {
|
||
const out = {};
|
||
const closes = [];
|
||
const selected = [];
|
||
const plan = { projectId: "p1", onClosed: (changed, openId) => closes.push([changed, openId]) };
|
||
const panel = buildDrawingSidePanel(
|
||
[{ id: "cover", kind: "cover", label: "표지", confirmed: false }, ...sheets()],
|
||
(drawing) => selected.push(drawing.id),
|
||
undefined,
|
||
false,
|
||
plan,
|
||
);
|
||
const section = panel.querySelector(".b07-plan");
|
||
out.left = {
|
||
manage: 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,
|
||
};
|
||
|
||
// 단추 → 모달
|
||
byText(section, "BUTTON", "평면도 관리")[0].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,
|
||
};
|
||
|
||
// 종류 추가 — 「새 종류」 · 이름 · 조각 하나
|
||
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") };
|
||
|
||
// 업데이트 — 새 종류를 골라 이름 · 축척 바꿈
|
||
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
|
||
byText(section, "BUTTON", "평면도 관리")[0].click();
|
||
await settle();
|
||
await settle();
|
||
const modal2 = modals[1];
|
||
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);
|
||
});
|