Files
Aislo/B06_Section/B06_Section_UI_Cross_View_Metrics.ts
T
eomsangdonandClaude Opus 5.5 410c4042b6 feat(b06): 면적표 열 연동 · [상세] 그림 테마 색 · 칸 가득(173-3 · 173-4)
- 면적표 글자 = 그림 칸 폭 비례(clamp · cqw) · 표 높이 변수 같은 식 · 4열부터 「계」만(그리드 data-columns)
- [상세] 그림 = 테마 토큰(어두운 고정색 지움) · 카드 그리기 함수로 모달 칸 크기에 맞춰 새로 그림(fitPlotBase · 빈 띠 없음)
- 칸 크기 바뀌면 다시 그림(ResizeObserver) · clip id 카드와 가름 · 그리는 동안 refresh 되먹임 막음
- 시험 test_173_areas_detail 새로 · test_164 · test_150 단정 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-10 08:42:45 +09:00

147 lines
5.4 KiB
TypeScript

import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch";
import { CROSS_HEIGHT, CROSS_PAD, validElevation } from "./B06_Section_UI_Section_Common";
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 };
}
/**
* 같은 기하를 다른 카드 폭으로 그렸을 때 자연 높이(바닥 적용 전 · 다시 계산 안 함 · 138-3).
* 그림 몫은 폭(좌우 여백 뺀)에 비례 · 위아래 여백은 그대로.
*/
export function naturalHeightAtWidth(
base: CrossPlotBase,
fromWidthPx: number,
toWidthPx: number,
): number {
const fixed = CROSS_PAD.top + CROSS_PAD.bottom + AREA_OVERLAY_HEADROOM_PX;
const inner = (width: number): number => Math.max(width - CROSS_PAD.left - CROSS_PAD.right, 1e-6);
return (base.naturalHeight - fixed) * (inner(toWidthPx) / inner(fromWidthPx)) + fixed;
}
/**
* 칸(카드 폭 W 로 잰 기하 × 높이 H px) 가득 그리기(173-4 [상세]) — 자연 높이가 H 보다 크면 가로 범위를 좌우 같이 넓혀
* m당 px 를 줄임(넓힌 자리 = 샘플 밖 빈 자리). 작으면 그대로(`crossPlotFromBase` 가 세로를 늘림).
*/
export function fitPlotBase(base: CrossPlotBase, heightPx: number): CrossPlotBase {
const fixed = CROSS_PAD.top + CROSS_PAD.bottom + AREA_OVERLAY_HEADROOM_PX;
if (base.naturalHeight <= heightPx || heightPx <= fixed) return base;
const k = (base.naturalHeight - fixed) / (heightPx - fixed);
const grow = ((base.maxOffset - base.minOffset) * (k - 1)) / 2;
return {
...base,
minOffset: base.minOffset - grow,
maxOffset: base.maxOffset + grow,
pixelsPerMeter: base.pixelsPerMeter / k,
naturalHeight: heightPx,
};
}
/** 재 둔 기하에 행 높이만 얹는다 — 다시 계산하지 않는다. */
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).
*
* 횡단 보기 설정 값 그대로다 — 사면 교차점 · 배수관 세트가 그 밖이어도 줌아웃하지 않는다
* (PLAN 79-2 사용자: 너무 작아짐). 계산은 캐시 전 구간으로 해 두었으니 팬으로 끌어다 본다.
*/
export function effectiveCardHalfWidth(
_section: CrossSection,
baseHalfWidth: number | undefined,
): number | undefined {
return baseHalfWidth;
}