From bf7d780f9afd3b685630f29913a98f843c8ee944 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 2 Aug 2026 13:50:38 +0900 Subject: [PATCH] =?UTF-8?q?fix(B06):=20=EA=B5=AC=EA=B0=84=20=EB=AC=BC?= =?UTF-8?q?=EB=9F=89=20=EB=A7=90=ED=92=8D=EC=84=A0=ED=99=94,=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EC=A0=9C=EB=AA=A9=C2=B7=EC=BA=90=EB=9F=BF=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC,=20=EA=B3=A1=EC=84=A0=202=EC=A2=85=20?= =?UTF-8?q?=EC=B6=95=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 횡단 카드 측점 종류 표기에서 "일반 측점" 제거(BP/EP만 남김). - 구간 물량을 패널 아래 칩 줄에서 유토곡선 위 둥근 사각 말풍선으로 옮긴다. 말풍선이 놓일 가로 구간의 곡선 세로 범위를 실측해 위/아래 빈 곳 중 들어가는 쪽에 놓고, 좌우 후보를 모두 재서 여유가 큰 조합을 고른다. HTML 오버레이 대신 SVG 안에 그려 X축 정렬을 지킨다. - 패널 제목 문구 삭제. 헤더는 캐럿만 남긴 손잡이 행이며, 캐럿은 좌측 사이드 패널과 같은 공용 규칙을 쓰되 방향만 상하(펼침 ▴ / 접힘 ▾)로 바꿨다. - 측점 라벨을 종단 그래프 바닥에 붙이고 유토곡선 상하 여백을 좁혀 두 그래프가 측점 표기를 공유하는 것처럼 보이게 한다. 좌우 여백(LONG_PAD)은 X축 정렬 때문에 그대로 둔다. - 패널을 줄이면 그래프 폭까지 줄던 문제: SVG가 flex 기본값으로 세로로 눌리면서 preserveAspectRatio(meet)가 가로도 같은 비율로 축소한 것. flex: none으로 막고 패널에 최소 높이를 건다. - 토사 단일 환산·환산 없음 곡선 삭제. 절토가 암반 경계선으로 갈려 나온 뒤로는 부피비를 단일값이나 1로 넣은 곡선에 정보가 없다. 곡선 키 형식이 바뀌어 세션 저장 키를 v2로 올렸다. Co-Authored-By: Claude Opus 5 (1M context) --- .../B06_wf3_ProfileCross_UI_Cross_View.ts | 16 +- .../B06_wf3_ProfileCross_UI_Longitudinal.ts | 4 +- .../B06_wf3_ProfileCross_UI_MassHaul.ts | 71 ++---- .../B06_wf3_ProfileCross_UI_MassHaul_View.ts | 223 +++++++++++++----- .../B06_wf3_ProfileCross_UI_Section_View.ts | 30 +-- .../B06_wf3_ProfileCross_UI_Style_Cross.css | 21 +- ...B06_wf3_ProfileCross_UI_Style_MassHaul.css | 51 ++-- ui_template/ui_template_locale_b2.ts | 9 +- 8 files changed, 253 insertions(+), 172 deletions(-) diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts index 51319352..598a4d7c 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts @@ -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"); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts index dc66fb10..3e7820ec 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts @@ -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", }), diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts index 614c4c06..368dec77 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts @@ -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; } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts index 6b7649ac..54e554a9 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_View.ts @@ -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 = { cross: "B06_MassHaul_Basis_Cross", longitudinal: "B06_MassHaul_Basis_Long", }; -const GROUND_MODE_LABEL: Record = { - 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, @@ -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; -} - /** * 곡선 아래 총괄 요약 줄. 절토는 자연상태(내역 기준)와 환산 후(곡선 기준)를 함께 적는다. * 곡선이 여러 개라 수치가 어느 곡선의 것인지 끝에 반드시 밝힌다. diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts index 191c1a0b..89b20bf0 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -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 { @@ -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"); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css index 7fdea050..a23477d3 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css @@ -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; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css index bf64b3fb..38ea1e3e 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_MassHaul.css @@ -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; +} diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index c19b7c90..8b6650cb 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -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"],