fix(B06): 횡단 면적을 EA/RR/BR 표로 바꾸고 강조 클릭 버블링 차단, 0선 눈금 표기

- 면적 하이라이트가 먹지 않던 원인: 값 칸·밴드 클릭이 카드까지 버블링돼 측점 선택이 다시
  걸리고, draw()가 카드를 새로 만들면서 방금 켠 강조가 즉시 지워졌다. stopPropagation 추가.
- 면적 표기를 칩 나열에서 절토/성토 2행 × EA·RR·BR·계 4열 표로 바꾼다. 이름은 도면 표기를
  그대로 쓰고, 0값이어도 항목을 유지해 카드끼리 비교가 되게 한다. 성토는 완성 단면이라
  지반유형으로 갈리지 않으므로 계 한 칸만 쓴다.
- 암반 밴드는 하나뿐이라 그 측점의 암종에 해당하는 칸만 강조 버튼이 된다.
- 유토곡선 0선에 Y축 눈금값 0을 적어 무슨 선인지 읽히게 한다(선 자체는 원래 상시 표시).
- 오버레이가 1줄에서 3행 표가 되어 상단 여유를 28 → 58px로 키웠다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 13:57:45 +09:00
co-authored by Claude Opus 5
parent bf7d780f9a
commit 2bd1c874ca
6 changed files with 189 additions and 66 deletions
@@ -162,7 +162,11 @@ export function appendCrossAreaBands(
if (!polygons.length) continue;
const group = svgElement("g", { class: `b06-chart__area b06-chart__area--${band.key}` });
for (const points of polygons) group.append(svgElement("polygon", { points }));
group.addEventListener("click", () => onSelect(band.key));
group.addEventListener("click", (event) => {
// 카드까지 올라가면 측점 선택이 다시 걸려 카드가 통째로 다시 그려진다 — 방금 켠 강조가 사라진다.
event.stopPropagation();
onSelect(band.key);
});
groups.set(band.key, group);
svg.append(group);
}
@@ -177,73 +181,153 @@ export function appendCrossAreaBands(
};
}
/** 절토 단면적을 도면 표기(EA 토사 / RR 리핑암 / BR 발파암)로 갈라 낸다. */
function cutBreakdown(design: CrossDesign): {
ea: number;
rr: number;
br: number;
total: number;
} {
const total = design.cut_area_m2;
// 구 데이터(분리 필드 없음)는 절토 전량을 측점 지반유형으로 돌린다 — 유토곡선 폴백과 같은 규칙.
if (design.cut_soil_area_m2 === undefined || design.cut_rock_area_m2 === undefined) {
const ground = design.ground_type;
return {
ea: ground === "soil" ? total : 0,
rr: ground === "ripping_rock" ? total : 0,
br: ground === "blasting_rock" ? total : 0,
total,
};
}
const rock = design.cut_rock_area_m2;
return {
ea: design.cut_soil_area_m2,
rr: design.cut_rock_kind === "ripping_rock" ? rock : 0,
br: design.cut_rock_kind === "blasting_rock" ? rock : 0,
total,
};
}
/** 표 한 칸. `key`가 없으면 강조 대상이 아닌 칸(성토의 EA/RR/BR 자리)이다. */
interface AreaCell {
key: CrossAreaKey | null;
value: number | null;
variant: string;
}
function appendCell(
row: HTMLTableRowElement,
cell: AreaCell,
onSelect: ((key: CrossAreaKey) => void) | undefined,
cells: Map<CrossAreaKey, HTMLElement[]>,
): void {
const td = document.createElement("td");
td.className = `b06-design__area b06-design__area--${cell.variant}`;
const text = cell.value === null ? "—" : cell.value.toFixed(2);
// 값이 0이어도 칸은 남긴다 — 측점마다 항목 수가 달라지면 카드끼리 비교가 안 된다.
if (!cell.key || !onSelect || cell.value === null) {
td.textContent = text;
row.append(td);
return;
}
const button = document.createElement("button");
button.type = "button";
button.textContent = text;
button.title = L("B06_Design_Area_Highlight");
button.setAttribute("aria-pressed", "false");
button.addEventListener("click", (event) => {
// 카드까지 올라가면 측점 선택이 다시 걸려 카드가 통째로 다시 그려진다 — 강조가 즉시 사라진다.
event.stopPropagation();
onSelect(cell.key as CrossAreaKey);
});
cells.set(cell.key, [...(cells.get(cell.key) ?? []), button]);
td.append(button);
row.append(td);
}
/**
* 절·성토 면적값 오버레이(E-4) — 그래프 중상단에 표시한다.
* `onSelect`가 오면 칩이 강조 토글 버튼이 된다(선택된 카드에서만 넘어온다).
* 절·성토 면적값 오버레이(E-4) — 그래프 중상단에 표(절토/성토 × EA·RR·BR·계)로 표시한다.
* 항목 이름은 도면 표기를 그대로 쓴다(EA 토사 / RR 리핑암 / BR 발파암).
* `onSelect`가 오면 값 칸이 강조 토글 버튼이 된다(선택된 카드에서만 넘어온다).
*/
export function buildAreaReadout(
design: CrossDesign | undefined,
onSelect?: (key: CrossAreaKey) => void,
): { root: HTMLElement; setActive: AreaHighlightSetter } {
const readout = document.createElement("div");
readout.className = "b06-cross-card__areas";
if (!design) {
const unset = document.createElement("span");
unset.className = "b06-design__area b06-design__area--unset";
const unset = document.createElement("div");
unset.className = "b06-cross-card__areas b06-design__area--unset";
unset.textContent = L("B06_Design_Unset");
readout.append(unset);
return { root: readout, setActive: () => undefined };
return { root: unset, setActive: () => undefined };
}
// 토사/암반 칩은 암 측점에서만 의미가 있다(토사 측점은 절토계와 같은 값이라 중복 표기).
const entries: Array<{ key: CrossAreaKey; label: string; value: number }> = [];
if (design.cut_rock_kind) {
entries.push(
{
key: "cut_soil",
label: L("B06_Design_Cut_Soil_Area"),
value: design.cut_soil_area_m2 ?? 0,
},
{
key: "cut_rock",
label: L("B06_Design_Cut_Rock_Area"),
value: design.cut_rock_area_m2 ?? 0,
},
);
const table = document.createElement("table");
table.className = "b06-cross-card__areas";
const head = document.createElement("tr");
for (const [text, tip] of [
["", ""],
[L("B06_Design_Area_EA"), L("B06_Design_Ground_Soil")],
[L("B06_Design_Area_RR"), L("B06_Design_Ground_Ripping")],
[L("B06_Design_Area_BR"), L("B06_Design_Ground_Blasting")],
[L("B06_Design_Area_Total"), ""],
]) {
const th = document.createElement("th");
th.textContent = text;
if (tip) th.title = tip;
head.append(th);
}
entries.push(
{ key: "cut_total", label: L("B06_Design_Cut_Area"), value: design.cut_area_m2 },
{ key: "fill", label: L("B06_Design_Fill_Area"), value: design.fill_area_m2 },
);
const thead = document.createElement("thead");
thead.append(head);
const chips = new Map<CrossAreaKey, HTMLElement>();
for (const entry of entries) {
const text = `${entry.label} ${entry.value.toFixed(2)}`;
if (!onSelect) {
const chip = document.createElement("span");
chip.className = `b06-design__area b06-design__area--${entry.key}`;
chip.textContent = text;
readout.append(chip);
continue;
}
const chip = document.createElement("button");
chip.type = "button";
chip.className = `b06-design__area b06-design__area--${entry.key}`;
chip.textContent = text;
chip.title = L("B06_Design_Area_Highlight");
chip.setAttribute("aria-pressed", "false");
chip.addEventListener("click", () => onSelect(entry.key));
chips.set(entry.key, chip);
readout.append(chip);
const cells = new Map<CrossAreaKey, HTMLElement[]>();
const cut = cutBreakdown(design);
const rockKind =
design.cut_rock_kind ?? (design.ground_type === "soil" ? null : design.ground_type);
const tbody = document.createElement("tbody");
const rows: Array<{ label: string; variant: string; cells: AreaCell[] }> = [
{
label: L("B06_Design_Cut_Area"),
variant: "cut_total",
cells: [
{ key: "cut_soil", value: cut.ea, variant: "cut_soil" },
// 암반 밴드는 하나뿐이다 — 그 측점의 암종에 해당하는 칸만 강조 버튼이 된다.
{ key: rockKind === "ripping_rock" ? "cut_rock" : null, value: cut.rr, variant: "cut_rr" },
{ key: rockKind === "blasting_rock" ? "cut_rock" : null, value: cut.br, variant: "cut_br" },
{ key: "cut_total", value: cut.total, variant: "cut_total" },
],
},
{
// 성토는 완성 단면(다짐 상태)이라 지반유형으로 갈리지 않는다 — 계 한 칸만 쓴다.
label: L("B06_Design_Fill_Area"),
variant: "fill",
cells: [
{ key: null, value: null, variant: "fill" },
{ key: null, value: null, variant: "fill" },
{ key: null, value: null, variant: "fill" },
{ key: "fill", value: design.fill_area_m2, variant: "fill" },
],
},
];
for (const row of rows) {
const tr = document.createElement("tr");
const th = document.createElement("th");
th.className = `b06-design__area b06-design__area--${row.variant}`;
th.textContent = row.label;
tr.append(th);
for (const cell of row.cells) appendCell(tr, cell, onSelect, cells);
tbody.append(tr);
}
table.append(thead, tbody);
return {
root: readout,
root: table,
setActive: (key) => {
for (const [chipKey, chip] of chips) {
const active = chipKey === key;
chip.classList.toggle("is-active", active);
chip.setAttribute("aria-pressed", String(active));
for (const [cellKey, elements] of cells) {
const active = cellKey === key;
for (const element of elements) {
element.classList.toggle("is-active", active);
element.setAttribute("aria-pressed", String(active));
}
}
},
};
@@ -38,7 +38,8 @@ import {
// 절·성토 값 오버레이(그래프 상단 고정)가 계획고 선을 가리는 드문 경우를 대비해, Y 플롯
// 최대값에 더하는 상단 여유(px). 오버레이·라벨은 손대지 않고 데이터를 그만큼 아래로 내린다.
const AREA_OVERLAY_HEADROOM_PX = 28;
// 오버레이가 칩 한 줄에서 표(머리글 + 절토 + 성토 3행)로 바뀌어 그만큼 키웠다.
const AREA_OVERLAY_HEADROOM_PX = 58;
interface CrossPlotMetrics {
sourceSamples: SectionSample[];
@@ -331,7 +331,8 @@ export function createMassHaulChart(
svg.append(marker);
}
// 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그다.
// 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그리고 눈금값도 따로 적는다.
// Y 범위 계산(`volumeRange`)이 0을 항상 품으므로 이 선은 어떤 노선에서도 화면에 남는다.
svg.append(
svgElement("line", {
x1: LONG_PAD.left,
@@ -340,6 +341,12 @@ export function createMassHaulChart(
y2: zeroY,
class: "b06-masshaul__zero",
}),
svgText("0", {
x: LONG_PAD.left - 9,
y: zeroY + 4,
"text-anchor": "end",
class: "b06-chart__tick b06-masshaul__zero-tick",
}),
);
// 뒤에 그린 곡선이 위로 오므로 범례 순서의 역순으로 그려 첫 곡선(정식)을 가장 위에 둔다.
@@ -521,37 +521,57 @@
padding: 1px 4px;
}
/* 절·성토 면적 오버레이(E-4) — 그래프 중상단, 배경색으로 그래프 선과 겹쳐도 가독. */
/* 절·성토 면적 오버레이(E-4) — 그래프 중상단, 배경색으로 그래프 선과 겹쳐도 가독.
절토/성토 × EA·RR·BR·계 표라서 칩을 늘어놓을 때보다 가로 폭을 덜 먹는다. */
.b06-cross-card__areas {
position: absolute;
top: var(--spacing-8);
left: 50%;
transform: translateX(-50%);
display: inline-flex;
gap: var(--spacing-8);
padding: 2px 8px;
padding: 2px 6px;
border-radius: var(--radius-inputs);
background: color-mix(in srgb, var(--color-surface) 82%, transparent);
background: color-mix(in srgb, var(--color-surface) 88%, transparent);
font-size: 0.72rem;
font-family: var(--font-mono);
white-space: nowrap;
border-collapse: collapse;
pointer-events: none;
}
.b06-cross-card__areas th,
.b06-cross-card__areas td {
padding: 0 5px;
text-align: right;
font-weight: var(--font-weight-regular);
}
.b06-cross-card__areas thead th {
color: var(--color-text-muted);
}
/* 행 이름(절토/성토)은 왼쪽 정렬 — 숫자 열과 구분된다. */
.b06-cross-card__areas tbody th {
text-align: left;
}
.b06-design__area--cut,
.b06-design__area--cut_total {
color: rgb(220 38 38);
}
/* 절토 내역 — 토사(지표면~암반 경계선)와 암반(경계선 아래)을 색으로 갈라 둔다. */
/* 절토 내역 — 토사(EA), 리핑암(RR), 발파암(BR)을 색으로 갈라 둔다. */
.b06-design__area--cut_soil {
color: rgb(217 119 6);
}
.b06-design__area--cut_rock {
.b06-design__area--cut_rr {
color: rgb(71 85 105);
}
.b06-design__area--cut_br {
color: rgb(15 23 42);
}
.b06-design__area--fill {
color: rgb(37 99 235);
}
@@ -560,20 +580,21 @@
color: var(--color-text-muted);
}
/* 선택된 카드에서만 이 버튼이 된다 — 부모가 pointer-events: none이라 여기서 되살린다. */
button.b06-design__area {
/* 선택된 카드에서만 값 칸이 버튼이 된다 — 부모가 pointer-events: none이라 여기서 되살린다. */
.b06-cross-card__areas button {
padding: 0 2px;
border: none;
border-radius: var(--radius-inputs);
background: none;
color: inherit;
font-family: inherit;
font-size: inherit;
cursor: pointer;
pointer-events: auto;
}
/* 켠 은 글자색을 유지한 채 같은 색으로 옅게 칠하고 테두리를 둘러 대비를 살린다. */
button.b06-design__area.is-active {
/* 켠 은 글자색을 유지한 채 같은 색으로 옅게 칠하고 테두리를 둘러 대비를 살린다. */
.b06-cross-card__areas button.is-active {
background: color-mix(in srgb, currentcolor 18%, transparent);
box-shadow: inset 0 0 0 1px currentcolor;
font-weight: var(--font-weight-medium);
@@ -86,13 +86,18 @@
fill: var(--color-success);
}
/* 0선: 절토 우세와 성토 우세의 경계라 격자보다 진하게. */
/* 0선: 절토 우세와 성토 우세의 경계라 격자보다 진하게. 어떤 노선에서도 반드시 보인다. */
.b06-masshaul__zero {
stroke: var(--color-text-secondary);
stroke-width: 1.2;
stroke-dasharray: 4 3;
}
.b06-masshaul__zero-tick {
fill: var(--color-text-secondary);
font-weight: var(--font-weight-medium);
}
.b06-masshaul__summary {
display: flex;
flex-wrap: wrap;
+5
View File
@@ -255,6 +255,11 @@ export const ui_locales_b2 = {
/* 절토 내역 — 지표면~암반 경계선이 토사, 그 아래가 암반(합=절토). */
B06_Design_Cut_Soil_Area: ["절토(토사)", "Cut (soil)"],
B06_Design_Cut_Rock_Area: ["절토(암반)", "Cut (rock)"],
/* 횡단 면적표 열 이름은 유토곡선 도면 표기를 그대로 쓴다(EA 토사 / RR 리핑암 / BR 발파암). */
B06_Design_Area_EA: ["EA", "EA"],
B06_Design_Area_RR: ["RR", "RR"],
B06_Design_Area_BR: ["BR", "BR"],
B06_Design_Area_Total: ["계 (㎡)", "Total (㎡)"],
B06_Design_Area_Highlight: ["누르면 해당 면적을 강조합니다", "Click to highlight this area"],
B06_Design_Fill_Area: ["성토", "Fill"],
B06_Design_Unset: ["미지정", "Not set"],