This commit is contained in:
2026-07-22 20:09:57 +09:00
parent 32dcc00e87
commit d73c47ff1e
2 changed files with 137 additions and 34 deletions
@@ -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<string, number>();
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(() => {
@@ -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);
}