feat(B06): [상세] 수량표 칸 누름 = 그림 그 항목 강조(150-4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
This commit is contained in:
2026-10-09 19:05:48 +09:00
co-authored by Claude Opus 5.5
parent 1850fb2e32
commit 81380c6f3e
5 changed files with 336 additions and 7 deletions
+33 -7
View File
@@ -20,6 +20,7 @@ import type {
SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import { inferStationInterval, stationLabel } from "./B06_Section_UI_Section_Common";
import { DETAIL_TARGETS, highlightDetail } from "./B06_Section_UI_Cross_Detail_Pick";
/** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */
export interface CrossDetailHost {
@@ -110,11 +111,18 @@ function place(dialog: HTMLElement, card: HTMLElement | null): void {
/* ── 표(B07 칸 배치 그대로) ─────────────────────────────────────────────── */
function cell(tag: "th" | "td", text: string, span?: { col?: number; row?: number }): HTMLElement {
function cell(
tag: "th" | "td",
text: string,
span?: { col?: number; row?: number },
key?: string | null,
): HTMLElement {
const element = document.createElement(tag);
element.textContent = text;
if (span?.col) element.setAttribute("colspan", String(span.col));
if (span?.row) element.setAttribute("rowspan", String(span.row));
// 그림 짝이 있는 칸만 누름(150-4 · `_Cross_Detail_Pick`)
if (key && DETAIL_TARGETS[key]) element.dataset.key = key;
return element;
}
@@ -128,18 +136,18 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT
titleRow.append(cell("th", title, { col: 8 }));
const headRow = table.insertRow();
for (const [label, key] of layout.header)
headRow.append(cell("th", label), cell("td", value(key)));
headRow.append(cell("th", label, undefined, key), cell("td", value(key), undefined, key));
layout.left.forEach(([groupL, subL, keyL], index) => {
const row = table.insertRow();
if (groupL !== null && subL !== null) row.append(cell("th", groupL, { row: 2 }));
else if (groupL !== null) row.append(cell("th", groupL, { col: 2 }));
if (subL !== null) row.append(cell("th", subL));
row.append(cell("td", value(keyL)));
else if (groupL !== null) row.append(cell("th", groupL, { col: 2 }, keyL));
if (subL !== null) row.append(cell("th", subL, undefined, keyL));
row.append(cell("td", value(keyL), undefined, keyL));
const [groupM, subM, keyM] = layout.middle[index];
if (groupM !== null) row.append(cell("th", groupM, { row: 2 }));
row.append(cell("th", subM), cell("td", value(keyM)));
row.append(cell("th", subM, undefined, keyM), cell("td", value(keyM), undefined, keyM));
const [labelR, keyR] = layout.right[index];
row.append(cell("th", labelR ?? ""), cell("td", value(keyR)));
row.append(cell("th", labelR ?? "", undefined, keyR), cell("td", value(keyR), undefined, keyR));
});
return table;
}
@@ -189,6 +197,22 @@ export function openCrossDetail(section: CrossSection): void {
tableBox.className = "b06-detail__table";
body.append(note, cad, tableBox);
// 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4)
let picked: string | null = null;
const showPicked = (): void => {
tableBox.querySelectorAll<HTMLElement>("[data-key]").forEach((el) => {
el.classList.toggle("is-picked", el.dataset.key === picked);
});
const svg = cad.querySelector("svg");
if (svg) highlightDetail(svg, state.section.design, picked);
};
tableBox.addEventListener("click", (event) => {
const key = (event.target as HTMLElement).closest<HTMLElement>("[data-key]")?.dataset.key;
if (!key) return;
picked = picked === key ? null : key;
showPicked();
});
const state: OpenDetail = {
stationId: section.station_id,
section,
@@ -197,6 +221,7 @@ export function openCrossDetail(section: CrossSection): void {
const svg = card?.querySelector("svg.b06-section__chart");
cad.replaceChildren(svg ? svg.cloneNode(true) : document.createTextNode("그림 없음"));
place(dialog, card);
showPicked();
},
loadTable() {
const projectId = host?.projectId;
@@ -216,6 +241,7 @@ export function openCrossDetail(section: CrossSection): void {
.then((sheet) => {
if (seq !== tableSeq || current !== state) return;
tableBox.replaceChildren(buildQuantityTable(sheet, title));
showPicked();
})
.catch((error: unknown) => {
if (seq !== tableSeq || current !== state) return;
@@ -0,0 +1,68 @@
/* =============================================================================
* B06_Section_UI_Cross_Detail_Pick.ts
* [상세] 수량표 칸 누름 → 그림 복제 안 그 항목 강조(150-4).
*
* 칸 키 → 그림 요소: 면적 띠(깍기 · 쌓기) · 사토장 선 · 중심 십자(높이 넷) · 보라 판정 선 구역
* (사면 · 노면 · 측구). 판정 선은 화면 좌표라 십자(x(0)) · `data-px-per-m` 로 m 로 되돌려
* `crossZoneAt` 으로 가름 — 측구 = 단면유형을 성토 양쪽으로 둬도 절토로 남는 자리.
* 편책 · 제근 · 표토제거는 늘 빈칸 = 누름 없음(짝 표에서 뺌).
* ponytail: 판정 선의 구조물 트림 여부는 복제에서 모름 → 자리로만 가름(트림 선은 놓인 구역을 따름).
* ========================================================================== */
import type { CrossDesign } from "./B06_Section_Api_Fetch";
import { crossZoneAt, type CrossZone } from "./B06_Section_UI_Cross_Design_Pick";
type LineZone = CrossZone | "ditch" | "cut_slope";
/** 칸 키 → 강조 대상(선택자 = 그 요소 · zone = 그 구역 판정 선). 없는 키 = 누름 없음. */
export const DETAIL_TARGETS: Readonly<Record<string, { selector: string } | { zone: LineZone }>> = {
ground: { selector: "g.b06-chart__center" },
planned: { selector: "g.b06-chart__center" },
fill: { selector: "g.b06-chart__center" },
cut: { selector: "g.b06-chart__center" },
cut_soil: { selector: "g.b06-chart__area--cut_soil" },
cut_rock: { selector: "g.b06-chart__area--cut_rock" },
embankment: { selector: "g.b06-chart__area--fill" },
spoil_fill: { selector: ".b06-chart__spoil-fill" },
ditch_soil: { zone: "ditch" },
ditch_rock: { zone: "ditch" },
benching: { zone: "fill" },
grading_fill: { zone: "fill" },
tree_removal_fill: { zone: "fill" },
fill_seeding: { zone: "fill" },
grading_cut: { zone: "cut_slope" },
tree_removal_cut: { zone: "cut_slope" },
cut_spraying: { zone: "cut_slope" },
road_compaction: { zone: "road" },
};
/** 판정 선 하나의 구역 — 측구 · 절토 사면 · 성토 · 도로. */
export function lineZone(design: CrossDesign, offsetM: number): LineZone {
const { zone } = crossZoneAt(design, offsetM);
if (zone !== "cut") return zone;
const ditch = crossZoneAt({ ...design, section_mode: "both_fill" }, offsetM).zone === "cut";
return ditch ? "ditch" : "cut_slope";
}
/** 그림 복제 안 강조 — 묻어 온 `.is-active` 를 지우고 `key` 대상만 켬(null = 모두 끔). */
export function highlightDetail(svg: Element, design: CrossDesign | undefined, key: string | null) {
svg.querySelectorAll(".is-active").forEach((el) => el.classList.remove("is-active"));
const target = key ? DETAIL_TARGETS[key] : undefined;
if (!target) return;
if ("selector" in target) {
svg.querySelectorAll(target.selector).forEach((el) => el.classList.add("is-active"));
return;
}
const center = svg.querySelector("g.b06-chart__center line");
const ppm = Number(svg.getAttribute("data-px-per-m"));
if (!design || !center || !(ppm > 0)) return;
const x0 = Number(center.getAttribute("x1"));
svg.querySelectorAll(".b06-chart__design-hit").forEach((hit) => {
const xs = (hit.getAttribute("points") ?? "")
.trim()
.split(/\s+/)
.map((pair) => Number(pair.split(",")[0]));
const mid = (xs[0] + xs[xs.length - 1]) / 2;
// x = 왼쪽 여백 + (최대 offset − offset) × ppm → offset = (x(0) − x) ÷ ppm
if (lineZone(design, (x0 - mid) / ppm) === target.zone) hit.classList.add("is-active");
});
}
@@ -87,3 +87,29 @@
text-align: left;
color: var(--color-text);
}
/* 표 칸 누름 ↔ 그림 강조(150-4) — 짝 있는 칸만 손가락 · 고른 칸 강조 */
.b06-detail__qtable [data-key] {
cursor: pointer;
}
.b06-detail__qtable [data-key]:hover,
.b06-detail__qtable .is-picked {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
}
/* 그림 복제에만 있는 강조 둘 — 중심 십자(높이 넷) · 사토장 선 */
.b06-detail__cad .b06-chart__center.is-active line {
stroke: var(--color-warning);
stroke-width: 3;
}
.b06-detail__cad .b06-chart__spoil-fill.is-active {
stroke-width: 4;
}
/* 판정 선은 복제에서 누를 일 없음 — 손가락 지움 */
.b06-detail__cad .b06-chart__design-hit,
.b06-detail__cad .b06-chart__area > polygon {
cursor: default;
}
+131
View File
@@ -0,0 +1,131 @@
/* PLAN 150-4 단위검증 헬퍼 — [상세] 수량표 칸 → 그림 복제 강조(`B06_Section_UI_Cross_Detail_Pick.ts`).
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌. 짝 = `test_150_detail_pick.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 resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base);
function loadTs(full) {
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 (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);
cache.set(full, box.exports);
return box.exports;
}
const pick = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Detail_Pick.ts"));
// ── 그림 복제 흉내 — 평평한 요소 목록 · 「tag.class」 · 「.class」 · 「g.class line」 만 ──
function node(tag, classes, attrs = {}, parent = null) {
const set = new Set(classes);
return {
tag,
parent,
set,
getAttribute: (name) => attrs[name] ?? null,
classList: {
add: (c) => set.add(c),
remove: (c) => set.delete(c),
contains: (c) => set.has(c),
},
};
}
const one = (el, part) => {
const [tag, ...cls] = part.split(".");
return (!tag || el.tag === tag) && cls.every((c) => el.set.has(c));
};
const matches = (el, selector) => {
const parts = selector.split(" ");
if (!one(el, parts.at(-1))) return false;
return parts.length === 1 || (el.parent !== null && one(el.parent, parts[0]));
};
function fakeSvg(ppm, x0) {
const center = node("g", ["b06-chart__center"]);
const els = [
center,
node("line", [], { x1: String(x0) }, center),
node("g", ["b06-chart__area", "b06-chart__area--cut_soil"]),
node("g", ["b06-chart__area", "b06-chart__area--cut_rock", "b06-chart__area--cut_rr"]),
node("g", ["b06-chart__area", "b06-chart__area--fill", "is-active"]), // 카드에서 묻어 온 강조
node("polyline", ["b06-chart__spoil-fill"]),
];
// 판정 선: offset 구간(m) → 화면 x = x0 − offset × ppm
const hit = (a, b) =>
node("polyline", ["b06-chart__design-hit"], {
points: `${x0 - a * ppm},10 ${x0 - b * ppm},20`,
});
const hits = {
road: hit(-2, 2),
ditch: hit(2.5, 3.0),
cutSlope: hit(3.0, 6.0),
fillSlope: hit(-2.5, -6.0),
};
els.push(...Object.values(hits));
return {
els,
hits,
getAttribute: (name) => (name === "data-px-per-m" ? String(ppm) : null),
querySelectorAll: (sel) =>
els.filter((el) => sel.split(",").some((s) => matches(el, s.trim()))),
querySelector: (sel) => els.find((el) => matches(el, sel)) ?? null,
};
}
const design = {
road_edges: {
left: { offset_m: 2.5, elevation_m: 99.95 },
right: { offset_m: -2.5, elevation_m: 99.95 },
},
ditch: { type: "standard", top_width_m: 0.5, bottom_width_m: 0.2, depth_m: 0.3 },
ditch_side: "left",
ditch_enabled: true,
section_mode: "left_cut",
};
const activeOf = (svg) => {
const names = [];
for (const [name, el] of Object.entries(svg.hits)) if (el.set.has("is-active")) names.push(name);
for (const el of svg.els)
if (el.set.has("is-active") && !el.set.has("b06-chart__design-hit"))
names.push(
[...el.set].find(
(c) => c.includes("--") || c === "b06-chart__center" || c === "b06-chart__spoil-fill",
),
);
return names;
};
const out = { targets: Object.keys(pick.DETAIL_TARGETS), zone: {}, active: {} };
out.zone = {
road: pick.lineZone(design, 0),
ditch: pick.lineZone(design, 2.8),
cutSlope: pick.lineZone(design, 4),
fillSlope: pick.lineZone(design, -4),
};
for (const key of [
null,
"cut_soil",
"cut_rock",
"embankment",
"spoil_fill",
"ground",
"ditch_soil",
"grading_cut",
"grading_fill",
"road_compaction",
"fence",
]) {
const svg = fakeSvg(20, 300);
pick.highlightDetail(svg, design, key);
out.active[String(key)] = activeOf(svg);
}
process.stdout.write(JSON.stringify(out));
+78
View File
@@ -0,0 +1,78 @@
"""PLAN 150-4 — B06 [상세] 수량표 칸 누름 = 그림 복제 안 그 항목 강조.
짝: `B06_Section/B06_Section_UI_Cross_Detail_Pick.ts`(짝 표 · 구역) · `_UI_Cross_Detail.ts`(칸 키 · 누름).
실제 TS 를 `helper_150_detail_pick.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
import pytest
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import QUANTITY_VALUE_KEYS
HERE = os.path.dirname(__file__)
ROOT = os.path.join(HERE, "..", "..")
pytestmark = pytest.mark.skipif(
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
reason="프론트엔드 의존성 미설치",
)
# 늘 빈칸(`Engine_Cross_Quantity` 근거 없음) = 누름 없음
NO_PICK = {"fence", "grubbing", "topsoil_fill", "topsoil_cut"}
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_150_detail_pick.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_표_키_22_모두_짝_또는_누름_없음(out):
assert set(out["targets"]) | NO_PICK == set(QUANTITY_VALUE_KEYS)
assert not set(out["targets"]) & NO_PICK
def test_판정_선_구역(out):
assert out["zone"] == {
"road": "road",
"ditch": "ditch",
"cutSlope": "cut_slope",
"fillSlope": "fill",
}
def test_칸마다_그림_강조(out):
active = out["active"]
assert active["null"] == [] # 카드에서 묻어 온 강조도 지움
assert active["cut_soil"] == ["b06-chart__area--cut_soil"]
assert active["cut_rock"] == ["b06-chart__area--cut_rock"]
assert active["embankment"] == ["b06-chart__area--fill"]
assert active["spoil_fill"] == ["b06-chart__spoil-fill"]
assert active["ground"] == ["b06-chart__center"]
assert active["ditch_soil"] == ["ditch"]
assert active["grading_cut"] == ["cutSlope"]
assert active["grading_fill"] == ["fillSlope"]
assert active["road_compaction"] == ["road"]
assert active["fence"] == []
def test_상세_표_칸_키_누름_배선():
with open(
os.path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Detail.ts"), encoding="utf-8"
) as f:
src = f.read()
assert "element.dataset.key = key" in src
assert "highlightDetail(svg, state.section.design, picked)" in src
assert 'closest<HTMLElement>("[data-key]")' in src