diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 4585997d..20c490cc 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -5,7 +5,7 @@ import type { import { createLongitudinalProfile, longitudinalMinimumWidth, -} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View"; +} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css"; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts new file mode 100644 index 00000000..188c58bf --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts @@ -0,0 +1,317 @@ +/* ============================================================================= + * 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"); + header.append(title, kind); + 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; +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts new file mode 100644 index 00000000..8d6bea09 --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts @@ -0,0 +1,260 @@ +/* ============================================================================= + * B06_wf3_ProfileCross_UI_Longitudinal.ts + * 종단면도(지반선·계획선·절성토 음영·측점 마커) SVG 렌더러. + * + * 700줄 제한 대응으로 기존 단일 렌더러 파일에서 종단 렌더링만 분리했다. 공통 상수·유틸은 + * `_UI_Section_Common`을 참조한다. 종단은 종/횡 공통 Y스케일(`YScaleOptions`)을 그대로 쓴다. + * ========================================================================== */ + +import type { DesignProfile, LongitudinalSection } from "./B06_wf3_ProfileCross_Api_Fetch"; +import { + emptyView, + inferStationInterval, + L, + LONG_HEIGHT, + LONG_PAD, + LONG_WIDTH, + stationLabel, + svgElement, + svgText, + validElevation, + type YScaleOptions, +} from "./B06_wf3_ProfileCross_UI_Section_Common"; + +export function longitudinalMinimumWidth( + data: LongitudinalSection, + configuredStationInterval?: number, +): number { + const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations); + const longestLabelLength = Math.max( + 1, + ...data.stations.map((station) => stationLabel(station.chainage_m, stationInterval).length), + ); + const labelWidth = Math.max(48, longestLabelLength * 6 + 16); + return LONG_PAD.left + LONG_PAD.right + Math.max(1, data.stations.length) * labelWidth; +} + +/** + * 계획선과 지반선 사이를 절토(계획고 < 지반고)·성토 구간으로 나눠 음영을 그린다. + * 부호가 바뀌는 지점에서 끊어 절토와 성토가 섞이지 않게 한다. + */ +function appendCutFillBands( + svg: SVGSVGElement, + profile: DesignProfile, + x: (chainage: number) => number, + planY: (index: number) => number, + groundY: (index: number) => number, +): void { + const samples = profile.samples; + let start = 0; + const flush = (end: number): void => { + if (end - start < 1) return; + const sign = samples[start].difference_m; + if (Math.abs(sign) < 1e-9) return; + const top: string[] = []; + const bottom: string[] = []; + for (let index = start; index <= end; index += 1) { + top.push(`${x(samples[index].chainage_m)},${planY(index)}`); + bottom.unshift(`${x(samples[index].chainage_m)},${groundY(index)}`); + } + svg.append( + svgElement("polygon", { + points: [...top, ...bottom].join(" "), + class: `b06-chart__band b06-chart__band--${sign < 0 ? "cut" : "fill"}`, + }), + ); + }; + for (let index = 1; index < samples.length; index += 1) { + const previous = samples[index - 1].difference_m; + const current = samples[index].difference_m; + if (previous === 0 || current === 0 || Math.sign(previous) !== Math.sign(current)) { + flush(index); + start = index; + } + } + flush(samples.length - 1); +} + +export function createLongitudinalProfile( + data: LongitudinalSection, + selectedStationId: string | null, + verticalExaggeration: number, + yScaleOptions: YScaleOptions | undefined, + onSelectStation: (stationId: string) => void, + configuredStationInterval?: number, + widthPx = LONG_WIDTH, + heightPx = LONG_HEIGHT, + minimumWidthPx = widthPx, + designProfiles: DesignProfile[] = [], +): HTMLElement { + const samples = data.samples.filter(validElevation); + if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal")); + + const wrapper = document.createElement("div"); + wrapper.className = "b06-section__chart-wrap"; + const svg = svgElement("svg", { + class: "b06-section__chart", + width: widthPx, + height: heightPx, + viewBox: `0 0 ${widthPx} ${heightPx}`, + role: "img", + "aria-label": L("B06_Profile_View_Longitudinal"), + }); + svg.style.width = "100%"; + svg.style.minWidth = `${minimumWidthPx}px`; + svg.append(svgElement("rect", { width: widthPx, height: heightPx, class: "b06-chart__bg" })); + + const maxChainage = Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1); + // 계획선이 지반선 밖으로 나가도 잘리지 않도록 세로 범위에 함께 반영한다. + const elevations = samples + .map((sample) => sample.elevation_m) + .concat(designProfiles.flatMap((profile) => profile.samples.map((s) => s.elevation_m))); + const rawMin = yScaleOptions?.globalMinElevation ?? Math.min(...elevations); + const rawMax = yScaleOptions?.globalMaxElevation ?? Math.max(...elevations); + const elevationMid = (rawMin + rawMax) / 2; + const exaggeration = Math.max(verticalExaggeration, 0.1); + const plotWidth = widthPx - LONG_PAD.left - LONG_PAD.right; + const plotHeight = heightPx - LONG_PAD.top - LONG_PAD.bottom; + const elevationSpan = yScaleOptions + ? plotHeight / yScaleOptions.pixelsPerMeter + : Math.max(rawMax - rawMin, 1); + const x = (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth; + const y = (elevation: number) => + LONG_PAD.top + ((elevationMid + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight; + const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations); + + for (const ratio of [0, 0.25, 0.5, 0.75, 1]) { + const gridY = LONG_PAD.top + ratio * plotHeight; + const displayed = elevationMid + elevationSpan / 2 - ratio * elevationSpan; + const rawValue = elevationMid + (displayed - elevationMid) / exaggeration; + svg.append( + svgElement("line", { + x1: LONG_PAD.left, + y1: gridY, + x2: widthPx - LONG_PAD.right, + y2: gridY, + class: "b06-chart__grid", + }), + svgText(`${rawValue.toFixed(1)}m`, { + x: LONG_PAD.left - 9, + y: gridY + 4, + "text-anchor": "end", + class: "b06-chart__tick", + }), + ); + } + + // 절·성토 음영과 균형 구역 경계는 측점선·프로파일선보다 아래에 깔린다. + const toY = (elevation: number) => y(elevationMid + (elevation - elevationMid) * exaggeration); + for (const profile of designProfiles) { + if (profile.samples.length < 2) continue; + appendCutFillBands( + svg, + profile, + x, + (index) => toY(profile.samples[index].elevation_m), + (index) => toY(profile.samples[index].ground_elevation_m), + ); + if (profile.balance_segments.length > 1) { + for (const segment of profile.balance_segments.slice(1)) { + svg.append( + svgElement("line", { + x1: x(segment.start_chainage_m), + y1: LONG_PAD.top, + x2: x(segment.start_chainage_m), + y2: heightPx - LONG_PAD.bottom, + class: "b06-chart__balance-boundary", + }), + ); + } + } + } + + for (const station of data.stations) { + const stationX = x(station.chainage_m); + const selected = station.station_id === selectedStationId; + const marker = svgElement("g", { + class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`, + tabindex: "0", + role: "button", + "aria-label": `${stationLabel(station.chainage_m, stationInterval)} ${station.chainage_m.toFixed(1)}m`, + }); + marker.addEventListener("click", () => onSelectStation(station.station_id)); + marker.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") onSelectStation(station.station_id); + }); + marker.append( + svgElement("line", { + x1: stationX, + y1: LONG_PAD.top, + x2: stationX, + y2: heightPx - LONG_PAD.bottom + 8, + class: "b06-chart__station-hit", + }), + svgElement("line", { + x1: stationX, + y1: LONG_PAD.top, + x2: stationX, + y2: heightPx - LONG_PAD.bottom + 8, + class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`, + }), + svgText(stationLabel(station.chainage_m, stationInterval), { + x: stationX, + y: heightPx - 23, + "text-anchor": "middle", + class: "b06-chart__station-label", + }), + ); + svg.append(marker); + } + + const points = samples + .map((sample) => { + const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration; + return `${x(sample.chainage_m ?? 0)},${y(elevated)}`; + }) + .join(" "); + for (const profile of designProfiles) { + if (profile.samples.length < 2) continue; + svg.append( + svgElement("polyline", { + points: profile.samples + .map((sample) => `${x(sample.chainage_m)},${toY(sample.elevation_m)}`) + .join(" "), + class: "b06-chart__design-profile", + }), + ); + } + svg.append( + svgElement("polyline", { points, class: "b06-chart__profile" }), + svgElement("line", { + x1: LONG_PAD.left, + y1: heightPx - LONG_PAD.bottom, + x2: widthPx - LONG_PAD.right, + y2: heightPx - LONG_PAD.bottom, + class: "b06-chart__axis", + }), + svgElement("line", { + x1: LONG_PAD.left, + y1: LONG_PAD.top, + x2: LONG_PAD.left, + y2: heightPx - LONG_PAD.bottom, + class: "b06-chart__axis", + }), + svgText(L("B06_Profile_View_LongitudinalXAxis"), { + x: widthPx / 2, + y: heightPx - 4, + "text-anchor": "middle", + class: "b06-chart__axis-label", + }), + svgText(L("B06_Profile_View_ElevationAxis"), { + x: 15, + y: heightPx / 2, + "text-anchor": "middle", + transform: `rotate(-90 15 ${heightPx / 2})`, + class: "b06-chart__axis-label", + }), + ); + wrapper.append(svg); + return wrapper; +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common.ts new file mode 100644 index 00000000..98379e74 --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common.ts @@ -0,0 +1,147 @@ +/* ============================================================================= + * B06_wf3_ProfileCross_UI_Section_Common.ts + * 종·횡단 SVG 렌더러가 공유하는 상수·타입·유틸리티. + * + * 700줄 제한 대응으로 기존 단일 렌더러 파일에서 공통 부분만 분리했다. 종단 렌더러 + * (`_UI_Longitudinal`), 횡단 카드 렌더러(`_UI_Cross_View`), 뷰 컨트롤러(`_UI_Section_View`)가 + * 이 모듈을 참조한다. 색상 하드코딩 금지 원칙은 그대로이며 여기서는 지오메트리 계산만 다룬다. + * ========================================================================== */ + +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import type { CrossDesignChange } from "./B06_wf3_ProfileCross_UI_Cross_Design"; +import type { + DesignProfile, + SectionDetailResponse, + SectionSample, +} from "./B06_wf3_ProfileCross_Api_Fetch"; + +export type { CrossDesignChange }; +export type DesignChangeHandler = (chainageM: number, change: CrossDesignChange) => void; + +export const SVG_NS = "http://www.w3.org/2000/svg"; +export const LONG_WIDTH = 1200; +export const LONG_HEIGHT = 220; +export const CROSS_WIDTH = 560; +export const CROSS_HEIGHT = 250; +export const CROSS_GRID_MIN_WIDTH = 480; +export const CROSS_GRID_GAP = 16; +export const LONG_PAD = { left: 62, right: 24, top: 30, bottom: 52 }; +export const CROSS_PAD = { left: 58, right: 20, top: 20, bottom: 52 }; + +export interface YScaleOptions { + pixelsPerMeter: number; + globalMinElevation: number; + globalMaxElevation: number; +} + +export function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +export function svgElement( + tag: K, + attributes: Record = {}, +): SVGElementTagNameMap[K] { + const element = document.createElementNS(SVG_NS, tag); + Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value))); + return element; +} + +export function svgText( + value: string, + attributes: Record, +): SVGTextElement { + const text = svgElement("text", attributes); + text.textContent = value; + return text; +} + +export function validElevation( + sample: SectionSample, +): sample is SectionSample & { elevation_m: number } { + return ( + sample.valid !== false && sample.elevation_m !== null && Number.isFinite(sample.elevation_m) + ); +} + +/** + * 측점 chainage 위치의 계획고를 계획선 샘플에서 선형보간한다. + * 범위를 벗어나면 양 끝값으로 클램프하며, 계획선이 없으면 undefined를 반환해 지반고 폴백을 유도한다. + */ +export function designElevationAt( + designProfiles: DesignProfile[] | undefined, + chainageM: number, +): number | undefined { + const samples = designProfiles?.[0]?.samples?.filter((sample) => + Number.isFinite(sample.elevation_m), + ); + if (!samples?.length) return undefined; + if (chainageM <= samples[0].chainage_m) return samples[0].elevation_m; + const last = samples[samples.length - 1]; + if (chainageM >= last.chainage_m) return last.elevation_m; + for (let index = 1; index < samples.length; index += 1) { + const previous = samples[index - 1]; + const current = samples[index]; + if (chainageM > current.chainage_m) continue; + const span = current.chainage_m - previous.chainage_m; + if (span <= 0) return current.elevation_m; + const ratio = (chainageM - previous.chainage_m) / span; + return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio; + } + return last.elevation_m; +} + +export function calculateYScale(detail: SectionDetailResponse): YScaleOptions | undefined { + const elevations = [ + ...detail.longitudinal.samples.map((sample) => sample.elevation_m), + ...detail.cross_sections.flatMap((section) => + section.samples.map((sample) => sample.elevation_m), + ), + ...(detail.longitudinal.design_profiles ?? []).flatMap((profile) => + profile.samples.map((sample) => sample.elevation_m), + ), + ].filter((value): value is number => typeof value === "number" && Number.isFinite(value)); + if (!elevations.length) return undefined; + const globalMinElevation = Math.min(...elevations); + const globalMaxElevation = Math.max(...elevations); + const plotHeight = LONG_HEIGHT - LONG_PAD.top - LONG_PAD.bottom; + return { + pixelsPerMeter: plotHeight / Math.max(globalMaxElevation - globalMinElevation, 1), + globalMinElevation, + globalMaxElevation, + }; +} + +export function emptyView(message: string): HTMLElement { + const empty = document.createElement("div"); + empty.className = "b06-section__empty"; + empty.textContent = message; + return empty; +} + +export function inferStationInterval(stations: Array<{ chainage_m: number }>): number { + const counts = new Map(); + for (let index = 1; index < stations.length; index += 1) { + const difference = stations[index].chainage_m - stations[index - 1].chainage_m; + if (difference <= 0) continue; + const rounded = Math.round(difference * 10) / 10; + counts.set(rounded, (counts.get(rounded) ?? 0) + 1); + } + return ( + [...counts.entries()].sort( + ([intervalA, countA], [intervalB, countB]) => countB - countA || intervalB - intervalA, + )[0]?.[0] ?? 1 + ); +} + +export function stationLabel(chainage: number, interval: number): string { + const safeInterval = interval > 0 ? interval : 1; + let stationNumber = Math.floor((chainage + 1e-6) / safeInterval); + let remainder = chainage - stationNumber * safeInterval; + if (Math.abs(remainder) < 0.05) remainder = 0; + if (remainder >= safeInterval - 0.05) { + stationNumber += 1; + remainder = 0; + } + return `${stationNumber}+${remainder.toFixed(1)}`; +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts index 32620089..bad97b00 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -1,671 +1,40 @@ -import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import type { - CrossSection, - DesignProfile, - LongitudinalSection, - SectionDetailResponse, - SectionSample, -} from "./B06_wf3_ProfileCross_Api_Fetch"; +/* ============================================================================= + * B06_wf3_ProfileCross_UI_Section_View.ts + * 종·횡단 도면 뷰 컨트롤러: 종단 렌더러·횡단 카드 렌더러를 조립하고, 측점 선택/스크롤, + * 반응형 폭 측정(ResizeObserver), 같은 행 높이 통일, 단건 카드 갱신을 관장한다. + * + * 700줄 제한 대응으로 렌더러 본체는 분리했다: + * - 공통 상수·유틸: `_UI_Section_Common` + * - 종단 렌더러: `_UI_Longitudinal` + * - 횡단 카드 렌더러: `_UI_Cross_View` + * 외부(Page)에는 `createSectionView`와 `CrossDesignChange` 타입만 노출한다. + * ========================================================================== */ + +import type { CrossSection, SectionDetailResponse } from "./B06_wf3_ProfileCross_Api_Fetch"; import { - appendCrossDesignOverlay, - buildDesignControls, + createCrossSectionCard, + crossCardNaturalHeight, +} from "./B06_wf3_ProfileCross_UI_Cross_View"; +import { + createLongitudinalProfile, + longitudinalMinimumWidth, +} from "./B06_wf3_ProfileCross_UI_Longitudinal"; +import { + calculateYScale, + CROSS_GRID_GAP, + CROSS_GRID_MIN_WIDTH, + CROSS_WIDTH, type CrossDesignChange, -} from "./B06_wf3_ProfileCross_UI_Cross_Design"; + type DesignChangeHandler, + designElevationAt, + emptyView, + inferStationInterval, + L, + LONG_HEIGHT, + type YScaleOptions, +} from "./B06_wf3_ProfileCross_UI_Section_Common"; -export type { CrossDesignChange }; -export type DesignChangeHandler = (chainageM: number, change: CrossDesignChange) => void; - -const SVG_NS = "http://www.w3.org/2000/svg"; -const LONG_WIDTH = 1200; -const LONG_HEIGHT = 220; -const CROSS_WIDTH = 560; -const CROSS_HEIGHT = 250; -const CROSS_GRID_MIN_WIDTH = 480; -const CROSS_GRID_GAP = 16; -const LONG_PAD = { left: 62, right: 24, top: 30, bottom: 52 }; -const CROSS_PAD = { left: 58, right: 20, top: 20, bottom: 52 }; - -interface YScaleOptions { - pixelsPerMeter: number; - globalMinElevation: number; - globalMaxElevation: number; -} - -function L(key: keyof typeof ui_locales): string { - return ui_locales[key][currentLanguageIndex]; -} - -function svgElement( - tag: K, - attributes: Record = {}, -): SVGElementTagNameMap[K] { - const element = document.createElementNS(SVG_NS, tag); - Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value))); - return element; -} - -function svgText(value: string, attributes: Record): SVGTextElement { - const text = svgElement("text", attributes); - text.textContent = value; - return text; -} - -function validElevation(sample: SectionSample): sample is SectionSample & { elevation_m: number } { - return ( - sample.valid !== false && sample.elevation_m !== null && Number.isFinite(sample.elevation_m) - ); -} - -/** - * 측점 chainage 위치의 계획고를 계획선 샘플에서 선형보간한다. - * 범위를 벗어나면 양 끝값으로 클램프하며, 계획선이 없으면 undefined를 반환해 지반고 폴백을 유도한다. - */ -function designElevationAt( - designProfiles: DesignProfile[] | undefined, - chainageM: number, -): number | undefined { - const samples = designProfiles?.[0]?.samples?.filter((sample) => - Number.isFinite(sample.elevation_m), - ); - if (!samples?.length) return undefined; - if (chainageM <= samples[0].chainage_m) return samples[0].elevation_m; - const last = samples[samples.length - 1]; - if (chainageM >= last.chainage_m) return last.elevation_m; - for (let index = 1; index < samples.length; index += 1) { - const previous = samples[index - 1]; - const current = samples[index]; - if (chainageM > current.chainage_m) continue; - const span = current.chainage_m - previous.chainage_m; - if (span <= 0) return current.elevation_m; - const ratio = (chainageM - previous.chainage_m) / span; - return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio; - } - return last.elevation_m; -} - -function calculateYScale(detail: SectionDetailResponse): YScaleOptions | undefined { - const elevations = [ - ...detail.longitudinal.samples.map((sample) => sample.elevation_m), - ...detail.cross_sections.flatMap((section) => - section.samples.map((sample) => sample.elevation_m), - ), - ...(detail.longitudinal.design_profiles ?? []).flatMap((profile) => - profile.samples.map((sample) => sample.elevation_m), - ), - ].filter((value): value is number => typeof value === "number" && Number.isFinite(value)); - if (!elevations.length) return undefined; - const globalMinElevation = Math.min(...elevations); - const globalMaxElevation = Math.max(...elevations); - const plotHeight = LONG_HEIGHT - LONG_PAD.top - LONG_PAD.bottom; - return { - pixelsPerMeter: plotHeight / Math.max(globalMaxElevation - globalMinElevation, 1), - globalMinElevation, - globalMaxElevation, - }; -} - -function emptyView(message: string): HTMLElement { - const empty = document.createElement("div"); - empty.className = "b06-section__empty"; - empty.textContent = message; - return empty; -} - -function inferStationInterval(stations: Array<{ chainage_m: number }>): number { - const counts = new Map(); - for (let index = 1; index < stations.length; index += 1) { - const difference = stations[index].chainage_m - stations[index - 1].chainage_m; - if (difference <= 0) continue; - const rounded = Math.round(difference * 10) / 10; - counts.set(rounded, (counts.get(rounded) ?? 0) + 1); - } - return ( - [...counts.entries()].sort( - ([intervalA, countA], [intervalB, countB]) => countB - countA || intervalB - intervalA, - )[0]?.[0] ?? 1 - ); -} - -function stationLabel(chainage: number, interval: number): string { - const safeInterval = interval > 0 ? interval : 1; - let stationNumber = Math.floor((chainage + 1e-6) / safeInterval); - let remainder = chainage - stationNumber * safeInterval; - if (Math.abs(remainder) < 0.05) remainder = 0; - if (remainder >= safeInterval - 0.05) { - stationNumber += 1; - remainder = 0; - } - return `${stationNumber}+${remainder.toFixed(1)}`; -} - -export function longitudinalMinimumWidth( - data: LongitudinalSection, - configuredStationInterval?: number, -): number { - const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations); - const longestLabelLength = Math.max( - 1, - ...data.stations.map((station) => stationLabel(station.chainage_m, stationInterval).length), - ); - const labelWidth = Math.max(48, longestLabelLength * 6 + 16); - return LONG_PAD.left + LONG_PAD.right + Math.max(1, data.stations.length) * labelWidth; -} - -/** - * 계획선과 지반선 사이를 절토(계획고 < 지반고)·성토 구간으로 나눠 음영을 그린다. - * 부호가 바뀌는 지점에서 끊어 절토와 성토가 섞이지 않게 한다. - */ -function appendCutFillBands( - svg: SVGSVGElement, - profile: DesignProfile, - x: (chainage: number) => number, - planY: (index: number) => number, - groundY: (index: number) => number, -): void { - const samples = profile.samples; - let start = 0; - const flush = (end: number): void => { - if (end - start < 1) return; - const sign = samples[start].difference_m; - if (Math.abs(sign) < 1e-9) return; - const top: string[] = []; - const bottom: string[] = []; - for (let index = start; index <= end; index += 1) { - top.push(`${x(samples[index].chainage_m)},${planY(index)}`); - bottom.unshift(`${x(samples[index].chainage_m)},${groundY(index)}`); - } - svg.append( - svgElement("polygon", { - points: [...top, ...bottom].join(" "), - class: `b06-chart__band b06-chart__band--${sign < 0 ? "cut" : "fill"}`, - }), - ); - }; - for (let index = 1; index < samples.length; index += 1) { - const previous = samples[index - 1].difference_m; - const current = samples[index].difference_m; - if (previous === 0 || current === 0 || Math.sign(previous) !== Math.sign(current)) { - flush(index); - start = index; - } - } - flush(samples.length - 1); -} - -export function createLongitudinalProfile( - data: LongitudinalSection, - selectedStationId: string | null, - verticalExaggeration: number, - yScaleOptions: YScaleOptions | undefined, - onSelectStation: (stationId: string) => void, - configuredStationInterval?: number, - widthPx = LONG_WIDTH, - heightPx = LONG_HEIGHT, - minimumWidthPx = widthPx, - designProfiles: DesignProfile[] = [], -): HTMLElement { - const samples = data.samples.filter(validElevation); - if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal")); - - const wrapper = document.createElement("div"); - wrapper.className = "b06-section__chart-wrap"; - const svg = svgElement("svg", { - class: "b06-section__chart", - width: widthPx, - height: heightPx, - viewBox: `0 0 ${widthPx} ${heightPx}`, - role: "img", - "aria-label": L("B06_Profile_View_Longitudinal"), - }); - svg.style.width = "100%"; - svg.style.minWidth = `${minimumWidthPx}px`; - svg.append(svgElement("rect", { width: widthPx, height: heightPx, class: "b06-chart__bg" })); - - const maxChainage = Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1); - // 계획선이 지반선 밖으로 나가도 잘리지 않도록 세로 범위에 함께 반영한다. - const elevations = samples - .map((sample) => sample.elevation_m) - .concat(designProfiles.flatMap((profile) => profile.samples.map((s) => s.elevation_m))); - const rawMin = yScaleOptions?.globalMinElevation ?? Math.min(...elevations); - const rawMax = yScaleOptions?.globalMaxElevation ?? Math.max(...elevations); - const elevationMid = (rawMin + rawMax) / 2; - const exaggeration = Math.max(verticalExaggeration, 0.1); - const plotWidth = widthPx - LONG_PAD.left - LONG_PAD.right; - const plotHeight = heightPx - LONG_PAD.top - LONG_PAD.bottom; - const elevationSpan = yScaleOptions - ? plotHeight / yScaleOptions.pixelsPerMeter - : Math.max(rawMax - rawMin, 1); - const x = (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth; - const y = (elevation: number) => - LONG_PAD.top + ((elevationMid + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight; - const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations); - - for (const ratio of [0, 0.25, 0.5, 0.75, 1]) { - const gridY = LONG_PAD.top + ratio * plotHeight; - const displayed = elevationMid + elevationSpan / 2 - ratio * elevationSpan; - const rawValue = elevationMid + (displayed - elevationMid) / exaggeration; - svg.append( - svgElement("line", { - x1: LONG_PAD.left, - y1: gridY, - x2: widthPx - LONG_PAD.right, - y2: gridY, - class: "b06-chart__grid", - }), - svgText(`${rawValue.toFixed(1)}m`, { - x: LONG_PAD.left - 9, - y: gridY + 4, - "text-anchor": "end", - class: "b06-chart__tick", - }), - ); - } - - // 절·성토 음영과 균형 구역 경계는 측점선·프로파일선보다 아래에 깔린다. - const toY = (elevation: number) => y(elevationMid + (elevation - elevationMid) * exaggeration); - for (const profile of designProfiles) { - if (profile.samples.length < 2) continue; - appendCutFillBands( - svg, - profile, - x, - (index) => toY(profile.samples[index].elevation_m), - (index) => toY(profile.samples[index].ground_elevation_m), - ); - if (profile.balance_segments.length > 1) { - for (const segment of profile.balance_segments.slice(1)) { - svg.append( - svgElement("line", { - x1: x(segment.start_chainage_m), - y1: LONG_PAD.top, - x2: x(segment.start_chainage_m), - y2: heightPx - LONG_PAD.bottom, - class: "b06-chart__balance-boundary", - }), - ); - } - } - } - - for (const station of data.stations) { - const stationX = x(station.chainage_m); - const selected = station.station_id === selectedStationId; - const marker = svgElement("g", { - class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`, - tabindex: "0", - role: "button", - "aria-label": `${stationLabel(station.chainage_m, stationInterval)} ${station.chainage_m.toFixed(1)}m`, - }); - marker.addEventListener("click", () => onSelectStation(station.station_id)); - marker.addEventListener("keydown", (event) => { - if (event.key === "Enter" || event.key === " ") onSelectStation(station.station_id); - }); - marker.append( - svgElement("line", { - x1: stationX, - y1: LONG_PAD.top, - x2: stationX, - y2: heightPx - LONG_PAD.bottom + 8, - class: "b06-chart__station-hit", - }), - svgElement("line", { - x1: stationX, - y1: LONG_PAD.top, - x2: stationX, - y2: heightPx - LONG_PAD.bottom + 8, - class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`, - }), - svgText(stationLabel(station.chainage_m, stationInterval), { - x: stationX, - y: heightPx - 23, - "text-anchor": "middle", - class: "b06-chart__station-label", - }), - ); - svg.append(marker); - } - - const points = samples - .map((sample) => { - const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration; - return `${x(sample.chainage_m ?? 0)},${y(elevated)}`; - }) - .join(" "); - for (const profile of designProfiles) { - if (profile.samples.length < 2) continue; - svg.append( - svgElement("polyline", { - points: profile.samples - .map((sample) => `${x(sample.chainage_m)},${toY(sample.elevation_m)}`) - .join(" "), - class: "b06-chart__design-profile", - }), - ); - } - svg.append( - svgElement("polyline", { points, class: "b06-chart__profile" }), - svgElement("line", { - x1: LONG_PAD.left, - y1: heightPx - LONG_PAD.bottom, - x2: widthPx - LONG_PAD.right, - y2: heightPx - LONG_PAD.bottom, - class: "b06-chart__axis", - }), - svgElement("line", { - x1: LONG_PAD.left, - y1: LONG_PAD.top, - x2: LONG_PAD.left, - y2: heightPx - LONG_PAD.bottom, - class: "b06-chart__axis", - }), - svgText(L("B06_Profile_View_LongitudinalXAxis"), { - x: widthPx / 2, - y: heightPx - 4, - "text-anchor": "middle", - class: "b06-chart__axis-label", - }), - svgText(L("B06_Profile_View_ElevationAxis"), { - x: 15, - y: heightPx / 2, - "text-anchor": "middle", - transform: `rotate(-90 15 ${heightPx / 2})`, - class: "b06-chart__axis-label", - }), - ); - wrapper.append(svg); - return wrapper; -} - -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"); - header.append(title, kind); - 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; -} +export type { CrossDesignChange, DesignChangeHandler }; export interface SectionViewController { root: HTMLElement;