- 한몸으로 동작하는 두 페이지라 한 커밋으로 처리 (상호 참조 다수) - 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>
660 lines
27 KiB
TypeScript
660 lines
27 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, 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;
|
||
}
|