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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user