- 좌측 패널 · 아래 단추 넷 · 종단도 · 구조물 배치 폼을 페이지에 한 번만 만듦(design_shared) — 두 보기가 같은 노드 - 보기는 가운데 위 칸만: 종단 보기 = 3D(design_view_profile) · 횡단 보기 = 횡단 카드(design_view_cross · 처음 보일 때 그림) - 옛 B05_Profile_UI_Page · B06_Section_UI_Page 삭제 · 한 벌이 받은 조작은 지금 보기로, 관 목록 · 횡단 재계산 알림은 두 보기 모두로 - 진행 단계 종단설계 · 횡단설계 두 칸이 보기를 따라 켜짐 · 누르면 주소만 바꿈 · 제목도 따라감(setTitle · setActiveStep) - [저장] · [확정] 한 벌 — 두 보기 몫을 모아 saveDesign 한 번 · 종단 보기 [확정] 켬(횡단 보기를 숨긴 채 세워 횡단 자료를 얻음) - [초기화] · 계획노선 편집은 같은 페이지라도 다시 세움(remountRoute) - 덧붙임: 방향키는 초점 있는 쪽만(조정창 ↔ 종단도 계획고) · 횡단 보기 계획고 편집에도 [3D 업데이트] 대기 표시 · 종단 보기 폼 관 옵션을 횡단 캐시에도 얹음 · 종단 보기에서 횡단 조정 칸 가림 - 나눔: Cross_View 756→658(_Cross_View_Frame) · Page_Structures_Panel 700→547(_Structures_Spec) · 옛 B05 Page 786 → design_view_profile 638(_Page_Latest · _Page_Stations) - 시험: test_design_view_parity 한 벌 기준(한 패널 · 보기 오가도 같은 노드) · page_single · profile_single · save_channels · save_recompute 등 옮긴 자리로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
277 lines
9.5 KiB
JavaScript
277 lines
9.5 KiB
JavaScript
/* 두 보기 좌측 패널 **한 벌 지킴** 헬퍼(계획서 24-2f · 24-3b) — 공용 등록부(`design_panel` · `design_dock` ·
|
|
* `design_sections`)와 두 보기 칸 정의를 트랜스파일해 가짜 DOM 위에 **패널 하나**를 세우고
|
|
* `setView` 로 보기를 오가며 같은 노드인지 본다(한 벌 `design_shared` 가 하는 그대로). 결과는 JSON 한 벌.
|
|
*
|
|
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다.
|
|
*/
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
|
|
|
const ROOT = path.join(__dirname, "..", "..");
|
|
const cache = new Map();
|
|
|
|
// ── 화면 그릇 흉내 — 요소 · 클래스 · 속성 · 글자 · 자식 ──────────────────────────
|
|
class FakeNode {
|
|
constructor(tag) {
|
|
this.tagName = tag;
|
|
this.children = [];
|
|
this.parent = null;
|
|
this.dataset = {};
|
|
this.style = {};
|
|
this.attrs = {};
|
|
this._text = "";
|
|
this._classes = new Set();
|
|
this.hidden = false;
|
|
this.disabled = false;
|
|
this.title = "";
|
|
this.listeners = {};
|
|
const self = this;
|
|
this.classList = {
|
|
add: (...n) => n.forEach((x) => self._classes.add(x)),
|
|
remove: (...n) => n.forEach((x) => self._classes.delete(x)),
|
|
toggle: (n, f) => {
|
|
const on = f === undefined ? !self._classes.has(n) : !!f;
|
|
on ? self._classes.add(n) : self._classes.delete(n);
|
|
return on;
|
|
},
|
|
contains: (n) => self._classes.has(n),
|
|
};
|
|
}
|
|
set className(v) {
|
|
this._classes = new Set(String(v).split(/\s+/).filter(Boolean));
|
|
}
|
|
get className() {
|
|
return [...this._classes].join(" ");
|
|
}
|
|
set textContent(v) {
|
|
this._text = String(v);
|
|
this.children.forEach((c) => (c.parent = null));
|
|
this.children = [];
|
|
}
|
|
get textContent() {
|
|
return this._text + this.children.map((c) => c.textContent).join("");
|
|
}
|
|
_adopt(kid) {
|
|
const node =
|
|
typeof kid === "string" ? Object.assign(new FakeNode("#text"), { _text: kid }) : kid;
|
|
if (node.parent) node.parent.children = node.parent.children.filter((c) => c !== node);
|
|
node.parent = this;
|
|
return node;
|
|
}
|
|
append(...kids) {
|
|
kids.forEach((k) => this.children.push(this._adopt(k)));
|
|
}
|
|
prepend(...kids) {
|
|
this.children.unshift(...kids.map((k) => this._adopt(k)));
|
|
}
|
|
insertBefore(node, ref) {
|
|
const adopted = this._adopt(node);
|
|
const at = this.children.indexOf(ref);
|
|
if (at < 0) this.children.push(adopted);
|
|
else this.children.splice(at, 0, adopted);
|
|
}
|
|
replaceChildren(...kids) {
|
|
this.children = [];
|
|
this.append(...kids);
|
|
}
|
|
setAttribute(k, v) {
|
|
this.attrs[k] = String(v);
|
|
}
|
|
removeAttribute(k) {
|
|
delete this.attrs[k];
|
|
if (k === "title") this.title = "";
|
|
}
|
|
addEventListener(type, handler) {
|
|
(this.listeners[type] ||= []).push(handler);
|
|
}
|
|
click() {
|
|
if (this.disabled) return;
|
|
(this.listeners.click || []).forEach((h) => h({ target: this }));
|
|
}
|
|
querySelector(selector) {
|
|
return this.querySelectorAll(selector)[0] ?? null;
|
|
}
|
|
querySelectorAll(selector) {
|
|
const cls = selector.slice(1);
|
|
const out = [];
|
|
const walk = (n) =>
|
|
n.children.forEach((k) => {
|
|
if (k.classList && k.classList.contains(cls)) out.push(k);
|
|
walk(k);
|
|
});
|
|
walk(this);
|
|
return out;
|
|
}
|
|
}
|
|
globalThis.document = { createElement: (tag) => new FakeNode(tag) };
|
|
|
|
function html(node) {
|
|
if (node.tagName === "#text") return node._text;
|
|
const cls = node.className ? ` class="${node.className}"` : "";
|
|
const attrs = Object.entries(node.attrs)
|
|
.sort()
|
|
.map(([k, v]) => ` ${k}="${v}"`)
|
|
.join("");
|
|
const hidden = node.hidden ? " hidden" : "";
|
|
const off = node.disabled ? " disabled" : "";
|
|
const inner = node._text + node.children.map(html).join("");
|
|
return `<${node.tagName}${cls}${attrs}${hidden}${off}>${inner}</${node.tagName}>`;
|
|
}
|
|
|
|
// ── 바깥 모듈 대역 ─────────────────────────────────────────────────────────────
|
|
const LOCALES = {
|
|
Common_Btn_Reset: ["초기화", "Reset"],
|
|
B06_Profile_Btn_Save: ["저장", "Save"],
|
|
B06_Profile_Btn_Confirm: ["확정", "Confirm"],
|
|
Design_Btn_ToCross: ["횡단 보기", "Cross view"],
|
|
Design_Btn_ToProfile: ["종단 보기", "Profile view"],
|
|
};
|
|
const STUBS = {
|
|
"@ui/ui_template_locale": { ui_locales: LOCALES, currentLanguageIndex: 0 },
|
|
"@ui/ui_template_collapsible": { attachCollapsible: () => undefined },
|
|
"@ui/ui_template_elements": {
|
|
createButton: ({ label, variant, onClick }) => {
|
|
const button = new FakeNode("button");
|
|
button.className = `ui-btn ui-btn--${variant ?? "ghost"}`;
|
|
const span = new FakeNode("span");
|
|
span.className = "ui-btn__label";
|
|
span.textContent = label;
|
|
button.append(span);
|
|
button.addEventListener("click", () => onClick && onClick());
|
|
return button;
|
|
},
|
|
createInputField: () => ({ root: new FakeNode("div"), input: new FakeNode("input") }),
|
|
},
|
|
"./B06_Section_UI_Page_Common": {
|
|
L: (key) =>
|
|
({ B06_View_Title: "횡단 보기 설정", B06_Std_Title: "표준 횡단면 설정" })[key] ?? key,
|
|
},
|
|
};
|
|
|
|
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.endsWith(".css")) return {};
|
|
if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`);
|
|
return require(request);
|
|
};
|
|
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
|
cache.set(full, box.exports);
|
|
return box.exports;
|
|
}
|
|
|
|
const panel = loadTs(path.join(ROOT, "A00_Common", "design", "design_panel.ts"));
|
|
loadTs(path.join(ROOT, "A00_Common", "design", "design_sections.ts"));
|
|
loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Panel_Sections.ts"));
|
|
loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Panel.ts"));
|
|
|
|
// ── 견본 — 두 보기 부품을 한 `parts` 에 모은다(한 벌 `design_shared` 와 같은 모양) ──────────
|
|
function sample() {
|
|
const root = new FakeNode("section");
|
|
root.className =
|
|
"b05-route__panel-section b05-structure-section ui-collapsible ui-sidebar-section";
|
|
const title = new FakeNode("h3");
|
|
title.className = "ui-collapsible__title";
|
|
title.textContent = "구조물 배치";
|
|
root.append(title);
|
|
const listRoot = new FakeNode("div");
|
|
listRoot.className = "b05-structure__list";
|
|
listRoot.textContent = "구조물 목록";
|
|
return { root, listRoot };
|
|
}
|
|
const text = (t) => Object.assign(new FakeNode("div"), { _text: t });
|
|
const calls = [];
|
|
let current = "profile";
|
|
const run = (name) => () => calls.push(`${current}:${name}`);
|
|
const built = panel.createDesignPanel(
|
|
{
|
|
projectId: "p",
|
|
parts: {
|
|
structures: sample(),
|
|
settings: { nodes: [text("a"), text("b")] },
|
|
route: {
|
|
plannedRouteButton: text("plan"),
|
|
calcNodes: [text("calc")],
|
|
selectedNodes: [text("sel")],
|
|
bindSelectedRoot: (root) => {
|
|
root.hidden = true;
|
|
},
|
|
},
|
|
crossView: { row: text("cross-row") },
|
|
standardSlot: text("std"),
|
|
},
|
|
dock: {
|
|
reset: { run: run("reset") },
|
|
save: { run: run("save") },
|
|
confirm: { run: run("confirm") },
|
|
switchView: { run: run("switch") },
|
|
},
|
|
},
|
|
"profile",
|
|
);
|
|
|
|
const titleOf = (node) => node.children.find((c) => c.tagName === "h3")?.textContent ?? "";
|
|
const nodes = () => {
|
|
const dockRoot = built.dock.root;
|
|
return {
|
|
root: built.root,
|
|
dock: dockRoot,
|
|
buttons: dockRoot.querySelectorAll(".ui-btn"),
|
|
structures: built.root.children.find((c) => c.classList.contains("b05-structure-section")),
|
|
};
|
|
};
|
|
function snapshot() {
|
|
const { dock, buttons, structures } = nodes();
|
|
const shown = built.root.children.filter((c) => c !== dock && c.style.display !== "none");
|
|
buttons.forEach((b) => b.click());
|
|
return {
|
|
rootClass: built.root.className,
|
|
shownTitles: shown.map(titleOf),
|
|
structuresFirst: shown[0] === structures,
|
|
structuresHtml: html(structures),
|
|
dockFirstChild: html(dock.children[0]),
|
|
dockIsLast: built.root.children[built.root.children.length - 1] === dock,
|
|
dockLabels: buttons.map((b) => b.textContent),
|
|
dockActions: buttons.map((b) => b.dataset.dockAction),
|
|
dockDisabled: buttons.map((b) => b.disabled),
|
|
};
|
|
}
|
|
|
|
const first = nodes();
|
|
const views = { profile: snapshot() };
|
|
current = "cross";
|
|
built.setView("cross");
|
|
views.cross = snapshot();
|
|
const afterCross = nodes();
|
|
current = "profile";
|
|
built.setView("profile");
|
|
views.back = snapshot();
|
|
const afterBack = nodes();
|
|
const same = (a, b) =>
|
|
a.root === b.root &&
|
|
a.dock === b.dock &&
|
|
a.structures === b.structures &&
|
|
a.buttons.length === b.buttons.length &&
|
|
a.buttons.every((button, index) => button === b.buttons[index]);
|
|
|
|
process.stdout.write(
|
|
JSON.stringify({
|
|
views,
|
|
calls,
|
|
sameNodes: { cross: same(first, afterCross), back: same(first, afterBack) },
|
|
childCount: built.root.children.length,
|
|
sectionIds: {
|
|
profile: panel.panelSectionsFor("profile").map((d) => d.id),
|
|
cross: panel.panelSectionsFor("cross").map((d) => d.id),
|
|
},
|
|
}),
|
|
);
|