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:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
// 표는 그대로 두고 선택된 값만 위에 얹는다 — 이웃 셀을 숨기면 고를 때마다 표가 비어
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user