- 화면 검증에서 1+0 우 절토 사면 누름이 암 판정 선에 먹힘 · 그리는 차례만 아래로(키 차례 그대로) - test_370_3 판정 선 「설계선 판정 아래」 확인 · helper insertBefore 참조 자리 따름 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
314 lines
10 KiB
JavaScript
314 lines
10 KiB
JavaScript
/* PLAN 370-3 단위검증 헬퍼 — B06 암 경계선 고름 · 방향키 · 세로 끌기 · 아래 띠 공용 칸.
|
||
* `_Cross_Design_Pick.attachDesignLinePick` 을 그 자리에서 트랜스파일해 흉내 DOM 으로 돌림. 짝 = `test_370_3_rock_cell.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();
|
||
|
||
class Node {
|
||
constructor(tag) {
|
||
this.tagName = tag;
|
||
this.children = [];
|
||
this.attrs = {};
|
||
this.listeners = {};
|
||
this.hidden = false;
|
||
this.isConnected = true;
|
||
this._text = "";
|
||
this._cls = new Set();
|
||
const self = this;
|
||
this.classList = {
|
||
add: (...n) => n.forEach((c) => self._cls.add(c)),
|
||
toggle: (c, on) => ((on ?? !self._cls.has(c)) ? self._cls.add(c) : self._cls.delete(c)),
|
||
contains: (c) => self._cls.has(c),
|
||
};
|
||
}
|
||
get descendants() {
|
||
return this.children.flatMap((c) => [c, ...c.descendants]);
|
||
}
|
||
/** `.이름` 한 개만 — 패널 공용 부품이 슬롯 단추를 찾는 꼴. */
|
||
querySelector(sel) {
|
||
return this.descendants.find((n) => n._cls.has(sel.slice(1))) ?? null;
|
||
}
|
||
getClientRects() {
|
||
return this._cls.has("is-hidden") ? [] : [{}];
|
||
}
|
||
set className(v) {
|
||
this._cls = new Set(String(v).split(/\s+/).filter(Boolean));
|
||
}
|
||
set textContent(v) {
|
||
this._text = String(v);
|
||
}
|
||
get textContent() {
|
||
return this._text + this.children.map((c) => c.textContent).join("");
|
||
}
|
||
append(...kids) {
|
||
this.children.push(...kids);
|
||
}
|
||
insertBefore(kid, ref) {
|
||
this.children = this.children.filter((k) => k !== kid);
|
||
const at = ref ? this.children.indexOf(ref) : -1;
|
||
if (at < 0) this.children.push(kid);
|
||
else this.children.splice(at, 0, kid);
|
||
}
|
||
setAttribute(n, v) {
|
||
this.attrs[n] = String(v);
|
||
if (n === "class") this.className = v;
|
||
}
|
||
getAttribute(n) {
|
||
return this.attrs[n] ?? null;
|
||
}
|
||
hasAttribute(n) {
|
||
return n in this.attrs;
|
||
}
|
||
addEventListener(t, fn) {
|
||
(this.listeners[t] ??= []).push(fn);
|
||
}
|
||
click() {
|
||
(this.listeners.click ?? []).forEach((fn) => fn({ stopPropagation: () => undefined }));
|
||
}
|
||
}
|
||
const docListeners = {};
|
||
globalThis.document = {
|
||
createElement: (t) => new Node(t),
|
||
createElementNS: (_n, t) => new Node(t),
|
||
addEventListener: (t, fn) => (docListeners[t] ??= []).push(fn),
|
||
removeEventListener: (t, fn) => {
|
||
docListeners[t] = (docListeners[t] ?? []).filter((f) => f !== fn);
|
||
},
|
||
};
|
||
const key = (name) =>
|
||
[...(docListeners.keydown ?? [])].forEach((fn) =>
|
||
fn({ key: name, target: null, preventDefault: () => undefined }),
|
||
);
|
||
|
||
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 req = (r) =>
|
||
r.startsWith("@util/")
|
||
? loadTs(path.join(ROOT, "common_util", `${r.slice(6)}.ts`))
|
||
: loadTs(path.join(path.dirname(full), `${r}.ts`));
|
||
new Function("exports", "module", "require", js)(box.exports, box, req);
|
||
cache.set(full, box.exports);
|
||
return box.exports;
|
||
}
|
||
globalThis.Element = Node;
|
||
|
||
const pick = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Design_Pick.ts"));
|
||
const x = (o) => 100 - o * 12; // +offset = 화면 왼쪽
|
||
const y = (e) => 900 - (e - 100) * 12;
|
||
const line = (pts, structure = false) => {
|
||
const el = new Node("polyline");
|
||
el.setAttribute("class", "b06-chart__design-cross");
|
||
el.setAttribute("points", pts.map(([o, e]) => `${x(o)},${y(e)}`).join(" "));
|
||
if (!structure) el.setAttribute(pick.DESIGN_LINE_ATTR, "");
|
||
return el;
|
||
};
|
||
/** 방향키 한 번 — 막혔나(preventDefault) 돌려줌. */
|
||
/** 방향키 한 번 — 막혔나(preventDefault) 돌려줌. */
|
||
const press = (name) => {
|
||
let prevented = false;
|
||
[...(docListeners.keydown ?? [])].forEach((fn) =>
|
||
fn({ key: name, target: null, preventDefault: () => (prevented = true) }),
|
||
);
|
||
return prevented;
|
||
};
|
||
// 끌기 — window 듣개(놓을 때 click 한 번 막음 포함)
|
||
const winListeners = {};
|
||
globalThis.window = {
|
||
addEventListener: (t, fn) => (winListeners[t] ??= []).push(fn),
|
||
removeEventListener: (t, fn) => {
|
||
winListeners[t] = (winListeners[t] ?? []).filter((f) => f !== fn);
|
||
},
|
||
setTimeout: () => 0,
|
||
clearTimeout: () => undefined,
|
||
setInterval: () => 0,
|
||
clearInterval: () => undefined,
|
||
};
|
||
globalThis.setTimeout = () => 0;
|
||
const fire = (type, event) => [...(winListeners[type] ?? [])].forEach((fn) => fn(event));
|
||
|
||
/** 암 측점 카드 — 좌 사면 · 노면 · 우 사면 + 암 경계선 점선 두 토막(결측으로 끊김). */
|
||
function card(rockOn = true) {
|
||
docListeners.keydown = [];
|
||
const design = {
|
||
road_edges: {
|
||
left: { offset_m: 2, elevation_m: 100 },
|
||
right: { offset_m: -2, elevation_m: 100 },
|
||
},
|
||
ditch: null,
|
||
ditch_side: "left",
|
||
ditch_enabled: false,
|
||
section_mode: "both_fill",
|
||
};
|
||
const layer = new Node("g");
|
||
layer.getScreenCTM = () => ({ a: 1, d: 1 });
|
||
layer.append(
|
||
line([
|
||
[6, 97],
|
||
[2, 100],
|
||
]),
|
||
line([
|
||
[2, 100],
|
||
[-2, 100],
|
||
]),
|
||
line([
|
||
[-2, 100],
|
||
[-6, 97],
|
||
]),
|
||
);
|
||
const rockLine = (pts) => {
|
||
const el = new Node("polyline");
|
||
el.setAttribute("class", "b06-chart__rock-boundary");
|
||
el.setAttribute("points", pts.map(([o, e]) => `${x(o)},${y(e)}`).join(" "));
|
||
el.style = { setProperty: (k, v) => (el.styleSet = v) };
|
||
return el;
|
||
};
|
||
const rocks = [
|
||
rockLine([
|
||
[8, 96],
|
||
[1, 98],
|
||
]),
|
||
rockLine([
|
||
[-1, 98],
|
||
[-8, 96],
|
||
]),
|
||
];
|
||
layer.append(...rocks);
|
||
const offsets = [];
|
||
const resets = [];
|
||
const rock = {
|
||
legend: "암 경계선",
|
||
offsetM: -0.5,
|
||
stepM: 0.1,
|
||
upTitle: "위로",
|
||
downTitle: "아래로",
|
||
resetTitle: "기본",
|
||
adjust: (d) => offsets.push(Math.round(d * 100) / 100),
|
||
reset: () => resets.push(true),
|
||
};
|
||
const values = { left: 0, right: 0 };
|
||
const picks = [];
|
||
const handle = pick.attachDesignLinePick({
|
||
layer,
|
||
design,
|
||
x,
|
||
toDisplayY: y,
|
||
onPick: (i) => picks.push(i),
|
||
shift: { valueOf: (s) => values[s], set: (s, v) => (values[s] = v) },
|
||
stationId: "R1",
|
||
...(rockOn && { rock }),
|
||
});
|
||
const cell = handle.cell;
|
||
const slot = (name) => cell.querySelector(`.b06-cross-card__cell-btn--${name}`);
|
||
const state = () => ({
|
||
empty: cell.classList.contains("is-empty"),
|
||
title: cell.children[0].textContent,
|
||
value: cell.children[1].children.at(-1).textContent,
|
||
slots: ["left", "up", "reset", "down", "right"].filter((n) => !slot(n).hidden),
|
||
arrowSlots: ["left", "up", "reset", "down", "right"].filter((n) =>
|
||
slot(n).classList.contains(`b06-structure-panel__btn--${n}`),
|
||
),
|
||
});
|
||
const hits = layer.children.filter((k) => k.classList.contains("b06-chart__design-hit"));
|
||
const rockHit = hits.find((k) => k.classList.contains("b06-chart__rock-hit"));
|
||
if (rockHit) rockHit.style = { setProperty: (k, v) => (rockHit.styleSet = v) };
|
||
return { handle, cell, slot, state, hits, rockHit, rocks, offsets, resets, values, picks };
|
||
}
|
||
|
||
const out = {};
|
||
|
||
// ① 안 고름 — 공용 칸 = 암 ▲▼↺ · 값 · 방향키는 안 먹음(종단 계획고로 그대로)
|
||
{
|
||
const c = card();
|
||
c.handle.setActive(null);
|
||
out.idle = { ...c.state(), upPassed: !press("ArrowUp") };
|
||
c.slot("up").click();
|
||
c.slot("down").click();
|
||
c.slot("reset").click();
|
||
out.idle.offsets = c.offsets;
|
||
out.idle.resets = c.resets.length;
|
||
}
|
||
|
||
// ② 판정 선 — 점선 위 path 하나(끊긴 두 토막 · M 둘) · 보라 선 판정 클래스 · 키 rock
|
||
{
|
||
const c = card();
|
||
c.rockHit.click();
|
||
const index = c.picks[0];
|
||
out.hit = {
|
||
found: !!c.rockHit,
|
||
tag: c.rockHit?.tagName,
|
||
moves: (c.rockHit?.getAttribute("d").match(/M/g) ?? []).length,
|
||
below: c.hits.indexOf(c.rockHit) === 0, // 그리는 차례 = 보라 선 판정 아래(겹친 자리는 설계선)
|
||
picked: c.picks,
|
||
};
|
||
// ③ 고름 — 제목 · ▲▼↺ · ◀▶ 막음 · ▲▼ = 암만(종단으로 안 샘)
|
||
c.handle.setActive(index);
|
||
pick.setPickedLine("R1", index);
|
||
out.picked = {
|
||
...c.state(),
|
||
active: c.rockHit.classList.contains("is-active"),
|
||
leftBlocked: press("ArrowLeft"),
|
||
rightBlocked: press("ArrowRight"),
|
||
upTaken: press("ArrowUp"),
|
||
downTaken: press("ArrowDown"),
|
||
sideValues: { ...c.values },
|
||
key: pick.pickedLineAt("R1") === index,
|
||
};
|
||
out.picked.offsets = c.offsets;
|
||
// ④ 세로 끌기 — 위로 0.34 m(화면 px = m × 12) → 0.3 m 한 번 · 끄는 동안 점선 · 판정 선만 옮겨 보임
|
||
let stopped = false;
|
||
c.rockHit.listeners.pointerdown[0]({
|
||
button: 0,
|
||
clientX: 0,
|
||
clientY: 500,
|
||
preventDefault: () => undefined,
|
||
stopPropagation: () => (stopped = true),
|
||
});
|
||
fire("pointermove", { clientX: 0, clientY: 500 - 0.34 * 12 });
|
||
const during = { rock: c.rocks[0].styleSet, hit: c.rockHit.styleSet, value: c.state().value };
|
||
const before = c.offsets.length;
|
||
fire("pointerup", {});
|
||
out.drag = {
|
||
stopped,
|
||
during,
|
||
sent: c.offsets.slice(before),
|
||
clickSwallowed: (winListeners.click ?? []).length === 1,
|
||
};
|
||
// 풀면 다시 암 칸 · 방향키 안 먹음
|
||
c.handle.setActive(null);
|
||
out.released = { ...c.state(), upPassed: !press("ArrowUp") };
|
||
// 사면을 고르면 그 선 칸(제목 · ◀↺▶ · 값 한 줄)
|
||
const slope = c.handle.pieces.findIndex((p) => Math.max(p.from.offset, p.to.offset) > 3);
|
||
c.handle.setActive(slope);
|
||
out.slope = c.state();
|
||
pick.setPickedLine("R1", null);
|
||
}
|
||
|
||
// ⑤ 안 고른 끌기는 안 감(카드 팬) · 토사(암 없음) = 빈 칸 · 판정 선 없음
|
||
{
|
||
const c = card();
|
||
c.handle.setActive(null);
|
||
let stopped = false;
|
||
c.rockHit.listeners.pointerdown[0]({
|
||
button: 0,
|
||
clientY: 0,
|
||
preventDefault: () => undefined,
|
||
stopPropagation: () => (stopped = true),
|
||
});
|
||
out.idleDrag = { stopped };
|
||
const soil = card(false);
|
||
soil.handle.setActive(null);
|
||
out.soil = { ...soil.state(), rockHit: !!soil.rockHit };
|
||
}
|
||
|
||
process.stdout.write(JSON.stringify(out));
|