refactor(B06): 횡단 카드 껍데기·반폭 계산 분리 (700줄 제한)

`_UI_Cross_View` 784줄 → 698줄. 도면과 무관한 제목행·하단 정보행을 `_UI_Cross_Card_Chrome`
으로 옮기고, 실효 표시 반폭 계산은 순수 계산이라 `_UI_Cross_View_Metrics` 로, 측점별 줌·팬
상태 맵은 임자인 `_UI_Cross_View_Zoom` 으로 옮겼다. 계산·판정 규칙은 그대로다.

검증 — 공용 브라우저 B06 실측: 카드 65·제목행 65·하단행 65·단면유형 pill 65·사면 미교차
경고 13, 첫 카드 제목행 `0+0.0 / 0.0m / 토사·리핑암·발파암 / 편측 성토 / BP` 로 종전과 동일.
pytest 383 passed·17 skipped, typecheck·prettier 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-03 13:56:01 +09:00
co-authored by Claude Opus 5
parent d6d211ef61
commit 15c2d81b49
4 changed files with 142 additions and 96 deletions
@@ -0,0 +1,102 @@
/* =============================================================================
* B06_Section_UI_Cross_Card_Chrome.ts
* 횡단 카드의 **껍데기**(제목행·하단 정보행)만 떼어 낸 조립기 (2026-09-03 · 700줄 제한).
*
* 카드 본체(`_UI_Cross_View`)는 SVG 도면과 조작을 맡고, 도면과 무관한 머리·꼬리는 여기서
* 만든다. 여기 있는 것은 전부 **읽어서 붙이는 값**이라 카드 상태(줌·선택)를 건드리지 않는다.
* ========================================================================== */
import type { CrossSection } from "./B06_Section_Api_Fetch";
import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design";
import {
buildDesignControls,
buildRockBoundaryControl,
sectionModeLabel,
} from "./B06_Section_UI_Cross_Design";
import { type DesignChangeHandler, L, stationLabel } from "./B06_Section_UI_Section_Common";
/**
* 카드 제목행을 만들어 카드에 붙인다(설계 조작이 있으면 조작 바까지).
*
* 1행 구조(E-2/E-3): 측점 위치표기 → 지반유형 → 단면유형 pill → 구조물 → 측점정보.
*/
export function appendCardHeader(
card: HTMLElement,
section: CrossSection,
stationInterval: number,
onDesignChange?: DesignChangeHandler,
): void {
const header = document.createElement("header");
const title = document.createElement("div");
title.className = "b06-cross-card__title";
const label = document.createElement("strong");
label.textContent = stationLabel(section.chainage_m, stationInterval);
const chainage = document.createElement("span");
chainage.textContent = `${section.chainage_m.toFixed(1)}m`;
title.append(label, chainage);
// 단면유형 pill(자동 판정, 읽기 전용) — 구조물 pill과 동일 표기, 좌측 배치(E-3).
const modePill = document.createElement("span");
modePill.className = "b06-cross-card__mode";
modePill.textContent = sectionModeLabel(section.design?.section_mode);
modePill.title = L("B06_Design_Mode_Legend");
const meta = document.createElement("div");
meta.className = "b06-cross-card__meta";
// 사면이 계산 반폭 끝까지 원지반을 못 만난 측점 — 면적이 거기서 잘려 유토곡선까지
// 그 값을 쌓는다. 계산은 손대지 않고 사실만 알린다(2026-09-03 사용자 확정).
if (section.design?.slope_unclosed) {
const openSlope = document.createElement("span");
openSlope.className = "b06-cross-card__warning";
openSlope.textContent = `${L("B06_Cross_SlopeUnclosed")}`;
openSlope.title = L("B06_Cross_SlopeUnclosed_Tip");
meta.append(openSlope);
}
if (section.structure) {
const structure = document.createElement("span");
structure.className = "b06-cross-card__structure";
structure.textContent = section.structure;
structure.title = `구조물: ${section.structure}`;
meta.append(structure);
}
// 측점 종류는 시·종점(BP/EP)만 적는다 — "일반측점"은 대다수라 정보가 없다(2026-08-02 사용자 지시).
if (section.kind === "bp" || section.kind === "ep") {
const kind = document.createElement("span");
kind.textContent = L(
section.kind === "ep" ? "B06_Profile_View_Kind_EP" : "B06_Profile_View_Kind_BP",
);
meta.append(kind);
}
// 단면유형 pill은 지반유형 우측·우측 맞춤으로 배치(1번). 좌측 구분선은 지반유형 세그먼트에 준다(3번).
modePill.classList.add("b06-cross-card__mode--right");
header.append(title);
if (onDesignChange) {
const controls = buildDesignControls(section, onDesignChange);
controls.groundSegment.classList.add("b06-cross-card__ground");
header.append(controls.groundSegment, modePill, meta);
card.append(header, controls.bar);
return;
}
header.append(modePill, meta);
card.append(header);
}
/** 하단 정보행 — 중심고와 (암 지반일 때) 암 경계선 제어. */
export function appendCardFooter(
card: HTMLElement,
section: CrossSection,
rockBoundary?: RockBoundaryControl,
): void {
const footer = document.createElement("footer");
const center = document.createElement("span");
center.textContent = `${L("B06_Profile_View_CenterElevation")} ${section.center_z?.toFixed(2) ?? "-"}m`;
footer.append(center);
// 암 경계선 제어는 하단 정보 행 가운데(2026-08-02) — 암 지반만.
if (rockBoundary && section.design?.geometry_preset === "rock") {
const rockControl = buildRockBoundaryControl(section, rockBoundary);
rockControl.classList.add("b06-cross-card__rockb");
footer.append(rockControl);
}
card.append(footer);
}
+10 -96
View File
@@ -23,11 +23,9 @@ import {
appendCrossDesignOverlay,
appendPavementOverlay,
appendRockBoundaryOverlay,
buildDesignControls,
buildRockBoundaryControl,
sectionModeLabel,
type RockBoundaryControl,
} from "./B06_Section_UI_Cross_Design";
import { appendCardFooter, appendCardHeader } from "./B06_Section_UI_Cross_Card_Chrome";
import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert";
import type { CulvertDesignTrim } from "./B06_Section_UI_Cross_Culvert";
import { appendBoxOverlay, computeBoxLayout } from "./B06_Section_UI_Cross_Box";
@@ -35,7 +33,7 @@ import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel";
import { createBodyWiring } from "./B06_Section_UI_Cross_View_Bodies";
import { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford";
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
import { computeCardCulvert, culvertRequiredHalfWidth } from "./B06_Section_UI_Cross_Culvert_Wire";
import { computeCardCulvert } from "./B06_Section_UI_Cross_Culvert_Wire";
import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
import type {
ExtraWallControl,
@@ -47,8 +45,8 @@ import type {
import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel";
import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Dock";
import { culvertCardState, structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure";
import { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom";
import type { CrossWidthActions, ZoomPanState } from "./B06_Section_UI_Cross_View_Zoom";
import { attachZoomPan, buildZoomControls, cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom";
import type { CrossWidthActions } from "./B06_Section_UI_Cross_View_Zoom";
import { toeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
import { createCrossAxes, IDENTITY_VIEW } from "./B06_Section_UI_Cross_Axes";
import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert";
@@ -60,12 +58,11 @@ import {
type DesignChangeHandler,
emptyView,
L,
stationLabel,
svgElement,
svgText,
validElevation,
} from "./B06_Section_UI_Section_Common";
import { crossPlotMetrics } from "./B06_Section_UI_Cross_View_Metrics";
import { crossPlotMetrics, effectiveCardHalfWidth } from "./B06_Section_UI_Cross_View_Metrics";
export { crossCardNaturalHeight } from "./B06_Section_UI_Cross_View_Metrics";
/**
@@ -113,9 +110,6 @@ export type {
StructureSpanControl,
} from "./B06_Section_UI_Cross_Culvert_Wire";
/** 측점별 줌·팬 상태 — 카드 재생성에도 배율 유지(2026-08-22 사용자 ③). */
const cardZoomStates = new Map<string, ZoomPanState>();
export function createCrossSectionCard(
section: CrossSection,
selected: boolean,
@@ -154,24 +148,10 @@ export function createCrossSectionCard(
// 선택·조정은 연다 — 연동을 풀어 이 측점 위치를 따로 잡을 수 있어야 한다
// (2026-08-24 사용자). 길이·전/후 같은 구간 값은 소유 측점 한 곳에서 관리한다.
const isLinkedCulvert = !section.culvert && !!culvertLink;
// 실효 표시 반폭 — 개별값 > 전역값(2026-08-06). 절·성토선이 원지반과 만나는 지점
// (교차점)이 반폭 밖이면 **이 카드만** 자동 줌아웃한다(2026-08-22, 판정 기준 개편
// 2026-08-23: 배수관용 5m 사면 규칙 대신 실제 교차점 = toeFitHalfWidth).
// 보유 샘플 밖까지는 넓히지 않는다 — 지반이 없어 빈 화면이 될 뿐이고, 그 경우
// [보기 반폭 적용]이 필요한 폭으로 백엔드 재생성을 건다.
const baseHalfWidth = stationWidth?.widthFor(section) ?? crossHalfWidth;
const sampledExtent = Math.max(
0,
...section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)),
const effectiveHalfWidth = effectiveCardHalfWidth(
section,
stationWidth?.widthFor(section) ?? crossHalfWidth,
);
const requiredHalfWidth = Math.min(
Math.max(culvertRequiredHalfWidth(section) ?? 0, toeFitHalfWidth(section) ?? 0),
sampledExtent,
);
const effectiveHalfWidth =
baseHalfWidth !== undefined && requiredHalfWidth > 0
? Math.max(baseHalfWidth, requiredHalfWidth)
: baseHalfWidth;
const card: CrossCardElement = document.createElement("article");
card.id = `cross-${section.station_id}`;
card.className = `b06-cross-card${selected ? " b06-cross-card--selected" : ""}`;
@@ -291,63 +271,7 @@ export function createCrossSectionCard(
onAreaSelect?.(section.station_id, activeArea);
};
// 제목행 1행 구조(E-2/E-3): 측점 위치표기 → 단면유형 pill → 지반유형 → 구조물 → 측점정보.
const header = document.createElement("header");
const title = document.createElement("div");
title.className = "b06-cross-card__title";
const label = document.createElement("strong");
label.textContent = stationLabel(section.chainage_m, stationInterval);
const chainage = document.createElement("span");
chainage.textContent = `${section.chainage_m.toFixed(1)}m`;
title.append(label, chainage);
// 단면유형 pill(자동 판정, 읽기 전용) — 구조물 pill과 동일 표기, 좌측 배치(E-3).
const modePill = document.createElement("span");
modePill.className = "b06-cross-card__mode";
modePill.textContent = sectionModeLabel(section.design?.section_mode);
modePill.title = L("B06_Design_Mode_Legend");
const meta = document.createElement("div");
meta.className = "b06-cross-card__meta";
// 사면이 계산 반폭 끝까지 원지반을 못 만난 측점 — 면적이 거기서 잘려 유토곡선까지
// 그 값을 쌓는다. 계산은 손대지 않고 사실만 알린다(2026-09-03 사용자 확정).
if (section.design?.slope_unclosed) {
const openSlope = document.createElement("span");
openSlope.className = "b06-cross-card__warning";
openSlope.textContent = `${L("B06_Cross_SlopeUnclosed")}`;
openSlope.title = L("B06_Cross_SlopeUnclosed_Tip");
meta.append(openSlope);
}
if (section.structure) {
const structure = document.createElement("span");
structure.className = "b06-cross-card__structure";
structure.textContent = section.structure;
structure.title = `구조물: ${section.structure}`;
meta.append(structure);
}
// 측점 종류는 시·종점(BP/EP)만 적는다 — "일반측점"은 대다수라 정보가 없다(2026-08-02 사용자 지시).
if (section.kind === "bp" || section.kind === "ep") {
const kind = document.createElement("span");
kind.textContent = L(
section.kind === "ep" ? "B06_Profile_View_Kind_EP" : "B06_Profile_View_Kind_BP",
);
meta.append(kind);
}
// 단면유형 pill은 지반유형 우측·우측 맞춤으로 배치(1번). 좌측 구분선은 지반유형 세그먼트에 준다(3번).
modePill.classList.add("b06-cross-card__mode--right");
header.append(title);
if (onDesignChange) {
// 제목행: 측점 라벨 → (구분선) 지반유형 → 단면유형 pill(우측) → 구조물·kind (D-6/E-2/1·3번).
const controls = buildDesignControls(section, onDesignChange);
controls.groundSegment.classList.add("b06-cross-card__ground");
header.append(controls.groundSegment, modePill, meta);
card.append(header);
card.append(controls.bar);
} else {
header.append(modePill, meta);
card.append(header);
}
appendCardHeader(card, section, stationInterval, onDesignChange);
const metrics = crossPlotMetrics(
section,
@@ -769,16 +693,6 @@ export function createCrossSectionCard(
card.append(chartWrap);
}
const footer = document.createElement("footer");
const center = document.createElement("span");
center.textContent = `${L("B06_Profile_View_CenterElevation")} ${section.center_z?.toFixed(2) ?? "-"}m`;
footer.append(center);
// 암 경계선 제어는 하단 정보 행 가운데(2026-08-02) — 암 지반만.
if (rockBoundary && section.design?.geometry_preset === "rock") {
const rockControl = buildRockBoundaryControl(section, rockBoundary);
rockControl.classList.add("b06-cross-card__rockb");
footer.append(rockControl);
}
card.append(footer);
appendCardFooter(card, section, rockBoundary);
return card;
}
@@ -1,5 +1,7 @@
import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch";
import { CROSS_HEIGHT, CROSS_PAD, validElevation } from "./B06_Section_UI_Section_Common";
import { culvertRequiredHalfWidth } from "./B06_Section_UI_Cross_Culvert_Wire";
import { toeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
const AREA_OVERLAY_HEADROOM_PX = 58;
@@ -73,3 +75,28 @@ export function crossCardNaturalHeight(
?.heightPx ?? CROSS_HEIGHT
);
}
/**
* 카드가 실제로 그릴 표시 반폭 — 개별값 > 전역값(2026-08-06).
*
* 절·성토선이 원지반과 만나는 지점(교차점)이 반폭 밖이면 **이 카드만** 자동 줌아웃한다
* (2026-08-22, 판정 기준 개편 2026-08-23: 배수관용 5m 사면 규칙 대신 실제 교차점).
* 보유 샘플 밖까지는 넓히지 않는다 — 지반이 없어 빈 화면이 될 뿐이고, 그 경우
* [보기 반폭 적용]이 필요한 폭으로 백엔드 재생성을 건다.
*/
export function effectiveCardHalfWidth(
section: CrossSection,
baseHalfWidth: number | undefined,
): number | undefined {
const sampledExtent = Math.max(
0,
...section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)),
);
const requiredHalfWidth = Math.min(
Math.max(culvertRequiredHalfWidth(section) ?? 0, toeFitHalfWidth(section) ?? 0),
sampledExtent,
);
return baseHalfWidth !== undefined && requiredHalfWidth > 0
? Math.max(baseHalfWidth, requiredHalfWidth)
: baseHalfWidth;
}
@@ -53,6 +53,9 @@ export interface ContentBounds {
height: number;
}
/** 측점별 줌·팬 상태 — 카드를 다시 만들어도 배율이 살아남는다(2026-08-22 사용자 ③). */
export const cardZoomStates = new Map<string, ZoomPanState>();
export function attachZoomPan(
svg: SVGSVGElement,
plotLayer: SVGGElement,