- 보라 선 · 교차점 손잡이 · 암 경계선 마우스 올림 = 고른 모양(고름은 누를 때만) - 손잡이 제목 「사면 이동 (좌/우)」 · 노견 끝 = 원지반(±5 mm · edgeOnGround)이면 값 앞 「절·성토 없음 · 」 - 시험: 정확히 0 자리(멈춘 자리) 경우 — 절·성토 없음 · ◀↺▶ · 방향키 듣개 · 원 8 px Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
339 lines
12 KiB
JavaScript
339 lines
12 KiB
JavaScript
/* PLAN 370-2 단위검증 헬퍼 — B06 사면 이동 멈춤 · 교차점 손잡이 · 고름 이어받기.
|
||
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌.
|
||
* 입력 = argv[2] 표준단면 JSON(파이썬 config) · 마루 지반(우측 −4 m 너머 내리막). 짝 = `test_370_2_toe_stop.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 cache = new Map();
|
||
const STUBS = {
|
||
"@ui/ui_template_toast": { showToast: () => undefined },
|
||
"@ui/ui_template_elements": { showToast: () => undefined },
|
||
};
|
||
|
||
class FakeNode {
|
||
constructor(tag) {
|
||
this.tagName = String(tag);
|
||
this.children = [];
|
||
this.attrs = {};
|
||
this.listeners = {};
|
||
this.hidden = false;
|
||
this.title = "";
|
||
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),
|
||
};
|
||
this.style = { setProperty: () => {} };
|
||
}
|
||
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;
|
||
}
|
||
get isConnected() {
|
||
return true;
|
||
}
|
||
_detach(kid) {
|
||
if (kid.parent) kid.parent.children = kid.parent.children.filter((k) => k !== kid);
|
||
kid.parent = this;
|
||
}
|
||
append(...kids) {
|
||
kids.forEach((k) => {
|
||
if (typeof k === "string") return;
|
||
this._detach(k);
|
||
this.children.push(k);
|
||
});
|
||
}
|
||
prepend(...kids) {
|
||
kids.reverse().forEach((k) => {
|
||
this._detach(k);
|
||
this.children.unshift(k);
|
||
});
|
||
}
|
||
appendChild(kid) {
|
||
this.append(kid);
|
||
return kid;
|
||
}
|
||
insertBefore(kid, ref) {
|
||
this._detach(kid);
|
||
const at = ref ? this.children.indexOf(ref) : -1;
|
||
if (at < 0) this.children.push(kid);
|
||
else this.children.splice(at, 0, kid);
|
||
return kid;
|
||
}
|
||
remove() {
|
||
if (this.parent) this.parent.children = this.parent.children.filter((k) => k !== this);
|
||
}
|
||
querySelector() {
|
||
return 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);
|
||
}
|
||
hasAttribute(name) {
|
||
return name in this.attrs;
|
||
}
|
||
addEventListener(type, fn) {
|
||
(this.listeners[type] ??= []).push(fn);
|
||
}
|
||
click() {
|
||
const event = { stopPropagation: () => {}, preventDefault: () => {} };
|
||
(this.listeners.click ?? []).forEach((fn) => fn(event));
|
||
}
|
||
}
|
||
const docKeys = new Set(); // 방향키 듣개(379-1 · 정확히 0 자리)
|
||
globalThis.document = {
|
||
createElement: (tag) => new FakeNode(tag),
|
||
createElementNS: (_ns, tag) => new FakeNode(tag),
|
||
addEventListener: (type, fn) => type === "keydown" && docKeys.add(fn),
|
||
removeEventListener: (type, fn) => type === "keydown" && docKeys.delete(fn),
|
||
};
|
||
globalThis.window = { setTimeout, clearTimeout, addEventListener: () => {} };
|
||
|
||
function loadTs(file) {
|
||
const full = path.resolve(file);
|
||
if (cache.has(full)) return cache.get(full);
|
||
if (full.endsWith(".json")) {
|
||
const data = JSON.parse(fs.readFileSync(full, "utf8"));
|
||
cache.set(full, data);
|
||
return data;
|
||
}
|
||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||
compilerOptions: {
|
||
module: ts.ModuleKind.CommonJS,
|
||
target: ts.ScriptTarget.ES2020,
|
||
esModuleInterop: true,
|
||
},
|
||
}).outputText;
|
||
const box = { exports: {} };
|
||
cache.set(full, box.exports);
|
||
const localRequire = (request) => {
|
||
if (STUBS[request]) return STUBS[request];
|
||
if (request.endsWith(".css")) return {};
|
||
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
|
||
for (const [prefix, dir] of Object.entries(alias))
|
||
if (request.startsWith(prefix))
|
||
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
|
||
return loadTs(resolve(path.join(path.dirname(full), request)));
|
||
};
|
||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||
cache.set(full, box.exports);
|
||
return box.exports;
|
||
}
|
||
const resolve = (base) =>
|
||
[base, `${base}.ts`, path.join(base, "index.ts")].find(
|
||
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
|
||
) ?? `${base}.ts`;
|
||
|
||
const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`);
|
||
const pick = loadTs(B06("B06_Section_UI_Cross_Design_Pick"));
|
||
const fit = loadTs(B06("logic/B06_Section_UI_Cross_Fit"));
|
||
const { appendCrossDesignOverlay } = loadTs(B06("B06_Section_UI_Cross_Design"));
|
||
const { computeCrossDesign } = loadTs(
|
||
path.join(ROOT, "common_util", "common_util_cross_design.ts"),
|
||
);
|
||
const standard = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||
const out = {};
|
||
|
||
// 마루 지반 — 우측 −4 m 까지 평지(노면보다 높음 = 절토) · 그 너머 1:0.6 내리막
|
||
const samples = Array.from({ length: 49 }, (_, i) => {
|
||
const o = -12 + i * 0.5;
|
||
return { offset_m: o, elevation_m: 100.3 - 0.6 * Math.max(0, -o - 4), valid: true };
|
||
});
|
||
const groundAt = (o) => 100.3 - 0.6 * Math.max(0, -o - 4);
|
||
const designOf = (extendRight) => {
|
||
const result = computeCrossDesign(samples, 100, {
|
||
standard,
|
||
groundType: "soil",
|
||
sectionMode: "right_cut",
|
||
ditchSide: "right",
|
||
shoulderExtendRightM: extendRight,
|
||
});
|
||
// 화면 design 칸 이름(저장 · 응답과 같음)
|
||
return { ...result, shoulder_extend_right_m: extendRight, shoulder_extend_left_m: 0 };
|
||
};
|
||
const section = (extendRight) => ({
|
||
station_id: "S1",
|
||
chainage_m: 20,
|
||
samples,
|
||
design: designOf(extendRight),
|
||
});
|
||
|
||
// ① 멈춤 — 우측 노견 끝이 원지반을 지나는 한 걸음 사이 → 그 자리
|
||
const gapAt = (design, e) => {
|
||
const { left, right } = design.road_edges;
|
||
const o = -(Math.abs(right.offset_m) - (design.shoulder_extend_right_m ?? 0) + e);
|
||
const plane =
|
||
right.elevation_m +
|
||
((left.elevation_m - right.elevation_m) * (o - right.offset_m)) /
|
||
(left.offset_m - right.offset_m);
|
||
return groundAt(o) - plane;
|
||
};
|
||
const base = designOf(0);
|
||
let crossing = null;
|
||
for (let e = 0; e < 6; e = Math.round((e + 0.1) * 10) / 10) {
|
||
if (Math.sign(gapAt(base, e)) !== Math.sign(gapAt(base, e + 0.1))) {
|
||
crossing = [e, Math.round((e + 0.1) * 10) / 10];
|
||
break;
|
||
}
|
||
}
|
||
const stop = fit.extendStop(
|
||
samples,
|
||
designOf(crossing[0]).road_edges,
|
||
"right",
|
||
crossing[0],
|
||
crossing[1],
|
||
);
|
||
out.stop = {
|
||
crossing,
|
||
value: stop,
|
||
gap: Math.abs(gapAt(base, stop)),
|
||
noCross: fit.extendStop(samples, base.road_edges, "right", 0, 0.1),
|
||
// 멈춘 자리에서 다음 걸음은 그대로 감(다시 안 섬)
|
||
fromStop: fit.extendStop(
|
||
samples,
|
||
designOf(stop).road_edges,
|
||
"right",
|
||
stop,
|
||
Math.round((stop + 0.1) * 10) / 10,
|
||
),
|
||
leftNoGround: fit.extendStop([], base.road_edges, "left", 0, 0.1),
|
||
};
|
||
|
||
// ② 교차점 손잡이 — 쪽마다 원 · 늘 보임 · 키 toe:left|right · 고르면 ◀▶ 가 멈춤 자리로 보냄
|
||
const x = (o) => 300 - o * 20;
|
||
const y = (e) => 400 - (e - 100) * 20;
|
||
const attach = (sec, changes) => {
|
||
const layer = new FakeNode("g");
|
||
const from = layer.childElementCount;
|
||
appendCrossDesignOverlay(layer, sec.design, x, y, sec.samples, undefined);
|
||
layer.children.slice(from).forEach((el) => el.setAttribute(pick.DESIGN_LINE_ATTR, ""));
|
||
const picks = [];
|
||
const handle = pick.attachDesignLinePick({
|
||
layer,
|
||
design: sec.design,
|
||
x,
|
||
toDisplayY: y,
|
||
onPick: (i) => picks.push(i),
|
||
...pick.designLineMoves(sec, (_chainage, change) => changes.push(change)),
|
||
});
|
||
return { layer, handle, picks };
|
||
};
|
||
const changes = [];
|
||
const near = section(crossing[0]);
|
||
const { layer, handle, picks } = attach(near, changes);
|
||
const toes = layer.children.filter((k) => k.classList.contains("b06-chart__toe-hit"));
|
||
const keys = pick.designLineMoves(near, () => undefined).toes;
|
||
out.toe = {
|
||
count: toes.length,
|
||
centerClass: toes.every((t) => t.classList.contains("b06-chart__center-hit")),
|
||
sides: ["left", "right"].filter((s) => keys[s]),
|
||
rightOnGround:
|
||
keys.right && Math.abs(keys.right.elevation_m - groundAt(keys.right.offset_m)) < 1e-9,
|
||
rightCx: toes.length === 2 ? Number(toes[1].getAttribute("cx")) : null,
|
||
rightX: keys.right ? x(keys.right.offset_m) : null,
|
||
};
|
||
toes[1].click();
|
||
handle.setActive(picks[0]);
|
||
// 아래 띠 공용 칸(370-3) — 제목 · 버튼 줄(끝 = 값 글)
|
||
const cellOf = (h) => ({
|
||
title: h.cell.children[0].textContent,
|
||
value: h.cell.children[1].children.at(-1).textContent,
|
||
button: (slot) =>
|
||
h.cell.children[1].children.find((k) =>
|
||
k.classList.contains(`b06-cross-card__cell-btn--${slot}`),
|
||
),
|
||
});
|
||
const cell = cellOf(handle);
|
||
cell.button(x(-1) > x(0) ? "right" : "left").click(); // 화면 → 바깥(우 = −offset)
|
||
out.toePick = {
|
||
picked: picks,
|
||
active: toes[1].classList.contains("is-active"),
|
||
value: cell.value,
|
||
title: cell.title,
|
||
panelOpen: !handle.cell.classList.contains("is-empty"),
|
||
sent: changes.map((c) => c.shoulder_extend),
|
||
};
|
||
|
||
// ③ 고름 이어받기 — 우 절토 사면을 고른 뒤 마루 너머로 옮기면(키 cut → fill) 같은 쪽 손잡이
|
||
const cutIndex = handle.pieces.findIndex(
|
||
(p) =>
|
||
pick.pieceZone(near.design, p).zone === "cut" &&
|
||
pick.pieceZone(near.design, p).side === "right",
|
||
);
|
||
pick.setPickedLine("S1", null);
|
||
attach(near, []); // 키 적음
|
||
pick.setPickedLine("S1", cutIndex);
|
||
const before = pick.pickedLineAt("S1");
|
||
const far = section(crossing[1] + 1.0);
|
||
const after = attach(far, []);
|
||
const afterIndex = pick.pickedLineAt("S1");
|
||
out.inherit = {
|
||
cutIndex,
|
||
before,
|
||
farMode: far.design.section_mode,
|
||
afterIndex,
|
||
};
|
||
after.handle.setActive(afterIndex);
|
||
out.inherit.label = cellOf(after.handle).title;
|
||
|
||
// ④ 정확히 0 자리(379-1) — 노견 끝 = 원지반(±5 mm = 절·성토 없음) · 손잡이 · 누르면 사면처럼 ◀↺▶ · 방향키
|
||
// 조각 0 은 안 봄 — 그림 규칙(노견 끝 → 첫 접점 보호 구간)이 지반 위 짧은 선을 남김(브레인 A)
|
||
const zero = section(out.stop.value);
|
||
const zeroPicks = [];
|
||
const zeroLayer = new FakeNode("g");
|
||
appendCrossDesignOverlay(zeroLayer, zero.design, x, y, zero.samples, undefined);
|
||
zeroLayer.children.forEach((el) => el.setAttribute(pick.DESIGN_LINE_ATTR, ""));
|
||
const zeroHandle = pick.attachDesignLinePick({
|
||
layer: zeroLayer,
|
||
design: zero.design,
|
||
x,
|
||
toDisplayY: y,
|
||
onPick: (i) => zeroPicks.push(i),
|
||
...pick.designLineMoves(zero, () => undefined),
|
||
});
|
||
const zeroToes = zeroLayer.children.filter((k) => k.classList.contains("b06-chart__toe-hit"));
|
||
const rightToe = zeroToes.find((t) => Number(t.getAttribute("cx")) > x(0));
|
||
docKeys.clear();
|
||
rightToe?.click();
|
||
zeroHandle.setActive(zeroPicks[0]);
|
||
const zeroCell = cellOf(zeroHandle);
|
||
out.zero = {
|
||
bare: pick.designLineMoves(zero, () => undefined).bare,
|
||
nearBare: pick.designLineMoves(near, () => undefined).bare,
|
||
toe: !!rightToe,
|
||
radius: rightToe ? rightToe.getAttribute("r") : null,
|
||
title: zeroCell.title,
|
||
value: zeroCell.value,
|
||
buttons: ["left", "reset", "right"].filter((slot) => zeroCell.button(slot)),
|
||
keys: docKeys.size,
|
||
};
|
||
|
||
process.stdout.write(JSON.stringify(out));
|