- 공용 구조물 패널 종류 바꿈: 관 지점 ↔ 구조물이면 새로 놓기 폼으로 빠지던 것 → 옛 것 지움 → 우클릭 추가 길(addAt)로 같은 측점 · 옛 측점 조정값 버림(318-4) - 횡단 초안 맞춤: 일반 측점 자리(grid)는 종류가 바뀌어도 저장분(옛 관 그림)이 남던 것 → 구조물만 뺀 사본(지형 쪽 고친 값 그대로) + 새 종류 - 시험: test_319_kind_swap 8 · test_318 중심고 겹침 기대값 바꿈 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
505 lines
18 KiB
JavaScript
505 lines
18 KiB
JavaScript
/* PLAN 318 단위검증 헬퍼 — B06 횡단 카드: 단추 줄 빈자리 고름 · 중심고 Y축 상자 · 빈 아래 띠 ·
|
|
* 관 종류 바꿈 = 옛 측점 조정값 버림(세션 · 미리 뜬 횡단 · [저장] patch).
|
|
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림(서버 · 무거운 화면 모듈은 흉내) · 결과 = 표준출력 JSON 한 벌.
|
|
* 짝 = `test_318_cross_card.py`.
|
|
*/
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
|
|
|
const ROOT = path.join(__dirname, "..", "..");
|
|
const B06 = (name) => path.join(ROOT, "B06_Section", `${name}.ts`);
|
|
|
|
// ── 화면 그릇 흉내 — 쓰는 만큼만 ─────────────────────────────────────────────
|
|
class FakeNode {
|
|
constructor(tag) {
|
|
this.tagName = String(tag).toLowerCase();
|
|
this.children = [];
|
|
this.attrs = {};
|
|
this.listeners = {};
|
|
this.parent = null;
|
|
this.dataset = {};
|
|
this.style = { setProperty: () => {} };
|
|
this._text = "";
|
|
this._classes = new Set();
|
|
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, force) => {
|
|
const on = force === undefined ? !self._classes.has(name) : !!force;
|
|
on ? self._classes.add(name) : self._classes.delete(name);
|
|
return on;
|
|
},
|
|
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("");
|
|
}
|
|
get childElementCount() {
|
|
return this.children.length;
|
|
}
|
|
append(...kids) {
|
|
kids.forEach((kid) => {
|
|
if (typeof kid === "string") {
|
|
const text = new FakeNode("#text");
|
|
text._text = kid;
|
|
kid = text;
|
|
}
|
|
if (kid.parent) kid.parent.children = kid.parent.children.filter((k) => k !== kid);
|
|
kid.parent = this;
|
|
this.children.push(kid);
|
|
});
|
|
}
|
|
appendChild(kid) {
|
|
this.append(kid);
|
|
return kid;
|
|
}
|
|
prepend(...kids) {
|
|
const rest = this.children;
|
|
this.children = [];
|
|
this.append(...kids, ...rest);
|
|
}
|
|
replaceChildren(...kids) {
|
|
this.children.forEach((k) => (k.parent = null));
|
|
this.children = [];
|
|
this.append(...kids);
|
|
}
|
|
remove() {
|
|
if (this.parent) this.parent.children = this.parent.children.filter((k) => k !== this);
|
|
this.parent = null;
|
|
}
|
|
setAttribute(name, value) {
|
|
this.attrs[name] = String(value);
|
|
if (name === "class") this.className = value;
|
|
}
|
|
getAttribute(name) {
|
|
return name === "class" ? this.className : (this.attrs[name] ?? null);
|
|
}
|
|
removeAttribute(name) {
|
|
delete this.attrs[name];
|
|
}
|
|
hasAttribute(name) {
|
|
return name in this.attrs;
|
|
}
|
|
addEventListener(type, fn) {
|
|
(this.listeners[type] ??= []).push(fn);
|
|
}
|
|
removeEventListener() {}
|
|
matches(selector) {
|
|
return selector
|
|
.split(",")
|
|
.map((part) => part.trim())
|
|
.some((part) =>
|
|
part.startsWith(".") ? this._classes.has(part.slice(1)) : this.tagName === part,
|
|
);
|
|
}
|
|
closest(selector) {
|
|
for (let node = this; node; node = node.parent) if (node.matches(selector)) return node;
|
|
return null;
|
|
}
|
|
querySelectorAll(selector) {
|
|
const out = [];
|
|
const walk = (node) =>
|
|
node.children.forEach((kid) => {
|
|
if (kid.matches(selector)) out.push(kid);
|
|
walk(kid);
|
|
});
|
|
walk(this);
|
|
return out;
|
|
}
|
|
querySelector(selector) {
|
|
return this.querySelectorAll(selector)[0] ?? null;
|
|
}
|
|
/** 붙기 전 = 0 — 상자 폭은 어림으로 감. */
|
|
getComputedTextLength() {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/** 누름 — 눌린 자리에서 위로 올라가며 듣개를 부름(stopPropagation 이면 멈춤). */
|
|
function click(target) {
|
|
const event = {
|
|
type: "click",
|
|
target,
|
|
stopped: false,
|
|
stopPropagation() {
|
|
this.stopped = true;
|
|
},
|
|
preventDefault() {},
|
|
};
|
|
for (let node = target; node && !event.stopped; node = node.parent)
|
|
(node.listeners.click ?? []).forEach((fn) => fn(event));
|
|
return event;
|
|
}
|
|
|
|
class FakeStorage {
|
|
constructor() {
|
|
this.map = new Map();
|
|
}
|
|
get length() {
|
|
return this.map.size;
|
|
}
|
|
key(index) {
|
|
return [...this.map.keys()][index] ?? null;
|
|
}
|
|
getItem(key) {
|
|
return this.map.has(key) ? this.map.get(key) : null;
|
|
}
|
|
setItem(key, value) {
|
|
this.map.set(key, String(value));
|
|
}
|
|
removeItem(key) {
|
|
this.map.delete(key);
|
|
}
|
|
}
|
|
|
|
globalThis.window = {
|
|
sessionStorage: new FakeStorage(),
|
|
localStorage: new FakeStorage(),
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
setTimeout,
|
|
clearTimeout,
|
|
};
|
|
globalThis.sessionStorage = window.sessionStorage;
|
|
globalThis.localStorage = window.localStorage;
|
|
globalThis.requestAnimationFrame = () => 0;
|
|
globalThis.ResizeObserver = class {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
};
|
|
globalThis.document = {
|
|
createElement: (tag) => new FakeNode(tag),
|
|
createElementNS: (_ns, tag) => new FakeNode(tag),
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
documentElement: new FakeNode("html"),
|
|
};
|
|
|
|
const anything = () =>
|
|
new Proxy({}, { get: (_t, key) => (key === "__esModule" ? false : () => undefined) });
|
|
|
|
/**
|
|
* 로더 하나 — `real` 에 든 파일만 진짜로 읽고, 그 밖 상대 경로는 흉내(무거운 화면 사슬을 안 끌어옴).
|
|
* `stubs` 는 지정자 그대로 갈음.
|
|
*/
|
|
function makeLoader(real, stubs = {}) {
|
|
const cache = new Map();
|
|
const realSet = new Set(real.map((file) => path.resolve(file)));
|
|
const load = (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 need = (spec) => {
|
|
if (spec in stubs) return stubs[spec];
|
|
let target = null;
|
|
if (spec.startsWith("@util/")) target = path.join(ROOT, "common_util", `${spec.slice(6)}.ts`);
|
|
else if (spec.startsWith(".")) target = path.join(path.dirname(full), `${spec}.ts`);
|
|
if (target && realSet.has(path.resolve(target))) return load(target);
|
|
return anything();
|
|
};
|
|
new Function("exports", "module", "require", js)(box.exports, box, need);
|
|
return box.exports;
|
|
};
|
|
return load;
|
|
}
|
|
|
|
const out = {};
|
|
|
|
// ── 1. 중심고 Y축 상자(318-2) ──────────────────────────────────────────────
|
|
{
|
|
const load = makeLoader(
|
|
[
|
|
B06("B06_Section_UI_Cross_Axes"),
|
|
B06("B06_Section_UI_Section_Common"),
|
|
path.join(ROOT, "common_util", "common_util_svg.ts"),
|
|
],
|
|
{
|
|
"@config/config_frontend": { PIPE_TYPES: [] },
|
|
"../B05_Profile/B05_Profile_UI_IrregularStations": { PIPE_DISPLAY_NAME: {} },
|
|
},
|
|
);
|
|
const { createCrossAxes, IDENTITY_VIEW } = load(B06("B06_Section_UI_Cross_Axes"));
|
|
const { CROSS_PAD } = load(B06("B06_Section_UI_Section_Common"));
|
|
const base = {
|
|
widthPx: 480,
|
|
heightPx: 300,
|
|
pixelsPerMeter: 10,
|
|
maxOffset: 20,
|
|
displayMax: 890,
|
|
elevationMid: 880,
|
|
exaggeration: 1,
|
|
};
|
|
const draw = (centerZ) => {
|
|
const layer = new FakeNode("g");
|
|
createCrossAxes(layer, { ...base, centerZ })(IDENTITY_VIEW);
|
|
return layer;
|
|
};
|
|
const layer = draw(876.57);
|
|
const box = layer.querySelector(".b06-chart__center-box");
|
|
const tag = layer.querySelector(".b06-chart__center-text");
|
|
const centerY = CROSS_PAD.top + (base.displayMax - 876.57) * base.pixelsPerMeter;
|
|
const ticks = layer
|
|
.querySelectorAll("text")
|
|
.filter(
|
|
(t) => !t.classList.contains("b06-chart__center-text") && t.attrs["text-anchor"] === "end",
|
|
);
|
|
out.center = {
|
|
axis_x: CROSS_PAD.left,
|
|
box: box && {
|
|
x: Number(box.attrs.x),
|
|
width: Number(box.attrs.width),
|
|
y: Number(box.attrs.y),
|
|
height: Number(box.attrs.height),
|
|
rx: Number(box.attrs.rx),
|
|
},
|
|
center_y: centerY,
|
|
text: tag?.textContent ?? null,
|
|
text_classes: tag?.className ?? null,
|
|
text_x: tag ? Number(tag.attrs.x) : null,
|
|
tick_x: ticks.length ? Number(ticks[0].attrs.x) : null,
|
|
tick_near_center: ticks.filter((t) => Math.abs(Number(t.attrs.y) - 3 - centerY) < 14).length,
|
|
tick_count: ticks.length,
|
|
on_top: layer.children.slice(-2).map((node) => node.className), // 319-1 상자 · 글 = 맨 뒤(위)
|
|
hidden_when_out_of_view: draw(800).querySelector(".b06-chart__center-box") === null,
|
|
absent_without_center: draw(null).querySelector(".b06-chart__center-box") === null,
|
|
};
|
|
}
|
|
|
|
// ── 2. 아래 띠(318-2 · 318-3) — 중심고 글 없음 · 암 경계 맨 왼쪽 · 토사도 띠 남음 ─────
|
|
{
|
|
const control = (cls) => () => {
|
|
const node = new FakeNode("div");
|
|
node.className = cls;
|
|
return node;
|
|
};
|
|
const load = makeLoader([B06("B06_Section_UI_Cross_Card_Chrome")], {
|
|
"./B06_Section_UI_Cross_CutSlope": { buildCutSlopeControl: control("cutslope") },
|
|
"./B06_Section_UI_Cross_Design": {
|
|
buildRockBoundaryControl: control("rockb"),
|
|
buildDesignControls: () => ({ bar: new FakeNode("div"), groundSegment: new FakeNode("div") }),
|
|
sectionModeLabel: () => "",
|
|
},
|
|
"./B06_Section_UI_Cross_Fit": { fillSlopeLengths: () => [] },
|
|
"./B06_Section_UI_Section_Common": {
|
|
L: (key) => (key === "B06_Profile_View_CenterElevation" ? "중심고" : key),
|
|
stationLabel: () => "",
|
|
structureDisplayName: () => "",
|
|
},
|
|
});
|
|
const { appendCardFooter } = load(B06("B06_Section_UI_Cross_Card_Chrome"));
|
|
const footerOf = (preset) => {
|
|
const card = new FakeNode("article");
|
|
appendCardFooter(card, { center_z: 876.57, design: { geometry_preset: preset } }, {}, {});
|
|
return card.querySelector("footer");
|
|
};
|
|
const rock = footerOf("rock");
|
|
const soil = footerOf("soil");
|
|
out.footer = {
|
|
rock_first: rock?.children[0]?.className ?? null,
|
|
rock_classes: rock?.children.map((kid) => kid.className) ?? null,
|
|
rock_text: rock?.textContent ?? null,
|
|
soil_exists: soil !== null,
|
|
soil_children: soil?.childElementCount ?? null,
|
|
};
|
|
}
|
|
|
|
// ── 3. 단추 줄(318-1) — 빈자리 누름 = 카드 고름 · 조작 누름 = 안 넘김 ───────────
|
|
{
|
|
const load = makeLoader([B06("B06_Section_UI_Cross_Design")], {
|
|
"@ui/ui_template_locale": {
|
|
currentLanguageIndex: 0,
|
|
ui_locales: new Proxy({}, { get: (_t, key) => [String(key)] }),
|
|
},
|
|
});
|
|
const { buildDesignControls } = load(B06("B06_Section_UI_Cross_Design"));
|
|
const { bar } = buildDesignControls(
|
|
{
|
|
chainage_m: 100,
|
|
uphill_side: "left",
|
|
design: { ground_type: "soil", section_mode: "left_cut" },
|
|
},
|
|
() => {},
|
|
);
|
|
const card = new FakeNode("article");
|
|
let picked = 0;
|
|
card.addEventListener("click", () => (picked += 1));
|
|
card.append(bar);
|
|
const pickedBy = (node) => {
|
|
const before = picked;
|
|
click(node);
|
|
return picked - before;
|
|
};
|
|
const button = bar.querySelector("button");
|
|
const label =
|
|
bar.querySelectorAll("div").find((node) => !node.closest("button, details")) ?? null;
|
|
const summary = bar.querySelector("summary");
|
|
out.bar = {
|
|
empty: pickedBy(bar),
|
|
button: button ? pickedBy(button) : null,
|
|
legend: label ? pickedBy(label) : null,
|
|
summary: summary ? pickedBy(summary) : null,
|
|
};
|
|
}
|
|
|
|
// ── 4. 종류 바꿈 = 그 측점 세션 조정값 버림 · 비울 자리 적음(318-4) ─────────────
|
|
{
|
|
const previewCalls = [];
|
|
const load = makeLoader(
|
|
[
|
|
B06("B06_Section_Api_Culvert_Options"),
|
|
path.join(ROOT, "A00_Common", "b_page_state.ts"),
|
|
path.join(ROOT, "A00_Common", "design", "design_selection.ts"),
|
|
],
|
|
{
|
|
"./B06_Section_Section_Drafts": {
|
|
forgetPreviewAdjusts: (...args) => previewCalls.push([args[0], args[1], [...args[2]]]),
|
|
},
|
|
},
|
|
);
|
|
const mod = load(B06("B06_Section_Api_Culvert_Options"));
|
|
const store = window.sessionStorage;
|
|
const put = (key, value) => store.setItem(key, JSON.stringify(value));
|
|
const get = (key) => JSON.parse(store.getItem(key) ?? "null");
|
|
put("aislo:draft:culvertopt:P:7", { "100.00": { inlet_revet_length_m: 3 }, "140.00": { a: 1 } });
|
|
put("aislo:draft:culvertopt:P:8", { "100.00": { a: 1 } });
|
|
put("aislo:draft:culvertopt:Q:7", { "100.00": { a: 1 } });
|
|
put("aislo:draft:revetx:P:7", {
|
|
"100.00:inlet": { x: 1 },
|
|
"100.00:extra0": { x: 2 },
|
|
"140.00:inlet": { x: 3 },
|
|
});
|
|
put("aislo:draft:extrawall:P:7", { "100.00": 2, "b100.00": 1, "140.00": 1 });
|
|
put("aislo:draft:extraspan:P:7", { "100.00:extra0": { length_m: 1 } });
|
|
put("aislo:draft:revetlink:P:7", {
|
|
detached: { "100.00": true, "140.00": true },
|
|
grade: { "100.00": true },
|
|
});
|
|
put("aislo:draft:fordadjust:P:7", { "100.00": { w: 1 } });
|
|
put("aislo:draft:boxadjust:P:7", { "100.30": { w: 1 } });
|
|
put("aislo:draft:basinadjust:P:7", { "100.00": { d: 1 } });
|
|
put("aislo:draft:inletstruct:P:7", { "100.00": "basin" });
|
|
put("aislo:draft:crossw:P:7", { "100.00": 5 });
|
|
let reloads = 0;
|
|
mod.setStationAdjustReload(() => (reloads += 1));
|
|
mod.forgetStationAdjusts("P", 100, "pipe", "box_culvert");
|
|
out.forget = {
|
|
culvertopt: get("aislo:draft:culvertopt:P:7"),
|
|
culvertopt_other_route: get("aislo:draft:culvertopt:P:8"),
|
|
culvertopt_other_project: get("aislo:draft:culvertopt:Q:7"),
|
|
revetx: get("aislo:draft:revetx:P:7"),
|
|
extrawall: get("aislo:draft:extrawall:P:7"),
|
|
extraspan: get("aislo:draft:extraspan:P:7"),
|
|
revetlink: get("aislo:draft:revetlink:P:7"),
|
|
fordadjust: get("aislo:draft:fordadjust:P:7"),
|
|
boxadjust: get("aislo:draft:boxadjust:P:7"),
|
|
basinadjust: get("aislo:draft:basinadjust:P:7"),
|
|
inletstruct: get("aislo:draft:inletstruct:P:7"),
|
|
crossw: get("aislo:draft:crossw:P:7"),
|
|
kindreset: get("aislo:draft:kindreset:P"),
|
|
changed: mod.kindChangedChainages("P"),
|
|
preview_calls: previewCalls,
|
|
reloads,
|
|
};
|
|
mod.forgetStationAdjusts("P", 100, "box_culvert", "ford_bridge");
|
|
out.forget.second = get("aislo:draft:kindreset:P");
|
|
mod.forgetStationAdjusts("P", 100, "ford_bridge", "pipe");
|
|
out.forget.reverted = get("aislo:draft:kindreset:P");
|
|
const before = store.length;
|
|
mod.forgetStationAdjusts("P", 140, "pipe", "pipe");
|
|
out.forget.same_kind_untouched =
|
|
store.length === before && get("aislo:draft:culvertopt:P:7")["140.00"] !== undefined;
|
|
out.forget.keys = [...mod.STRUCTURE_ADJUST_KEYS];
|
|
}
|
|
|
|
// ── 5. 미리 뜬 횡단 사본 걷음(318-4) — 그 자리 조정값만 · 다른 칸 그대로 ─────────
|
|
const state = { draftPipes: null, savedPipes: [] };
|
|
const loadDrafts = makeLoader(
|
|
[
|
|
B06("B06_Section_Section_Drafts"),
|
|
path.join(ROOT, "common_util", "common_util_structure_station.ts"),
|
|
path.join(ROOT, "common_util", "common_util_culvert_sets.ts"),
|
|
],
|
|
{
|
|
"../B04_PreProcess/B04_PreProcess_Api_Fetch": {
|
|
fetchDetailPipePoints: async () => ({ pipe_points: state.savedPipes }),
|
|
},
|
|
"../B05_Profile/B05_Profile_Api_Structures": {
|
|
fetchStructures: async () => ({ structures: [] }),
|
|
fetchStructureTypes: async () => [],
|
|
readPendingStructures: () => null,
|
|
},
|
|
"../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => state.draftPipes },
|
|
"./B06_Section_Api_Fetch": {
|
|
previewStructureSections: async (_p, chainages) =>
|
|
chainages.map((at) => ({
|
|
chainage_m: at,
|
|
kind: "irregular",
|
|
design: { ground_type: "soil", ford_adjust: { w: 1 }, revet_adjust: { inlet: { x: 1 } } },
|
|
})),
|
|
},
|
|
},
|
|
);
|
|
|
|
// ── 6. B05 [저장](캐시 patch) — 종류 바꾼 측점 = structure_reset · 옛 조정값 안 실음 ─────
|
|
const loadStore = makeLoader(
|
|
[
|
|
B06("B06_Section_Section_Store"),
|
|
B06("B06_Section_Api_Culvert_Options"),
|
|
path.join(ROOT, "A00_Common", "b_page_state.ts"),
|
|
path.join(ROOT, "A00_Common", "design", "design_selection.ts"),
|
|
],
|
|
{
|
|
"./B06_Section_Cross_Refresh": {
|
|
USER_TOUCHED_KEYS: ["display_half_width_m", "ford_adjust", "revet_adjust", "berm"],
|
|
},
|
|
},
|
|
);
|
|
|
|
(async () => {
|
|
const { withDraftStations, forgetPreviewAdjusts } = loadDrafts(B06("B06_Section_Section_Drafts"));
|
|
const detail = { cross_sections: [{ chainage_m: 0, kind: "regular", design: {} }] };
|
|
state.draftPipes = [{ chainage_m: 50, facility: "box_culvert" }];
|
|
await withDraftStations(detail, "P", 7);
|
|
const at50 = () => detail.cross_sections.find((s) => Math.abs(s.chainage_m - 50) < 0.01);
|
|
const had = Object.keys(at50()?.design ?? {}).sort();
|
|
forgetPreviewAdjusts("P", 50, ["ford_adjust", "revet_adjust"]);
|
|
forgetPreviewAdjusts("OTHER", 0, ["ground_type"]);
|
|
out.preview = { had, after: Object.keys(at50()?.design ?? {}).sort() };
|
|
|
|
const { crossPatchesFromCache } = loadStore(B06("B06_Section_Section_Store"));
|
|
out.cache_patches = crossPatchesFromCache(
|
|
{
|
|
cross_sections: [
|
|
{ chainage_m: 100, design: { ford_adjust: { w: 1 }, display_half_width_m: 6 } },
|
|
{ chainage_m: 140, design: { ford_adjust: { w: 2 } } },
|
|
],
|
|
},
|
|
[100],
|
|
);
|
|
out.cache_patches_plain = crossPatchesFromCache({
|
|
cross_sections: [{ chainage_m: 140, design: { ford_adjust: { w: 2 } } }],
|
|
});
|
|
process.stdout.write(JSON.stringify(out));
|
|
})().catch((error) => {
|
|
process.stderr.write(String(error?.stack ?? error));
|
|
process.exit(1);
|
|
});
|