Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_2

This commit is contained in:
2026-10-19 08:51:19 +09:00
17 changed files with 233 additions and 322 deletions
+4 -8
View File
@@ -152,8 +152,6 @@ export const STATE_REGISTRY = {
kindreset: { bucket: "draft", scope: "project" },
/** 배수관·암거 조정창에서 예약한 옵션 값. */
culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` },
/** 배수관 이동(측점 옮김) 예약. */
culvertmove: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertmove:${p}:${r}` },
/** 암 경계선 오프셋(측점별). */
rockb: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:rockb:${p}:${r}` },
/** 측점별 암 절토 경사비(1:n 의 n) — 카드에서 넣은 사용자 값(2026-09-07).
@@ -166,12 +164,10 @@ export const STATE_REGISTRY = {
berm: { bucket: "draft", scope: "route" },
/** 표준 횡단면 설정 패널의 편집값.
*
* ⚠ **[저장]·[확정] 뒤에도 지우지 않는다**(2026-09-07 확인). 다른 초안과 달리 이 값은
* 브라우저가 가진 **유일한** 사용자 표준단면이다 — `sections/context` 가 주는
* `standard_cross_section` 은 저장분이 아니라 **config 기본값**이고(`B06_Section_Router.py:132`),
* 저장분(`stored_standard_cross_section`)은 서버 안에서만 쓰인다. 그래서 비우면 브라우저
* 횡단 계산이 그 순간 config 기본값으로 되돌아간다. [초기화]에서만 버린다
* (`clearStandardCrossSession`). */
* 칸이 비었으면 `sections/context` 의 저장분(`stored_standard_cross_section`)으로 채운다
* (`B06_Section_Api_Fetch` seedStandardCross) — 같은 응답의 `standard_cross_section` 은
* config 기본값. 고침으로 안 본다 — [폐기]에도 안 지움(`design_leave` NOT_EDITS).
* [초기화] · 추천 노선 [적용]에서 버린다. */
"std-cross": { bucket: "draft", scope: "project", legacy: (p) => `b06:std-cross:${p}` },
/** 표시 반폭 — 사용자가 고른 값이라 초안이되, 바꾸면 횡단 재생성(③)을 함께 부른다. */
"cross-display": {
+1 -6
View File
@@ -45,12 +45,7 @@ import "./design_page.css";
export type { DesignPageShared } from "./design_shared";
/** 보기 ↔ 주소. */
export const DESIGN_VIEW_ROUTES: Record<DesignView, RoutePath> = {
profile: ROUTES.B05_PROFILE,
cross: ROUTES.B06_SECTION,
};
/** 주소 → 보기. */
export function designViewOf(route: RoutePath): DesignView | null {
if (route === ROUTES.B05_PROFILE) return "profile";
if (route === ROUTES.B06_SECTION) return "cross";
+1 -4
View File
@@ -11,7 +11,7 @@
* (다만 그 자리는 듣는 쪽을 못 부른다 — 쓰는 길은 가게로 옮길 것).
*
* `alignmentDraft` 는 등록부 `alignment-draft`(노선 칸)다 — 옛 키(`b05-profile-alignment-draft:{노선}`)
* 값은 처음 읽을 때 옮겨 받는다. B05 되돌리기(`_Profile_History`)도 이 칸을 사진 찍는다(24-2c).
* 값은 안 받는다(387-1). B05 되돌리기(`_Profile_History`)도 이 칸을 사진 찍는다(24-2c).
* 부르는 쪽이 프로젝트를 모르면(`null`) 지금 연 프로젝트 칸을 쓴다.
*
* 계산 묶음 칸(344-4 · 모양 = `design_pipeline_types.ts`) — `routeDraft` 는 초안(세션 즉시) ·
@@ -73,9 +73,6 @@ export interface DesignStore {
): () => void;
}
/** 옛 계획고 초안 키 — 위 머리 주석 참조(남은 옛 값을 쓸어 낼 때만 쓴다). */
export const ALIGNMENT_DRAFT_KEY_PREFIX = "b05-profile-alignment-draft";
/** 이 탭 페이지가 연 프로젝트(388-3) — 다른 탭이 현재 프로젝트를 바꿔도 이 탭 초안이 안 섞임. */
let openedProjectId: string | null = null;
export function pinDraftProject(projectId: string | null): void {
+2 -11
View File
@@ -1,8 +1,7 @@
/* =============================================================================
* A00_Common/design/design_structure_status.ts
* 구조물 현황 한 벌(322-3) — 저장분 둘(관 `pipe_points.json` · 구조물 `structures.json`) + 초안 둘(세션)
* → 한 목록(초안이 있으면 초안 · 없으면 저장분 · 종류마다 따로) · 측점 계획 한 벌(`planStations`) →
* 종단 측점 목록(B05 종단 · 3D 막대 · B06 종단 띠가 같은 꼴).
* 구조물 현황 한 벌(322-3) — 측점 계획 한 벌(`planStations`) → 종단 측점 목록(B05 종단 · 3D 막대 ·
* B06 종단 띠가 같은 꼴) · 관 · 구조물 · 추가 측점 초안 바뀜 신호.
*
* 구조물 id = 구조물은 structure_id · 관은 세션 id(`design_pipe_ids` · 323-5 · 파일 모양 그대로 · 저장 안 함).
* ========================================================================== */
@@ -11,14 +10,6 @@ import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_Irregula
import type { PlannedStation } from "../../common_util/common_util_structure_station";
import { useDesignStore, type DesignStoreValues } from "./design_store";
/** 현황 — 종류마다 초안이 앞섬(초안 null = 만진 적 없음 · 빈 목록 = 다 지움). */
export function structureStatus<P, S>(
saved: readonly [ReadonlyArray<P>, ReadonlyArray<S>],
drafts: readonly [ReadonlyArray<P> | null, ReadonlyArray<S> | null],
): { pipes: P[]; structures: S[] } {
return { pipes: [...(drafts[0] ?? saved[0])], structures: [...(drafts[1] ?? saved[1])] };
}
/**
* 측점 계획 → 종단 측점(측점번호 · 잔여거리 = 간격 기준) — 기준 측점 id = 첫 주인 id(관 = `pipe-…` ·
* 구조물 = structure_id) · 시작 · 끝 = `{id}:{역할}`(옮겨도 계획고 편집이 따라감 · 272) · 관 기준 측점 =
@@ -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";
@@ -7,12 +7,12 @@
*
* ① **손잡이 → 교각점·반지름**(`dragHandleTo`) — 서버 셈의 **반대 방향**이다. 서버는
* 교각점·R 에서 접선점을 내고, 여기서는 끈 접선점에서 교각점·R 을 낸다.
* 왕복이 제자리인지는 `tmp/tests/test_route_polyline_handle_drag.py` 가 지킨다.
* 왕복이 제자리인지는 `resources/tester/test_route_polyline_handle_drag.py` 가 지킨다.
*
* ② **노드 → 폴리라인**(`buildEditedPolyline`) — ⚠⚠ **짝**:
* `common_util/common_util_route_polyline.py` 의 `build_planned_polyline` 중
* **편집 갈래**(`simplify=False` + `curve_flags`/`radii`)와 같은 값을 내야 한다.
* 거울 시험: `tmp/tests/test_route_polyline_browser_mirror.py`.
* 거울 시험: `resources/tester/test_route_polyline_browser_mirror.py`.
*
* 왜 브라우저에도 두나(2026-09-07 사용자 지적 ①②) — 곡선을 서버만 그리면 노드를 잡는
* 순간 그려 둔 선을 통째로 버려야 해서 **곡선이 전부 사라진 것처럼 보인다**. 값은 남아
-9
View File
@@ -51,15 +51,6 @@ export function sideBerm(berm: BermSpec | null, side: string): BermSpec | null {
return own === undefined || own === null ? berm : { ...berm, depthM: Math.max(own, 0) };
}
export function bermSpec(
widthM = BERM_DEFAULT_WIDTH_M,
intervalM = BERM_DEFAULT_INTERVAL_M,
slopeDeg = BERM_DEFAULT_SLOPE_DEG,
depthM = BERM_DEFAULT_DEPTH_M,
): BermSpec {
return { widthM, intervalM, slopeDeg, depthM };
}
/**
* 짝: `cut_profile_points`. 절토 사면 꼭짓점 `[[거리, 표고], ...]` — 시작에서 바깥으로.
*
+1 -1
View File
@@ -6,7 +6,7 @@
* 짝: `B06_Section/B06_Section_Engine_Design.py` (`compute_cross_design`)
* 면적 적분은 `common_util_cross_design_areas.ts` ↔ `B06_Section_Engine_Areas.py`.
* 회귀 테스트가 두 구현을 같은 입력으로 실제 비교한다:
* `tmp/tests/test_b06_cross_design_mirror.py` — 어느 쪽을 고치든 반드시 같이 돌릴 것.
* `resources/tester/test_b06_cross_design_mirror.py` — 어느 쪽을 고치든 반드시 같이 돌릴 것.
*
* ── 왜 같은 계산이 두 벌인가 (2026-09-03 사용자 확정) ────────────────
* 사용자가 계획선을 만지는 동안의 계산은 **브라우저 안에서 끝나야 한다**. 조작은 세션
@@ -5,7 +5,7 @@
* ⚠⚠ 파이썬 짝 파일과 **한 벌**이다 — 한쪽만 고치면 두 화면 값이 갈린다 ⚠⚠
* 짝: `B06_Section/B06_Section_Engine_Areas.py`
* 같은 입력에 같은 값을 내야 한다. 회귀 테스트가 두 구현을 실제로 비교한다:
* `tmp/tests/test_b06_cross_design_mirror.py` — 고칠 때 반드시 같이 돌릴 것.
* `resources/tester/test_b06_cross_design_mirror.py` — 고칠 때 반드시 같이 돌릴 것.
* 왜 두 벌인가: 사용자 조작 중 계산은 브라우저 안에서 끝나야 하고(2026-09-03 사용자
* 확정), 저장·확정·도면 산출은 서버가 정본으로 다시 계산하기 때문이다.
*
+1 -1
View File
@@ -3,7 +3,7 @@
* 배수관 세트(배관·기슭막이·보호공·세월교·BOX암거·물넘이포장) 제원 — **브라우저 몫**.
*
* ⚠ 파이썬 `B06_Section/B06_Section_Engine_Culvert.py` 와 **한 벌**이다(2026-09-06).
* 한쪽만 고치면 화면과 저장본이 갈린다. 거울 테스트: `tmp/tests/test_b06_culvert_sets_mirror.py`
* 한쪽만 고치면 화면과 저장본이 갈린다. 거울 테스트: `resources/tester/test_b06_culvert_sets_mirror.py`
*
* 왜 짝으로 두나(CLAUDE.md 5장 「계산 자리」) — 이 계산은 종횡단 **상세를 읽을 때마다**
* 서버에서 돈다. 매 요청 도는 자리라 Node 왕복(코리도·구조물 면적 방식)이 비싸다.
+1 -1
View File
@@ -1,7 +1,7 @@
"""터파기 단면 — 관·벽 두 갈래. **근거의 급이 다르므로 갈라 둔다.**
⚠⚠ TS 짝 파일과 **한 벌**이다 — `common_util/common_util_excavation.ts`.
거울 시험: `tmp/tests/test_excavation_mirror.py` (고칠 때 같이 돌릴 것).
거울 시험: `resources/tester/test_excavation_mirror.py` (고칠 때 같이 돌릴 것).
화면(B06 횡단도)이 그리고 서버(B08 수량)가 세는 값이라 두 쪽에 같은 수가 있어야 한다.
① 관 — **법정 시방서에 표가 있다**
+1 -1
View File
@@ -3,7 +3,7 @@
* 터파기 단면 — 관·벽 두 갈래. **근거의 급이 다르므로 갈라 둔다.**
*
* ⚠⚠ 파이썬 짝과 **한 벌**이다 — `common_util/common_util_excavation.py`.
* 거울 시험: `tmp/tests/test_excavation_mirror.py`. 어느 쪽을 고치든 같이 돌릴 것.
* 거울 시험: `resources/tester/test_excavation_mirror.py`. 어느 쪽을 고치든 같이 돌릴 것.
* 화면(B06 횡단도)이 그리고 서버(B08 수량)가 세는 값이라 두 쪽 수가 같아야 한다.
*
* ① 관 — 법정 시방서에 표가 있다: `KCS 44 40 10 배수관(23.01) §3.2(4) 그림 3.2-1`.
@@ -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,80 @@
/* =============================================================================
* common_util_mass_haul_legend.ts
* 유토곡선 곡선 이름 · 범례 줄(`common_util_mass_haul_view` 에서 나눔).
* ========================================================================== */
import type { MassHaulBasis, MassHaulSeries } from "./common_util_mass_haul";
import type { LocaleKey } from "@ui/ui_template_locale";
import { MASS_HAUL_BALANCE_KEY } from "./common_util_mass_haul";
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;
}
+6 -271
View File
@@ -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";
@@ -111,8 +113,6 @@ const WINDOW_EASE = 0.2;
/** 축 눈금이 읽히는 최소 높이. 이보다 낮아지면 그래프가 뭉개진다. */
export const MASS_HAUL_MIN_HEIGHT = 90;
/** 패널을 건드리지 않았을 때의 유토곡선 높이. */
export const MASS_HAUL_HEIGHT = 190;
/**
* 유토곡선 전용 상하 여백. 좌우는 종단면도와 **반드시 같아야**(LONG_PAD) X축이 맞으므로
@@ -123,20 +123,6 @@ export const MASS_HAUL_HEIGHT = 190;
const MASS_PAD_TOP = 10;
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`가 쓰는 것과 같은 기하**로 그려야 한다 — 평형선·장비
* 경계현·평균운반거리선의 양 끝이 그 기하와의 교점으로 계산되므로, 그리기와 재기가 어긋나면
@@ -173,14 +159,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 +273,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<string>,
@@ -734,133 +599,3 @@ export function createMassHaulChart(
);
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;
}
@@ -30,7 +30,6 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "A00_Common
| `berm` | `:166` | 시설 줄 `syncBerm` 만(구조물 파생) | 안 감 — 서버가 `structures.json` 에서 |
| `cross-display` | `:177` | 전역 표시 반폭 | 안 감 |
| `selection` · `structure-pick` · `pipe-ids` · `pipe-option-queue` · `structure-type-cache` | `:148` · `:138` · `:144` · `:146` · `:150` | 고름 · 관 id 장부 · 옵션 넘김 · 종류 캐시 | 안 감 · `structure-type-cache` 는 저장 뒤 비움 |
| `culvertmove` | `:156` | 부르는 곳 없음 | 안 감 |
## result
@@ -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(