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
+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",
}),