Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
274 lines
10 KiB
JavaScript
274 lines
10 KiB
JavaScript
/* PLAN 295 단위검증 헬퍼 — 횡단 카드 고르기 차례 · [상세] 모달 고르기 차례 · 첫 보기 · 구조물 선 안 나눔.
|
|
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌.
|
|
* 짝 = `test_295_pick_order_detail.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 = path.join(ROOT, "B06_Section");
|
|
const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8");
|
|
const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)();
|
|
globalThis.window = { sessionStorage: new Map(), localStorage: new Map() };
|
|
globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} };
|
|
|
|
const STUBS = {
|
|
"@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) },
|
|
"./B06_Section_UI_Section_Common": { CROSS_PAD },
|
|
};
|
|
const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base);
|
|
function loadTs(full) {
|
|
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
|
}).outputText;
|
|
const box = { exports: {} };
|
|
const localRequire = (request) => {
|
|
if (STUBS[request]) return STUBS[request];
|
|
if (request.endsWith(".css")) return {};
|
|
if (request.startsWith("@util/"))
|
|
return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6))));
|
|
return loadTs(resolve(path.join(path.dirname(full), request)));
|
|
};
|
|
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
|
return box.exports;
|
|
}
|
|
const P = loadTs(path.join(B06, "B06_Section_UI_Cross_Pick_Order.ts"));
|
|
const G = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Geom.ts"));
|
|
const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts"));
|
|
const H = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Home.ts"));
|
|
const out = {};
|
|
|
|
/* ── 카드: 흉내 층 ───────────────────────────────────────────────────── */
|
|
function node(name, classes, points) {
|
|
return {
|
|
name,
|
|
parent: null,
|
|
tagName: points && classes.includes("b06-chart__design-hit") ? "polyline" : "polygon",
|
|
classList: { contains: (c) => classes.includes(c) },
|
|
getAttribute: (a) => (a === "points" ? points : null),
|
|
};
|
|
}
|
|
function layer(kids) {
|
|
const listeners = [];
|
|
const self = {
|
|
children: [],
|
|
append: (...nodes) =>
|
|
nodes.forEach((n) => {
|
|
const at = self.children.indexOf(n);
|
|
if (at >= 0) self.children.splice(at, 1);
|
|
self.children.push(n);
|
|
}),
|
|
addEventListener: (type, fn, capture) => listeners.push({ type, fn, capture }),
|
|
fire: (type, event) => {
|
|
let stopped = false;
|
|
const full = { ...event, stopPropagation: () => (stopped = true) };
|
|
listeners.filter((l) => l.type === type && l.capture).forEach((l) => l.fn(full));
|
|
return stopped;
|
|
},
|
|
};
|
|
self.append(...kids);
|
|
return self;
|
|
}
|
|
const box = (x0, y0, x1, y1) => `${x0},${y0} ${x1},${y0} ${x1},${y1} ${x0},${y1}`;
|
|
const HIT = "b06-chart__culvert-revet-hit";
|
|
// 꽂힌 옛 차례 — 면적 띠(맨 아래) · 보라 선(구조물 판정면 앞) · 형상 · 도수로 띠 · 벽 · 관
|
|
const kids = [
|
|
node("band", ["b06-chart__area"], box(0, 0, 600, 300)),
|
|
node("slope", ["b06-chart__design-hit"], "400,100 500,200"),
|
|
node("top", ["b06-chart__design-hit"], "300,100 400,100"),
|
|
node("shape", ["b06-chart__shape-hit"], box(300, 100, 310, 110)), // 100
|
|
node("chute", [HIT, "b06-chart__chute-hit"], box(390, 90, 510, 210)), // 14400 — 성토 사면을 덮음
|
|
node("wall", [HIT], box(200, 100, 220, 300)), // 4000
|
|
node("pipe", [HIT], box(250, 250, 255, 255)), // 25
|
|
];
|
|
const L = layer(kids);
|
|
P.stackPickTargets(L);
|
|
out.cardOrder = L.children.map((n) => n.name);
|
|
// 누른 자리에서 맨 위 판정(뒤에서부터) — 다각형 안 · 선은 5 px 안
|
|
const inside = (p, pts) => {
|
|
let inn = false;
|
|
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
|
|
const [ax, ay] = pts[i];
|
|
const [bx, by] = pts[j];
|
|
if (ay > p[1] !== by > p[1] && p[0] < ((bx - ax) * (p[1] - ay)) / (by - ay) + ax) inn = !inn;
|
|
}
|
|
return inn;
|
|
};
|
|
const gap = (p, [a, b]) => {
|
|
const d = [b[0] - a[0], b[1] - a[1]];
|
|
const t = Math.max(
|
|
0,
|
|
Math.min(1, ((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / (d[0] ** 2 + d[1] ** 2)),
|
|
);
|
|
return Math.hypot(p[0] - a[0] - t * d[0], p[1] - a[1] - t * d[1]);
|
|
};
|
|
const ptsOf = (n) =>
|
|
n
|
|
.getAttribute("points")
|
|
.split(" ")
|
|
.map((q) => q.split(",").map(Number));
|
|
const topAt = (p, order) =>
|
|
[...order]
|
|
.reverse()
|
|
.find((n) =>
|
|
n.classList.contains("b06-chart__design-hit") ? gap(p, ptsOf(n)) <= 5 : inside(p, ptsOf(n)),
|
|
)?.name;
|
|
out.slopeClick = topAt([450, 150], L.children); // 띠 안 성토 사면 위
|
|
out.slopeClickOld = topAt([450, 150], kids); // 옛 차례
|
|
out.chuteClick = topAt([480, 120], L.children); // 띠 안 · 선 밖
|
|
out.pipeOverWall = topAt([252, 252], [...L.children]);
|
|
out.area = P.pointsArea(box(0, 0, 10, 20));
|
|
// 끌기 = 고름 아님
|
|
L.fire("pointerdown", { clientX: 10, clientY: 10 });
|
|
out.dragStopped = L.fire("click", { clientX: 20, clientY: 10 });
|
|
L.fire("pointerdown", { clientX: 10, clientY: 10 });
|
|
out.clickStopped = L.fire("click", { clientX: 11, clientY: 11 });
|
|
|
|
/* ── 모달: 고르기 차례 ───────────────────────────────────────────────── */
|
|
const sq = (x0, y0, x1, y1) => [
|
|
[x0, y0],
|
|
[x1, y0],
|
|
[x1, y1],
|
|
[x0, y1],
|
|
];
|
|
const big = sq(0, 0, 100, 100);
|
|
const small = sq(40, 40, 60, 60);
|
|
const edges = (ring) => ring.map((p, i) => [p, ring[(i + 1) % ring.length]]);
|
|
const paths = [...edges(big), ...edges(small)];
|
|
const pieces = paths.map((points) => ({ line: "design", points }));
|
|
const rings = [big, small];
|
|
const areas = [10000, 400];
|
|
const pick = (p, band = null) => S.pickAt(p, 1, paths, rings, areas, pieces, band);
|
|
out.modal = {
|
|
line: pick([50, 41]), // 작은 면 둘레 1 px
|
|
small: pick([50, 50]),
|
|
big: pick([20, 20]),
|
|
bandSmaller: pick([50, 50], 100),
|
|
bandBigger: pick([50, 50], 5000),
|
|
bandBigFace: pick([20, 20], 5000),
|
|
bandOnly: S.pickAt([500, 500], 1, paths, rings, areas, pieces, 50),
|
|
lineOverBand: pick([50, 41], 1),
|
|
};
|
|
|
|
/* ── 모달: 첫 보기 ───────────────────────────────────────────────────── */
|
|
const plot = { x: 50, y: 10, width: 500, height: 280 };
|
|
const center = [300, 150];
|
|
const drawn = [
|
|
[100, 120],
|
|
[500, 140],
|
|
[300, 360], // 아래로 210 — 큰 쪽
|
|
[200, 100],
|
|
[5000, 9000], // 가로 창 밖 = 셈 안 함
|
|
];
|
|
const home = H.detailHomeView(drawn, center, plot, 1, 16);
|
|
const map = ([x, y]) => [home.tx + home.scale * x, home.ty + home.scale * y];
|
|
out.home = {
|
|
scale: home.scale,
|
|
center: map(center),
|
|
plotCenter: [plot.x + plot.width / 2, plot.y + plot.height / 2],
|
|
fits: drawn.slice(0, 4).every(([x, y]) => {
|
|
const [sx, sy] = map([x, y]);
|
|
return (
|
|
sx >= plot.x - 1e-6 &&
|
|
sx <= plot.x + plot.width + 1e-6 &&
|
|
sy >= plot.y - 1e-6 &&
|
|
sy <= plot.y + plot.height + 1e-6
|
|
);
|
|
}),
|
|
flat: H.detailHomeView([[300, 150]], center, plot, 2, 16).scale,
|
|
};
|
|
|
|
/* ── 모달: 구조물 선 안 나눔 ─────────────────────────────────────────── */
|
|
// 상자(구조물 네 변 · 변마다 묶음) + 가로지르는 원지반선
|
|
const boxLines = edges(sq(0, 0, 10, 10)).map((points, group) => ({
|
|
line: "structure",
|
|
points,
|
|
group,
|
|
}));
|
|
const split = G.splitAtCrossings([
|
|
...boxLines,
|
|
{
|
|
line: "ground",
|
|
points: [
|
|
[-5, 5],
|
|
[15, 5],
|
|
],
|
|
},
|
|
]);
|
|
const left = split.findIndex(
|
|
(p) => p.line === "structure" && p.points.every(([x]) => Math.abs(x) < 1e-9),
|
|
);
|
|
const members = S.groupMembers(split, left);
|
|
const chosen = S.toggleMembers([], members, false);
|
|
out.structure = {
|
|
pieces: split.length,
|
|
groundPieces: split.filter((p) => p.line === "ground").length,
|
|
members: members.length,
|
|
chosen: chosen.length,
|
|
again: S.toggleMembers(chosen, members, false).length,
|
|
additiveOff: S.toggleMembers([...chosen, 99], members, true),
|
|
label: S.selectionLabel(
|
|
undefined,
|
|
chosen.map((i) => split[i]),
|
|
),
|
|
faces: G.findFaces(split)
|
|
.map((f) => Math.round(f.area * 100) / 100)
|
|
.sort(),
|
|
// 옛 저장 조각(구조물 선 아래 반쪽) — 그대로 다시 고름
|
|
oldPick: split.filter((p) => S.samePiece(p, { line: "structure", start: [0, 0], end: [0, 5] }))
|
|
.length,
|
|
wholeValue: G.piecesValue(
|
|
"length",
|
|
chosen.map((i) => G.pieceSource(split[i])),
|
|
{},
|
|
),
|
|
};
|
|
// collectPieces — 구조물 선(설계선 표식 없음)에 묶음 · 설계선은 없음
|
|
const axes = {
|
|
widthPx: 600,
|
|
heightPx: 300,
|
|
pixelsPerMeter: 10,
|
|
maxOffset: 20,
|
|
displayMax: 120,
|
|
elevationMid: 110,
|
|
exaggeration: 1,
|
|
};
|
|
const px = ([offset, elevation]) =>
|
|
[
|
|
CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter,
|
|
CROSS_PAD.top +
|
|
(axes.displayMax -
|
|
(axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration)) *
|
|
axes.pixelsPerMeter,
|
|
].join(",");
|
|
const el = (classes, points, attrs = {}) => ({
|
|
tagName: "polyline",
|
|
classList: { contains: (name) => classes.includes(name) },
|
|
hasAttribute: (name) => name in attrs,
|
|
getAttribute: (name) => (name === "points" ? points.map(px).join(" ") : (attrs[name] ?? null)),
|
|
});
|
|
const collected = S.collectPieces(
|
|
[
|
|
el(
|
|
["b06-chart__design-cross"],
|
|
[
|
|
[-10, 110],
|
|
[10, 110],
|
|
],
|
|
{ "data-design-line": "" },
|
|
),
|
|
el(
|
|
["b06-chart__design-cross"],
|
|
[
|
|
[0, 105],
|
|
[0, 115],
|
|
],
|
|
),
|
|
],
|
|
axes,
|
|
);
|
|
out.collected = collected.map((p) => ({ line: p.line, group: p.group ?? null }));
|
|
|
|
process.stdout.write(JSON.stringify(out));
|