260722_0
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user