fix(B06): 구간 물량 말풍선화, 패널 제목·캐럿 정리, 곡선 2종 축소

- 횡단 카드 측점 종류 표기에서 "일반 측점" 제거(BP/EP만 남김).
- 구간 물량을 패널 아래 칩 줄에서 유토곡선 위 둥근 사각 말풍선으로 옮긴다. 말풍선이 놓일
  가로 구간의 곡선 세로 범위를 실측해 위/아래 빈 곳 중 들어가는 쪽에 놓고, 좌우 후보를
  모두 재서 여유가 큰 조합을 고른다. HTML 오버레이 대신 SVG 안에 그려 X축 정렬을 지킨다.
- 패널 제목 문구 삭제. 헤더는 캐럿만 남긴 손잡이 행이며, 캐럿은 좌측 사이드 패널과 같은
  공용 규칙을 쓰되 방향만 상하(펼침 ▴ / 접힘 ▾)로 바꿨다.
- 측점 라벨을 종단 그래프 바닥에 붙이고 유토곡선 상하 여백을 좁혀 두 그래프가 측점 표기를
  공유하는 것처럼 보이게 한다. 좌우 여백(LONG_PAD)은 X축 정렬 때문에 그대로 둔다.
- 패널을 줄이면 그래프 폭까지 줄던 문제: SVG가 flex 기본값으로 세로로 눌리면서
  preserveAspectRatio(meet)가 가로도 같은 비율로 축소한 것. flex: none으로 막고 패널에
  최소 높이를 건다.
- 토사 단일 환산·환산 없음 곡선 삭제. 절토가 암반 경계선으로 갈려 나온 뒤로는 부피비를
  단일값이나 1로 넣은 곡선에 정보가 없다. 곡선 키 형식이 바뀌어 세션 저장 키를 v2로 올렸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 13:50:38 +09:00
