Files
Aislo/B06_Section/B06_Section_UI_Cross_View.ts
T
eomsangdonandClaude Opus 5 f2ad5508f5 feat(B06): 횡단 줌 조작 일원화 + 교차점 맞춤 자동 확대
- 카드 하단 ◀/▶/↺(개별 표시 반폭) 삭제. 조작구는 우측 상단 줌 버튼 하나로.
- 줌 버튼 역할 확장(원배율에서만 반폭 조작):
  − 배율>1이면 축소, 원배율이면 표시 반폭 +1m
  + 배율>1이면 확대, 원배율이면 표시 반폭 −1m(하한 2m)
  ⤢ 배율 원복 + 절·성토 교차점이 다 보이는 폭으로 맞춤
- 교차점 맞춤(_UI_Cross_Fit): 보유 샘플(계산 반폭 20m) 안에서 교차점을 찾고,
  샘플 끝까지 안 만나면 끝단 사면·지반 기울기 차로 만나는 자리를 추세 외삽해
  올림 +1m을 요청 반폭으로 쓴다(상한 60m).
- 요청 반폭 > 보유 샘플이면 그 폭으로 재생성(regenerate) 후 해당 측점만 세션
  반폭으로 넓게 표시. 계산 기본 20m·캐시는 그대로, 확정 시 기존 경로대로
  display_half_width_m에 영구 저장.
- 재생성 로직을 _UI_Page_Common.createSampleWidener로 분리(700줄 제한),
  [전체 측점 반영]도 같은 경로 사용. 연타 중복 요청 차단.

자체검증: node tmp/tests/test_toe_extrapolate.mjs (외삽 좌/우·발산 지형·+1m 올림),
tsc --noEmit 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 18:16:31 +09:00

