feat(B05): 배관 정착 계획선 1차 로직 + 유토곡선 UI 정리 5건

계획선 1차 로직 (2026-08-03 사용자 확정)
- design_pipe_anchored_profile 신설: 배수유역도가 확정한 배관 배치 측점을
  변화점으로 삼아 계획선이 각 배관 자리에서 지면선과 만나도록(계획고=지반고)
  시작점→배관1→…→종점을 직선으로 잇고 기본 R을 얹는다. 배관(암거)은 계곡
  유하부라 계획선이 그 지점에 붙어야 복토·유입 조건이 성립한다.
- 기존 지반 추종 직선 분할 DP 선형은 2차 폴백으로, 구 균형 최적화 계획선은
  3차 폴백으로 강등. 관 지점 파일의 노선 지문이 다르면 배관을 쓰지 않는다.
- 테스트 프로젝트 solve 재실행으로 저장분 재계산 — 변화점이 배관 5개 자리와
  일치하고 각 자리 계획고=지반고임을 검증.

유토곡선 UI (사용자 피드백 5건)
- 기준 버튼 라디오화: 횡단/종단 택1로 그 기준 그래프만 전체 영역에 표시
  (applyLegendToggle/normalizeVisibleBasis, B05·B06 공통 저장 키 공유).
  Y 범위도 표시 중인 기준으로 산정.
- B05 Y축 이중 표시 제거: MassHaulAxis.axisX 신설 — 축 선·눈금을 종단 그래프
  축과 같은 자리에 그리고 데이터 매핑은 유지해 측점 세로 정렬 불변.
- 종단↔유토곡선 경계 드래그 리사이저(비율 0.25~0.75, 세션 보존, 기본 1:1).
- B05 범례를 B06과 같은 오버레이 우상단 절대배치로 이동.
- 도면 테이블: 구조물 배치로 갈라진 구배 블록 값을 기본 숨김(윤곽·툴팁만),
  해당 측점 선택 시 하이라이트와 함께 표시.

