fix(B06): 0값 칸도 선택 가능, 성토 경계는 첫 교차까지, 접기 손잡이 치수 교정

- 면적표의 모든 칸을 버튼으로 만든다. 예전에는 그 측점의 암종과 맞지 않는 칸(값 0)을 막아
  칸마다 눌리는 자리가 달라졌다. 강조 키를 cut_rock 하나에서 cut_rr/cut_br 둘로 갈라, 표는
  늘 따로 서되 그림의 암반 밴드는 암종이 맞는 쪽을 눌렀을 때만 켜진다.
- 성토 밴드는 중심선에서 바깥으로 나가다 지면과 처음 만나는 곳까지만 유효하다. 그 너머에서
  지면이 다시 설계선 아래로 내려가 생기던 조각을 버린다(좌·우 각각 안쪽 조각만 유지).
- 접기 손잡이 치수를 좌측 사이드 손잡이(24×64, 오른쪽 모서리 둥금)의 시계방향 90° 회전값인
  64×24 · 아래쪽 모서리 둥금으로 교정한다. 리사이저 위치와 패널 여유 높이도 함께 맞췄다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 14:36:35 +09:00
co-authored by Claude Opus 5
parent df83ac6699
commit 24843c7143
3 changed files with 73 additions and 23 deletions
@@ -18,8 +18,15 @@
import type { CrossDesign, SectionSample } from "./B06_wf3_ProfileCross_Api_Fetch";
import { L, svgElement } from "./B06_wf3_ProfileCross_UI_Section_Common";
/** 강조 대상. `cut_total`은 토사·암반 두 밴드를 함께 켠다. */
export type CrossAreaKey = "cut_soil" | "cut_rock" | "cut_total" | "fill";
/**
* 강조 대상 = 면적표의 칸. `cut_total`은 토사·암반 두 밴드를 함께 켠다.
* `cut_rr`/`cut_br`은 표에서는 늘 따로 서지만 그림에서는 암반 밴드 **하나**를 가리키므로,
* 그 측점의 암종과 맞는 쪽을 눌렀을 때만 밴드가 켜진다(값이 0인 쪽은 눌려도 그림 변화 없음).
*/
export type CrossAreaKey = "cut_soil" | "cut_rr" | "cut_br" | "cut_total" | "fill";
/** 그림에 실제로 깔리는 밴드. 표의 칸(`CrossAreaKey`)보다 적다. */
type BandKey = "cut_soil" | "cut_rock" | "fill";
export type AreaHighlightSetter = (key: CrossAreaKey | null) => void;
@@ -43,6 +50,37 @@ function interpolator(points: Array<{ offset: number; value: number }>) {
};
}
/** 밴드 조각 하나. `from`/`to`는 그 조각이 덮는 편거리(offset) 범위다. */
interface BandPiece {
points: string;
from: number;
to: number;
}
/**
* 성토 밴드는 **중심선에서 바깥으로 나가다 지면과 처음 만나는 곳까지**만 유효하다.
* 그 너머에서 지면이 다시 설계선 아래로 내려가 생기는 조각은 성토부가 아니므로 버린다
* (2026-08-02 사용자 지시). 좌·우 각각 중심선에 가장 가까운 조각만 남긴다.
*/
function keepInnermost(pieces: BandPiece[]): BandPiece[] {
let left: BandPiece | null = null;
let right: BandPiece | null = null;
const kept: BandPiece[] = [];
for (const piece of pieces) {
if (piece.from <= 0 && piece.to >= 0) {
// 중심선을 물고 있는 조각은 좌우 어느 쪽으로도 첫 조각이라 그대로 둔다.
kept.push(piece);
continue;
}
if (piece.to < 0) {
if (!left || piece.to > left.to) left = piece;
} else if (!right || piece.from < right.from) right = piece;
}
if (left) kept.push(left);
if (right) kept.push(right);
return kept;
}
/**
* `top > bottom`인 구간만 잘라 폴리곤 점 문자열을 만든다.
* 두께가 0이 되는 지점을 끼워 넣어 밴드 끝이 지면선·설계선 교점에서 정확히 닫히게 한다.
@@ -53,16 +91,21 @@ function bandPolygons(
bottom: number[],
px: (offset: number) => number,
py: (elevation: number) => number,
): string[] {
const polygons: string[] = [];
): BandPiece[] {
const polygons: BandPiece[] = [];
let upper: string[] = [];
let lower: string[] = [];
let from = 0;
let to = 0;
const flush = (): void => {
if (upper.length >= 2) polygons.push([...upper, ...lower.reverse()].join(" "));
if (upper.length >= 2)
polygons.push({ points: [...upper, ...lower.reverse()].join(" "), from, to });
upper = [];
lower = [];
};
const add = (offset: number, high: number, low: number): void => {
if (!upper.length) from = offset;
to = offset;
upper.push(`${px(offset)},${py(high)}`);
lower.push(`${px(offset)},${py(low)}`);
};
@@ -142,7 +185,7 @@ export function appendCrossAreaBands(
const depth = soilDepth(design);
const rockLevels = depth === null ? null : groundLevels.map((level) => level - depth);
const bands: Array<{ key: CrossAreaKey; top: number[]; bottom: number[] }> = [
const bands: Array<{ key: BandKey; top: number[]; bottom: number[] }> = [
{ key: "fill", top: designLevels, bottom: groundLevels },
];
if (rockLevels) {
@@ -156,16 +199,19 @@ export function appendCrossAreaBands(
bands.push({ key: "cut_soil", top: groundLevels, bottom: designLevels });
}
const groups = new Map<CrossAreaKey, SVGGElement>();
// 표에서 암반 칸을 눌렀을 때 이 측점의 암종과 맞는 쪽만 밴드를 켠다.
const rockCell: CrossAreaKey = design.cut_rock_kind === "blasting_rock" ? "cut_br" : "cut_rr";
const groups = new Map<BandKey, SVGGElement>();
for (const band of bands) {
const polygons = bandPolygons(xs, band.top, band.bottom, x, toDisplayY);
if (!polygons.length) continue;
let pieces = bandPolygons(xs, band.top, band.bottom, x, toDisplayY);
if (band.key === "fill") pieces = keepInnermost(pieces);
if (!pieces.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 }));
for (const piece of pieces) group.append(svgElement("polygon", { points: piece.points }));
group.addEventListener("click", (event) => {
// 카드까지 올라가면 측점 선택이 다시 걸려 카드가 통째로 다시 그려진다 — 방금 켠 강조가 사라진다.
event.stopPropagation();
onSelect(band.key);
onSelect(band.key === "cut_rock" ? rockCell : band.key);
});
groups.set(band.key, group);
svg.append(group);
@@ -174,8 +220,11 @@ export function appendCrossAreaBands(
return (key) => {
for (const [bandKey, group] of groups) {
const active =
key === bandKey ||
(key === "cut_total" && (bandKey === "cut_soil" || bandKey === "cut_rock"));
key === "cut_total"
? bandKey === "cut_soil" || bandKey === "cut_rock"
: bandKey === "cut_rock"
? key === rockCell
: key === bandKey;
group.classList.toggle("is-active", active);
}
};
@@ -282,18 +331,17 @@ export function buildAreaReadout(
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",
// 값이 0이어도 누를 수 있다(2026-08-02 사용자 지시) — 그림에 없는 면적이면 강조만 켜지고
// 밴드는 그대로다. 칸마다 눌리는 자리가 달라지면 어느 칸이 눌리는지 매번 확인해야 한다.
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_rr", value: cut.rr, variant: "cut_rr" },
{ key: "cut_br", value: cut.br, variant: "cut_br" },
{ key: "cut_total", value: cut.total, variant: "cut_total" },
],
},
@@ -65,7 +65,7 @@ export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl };
* 그래프 몫이 된다. (`.b06-section__panel-body`가 `overflow: hidden`이라 모자라면 아래가
* 잘린다. 범례는 그래프 위에 겹치는 오버레이라 흐름 높이를 먹지 않는다.)
*/
const PANEL_CHROME_PX = 116;
const PANEL_CHROME_PX = 126;
/** 손대지 않았을 때의 패널 높이 — 이 값이 축소 비례의 기준(H₀)이 된다. */
const BASE_PANEL_HEIGHT = LONG_HEIGHT + MASS_HAUL_HEIGHT + PANEL_CHROME_PX;
const MIN_LONG_HEIGHT = 110;
@@ -60,8 +60,8 @@
잘려 나가 잡히지 않는다 — 안쪽에 붙여 전체가 클릭 영역으로 남게 한다. */
.b06-section__panel > .ui-resizer--vertical {
top: auto;
/* 하단 가운데 접기 손잡이(14px) 위에 얹는다 — 겹치면 어느 쪽이 잡히는지 헷갈린다. */
bottom: 14px;
/* 하단 가운데 접기 손잡이(24px) 위에 얹는다 — 겹치면 어느 쪽이 잡히는지 헷갈린다. */
bottom: var(--spacing-24);
height: 8px;
}
@@ -107,10 +107,12 @@
/* `ui-collapsible__title`이 flex + space-between을 걸어 두므로 아이콘을 가운데로 되돌린다. */
justify-content: center;
align-self: center;
/* 좌측 사이드 손잡이(24×64, 오른쪽 모서리 둥금)를 시계방향 90° 돌린 값 —
가로 64 × 세로 24, 아래쪽 모서리가 둥글다. */
width: var(--spacing-64);
height: 14px;
height: var(--spacing-24);
margin-top: auto;
border-radius: var(--radius-buttons) var(--radius-buttons) 0 0;
border-radius: 0 0 var(--radius-buttons) var(--radius-buttons);
}
.b06-section__panel-toggle::after {