co-authored by Claude Opus 5
parent 7bf2cff68e
commit bf7d780f9a
8 changed files with 253 additions and 172 deletions
@@ -245,13 +245,6 @@ export function createCrossSectionCard(
modePill.textContent = sectionModeLabel(section.design?.section_mode);
modePill.title = L("B06_Design_Mode_Legend");
const kind = document.createElement("span");
kind.textContent =
section.kind === "ep"
? L("B06_Profile_View_Kind_EP")
: section.kind === "bp"
? L("B06_Profile_View_Kind_BP")
: L("B06_Profile_View_Kind_Station");
const meta = document.createElement("div");
meta.className = "b06-cross-card__meta";
if (section.structure) {
@@ -261,7 +254,14 @@ export function createCrossSectionCard(
structure.title = `구조물: ${section.structure}`;
meta.append(structure);
}
meta.append(kind);
// 측점 종류는 시·종점(BP/EP)만 적는다 — "일반측점"은 대다수라 정보가 없다(2026-08-02 사용자 지시).
if (section.kind === "bp" || section.kind === "ep") {
const kind = document.createElement("span");
kind.textContent = L(
section.kind === "ep" ? "B06_Profile_View_Kind_EP" : "B06_Profile_View_Kind_BP",
);
meta.append(kind);
}
// 단면유형 pill은 지반유형 우측·우측 맞춤으로 배치(1번). 좌측 구분선은 지반유형 세그먼트에 준다(3번).
modePill.classList.add("b06-cross-card__mode--right");
@@ -284,9 +284,11 @@ export function createLongitudinalProfile(
y2: heightPx - LONG_PAD.bottom + 8,
class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`,
}),
// 측점 라벨은 그래프 바닥에 붙인다 — 바로 아래 유토곡선과의 틈을 줄여 두 그래프가
// 측점 표기를 공유하는 것처럼 보이게 한다(2026-08-02 사용자 지시).
svgText(offsetStationLabel(station.chainage_m, stationInterval, stationNumberOffset), {
x: stationX,
y: heightPx - 23,
y: heightPx - 7,
"text-anchor": "middle",
class: "b06-chart__station-label",
}),
@@ -26,8 +26,9 @@
* 나눈 `cut_soil_area_m2` / `cut_rock_area_m2`를 각각 자기 환산계수로 곱해 합산한다.
* 참고 도면(3공구)의 작업별 EA:RR 비율이 34:66~71:29로 흩어지는 것이 이 구조의 근거다.
*
* 지반유형 처리 3종(`ground_type`/`soil_only`/`natural`)은 환산 방식이 결과를 얼마나
* 흔드는지 눈으로 대조하기 위한 비교군으로 남겨 둔다.
* 예전에 있던 비교군 2종(토사 단일 환산 / 환산 없음)은 걷어냈다(2026-08-02 사용자 지시).
* 절토면적이 암반 경계선으로 갈리기 전에는 환산계수 차이를 눈으로 대조할 필요가 있었지만,
* 이제 지형이 면적으로 갈려 나오므로 **부피비를 단일값이나 1로 밀어 넣은 곡선은 정보가 없다.**
*
* 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md
* ========================================================================== */
@@ -87,56 +88,27 @@ export interface MassHaulResult {
/** 토량을 어디서 뽑았는지. `cross`가 정식(실측 단면적), `longitudinal`은 계획고 기반 개략값. */
export type MassHaulBasis = "cross" | "longitudinal";
/**
* 절토량에 어떤 환산계수를 물릴지.
* `ground_type` — 측점 지반유형별 C (현행 정식)
* `soil_only` — 전 구간 토사 C 단일 적용 (암 판정을 신뢰하지 않는 하한 케이스)
* `natural` — 환산 미적용. 절토 자연상태 그대로 성토 설계값과 뺀다(무보정 대조군)
*/
export type MassHaulGroundMode = "ground_type" | "soil_only" | "natural";
export interface MassHaulSeries {
/** `${basis}_${groundMode}` — 표시 토글 저장 키이자 CSS 변형 클래스 접미사. */
key: string;
/** 표시 토글 저장 키이자 CSS 변형 클래스 접미사(= `basis`). */
key: MassHaulBasis;
basis: MassHaulBasis;
groundMode: MassHaulGroundMode;
result: MassHaulResult;
}
const GROUND_TYPES: GroundType[] = ["soil", "ripping_rock", "blasting_rock"];
/** 곡선을 그릴 기준·환산 조합. 배열 순서가 곧 범례 순서다. */
const SERIES_MATRIX: Array<{ basis: MassHaulBasis; groundMode: MassHaulGroundMode }> = [
{ basis: "cross", groundMode: "ground_type" },
{ basis: "longitudinal", groundMode: "ground_type" },
{ basis: "cross", groundMode: "soil_only" },
{ basis: "longitudinal", groundMode: "soil_only" },
{ basis: "cross", groundMode: "natural" },
{ basis: "longitudinal", groundMode: "natural" },
];
/** 곡선을 그릴 산출 기준. 배열 순서가 곧 범례 순서다. */
const SERIES_BASES: MassHaulBasis[] = ["cross", "longitudinal"];
/** 기본으로 켜 두는 곡선 — 두 기준의 정식 환산만 보이고 나머지는 사용자가 켠다. */
export const MASS_HAUL_DEFAULT_VISIBLE = ["cross_ground_type", "longitudinal_ground_type"];
export function massHaulSeriesKey(basis: MassHaulBasis, groundMode: MassHaulGroundMode): string {
return `${basis}_${groundMode}`;
}
/** 기본으로 켜 두는 곡선. */
export const MASS_HAUL_DEFAULT_VISIBLE: MassHaulBasis[] = ["cross", "longitudinal"];
/** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */
function compactedFactor(conversion: EarthworkConversion, ground: GroundType): number {
function factorFor(conversion: EarthworkConversion, ground: GroundType): number {
const factor = conversion?.[ground]?.compacted;
return Number.isFinite(factor) && factor > 0 ? factor : 1;
}
function factorFor(
conversion: EarthworkConversion,
ground: GroundType,
groundMode: MassHaulGroundMode,
): number {
if (groundMode === "natural") return 1;
return compactedFactor(conversion, groundMode === "soil_only" ? "soil" : ground);
}
function emptyVolumes(): GroundVolumes {
return { soil: 0, ripping_rock: 0, blasting_rock: 0 };
}
@@ -193,11 +165,7 @@ function splitCut(design: CrossDesign | undefined): {
* 절반(`단면적 × Δd / 2`)을 자기 지반유형으로 가져간다. 두 절반을 합치면
* `(A_i + A_i+1) / 2 × Δd`가 되어 평균단면법과 정확히 일치한다.
*/
function integrate(
samples: AreaSample[],
conversion: EarthworkConversion,
groundMode: MassHaulGroundMode,
): MassHaulResult | null {
function integrate(samples: AreaSample[], conversion: EarthworkConversion): MassHaulResult | null {
if (samples.length < 2) return null;
const points: MassHaulPoint[] = [
@@ -237,12 +205,12 @@ function integrate(
if (soilM3 > 0) {
cutNatural.soil += soilM3;
segmentCutSoil += soilM3;
segmentCut += soilM3 * factorFor(conversion, "soil", groundMode);
segmentCut += soilM3 * factorFor(conversion, "soil");
}
if (rockM3 > 0 && end.rock_kind) {
cutNatural[end.rock_kind] += rockM3;
segmentCutRock += rockM3;
segmentCut += rockM3 * factorFor(conversion, end.rock_kind, groundMode);
segmentCut += rockM3 * factorFor(conversion, end.rock_kind);
}
}
@@ -363,12 +331,12 @@ export function computeMassHaul(
crossSections: CrossSection[],
conversion: EarthworkConversion,
): MassHaulResult | null {
return integrate(crossAreaSamples(designedSections(crossSections)), conversion, "ground_type");
return integrate(crossAreaSamples(designedSections(crossSections)), conversion);
}
/**
* 비교용 곡선 묶음. 기준 2종 × 지반유형 처리 3종을 모두 계산한다.
* 계산이 불가능한 조합(설계 측점 부족, 종단 계획선 없음)은 조용히 빠진다.
* 비교용 곡선 묶음. 산출 기준 2종(횡단/종단)을 계산한다.
* 계산이 불가능한 기준(설계 측점 부족, 종단 계획선 없음)은 조용히 빠진다.
*/
export function computeMassHaulSeries(
longitudinal: LongitudinalSection,
@@ -381,10 +349,9 @@ export function computeMassHaulSeries(
longitudinal: longitudinalAreaSamples(longitudinal, sections),
};
const series: MassHaulSeries[] = [];
for (const { basis, groundMode } of SERIES_MATRIX) {
const result = integrate(sampleSets[basis], conversion, groundMode);
if (result)
series.push({ key: massHaulSeriesKey(basis, groundMode), basis, groundMode, result });
for (const basis of SERIES_BASES) {
const result = integrate(sampleSets[basis], conversion);
if (result) series.push({ key: basis, basis, result });
}
return series;
}
@@ -6,9 +6,9 @@
* (LONG_PAD)·누가거리 최댓값(longitudinalMaxChainage)을 종단도와 동일하게 받아,
* 같은 측점이 두 그래프에서 같은 가로 위치에 오도록 맞춘다.
*
* 기준(종단/단) 2종 × 지반유형 처리 3종의 곡선을 한 축에 겹쳐 그리고, 그래프 상단
* 오버레이 범례 버튼으로 켜고 끈다. **Y 스케일은 표시 여부와 무관하게 항상 전체 곡선
* 기준으로 잡는다** — 곡선을 껐다 켤 때마다 축이 움직이면 비교 자체가 불가능해진다.
* 산출 기준 2종(횡단/단)의 곡선을 한 축에 겹쳐 그리고, 그래프 아래 범례 버튼으로 켜고
* 끈다. **Y 스케일은 표시 여부와 무관하게 항상 전체 곡선 기준으로 잡는다** — 곡선을 껐다
* 켤 때마다 축이 움직이면 비교 자체가 불가능해진다.
*
* Y축은 데이터 범위 기반 자동 스케일이다. 참고 도면의 수직축척이 공구마다 다르므로
* (1공구 V=1:10,000 / 3공구 V=1:50,000) 고정 축척을 쓰면 안 된다.
@@ -18,7 +18,6 @@
import type { LongitudinalSection } from "./B06_wf3_ProfileCross_Api_Fetch";
import type {
MassHaulBasis,
MassHaulGroundMode,
MassHaulPoint,
MassHaulSeries,
} from "./B06_wf3_ProfileCross_UI_MassHaul";
@@ -37,24 +36,27 @@ export const MASS_HAUL_MIN_HEIGHT = 90;
/** 패널을 건드리지 않았을 때의 유토곡선 높이. */
export const MASS_HAUL_HEIGHT = 190;
/**
* 유토곡선 전용 상하 여백. 좌우는 종단면도와 **반드시 같아야**(LONG_PAD) X축이 맞으므로
* 건드리지 않고, 위아래만 좁힌다. 위쪽을 좁히는 이유는 바로 위 종단면도의 측점 라벨과
* 붙여 두 그래프가 측점 표기를 공유하는 것처럼 보이게 하기 위해서다(2026-08-02 사용자 지시).
* 아래쪽은 X축 제목을 종단면도 상단으로 합치면서 비었다.
*/
const MASS_PAD_TOP = 12;
const MASS_PAD_BOTTOM = 16;
const BASIS_LABEL: Record<MassHaulBasis, LocaleKey> = {
cross: "B06_MassHaul_Basis_Cross",
longitudinal: "B06_MassHaul_Basis_Long",
};
const GROUND_MODE_LABEL: Record<MassHaulGroundMode, LocaleKey> = {
ground_type: "B06_MassHaul_Ground_ByType",
soil_only: "B06_MassHaul_Ground_SoilOnly",
natural: "B06_MassHaul_Ground_Natural",
};
/** 범례·곡선에 함께 쓰는 이름. 색은 기준, 선 모양은 환산 방식을 뜻한다. */
/** 범례·곡선에 함께 쓰는 이름. 색이 곧 산출 기준이다. */
export function massHaulSeriesLabel(series: MassHaulSeries): string {
return `${L(BASIS_LABEL[series.basis])} · ${L(GROUND_MODE_LABEL[series.groundMode])}`;
return L(BASIS_LABEL[series.basis]);
}
function seriesClass(series: MassHaulSeries, base: string): string {
return `${base} ${base}--${series.basis} ${base}--${series.groundMode}`;
return `${base} ${base}--${series.basis}`;
}
/** 누가토량 값 표기 — 천 단위 구분 + 소수점 1자리. */
@@ -108,6 +110,118 @@ export function massHaulPointAt(
return bestGap <= 0.5 ? best : null;
}
interface CalloutPlacement {
anchorX: number;
anchorY: number;
left: number;
right: number;
top: number;
bottom: number;
/** 주어진 가로 구간에서 표시 중인 곡선이 지나는 세로 범위(없으면 null). */
curveYAt: (fromPx: number, toPx: number) => { top: number; bottom: number } | null;
}
const CALLOUT_LINE_H = 14;
const CALLOUT_PAD_X = 9;
const CALLOUT_PAD_Y = 7;
/** 말풍선과 곡선 사이 최소 틈(px). */
const CALLOUT_CLEARANCE = 6;
/** SVG는 자동 크기가 없어 폭을 어림해야 한다. 한글은 라틴 글자보다 넓다. */
function textWidth(value: string): number {
let width = 0;
for (const char of value) width += /[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(char) ? 11 : 6.1;
return width;
}
/**
* 선택 측점의 구간 물량 말풍선(둥근 사각형)을 **곡선과 겹치지 않는 자리에** 그린다.
*
* 말풍선이 놓일 가로 구간에서 곡선이 실제로 지나는 세로 범위를 먼저 재고, 그 위/아래
* 빈 곳 중 들어갈 수 있는 쪽에 놓는다(2026-08-02 사용자 지시). 오른쪽·왼쪽 자리를 모두
* 재서 여유가 큰 쪽을 고르며, 어느 쪽에도 안 들어가면 여유가 더 큰 쪽 끝에 붙인다.
*/
function appendVolumeCallout(
svg: SVGSVGElement,
point: MassHaulPoint,
stationInterval: number,
box: CalloutPlacement,
): void {
const lines = [
`${stationLabel(point.chainage_m, stationInterval)}` +
` · ${L("B06_MassHaul_Station_Net")} ${formatVolume(point.net_volume_m3)}` +
` · ${L("B06_MassHaul_Station_Cumulative")} ${formatVolume(point.cumulative_volume_m3)}`,
`${L("B06_Design_Cut_Soil_Area")} ${formatVolume(point.cut_soil_m3)}` +
` · ${L("B06_Design_Cut_Rock_Area")} ${formatVolume(point.cut_rock_m3)}` +
` · ${L("B06_MassHaul_CutCompacted")} ${formatVolume(point.cut_compacted_m3)}` +
` · ${L("B06_MassHaul_Fill")} ${formatVolume(point.fill_m3)} (㎥)`,
];
const width = Math.min(
Math.max(...lines.map(textWidth)) + CALLOUT_PAD_X * 2,
Math.max(box.right - box.left, 80),
);
const height = lines.length * CALLOUT_LINE_H + CALLOUT_PAD_Y * 2;
const clampX = (value: number): number =>
Math.min(Math.max(value, box.left), Math.max(box.left, box.right - width));
// 오른쪽 먼저, 안 되면 왼쪽 — 각 자리마다 위/아래 여유를 재서 가장 넉넉한 조합을 고른다.
let best: { x: number; y: number; room: number; fits: boolean } | null = null;
for (const candidateX of [clampX(box.anchorX + 14), clampX(box.anchorX - 14 - width)]) {
const band = box.curveYAt(candidateX, candidateX + width);
const roomAbove = (band ? band.top : box.bottom) - box.top;
const roomBelow = box.bottom - (band ? band.bottom : box.top);
for (const [room, top] of [
[roomAbove, (band ? band.top : box.bottom) - height - CALLOUT_CLEARANCE],
[roomBelow, (band ? band.bottom : box.top) + CALLOUT_CLEARANCE],
] as Array<[number, number]>) {
const fits = room >= height + CALLOUT_CLEARANCE;
const better = !best || (fits && !best.fits) || (fits === best.fits && room > best.room);
if (better) best = { x: candidateX, y: top, room, fits };
}
}
if (!best) return;
const boxY = Math.min(
Math.max(best.y, box.top + 2),
Math.max(box.top + 2, box.bottom - height - 2),
);
const group = svgElement("g", { class: "b06-masshaul__callout" });
// 꼬리는 앵커가 말풍선 가로 범위 안에 있을 때만 — 밖이면 지시선으로 잇는다.
const tailX = Math.min(Math.max(box.anchorX, best.x + 12), best.x + width - 12);
const below = boxY > box.anchorY;
if (box.anchorX >= best.x && box.anchorX <= best.x + width) {
const edge = below ? boxY : boxY + height;
const tip = below ? edge - 7 : edge + 7;
group.append(
svgElement("polygon", {
points: `${tailX - 6},${edge} ${tailX + 6},${edge} ${tailX},${tip}`,
class: "b06-masshaul__callout-tail",
}),
);
} else {
group.append(
svgElement("line", {
x1: box.anchorX,
y1: box.anchorY,
x2: box.anchorX < best.x ? best.x : best.x + width,
y2: boxY + height / 2,
class: "b06-masshaul__callout-leader",
}),
);
}
group.append(
svgElement("rect", { x: best.x, y: boxY, width, height, rx: 8, ry: 8 }),
...lines.map((line, index) =>
svgText(line, {
x: best.x + CALLOUT_PAD_X,
y: boxY + CALLOUT_PAD_Y + CALLOUT_LINE_H * (index + 0.8),
class: "b06-masshaul__callout-text",
}),
),
);
svg.append(group);
}
export function createMassHaulChart(
series: MassHaulSeries[],
visibleKeys: ReadonlySet<string>,
@@ -134,17 +248,17 @@ export function createMassHaulChart(
// 종단면도와 완전히 같은 X 매핑 — 여백·최댓값 모두 종단 렌더러와 공유한다.
const maxChainage = longitudinalMaxChainage(longitudinal);
const plotWidth = widthPx - LONG_PAD.left - LONG_PAD.right;
const plotHeight = heightPx - LONG_PAD.top - LONG_PAD.bottom;
const plotHeight = heightPx - MASS_PAD_TOP - MASS_PAD_BOTTOM;
const x = (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth;
const { min, max } = volumeRange(series);
const span = Math.max(max - min, 1e-6);
const y = (volume: number) => LONG_PAD.top + ((max - volume) / span) * plotHeight;
const y = (volume: number) => MASS_PAD_TOP + ((max - volume) / span) * plotHeight;
// 패널을 줄이면 그래프 몫이 60px대까지 내려간다 — 눈금 5개를 그대로 두면 라벨이 서로 겹친다.
const tickRatios = plotHeight < 110 ? [0, 0.5, 1] : [0, 0.25, 0.5, 0.75, 1];
for (const ratio of tickRatios) {
const gridY = LONG_PAD.top + ratio * plotHeight;
const gridY = MASS_PAD_TOP + ratio * plotHeight;
const value = max - ratio * span;
svg.append(
svgElement("line", {
@@ -201,16 +315,16 @@ export function createMassHaulChart(
marker.append(
svgElement("line", {
x1: stationX,
y1: LONG_PAD.top,
y1: MASS_PAD_TOP,
x2: stationX,
y2: heightPx - LONG_PAD.bottom,
y2: heightPx - MASS_PAD_BOTTOM,
class: "b06-chart__station-hit",
}),
svgElement("line", {
x1: stationX,
y1: LONG_PAD.top,
y1: MASS_PAD_TOP,
x2: stationX,
y2: heightPx - LONG_PAD.bottom,
y2: heightPx - MASS_PAD_BOTTOM,
class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`,
}),
);
@@ -240,25 +354,49 @@ export function createMassHaulChart(
);
}
// 선택 측점 강조 — 첫 표시 곡선 위을 찍어 아래 물량 표기 줄과 시선을 잇는다.
// 선택 측점 강조 — 곡선 위 점 + 구간 물량 말풍선.
const focus = banded ? massHaulPointAt(banded, longitudinal, selectedStationId) : null;
if (focus) {
const focusX = x(focus.chainage_m);
svg.append(
svgElement("circle", {
cx: x(focus.chainage_m),
cx: focusX,
cy: y(focus.cumulative_volume_m3),
r: 4,
class: "b06-masshaul__focus",
}),
);
appendVolumeCallout(svg, focus, stationInterval, {
anchorX: focusX,
anchorY: y(focus.cumulative_volume_m3),
left: LONG_PAD.left,
right: widthPx - LONG_PAD.right,
top: MASS_PAD_TOP,
bottom: heightPx - MASS_PAD_BOTTOM,
curveYAt: (fromPx, toPx) => {
// 말풍선이 놓일 가로 구간에서 곡선들이 실제로 지나는 세로 범위.
let lowest = Number.POSITIVE_INFINITY;
let highest = Number.NEGATIVE_INFINITY;
for (const entry of visible) {
for (const point of entry.result.points) {
const px = x(point.chainage_m);
if (px < fromPx || px > toPx) continue;
const py = y(point.cumulative_volume_m3);
lowest = Math.min(lowest, py);
highest = Math.max(highest, py);
}
}
return Number.isFinite(lowest) ? { top: lowest, bottom: highest } : null;
},
});
}
svg.append(
svgElement("line", {
x1: LONG_PAD.left,
y1: LONG_PAD.top,
y1: MASS_PAD_TOP,
x2: LONG_PAD.left,
y2: heightPx - LONG_PAD.bottom,
y2: heightPx - MASS_PAD_BOTTOM,
class: "b06-chart__axis",
}),
// X축 제목은 종단면도 상단 하나로 합쳤다 — 여기서 또 적으면 두 그래프가 별개 축으로 읽힌다.
@@ -310,43 +448,6 @@ export function createMassHaulLegend(
return legend;
}
/**
* 선택 측점의 **구간 물량** 표기 줄 (2026-08-02 사용자 지시).
* 횡단도를 고르든 종단도 측점을 고르든 같은 선택 경로를 타므로 여기 한 곳만 그리면 된다.
* 물량은 "직전 측점 → 이 측점" 구간 몫이며, 횡단도 카드는 면적(㎡)만 표시한다(역할 분리).
*/
export function createMassHaulStationInfo(
series: MassHaulSeries,
longitudinal: LongitudinalSection,
selectedStationId: string | null,
stationInterval: number,
): HTMLElement | null {
const point = massHaulPointAt(series, longitudinal, selectedStationId);
if (!point) return null;
const row = document.createElement("div");
row.className = "b06-masshaul__station";
const chips: Array<[string, string]> = [
[L("B06_MassHaul_Station_Segment"), stationLabel(point.chainage_m, stationInterval)],
[L("B06_Design_Cut_Soil_Area"), `${formatVolume(point.cut_soil_m3)}`],
[L("B06_Design_Cut_Rock_Area"), `${formatVolume(point.cut_rock_m3)}`],
[L("B06_MassHaul_CutCompacted"), `${formatVolume(point.cut_compacted_m3)}`],
[L("B06_MassHaul_Fill"), `${formatVolume(point.fill_m3)}`],
[L("B06_MassHaul_Station_Net"), `${formatVolume(point.net_volume_m3)}`],
[L("B06_MassHaul_Station_Cumulative"), `${formatVolume(point.cumulative_volume_m3)}`],
];
for (const [label, value] of chips) {
const chip = document.createElement("span");
chip.className = "b06-masshaul__chip";
const name = document.createElement("em");
name.textContent = label;
const amount = document.createElement("strong");
amount.textContent = value;
chip.append(name, amount);
row.append(chip);
}
return row;
}
/**
* 곡선 아래 총괄 요약 줄. 절토는 자연상태(내역 기준)와 환산 후(곡선 기준)를 함께 적는다.
* 곡선이 여러 개라 수치가 어느 곡선의 것인지 끝에 반드시 밝힌다.
@@ -37,7 +37,6 @@ import {
import {
createMassHaulChart,
createMassHaulLegend,
createMassHaulStationInfo,
createMassHaulSummary,
MASS_HAUL_HEIGHT,
MASS_HAUL_MIN_HEIGHT,
@@ -60,11 +59,11 @@ import {
export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl };
/**
* 헤더·범례줄·선택측점 물량줄·요약줄·테두리가 먹는 세로 공간. 패널 높이에서 이만큼 빼야
* 그래프 몫이 된다. (`.b06-section__panel-body`가 `overflow: hidden`이라 모자라면 아래가 잘린다.
* 헤더(캐럿 행)·범례줄·요약줄·테두리가 먹는 세로 공간. 패널 높이에서 이만큼 빼야 그래프
* 몫이 된다. (`.b06-section__panel-body`가 `overflow: hidden`이라 모자라면 아래가 잘린다.
* 범례는 CSS에서 한 줄 고정 가로 스크롤이라 개수가 늘어도 이 값이 흔들리지 않는다.)
*/
const PANEL_CHROME_PX = 160;
const PANEL_CHROME_PX = 130;
/** 손대지 않았을 때의 패널 높이 — 이 값이 축소 비례의 기준(H₀)이 된다. */
const BASE_PANEL_HEIGHT = LONG_HEIGHT + MASS_HAUL_HEIGHT + PANEL_CHROME_PX;
const MIN_LONG_HEIGHT = 110;
@@ -72,7 +71,9 @@ const MIN_PANEL_HEIGHT = MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT + PANEL_CHROME_P
const MAX_PANEL_HEIGHT_RATIO = 0.8;
const PANEL_HEIGHT_KEY = "b06:profile-panel-height";
const PANEL_COLLAPSED_KEY = "b06:profile-panel-collapsed";
const MASS_HAUL_VISIBLE_KEY = "b06:masshaul-visible";
// 곡선 키가 `${기준}_${환산}`에서 기준 하나로 바뀌었다 — 옛 세션값을 읽으면 전부 꺼진 것처럼
// 보이므로 저장 키의 판을 올려 갈아탄다.
const MASS_HAUL_VISIBLE_KEY = "b06:masshaul-visible-v2";
/** 켜 둔 곡선 목록은 세션에만 남긴다(패널 높이·접힘과 같은 규칙). */
function readVisibleSeries(): Set<string> {
@@ -164,11 +165,15 @@ export function createSectionView(
* 매 렌더마다 새로 만들면 리사이저 핸들과 접힘 상태가 초기화된다. */
const panel = document.createElement("section");
panel.className = "b06-section__panel ui-collapsible";
// 최소 높이는 TS 상수 하나로만 정의한다(세션에 저장된 옛 높이가 더 작아도 여기서 걸린다).
panel.style.minHeight = `${MIN_PANEL_HEIGHT}px`;
// 제목 문구는 없앴다(2026-08-02 사용자 지시). 헤더 행은 접기/펼치기 손잡이 겸 상태 표시줄이며,
// 캐럿은 공용 `ui-collapsible` 규칙이 그린다(방향만 B06 CSS에서 상하로 바꾼다).
const panelHeader = document.createElement("header");
panelHeader.className = "ui-collapsible__title";
const panelTitle = document.createElement("h3");
panelHeader.setAttribute("aria-label", L("B06_Profile_View_Longitudinal"));
const panelCount = document.createElement("span");
panelHeader.append(panelTitle, panelCount);
panelHeader.append(panelCount);
const chartWrap = document.createElement("div");
chartWrap.className = "b06-section__chart-wrap";
const panelBody = document.createElement("div");
@@ -338,24 +343,15 @@ export function createSectionView(
}
chartWrap.replaceChildren(...nodes);
panelTitle.textContent = `${L("B06_Profile_View_Longitudinal")} · ${L("B06_MassHaul_Title")}`;
panel.querySelector(".b06-masshaul__legend")?.remove();
panel.querySelector(".b06-masshaul__summary")?.remove();
panel.querySelector(".b06-masshaul__station")?.remove();
// 범례는 그래프 아래, 스크롤 컨테이너 밖에 둔다(곡선 폭에 관여하지 않게).
if (series.length) panelBody.append(createMassHaulLegend(series, visibleSeries, toggleSeries));
// 요약 수치는 켜 둔 곡선 중 첫 번째 것 — 곡선이 여러 개라 어느 것인지 요약 끝에 밝힌다.
// (선택 측점의 구간 물량은 곡선 위 말풍선이 맡는다.)
const summarySeries = series.find((entry) => visibleSeries.has(entry.key));
if (summarySeries) {
panelCount.textContent = "";
// 선택 측점의 구간 물량은 요약 위에 붙인다(횡단도는 면적만, 물량은 여기서 본다).
const stationInfo = createMassHaulStationInfo(
summarySeries,
detail.longitudinal,
selectedStationId,
cachedStationInterval,
);
if (stationInfo) panelBody.append(stationInfo);
panelBody.append(createMassHaulSummary(summarySeries));
} else {
panelCount.textContent = L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty");
@@ -81,11 +81,23 @@
font-size: var(--text-caption);
}
/* 제목 문구 없이 접기/펼치기 손잡이만 남은 행 — 캐럿을 가운데 두고 높이를 낮춘다. */
.b06-section__panel > header {
justify-content: center;
padding-block: 2px;
border-bottom: 1px solid var(--color-border);
}
.b06-section__panel > header h3,
/* 좌측 사이드 패널과 같은 캐럿을 쓰되 **방향만 상하로** 바꾼다(이 패널은 위로 접힌다).
펼침 = 위로 접는다(▴) / 접힘 = 아래로 편다(▾). */
.b06-section__panel > .ui-collapsible__title::after {
content: "▴";
}
.b06-section__panel.is-collapsed > .ui-collapsible__title::after {
content: "▾";
}
.b06-section__heading h3 {
font-size: var(--text-body);
}
@@ -106,6 +118,13 @@
max-width: none;
}
/* 상단 패널의 두 그래프는 세로로 눌리면 안 된다. flex 기본값(shrink: 1)으로 두면 패널을
줄였을 때 렌더 높이가 height 속성보다 작아지고, SVG는 preserveAspectRatio 기본값이 meet라
**가로까지 같은 비율로 축소**된다(그래프 폭이 줄어 보이던 원인). */
.b06-section__chart-wrap > .b06-section__chart {
flex: none;
}
/* 횡단도 줌·팬 영역(E-5) — 휠 줌/드래그 팬. */
.b06-cross-card__chart-wrap {
position: relative;
@@ -74,16 +74,6 @@
stroke: var(--color-success);
}
.b06-masshaul__curve--soil_only {
stroke-width: 1.6;
stroke-dasharray: 7 4;
}
.b06-masshaul__curve--natural {
stroke-width: 1.6;
stroke-dasharray: 2 3;
}
/* 곡선과 0선 사이 — 절토 우세/성토 우세 구간을 한눈에 가르는 옅은 면.
여러 곡선에 겹쳐 칠하면 서로 가리므로 렌더러가 맨 앞 곡선 하나에만 붙인다. */
.b06-masshaul__band {
@@ -135,22 +125,35 @@
color: var(--color-text-muted);
}
/* 선택 측점의 구간 물량 줄 — 총괄 요약과 구분되게 배경을 옅게 깐다. */
.b06-masshaul__station {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8) var(--spacing-16);
padding: var(--spacing-8) var(--spacing-16);
border-top: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-royal-amethyst) 7%, transparent);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
/* 선택 측점이 곡선의 어디인지 찍는 점 — 물량 줄과 시선을 잇는다. */
/* 선택 측점이 곡선의 어디인지 찍는 점 — 말풍선과 시선을 잇는다. */
.b06-masshaul__focus {
fill: var(--color-royal-amethyst);
stroke: var(--color-surface);
stroke-width: 1.5;
}
/* 선택 측점 구간 물량 말풍선. 렌더러가 곡선이 지나지 않는 자리를 골라 놓지만, 배치가
빡빡한 화면에서도 곡선이 비쳐 보이도록 배경을 완전 불투명으로 두지 않는다. */
.b06-masshaul__callout > rect {
fill: color-mix(in srgb, var(--color-surface-raised) 94%, transparent);
stroke: var(--color-royal-amethyst);
stroke-width: 1;
}
.b06-masshaul__callout-tail {
fill: color-mix(in srgb, var(--color-surface-raised) 94%, transparent);
stroke: var(--color-royal-amethyst);
stroke-width: 1;
}
.b06-masshaul__callout-leader {
stroke: var(--color-royal-amethyst);
stroke-width: 1;
stroke-dasharray: 3 2;
}
.b06-masshaul__callout-text {
fill: var(--color-text-body);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
+1 -8
View File
@@ -200,7 +200,6 @@ export const ui_locales_b2 = {
B06_Profile_Zoom_Reset: ["원래 크기", "Reset zoom"],
B06_Profile_View_Kind_BP: ["BP", "BP"],
B06_Profile_View_Kind_EP: ["EP", "EP"],
B06_Profile_View_Kind_Station: ["일반 측점", "Station"],
B06_Profile_View_NoLongitudinal: [
"표시할 종단면 데이터가 없습니다.",
"No longitudinal profile data to display.",
@@ -213,7 +212,6 @@ export const ui_locales_b2 = {
/* --- B06 유토곡선 (종단면도 아래 상단 패널) --- */
B06_MassHaul_Title: ["유토곡선", "Mass Haul Diagram"],
B06_MassHaul_YAxis: ["누가토량 (㎥)", "Cumulative volume (㎥)"],
B06_MassHaul_XAxis: ["BP 기준 누적거리", "Chainage from BP"],
B06_MassHaul_Empty: [
"측점에 지반유형을 지정하면 유토곡선이 표시됩니다.",
"The mass haul diagram appears once ground types are assigned to stations.",
@@ -233,15 +231,10 @@ export const ui_locales_b2 = {
2.다.(4)(마)는 측점별 횡단 단면적을 각각 근거로 삼는다. */
B06_MassHaul_Basis_Cross: ["횡단 기준", "Cross-section basis"],
B06_MassHaul_Basis_Long: ["종단 기준", "Profile basis"],
/* 절토량에 물리는 토량환산계수 처리 방식 (암반 경계선 분리 전 비교용). */
B06_MassHaul_Ground_ByType: ["지반유형별 환산", "Per ground type"],
B06_MassHaul_Ground_SoilOnly: ["토사 단일 환산", "Soil factor only"],
B06_MassHaul_Ground_Natural: ["환산 없음", "No conversion"],
B06_MassHaul_Legend_Title: ["표시할 유토곡선", "Visible mass haul curves"],
B06_MassHaul_Legend_Show: ["표시", "Show"],
B06_MassHaul_Legend_Hide: ["숨김", "Hide"],
/* 선택 측점의 구간 물량 — 직전 측점부터 이 측점까지의 몫이다. */
B06_MassHaul_Station_Segment: ["구간 물량", "Segment volume"],
/* 선택 측점의 구간 물량 말풍선 — 직전 측점부터 이 측점까지의 몫이다. */
B06_MassHaul_Station_Net: ["순토량", "Net volume"],
B06_MassHaul_Station_Cumulative: ["누가토량", "Cumulative"],