typecheck·vite build·ruff·B03 테스트 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 20:14:09 +09:00
co-authored by Claude Opus 5
parent f4f56069b0
commit a9741277cf
10 changed files with 379 additions and 54 deletions
@@ -5,8 +5,13 @@
[[B05_wf2_Route_Engine_Grade_Alignment]] 의 기하 파생을 묶어
`design_profiles` 배열에 넣을 계획선 한 벌을 만든다.
진입점이 있다.
- `design_alignment_profile()` : 노선 계산 직후. 직선 분할 DP부터 새로 푼다.
진입점이 있다.
- `design_pipe_anchored_profile()` : **1차(기본)**. 배수유역도가 산출한 배관 배치 측점을
변화점으로 삼아, 계획선이 각 배관 자리에서 지면선과 만나도록(계획고 = 지반고) 시작점 →
배관1 → 배관2 → … → 종점을 직선으로 잇고 기본 R을 얹는다(2026-08-03 사용자 확정).
배관(암거)은 계곡 유하부라 계획선이 그 지점에 붙어야 복토·유입 조건이 성립한다.
- `design_alignment_profile()` : **2차(폴백)**. 배관이 없거나 1차 산출이 불가할 때
쓰는 기존 지반 추종 직선 분할 DP 선형.
- `rebuild_alignment_profile()`: 사용자 편집 확정 시. **저장된 자동 선형(base_pvi)과
정책을 그대로 재사용**하고 편집 델타만 다시 얹는다. DP를 다시 돌리면 기준선이
흔들려 "원복" 이 원래 위치로 돌아가지 않기 때문이다.
@@ -110,6 +115,82 @@ def _profile_entry(
}
def design_pipe_anchored_profile(
longitudinal: dict[str, Any],
options: GradeDesignOptions,
pipe_chainages: list[float],
*,
station_interval_m: float | None = None,
edits: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""배관 배치 측점을 변화점으로 삼는 1차 계획선.
변화점 표고는 그 자리의 지반고다 — 계획선이 지면선과 교차하는 지점에 배관이 앉는다.
시·종점은 지반고에 사용자 오프셋을 더한다(기존 계약 유지). 종단곡선 R은
`build_alignment`이 기본값(`default_curve_radius_m`)으로 얹고, 기울기 위반은
막지 않고 경고로 남긴다 — 배관 위치가 우선이고 조정은 사용자 몫이다.
"""
options.validate()
chainage, ground = ground_profile(longitudinal)
total = float(chainage[-1])
if total <= 0:
raise ValueError("종단 연장이 0이어서 계획선을 만들 수 없습니다.")
stations = list(longitudinal.get("stations") or [])
interval = float(station_interval_m or 0) or infer_station_interval(stations)
policy = AlignmentPolicy.from_config(
station_interval_m=interval,
max_grade_pct=options.max_grade_pct,
curve_skip_delta_pct=options.vertical_curve_skip_delta_pct,
paved=options.paved,
)
warnings = list(options.warnings)
fixed = (
float(ground[0]) + options.start_elevation_offset_m,
float(ground[-1]) + options.end_elevation_offset_m,
)
rise = fixed[1] - fixed[0]
direction, note = (
detect_main_direction(ground, rise)
if options.main_direction == "auto"
else (options.main_direction, None)
)
if note:
warnings.append(note)
# 범위 밖·양끝에 붙은 것은 버리고, 서로 붙은 배관(0.5m 미만)은 하나로 본다.
margin = 0.5
anchors: list[float] = []
for value in sorted(float(c) for c in pipe_chainages):
if value <= margin or value >= total - margin:
continue
if anchors and value - anchors[-1] < margin:
continue
anchors.append(round(value, 3))
if not anchors:
raise ValueError("계획선 변화점으로 쓸 배관 배치 측점이 없습니다.")
base_s = np.array([0.0, *anchors, total], dtype=np.float64)
# 배관 자리 표고 = 지반고(지면선 교차). 시·종점만 오프셋을 얹는다.
base_z = np.interp(base_s, chainage, ground)
base_z[0] = fixed[0]
base_z[-1] = fixed[1]
alignment = build_alignment(
base_s=base_s,
base_z=base_z,
chainage=chainage,
ground=ground,
stations=stations,
policy=policy,
edits=edits,
)
warnings.extend(alignment["warnings"])
balanced = bool(alignment["balance"]["within_tolerance"])
return alignment, _profile_entry(alignment, options, direction, balanced, warnings)
def design_alignment_profile(
longitudinal: dict[str, Any],
options: GradeDesignOptions,
+76 -16
View File
@@ -12,14 +12,24 @@ from pathlib import Path
from typing import Any
from B05_wf2_Route.B05_wf2_Route_Engine_Grade import GradeDesignOptions, design_grade_line
from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Profile import design_alignment_profile
from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Profile import (
design_alignment_profile,
design_pipe_anchored_profile,
)
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import (
SectionGenerationOptions,
generate_sections,
)
from common_util.common_util_drainage_pipes import parse_pipe_points, route_signature
from common_util.common_util_json import atomic_write_json
from common_util.common_util_route_geometry import RouteVertex
from common_util.common_util_surface_sampler import build_surface_sampler
from config.config_system import FOREST_ROAD_PROFILE_CRITERIA
from config.config_system import (
DRAINAGE_CACHE_DIRNAME,
DRAINAGE_EDITS_DIRNAME,
DRAINAGE_PIPE_POINTS_FILENAME,
FOREST_ROAD_PROFILE_CRITERIA,
)
logger = logging.getLogger(__name__)
@@ -79,33 +89,81 @@ def _cross_summary(cross_section: dict[str, Any]) -> dict[str, Any]:
}
def _load_pipe_chainages(project_root: Path, polyline: list[list[float]]) -> list[float]:
"""배수유역도가 확정한 배관 배치 측점(누가거리)을 읽는다.
관 지점 파일에는 저장 당시 노선 지문이 함께 있다 — 노선이 바뀌었으면 버린다
(옛 노선의 배관 자리로 계획선을 앉히면 전부 어긋난다). 파일이 없거나 못 읽으면 빈 목록.
"""
path = (
project_root
/ "B04_wf1_Surface"
/ DRAINAGE_CACHE_DIRNAME
/ DRAINAGE_EDITS_DIRNAME
/ DRAINAGE_PIPE_POINTS_FILENAME
)
if not path.is_file():
return []
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.warning("B05 계획선: 관 지점 파일을 읽지 못했습니다 (%s)", path)
return []
vertices = [
RouteVertex(
x=float(p[0]), y=float(p[1]), z=float(p[2]) if len(p) > 2 else 0.0, chainage_m=0.0
)
for p in polyline
]
if str(document.get("route_signature") or "") != route_signature(vertices):
logger.info("B05 계획선: 노선이 바뀌어 저장된 관 지점을 쓰지 않습니다.")
return []
return [pipe.chainage_m for pipe in parse_pipe_points(document.get("points"))]
def _append_design_profiles(
longitudinal: dict[str, Any],
grade_options: GradeDesignOptions | None,
station_interval_m: float | None = None,
pipe_chainages: list[float] | None = None,
) -> dict[str, Any] | None:
"""종단 계획선을 산출해 longitudinal에 붙이고 요약을 반환한다.
1순위는 측점 제약 직선 분할 선형(`profile_alignment`)이며, 여기서 산출된
변화점 구조가 사용자 편집의 기준선이 된다. 산출이 실패하면 구 균형 최적화
계획선으로 폴백하고(편집 불가), 그마저 실패해도 종횡단 생성 자체는 유지한다.
횡단 설계 기반 계획선을 나중에 추가할 수 있게 배열로 보관한다.
1순위는 **배관 정착 선형** — 배수유역도의 배관 배치 측점마다 계획선이 지면선과
만나도록 직선으로 잇고 기본 R을 얹는다(2026-08-03 사용자 확정). 배관이 없거나
산출이 불가하면 2순위로 기존 지반 추종 직선 분할 선형(`design_alignment_profile`),
그마저 실패하면 구 균형 최적화 계획선으로 폴백하고(편집 불가), 모두 실패해도
종횡단 생성 자체는 유지한다.
"""
longitudinal.setdefault("design_profiles", [])
if grade_options is None:
return None
try:
alignment, profile = design_alignment_profile(
longitudinal, grade_options, station_interval_m=station_interval_m
)
longitudinal["profile_alignment"] = alignment
except (ValueError, KeyError, ArithmeticError):
logger.exception("B05 계획선 선형 산출 실패 — 균형 최적화 계획선으로 대체")
alignment = None
profile = None
if pipe_chainages:
try:
profile = design_grade_line(longitudinal, grade_options)
alignment, profile = design_pipe_anchored_profile(
longitudinal,
grade_options,
pipe_chainages,
station_interval_m=station_interval_m,
)
except (ValueError, KeyError, ArithmeticError):
logger.exception("B05 종단 계획선 산출 실패 (종횡단은 유지)")
return None
logger.exception("B05 배관 정착 계획선 산출 실패 — 직선 분할 선형으로 대체")
if profile is None:
try:
alignment, profile = design_alignment_profile(
longitudinal, grade_options, station_interval_m=station_interval_m
)
except (ValueError, KeyError, ArithmeticError):
logger.exception("B05 계획선 선형 산출 실패 — 균형 최적화 계획선으로 대체")
try:
profile = design_grade_line(longitudinal, grade_options)
except (ValueError, KeyError, ArithmeticError):
logger.exception("B05 종단 계획선 산출 실패 (종횡단은 유지)")
return None
if alignment is not None:
longitudinal["profile_alignment"] = alignment
longitudinal["design_profiles"].append(profile)
return {"id": profile["id"], **profile["summary"]}
@@ -207,6 +265,8 @@ def run_section_generation(
result["longitudinal"],
grade_options,
(result.get("options") or {}).get("station_interval_m"),
# 1차 계획선의 변화점 = 배수유역도가 확정한 배관 배치 측점.
pipe_chainages=_load_pipe_chainages(project_root, polyline),
)
# 계획선 경사 기반 포장 제안(법정 상한 초과 측점) — 비치명적.
try:
@@ -31,9 +31,11 @@ import type { MassHaulSeries } from "@util/common_util_mass_haul";
import type { HaulPlan } from "@util/common_util_mass_haul_balance";
import type { MassHaulAxis } from "@util/common_util_mass_haul_view";
import {
applyLegendToggle,
computeMassHaulSeries,
MASS_HAUL_BALANCE_KEY,
MASS_HAUL_DEFAULT_VISIBLE,
normalizeVisibleBasis,
} from "@util/common_util_mass_haul";
import { computeHaulPlan } from "@util/common_util_mass_haul_balance";
import { resetBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
@@ -66,6 +68,8 @@ export interface RouteMassHaulContext {
}
export interface RouteMassHaulDrawParams {
/** 범례 오버레이의 세로 위치(px) — 종단 그래프 높이 + 여백. B06과 같은 문법. */
legendTopPx: number;
/** 측점선을 세울 목록 — 종단 그래프에 넣은 것과 **같은 배열**이어야 자리가 맞는다. */
stationSource: MassHaulStationSource;
/** 종단 개략 곡선 입력 — 편집이 반영된 현재 계획선을 담은 종단 데이터. */
@@ -84,8 +88,13 @@ export interface RouteMassHaulDrawParams {
export interface RouteMassHaulPanel {
/** 종단면 패널 바닥에 붙는 2차 슬라이드 손잡이. */
handle: HTMLElement;
/** 범례·요약 막대 — 가로 스크롤러 **밖**에 놓는다. */
/** 요약 막대 — 가로 스크롤러 **밖**에 놓는다. */
bar: HTMLElement;
/**
* 범례 오버레이 — 유토곡선 우상단에 절대배치(B06과 동일 문법, 2026-08-03 사용자 지시).
* 호출한 쪽이 `position: relative`인 패널 본문에 붙인다.
*/
legendLayer: HTMLElement;
isOpen(): boolean;
setContext(next: RouteMassHaulContext | null): void;
/**
@@ -100,7 +109,10 @@ function readVisible(): Set<string> {
const raw = sessionStorage.getItem(VISIBLE_KEY);
if (!raw) return new Set(DEFAULT_VISIBLE);
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? new Set(parsed.map(String)) : new Set(DEFAULT_VISIBLE);
// 기준 키는 라디오라 항상 하나로 눌러 맞춘다(B06 읽기와 같은 규칙).
return Array.isArray(parsed)
? normalizeVisibleBasis(new Set(parsed.map(String)))
: new Set(DEFAULT_VISIBLE);
} catch {
return new Set(DEFAULT_VISIBLE);
}
@@ -120,6 +132,9 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
const bar = document.createElement("div");
bar.className = "b05-profile__masshaul-bar";
const legendLayer = document.createElement("div");
legendLayer.className = "b05-profile__masshaul-legend";
legendLayer.hidden = true;
let open = sessionStorage.getItem(OPEN_KEY) === "true";
let context: RouteMassHaulContext | null = null;
@@ -130,18 +145,19 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
handleControl.setOpen(next);
handle.classList.toggle("is-open", next);
bar.hidden = !next;
legendLayer.hidden = !next;
onChanged();
}
handleControl.setOpen(open);
handle.classList.toggle("is-open", open);
bar.hidden = !open;
legendLayer.hidden = !open;
handleControl.root.addEventListener("click", () => applyOpen(!open));
caption.addEventListener("click", () => applyOpen(!open));
function toggleSeries(key: string): void {
const visible = readVisible();
if (visible.has(key)) visible.delete(key);
else visible.add(key);
// 곡선 기준(횡단/종단)은 라디오 — 하나를 고르면 그 기준 그래프만 전체 영역에 보인다.
const visible = applyLegendToggle(readVisible(), key);
sessionStorage.setItem(VISIBLE_KEY, JSON.stringify([...visible]));
onChanged();
}
@@ -149,12 +165,14 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
return {
handle,
bar,
legendLayer,
isOpen: () => open,
setContext(next) {
context = next;
},
draw(params) {
bar.replaceChildren();
legendLayer.replaceChildren();
if (!open) return null;
const note = (message: string): null => {
const empty = document.createElement("span");
@@ -192,7 +210,8 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
params.onSelectStation,
haulPlan,
);
bar.append(
legendLayer.style.top = `${params.legendTopPx}px`;
legendLayer.append(
createMassHaulLegend(series, visible, toggleSeries, () => {
resetBalloonOffsets();
onChanged();
@@ -77,6 +77,10 @@ const HEIGHT_KEY = "b05-route-profile-height";
const TABLE_HEIGHT_KEY = "b05-route-profile-table-height";
/** 정보 라인을 뺀 본문 세로를 그래프 40% : 테이블 60%로 나눈다(4:6, 6이 테이블). */
const CHART_HEIGHT_RATIO = 0.4;
/** 유토곡선 펼침 시 종단:곡선 분할 비율(종단 몫). 경계 드래그로 조절, 세션 보존. */
const MASS_SPLIT_KEY = "b05-route-profile-masshaul-split";
const MASS_SPLIT_MIN = 0.25;
const MASS_SPLIT_MAX = 0.75;
const MIN_CHART_HEIGHT = 100;
/** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */
const MIN_PANEL_HEIGHT = 180;
@@ -265,9 +269,9 @@ export function createRouteProfilePanel(
const bodyWrap = document.createElement("div");
bodyWrap.className = "b05-route-profile__body-wrap";
bodyWrap.append(body, progress.root);
// 계획 유토곡선 — 2차 하단 슬라이드. 펼치면 도면 테이블 자리를 덮는다.
// 유토곡선 — 2차 하단 슬라이드. 펼치면 도면 테이블 자리를 덮는다.
const massHaul = createRouteMassHaulPanel(() => draw());
bodyWrap.append(massHaul.bar, massHaul.handle);
bodyWrap.append(massHaul.legendLayer, massHaul.bar, massHaul.handle);
const content = document.createElement("div");
content.className = "b05-route-profile__content";
// 관 목록이 바뀌면 종단 테이블의 "배관" 구조물 라인도 같이 맞춘다(정본은 관 지점 파일).
@@ -441,10 +445,14 @@ export function createRouteProfilePanel(
fixedTableHeight = Math.round(available * (1 - CHART_HEIGHT_RATIO));
sessionStorage.setItem(TABLE_HEIGHT_KEY, String(fixedTableHeight));
}
// 유토곡선을 펼치면 종단 그래프와 곡선이 세로를 **1:1**로 나눈다(2026-08-03 사용자 지시)
// — 테이블용 고정 높이를 그대로 쓰면 곡선이 종단 그래프를 밀어낸다.
// 유토곡선을 펼치면 종단 그래프와 곡선이 세로를 나눈다. 기본 1:1(2026-08-03 사용자 지시),
// 두 그래프 사이 경계선을 끌어 비율을 바꿀 수 있고 세션 동안 유지된다.
const massSplit = Math.min(
MASS_SPLIT_MAX,
Math.max(MASS_SPLIT_MIN, Number(sessionStorage.getItem(MASS_SPLIT_KEY)) || 0.5),
);
const chartHeight = massHaul.isOpen()
? Math.max(MIN_CHART_HEIGHT, Math.floor(available / 2))
? Math.max(MIN_CHART_HEIGHT, Math.floor(available * massSplit))
: alignment
? Math.max(MIN_CHART_HEIGHT, available - fixedTableHeight)
: available;
@@ -575,7 +583,10 @@ export function createRouteProfilePanel(
// 좌우 여백에 합쳐 넘긴다 — 어긋나면 같은 측점이 두 그래프에서 다른 자리에 선다.
padLeft: LONG_PAD.left + originOffset,
padRight: LONG_PAD.right + originOffset,
// 축 선·눈금은 위 종단 그래프의 축과 같은 자리(62px)에 — 축이 두 개로 보이지 않게.
axisX: LONG_PAD.left,
},
legendTopPx: chartHeight + 6,
stationInterval: stationIntervalM ?? 1,
widthPx: width,
heightPx: tableHeight,
@@ -587,7 +598,35 @@ export function createRouteProfilePanel(
canvas.style.height = `${available}px`;
canvas.append(chartWrap);
if (table) canvas.append(table);
if (massHaulChart) canvas.append(massHaulChart);
if (massHaulChart) {
// 종단 그래프와 유토곡선 사이 경계 — 세로로 끌어 두 그래프의 높이 배분을 바꾼다.
const splitBar = document.createElement("div");
splitBar.className = "b05-profile__masshaul-split";
splitBar.title = "끌어서 종단 그래프와 유토곡선의 높이 비율을 조절합니다.";
// 절대배치 — 흐름에 끼우면 6px만큼 세로가 넘쳐 곡선 아래가 잘린다.
splitBar.style.top = `${chartHeight - 3}px`;
splitBar.addEventListener("pointerdown", (event) => {
event.preventDefault();
splitBar.setPointerCapture(event.pointerId);
const startY = event.clientY;
const startRatio = chartHeight / Math.max(available, 1);
const onMove = (move: PointerEvent): void => {
const ratio = startRatio + (move.clientY - startY) / Math.max(available, 1);
sessionStorage.setItem(
MASS_SPLIT_KEY,
String(Math.min(MASS_SPLIT_MAX, Math.max(MASS_SPLIT_MIN, ratio))),
);
rebuild();
};
const onUp = (): void => {
splitBar.removeEventListener("pointermove", onMove);
splitBar.removeEventListener("pointerup", onUp);
};
splitBar.addEventListener("pointermove", onMove);
splitBar.addEventListener("pointerup", onUp);
});
canvas.append(splitBar, massHaulChart);
}
body.replaceChildren(canvas);
body.scrollLeft = scrollLeft;
}
@@ -212,7 +212,19 @@ function buildSegmentRows(
x: (chainage: number) => number,
fontPx: number,
rowHeight: number,
/** 구조물(비정규) 측점 승격으로 생긴 변화점 chainage 집합(소수 3자리 키). */
structureChainages: ReadonlySet<string>,
/** 현재 선택된 측점 chainage — 구조물 파생 블록은 선택됐을 때만 값을 보인다. */
selectedChainageM: number | null,
): HTMLElement[] {
// 구조물 배치로 갈라진 구간인지 — 양 끝 중 하나라도 구조물 변화점이면 파생 블록이다.
const isStructureSegment = (segment: AlignmentSegment): boolean =>
structureChainages.has(segment.from_m.toFixed(3)) ||
structureChainages.has(segment.to_m.toFixed(3));
const touchesSelected = (segment: AlignmentSegment): boolean =>
selectedChainageM !== null &&
(Math.abs(segment.from_m - selectedChainageM) < 0.01 ||
Math.abs(segment.to_m - selectedChainageM) < 0.01);
return SEGMENT_ROWS.map((spec) => {
const row = createRow("b05-profile-table__row--grade", spec.label, spec.unit);
alignment.segments.forEach((segment) => {
@@ -220,7 +232,24 @@ function buildSegmentRows(
const span = x(segment.to_m) - left;
const text = spec.cell(segment);
const node = element("span", "b05-profile-table__segment", "");
// 값은 항상 표기한다. 가로로 안 들어가면 90도로 세워 블록의 폭(span)·행 높이에 맞춰
// 구조물 배치로 갈라진 좁은 구간 값은 처음부터 보이지 않는다 — 블록·경계선·툴팁만 남기고,
// 그 측점을 **선택했을 때** 하이라이트와 함께 값을 보인다(2026-08-03 사용자 지시).
const structural = isStructureSegment(segment);
const highlighted = structural && touchesSelected(segment);
if (structural) node.classList.add("is-structure");
if (highlighted) node.classList.add("is-highlight");
if (structural && !highlighted) {
node.style.left = `${left}px`;
node.style.width = `${span}px`;
node.title =
`${segment.from_m.toFixed(1)} ~ ${segment.to_m.toFixed(1)}m 직선 (구조물 구간)
` +
`연장 ${segment.length_m.toFixed(2)}m · 고저차 ${segment.height_m.toFixed(2)}m · ` +
`구배 ${segment.grade_percent.toFixed(2)}%`;
row.append(node);
return;
}
// 값은 표기한다. 가로로 안 들어가면 90도로 세워 블록의 폭(span)·행 높이에 맞춰
// 글자를 줄여 넣는다(작아도 무시 — 값이 아예 안 보이는 것보단 낫다).
const value = element("span", "b05-profile-table__segment-value", text);
if (!segmentTextFits(text, span, fontPx)) {
@@ -480,7 +509,24 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
table.style.setProperty("--b05-table-label-width", `${labelWidth}px`);
table.style.setProperty("--b05-table-label-font", `${Math.max(8, labelFontSize)}px`);
table.append(...buildSegmentRows(alignment, x, fontSize, rowHeight));
// 구조물(비정규) 측점 승격 변화점 = 사용자 변화점 중 규칙 측점과 겹치지 않는 것.
const stationKeys = new Set(alignment.stations.map((row) => row.chainage_m.toFixed(3)));
const structureChainages = new Set(
alignment.pvi
.filter((node) => node.source === "user" && !stationKeys.has(node.chainage_m.toFixed(3)))
.map((node) => node.chainage_m.toFixed(3)),
);
const selectedIrregularEntry = options.irregularStations?.find(
(entry) => irregularStationId(entry.id) === options.selectedStationId,
);
const selectedStationRow = options.selectedStationId
? alignment.stations.find((row) => row.station_id === options.selectedStationId)
: undefined;
const selectedChainageM =
selectedIrregularEntry?.chainage_m ?? selectedStationRow?.chainage_m ?? null;
table.append(
...buildSegmentRows(alignment, x, fontSize, rowHeight, structureChainages, selectedChainageM),
);
buildStationRows(alignment, stationInterval, display).forEach((spec, index) => {
const row = createRow(
`${spec.modifier ? `is-${spec.modifier}` : ""}${index === 0 ? " is-group-start" : ""}`,
@@ -498,11 +544,9 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
// 선택된 측점(규칙·비정규 공용)을 **값 열 오버레이**로 강조한다.
// 규칙 측점은 종점 이동을 반영한 `centers[index]`, 비정규 측점은 `x(chainage)`를 중심으로 쓴다.
const selectedIrregular = options.irregularStations?.find(
(entry) => irregularStationId(entry.id) === options.selectedStationId,
);
const selectedRegularIndex = options.selectedStationId
? alignment.stations.findIndex((row) => row.station_id === options.selectedStationId)
const selectedIrregular = selectedIrregularEntry;
const selectedRegularIndex = selectedStationRow
? alignment.stations.indexOf(selectedStationRow)
: -1;
if (selectedIrregular) {
// 표는 그대로 두고 선택된 값만 위에 얹는다 — 이웃 셀을 숨기면 고를 때마다 표가 비어
+12
View File
@@ -109,6 +109,7 @@
/* 그래프와 테이블을 같은 폭으로 쌓아 X축이 저절로 맞물리게 한다. */
.b05-profile__canvas {
position: relative; /* 유토곡선 경계 드래그 바(absolute)의 기준 */
display: flex;
flex-direction: column;
}
@@ -1134,3 +1135,14 @@
text-overflow: ellipsis;
white-space: nowrap;
}
/* 구조물(배관) 배치로 갈라진 구배 블록 — 평소엔 값 없이 윤곽만, 선택하면 강조와 함께 값 표시. */
.b05-profile-table__segment.is-structure {
opacity: 0.55;
}
.b05-profile-table__segment.is-structure.is-highlight {
opacity: 1;
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
outline: 1px solid var(--color-primary);
}
@@ -21,12 +21,35 @@
background: var(--color-surface);
}
/* 공용 범례는 B06에서 그래프 위에 절대배치로 띄우지만, 여기서는 막대 안에 줄로 눕힌다. */
.b05-profile__masshaul-bar .b06-masshaul__legend {
position: static;
right: auto;
flex-wrap: wrap;
padding: var(--spacing-4) var(--spacing-16);
/* 범례 오버레이 — B06과 같은 문법으로 유토곡선 우상단에 절대배치한다(2026-08-03 지시).
공용 범례(.b06-masshaul__legend)가 이미 absolute + right 배치라 top만 이 래퍼가 정한다. */
.b05-profile__masshaul-legend {
position: absolute;
top: 0;
right: 0;
left: 0;
z-index: 3;
height: 0;
pointer-events: none;
}
.b05-profile__masshaul-legend .b06-masshaul__legend {
pointer-events: auto;
}
/* 종단 그래프와 유토곡선 사이 경계 — 세로 드래그로 높이 배분을 바꾼다. */
.b05-profile__masshaul-split {
position: absolute;
left: 0;
right: 0;
height: 6px;
z-index: 2;
cursor: row-resize;
touch-action: none;
}
.b05-profile__masshaul-split:hover {
background: color-mix(in srgb, var(--color-primary) 25%, transparent);
}
.b05-profile__masshaul-bar .b06-masshaul__summary {
@@ -33,9 +33,11 @@ import {
longitudinalMinimumWidth,
} from "./B06_wf3_ProfileCross_UI_Longitudinal";
import {
applyLegendToggle,
computeMassHaulSeries,
MASS_HAUL_BALANCE_KEY,
MASS_HAUL_DEFAULT_VISIBLE,
normalizeVisibleBasis,
type MassHaulSeries,
} from "@util/common_util_mass_haul";
import { computeHaulPlan } from "@util/common_util_mass_haul_balance";
@@ -98,8 +100,10 @@ function readVisibleSeries(): Set<string> {
const raw = sessionStorage.getItem(MASS_HAUL_VISIBLE_KEY);
if (!raw) return new Set(DEFAULT_VISIBLE_KEYS);
const parsed: unknown = JSON.parse(raw);
// 전부 끈 상태(빈 배열)도 사용자의 선택이므로 기본값으로 되돌리지 않는다.
return Array.isArray(parsed) ? new Set(parsed.map(String)) : new Set(DEFAULT_VISIBLE_KEYS);
// 기준 키는 라디오라 항상 하나로 눌러 맞춘다(분배 레이어 켜짐 여부는 저장값 그대로).
return Array.isArray(parsed)
? normalizeVisibleBasis(new Set(parsed.map(String)))
: new Set(DEFAULT_VISIBLE_KEYS);
} catch {
return new Set(DEFAULT_VISIBLE_KEYS);
}
@@ -423,8 +427,10 @@ export function createSectionView(
/** 범례 버튼 — 곡선 하나를 켜고 끈다. 축은 전체 곡선 기준이라 여기서 움직이지 않는다. */
const toggleSeries = (key: string): void => {
if (visibleSeries.has(key)) visibleSeries.delete(key);
else visibleSeries.add(key);
// 곡선 기준(횡단/종단)은 라디오 — 하나를 고르면 그 기준으로 계산된 그래프만 보인다.
const next = applyLegendToggle(visibleSeries, key);
visibleSeries.clear();
next.forEach((entry) => visibleSeries.add(entry));
sessionStorage.setItem(MASS_HAUL_VISIBLE_KEY, JSON.stringify([...visibleSeries]));
drawPanel();
};
+29
View File
@@ -135,6 +135,35 @@ export const MASS_HAUL_DEFAULT_VISIBLE: MassHaulBasis[] = ["cross"];
*/
export const MASS_HAUL_BALANCE_KEY = "balance";
/**
* 곡선 기준 버튼은 **라디오**다 — 횡단/종단 중 하나를 고르면 그 기준으로 계산된 그래프
* 하나만 전체 영역에 보인다(2026-08-03 사용자 지시. 겹쳐 비교하던 이전 방식 폐기).
* 켜짐 집합에 기준 키가 없거나 여럿이면 순서상 앞선 기준 하나로 눌러 맞춘다 —
* 두 화면(B05/B06)이 같은 저장 키를 공유하므로 읽는 쪽마다 같은 규칙이어야 한다.
*/
export function normalizeVisibleBasis(visible: Set<string>): Set<string> {
const active = SERIES_BASES.filter((basis) => visible.has(basis));
if (active.length === 1) return visible;
const next = new Set(visible);
SERIES_BASES.forEach((basis) => next.delete(basis));
next.add(active[0] ?? SERIES_BASES[0]);
return next;
}
/** 범례 클릭 하나를 적용한다 — 기준 키는 라디오 선택, 그 외(분배 레이어)는 켜고 끄기. */
export function applyLegendToggle(visible: Set<string>, key: string): Set<string> {
const next = new Set(visible);
if ((SERIES_BASES as string[]).includes(key)) {
SERIES_BASES.forEach((basis) => next.delete(basis));
next.add(key);
} else if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return normalizeVisibleBasis(next);
}
/** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */
function factorFor(conversion: EarthworkConversion, ground: GroundType): number {
const factor = conversion?.[ground]?.compacted;
+19 -7
View File
@@ -33,6 +33,14 @@ export interface MassHaulAxis {
maxChainageM: number;
padLeft: number;
padRight: number;
/**
* Y축 선·눈금을 그릴 x(px). 생략하면 `padLeft`.
*
* B05는 데이터 시작을 반 칸(originOffset) 들여쓰므로 `padLeft = 62 + originOffset`인데,
* 축까지 그 자리에 그리면 위 종단 그래프의 축(62px)과 어긋나 **축이 두 개**로 보인다
* (2026-08-03 사용자 보고). 축은 여기(종단과 같은 자리)에, 데이터는 padLeft에 둔다.
*/
axisX?: number;
}
/** 축 눈금이 읽히는 최소 높이. 이보다 낮아지면 그래프가 뭉개진다. */
@@ -289,11 +297,15 @@ export function createMassHaulChart(
// 종단면도와 완전히 같은 X 매핑 — 여백·최댓값 모두 호출한 쪽 종단 렌더러에서 받는다.
const maxChainage = Math.max(axis.maxChainageM, 1);
const axisX = axis.axisX ?? axis.padLeft;
const plotWidth = widthPx - axis.padLeft - axis.padRight;
const plotHeight = heightPx - MASS_PAD_TOP - MASS_PAD_BOTTOM;
const x = (chainage: number) => axis.padLeft + (chainage / maxChainage) * plotWidth;
const { min, max } = volumeRange(series);
// 기준 버튼이 라디오가 되면서(택1 표시) Y 범위도 **표시 중인 곡선**으로 잡는다 —
// 숨은 기준까지 합쳐 잡으면 선택한 그래프가 눌려 보인다. 아무것도 안 켰으면 전체로 폴백.
const rangeSource = series.filter((entry) => visibleKeys.has(entry.key));
const { min, max } = volumeRange(rangeSource.length ? rangeSource : series);
const span = Math.max(max - min, 1e-6);
const y = (volume: number) => MASS_PAD_TOP + ((max - volume) / span) * plotHeight;
@@ -304,14 +316,14 @@ export function createMassHaulChart(
const value = max - ratio * span;
svg.append(
svgElement("line", {
x1: axis.padLeft,
x1: axisX,
y1: gridY,
x2: widthPx - axis.padRight,
y2: gridY,
class: "b06-chart__grid",
}),
svgText(formatVolume(value), {
x: axis.padLeft - 9,
x: axisX - 9,
y: gridY + 4,
"text-anchor": "end",
class: "b06-chart__tick",
@@ -391,14 +403,14 @@ export function createMassHaulChart(
// Y 범위 계산(`volumeRange`)이 0을 항상 품으므로 이 선은 어떤 노선에서도 화면에 남는다.
svg.append(
svgElement("line", {
x1: axis.padLeft,
x1: axisX,
y1: zeroY,
x2: widthPx - axis.padRight,
y2: zeroY,
class: "b06-masshaul__zero",
}),
svgText("0", {
x: axis.padLeft - 9,
x: axisX - 9,
y: zeroY + 4,
"text-anchor": "end",
class: "b06-chart__tick b06-masshaul__zero-tick",
@@ -486,9 +498,9 @@ export function createMassHaulChart(
svg.append(
svgElement("line", {
x1: axis.padLeft,
x1: axisX,
y1: MASS_PAD_TOP,
x2: axis.padLeft,
x2: axisX,
y2: heightPx - MASS_PAD_BOTTOM,
class: "b06-chart__axis",
}),