feat(B06): 유토곡선 직선 확정 + 잔량 표기 통일 + 횡단도 줌 버튼

유토곡선을 측점 사이에서 직선으로 잇는다(C안 확정). PARABOLIC_MASS_CURVE 한 곳으로
그리기(curvePath)와 재기(crossFrom)가 함께 바뀐다 - 둘이 어긋나면 수평선 끝이
곡선에서 뜬다. 대가는 평균운반거리가 포물선 대비 약 40% 짧아지는 것이고, 이는
20m/70m 장비 경계 판정에 직접 영향을 준다.

- 사토·토취를 balloon 계열로 통일: 단차 화살표는 22px 고정으로 잘라 위치만 가리키고
  수량 라벨은 balloon과 같은 자리 찾기·지시선·드래그를 쓴다(음수 키). 도형 없이 언더바만
- 토취 색 초록 -> 주황. 초록은 "정상"으로 읽혀 흙이 모자란 경고와 안 맞았다
- balloon 내용을 도면 양식(항목별 세로 표기)으로. 165px 이상이면 6줄
  (이름·번호 / Q / L / EA / RR / BR), 사토는 L 대신 M.N=측점, 토취는 지반유형 없음
- HaulResidual에 사토 지반유형 안분 추가. 장거리 상쇄로 깎이면 안분도 같은 비율로 감소
- 횡단도 휠 줌 제거, 우측 상단 + / − / ⤢ 버튼. 팬은 가운데 버튼 유지
- 암 경계선 제어를 그래프 안에서 중심고·방위각 행 가운데로 이동
- 700줄 초과 2건 분리: _UI_MassHaul_Balloon.ts, _UI_Style_Cross_Areas.css

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 17:59:30 +09:00
co-authored by Claude Opus 5
parent 0826015a76
commit dc19d09f0e
12 changed files with 853 additions and 580 deletions
@@ -62,6 +62,7 @@ import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css";
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css";
import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross_Areas.css";
const COLLAPSED_KEY = "b05-route-profile-collapsed";
/** 드래그로 조절한 하단 패널 높이(px) 보관 키 — 브라우저 세션 동안만 유지한다. */
@@ -139,7 +139,19 @@ export function crossCardNaturalHeight(
* viewBox만 조작하고 선은 `vector-effect: non-scaling-stroke`(CSS)로 굵기를 유지해
* 확대해도 선·글자가 선명하다. 최대 8배까지, 축소는 원본까지만 허용한다.
*/
function attachZoomPan(svg: SVGSVGElement, widthPx: number, heightPx: number): void {
/**
* 횡단 카드 줌·팬 제어. 반환한 `zoom()`을 그래프 우측 상단 버튼이 부른다.
*
* **휠은 줌이 아니다**(2026-08-02 사용자 확정). 카드가 수십 장 깔리는 화면에서 휠을 줌에
* 묶으면 목록을 훑을 수가 없다. 확대·축소는 버튼, 팬은 가운데 버튼 드래그, 원복은 더블클릭.
*/
interface ZoomPanHandle {
/** 1보다 크면 확대, 작으면 축소. 보이는 화면의 중앙을 붙잡는다. */
zoom: (factor: number) => void;
reset: () => void;
}
function attachZoomPan(svg: SVGSVGElement, widthPx: number, heightPx: number): ZoomPanHandle {
const base = { x: 0, y: 0, w: widthPx, h: heightPx };
const vb = { ...base };
const applyVB = (): void => svg.setAttribute("viewBox", `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
@@ -148,26 +160,19 @@ function attachZoomPan(svg: SVGSVGElement, widthPx: number, heightPx: number): v
vb.y = Math.min(Math.max(vb.y, base.y), base.y + base.h - vb.h);
};
svg.addEventListener(
"wheel",
(event) => {
event.preventDefault();
const rect = svg.getBoundingClientRect();
const mx = vb.x + ((event.clientX - rect.left) / rect.width) * vb.w;
const my = vb.y + ((event.clientY - rect.top) / rect.height) * vb.h;
// 휠을 **당기면 확대**, 밀면 축소한다 — B04·B05 2D 지도와 같은 방향(2026-08-02 사용자 지시).
const factor = event.deltaY > 0 ? 0.85 : 1 / 0.85;
const nw = Math.min(base.w, Math.max(base.w / 8, vb.w * factor));
const nh = Math.min(base.h, Math.max(base.h / 8, vb.h * factor));
vb.x = mx - (mx - vb.x) * (nw / vb.w);
vb.y = my - (my - vb.y) * (nh / vb.h);
vb.w = nw;
vb.h = nh;
clampPan();
applyVB();
},
{ passive: false },
);
// 보이는 화면의 **중앙**을 붙잡고 확대·축소한다 — 버튼에는 마우스 위치가 없다.
const zoom = (factor: number): void => {
const mx = vb.x + vb.w / 2;
const my = vb.y + vb.h / 2;
const nw = Math.min(base.w, Math.max(base.w / 8, vb.w / factor));
const nh = Math.min(base.h, Math.max(base.h / 8, vb.h / factor));
vb.x = mx - (mx - vb.x) * (nw / vb.w);
vb.y = my - (my - vb.y) * (nh / vb.h);
vb.w = nw;
vb.h = nh;
clampPan();
applyVB();
};
let panning = false;
let moved = false;
@@ -219,15 +224,42 @@ function attachZoomPan(svg: SVGSVGElement, widthPx: number, heightPx: number): v
svg.addEventListener("click", (event) => {
if (moved) event.stopPropagation();
});
// 더블클릭 원복.
svg.addEventListener("dblclick", (event) => {
event.stopPropagation();
const reset = (): void => {
vb.x = base.x;
vb.y = base.y;
vb.w = base.w;
vb.h = base.h;
applyVB();
};
// 더블클릭 원복.
svg.addEventListener("dblclick", (event) => {
event.stopPropagation();
reset();
});
return { zoom, reset };
}
/** 그래프 우측 상단 줌 버튼(확대/축소/핏). 휠을 대신하는 조작구다. */
function buildZoomControls(handle: ZoomPanHandle): HTMLElement {
const bar = document.createElement("div");
bar.className = "b06-cross-card__zoom";
const add = (label: string, title: string, action: () => void): void => {
const button = document.createElement("button");
button.type = "button";
button.className = "b06-cross-card__zoom-btn";
button.textContent = label;
button.title = title;
button.addEventListener("click", (event) => {
// 카드 선택으로 번지면 그래프가 다시 그려져 방금 맞춘 배율이 날아간다.
event.stopPropagation();
action();
});
bar.append(button);
};
add("+", L("B06_Profile_View_ZoomIn"), () => handle.zoom(1 / 0.85));
add("", L("B06_Profile_View_ZoomOut"), () => handle.zoom(0.85));
add("⤢", L("B06_Profile_View_ZoomFit"), () => handle.reset());
return bar;
}
export function createCrossSectionCard(
@@ -516,22 +548,16 @@ export function createCrossSectionCard(
// 마우스 휠 줌·드래그 팬·더블클릭 원복(E-5). viewBox 조작 + non-scaling-stroke로 선명도 유지.
const chartWrap = document.createElement("div");
chartWrap.className = "b06-cross-card__chart-wrap";
attachZoomPan(svg, widthPx, heightPx);
const zoomPan = attachZoomPan(svg, widthPx, heightPx);
// 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 값 칸은 항상 강조 토글이다.
const readout = buildAreaReadout(section.design, toggleArea);
setChipActive = readout.setActive;
chartWrap.append(svg, readout.root);
chartWrap.append(svg, readout.root, buildZoomControls(zoomPan));
// 다시 그려지기 전에 켜져 있던 강조를 되살린다(부모가 들고 있던 값).
if (activeArea) {
setBandActive(activeArea);
setChipActive(activeArea);
}
// 암 경계선 제어는 그래프 X축 제목 행 우측에 배치(E-7). 암 지반에서만.
if (rockBoundary && section.design?.geometry_preset === "rock") {
const rockControl = buildRockBoundaryControl(section, rockBoundary);
rockControl.classList.add("b06-cross-card__rockb");
chartWrap.append(rockControl);
}
card.append(chartWrap);
}
@@ -540,7 +566,15 @@ export function createCrossSectionCard(
center.textContent = `${L("B06_Profile_View_CenterElevation")} ${section.center_z?.toFixed(2) ?? "-"}m`;
const azimuth = document.createElement("span");
azimuth.textContent = `${L("B06_Profile_View_Azimuth")} ${section.azimuth_deg?.toFixed(1) ?? "-"}°`;
footer.append(center, azimuth);
footer.append(center);
// 암 경계선 제어는 그래프 안이 아니라 **중심고·방위각 행 가운데**에 둔다
// (2026-08-02 사용자 지시). 그래프 안에 있으면 도면 위에 겹쳐 단면을 가렸다. 암 지반만.
if (rockBoundary && section.design?.geometry_preset === "rock") {
const rockControl = buildRockBoundaryControl(section, rockBoundary);
rockControl.classList.add("b06-cross-card__rockb");
footer.append(rockControl);
}
footer.append(azimuth);
card.append(footer);
return card;
}
@@ -127,6 +127,13 @@ export interface HaulResidual {
/** 평형선이 이 잔량만큼 계단으로 옮겨 간다 — 렌더러가 단차를 그대로 그린다. */
level_from_m3: number;
level_to_m3: number;
/**
* 사토의 지반유형 안분(㎥). 사토는 절토에서 남은 흙이라 구성비를 물을 수 있다.
* **토취는 0이다** — 밖에서 사 오는 흙이라 이 노선의 지반유형이 없다.
*/
ea_m3: number;
rr_m3: number;
br_m3: number;
}
/**
@@ -445,14 +452,19 @@ export function computeHaulPlan(
const pushResidual = (fromM: number, toM: number, from: number, to: number): void => {
const delta = to - from;
if (Math.abs(delta) < EPSILON) return;
const volume = Math.abs(delta);
residuals.push({
index: residuals.length + 1,
kind: delta > 0 ? "spoil" : "borrow",
from_m: fromM,
to_m: toM,
volume_m3: Math.abs(delta),
volume_m3: volume,
level_from_m3: from,
level_to_m3: to,
// 사토만 지반유형을 물을 수 있다(절토에서 남은 흙). 토취는 밖에서 사 온다.
...(delta > 0
? apportion(cutMix(points, fromM, toM, result.conversion), volume)
: { ea_m3: 0, rr_m3: 0, br_m3: 0 }),
});
};
@@ -587,6 +599,11 @@ function settleResiduals(
for (const pair of pairs) {
const volume = Math.min(pair.spoil.volume_m3, pair.borrow.volume_m3);
if (!(volume > EPSILON)) continue;
// 잔량이 깎인 만큼 지반유형 안분도 같은 비율로 줄인다 — 남은 사토의 구성비는 그대로다.
const ratio = pair.spoil.volume_m3 > 0 ? 1 - volume / pair.spoil.volume_m3 : 0;
pair.spoil.ea_m3 *= ratio;
pair.spoil.rr_m3 *= ratio;
pair.spoil.br_m3 *= ratio;
pair.spoil.volume_m3 -= volume;
pair.borrow.volume_m3 -= volume;
// 퍼오는 쪽이 절토 구간이므로 지반유형은 사토 구간에서 읽는다.
@@ -649,6 +666,9 @@ export function haulPlanPayload(plan: HaulPlan): Record<string, unknown> {
from_m: round(residual.from_m),
to_m: round(residual.to_m),
volume_m3: round(residual.volume_m3),
ea_m3: round(residual.ea_m3),
rr_m3: round(residual.rr_m3),
br_m3: round(residual.br_m3),
})),
};
}
@@ -7,12 +7,13 @@
* 2. 장비 띠 면 — 경계현 두 개와 곡선 두 변으로 둘러싸인 다각형. 클릭 대상이다.
* 3. 경계현(파선) — 그 현의 길이가 장비 경계거리와 같아지는 높이의 수평선.
* 4. 평균운반거리(점선 + 화살촉) — 띠 중간 높이의 현. 방향은 산=좌→우, 골=우→좌.
* 5. balloon — 도형이 곧 운반수단이다: 종무대 육각 / 도쟈 원 / 덤프 사각.
* 5. 장거리 운반선(1점쇄선) — 떨어진 잉여 ↔ 부족을 잇는 선.
* 6. balloon — 도형이 곧 운반수단이다: 종무대 육각 / 도쟈 원 / 덤프 사각.
* 사토·토취는 도형 없이 **언더바만**(2026-08-02 사용자 지시).
*
* 좌표 변환(x/y)과 그릴 상자는 유토곡선 렌더러가 넘겨준다 — 축을 두 번 정의하지 않는다.
* 계산은 전부 `_UI_MassHaul_Balance`가 끝내 두므로 여기서는 배치만 판단한다.
* 700줄 제한 대응으로 `_UI_MassHaul_View`에서 분리했다.
* balloon의 도형·자리 찾기·드래그·위치 보관은 `_UI_MassHaul_Balloon`이 맡는다.
* ========================================================================== */
import type {
@@ -22,314 +23,35 @@ import type {
HaulResidual,
HaulTransfer,
} from "./B06_wf3_ProfileCross_UI_MassHaul_Balance";
import type { LocaleKey } from "@ui/ui_template_locale";
import type { BalanceLayerBox, PlacedBox } from "./B06_wf3_ProfileCross_UI_MassHaul_Balloon";
import {
attachBalloonDrag,
balloonOffsets,
balloonShape,
compactDistance,
compactVolume,
EQUIPMENT_SHAPE,
equipmentLabel,
findSlot,
FULL_BALLOON_ROOM_PX,
LINE_HEIGHT,
MIN_BAND_THICKNESS_PX,
MIN_BAND_WIDTH_PX,
NOTCH,
PAD_X,
PAD_Y,
RESIDUAL_ARROW_PX,
textWidth,
TWO_LINE_ROOM_PX,
} from "./B06_wf3_ProfileCross_UI_MassHaul_Balloon";
import { L, stationLabel, svgElement, svgText } from "./B06_wf3_ProfileCross_UI_Section_Common";
/** 유토곡선 렌더러가 넘겨주는 좌표계와 그릴 수 있는 상자(px). */
export interface BalanceLayerBox {
x: (chainageM: number) => number;
y: (volumeM3: number) => number;
left: number;
right: number;
top: number;
bottom: number;
/**
* 주어진 가로 구간에서 곡선이 실제로 지나는 세로 범위(없으면 null).
* balloon을 곡선과 겹치지 않는 자리에 놓기 위해 유토곡선 렌더러가 넘겨준다.
*/
curveYAt?: (fromPx: number, toPx: number) => { top: number; bottom: number } | null;
/** 사토·토취 발생 위치를 측점으로 적기 위한 측점 간격(m). */
stationInterval?: number;
}
const EQUIPMENT_LABEL: Record<string, LocaleKey> = {
free_haul: "B06_MassHaul_Equip_FreeHaul",
dozer: "B06_MassHaul_Equip_Dozer",
dump_truck: "B06_MassHaul_Equip_Dump",
};
/** balloon 도형 = 운반수단. 모르는 장비는 사각으로 떨어뜨린다. */
type BalloonShape = "hexagon" | "ellipse" | "rect";
const EQUIPMENT_SHAPE: Record<string, BalloonShape> = {
free_haul: "hexagon",
dozer: "ellipse",
dump_truck: "rect",
};
/* balloon 치수 — 도면과 달리 화면 그래프는 200px 안팎이라 최대한 작게 잡는다
(2026-08-02 사용자 지적: 도형이 크고 빈자리를 못 찾는다). 자세한 값은 툴팁이 맡는다. */
const LINE_HEIGHT = 10;
const PAD_X = 5;
const PAD_Y = 3;
/** 육각형 좌우 꼭짓점이 파고드는 깊이(px). */
const NOTCH = 5;
/** balloon과 곡선 사이 최소 틈(px). */
const CURVE_CLEARANCE = 5;
/** 이보다 좁은 띠에는 balloon을 달지 않는다 — 서로 겹쳐 도면을 못 읽는다. */
const MIN_BAND_WIDTH_PX = 34;
/** 이보다 얇은 띠에는 평균운반거리 선을 긋지 않는다(경계현과 붙어 한 줄로 보인다). */
const MIN_BAND_THICKNESS_PX = 9;
/**
* 사용자가 끌어 옮긴 balloon 위치(띠 번호 → [dx, dy]).
*
* 저장 경로가 둘이다:
* ① **프론트 캐시**(localStorage) — 끌어 옮기는 즉시. 새로고침·재접속에도 남는다.
* ② **영구저장소** — 횡단 확정 시점에 `mass_haul.balloon_offsets`로 넘어간다.
* 진입할 때는 ②가 있으면 그것으로 캐시를 덮어써 **다른 브라우저에서도 같은 자리**에 뜬다
* (2026-08-02 사용자 지시). 캐시는 경로별로 갈라 다른 노선의 위치가 섞이지 않게 한다.
*/
const BALLOON_OFFSET_PREFIX = "b06:balance-balloon-offset";
let offsetStorageKey = BALLOON_OFFSET_PREFIX;
let balloonOffsets = new Map<number, [number, number]>();
function parseOffsets(source: unknown): Map<number, [number, number]> {
const result = new Map<number, [number, number]>();
if (!source || typeof source !== "object") return result;
for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
if (!Array.isArray(value) || value.length !== 2) continue;
const [dx, dy] = value as [number, number];
if (Number.isFinite(dx) && Number.isFinite(dy)) result.set(Number(key), [dx, dy]);
}
return result;
}
function writeOffsets(): void {
try {
localStorage.setItem(offsetStorageKey, JSON.stringify(balloonOffsetsPayload()));
} catch {
/* 저장 실패는 무시 — 위치는 화면이 살아 있는 동안 유지된다. */
}
}
/**
* 노선이 바뀌거나 상세를 새로 받았을 때 캐시를 맞춘다.
* `stored`(영구저장소 값)가 있으면 **그것이 이긴다** — 다른 브라우저에서 옮긴 자리를 그대로
* 받아야 하기 때문이다. 없으면 이 브라우저의 캐시를 그대로 이어 쓴다.
*/
export function configureBalloonOffsets(scope: string, stored?: unknown): void {
offsetStorageKey = `${BALLOON_OFFSET_PREFIX}:${scope}`;
if (stored && Object.keys(stored as object).length) {
balloonOffsets = parseOffsets(stored);
writeOffsets();
return;
}
try {
balloonOffsets = parseOffsets(JSON.parse(localStorage.getItem(offsetStorageKey) ?? "null"));
} catch {
balloonOffsets = new Map();
}
}
/** 확정 시 영구저장소로 넘길 형태. */
export function balloonOffsetsPayload(): Record<string, [number, number]> {
return Object.fromEntries([...balloonOffsets].map(([key, value]) => [String(key), value]));
}
/** 자동 배치로 되돌린다 — 프론트 캐시를 비우고, 다음 확정 때 영구저장소도 빈 값으로 덮인다. */
export function resetBalloonOffsets(): void {
balloonOffsets = new Map();
try {
localStorage.removeItem(offsetStorageKey);
} catch {
/* 무시 */
}
}
/** SVG는 자동 크기가 없어 폭을 어림해야 한다. 한글은 라틴 글자보다 넓다. */
function textWidth(value: string): number {
let width = 0;
for (const char of value) width += /[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(char) ? 9.5 : 5.2;
return width;
}
/** balloon 안 수치는 자리를 아껴야 한다 — 천 단위 구분만 두고 소수점은 버린다. */
function compactVolume(value: number): string {
return Math.round(value).toLocaleString();
}
function compactDistance(value: number): string {
return value >= 100 ? `${Math.round(value)}` : value.toFixed(1);
}
function equipmentLabel(key: string | null): string {
if (!key) return "";
const locale = EQUIPMENT_LABEL[key];
return locale ? L(locale) : key;
}
/** 육각형 — 좌우 끝이 뾰족한 도면용 모양. */
function hexagonPoints(cx: number, cy: number, width: number, height: number): string {
const halfW = width / 2;
const halfH = height / 2;
const notch = Math.min(NOTCH, width * 0.2);
return [
`${cx - halfW},${cy}`,
`${cx - halfW + notch},${cy - halfH}`,
`${cx + halfW - notch},${cy - halfH}`,
`${cx + halfW},${cy}`,
`${cx + halfW - notch},${cy + halfH}`,
`${cx - halfW + notch},${cy + halfH}`,
].join(" ");
}
function balloonShape(
shape: BalloonShape,
cx: number,
cy: number,
width: number,
height: number,
): SVGElement {
if (shape === "ellipse") {
// 원형은 같은 글자를 담으려면 사각보다 커야 한다 — 모서리가 비기 때문이다.
return svgElement("ellipse", {
cx,
cy,
rx: width / 2 + 5,
ry: height / 2 + 3,
class: "b06-balance__balloon-shape",
});
}
if (shape === "rect") {
return svgElement("rect", {
x: cx - width / 2,
y: cy - height / 2,
width,
height,
rx: 2,
ry: 2,
class: "b06-balance__balloon-shape",
});
}
return svgElement("polygon", {
points: hexagonPoints(cx, cy, width, height),
class: "b06-balance__balloon-shape",
});
}
interface PlacedBox {
left: number;
right: number;
top: number;
bottom: number;
}
function overlaps(a: PlacedBox, b: PlacedBox): boolean {
return !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom);
}
/** 이 자리에 놓으면 곡선과 겹치는가. 곡선 정보를 못 받았으면 겹치지 않는 것으로 본다. */
function hitsCurve(box: BalanceLayerBox, candidate: PlacedBox): boolean {
const band = box.curveYAt?.(candidate.left, candidate.right);
if (!band) return false;
return !(
candidate.bottom + CURVE_CLEARANCE <= band.top || candidate.top - CURVE_CLEARANCE >= band.bottom
);
}
/**
* 빈자리를 찾는다. 선호 지점에서 **가로·세로 양쪽으로** 후보를 넓혀 가며, 이미 놓인 balloon과
* 겹치지 않고 곡선도 피하는 첫 자리를 고른다(2026-08-02 사용자 지적: 남는 공간을 못 찾음).
*
* 곡선까지 피하는 자리가 끝내 없으면 **곡선 조건만 버리고** 다시 훑는다 — 겹치더라도 놓는 게
* 안 그리는 것보다 낫고, 사용자가 끌어 옮길 수 있다.
*/
function findSlot(
placed: PlacedBox[],
preferredX: number,
preferredY: number,
width: number,
height: number,
box: BalanceLayerBox,
upward: boolean,
): { x: number; y: number } {
const stepY = height + 3;
const stepX = width * 0.55;
const clampX = (value: number): number =>
Math.min(Math.max(value, box.left + width / 2), box.right - width / 2);
const candidates: Array<{ x: number; y: number }> = [];
for (let ring = 0; ring < 7; ring += 1) {
for (const dy of ring === 0 ? [0] : upward ? [-ring, ring] : [ring, -ring]) {
for (const dx of ring === 0 ? [0] : [0, -1, 1, -2, 2]) {
candidates.push({ x: clampX(preferredX + dx * stepX), y: preferredY + dy * stepY });
}
}
}
for (const avoidCurve of [true, false]) {
for (const candidate of candidates) {
if (candidate.y - height / 2 < box.top || candidate.y + height / 2 > box.bottom) continue;
const rect: PlacedBox = {
left: candidate.x - width / 2,
right: candidate.x + width / 2,
top: candidate.y - height / 2,
bottom: candidate.y + height / 2,
};
if (placed.some((entry) => overlaps(entry, rect))) continue;
if (avoidCurve && hitsCurve(box, rect)) continue;
return candidate;
}
}
return { x: clampX(preferredX), y: preferredY };
}
/**
* balloon을 끌어 옮길 수 있게 한다. 자동 배치가 아무리 좋아도 200px 높이 그래프에서는
* 빈자리가 모자라므로, 마지막 판단은 사용자에게 맡긴다(2026-08-02 사용자 지시).
* 옮긴 만큼 지시선 끝점도 따라가고, 놓은 자리는 세션에 남는다.
*/
function attachBalloonDrag(
balloon: SVGGElement,
leader: SVGLineElement,
svg: SVGSVGElement,
key: number,
home: { x: number; y: number },
): void {
let start: { x: number; y: number; dx: number; dy: number } | null = null;
const scale = (): number => {
const rendered = svg.getBoundingClientRect().width;
const viewBox = svg.viewBox.baseVal?.width || rendered;
return rendered > 0 && viewBox > 0 ? viewBox / rendered : 1;
};
const apply = (dx: number, dy: number): void => {
balloon.setAttribute("transform", `translate(${dx} ${dy})`);
leader.setAttribute("x2", String(home.x + dx));
leader.setAttribute("y2", String(home.y + dy));
};
balloon.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
// 카드·측점 선택으로 번지면 그래프가 다시 그려져 끌던 balloon이 사라진다.
event.stopPropagation();
event.preventDefault();
const current = balloonOffsets.get(key) ?? [0, 0];
start = { x: event.clientX, y: event.clientY, dx: current[0], dy: current[1] };
balloon.setPointerCapture(event.pointerId);
balloon.classList.add("is-dragging");
});
balloon.addEventListener("pointermove", (event) => {
if (!start) return;
const factor = scale();
const dx = start.dx + (event.clientX - start.x) * factor;
const dy = start.dy + (event.clientY - start.y) * factor;
balloonOffsets.set(key, [dx, dy]);
apply(dx, dy);
});
const finish = (event: PointerEvent): void => {
if (!start) return;
start = null;
balloon.releasePointerCapture(event.pointerId);
balloon.classList.remove("is-dragging");
writeOffsets();
};
balloon.addEventListener("pointerup", finish);
balloon.addEventListener("pointercancel", finish);
// 두 번 누르면 자동 배치로 되돌린다 — 잘못 끌었을 때 되돌릴 길을 남긴다.
balloon.addEventListener("dblclick", (event) => {
event.stopPropagation();
balloonOffsets.delete(key);
apply(0, 0);
writeOffsets();
});
}
export type { BalanceLayerBox } from "./B06_wf3_ProfileCross_UI_MassHaul_Balloon";
export {
balloonOffsetsPayload,
configureBalloonOffsets,
resetBalloonOffsets,
} from "./B06_wf3_ProfileCross_UI_MassHaul_Balloon";
/** 계단형 평형선(기선) — 칸 사이는 수직선으로 이어 계단을 만든다. */
function appendBalanceLine(group: SVGGElement, plan: HaulPlan, box: BalanceLayerBox): void {
@@ -419,11 +141,23 @@ function appendBandBalloon(
const equipment = equipmentLabel(band.equipment);
const room = box.bottom - box.top;
// 화면 balloon은 도면과 달리 두 줄이 상한이다. EA/RR/BR·블록 번호는 툴팁이 맡는다.
const lines = [`${band.index} · ${compactVolume(band.volume_m3)}`];
if (room >= 120) {
lines.push(`L ${compactDistance(band.haul_distance_m)}m${equipment ? ` · ${equipment}` : ""}`);
}
// 자리가 나면 도면과 같은 항목별 세로 표기, 좁아지면 줄을 접는다.
const lines =
room >= FULL_BALLOON_ROOM_PX
? [
`${equipment || "-"} ${band.index}`,
`Q=${compactVolume(band.volume_m3)}`,
`L=${compactDistance(band.haul_distance_m)}m`,
`EA=${compactVolume(band.ea_m3)}`,
`RR=${compactVolume(band.rr_m3)}`,
`BR=${compactVolume(band.br_m3)}`,
]
: room >= TWO_LINE_ROOM_PX
? [
`${band.index} · ${compactVolume(band.volume_m3)}`,
`L ${compactDistance(band.haul_distance_m)}m${equipment ? ` · ${equipment}` : ""}`,
]
: [`${band.index} · ${compactVolume(band.volume_m3)}`];
const width = Math.max(...lines.map(textWidth)) + PAD_X * 2 + NOTCH;
const height = lines.length * LINE_HEIGHT + PAD_Y * 2;
@@ -492,13 +226,19 @@ function appendBandBalloon(
}
/**
* 사토·토취 — 평형선 단차를 세로 화살표로 찍고 수량은 **언더바만** 두른 라벨로 적는다
* (2026-08-02 사용자 지시: 도형은 운반수단 표시 전용).
* 사토·토취 — **balloon과 같은 규칙**으로 그린다(2026-08-02 사용자 확정: B + D안).
*
* 예전에는 평형선 단차 높이만큼 세로 화살표를 통째로 그려서, 단차가 크면 화살표가 그래프
* 높이를 거의 다 먹고 라벨이 곡선 한가운데 얹혔다. 이제 화살표는 **위치만 가리키는 짧은
* 표시**로 자르고, 수량 라벨은 balloon과 같은 자리 찾기·지시선·드래그를 쓴다.
* 도형은 두르지 않고 **언더바만** 남긴다 — 도형은 운반수단 표시 전용이기 때문이다.
*/
function appendResidual(
group: SVGGElement,
svg: SVGSVGElement,
residual: HaulResidual,
box: BalanceLayerBox,
placed: PlacedBox[],
compact: boolean,
): void {
const centerX = Math.min(
@@ -507,42 +247,113 @@ function appendResidual(
);
const y1 = box.y(residual.level_from_m3);
const y2 = box.y(residual.level_to_m3);
// 단차가 그래프를 가로지를 만큼 커도 화살표는 짧게 — 크기는 라벨의 수량이 말한다.
const down = y2 > y1;
const tipY = Math.min(
Math.max(y1 + (down ? RESIDUAL_ARROW_PX : -RESIDUAL_ARROW_PX), box.top + 2),
box.bottom - 2,
);
// 도면은 사토 balloon에 거리(L)가 아니라 **측점**(M.N)을 적는다 — 사토는 운반이 아니라
// 그 자리 처리라 운반거리를 매기지 않기 때문이다(2026-08-02 도면 분석).
const at = (residual.from_m + residual.to_m) / 2;
const station = box.stationInterval ? ` · ${stationLabel(at, box.stationInterval)}` : "";
const label =
`${L(residual.kind === "spoil" ? "B06_MassHaul_Surplus" : "B06_MassHaul_Shortage")} ` +
`${compactVolume(residual.volume_m3)}${station}`;
const station = box.stationInterval ? stationLabel(at, box.stationInterval) : "-";
const kindLabel = L(residual.kind === "spoil" ? "B06_MassHaul_Surplus" : "B06_MassHaul_Shortage");
const label = `${kindLabel} ${compactVolume(residual.volume_m3)}㎥ · ${station}`;
const marker = svgElement("g", {
class: `b06-balance__residual b06-balance__residual--${residual.kind}`,
});
const title = svgElement("title");
title.textContent = label;
const head = down ? tipY - 6 : tipY + 6;
marker.append(
title,
svgElement("line", { x1: centerX, y1, x2: centerX, y2, class: "b06-balance__step-arrow" }),
svgElement("line", {
x1: centerX,
y1,
x2: centerX,
y2: tipY,
class: "b06-balance__step-arrow",
}),
svgElement("polygon", {
points: `${centerX},${y2} ${centerX - 3.5},${y2 + (y2 > y1 ? -6 : 6)} ${centerX + 3.5},${y2 + (y2 > y1 ? -6 : 6)}`,
points: `${centerX},${tipY} ${centerX - 3.5},${head} ${centerX + 3.5},${head}`,
class: "b06-balance__step-head",
}),
);
// 좁은 그래프에서는 화살표만 남기고 수량은 툴팁으로 돌린다.
if (!compact) {
const textY = (y1 + y2) / 2 + 3;
const width = textWidth(label);
marker.append(
svgText(label, { x: centerX + 5, y: textY, class: "b06-balance__residual-text" }),
svgElement("line", {
x1: centerX + 5,
y1: textY + 3,
x2: centerX + 5 + width,
y2: textY + 3,
class: "b06-balance__residual-underline",
}),
);
}
group.append(marker);
// 좁은 그래프에서는 화살표만 남기고 수량은 툴팁으로 돌린다.
if (compact) return;
// 자리가 나면 도면 양식으로 편다. 사토는 `L=` 대신 **`M.N=측점`**이고, 토취는 밖에서
// 사 오는 흙이라 지반유형(EA/RR/BR)이 없어 세 줄을 뺀다.
const room = box.bottom - box.top;
const lines =
room >= FULL_BALLOON_ROOM_PX
? [
`${kindLabel} ${residual.index}`,
`Q=${compactVolume(residual.volume_m3)}`,
`M.N=${station}`,
...(residual.kind === "spoil"
? [
`EA=${compactVolume(residual.ea_m3)}`,
`RR=${compactVolume(residual.rr_m3)}`,
`BR=${compactVolume(residual.br_m3)}`,
]
: []),
]
: [label];
const width = Math.max(...lines.map(textWidth)) + PAD_X * 2;
const height = lines.length * LINE_HEIGHT + PAD_Y * 2;
const home = findSlot(
placed,
centerX,
tipY + (down ? height : -height),
width,
height,
box,
!down,
);
placed.push({
left: home.x - width / 2,
right: home.x + width / 2,
top: home.y - height / 2,
bottom: home.y + height / 2,
});
const leader = svgElement("line", {
x1: centerX,
y1: tipY,
x2: home.x,
y2: home.y,
class: "b06-balance__leader",
});
const text = svgElement("g", {
class: `b06-balance__residual b06-balance__residual--${residual.kind} b06-balance__balloon`,
});
const labelTitle = svgElement("title");
labelTitle.textContent = label;
const bottomY = home.y - height / 2 + PAD_Y + LINE_HEIGHT * (lines.length - 0.2);
text.append(
labelTitle,
...lines.map((line, index) =>
svgText(line, {
x: home.x,
y: home.y - height / 2 + PAD_Y + LINE_HEIGHT * (index + 0.8),
"text-anchor": "middle",
class: "b06-balance__residual-text",
}),
),
// 도형 대신 언더바만 두른다 — 도형은 운반수단 표시 전용이다.
svgElement("line", {
x1: home.x - width / 2 + PAD_X,
y1: bottomY,
x2: home.x + width / 2 - PAD_X,
y2: bottomY,
class: "b06-balance__residual-underline",
}),
);
group.append(leader, text);
// 잔량 라벨도 끌어 옮길 수 있다. 띠 번호와 겹치지 않게 **음수 키**를 쓴다.
attachBalloonDrag(text, leader, svg, -residual.index, home);
}
/**
@@ -605,7 +416,6 @@ export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: Bala
const compact = box.bottom - box.top < 110;
for (const transfer of plan.transfers) appendTransfer(group, transfer, box, compact);
for (const residual of plan.residuals) appendResidual(group, residual, box, compact);
const faces: SVGGElement[] = [];
const balloons: Array<SVGGElement | null> = [];
@@ -617,6 +427,8 @@ export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: Bala
for (const band of block.bands)
balloons.push(appendBandBalloon(group, svg, block, band, box, placed));
}
// 잔량 라벨은 balloon **뒤에** 자리를 잡는다 — 같은 `placed`를 공유해 서로 겹치지 않는다.
for (const residual of plan.residuals) appendResidual(group, svg, residual, box, placed, compact);
const clearAll = (): void => {
for (const face of faces) face.classList.remove("is-active");
@@ -0,0 +1,328 @@
/* =============================================================================
* B06_wf3_ProfileCross_UI_MassHaul_Balloon.ts
* 유토곡선 balloon의 공통 부품 — 좌표 상자 · 도형 · 자리 찾기 · 드래그 · 위치 보관.
*
* 토량 분배 렌더러(`_UI_MassHaul_Balance_View`)가 700줄을 넘겨, 「무엇을 그리나」(그쪽)와
* 「어떻게 놓고 어떻게 잡나」(여기)로 갈랐다. 띠 balloon과 사토·토취 라벨이 이 부품을
* **함께** 쓰므로 배치·드래그 규칙이 한 곳에서만 정의된다.
* ========================================================================== */
import { L } from "./B06_wf3_ProfileCross_UI_Section_Common";
import type { LocaleKey } from "@ui/ui_template_locale";
import { svgElement } from "./B06_wf3_ProfileCross_UI_Section_Common";
/** 유토곡선 렌더러가 넘겨주는 좌표계와 그릴 수 있는 상자(px). */
export interface BalanceLayerBox {
x: (chainageM: number) => number;
y: (volumeM3: number) => number;
left: number;
right: number;
top: number;
bottom: number;
/**
* 주어진 가로 구간에서 곡선이 실제로 지나는 세로 범위(없으면 null).
* balloon을 곡선과 겹치지 않는 자리에 놓기 위해 유토곡선 렌더러가 넘겨준다.
*/
curveYAt?: (fromPx: number, toPx: number) => { top: number; bottom: number } | null;
/** 사토·토취 발생 위치를 측점으로 적기 위한 측점 간격(m). */
stationInterval?: number;
}
export const EQUIPMENT_LABEL: Record<string, LocaleKey> = {
free_haul: "B06_MassHaul_Equip_FreeHaul",
dozer: "B06_MassHaul_Equip_Dozer",
dump_truck: "B06_MassHaul_Equip_Dump",
};
/** balloon 도형 = 운반수단. 모르는 장비는 사각으로 떨어뜨린다. */
export type BalloonShape = "hexagon" | "ellipse" | "rect";
export const EQUIPMENT_SHAPE: Record<string, BalloonShape> = {
free_haul: "hexagon",
dozer: "ellipse",
dump_truck: "rect",
};
/* balloon 치수 — 도면과 달리 화면 그래프는 200px 안팎이라 최대한 작게 잡는다
(2026-08-02 사용자 지적: 도형이 크고 빈자리를 못 찾는다). 자세한 값은 툴팁이 맡는다. */
export const LINE_HEIGHT = 10;
export const PAD_X = 5;
export const PAD_Y = 3;
/** 육각형 좌우 꼭짓점이 파고드는 깊이(px). */
export const NOTCH = 5;
/** balloon과 곡선 사이 최소 틈(px). */
export const CURVE_CLEARANCE = 5;
/** 이보다 좁은 띠에는 balloon을 달지 않는다 — 서로 겹쳐 도면을 못 읽는다. */
export const MIN_BAND_WIDTH_PX = 34;
/**
* 그래프가 이만큼 높으면 balloon을 **도면 양식(항목별 세로 표기)**으로 편다
* (2026-08-02 사용자 지시). 도면 balloon이 `종무대 1 / Q= / L= / EA= / RR= / BR=` 6줄이다.
* 기본 패널 높이(유토곡선 190px)에서 바로 이 양식이 나온다.
*/
export const FULL_BALLOON_ROOM_PX = 165;
/** 그보다 낮으면 두 줄, 더 낮으면 물량 한 줄만 남긴다. */
export const TWO_LINE_ROOM_PX = 120;
/** 이보다 얇은 띠에는 평균운반거리 선을 긋지 않는다(경계현과 붙어 한 줄로 보인다). */
export const MIN_BAND_THICKNESS_PX = 9;
/** 사토·토취 단차 화살표 길이(px). 단차가 아무리 커도 이만큼만 그린다 — 크기는 라벨이 말한다. */
export const RESIDUAL_ARROW_PX = 22;
/**
* 사용자가 끌어 옮긴 balloon 위치(띠 번호 → [dx, dy]).
*
* 저장 경로가 둘이다:
* ① **프론트 캐시**(localStorage) — 끌어 옮기는 즉시. 새로고침·재접속에도 남는다.
* ② **영구저장소** — 횡단 확정 시점에 `mass_haul.balloon_offsets`로 넘어간다.
* 진입할 때는 ②가 있으면 그것으로 캐시를 덮어써 **다른 브라우저에서도 같은 자리**에 뜬다
* (2026-08-02 사용자 지시). 캐시는 경로별로 갈라 다른 노선의 위치가 섞이지 않게 한다.
*/
const BALLOON_OFFSET_PREFIX = "b06:balance-balloon-offset";
let offsetStorageKey = BALLOON_OFFSET_PREFIX;
export let balloonOffsets = new Map<number, [number, number]>();
function parseOffsets(source: unknown): Map<number, [number, number]> {
const result = new Map<number, [number, number]>();
if (!source || typeof source !== "object") return result;
for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
if (!Array.isArray(value) || value.length !== 2) continue;
const [dx, dy] = value as [number, number];
if (Number.isFinite(dx) && Number.isFinite(dy)) result.set(Number(key), [dx, dy]);
}
return result;
}
export function writeOffsets(): void {
try {
localStorage.setItem(offsetStorageKey, JSON.stringify(balloonOffsetsPayload()));
} catch {
/* 저장 실패는 무시 — 위치는 화면이 살아 있는 동안 유지된다. */
}
}
/**
* 노선이 바뀌거나 상세를 새로 받았을 때 캐시를 맞춘다.
* `stored`(영구저장소 값)가 있으면 **그것이 이긴다** — 다른 브라우저에서 옮긴 자리를 그대로
* 받아야 하기 때문이다. 없으면 이 브라우저의 캐시를 그대로 이어 쓴다.
*/
export function configureBalloonOffsets(scope: string, stored?: unknown): void {
offsetStorageKey = `${BALLOON_OFFSET_PREFIX}:${scope}`;
if (stored && Object.keys(stored as object).length) {
balloonOffsets = parseOffsets(stored);
writeOffsets();
return;
}
try {
balloonOffsets = parseOffsets(JSON.parse(localStorage.getItem(offsetStorageKey) ?? "null"));
} catch {
balloonOffsets = new Map();
}
}
/** 확정 시 영구저장소로 넘길 형태. */
export function balloonOffsetsPayload(): Record<string, [number, number]> {
return Object.fromEntries([...balloonOffsets].map(([key, value]) => [String(key), value]));
}
/** 자동 배치로 되돌린다 — 프론트 캐시를 비우고, 다음 확정 때 영구저장소도 빈 값으로 덮인다. */
export function resetBalloonOffsets(): void {
balloonOffsets = new Map();
try {
localStorage.removeItem(offsetStorageKey);
} catch {
/* 무시 */
}
}
/** SVG는 자동 크기가 없어 폭을 어림해야 한다. 한글은 라틴 글자보다 넓다. */
export function textWidth(value: string): number {
let width = 0;
for (const char of value) width += /[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(char) ? 9.5 : 5.2;
return width;
}
/** balloon 안 수치는 자리를 아껴야 한다 — 천 단위 구분만 두고 소수점은 버린다. */
export function compactVolume(value: number): string {
return Math.round(value).toLocaleString();
}
export function compactDistance(value: number): string {
return value >= 100 ? `${Math.round(value)}` : value.toFixed(1);
}
export function equipmentLabel(key: string | null): string {
if (!key) return "";
const locale = EQUIPMENT_LABEL[key];
return locale ? L(locale) : key;
}
/** 육각형 — 좌우 끝이 뾰족한 도면용 모양. */
export function hexagonPoints(cx: number, cy: number, width: number, height: number): string {
const halfW = width / 2;
const halfH = height / 2;
const notch = Math.min(NOTCH, width * 0.2);
return [
`${cx - halfW},${cy}`,
`${cx - halfW + notch},${cy - halfH}`,
`${cx + halfW - notch},${cy - halfH}`,
`${cx + halfW},${cy}`,
`${cx + halfW - notch},${cy + halfH}`,
`${cx - halfW + notch},${cy + halfH}`,
].join(" ");
}
export function balloonShape(
shape: BalloonShape,
cx: number,
cy: number,
width: number,
height: number,
): SVGElement {
if (shape === "ellipse") {
// 원형은 같은 글자를 담으려면 사각보다 커야 한다 — 모서리가 비기 때문이다.
return svgElement("ellipse", {
cx,
cy,
rx: width / 2 + 5,
ry: height / 2 + 3,
class: "b06-balance__balloon-shape",
});
}
if (shape === "rect") {
return svgElement("rect", {
x: cx - width / 2,
y: cy - height / 2,
width,
height,
rx: 2,
ry: 2,
class: "b06-balance__balloon-shape",
});
}
return svgElement("polygon", {
points: hexagonPoints(cx, cy, width, height),
class: "b06-balance__balloon-shape",
});
}
export interface PlacedBox {
left: number;
right: number;
top: number;
bottom: number;
}
export function overlaps(a: PlacedBox, b: PlacedBox): boolean {
return !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom);
}
/** 이 자리에 놓으면 곡선과 겹치는가. 곡선 정보를 못 받았으면 겹치지 않는 것으로 본다. */
export function hitsCurve(box: BalanceLayerBox, candidate: PlacedBox): boolean {
const band = box.curveYAt?.(candidate.left, candidate.right);
if (!band) return false;
return !(
candidate.bottom + CURVE_CLEARANCE <= band.top || candidate.top - CURVE_CLEARANCE >= band.bottom
);
}
/**
* 빈자리를 찾는다. 선호 지점에서 **가로·세로 양쪽으로** 후보를 넓혀 가며, 이미 놓인 balloon과
* 겹치지 않고 곡선도 피하는 첫 자리를 고른다(2026-08-02 사용자 지적: 남는 공간을 못 찾음).
*
* 곡선까지 피하는 자리가 끝내 없으면 **곡선 조건만 버리고** 다시 훑는다 — 겹치더라도 놓는 게
* 안 그리는 것보다 낫고, 사용자가 끌어 옮길 수 있다.
*/
export function findSlot(
placed: PlacedBox[],
preferredX: number,
preferredY: number,
width: number,
height: number,
box: BalanceLayerBox,
upward: boolean,
): { x: number; y: number } {
const stepY = height + 3;
const stepX = width * 0.55;
const clampX = (value: number): number =>
Math.min(Math.max(value, box.left + width / 2), box.right - width / 2);
const candidates: Array<{ x: number; y: number }> = [];
for (let ring = 0; ring < 7; ring += 1) {
for (const dy of ring === 0 ? [0] : upward ? [-ring, ring] : [ring, -ring]) {
for (const dx of ring === 0 ? [0] : [0, -1, 1, -2, 2]) {
candidates.push({ x: clampX(preferredX + dx * stepX), y: preferredY + dy * stepY });
}
}
}
for (const avoidCurve of [true, false]) {
for (const candidate of candidates) {
if (candidate.y - height / 2 < box.top || candidate.y + height / 2 > box.bottom) continue;
const rect: PlacedBox = {
left: candidate.x - width / 2,
right: candidate.x + width / 2,
top: candidate.y - height / 2,
bottom: candidate.y + height / 2,
};
if (placed.some((entry) => overlaps(entry, rect))) continue;
if (avoidCurve && hitsCurve(box, rect)) continue;
return candidate;
}
}
return { x: clampX(preferredX), y: preferredY };
}
/**
* balloon을 끌어 옮길 수 있게 한다. 자동 배치가 아무리 좋아도 200px 높이 그래프에서는
* 빈자리가 모자라므로, 마지막 판단은 사용자에게 맡긴다(2026-08-02 사용자 지시).
* 옮긴 만큼 지시선 끝점도 따라가고, 놓은 자리는 세션에 남는다.
*/
export function attachBalloonDrag(
balloon: SVGGElement,
leader: SVGLineElement,
svg: SVGSVGElement,
key: number,
home: { x: number; y: number },
): void {
let start: { x: number; y: number; dx: number; dy: number } | null = null;
const scale = (): number => {
const rendered = svg.getBoundingClientRect().width;
const viewBox = svg.viewBox.baseVal?.width || rendered;
return rendered > 0 && viewBox > 0 ? viewBox / rendered : 1;
};
const apply = (dx: number, dy: number): void => {
balloon.setAttribute("transform", `translate(${dx} ${dy})`);
leader.setAttribute("x2", String(home.x + dx));
leader.setAttribute("y2", String(home.y + dy));
};
balloon.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
// 카드·측점 선택으로 번지면 그래프가 다시 그려져 끌던 balloon이 사라진다.
event.stopPropagation();
event.preventDefault();
const current = balloonOffsets.get(key) ?? [0, 0];
start = { x: event.clientX, y: event.clientY, dx: current[0], dy: current[1] };
balloon.setPointerCapture(event.pointerId);
balloon.classList.add("is-dragging");
});
balloon.addEventListener("pointermove", (event) => {
if (!start) return;
const factor = scale();
const dx = start.dx + (event.clientX - start.x) * factor;
const dy = start.dy + (event.clientY - start.y) * factor;
balloonOffsets.set(key, [dx, dy]);
apply(dx, dy);
});
const finish = (event: PointerEvent): void => {
if (!start) return;
start = null;
balloon.releasePointerCapture(event.pointerId);
balloon.classList.remove("is-dragging");
writeOffsets();
};
balloon.addEventListener("pointerup", finish);
balloon.addEventListener("pointercancel", finish);
// 두 번 누르면 자동 배치로 되돌린다 — 잘못 끌었을 때 되돌릴 길을 남긴다.
balloon.addEventListener("dblclick", (event) => {
event.stopPropagation();
balloonOffsets.delete(key);
apply(0, 0);
writeOffsets();
});
}
@@ -2,10 +2,17 @@
* B06_wf3_ProfileCross_UI_MassHaul_Curve.ts
* 누가토량 곡선의 기하 — 포물선 보간 · 수평선 교점 · 극값 추출.
*
* 유토곡선은 측점 사이에서 **직선이 아니라 포물선**이다. 측점별 단면적이 선형으로 변하면
* 그 적분인 누가토량은 2차식이 되기 때문이다. 이걸 직선으로 보면 평균운반거리(반종거 현)가
* 참고 도면 대비 약 40% 짧게 나와 장비 경계 판정이 어긋난다 — 도면에서 측점간격 20m 구간의
* 반종거가 `20/√2 = 14.14m` 12번 반복해 찍히는 것이 근거다(직선이면 10m).
* ── 측점 사이를 무엇으로 잇는가: **직선**(2026-08-02 사용자 확정) ──────────
* 이론상으로는 포물선이다 — 평균단면법 `V = (A₁+A₂)/2 × L`이 "구간 내 단면적이 선형으로
* 변한다"를 전제하므로, 그 적분인 누가토량은 2차식이 된다. 참고 도면의 종무대 평균운반거리에
* `20/√2 = 14.14m` 12번 찍히는 것도 포물선 + "종거 1/2 수평선" 규칙으로만 설명된다
* (직선이면 `20/2 = 10m`).
*
* 그럼에도 **직선을 쓴다.** 측점을 기준으로 절·성토량을 읽고 판단하는 것이 실무이고, 도면도
* 직선으로 그린다는 사용자(임도설계 실무자) 판단이다. 대가는 평균운반거리가 포물선 대비
* 약 40% 짧게 나오는 것이며, 이는 20m·70m 장비 경계 판정에 직접 영향을 준다.
*
* 되돌릴 일이 생기면 `PARABOLIC_MASS_CURVE` 하나만 켜면 된다 — 그리기·재기가 함께 바뀐다.
*
* 토량 분배 계산(`_UI_MassHaul_Balance`)이 700줄을 넘겨 곡선 기하만 여기로 떼어냈다.
* ========================================================================== */
@@ -14,6 +21,13 @@ import type { MassHaulPoint } from "./B06_wf3_ProfileCross_UI_MassHaul";
export const EPSILON = 1e-9;
/**
* 누가토량 곡선을 측점 사이에서 포물선으로 볼지 여부. **false = 직선**(사용자 확정).
* 이 값 하나가 곡선 렌더링(`curvePath`)과 수평선 교점(`crossFrom`)을 함께 바꾼다 —
* 둘이 어긋나면 수평선 끝이 곡선에서 떠 보인다(2026-08-02에 이미 겪은 문제).
*/
export const PARABOLIC_MASS_CURVE = false;
/**
* 구간 하나의 2차식 계수. 측점 사이에서 단면적이 선형으로 변하므로 누가토량은
* `V(t) = V₀ + a₀·t + (a₁ a₀)·t² / (2Δ)` (t = 구간 시작으로부터의 거리)
@@ -47,8 +61,9 @@ export function segmentOf(points: MassHaulPoint[], index: number): Segment | nul
x0: a.chainage_m,
span,
v0: a.cumulative_volume_m3,
a0: start + shift,
curvature: (end - start) / (2 * span),
// 직선 모드에서는 구간 평균 단면적 하나로 기울기를 잡는다 → 곡률 0 = 직선.
a0: PARABOLIC_MASS_CURVE ? start + shift : average,
curvature: PARABOLIC_MASS_CURVE ? (end - start) / (2 * span) : 0,
};
}
@@ -64,15 +64,12 @@ function seriesClass(series: MassHaulSeries, base: string): string {
}
/**
* 곡선을 **2차 베지에 경로**로 그린다. 유토곡선은 측점 사이에서 포물선이므로
* (단면적이 선형 → 그 적분인 누가토량은 2차식) 직선으로 이으면 실제 곡선과 어긋난다.
* 곡선 경로. **`_UI_MassHaul_Curve`가 쓰는 것과 같은 기하**로 그려야 한다 — 평형선·장비
* 경계현·평균운반거리선의 양 끝이 그 기하와의 교점으로 계산되므로, 그리기와 재기가 어긋나면
* 수평선 끝이 곡선에서 떠 보인다(2026-08-02에 겪은 문제).
*
* 이게 눈에 보이는 이유는 **수평선 때문**이다. 평형선·장비 경계현·평균운반거리선의 양 끝은
* 포물선과의 교점으로 계산되는데, 곡선만 직선으로 그리면 그 끝점이 곡선에서 떨어져 보인다
* (2026-08-02 사용자 지적). 두 곳의 기하를 같은 것으로 맞춘다.
*
* 제어점은 `(x₀ + Δ/2, V₀ + a₀Δ/2)` — 이 자리에 두면 2차 베지에가 구간 포물선과
* **정확히** 같아진다. 순단면적이 없으면 곡률이 0이 되어 자동으로 직선으로 떨어진다.
* 지금은 직선 모드(`PARABOLIC_MASS_CURVE = false`)라 `L` 명령만 나온다. 포물선 모드를 켜면
* 제어점 `(x₀ + Δ/2, V₀ + a₀Δ/2)`의 2차 베지에가 구간 포물선과 **정확히** 같아진다.
*/
function curvePath(
points: MassHaulPoint[],
@@ -88,6 +85,11 @@ function curvePath(
parts.push(`L ${x(point.chainage_m)},${y(point.cumulative_volume_m3)}`);
continue;
}
if (Math.abs(segment.curvature) < 1e-12) {
// 직선 모드 — 제어점을 둘 이유가 없다. 명령 자체를 직선으로 낸다.
parts.push(`L ${x(point.chainage_m)},${y(point.cumulative_volume_m3)}`);
continue;
}
const controlX = segment.x0 + segment.span / 2;
const controlY = segment.v0 + (segment.a0 * segment.span) / 2;
parts.push(
@@ -43,6 +43,7 @@ import {
} from "./B06_wf3_ProfileCross_UI_Standard_Panel";
import "./B06_wf3_ProfileCross_UI_Style.css";
import "./B06_wf3_ProfileCross_UI_Style_Cross.css";
import "./B06_wf3_ProfileCross_UI_Style_Cross_Areas.css";
import "./B06_wf3_ProfileCross_UI_Style_MassHaul.css";
function L(key: keyof typeof ui_locales): string {
@@ -525,212 +525,46 @@
font-family: var(--font-mono);
}
/* 암 경계선 제어(E-7) — 그래프 영역 **하단 행 가운데**(2026-08-02 사용자 지시).
우측 맞춤이던 것을 가운데로 옮겼다. `left: 50%` + 음수 translate가 폭을 모르고도
가운데를 잡는 유일한 방법이다(절대 위치라 flex 정렬이 안 먹는다). */
/* 암 경계선 제어(E-7) — **중심고·방위각 행 가운데**(2026-08-02 사용자 지시).
그래프 안 오버레이였을 때는 단면을 가렸다. footer가 space-between이라 두 수치 사이에
끼우면 자연히 가운데로 온다. */
.b06-cross-card__rockb {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 2px;
background: color-mix(in srgb, var(--color-surface) 82%, transparent);
flex: 0 0 auto;
border-radius: var(--radius-inputs);
padding: 1px 4px;
}
/* 절·성토 면적 오버레이(E-4) — 그래프 중상단, 배경색으로 그래프 선과 겹쳐도 가독.
절토/성토 × EA·RR·BR·계 표라서 칩을 늘어놓을 때보다 가로 폭을 덜 먹는다. */
.b06-cross-card__areas {
/* 줌 버튼 — 그래프 우측 상단 오버레이. 휠 대신 쓰는 조작구다(2026-08-02 사용자 확정). */
.b06-cross-card__zoom {
position: absolute;
top: var(--spacing-8);
left: 50%;
transform: translateX(-50%);
padding: 2px 6px;
top: var(--spacing-4);
right: var(--spacing-4);
z-index: 2;
display: flex;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
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;
background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent);
}
.b06-cross-card__areas th,
.b06-cross-card__areas td {
padding: 0 5px;
text-align: center;
font-weight: var(--font-weight-regular);
}
/* 열 제목은 가운데 맞춤 — 값은 우측 정렬이라 자릿수가 흔들려도 머리글은 열 중앙에 선다. */
.b06-cross-card__areas thead th {
text-align: center;
color: var(--color-text-muted);
}
/* 행 이름(절토/성토)은 왼쪽 정렬 — 숫자 열과 구분된다. */
.b06-cross-card__areas tbody th {
text-align: left;
}
/* 면적 표기 색은 **테마 토큰만** 쓴다. 고정 RGB를 박으면 다크 테마에서 글자가 배경에
묻힌다(발파암에 쓰던 검정이 그랬다). 차트 팔레트는 밝기가 두 테마 모두에 맞춰져 있다. */
.b06-design__area--cut,
.b06-design__area--cut_total {
color: var(--color-danger);
}
/* 절토 내역 — 토사(EA), 리핑암(RR), 발파암(BR)을 색으로 갈라 둔다. */
.b06-design__area--cut_soil {
color: var(--color-chart-2);
}
/* RR은 `--color-slate`를 쓰면 안 된다 — 그 토큰이 곧 `--color-text-secondary`라
라이트 테마에서 회색 글자가 "비활성"으로 읽히고, 28% 밴드는 흰 배경에 묻힌다.
`--color-warning`도 못 쓴다(암 경계선 점선이 이미 그 색이라 밴드가 제 경계선과 섞인다). */
.b06-design__area--cut_rr {
color: var(--color-success);
}
.b06-design__area--cut_br {
color: var(--color-chart-3);
}
.b06-design__area--fill {
color: var(--color-chart-0);
}
.b06-design__area--unset {
color: var(--color-text-muted);
}
/* 선택된 카드에서만 값 칸이 버튼이 된다 — 부모가 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;
}
/* 켠 칸은 글자색을 유지한 채 같은 색으로 옅게 칠하고 테두리를 둘러 대비를 살린다. */
.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);
}
/* 면적 강조 밴드 — 평소엔 클릭 대상으로만 남고, 켜질 때만 채워진다. */
.b06-chart__area > polygon {
fill: transparent;
stroke: none;
cursor: pointer;
}
/* 밴드 색은 위 면적표 글자색과 짝을 맞춘다 — 어느 숫자를 켰는지 색으로 바로 읽히게. */
.b06-chart__area--cut_soil.is-active > polygon {
fill: color-mix(in srgb, var(--color-chart-2) 28%, transparent);
stroke: var(--color-chart-2);
stroke-width: 1.2;
}
/* 암반 밴드는 하나지만 색은 암종을 따라간다 — 표의 RR/BR 글자색과 같아야 짝이 읽힌다. */
.b06-chart__area--cut_rr.is-active > polygon {
fill: color-mix(in srgb, var(--color-success) 28%, transparent);
stroke: var(--color-success);
stroke-width: 1.2;
}
.b06-chart__area--cut_br.is-active > polygon {
fill: color-mix(in srgb, var(--color-chart-3) 28%, transparent);
stroke: var(--color-chart-3);
stroke-width: 1.2;
}
.b06-chart__area--fill.is-active > polygon {
fill: color-mix(in srgb, var(--color-chart-0) 28%, transparent);
stroke: var(--color-chart-0);
stroke-width: 1.2;
}
/* 횡단 표준단면 설계선 오버레이. 기본은 실선. */
.b06-chart__design-cross {
fill: none;
stroke: var(--color-royal-amethyst);
stroke-width: 1.8;
stroke-linejoin: round;
}
/* 지면선과 겹치는 구간만 점선(dash:gap = 1:1)으로 그려 뒤 지표선이 빈 칸으로 비쳐 보이게 한다. */
.b06-chart__design-cross--overlap {
stroke-dasharray: 4 4;
}
/* 차도·노견 경계 짧은 수직 틱(N-4-2) — 설계선과 같은 계열, 얇게. */
.b06-chart__carriageway-tick {
stroke: var(--color-royal-amethyst);
stroke-width: 1.2;
}
/* 암 경계선(설계선 복사 + 오프셋): 리핑암·발파암 구간 점선 */
.b06-chart__rock-boundary {
fill: none;
stroke: var(--color-warning);
stroke-width: 1.6;
stroke-dasharray: 6 4;
stroke-linejoin: round;
opacity: 0.9;
}
/* 포장층 박스: 노면 양 끝점 기준 두께만큼 하향 채움 */
.b06-chart__pavement {
fill: color-mix(in srgb, var(--color-text-secondary) 30%, transparent);
stroke: var(--color-text-secondary);
stroke-width: 1;
}
/* 암 경계선 상/하/리셋 제어 (B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용) */
.b06-design__rockb-btn {
.b06-cross-card__zoom-btn {
width: 22px;
height: 22px;
padding: 0;
font-size: 0.7rem;
line-height: 1;
color: var(--color-text-secondary);
background: var(--color-surface);
border: none;
border-left: 1px solid var(--color-border);
background: none;
color: var(--color-text-secondary);
font-size: 0.8rem;
line-height: 1;
cursor: pointer;
}
.b06-design__rockb-btn:first-child {
.b06-cross-card__zoom-btn:first-child {
border-left: none;
}
.b06-design__rockb-btn:hover {
.b06-cross-card__zoom-btn:hover {
color: var(--color-text);
background: var(--color-surface-raised);
}
.b06-design__rockb-btn.is-reset {
color: var(--color-warning);
}
.b06-design__rockb-readout {
padding: 0 var(--spacing-8);
font-size: 0.72rem;
font-family: var(--font-mono);
color: var(--color-warning);
align-self: center;
}
/* 포장 제안 배지: B05 법정 경사 분석이 포장을 권장한 측점 표시 */
.b06-design__paved-badge {
font-size: 0.72rem;
color: var(--color-warning);
cursor: help;
background: var(--color-surface);
}
@@ -0,0 +1,205 @@
/* =============================================================================
* B06_wf3_ProfileCross_UI_Style_Cross_Areas.css
* 횡단 카드의 **면적 오버레이 계열** 스타일 ·성토 면적 , 면적 강조 밴드,
* 설계선· 경계선·포장층, 경계선 제어 버튼.
*
* `_UI_Style_Cross.css` 700줄을 넘겨, 도면 뼈대(섹션·카드·차트·/)
* 위에 얹는 면적 표기 계열을 갈랐다. 색은 모두 theme.css 토큰만 쓴다.
* ========================================================================== */
/* ·성토 면적 오버레이(E-4) 그래프 중상단, 배경색으로 그래프 선과 겹쳐도 가독.
절토/성토 × EA·RR·BR· 표라서 칩을 늘어놓을 때보다 가로 폭을 먹는다. */
.b06-cross-card__areas {
position: absolute;
top: var(--spacing-8);
left: 50%;
transform: translateX(-50%);
padding: 2px 6px;
border-radius: var(--radius-inputs);
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: center;
font-weight: var(--font-weight-regular);
}
/* 열 제목은 가운데 맞춤 — 값은 우측 정렬이라 자릿수가 흔들려도 머리글은 열 중앙에 선다. */
.b06-cross-card__areas thead th {
text-align: center;
color: var(--color-text-muted);
}
/* 행 이름(절토/성토)은 왼쪽 정렬 — 숫자 열과 구분된다. */
.b06-cross-card__areas tbody th {
text-align: left;
}
/* 면적 표기 색은 **테마 토큰만** 쓴다. 고정 RGB를 박으면 다크 테마에서 글자가 배경에
묻힌다(발파암에 쓰던 검정이 그랬다). 차트 팔레트는 밝기가 테마 모두에 맞춰져 있다. */
.b06-design__area--cut,
.b06-design__area--cut_total {
color: var(--color-danger);
}
/* 절토 내역 — 토사(EA), 리핑암(RR), 발파암(BR)을 색으로 갈라 둔다. */
.b06-design__area--cut_soil {
color: var(--color-chart-2);
}
/* RR은 `--color-slate` 쓰면 된다 토큰이 `--color-text-secondary`
라이트 테마에서 회색 글자가 "비활성"으로 읽히고, 28% 밴드는 배경에 묻힌다.
`--color-warning` 쓴다( 경계선 점선이 이미 색이라 밴드가 경계선과 섞인다). */
.b06-design__area--cut_rr {
color: var(--color-success);
}
.b06-design__area--cut_br {
color: var(--color-chart-3);
}
.b06-design__area--fill {
color: var(--color-chart-0);
}
.b06-design__area--unset {
color: var(--color-text-muted);
}
/* 선택된 카드에서만 값 칸이 버튼이 된다 — 부모가 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;
}
/* 켠 칸은 글자색을 유지한 채 같은 색으로 옅게 칠하고 테두리를 둘러 대비를 살린다. */
.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);
}
/* 면적 강조 밴드 — 평소엔 클릭 대상으로만 남고, 켜질 때만 채워진다. */
.b06-chart__area > polygon {
fill: transparent;
stroke: none;
cursor: pointer;
}
/* 밴드 색은 위 면적표 글자색과 짝을 맞춘다 — 어느 숫자를 켰는지 색으로 바로 읽히게. */
.b06-chart__area--cut_soil.is-active > polygon {
fill: color-mix(in srgb, var(--color-chart-2) 28%, transparent);
stroke: var(--color-chart-2);
stroke-width: 1.2;
}
/* 암반 밴드는 하나지만 색은 암종을 따라간다 — 표의 RR/BR 글자색과 같아야 짝이 읽힌다. */
.b06-chart__area--cut_rr.is-active > polygon {
fill: color-mix(in srgb, var(--color-success) 28%, transparent);
stroke: var(--color-success);
stroke-width: 1.2;
}
.b06-chart__area--cut_br.is-active > polygon {
fill: color-mix(in srgb, var(--color-chart-3) 28%, transparent);
stroke: var(--color-chart-3);
stroke-width: 1.2;
}
.b06-chart__area--fill.is-active > polygon {
fill: color-mix(in srgb, var(--color-chart-0) 28%, transparent);
stroke: var(--color-chart-0);
stroke-width: 1.2;
}
/* 횡단 표준단면 설계선 오버레이. 기본은 실선. */
.b06-chart__design-cross {
fill: none;
stroke: var(--color-royal-amethyst);
stroke-width: 1.8;
stroke-linejoin: round;
}
/* 지면선과 겹치는 구간만 점선(dash:gap = 1:1)으로 그려 뒤 지표선이 빈 칸으로 비쳐 보이게 한다. */
.b06-chart__design-cross--overlap {
stroke-dasharray: 4 4;
}
/* 차도·노견 경계 짧은 수직 틱(N-4-2) — 설계선과 같은 계열, 얇게. */
.b06-chart__carriageway-tick {
stroke: var(--color-royal-amethyst);
stroke-width: 1.2;
}
/* 암 경계선(설계선 복사 + 오프셋): 리핑암·발파암 구간 점선 */
.b06-chart__rock-boundary {
fill: none;
stroke: var(--color-warning);
stroke-width: 1.6;
stroke-dasharray: 6 4;
stroke-linejoin: round;
opacity: 0.9;
}
/* 포장층 박스: 노면 양 끝점 기준 두께만큼 하향 채움 */
.b06-chart__pavement {
fill: color-mix(in srgb, var(--color-text-secondary) 30%, transparent);
stroke: var(--color-text-secondary);
stroke-width: 1;
}
/* 암 경계선 상/하/리셋 제어 (B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용) */
.b06-design__rockb-btn {
width: 22px;
height: 22px;
padding: 0;
font-size: 0.7rem;
line-height: 1;
color: var(--color-text-secondary);
background: var(--color-surface);
border: none;
border-left: 1px solid var(--color-border);
cursor: pointer;
}
.b06-design__rockb-btn:first-child {
border-left: none;
}
.b06-design__rockb-btn:hover {
color: var(--color-text);
background: var(--color-surface-raised);
}
.b06-design__rockb-btn.is-reset {
color: var(--color-warning);
}
.b06-design__rockb-readout {
padding: 0 var(--spacing-8);
font-size: 0.72rem;
font-family: var(--font-mono);
color: var(--color-warning);
align-self: center;
}
/* 포장 제안 배지: B05 법정 경사 분석이 포장을 권장한 측점 표시 */
.b06-design__paved-badge {
font-size: 0.72rem;
color: var(--color-warning);
cursor: help;
}
@@ -292,16 +292,24 @@
stroke-width: 1.4;
}
.b06-balance__residual--spoil .b06-balance__step-arrow,
.b06-balance__residual--spoil .b06-balance__residual-text {
/* 색은 **선에는 stroke, 글자에는 fill**로만 준다 글자의 stroke는 아래 가독성 테두리
전용이라, 여기서 함께 칠하면 글자가 색으로 굵게 번진다. */
.b06-balance__residual--spoil .b06-balance__step-arrow {
stroke: var(--color-danger);
}
.b06-balance__residual--spoil .b06-balance__residual-text {
fill: var(--color-danger);
}
.b06-balance__residual--borrow .b06-balance__step-arrow,
/* 토취는 **주황**이다(2026-08-02 사용자 확정: D안). 초록은 "정상"으로 읽혀,
흙이 모자라 사와야 한다는 경고 성격과 맞지 않았다. */
.b06-balance__residual--borrow .b06-balance__step-arrow {
stroke: var(--color-chart-2);
}
.b06-balance__residual--borrow .b06-balance__residual-text {
stroke: var(--color-success);
fill: var(--color-success);
fill: var(--color-chart-2);
}
.b06-balance__residual--spoil .b06-balance__step-head {
@@ -310,12 +318,21 @@
}
.b06-balance__residual--borrow .b06-balance__step-head {
fill: var(--color-success);
fill: var(--color-chart-2);
stroke: none;
}
/* 사토·토취 라벨도 balloon과 같이 끌어 옮길 있다(도형은 두르지 않고 언더바만).
도형 배경이 없으므로 글자에 표면색 테두리를 둘러 곡선 위에서도 읽히게 한다. */
.b06-balance__residual.b06-balance__balloon {
cursor: move;
}
.b06-balance__residual-text {
stroke: none;
paint-order: stroke;
stroke: var(--color-surface-raised);
stroke-width: 3;
stroke-linejoin: round;
font-size: 9px;
font-variant-numeric: tabular-nums;
}
@@ -327,7 +344,7 @@
}
.b06-balance__residual--borrow .b06-balance__residual-underline {
stroke: var(--color-success);
stroke: var(--color-chart-2);
stroke-width: 1.2;
}
+4
View File
@@ -254,6 +254,10 @@ export const ui_locales_b2 = {
B06_MassHaul_Check: ["운반·성토 검산", "Haul vs fill check"],
B06_MassHaul_Check_Ok: ["일치", "Balanced"],
B06_MassHaul_Balloon_Reset: ["도형 위치 초기화", "Reset label positions"],
/* 횡단도 줌 버튼 — 휠은 페이지 스크롤로 돌려주고 확대·축소는 버튼이 맡는다. */
B06_Profile_View_ZoomIn: ["확대", "Zoom in"],
B06_Profile_View_ZoomOut: ["축소", "Zoom out"],
B06_Profile_View_ZoomFit: ["원래 크기", "Fit"],
B06_MassHaul_Balloon_Reset_Tip: [
"끌어 옮긴 유토곡선 도형을 자동 배치로 되돌립니다. 횡단을 확정하면 저장된 위치도 함께 지워집니다.",
"Restores dragged mass-haul labels to automatic placement. Confirming the sections also clears the stored positions.",