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 8cc51539..32620089 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -376,16 +376,96 @@ export function createLongitudinalProfile( 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, - yScaleOptions: YScaleOptions | undefined, onSelect: (stationId: string) => void, stationInterval: number, crossHalfWidth?: number, widthPx = CROSS_WIDTH, - heightPx = CROSS_HEIGHT, + forcedHeightPx?: number, designElevation?: number, onDesignChange?: DesignChangeHandler, ): HTMLElement { @@ -416,37 +496,32 @@ export function createCrossSectionCard( card.append(header); if (onDesignChange) card.append(buildDesignControls(section, onDesignChange)); - const sourceSamples = section.samples.filter( - (sample) => - crossHalfWidth === undefined || Math.abs(sample.offset_m ?? 0) <= crossHalfWidth + 1e-6, + const metrics = crossPlotMetrics( + section, + verticalExaggeration, + widthPx, + crossHalfWidth, + designElevation, + forcedHeightPx, ); - const valid = sourceSamples.filter(validElevation); - if (!valid.length) { + if (!metrics) { card.append(emptyView(L("B06_Profile_View_NoCross"))); } else { - const offsets = sourceSamples.map((sample) => sample.offset_m ?? 0); - const minOffset = Math.min(...offsets, -1); - const maxOffset = Math.max(...offsets, 1); + const { + sourceSamples, + minOffset, + maxOffset, + elevationMid, + displaySpan, + displayMax, + pixelsPerMeter, + heightPx, + } = metrics; 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 valid = sourceSamples.filter(validElevation); const exaggeration = Math.max(verticalExaggeration, 0.1); - const plotWidth = widthPx - CROSS_PAD.left - CROSS_PAD.right; - const plotHeight = heightPx - CROSS_PAD.top - CROSS_PAD.bottom; - const displaySpan = yScaleOptions - ? plotHeight / yScaleOptions.pixelsPerMeter - : Math.max((rawMax - rawMin + padding * 2) * exaggeration, 1); - const displayMin = elevationMid - displaySpan / 2; - const displayMax = elevationMid + displaySpan / 2; - const x = (offset: number) => - CROSS_PAD.left + ((offset - minOffset) / Math.max(maxOffset - minOffset, 1)) * plotWidth; - const y = (elevation: number) => - CROSS_PAD.top + - ((displayMax - elevation) / Math.max(displayMax - displayMin, 1)) * plotHeight; + 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, @@ -620,6 +695,8 @@ export function createSectionView(onDesignChange?: DesignChangeHandler): Section let cachedYScale: YScaleOptions | undefined; let cachedStationInterval = 1; let cachedCardWidth = CROSS_WIDTH; + // 같은 행 카드는 같은 높이가 되도록 draw에서 측점별 행 높이를 계산해 둔다(단건 갱신도 이 값 재사용). + const cachedRowHeight = new Map(); const contentWidth = (): number => { const style = getComputedStyle(root); @@ -639,17 +716,16 @@ export function createSectionView(onDesignChange?: DesignChangeHandler): Section } }; - const buildCrossCard = (section: CrossSection): HTMLElement => + const buildCrossCard = (section: CrossSection, forcedHeightPx?: number): HTMLElement => createCrossSectionCard( section, section.station_id === selectedStationId, currentExaggeration, - cachedYScale, (stationId) => selectStation(stationId, false), cachedStationInterval, currentCrossHalfWidth, cachedCardWidth, - CROSS_HEIGHT, + forcedHeightPx, currentDetail ? designElevationAt(currentDetail.longitudinal.design_profiles, section.chainage_m) : undefined, @@ -699,8 +775,31 @@ export function createSectionView(onDesignChange?: DesignChangeHandler): Section Math.floor((renderWidth + CROSS_GRID_GAP) / (CROSS_GRID_MIN_WIDTH + CROSS_GRID_GAP)), ); cachedCardWidth = (renderWidth - (columnCount - 1) * CROSS_GRID_GAP) / columnCount; + cachedRowHeight.clear(); if (detail.cross_sections.length) { - detail.cross_sections.forEach((section) => grid.append(buildCrossCard(section))); + // 1차: 각 카드의 자연 높이(250px 바닥 적용) 측정 → 같은 행(columnCount 단위) 최댓값을 행 높이로. + const sections = detail.cross_sections; + const naturalHeights = sections.map((section) => + crossCardNaturalHeight( + section, + currentExaggeration, + cachedCardWidth, + currentCrossHalfWidth, + designElevationAt(detail.longitudinal.design_profiles, section.chainage_m), + ), + ); + // 2차: 행별 최댓값으로 강제 높이를 정해 같은 행 카드를 동일 높이로 렌더한다. + for (let start = 0; start < sections.length; start += columnCount) { + const rowHeight = Math.max(...naturalHeights.slice(start, start + columnCount)); + for ( + let index = start; + index < Math.min(start + columnCount, sections.length); + index += 1 + ) { + cachedRowHeight.set(sections[index].station_id, rowHeight); + grid.append(buildCrossCard(sections[index], rowHeight)); + } + } } else { grid.append(emptyView(L("B06_Profile_View_NoCross"))); } @@ -714,7 +813,9 @@ export function createSectionView(onDesignChange?: DesignChangeHandler): Section ); if (!section) return; const existing = document.getElementById(`cross-${section.station_id}`); - if (existing) existing.replaceWith(buildCrossCard(section)); + // 단건 갱신은 draw에서 정해둔 행 높이를 재사용해 같은 행 카드와 높이를 유지한다. + if (existing) + existing.replaceWith(buildCrossCard(section, cachedRowHeight.get(section.station_id))); }; const resizeObserver = new ResizeObserver(() => { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css index f1e89370..85491a8e 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css @@ -213,7 +213,9 @@ box-shadow var(--transition-fast); } -.b06-cross-card:hover { +/* 마우스 hover 회색 강조는 선택 강조(빨강)보다 하위 — 미선택 카드에만 적용해 + :not(...):hover 특정도가 selected 규칙을 넘어 빨강 테두리를 덮지 않게 한다. */ +.b06-cross-card:not(.b06-cross-card--selected):hover { border-color: var(--color-text-muted); box-shadow: var(--shadow-sm); }