Files
Aislo/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts
T
eomsangdonandClaude Opus 5 2dc75f7449 fix(B06): 선택해도 카드를 다시 만들지 않게, RR 밴드 폴백 불일치 해결
- 선택 반영이 아직 카드 재생성이라 그 카드의 viewBox(휠 줌·팬)가 초기화됐다. 카드 요소에
  applySelection() 핸들을 달아 클래스와 강조 상태만 갈아 끼운다. 재생성을 없애기 위해 면적
  밴드는 선택 여부와 무관하게 항상 깔아 둔다(평소 투명이라 화면 변화는 없다).
- RR 절토를 눌러도 단면이 안 켜지던 원인은 구 데이터 폴백 불일치였다. 표는 분리 필드가 없으면
  절토 전량을 측점 지반유형으로 돌려 RR에 값을 넣는데, 밴드 쪽은 cut_rock_kind가 없으면 곧장
  null을 반환해 암반 밴드를 만들지 않았다. rockKindOf()로 표·밴드·강조 매핑의 폴백 규칙을
  하나로 통일한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:11:06 +09:00

547 lines
22 KiB
TypeScript

/* =============================================================================
* B06_wf3_ProfileCross_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_wf3_ProfileCross_Api_Fetch";
import {
appendCrossAreaBands,
type AreaHighlightSetter,
buildAreaReadout,
type CrossAreaKey,
} from "./B06_wf3_ProfileCross_UI_Cross_Areas";
import {
appendCrossDesignOverlay,
appendPavementOverlay,
appendRockBoundaryOverlay,
buildDesignControls,
buildRockBoundaryControl,
sectionModeLabel,
type RockBoundaryControl,
} from "./B06_wf3_ProfileCross_UI_Cross_Design";
import {
CROSS_HEIGHT,
CROSS_PAD,
CROSS_WIDTH,
type DesignChangeHandler,
emptyView,
L,
stationLabel,
svgElement,
svgText,
validElevation,
} from "./B06_wf3_ProfileCross_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).
* viewBox만 조작하고 선은 `vector-effect: non-scaling-stroke`(CSS)로 굵기를 유지해
* 확대해도 선·글자가 선명하다. 최대 8배까지, 축소는 원본까지만 허용한다.
*/
function attachZoomPan(svg: SVGSVGElement, widthPx: number, heightPx: number): void {
const base = { x: 0, y: 0, w: widthPx, h: heightPx };
const vb = { ...base };
const applyVB = (): void => svg.setAttribute("viewBox", `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
const clampPan = (): void => {
vb.x = Math.min(Math.max(vb.x, base.x), base.x + base.w - vb.w);
vb.y = Math.min(Math.max(vb.y, base.y), base.y + base.h - vb.h);
};
svg.addEventListener(
"wheel",
(event) => {
event.preventDefault();
const rect = svg.getBoundingClientRect();
const mx = vb.x + ((event.clientX - rect.left) / rect.width) * vb.w;
const my = vb.y + ((event.clientY - rect.top) / rect.height) * vb.h;
// 휠을 **당기면 확대**, 밀면 축소한다 — B04·B05 2D 지도와 같은 방향(2026-08-02 사용자 지시).
const factor = event.deltaY > 0 ? 0.85 : 1 / 0.85;
const nw = Math.min(base.w, Math.max(base.w / 8, vb.w * factor));
const nh = Math.min(base.h, Math.max(base.h / 8, vb.h * factor));
vb.x = mx - (mx - vb.x) * (nw / vb.w);
vb.y = my - (my - vb.y) * (nh / vb.h);
vb.w = nw;
vb.h = nh;
clampPan();
applyVB();
},
{ passive: false },
);
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;
const rect = svg.getBoundingClientRect();
vb.x -= ((event.clientX - lastX) / rect.width) * vb.w;
vb.y -= ((event.clientY - lastY) / rect.height) * vb.h;
lastX = event.clientX;
lastY = event.clientY;
clampPan();
applyVB();
});
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();
});
// 더블클릭 원복.
svg.addEventListener("dblclick", (event) => {
event.stopPropagation();
vb.x = base.x;
vb.y = base.y;
vb.w = base.w;
vb.h = base.h;
applyVB();
});
}
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,
): CrossCardElement {
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,
crossHalfWidth,
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",
}),
);
}
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) =>
svg.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })),
);
if (section.design) {
const toDisplayY = (elevation: number): number =>
y(elevationMid + (elevation - elevationMid) * exaggeration);
// 면적 밴드는 지면선 위·설계선 아래에 깔아 선이 밴드에 가리지 않게 한다.
// 선택 여부와 무관하게 항상 깐다 — 평소에는 투명이고, 선택이 바뀔 때 카드를 새로 만들지
// 않고 클래스만 켜면 되므로 줌·팬이 살아남는다.
setBandActive = appendCrossAreaBands(
svg,
section.design,
sourceSamples,
x,
toDisplayY,
toggleArea,
);
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
appendPavementOverlay(svg, section.design, x, toDisplayY);
appendCrossDesignOverlay(svg, section.design, x, toDisplayY, sourceSamples);
// 암 경계선은 지면선(지반선) 복사 + 오프셋 — 계획선 기준이 아님에 유의.
if (rockBoundary && section.design.geometry_preset === "rock") {
appendRockBoundaryOverlay(
svg,
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" : ""}`;
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",
}),
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,
}),
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). viewBox 조작 + non-scaling-stroke로 선명도 유지.
const chartWrap = document.createElement("div");
chartWrap.className = "b06-cross-card__chart-wrap";
attachZoomPan(svg, widthPx, heightPx);
// 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 값 칸은 항상 강조 토글이다.
const readout = buildAreaReadout(section.design, toggleArea);
setChipActive = readout.setActive;
chartWrap.append(svg, readout.root);
// 다시 그려지기 전에 켜져 있던 강조를 되살린다(부모가 들고 있던 값).
if (activeArea) {
setBandActive(activeArea);
setChipActive(activeArea);
}
// 암 경계선 제어는 그래프 X축 제목 행 우측에 배치(E-7). 암 지반에서만.
if (rockBoundary && section.design?.geometry_preset === "rock") {
const rockControl = buildRockBoundaryControl(section, rockBoundary);
rockControl.classList.add("b06-cross-card__rockb");
chartWrap.append(rockControl);
}
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`;
const azimuth = document.createElement("span");
azimuth.textContent = `${L("B06_Profile_View_Azimuth")} ${section.azimuth_deg?.toFixed(1) ?? "-"}°`;
footer.append(center, azimuth);
card.append(footer);
return card;
}