refactor(472-2 2-C 유토곡선 나눔): common_util_mass_haul_view 866 줄 → 그래프 · 말풍선(_callout) · 범례 · 요약(_legend) 셋 · 동작 같음 · B05 범례 import 고침 · 팝아웃 시험 목록에 새 파일 둘
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -44,11 +44,8 @@ import {
|
|||||||
} from "@util/common_util_mass_haul";
|
} from "@util/common_util_mass_haul";
|
||||||
import { badgeValuesFrom, type MassHaulBadgeValues } from "@util/common_util_mass_haul_badge";
|
import { badgeValuesFrom, type MassHaulBadgeValues } from "@util/common_util_mass_haul_badge";
|
||||||
import { resetBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
|
import { resetBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
|
||||||
import {
|
import { createMassHaulChart, MASS_HAUL_MIN_HEIGHT } from "@util/common_util_mass_haul_view";
|
||||||
createMassHaulChart,
|
import { createMassHaulLegend } from "@util/common_util_mass_haul_legend";
|
||||||
createMassHaulLegend,
|
|
||||||
MASS_HAUL_MIN_HEIGHT,
|
|
||||||
} from "@util/common_util_mass_haul_view";
|
|
||||||
import { Y_WINDOW_BAKED_ATTR } from "../B06_Section/B06_Section_UI_Longitudinal";
|
import { Y_WINDOW_BAKED_ATTR } from "../B06_Section/B06_Section_UI_Longitudinal";
|
||||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<MassHaulBasis, LocaleKey> = {
|
||||||
|
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<string>,
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -13,15 +13,17 @@
|
|||||||
* Y축은 데이터 범위 기반 자동 스케일이다. 참고 도면의 수직축척이 공구마다 다르므로
|
* Y축은 데이터 범위 기반 자동 스케일이다. 참고 도면의 수직축척이 공구마다 다르므로
|
||||||
* (1공구 V=1:10,000 / 3공구 V=1:50,000) 고정 축척을 쓰면 안 된다.
|
* (1공구 V=1:10,000 / 3공구 V=1:50,000) 고정 축척을 쓰면 안 된다.
|
||||||
* 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md 3.2절
|
* 근거: 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 { MassHaulStationSource } from "./common_util_mass_haul_types";
|
||||||
import type { HaulPlan } from "./common_util_mass_haul_balance";
|
import type { HaulPlan } from "./common_util_mass_haul_balance";
|
||||||
import type { LocaleKey } from "@ui/ui_template_locale";
|
import { appendBalanceLayer } from "./common_util_mass_haul_balance_view";
|
||||||
import { MASS_HAUL_BALANCE_KEY } from "./common_util_mass_haul";
|
import { appendVolumeCallout, formatVolume, textWidth } from "./common_util_mass_haul_callout";
|
||||||
import { appendBalanceLayer, haulPlanChips } from "./common_util_mass_haul_balance_view";
|
|
||||||
import { segmentOf } from "./common_util_mass_haul_curve";
|
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 { L, stationLabel, svgElement, svgText } from "./common_util_svg";
|
||||||
import { viewOf } from "./common_util_view_window";
|
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_TOP = 10;
|
||||||
const MASS_PAD_BOTTOM = 8;
|
const MASS_PAD_BOTTOM = 8;
|
||||||
|
|
||||||
const BASIS_LABEL: Record<MassHaulBasis, LocaleKey> = {
|
|
||||||
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`가 쓰는 것과 같은 기하**로 그려야 한다 — 평형선·장비
|
* 곡선 경로. **`common_util_mass_haul_curve`가 쓰는 것과 같은 기하**로 그려야 한다 — 평형선·장비
|
||||||
* 경계현·평균운반거리선의 양 끝이 그 기하와의 교점으로 계산되므로, 그리기와 재기가 어긋나면
|
* 경계현·평균운반거리선의 양 끝이 그 기하와의 교점으로 계산되므로, 그리기와 재기가 어긋나면
|
||||||
@@ -173,14 +161,6 @@ function curvePath(
|
|||||||
return parts.join(" ");
|
return parts.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 누가토량 값 표기 — 천 단위 구분 + 소수점 1자리. */
|
|
||||||
export function formatVolume(value: number): string {
|
|
||||||
return value.toLocaleString(undefined, {
|
|
||||||
minimumFractionDigits: 1,
|
|
||||||
maximumFractionDigits: 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Y축 기본 스케일(㎥). 계획선을 편집할 때마다 범위가 데이터로 다시 잡히면 그래프가
|
* Y축 기본 스케일(㎥). 계획선을 편집할 때마다 범위가 데이터로 다시 잡히면 그래프가
|
||||||
* 출렁여 변화를 읽기 어렵다(2026-08-04 사용자 확정) — 기본 ±200㎥으로 고정하고,
|
* 출렁여 변화를 읽기 어렵다(2026-08-04 사용자 확정) — 기본 ±200㎥으로 고정하고,
|
||||||
@@ -295,119 +275,6 @@ export function massHaulPointAt(
|
|||||||
return bestGap <= 0.5 ? best : null;
|
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(
|
export function createMassHaulChart(
|
||||||
series: MassHaulSeries[],
|
series: MassHaulSeries[],
|
||||||
visibleKeys: ReadonlySet<string>,
|
visibleKeys: ReadonlySet<string>,
|
||||||
@@ -734,133 +601,3 @@ export function createMassHaulChart(
|
|||||||
);
|
);
|
||||||
return svg;
|
return svg;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 곡선 표시/숨김 버튼 줄.
|
|
||||||
*
|
|
||||||
* 그래프 **아래**에 둔다(2026-08-02 사용자 지시). 예전에는 그래프 위에 겹치는 오버레이였는데,
|
|
||||||
* 가로 스크롤 컨테이너 안에 있으면 컨테이너 폭 계산에 끼어들어 종단면도와 X축이 어긋난다.
|
|
||||||
* 지금은 스크롤 컨테이너 **밖** 패널 본문에 붙어 곡선 폭에 전혀 관여하지 않는다.
|
|
||||||
*/
|
|
||||||
export function createMassHaulLegend(
|
|
||||||
series: MassHaulSeries[],
|
|
||||||
visibleKeys: ReadonlySet<string>,
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ POPOUT_FILES = [
|
|||||||
"B05_Profile/B05_Profile_UI_Drainage_Panel.ts",
|
"B05_Profile/B05_Profile_UI_Drainage_Panel.ts",
|
||||||
"B05_Profile/B05_Profile_UI_Structures_Marks.ts",
|
"B05_Profile/B05_Profile_UI_Structures_Marks.ts",
|
||||||
"common_util/common_util_mass_haul_view.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` 처럼 창을 짚은 것은 제외
|
# 메인 창 전역 틀 · 시계 — `x.requestAnimationFrame` 처럼 창을 짚은 것은 제외
|
||||||
MAIN_WINDOW_CLOCK = re.compile(
|
MAIN_WINDOW_CLOCK = re.compile(
|
||||||
|
|||||||
Reference in New Issue
Block a user