/* ============================================================================= * 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 { appendCrossDesignOverlay, buildDesignControls, } 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"; 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); const naturalHeight = rawSpan * pixelsPerMeter + CROSS_PAD.top + CROSS_PAD.bottom; // 강제 높이가 있으면 그것을, 없으면 자연 높이에 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 displayMax = elevationMid + displaySpan / 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 ); } 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, ): HTMLElement { const card = 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); }); const header = document.createElement("header"); const title = document.createElement("div"); 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); const kind = document.createElement("span"); kind.textContent = section.kind === "ep" ? L("B06_Profile_View_Kind_EP") : section.kind === "bp" ? L("B06_Profile_View_Kind_BP") : L("B06_Profile_View_Kind_Station"); // kind 라벨(일반측점/BP/EP) 좌측에 구조물 정보를 표기(비정규 측점이 확정 시 실어온 값). 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); } meta.append(kind); header.append(title, meta); card.append(header); if (onDesignChange) card.append(buildDesignControls(section, onDesignChange)); 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); const x = (offset: number) => CROSS_PAD.left + (offset - minOffset) * 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); appendCrossDesignOverlay(svg, section.design, 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", }), ); card.append(svg); } 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; }