668 lines
29 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Cross_View.ts
* 개별 횡단면 카드 SVG 렌더러와 X:Y 1:1 스케일 지표 계산.
*
* 700줄 제한 대응으로 기존 단일 렌더러 파일에서 횡단 카드 렌더링만 분리했다. 공통 상수·유틸은
* `_UI_Section_Common`, 설계 지정 컨트롤/설계선 오버레이는 `_UI_Cross_Design`을 참조한다.
* 횡단은 종/횡 공통 Y스케일을 쓰지 않고 카드마다 자체 1:1 ppm으로 스케일한다.
* ========================================================================== */
import type { CrossSection } from "./B06_Section_Api_Fetch";
import {
appendCrossAreaBands,
type AreaHighlightSetter,
buildAreaReadout,
type CrossAreaKey,
} from "./B06_Section_UI_Cross_Areas";
import {
appendCrossDesignOverlay,
appendPavementOverlay,
appendRockBoundaryOverlay,
buildDesignControls,
buildRockBoundaryControl,
sectionModeLabel,
type RockBoundaryControl,
} from "./B06_Section_UI_Cross_Design";
import { showToast } from "@ui/ui_template_elements";
import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert";
import { computeCardCulvert, culvertRequiredHalfWidth } from "./B06_Section_UI_Cross_Culvert_Wire";
import type {
ExtraWallControl,
InletStructureControl,
RevetOffsetControl,
} from "./B06_Section_UI_Cross_Culvert_Wire";
import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel";
import { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom";
import type { CrossWidthActions, ZoomPanState } from "./B06_Section_UI_Cross_View_Zoom";
import { toeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import {
CROSS_PAD,
CROSS_WIDTH,
type DesignChangeHandler,
emptyView,
L,
niceTickStep,
stationLabel,
svgElement,
svgText,
validElevation,
} from "./B06_Section_UI_Section_Common";
import { crossPlotMetrics } from "./B06_Section_UI_Cross_View_Metrics";
export { crossCardNaturalHeight } from "./B06_Section_UI_Cross_View_Metrics";
/**
* 횡단 카드 요소. 선택 표시와 면적 강조를 **카드를 다시 만들지 않고** 갈아 끼우는 핸들을 단다
* — 카드를 새로 만들면 사용자가 맞춰 둔 휠 줌·팬(viewBox)이 초기화되기 때문이다.
*/
export interface CrossCardElement extends HTMLElement {
applySelection?: (selected: boolean, areaKey: CrossAreaKey | null) => void;
}
/**
* 카드 SVG에 확대·축소 + 드래그 팬 + 더블클릭 원복을 붙인다(E-5).
*
* 확대·축소 대상은 **플롯 안 도형(지반선·설계선·면적 밴드·중심 십자선)뿐**이다. 축·눈금·축
* 이름은 제자리에 남고, 데이터 레이어(`plotLayer`)의 transform만 바뀐다(2026-08-08 사용자
* 지시). 예전에는 viewBox를 조작해 축과 글자까지 같이 커졌다.
* 선 굵기는 `vector-effect: non-scaling-stroke`(CSS)라 배율과 무관하게 유지된다.
* 최대 8배까지, 축소는 원배율까지만 허용한다.
*/
/**
* 측점 **개별 표시 반폭** 세션 제어기(2026-08-06 사용자 지시).
* Page가 세션 보관·저장값 복원·카드 갱신·확정 저장(cross_patches)을 연결해 구현한다.
*/
export interface StationWidthControl {
/** 개별값(세션→저장값) 우선. 없으면 undefined — 카드가 전역 반폭으로 그린다. */
widthFor: (section: CrossSection) => number | undefined;
/**
* 이 측점 표시 반폭을 지정한다(2026-08-23 개편). 계산 반폭(기본 20m)을 넘는 값이면
* 페이지가 백엔드 재생성으로 샘플을 넓힌 뒤 이 카드만 그 폭으로 다시 그린다.
*/
request: (chainageM: number, widthM: number) => void;
}
// 기슭막이·유입 구조물·다단 제어 인터페이스는 Wire에 있다(700줄 제한) — 재수출.
export type {
ExtraWallControl,
InletStructureControl,
RevetOffsetControl,
} from "./B06_Section_UI_Cross_Culvert_Wire";
/** 측점별 줌·팬 상태 — 카드 재생성에도 배율 유지(2026-08-22 사용자 ③). */
const cardZoomStates = new Map<string, ZoomPanState>();
export function createCrossSectionCard(
section: CrossSection,
selected: boolean,
verticalExaggeration: number,
onSelect: (stationId: string) => void,
stationInterval: number,
crossHalfWidth?: number,
widthPx = CROSS_WIDTH,
forcedHeightPx?: number,
designElevation?: number,
onDesignChange?: DesignChangeHandler,
rockBoundary?: RockBoundaryControl,
/** 이 카드가 선택 상태일 때 되살릴 면적 강조(없으면 강조 없음). */
initialAreaKey?: CrossAreaKey | null,
/** 면적 값을 눌렀을 때. 선택되지 않은 카드에서도 눌릴 수 있어 측점 id를 함께 넘긴다. */
onAreaSelect?: (stationId: string, key: CrossAreaKey | null) => void,
/** 개별 표시 반폭 제어 — 있으면 우측 상단 줌 버튼이 원배율에서 반폭을 조절한다. */
stationWidth?: StationWidthControl,
/** 기슭막이 X 자리 제어 — 있으면 벽이 선택 가능해지고 선택 시 ◀/▶/↺가 뜬다. */
revetOffset?: RevetOffsetControl,
/** 유입측 구조물 형식 선택(드롭다운 — 2026-08-22). */
inletStructure?: InletStructureControl,
/** 유출측 추가 기슭막이 개수 제어(2026-08-22). */
extraWalls?: ExtraWallControl,
): CrossCardElement {
// 실효 표시 반폭 — 개별값 > 전역값(2026-08-06). **구조물 없는 상태**의 절·성토선이
// 원지반과 만나는 지점이 반폭 밖이면 이 카드만 자동 줌아웃(2026-08-22 사용자).
const baseHalfWidth = stationWidth?.widthFor(section) ?? crossHalfWidth;
const requiredHalfWidth = culvertRequiredHalfWidth(section);
const effectiveHalfWidth =
baseHalfWidth !== undefined && requiredHalfWidth !== null
? 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" : ""}`;
card.tabIndex = 0;
card.addEventListener("click", () => onSelect(section.station_id));
card.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") onSelect(section.station_id);
});
// 선택 표시·면적 강조는 **DOM만 갈아 끼운다**. 카드를 새로 만들면 휠 줌·팬(viewBox)이
// 초기화돼 사용자가 맞춰 둔 배율이 날아간다(2026-08-02 사용자 지적).
let isSelected = selected;
let activeArea: CrossAreaKey | null = selected ? (initialAreaKey ?? null) : null;
let setBandActive: AreaHighlightSetter = () => undefined;
let setChipActive: AreaHighlightSetter = () => undefined;
let activeRevet: RevetKey | null = revetOffset?.selectedFor(section) ?? null;
let setRevetActive: RevetHighlightSetter = () => undefined;
let showRevetControl: (visible: boolean) => void = () => undefined;
/** 지금 그린 관 길이(m) — 조정창 표기용. 배수관 측점이 아니면 null. */
let culvertPipeLengthM: number | null = null;
let culvertInletIsBasin = false; // 유입이 집수정인가 — 조정창 표시·제목 분기
/** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */
let culvertInletOptions = { revetAllowed: true, basinLUAllowed: true };
/** 추가 기슭막이 상태(2026-08-22) — 끝 성토부 5m 이상(canAdd)·세워진 개수. */
let culvertExtraState = { canAdd: false, count: 0 };
/** 집수정 계류측 다단 상태(2026-08-22) — 조정창의 집수정 단 수 행이 쓴다. */
let culvertBasinExtraState = { canAdd: false, count: 0 };
/** 마지막 계산의 벽 제원(높이·재질) — 조정창 표시·높이 조작 기준. */
let culvertWallSpecs = new Map<RevetKey, { height: number; material: RevetMaterial }>();
/**
* 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를
* 고르면 다른 하나는 풀린다(2026-08-21 사용자). 또 구조물을 고르면 그 **측점 카드도
* 같이 선택**된다 — 이미 선택된 카드 안에서 다른 구조물을 고를 때는 선택이 옮겨만
* 가고 카드 선택은 건드리지 않는다.
*/
const toggleRevet = (key: RevetKey): void => {
const wasSelected = isSelected;
activeRevet = activeRevet === key ? null : key;
setRevetActive(activeRevet);
showRevetControl(activeRevet !== null);
revetOffset?.select(section.chainage_m, activeRevet);
if (activeRevet !== null && activeArea !== null) {
activeArea = null;
setBandActive(null);
setChipActive(null);
}
if (activeRevet !== null) {
// 미선택 카드는 **카드 선택만** 부른다. 여기서 면적 비우기(onAreaSelect)를 먼저
// 부르면 그쪽이 카드를 선택해 버려, 뒤이은 onSelect가 "같은 측점 재클릭 = 해제"로
// 먹혀 아무 일도 일어나지 않는다(2026-08-21 화면 확인). 카드 선택 경로가 부모의
// 면적 강조를 이미 비운다.
if (wasSelected) onAreaSelect?.(section.station_id, null);
else onSelect(section.station_id);
}
};
card.applySelection = (nextSelected, areaKey) => {
isSelected = nextSelected;
card.classList.toggle("b06-cross-card--selected", nextSelected);
activeArea = nextSelected ? (areaKey ?? null) : null;
setBandActive(activeArea);
setChipActive(activeArea);
// 면적이 켜졌거나 카드가 선택에서 빠지면 구조물 선택은 풀린다(같은 레벨).
if ((activeArea !== null || !nextSelected) && activeRevet !== null) {
activeRevet = null;
setRevetActive(null);
showRevetControl(false);
revetOffset?.select(section.chainage_m, null);
}
};
const toggleArea = (key: CrossAreaKey): void => {
if (!isSelected) {
// 아직 선택되지 않은 카드 — 부모가 카드를 고르면서 이 면적까지 한 번에 켠다.
onAreaSelect?.(section.station_id, key);
return;
}
activeArea = activeArea === key ? null : key;
if (activeArea !== null && activeRevet !== null) {
activeRevet = null;
setRevetActive(null);
showRevetControl(false);
revetOffset?.select(section.chainage_m, null);
}
setBandActive(activeArea);
setChipActive(activeArea);
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";
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);
}
const metrics = crossPlotMetrics(
section,
verticalExaggeration,
widthPx,
effectiveHalfWidth,
designElevation,
forcedHeightPx,
);
if (!metrics) {
card.append(emptyView(L("B06_Profile_View_NoCross")));
} else {
const {
sourceSamples,
minOffset,
maxOffset,
elevationMid,
displaySpan,
displayMax,
pixelsPerMeter,
heightPx,
} = metrics;
const hasDesign = designElevation !== undefined && Number.isFinite(designElevation);
const valid = sourceSamples.filter(validElevation);
const exaggeration = Math.max(verticalExaggeration, 0.1);
// 좌표 규약(+offset=좌, -offset=우)을 표준 횡단면도 관례에 맞춘다: 진행방향을 바라보는
// 시점이라 좌측(+offset)이 화면 왼쪽에 와야 B05 3D 방향 표시와 측구 방향이 일치한다(작업 C-3).
const x = (offset: number) => CROSS_PAD.left + (maxOffset - offset) * pixelsPerMeter;
const y = (elevation: number) => CROSS_PAD.top + (displayMax - elevation) * pixelsPerMeter;
const svg = svgElement("svg", {
class: "b06-section__chart",
width: widthPx,
height: heightPx,
viewBox: `0 0 ${widthPx} ${heightPx}`,
role: "img",
"aria-label": `${section.label} ${L("B06_Profile_View_Cross")}`,
});
svg.append(svgElement("rect", { width: widthPx, height: heightPx, class: "b06-chart__bg" }));
const xTicks = Array.from(
{ length: 7 },
(_, index) => minOffset + ((maxOffset - minOffset) * index) / 6,
);
for (const tick of xTicks) {
svg.append(
svgElement("line", {
x1: x(tick),
y1: CROSS_PAD.top,
x2: x(tick),
y2: heightPx - CROSS_PAD.bottom,
class: "b06-chart__grid",
}),
svgText(Math.abs(tick) < 1e-6 ? "0" : tick.toFixed(0), {
x: x(tick),
y: heightPx - CROSS_PAD.bottom + 16,
"text-anchor": "middle",
class: "b06-chart__tick",
}),
);
}
// Y축 눈금: 표고 범위를 10칸 안팎으로 나누되 눈금값이 1·2·5 계열로 딱 떨어지게 한다
// (종단면도와 같은 규칙). 5등분 고정은 눈금값이 소수로 흘러 표고를 못 읽었다
// (2026-08-23 사용자 지시). 라벨 겹침 방지로 눈금 간격은 14px 이상 띄운다.
// 눈금 범위는 **실제로 보이는 플롯 상·하단**(y() 기준)에서 뽑는다 — elevationMid를
// 중심으로 잡으면 displayMax의 면적표 여유(headroom)만큼 아래로 밀려 X축 라벨 줄
// 아래에 격자선이 삐져나왔다(2026-08-23 사용자 보고).
const rawDisplaySpan = displaySpan / exaggeration;
const rawTop = elevationMid + (displayMax - elevationMid) / exaggeration;
const rawBottom = rawTop - rawDisplaySpan;
const plotHeightPx = heightPx - CROSS_PAD.top - CROSS_PAD.bottom;
const tickStep = niceTickStep(rawDisplaySpan, 10, Math.floor(plotHeightPx / 14));
const tickDecimals = Math.max(0, Math.ceil(-Math.log10(tickStep) - 1e-9));
for (
let tick = Math.ceil(rawBottom / tickStep) * tickStep;
tick <= rawTop + 1e-9;
tick += tickStep
) {
const displayTick = elevationMid + (tick - elevationMid) * exaggeration;
svg.append(
svgElement("line", {
x1: CROSS_PAD.left,
y1: y(displayTick),
x2: widthPx - CROSS_PAD.right,
y2: y(displayTick),
class: "b06-chart__grid",
}),
svgText(tick.toFixed(tickDecimals), {
x: CROSS_PAD.left - 7,
y: y(displayTick) + 3,
"text-anchor": "end",
class: "b06-chart__tick",
}),
);
}
// 확대·축소·팬이 닿는 범위 = 이 레이어 안(2026-08-08 사용자 지시. 축·눈금·축 이름은 제자리).
// clip 그룹은 고정하고 안쪽 plotLayer만 transform으로 움직인다 — clip을 transform
// 요소에 직접 걸면 자르는 창까지 확대된다. 표시 반폭 밖 잘라내기도 이 clip 몫.
const clipId = `b06-cross-clip-${section.station_id}`;
const clip = svgElement("clipPath", { id: clipId });
clip.append(
svgElement("rect", {
x: CROSS_PAD.left,
y: CROSS_PAD.top,
width: Math.max(widthPx - CROSS_PAD.left - CROSS_PAD.right, 1),
height: Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1),
}),
);
const clipGroup = svgElement("g", { "clip-path": `url(#${clipId})` });
const plotLayer = svgElement("g", { class: "b06-chart__plot-layer" });
clipGroup.append(plotLayer);
svg.append(clip, clipGroup);
const segments: string[] = [];
let current: string[] = [];
for (const sample of sourceSamples) {
if (!validElevation(sample)) {
if (current.length > 1) segments.push(current.join(" "));
current = [];
continue;
}
const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration;
current.push(`${x(sample.offset_m ?? 0)},${y(elevated)}`);
}
if (current.length > 1) segments.push(current.join(" "));
segments.forEach((points) =>
plotLayer.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })),
);
if (section.design) {
const toDisplayY = (elevation: number): number =>
y(elevationMid + (elevation - elevationMid) * exaggeration);
// 면적 밴드는 지면선 위·설계선 아래에 깔아 선이 밴드에 가리지 않게 한다.
// 선택 여부와 무관하게 항상 깐다 — 평소에는 투명이고, 선택이 바뀔 때 카드를 새로 만들지
// 않고 클래스만 켜면 되므로 줌·팬이 살아남는다.
setBandActive = appendCrossAreaBands(
plotLayer,
section.design,
sourceSamples,
x,
toDisplayY,
toggleArea,
);
// 배수관 세트 기하 계산 + 이동량 되받기(토스트) — 분리 파일(Wire, 700줄 제한).
// 계산은 **표시 반폭으로 자르지 않은 전체 샘플**로 한다(2026-08-22 사용자 확정) —
// 표시 반폭은 보기용이고, 사면 끝·지반 교차 판정은 계산용 전 구간이 필요하다.
const culvertLayout = computeCardCulvert(
section,
section.samples,
activeRevet,
revetOffset,
inletStructure,
extraWalls,
);
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
appendPavementOverlay(plotLayer, section.design, x, toDisplayY);
appendCrossDesignOverlay(
plotLayer,
section.design,
x,
toDisplayY,
sourceSamples,
culvertLayout?.designTrim ?? undefined,
);
// 암 경계선은 지면선(지반선) 복사 + 오프셋 — 계획선 기준이 아님에 유의.
if (rockBoundary && section.design.geometry_preset === "rock") {
appendRockBoundaryOverlay(
plotLayer,
sourceSamples,
rockBoundary.offsetFor(section),
x,
toDisplayY,
);
}
// 배수관 세트 — 조정창이 쓸 카드 상태를 여기서 받아 둔다(2026-08-19).
culvertPipeLengthM = culvertLayout?.pipe.lengthM ?? null;
culvertInletIsBasin = !!culvertLayout?.basin;
if (culvertLayout) {
culvertInletOptions = culvertLayout.inletOptions;
culvertExtraState = {
canAdd: culvertLayout.outletFill.addable,
count: culvertLayout.extraWalls.length,
};
culvertBasinExtraState = {
canAdd: culvertLayout.basinFill.addable,
count: culvertLayout.basinExtras.length,
};
culvertWallSpecs = new Map();
const specOf = (wall: (typeof culvertLayout.walls)[number], key: RevetKey) =>
culvertWallSpecs.set(key, { height: wall.height, material: wall.material });
for (const wall of culvertLayout.walls) specOf(wall, wall.role as RevetKey);
culvertLayout.extraWalls.forEach((wall, i) => specOf(wall, `extra${i}` as RevetKey));
culvertLayout.basinExtras.forEach((wall, i) => specOf(wall, `bextra${i}` as RevetKey));
}
if (culvertLayout) {
setRevetActive = appendCulvertOverlay(
plotLayer,
culvertLayout,
x,
toDisplayY,
revetOffset ? toggleRevet : undefined,
);
if (activeRevet) setRevetActive(activeRevet);
}
}
const centerSample = valid.reduce<(typeof valid)[number] | null>((nearest, sample) => {
if (!nearest || Math.abs(sample.offset_m ?? 0) < Math.abs(nearest.offset_m ?? 0))
return sample;
return nearest;
}, null);
const centerX = x(0);
// 십자선 = 계획 노선 자리(구 데이터는 지반고 폴백).
const centerElevation = hasDesign
? (designElevation as number)
: (centerSample?.elevation_m ?? null);
const centerY =
centerElevation !== null
? y(elevationMid + (centerElevation - elevationMid) * exaggeration)
: heightPx / 2;
const centerMarkerClass = `b06-chart__center-marker${hasDesign ? " b06-chart__center-marker--design" : ""}`;
plotLayer.append(
svgElement("line", {
x1: centerX,
y1: centerY - 18,
x2: centerX,
y2: centerY + 18,
class: centerMarkerClass,
}),
svgElement("line", {
x1: centerX - 18,
y1: centerY,
x2: centerX + 18,
y2: centerY,
class: centerMarkerClass,
}),
);
svg.append(
svgElement("line", {
x1: CROSS_PAD.left,
y1: heightPx - CROSS_PAD.bottom,
x2: widthPx - CROSS_PAD.right,
y2: heightPx - CROSS_PAD.bottom,
class: "b06-chart__axis",
}),
svgElement("line", {
x1: CROSS_PAD.left,
y1: CROSS_PAD.top,
x2: CROSS_PAD.left,
y2: heightPx - CROSS_PAD.bottom,
class: "b06-chart__axis",
}),
svgText(L("B06_Profile_View_CrossXAxis"), {
x: widthPx / 2,
y: heightPx - 8,
"text-anchor": "middle",
class: "b06-chart__axis-label",
}),
svgText(L("B06_Profile_View_ElevationAxis"), {
x: 13,
y: heightPx / 2,
"text-anchor": "middle",
transform: `rotate(-90 13 ${heightPx / 2})`,
class: "b06-chart__axis-label",
}),
);
// 확대·축소 버튼, 드래그 팬, 더블클릭 원복(E-5). 도형 레이어 transform + non-scaling-stroke.
const chartWrap = document.createElement("div");
chartWrap.className = "b06-cross-card__chart-wrap";
const zoomPan = attachZoomPan(
svg,
plotLayer,
widthPx,
heightPx,
cardZoomStates.get(section.station_id),
(state) => cardZoomStates.set(section.station_id, state),
);
// 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 값 칸은 항상 강조 토글이다.
const readout = buildAreaReadout(section.design, toggleArea);
setChipActive = readout.setActive;
// 구조물 조정은 도면 안 오버레이 창(2026-08-21). 화면 좌(◀) = offset 증가 —
// 벽 기준 이동량(outward 부호)으로 환산해 넘긴다.
const outwardOf = (role: RevetKey): number =>
((section.uphill_side ?? "left") === "left") === (role === "inlet") ? 1 : -1;
const heightOfWall = (key: RevetKey): number => culvertWallSpecs.get(key)?.height ?? 0;
const materialOfWall = (key: RevetKey): RevetMaterial =>
culvertWallSpecs.get(key)?.material ?? "dry";
const adjustOf = (key: RevetKey): WallAdjust =>
revetOffset?.adjustFor(section, key) ?? { x: 0, d: 0, h: null, m: null };
const panel = buildStructurePanel({
adjustFor: adjustOf,
nudge: (key, screenDeltaM) =>
revetOffset?.update(section.chainage_m, key, {
x: adjustOf(key).x + screenDeltaM * outwardOf(key),
}),
nudgeSlope: (key, deltaM) =>
revetOffset?.update(section.chainage_m, key, { d: adjustOf(key).d + deltaM }),
nudgeHeight: (key, deltaM) => {
const current = adjustOf(key);
const base = current.h ?? heightOfWall(key);
revetOffset?.update(section.chainage_m, key, {
h: Math.round((base + deltaM) * 10) / 10,
});
},
materialFor: (key) => materialOfWall(key),
setMaterial: (key, material) => revetOffset?.update(section.chainage_m, key, { m: material }),
heightFor: (key) => heightOfWall(key),
reset: (key) => revetOffset?.reset(section.chainage_m, key),
pipeLengthM: () => culvertPipeLengthM,
close: () => {
if (activeRevet) toggleRevet(activeRevet);
},
structureFor: () => inletStructure?.valueFor(section) ?? "auto",
setStructure: (value) => inletStructure?.set(section.chainage_m, value),
basinAdjustFor: () =>
inletStructure?.adjustFor(section) ?? {
innerWidthM: 1,
innerHeightM: 1.2,
lateralM: 0,
slopeM: 0,
},
updateBasin: (patch) =>
inletStructure?.updateAdjust(section.chainage_m, {
...inletStructure.adjustFor(section),
...patch,
}),
// 집수정 좌우는 **화면 기준**으로 받는다(2026-08-22 사용자 확정) — 유입이 우측인
// 측점에서 ◀가 화면 오른쪽으로 가던 반전을 없앤다. 노견 안쪽(음수)은 금지라
// 0에서 멈추고, 멈춘 사실은 토스트로 알린다(기슭막이와 같은 규칙).
nudgeBasinLateral: (screenDeltaM) => {
const current = inletStructure?.adjustFor(section);
if (!current) return;
const next = current.lateralM + screenDeltaM * outwardOf("inlet");
if (next < -1e-9) {
showToast(L("B06_Cross_Revet_Limit_Inward"), "info");
}
inletStructure?.updateAdjust(section.chainage_m, {
...current,
lateralM: Math.max(0, Math.round(next * 10) / 10),
});
},
resetBasin: () => inletStructure?.resetAdjust(section.chainage_m),
// 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다(추가 벽은 항상 이동).
canNudge: (key) => key !== "inlet" || !culvertInletIsBasin,
optionsFor: () => culvertInletOptions,
extraState: () => culvertExtraState,
basinExtraState: () => culvertBasinExtraState,
setExtraCount: (count) => {
// 줄어들며 사라지는 벽이 선택 중이면 선택을 유출 벽으로 옮긴다(빈 대상 방지).
if (
activeRevet?.startsWith("extra") &&
!activeRevet.startsWith("bextra") &&
Number(activeRevet.slice(5)) >= count
) {
activeRevet = "outlet";
revetOffset?.select(section.chainage_m, "outlet");
}
extraWalls?.setCount(section.chainage_m, count, "outlet");
},
setBasinExtraCount: (count) => {
if (activeRevet?.startsWith("bextra") && Number(activeRevet.slice(6)) >= count) {
activeRevet = "inlet";
revetOffset?.select(section.chainage_m, "inlet");
}
extraWalls?.setCount(section.chainage_m, count, "basin");
},
equalizeExtras: () => extraWalls?.equalize(section.chainage_m),
});
showRevetControl = (visible) => panel.show(visible ? activeRevet : null);
showRevetControl(activeRevet !== null);
// 우측 상단 줌 버튼이 원배율에서 표시 반폭까지 다룬다(하단 ◀/▶/↺ 폐지, 2026-08-23).
const widthActions: CrossWidthActions | undefined = stationWidth && {
step: (deltaM) =>
stationWidth.request(
section.chainage_m,
Math.round((effectiveHalfWidth ?? maxOffset) + deltaM),
),
fit: () => {
const fitted = toeFitHalfWidth(section);
if (fitted !== null) stationWidth.request(section.chainage_m, fitted);
},
};
chartWrap.append(svg, readout.root, buildZoomControls(zoomPan, widthActions), panel.root);
if (activeArea) {
setBandActive(activeArea);
setChipActive(activeArea);
}
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);
return card;
}