From c856a084924421d11e157edd571c6b85bbad566a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 08:41:17 +0900 Subject: [PATCH] =?UTF-8?q?refactor(472-2=202-C=20=EC=9C=A0=ED=86=A0?= =?UTF-8?q?=EA=B3=A1=EC=84=A0=20=EB=82=98=EB=88=94):=20common=5Futil=5Fmas?= =?UTF-8?q?s=5Fhaul=5Fview=20866=20=EC=A4=84=20=E2=86=92=20=EA=B7=B8?= =?UTF-8?q?=EB=9E=98=ED=94=84=20=C2=B7=20=EB=A7=90=ED=92=8D=EC=84=A0(=5Fca?= =?UTF-8?q?llout)=20=C2=B7=20=EB=B2=94=EB=A1=80=20=C2=B7=20=EC=9A=94?= =?UTF-8?q?=EC=95=BD(=5Flegend)=20=EC=85=8B=20=C2=B7=20=EB=8F=99=EC=9E=91?= =?UTF-8?q?=20=EA=B0=99=EC=9D=8C=20=C2=B7=20B05=20=EB=B2=94=EB=A1=80=20imp?= =?UTF-8?q?ort=20=EA=B3=A0=EC=B9=A8=20=C2=B7=20=ED=8C=9D=EC=95=84=EC=9B=83?= =?UTF-8?q?=20=EC=8B=9C=ED=97=98=20=EB=AA=A9=EB=A1=9D=EC=97=90=20=EC=83=88?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EB=91=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../B05_Profile_UI_Profile_MassHaul.ts | 7 +- common_util/common_util_mass_haul_callout.ts | 128 ++++++++ common_util/common_util_mass_haul_legend.ts | 156 ++++++++++ common_util/common_util_mass_haul_view.ts | 275 +----------------- resources/tester/test_230_2_popout_frames.py | 2 + 5 files changed, 294 insertions(+), 274 deletions(-) create mode 100644 common_util/common_util_mass_haul_callout.ts create mode 100644 common_util/common_util_mass_haul_legend.ts diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index d6f267972..87aa9f811 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -44,11 +44,8 @@ import { } from "@util/common_util_mass_haul"; import { badgeValuesFrom, type MassHaulBadgeValues } from "@util/common_util_mass_haul_badge"; import { resetBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; -import { - createMassHaulChart, - createMassHaulLegend, - MASS_HAUL_MIN_HEIGHT, -} from "@util/common_util_mass_haul_view"; +import { createMassHaulChart, MASS_HAUL_MIN_HEIGHT } from "@util/common_util_mass_haul_view"; +import { createMassHaulLegend } from "@util/common_util_mass_haul_legend"; import { Y_WINDOW_BAKED_ATTR } from "../B06_Section/B06_Section_UI_Longitudinal"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; diff --git a/common_util/common_util_mass_haul_callout.ts b/common_util/common_util_mass_haul_callout.ts new file mode 100644 index 000000000..400eaac49 --- /dev/null +++ b/common_util/common_util_mass_haul_callout.ts @@ -0,0 +1,128 @@ +/* ============================================================================= + * common_util_mass_haul_callout.ts + * 유토곡선 값 표기 · 선택 측점 구간 물량 말풍선(`common_util_mass_haul_view` 에서 나눔). + * ========================================================================== */ + +import type { MassHaulPoint } from "./common_util_mass_haul"; +import { L, stationLabel, svgElement, svgText } from "./common_util_svg"; + +/** 누가토량 값 표기 — 천 단위 구분 + 소수점 1자리. */ +export function formatVolume(value: number): string { + return value.toLocaleString(undefined, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }); +} + +export 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는 자동 크기가 없어 폭을 어림해야 한다. 한글은 라틴 글자보다 넓다. */ +export function textWidth(value: string): number { + let width = 0; + for (const char of value) width += /[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(char) ? 11 : 6.1; + return width; +} + +/** + * 선택 측점의 구간 물량 말풍선(둥근 사각형)을 **곡선과 겹치지 않는 자리에** 그린다. + * + * 말풍선이 놓일 가로 구간에서 곡선이 실제로 지나는 세로 범위를 먼저 재고, 그 위/아래 + * 빈 곳 중 들어갈 수 있는 쪽에 놓는다(2026-08-02 사용자 지시). 오른쪽·왼쪽 자리를 모두 + * 재서 여유가 큰 쪽을 고르며, 어느 쪽에도 안 들어가면 여유가 더 큰 쪽 끝에 붙인다. + */ +export function appendVolumeCallout( + /** 붙일 자리 — 그래프 칸 밖을 자르는 겹이다(2026-09-04). */ + svg: SVGSVGElement | SVGGElement, + 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); +} diff --git a/common_util/common_util_mass_haul_legend.ts b/common_util/common_util_mass_haul_legend.ts new file mode 100644 index 000000000..d9359b0dc --- /dev/null +++ b/common_util/common_util_mass_haul_legend.ts @@ -0,0 +1,156 @@ +/* ============================================================================= + * common_util_mass_haul_legend.ts + * 유토곡선 곡선 이름 · 범례 줄 · 총괄 요약 줄(`common_util_mass_haul_view` 에서 나눔). + * ========================================================================== */ + +import type { MassHaulBasis, MassHaulSeries } from "./common_util_mass_haul"; +import type { HaulPlan } from "./common_util_mass_haul_balance"; +import type { LocaleKey } from "@ui/ui_template_locale"; +import { MASS_HAUL_BALANCE_KEY } from "./common_util_mass_haul"; +import { haulPlanChips } from "./common_util_mass_haul_balance_view"; +import { formatVolume } from "./common_util_mass_haul_callout"; +import { L } from "./common_util_svg"; + +const BASIS_LABEL: Record = { + cross: "B06_MassHaul_Basis_Cross", + longitudinal: "B06_MassHaul_Basis_Long", +}; + +/** 범례·곡선에 함께 쓰는 이름. 색이 곧 산출 기준이다. */ +export function massHaulSeriesLabel(series: MassHaulSeries): string { + return L(BASIS_LABEL[series.basis]); +} + +export function seriesClass(series: MassHaulSeries, base: string): string { + return `${base} ${base}--${series.basis}`; +} + +/** + * 곡선 표시/숨김 버튼 줄. + * + * 그래프 **아래**에 둔다(2026-08-02 사용자 지시). 예전에는 그래프 위에 겹치는 오버레이였는데, + * 가로 스크롤 컨테이너 안에 있으면 컨테이너 폭 계산에 끼어들어 종단면도와 X축이 어긋난다. + * 지금은 스크롤 컨테이너 **밖** 패널 본문에 붙어 곡선 폭에 전혀 관여하지 않는다. + */ +export function createMassHaulLegend( + series: MassHaulSeries[], + visibleKeys: ReadonlySet, + onToggle: (key: string) => void, + onResetBalloons?: () => void, +): HTMLElement { + const legend = document.createElement("div"); + legend.className = "b06-masshaul__legend"; + legend.setAttribute("role", "group"); + legend.setAttribute("aria-label", L("B06_MassHaul_Legend_Title")); + const addItem = (key: string, label: string, variantClass: string): void => { + const shown = visibleKeys.has(key); + const button = document.createElement("button"); + button.type = "button"; + button.className = variantClass; + button.classList.toggle("is-off", !shown); + button.setAttribute("aria-pressed", String(shown)); + button.title = `${label} — ${L(shown ? "B06_MassHaul_Legend_Hide" : "B06_MassHaul_Legend_Show")}`; + const swatch = document.createElement("span"); + swatch.className = "b06-masshaul__legend-swatch"; + swatch.setAttribute("aria-hidden", "true"); + const text = document.createElement("span"); + text.textContent = label; + button.append(swatch, text); + button.addEventListener("click", () => onToggle(key)); + legend.append(button); + }; + for (const entry of series) { + addItem(entry.key, massHaulSeriesLabel(entry), seriesClass(entry, "b06-masshaul__legend-item")); + } + // 토량 분배는 곡선이 아니라 곡선 위에 얹는 레이어라 스와치 모양을 따로 준다. + addItem( + MASS_HAUL_BALANCE_KEY, + L("B06_MassHaul_Balance_Layer"), + "b06-masshaul__legend-item b06-masshaul__legend-item--balance", + ); + // 끌어 옮긴 balloon을 한 번에 자동 배치로 되돌린다(프론트 캐시 + 다음 확정 시 영구저장소). + if (onResetBalloons && visibleKeys.has(MASS_HAUL_BALANCE_KEY)) { + const reset = document.createElement("button"); + reset.type = "button"; + reset.className = "b06-masshaul__legend-item b06-masshaul__legend-item--reset"; + reset.textContent = L("B06_MassHaul_Balloon_Reset"); + reset.title = L("B06_MassHaul_Balloon_Reset_Tip"); + reset.addEventListener("click", onResetBalloons); + // [횡단 기준] 왼쪽(367-1) + legend.prepend(reset); + } + return legend; +} + +/** + * 곡선 아래 총괄 요약 줄. 절토는 자연상태(내역 기준)와 환산 후(곡선 기준)를 함께 적는다. + * 곡선이 여러 개라 수치가 어느 곡선의 것인지 끝에 반드시 밝힌다. + */ +export function createMassHaulSummary( + series: MassHaulSeries, + haulPlan: HaulPlan | null, +): HTMLElement { + const result = series.result; + const row = document.createElement("div"); + row.className = "b06-masshaul__summary"; + const cut = result.cut_natural_m3; + const cutTotal = cut.soil + cut.ripping_rock + cut.blasting_rock; + /* 요약 줄이 12칩까지 늘어 한눈에 안 읽혔다(2026-08-19 사용자 지적). 판단에 쓰는 + * 값만 칩으로 남기고, 세부 내역은 그 칩의 툴팁으로 내린다. + * 절토(자연) — 토질 3종 내역은 툴팁 + * 성토(다짐) — 절토 다짐 환산은 툴팁(토량환산계수 f 적용 결과) + * 잉여 또는 부족 — 둘은 동시에 성립하지 않는다 + * 운반·검산 — 토량 분배를 켰을 때만(haulPlanChips) + * B05 종단 패널과 B06 횡단 화면이 같은 함수를 쓰므로 양쪽이 함께 바뀐다. */ + const chips: Array<[string, string, string?]> = [ + [ + L("B06_MassHaul_CutNatural"), + `${formatVolume(cutTotal)}㎥`, + [ + `${L("B06_Design_Ground_Soil")} ${formatVolume(cut.soil)}㎥`, + `${L("B06_Design_Ground_Ripping")} ${formatVolume(cut.ripping_rock)}㎥`, + `${L("B06_Design_Ground_Blasting")} ${formatVolume(cut.blasting_rock)}㎥`, + ].join(" · "), + ], + [ + L("B06_MassHaul_Fill"), + `${formatVolume(result.fill_compacted_m3)}㎥`, + `${L("B06_MassHaul_CutCompacted")} ${formatVolume(result.cut_compacted_m3)}㎥`, + ], + ]; + // 잉여와 부족은 동시에 성립하지 않으므로 해당하는 쪽만 보여 준다. + chips.push( + result.shortage_m3 > 0 + ? [L("B06_MassHaul_Shortage"), `${formatVolume(result.shortage_m3)}㎥`] + : [L("B06_MassHaul_Surplus"), `${formatVolume(result.surplus_m3)}㎥`], + ); + // 절·성토 균형 지표 — 판정·조정은 사용자 몫이고 프로그램은 값만 드러낸다(2026-09-03 + // 사용자 확정). 곡선이 0선을 넘지 않으면 평형선·운반 블록이 아예 생기지 않으므로, + // 벌룬이 없을 때 왜 없는지를 이 값 하나로 읽을 수 있어야 한다. + const finalCumulative = result.points.length + ? result.points[result.points.length - 1].cumulative_volume_m3 + : 0; + chips.push([ + L("B06_MassHaul_FinalCumulative"), + `${formatVolume(finalCumulative)}㎥`, + L("B06_MassHaul_FinalCumulative_Tip"), + ]); + // 토량 분배를 켜 두면 운반 총량·검산이 뒤에 붙는다(사토·토취는 발생 지점 기준 합계). + chips.push(...haulPlanChips(haulPlan)); + for (const [label, value, tip] of chips) { + const chip = document.createElement("span"); + chip.className = "b06-masshaul__chip"; + if (tip) chip.title = tip; + const name = document.createElement("em"); + name.textContent = label; + const amount = document.createElement("strong"); + amount.textContent = value; + chip.append(name, amount); + row.append(chip); + } + const basis = document.createElement("span"); + basis.className = seriesClass(series, "b06-masshaul__basis"); + basis.textContent = massHaulSeriesLabel(series); + row.append(basis); + return row; +} diff --git a/common_util/common_util_mass_haul_view.ts b/common_util/common_util_mass_haul_view.ts index 41bc3a659..b36b7003b 100644 --- a/common_util/common_util_mass_haul_view.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -13,15 +13,17 @@ * Y축은 데이터 범위 기반 자동 스케일이다. 참고 도면의 수직축척이 공구마다 다르므로 * (1공구 V=1:10,000 / 3공구 V=1:50,000) 고정 축척을 쓰면 안 된다. * 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md 3.2절 + * + * 값 표기 · 말풍선 = `common_util_mass_haul_callout` · 곡선 이름 · 범례 · 요약 = `common_util_mass_haul_legend`. * ========================================================================== */ -import type { MassHaulBasis, MassHaulPoint, MassHaulSeries } from "./common_util_mass_haul"; +import type { MassHaulPoint, MassHaulSeries } from "./common_util_mass_haul"; import type { MassHaulStationSource } from "./common_util_mass_haul_types"; import type { HaulPlan } from "./common_util_mass_haul_balance"; -import type { LocaleKey } from "@ui/ui_template_locale"; -import { MASS_HAUL_BALANCE_KEY } from "./common_util_mass_haul"; -import { appendBalanceLayer, haulPlanChips } from "./common_util_mass_haul_balance_view"; +import { appendBalanceLayer } from "./common_util_mass_haul_balance_view"; +import { appendVolumeCallout, formatVolume, textWidth } from "./common_util_mass_haul_callout"; import { segmentOf } from "./common_util_mass_haul_curve"; +import { seriesClass } from "./common_util_mass_haul_legend"; import { L, stationLabel, svgElement, svgText } from "./common_util_svg"; import { viewOf } from "./common_util_view_window"; @@ -123,20 +125,6 @@ export const MASS_HAUL_HEIGHT = 190; const MASS_PAD_TOP = 10; const MASS_PAD_BOTTOM = 8; -const BASIS_LABEL: Record = { - cross: "B06_MassHaul_Basis_Cross", - longitudinal: "B06_MassHaul_Basis_Long", -}; - -/** 범례·곡선에 함께 쓰는 이름. 색이 곧 산출 기준이다. */ -export function massHaulSeriesLabel(series: MassHaulSeries): string { - return L(BASIS_LABEL[series.basis]); -} - -function seriesClass(series: MassHaulSeries, base: string): string { - return `${base} ${base}--${series.basis}`; -} - /** * 곡선 경로. **`common_util_mass_haul_curve`가 쓰는 것과 같은 기하**로 그려야 한다 — 평형선·장비 * 경계현·평균운반거리선의 양 끝이 그 기하와의 교점으로 계산되므로, 그리기와 재기가 어긋나면 @@ -173,14 +161,6 @@ function curvePath( return parts.join(" "); } -/** 누가토량 값 표기 — 천 단위 구분 + 소수점 1자리. */ -export function formatVolume(value: number): string { - return value.toLocaleString(undefined, { - minimumFractionDigits: 1, - maximumFractionDigits: 1, - }); -} - /** * Y축 기본 스케일(㎥). 계획선을 편집할 때마다 범위가 데이터로 다시 잡히면 그래프가 * 출렁여 변화를 읽기 어렵다(2026-08-04 사용자 확정) — 기본 ±200㎥으로 고정하고, @@ -295,119 +275,6 @@ 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( - /** 붙일 자리 — 그래프 칸 밖을 자르는 겹이다(2026-09-04). */ - svg: SVGSVGElement | SVGGElement, - 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, @@ -734,133 +601,3 @@ export function createMassHaulChart( ); return svg; } - -/** - * 곡선 표시/숨김 버튼 줄. - * - * 그래프 **아래**에 둔다(2026-08-02 사용자 지시). 예전에는 그래프 위에 겹치는 오버레이였는데, - * 가로 스크롤 컨테이너 안에 있으면 컨테이너 폭 계산에 끼어들어 종단면도와 X축이 어긋난다. - * 지금은 스크롤 컨테이너 **밖** 패널 본문에 붙어 곡선 폭에 전혀 관여하지 않는다. - */ -export function createMassHaulLegend( - series: MassHaulSeries[], - visibleKeys: ReadonlySet, - onToggle: (key: string) => void, - onResetBalloons?: () => void, -): HTMLElement { - const legend = document.createElement("div"); - legend.className = "b06-masshaul__legend"; - legend.setAttribute("role", "group"); - legend.setAttribute("aria-label", L("B06_MassHaul_Legend_Title")); - const addItem = (key: string, label: string, variantClass: string): void => { - const shown = visibleKeys.has(key); - const button = document.createElement("button"); - button.type = "button"; - button.className = variantClass; - button.classList.toggle("is-off", !shown); - button.setAttribute("aria-pressed", String(shown)); - button.title = `${label} — ${L(shown ? "B06_MassHaul_Legend_Hide" : "B06_MassHaul_Legend_Show")}`; - const swatch = document.createElement("span"); - swatch.className = "b06-masshaul__legend-swatch"; - swatch.setAttribute("aria-hidden", "true"); - const text = document.createElement("span"); - text.textContent = label; - button.append(swatch, text); - button.addEventListener("click", () => onToggle(key)); - legend.append(button); - }; - for (const entry of series) { - addItem(entry.key, massHaulSeriesLabel(entry), seriesClass(entry, "b06-masshaul__legend-item")); - } - // 토량 분배는 곡선이 아니라 곡선 위에 얹는 레이어라 스와치 모양을 따로 준다. - addItem( - MASS_HAUL_BALANCE_KEY, - L("B06_MassHaul_Balance_Layer"), - "b06-masshaul__legend-item b06-masshaul__legend-item--balance", - ); - // 끌어 옮긴 balloon을 한 번에 자동 배치로 되돌린다(프론트 캐시 + 다음 확정 시 영구저장소). - if (onResetBalloons && visibleKeys.has(MASS_HAUL_BALANCE_KEY)) { - const reset = document.createElement("button"); - reset.type = "button"; - reset.className = "b06-masshaul__legend-item b06-masshaul__legend-item--reset"; - reset.textContent = L("B06_MassHaul_Balloon_Reset"); - reset.title = L("B06_MassHaul_Balloon_Reset_Tip"); - reset.addEventListener("click", onResetBalloons); - // [횡단 기준] 왼쪽(367-1) - legend.prepend(reset); - } - return legend; -} - -/** - * 곡선 아래 총괄 요약 줄. 절토는 자연상태(내역 기준)와 환산 후(곡선 기준)를 함께 적는다. - * 곡선이 여러 개라 수치가 어느 곡선의 것인지 끝에 반드시 밝힌다. - */ -export function createMassHaulSummary( - series: MassHaulSeries, - haulPlan: HaulPlan | null, -): HTMLElement { - const result = series.result; - const row = document.createElement("div"); - row.className = "b06-masshaul__summary"; - const cut = result.cut_natural_m3; - const cutTotal = cut.soil + cut.ripping_rock + cut.blasting_rock; - /* 요약 줄이 12칩까지 늘어 한눈에 안 읽혔다(2026-08-19 사용자 지적). 판단에 쓰는 - * 값만 칩으로 남기고, 세부 내역은 그 칩의 툴팁으로 내린다. - * 절토(자연) — 토질 3종 내역은 툴팁 - * 성토(다짐) — 절토 다짐 환산은 툴팁(토량환산계수 f 적용 결과) - * 잉여 또는 부족 — 둘은 동시에 성립하지 않는다 - * 운반·검산 — 토량 분배를 켰을 때만(haulPlanChips) - * B05 종단 패널과 B06 횡단 화면이 같은 함수를 쓰므로 양쪽이 함께 바뀐다. */ - const chips: Array<[string, string, string?]> = [ - [ - L("B06_MassHaul_CutNatural"), - `${formatVolume(cutTotal)}㎥`, - [ - `${L("B06_Design_Ground_Soil")} ${formatVolume(cut.soil)}㎥`, - `${L("B06_Design_Ground_Ripping")} ${formatVolume(cut.ripping_rock)}㎥`, - `${L("B06_Design_Ground_Blasting")} ${formatVolume(cut.blasting_rock)}㎥`, - ].join(" · "), - ], - [ - L("B06_MassHaul_Fill"), - `${formatVolume(result.fill_compacted_m3)}㎥`, - `${L("B06_MassHaul_CutCompacted")} ${formatVolume(result.cut_compacted_m3)}㎥`, - ], - ]; - // 잉여와 부족은 동시에 성립하지 않으므로 해당하는 쪽만 보여 준다. - chips.push( - result.shortage_m3 > 0 - ? [L("B06_MassHaul_Shortage"), `${formatVolume(result.shortage_m3)}㎥`] - : [L("B06_MassHaul_Surplus"), `${formatVolume(result.surplus_m3)}㎥`], - ); - // 절·성토 균형 지표 — 판정·조정은 사용자 몫이고 프로그램은 값만 드러낸다(2026-09-03 - // 사용자 확정). 곡선이 0선을 넘지 않으면 평형선·운반 블록이 아예 생기지 않으므로, - // 벌룬이 없을 때 왜 없는지를 이 값 하나로 읽을 수 있어야 한다. - const finalCumulative = result.points.length - ? result.points[result.points.length - 1].cumulative_volume_m3 - : 0; - chips.push([ - L("B06_MassHaul_FinalCumulative"), - `${formatVolume(finalCumulative)}㎥`, - L("B06_MassHaul_FinalCumulative_Tip"), - ]); - // 토량 분배를 켜 두면 운반 총량·검산이 뒤에 붙는다(사토·토취는 발생 지점 기준 합계). - chips.push(...haulPlanChips(haulPlan)); - for (const [label, value, tip] of chips) { - const chip = document.createElement("span"); - chip.className = "b06-masshaul__chip"; - if (tip) chip.title = tip; - const name = document.createElement("em"); - name.textContent = label; - const amount = document.createElement("strong"); - amount.textContent = value; - chip.append(name, amount); - row.append(chip); - } - const basis = document.createElement("span"); - basis.className = seriesClass(series, "b06-masshaul__basis"); - basis.textContent = massHaulSeriesLabel(series); - row.append(basis); - return row; -} diff --git a/resources/tester/test_230_2_popout_frames.py b/resources/tester/test_230_2_popout_frames.py index a0f51bd03..891881d19 100644 --- a/resources/tester/test_230_2_popout_frames.py +++ b/resources/tester/test_230_2_popout_frames.py @@ -33,6 +33,8 @@ POPOUT_FILES = [ "B05_Profile/B05_Profile_UI_Drainage_Panel.ts", "B05_Profile/B05_Profile_UI_Structures_Marks.ts", "common_util/common_util_mass_haul_view.ts", + "common_util/common_util_mass_haul_callout.ts", + "common_util/common_util_mass_haul_legend.ts", ] # 메인 창 전역 틀 · 시계 — `x.requestAnimationFrame` 처럼 창을 짚은 것은 제외 MAIN_WINDOW_CLOCK = re.compile(