Files
Aislo/B06_Section/B06_Section_UI_Cross_View_Metrics.ts
eomsangdonandClaude Opus 5 4df5d0a7ea perf(B06): 카드 기하를 두 번 계산하던 것을 한 번으로
행 높이를 재는 1차와 실제 그리는 2차가 같은 측점의 crossPlotMetrics 를 각각 계산했음.
측점 67곳 기준 3.2ms/장이라 진입에서 draw 자기 시간 213ms 의 대부분이었음
(보조 창 CPU 프로파일).

- crossPlotMetrics 를 crossPlotBase(강제 높이 전) + crossPlotFromBase(높이만 얹기)로
  가름. 기존 시그니처는 둘을 잇는 감싸개로 유지.
- Section_View 1차에서 base 를 만들어 2차 카드에 그대로 넘김.
- 1차 반폭도 카드가 실제로 쓰는 값(effectiveCardHalfWidth, 자동 줌아웃 포함)으로 맞춤 —
  전에는 1차가 원래 반폭으로 재 카드·행 높이 기준이 어긋났음.
- 쓰이지 않게 된 crossCardNaturalHeight 와 그 재수출 삭제.

자체검증(공용 브라우저) — 카드 67장·SVG 67개 그대로, 같은 행 카드 높이 통일 유지
(rowsUniform true), 높이 범위 292~1,119px. 시험 400 통과·17 건너뜀, typecheck 통과.
효과 수치는 보조 창의 격리된 프로젝트에서 3회 중앙값으로 재기로 함(내 창은 편차가 큼).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 22:54:12 +09:00

129 lines
4.7 KiB
TypeScript

import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch";
import { CROSS_HEIGHT, CROSS_PAD, validElevation } from "./B06_Section_UI_Section_Common";
import { culvertRequiredHalfWidth } from "./B06_Section_UI_Cross_Culvert_Wire";
import { toeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
const AREA_OVERLAY_HEADROOM_PX = 58;
export interface CrossPlotMetrics {
sourceSamples: SectionSample[];
minOffset: number;
maxOffset: number;
elevationMid: number;
displaySpan: number;
displayMax: number;
pixelsPerMeter: number;
heightPx: number;
}
/**
* 강제 높이를 얹기 **전**까지의 값 — 카드 두 벌(행 높이 재기 / 실제 그리기)이 나눠 쓴다.
* 예전에는 같은 측점의 기하를 두 번 계산했고, 그 값이 B06 진입에서 `draw` 자기 시간
* 213ms 의 대부분이었다(2026-09-06 CPU 프로파일, 측점 67곳 기준 3.2ms/장).
*/
export interface CrossPlotBase {
sourceSamples: SectionSample[];
minOffset: number;
maxOffset: number;
elevationMid: number;
pixelsPerMeter: number;
/** 강제 높이가 없을 때 쓰는 높이(바닥 `CROSS_HEIGHT` 적용 전). */
naturalHeight: number;
}
export function crossPlotBase(
section: CrossSection,
verticalExaggeration: number,
widthPx: number,
crossHalfWidth?: number,
designElevation?: number,
): CrossPlotBase | 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 elevations = valid.map((sample) => sample.elevation_m);
if (designElevation !== undefined && Number.isFinite(designElevation))
elevations.push(designElevation);
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 pixelsPerMeter =
(widthPx - CROSS_PAD.left - CROSS_PAD.right) / Math.max(maxOffset - minOffset, 1e-6);
const rawSpan = Math.max(
(rawMax - rawMin + padding * 2) * Math.max(verticalExaggeration, 0.1),
1e-6,
);
const naturalHeight =
rawSpan * pixelsPerMeter + CROSS_PAD.top + CROSS_PAD.bottom + AREA_OVERLAY_HEADROOM_PX;
return { sourceSamples, minOffset, maxOffset, elevationMid, pixelsPerMeter, naturalHeight };
}
/** 재 둔 기하에 행 높이만 얹는다 — 다시 계산하지 않는다. */
export function crossPlotFromBase(base: CrossPlotBase, forcedHeightPx?: number): CrossPlotMetrics {
const heightPx = forcedHeightPx ?? Math.max(base.naturalHeight, CROSS_HEIGHT);
const displaySpan =
Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1e-6) / base.pixelsPerMeter;
const displayMax =
base.elevationMid + displaySpan / 2 + AREA_OVERLAY_HEADROOM_PX / base.pixelsPerMeter / 2;
return {
sourceSamples: base.sourceSamples,
minOffset: base.minOffset,
maxOffset: base.maxOffset,
elevationMid: base.elevationMid,
displaySpan,
displayMax,
pixelsPerMeter: base.pixelsPerMeter,
heightPx,
};
}
export function crossPlotMetrics(
section: CrossSection,
verticalExaggeration: number,
widthPx: number,
crossHalfWidth?: number,
designElevation?: number,
forcedHeightPx?: number,
): CrossPlotMetrics | null {
const base = crossPlotBase(
section,
verticalExaggeration,
widthPx,
crossHalfWidth,
designElevation,
);
return base ? crossPlotFromBase(base, forcedHeightPx) : null;
}
/**
* 카드가 실제로 그릴 표시 반폭 — 개별값 > 전역값(2026-08-06).
*
* 절·성토선이 원지반과 만나는 지점(교차점)이 반폭 밖이면 **이 카드만** 자동 줌아웃한다
* (2026-08-22, 판정 기준 개편 2026-08-23: 배수관용 5m 사면 규칙 대신 실제 교차점).
* 보유 샘플 밖까지는 넓히지 않는다 — 지반이 없어 빈 화면이 될 뿐이고, 그 경우
* [보기 반폭 적용]이 필요한 폭으로 백엔드 재생성을 건다.
*/
export function effectiveCardHalfWidth(
section: CrossSection,
baseHalfWidth: number | undefined,
): number | undefined {
const sampledExtent = Math.max(
0,
...section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)),
);
const requiredHalfWidth = Math.min(
Math.max(culvertRequiredHalfWidth(section) ?? 0, toeFitHalfWidth(section) ?? 0),
sampledExtent,
);
return baseHalfWidth !== undefined && requiredHalfWidth > 0
? Math.max(baseHalfWidth, requiredHalfWidth)
: baseHalfWidth;
}