Files
Aislo/B06_Section/B06_Section_UI_Cross_View.ts
T
eomsangdonandClaude Fable 5 54954a05e5 refactor(B05,B06): B05_wf2_Route -> B05_Profile, B06_wf3_ProfileCross -> B06_Section 동시 개명
- 한몸으로 동작하는 두 페이지라 한 커밋으로 처리 (상호 참조 다수)
- B05 37파일 + B06 20파일 접두사 개명 (git mv, 이력 보존)
- 참조 치환 91파일: import 경로, 라우트 슬러그(b05-profile/b06-section),
  라우트 키(B05_PROFILE/B06_SECTION), B03 자동 체인, storage 상수, pyproject 제외 경로
- 로직 변경 없음. typecheck·백엔드 import 검증 통과

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 10:03:11 +09:00

660 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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, SectionSample } 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 {
CROSS_HEIGHT,
CROSS_PAD,
CROSS_WIDTH,
type DesignChangeHandler,
emptyView,
L,
stationLabel,
svgElement,
svgText,
validElevation,
} from "./B06_Section_UI_Section_Common";
/**
* 횡단 카드 요소. 선택 표시와 면적 강조를 **카드를 다시 만들지 않고** 갈아 끼우는 핸들을 단다
* — 카드를 새로 만들면 사용자가 맞춰 둔 휠 줌·팬(viewBox)이 초기화되기 때문이다.
*/
export interface CrossCardElement extends HTMLElement {
applySelection?: (selected: boolean, areaKey: CrossAreaKey | null) => void;
}
// 절·성토 값 오버레이(그래프 상단 고정)가 계획고 선을 가리는 드문 경우를 대비해, Y 플롯
// 최대값에 더하는 상단 여유(px). 오버레이·라벨은 손대지 않고 데이터를 그만큼 아래로 내린다.
// 오버레이가 칩 한 줄에서 표(머리글 + 절토 + 성토 3행)로 바뀌어 그만큼 키웠다.
const AREA_OVERLAY_HEADROOM_PX = 58;
interface CrossPlotMetrics {
sourceSamples: SectionSample[];
minOffset: number;
maxOffset: number;
elevationMid: number;
displaySpan: number;
displayMax: number;
pixelsPerMeter: number;
heightPx: number;
}
/**
* 횡단 카드의 X:Y 1:1 스케일 지표를 계산한다.
* X pixels-per-meter는 반폭(offset 범위)이 카드 폭을 채우도록 고정하고, 같은 ppm을 Y에도 써서
* 형상 왜곡을 없앤다(과장=1 → 정확히 1:1). 카드 높이는 데이터가 요구하는 자연 높이에 최소
* `CROSS_HEIGHT`(250px) 바닥을 적용하며, `forcedHeightPx`가 오면 그 높이로 강제한다(같은 행 높이 통일).
* 어느 경우든 displaySpan을 실제 plotHeight에 맞춰 되계산하므로 ppm(=1:1)은 그대로 보존되고,
* 남는 세로 공간은 elevationMid를 중심으로 대칭 여백이 된다.
*/
function crossPlotMetrics(
section: CrossSection,
verticalExaggeration: number,
widthPx: number,
crossHalfWidth?: number,
designElevation?: number,
forcedHeightPx?: number,
): CrossPlotMetrics | null {
const sourceSamples = section.samples.filter(
(sample) =>
crossHalfWidth === undefined || Math.abs(sample.offset_m ?? 0) <= crossHalfWidth + 1e-6,
);
const valid = sourceSamples.filter(validElevation);
if (!valid.length) return null;
const offsets = sourceSamples.map((sample) => sample.offset_m ?? 0);
const minOffset = Math.min(...offsets, -1);
const maxOffset = Math.max(...offsets, 1);
const hasDesign = designElevation !== undefined && Number.isFinite(designElevation);
const elevations = valid.map((sample) => sample.elevation_m);
if (hasDesign) elevations.push(designElevation as number);
const rawMin = Math.min(...elevations);
const rawMax = Math.max(...elevations);
const elevationMid = (rawMin + rawMax) / 2;
const padding = rawMax > rawMin ? (rawMax - rawMin) * 0.08 : 0.5;
const exaggeration = Math.max(verticalExaggeration, 0.1);
const plotWidth = widthPx - CROSS_PAD.left - CROSS_PAD.right;
const xSpan = Math.max(maxOffset - minOffset, 1e-6);
const pixelsPerMeter = plotWidth / xSpan;
const rawSpan = Math.max((rawMax - rawMin + padding * 2) * exaggeration, 1e-6);
// 상단 오버레이 여유(headroom)를 자연 높이에 더해 카드가 그만큼 커지게 한다.
const naturalHeight =
rawSpan * pixelsPerMeter + CROSS_PAD.top + CROSS_PAD.bottom + AREA_OVERLAY_HEADROOM_PX;
// 강제 높이가 있으면 그것을, 없으면 자연 높이에 250px 바닥 적용.
const heightPx = forcedHeightPx ?? Math.max(naturalHeight, CROSS_HEIGHT);
const plotHeight = Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1e-6);
// 실제 plotHeight에 맞춰 표시 표고폭을 되계산 → ppm(1:1) 보존, 여유분은 상하 대칭 여백.
const displaySpan = plotHeight / pixelsPerMeter;
// 늘린 세로 여유의 절반을 위쪽에 몰아 데이터·계획선을 오버레이 아래로 내린다(라벨 위치 불변).
const headroomM = AREA_OVERLAY_HEADROOM_PX / pixelsPerMeter;
const displayMax = elevationMid + displaySpan / 2 + headroomM / 2;
return {
sourceSamples,
minOffset,
maxOffset,
elevationMid,
displaySpan,
displayMax,
pixelsPerMeter,
heightPx,
};
}
/** 같은 행 높이 통일을 위해 카드를 만들지 않고 자연(바닥 적용) 높이만 계산한다. */
export function crossCardNaturalHeight(
section: CrossSection,
verticalExaggeration: number,
widthPx: number,
crossHalfWidth?: number,
designElevation?: number,
): number {
return (
crossPlotMetrics(section, verticalExaggeration, widthPx, crossHalfWidth, designElevation)
?.heightPx ?? CROSS_HEIGHT
);
}
/**
* 카드 SVG에 확대·축소 + 드래그 팬 + 더블클릭 원복을 붙인다(E-5).
*
* 확대·축소 대상은 **플롯 안 도형(지반선·설계선·면적 밴드·중심 십자선)뿐**이다. 축·눈금·축
* 이름은 제자리에 남고, 데이터 레이어(`plotLayer`)의 transform만 바뀐다(2026-08-08 사용자
* 지시). 예전에는 viewBox를 조작해 축과 글자까지 같이 커졌다.
* 선 굵기는 `vector-effect: non-scaling-stroke`(CSS)라 배율과 무관하게 유지된다.
* 최대 8배까지, 축소는 원배율까지만 허용한다.
*/
/**
* 횡단 카드 줌·팬 제어. 반환한 `zoom()`을 그래프 우측 상단 버튼이 부른다.
*
* **휠은 줌이 아니다**(2026-08-02 사용자 확정). 카드가 수십 장 깔리는 화면에서 휠을 줌에
* 묶으면 목록을 훑을 수가 없다. 확대·축소는 버튼, 팬은 가운데 버튼 드래그, 원복은 더블클릭.
*/
interface ZoomPanHandle {
/** 1보다 크면 확대, 작으면 축소. 플롯 영역의 중앙을 붙잡는다. */
zoom: (factor: number) => void;
reset: () => void;
}
function attachZoomPan(
svg: SVGSVGElement,
plotLayer: SVGGElement,
widthPx: number,
heightPx: number,
): ZoomPanHandle {
// 플롯 영역(축 안쪽) — 확대·축소의 중심이자 이동 한계의 기준이다.
const plot = {
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 maxScale = 8;
let scale = 1;
let tx = 0;
let ty = 0;
const applyTransform = (): void =>
plotLayer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
// 확대한 도형이 플롯 영역을 항상 덮게 이동량을 가둔다 — 원배율에서는 이동량이 0으로 묶인다.
const clampPan = (): void => {
tx = Math.min(Math.max(tx, (plot.x + plot.width) * (1 - scale)), plot.x * (1 - scale));
ty = Math.min(Math.max(ty, (plot.y + plot.height) * (1 - scale)), plot.y * (1 - scale));
};
// 보이는 **플롯 영역의 중앙**을 붙잡고 확대·축소한다 — 버튼에는 마우스 위치가 없다.
const zoom = (factor: number): void => {
const next = Math.min(maxScale, Math.max(1, scale * factor));
const cx = plot.x + plot.width / 2;
const cy = plot.y + plot.height / 2;
tx = cx - ((cx - tx) / scale) * next;
ty = cy - ((cy - ty) / scale) * next;
scale = next;
clampPan();
applyTransform();
};
let panning = false;
let moved = false;
let lastX = 0;
let lastY = 0;
// 가운데 버튼을 누르면 브라우저가 자동 스크롤(가운데 클릭 스크롤)을 켠다 — `mousedown`
// 기본동작이라 `pointerdown`에서는 못 막는다. 여기서 막아야 팬만 남는다(2026-08-02 사용자 지시).
svg.addEventListener("mousedown", (event) => {
if (event.button === 1) event.preventDefault();
});
svg.addEventListener("auxclick", (event) => {
if (event.button === 1) event.preventDefault();
});
svg.addEventListener("pointerdown", (event) => {
// 팬은 **가운데 버튼**만. 좌클릭은 측점 선택·면적 강조 몫이다.
if (event.button !== 1) return;
event.preventDefault();
panning = true;
moved = false;
lastX = event.clientX;
lastY = event.clientY;
svg.classList.add("is-panning");
svg.setPointerCapture(event.pointerId);
});
svg.addEventListener("pointermove", (event) => {
if (!panning) return;
if (Math.abs(event.clientX - lastX) + Math.abs(event.clientY - lastY) > 2) moved = true;
// 도형을 직접 미는 방식이라 커서를 따라간다(viewBox를 밀던 때와 부호가 반대다).
const rect = svg.getBoundingClientRect();
tx += ((event.clientX - lastX) / rect.width) * widthPx;
ty += ((event.clientY - lastY) / rect.height) * heightPx;
lastX = event.clientX;
lastY = event.clientY;
clampPan();
applyTransform();
});
const endPan = (event: PointerEvent): void => {
if (!panning) return;
panning = false;
svg.classList.remove("is-panning");
try {
svg.releasePointerCapture(event.pointerId);
} catch {
/* 이미 해제됨 */
}
};
svg.addEventListener("pointerup", endPan);
svg.addEventListener("pointercancel", endPan);
// 드래그(팬)로 끝난 클릭은 카드 선택으로 전파하지 않는다.
svg.addEventListener("click", (event) => {
if (moved) event.stopPropagation();
});
const reset = (): void => {
scale = 1;
tx = 0;
ty = 0;
applyTransform();
};
// 더블클릭 원복.
svg.addEventListener("dblclick", (event) => {
event.stopPropagation();
reset();
});
return { zoom, reset };
}
/** 그래프 우측 상단 줌 버튼(확대/축소/핏). 휠을 대신하는 조작구다. */
function buildZoomControls(handle: ZoomPanHandle): HTMLElement {
const bar = document.createElement("div");
bar.className = "b06-cross-card__zoom";
const add = (label: string, title: string, action: () => void): void => {
const button = document.createElement("button");
button.type = "button";
button.className = "b06-cross-card__zoom-btn";
button.textContent = label;
button.title = title;
button.addEventListener("click", (event) => {
// 카드 선택으로 번지면 그래프가 다시 그려져 방금 맞춘 배율이 날아간다.
event.stopPropagation();
action();
});
bar.append(button);
};
add("+", L("B06_Profile_View_ZoomIn"), () => handle.zoom(1 / 0.85));
add("", L("B06_Profile_View_ZoomOut"), () => handle.zoom(0.85));
add("⤢", L("B06_Profile_View_ZoomFit"), () => handle.reset());
return bar;
}
/**
* 측점 **개별 표시 반폭** 세션 제어기(2026-08-06 사용자 지시).
* Page가 세션 보관·저장값 복원·카드 갱신·확정 저장(cross_patches)을 연결해 구현한다.
*/
export interface StationWidthControl {
/** 개별값(세션→저장값) 우선. 없으면 undefined — 카드가 전역 반폭으로 그린다. */
widthFor: (section: CrossSection) => number | undefined;
adjust: (chainageM: number, deltaM: number) => void;
reset: (chainageM: number) => void;
}
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,
): CrossCardElement {
// 이 카드의 실효 표시 반폭 — 개별값이 전역 반폭보다 우선한다(2026-08-06).
const effectiveHalfWidth = stationWidth?.widthFor(section) ?? crossHalfWidth;
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;
card.applySelection = (nextSelected, areaKey) => {
isSelected = nextSelected;
card.classList.toggle("b06-cross-card--selected", nextSelected);
activeArea = nextSelected ? (areaKey ?? null) : null;
setBandActive(activeArea);
setChipActive(activeArea);
};
const toggleArea = (key: CrossAreaKey): void => {
if (!isSelected) {
// 아직 선택되지 않은 카드 — 부모가 카드를 고르면서 이 면적까지 한 번에 켠다.
onAreaSelect?.(section.station_id, key);
return;
}
activeArea = activeArea === key ? null : key;
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",
}),
);
}
const rawDisplaySpan = displaySpan / exaggeration;
for (let index = 0; index < 5; index += 1) {
const tick = elevationMid - rawDisplaySpan / 2 + (rawDisplaySpan * index) / 4;
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(1), {
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이 겸한다
// (2026-08-06 사용자 지적 — 지면 샘플만 잘라서는 나머지가 플롯을 뚫고 나갔다).
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,
);
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
appendPavementOverlay(plotLayer, section.design, x, toDisplayY);
appendCrossDesignOverlay(plotLayer, section.design, x, toDisplayY, sourceSamples);
// 암 경계선은 지면선(지반선) 복사 + 오프셋 — 계획선 기준이 아님에 유의.
if (rockBoundary && section.design.geometry_preset === "rock") {
appendRockBoundaryOverlay(
plotLayer,
sourceSamples,
rockBoundary.offsetFor(section),
x,
toDisplayY,
);
}
}
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);
// 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 값 칸은 항상 강조 토글이다.
const readout = buildAreaReadout(section.design, toggleArea);
setChipActive = readout.setActive;
chartWrap.append(svg, readout.root, buildZoomControls(zoomPan));
// 다시 그려지기 전에 켜져 있던 강조를 되살린다(부모가 들고 있던 값).
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 사용자 지시). 그래프 안에 있으면 도면 위에 겹쳐 단면을 가렸다. 암 지반만.
// 방위각 표기는 볼 일이 없어 삭제했다(2026-08-06 사용자 지시).
if (rockBoundary && section.design?.geometry_preset === "rock") {
const rockControl = buildRockBoundaryControl(section, rockBoundary);
rockControl.classList.add("b06-cross-card__rockb");
footer.append(rockControl);
}
// 개별 표시 반폭 ◀/▶/↺ — 암 경계 그룹 우측에 우측 맞춤(2026-08-06 사용자 지시).
// 숫자 표시는 두지 않고, 초기화는 전역 반폭으로 되돌린다.
if (stationWidth) {
const widthControl = document.createElement("div");
widthControl.className = "b06-cross-card__widthctl";
const makeButton = (label: string, title: string, onClick: () => void): HTMLButtonElement => {
const button = document.createElement("button");
button.type = "button";
button.className = "b06-design__rockb-btn";
button.textContent = label;
button.title = title;
button.addEventListener("click", (event) => {
// 카드 선택 클릭으로 번지면 재렌더로 줌·팬이 초기화된다.
event.stopPropagation();
onClick();
});
return button;
};
widthControl.append(
makeButton("◀", L("B06_Cross_Width_Dec"), () => stationWidth.adjust(section.chainage_m, -1)),
makeButton("▶", L("B06_Cross_Width_Inc"), () => stationWidth.adjust(section.chainage_m, 1)),
makeButton("↺", L("B06_Cross_Width_Reset"), () => stationWidth.reset(section.chainage_m)),
);
footer.append(widthControl);
}
card.append(footer);
return card;
}