diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index 25e6c2f6f..b60605153 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -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("[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("[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; diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts b/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts new file mode 100644 index 000000000..6912c05d2 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts @@ -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> = { + 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"); + }); +} diff --git a/B06_Section/B06_Section_UI_Style_Cross_Detail.css b/B06_Section/B06_Section_UI_Style_Cross_Detail.css index 556ec025c..39629a215 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Detail.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Detail.css @@ -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; +} diff --git a/resources/tester/helper_150_detail_pick.cjs b/resources/tester/helper_150_detail_pick.cjs new file mode 100644 index 000000000..acb3e4c02 --- /dev/null +++ b/resources/tester/helper_150_detail_pick.cjs @@ -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)); diff --git a/resources/tester/test_150_detail_pick.py b/resources/tester/test_150_detail_pick.py new file mode 100644 index 000000000..93004248f --- /dev/null +++ b/resources/tester/test_150_detail_pick.py @@ -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("[data-key]")' in src