Files
Aislo/B05_Profile/B05_Profile_UI_Profile_Layout.ts
eomsangdonandClaude Opus 5 4cbfb7e690 feat(B05): 종단 테이블 12행 → 6행 정리
화면 자리 확보 목적 (2026-09-06 사용자 지시). 뺀 6행 = 구배 L·H, 절토고,
성토고, 누가거리, 거리. 남긴 6행 = 구배 S, 계획고, 지반고, 측점, 곡선 L·R.
뺀 값은 사라지지 않음 — 구배 L·H 는 블록 툴팁, 절·성토고와 누가거리는 선택
측점 값 열 툴팁에 남김. B07 도면 종단도는 별도 코드라 12행 그대로.

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

133 lines
6.3 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Profile_Layout.ts
* 종단면도의 X축 배치 — 측점 칸 폭·캔버스 폭·chainage ↔ 화면 x 매핑.
*
* 패널 본체(B05_Profile_UI_Profile_Panel)가 700줄 한계에 닿아 분리했다.
* 그래프·테이블·구조물 레인이 **같은 매핑**을 써야 X축이 맞물리므로 그 계산을
* 한 곳에 모아 둔다. 전부 순수 함수라 패널 상태와 무관하다.
* ========================================================================== */
import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common";
import { normalizedLongitudinal } from "./B05_Profile_UI_Profile_Data";
import { TABLE_TARGET_FONT_PX, tableCellWidthFor } from "./B05_Profile_UI_Profile_Table";
import type { LongitudinalSection, SectionStation } from "../B06_Section/B06_Section_Api_Fetch";
import {
irregularLabel,
irregularStationId,
type IrregularStation,
} from "./B05_Profile_UI_IrregularStations";
/** 테이블 6행(구배 S · 계획고 · 지반고 · 측점 · 곡선 L · 곡선 R). 행 높이와 셀 폭에서
* 글자 크기를 정하는 데 쓴다. 2026-09-06 사용자 지시로 12행에서 줄였다. */
export const TABLE_ROW_COUNT = 6;
/** 측점 사이 여백 — 이웃 셀끼리 붙어 보이지 않게 띄운다. */
export const CELL_GAP_PX = 2;
/**
* 측점 한 칸의 **기준 폭(px)** — 목표 글자 크기(12px)로 7자리 값(`3000.00`)이 잘리지 않는 크기.
* 실제 기본 간격은 여기에 배수(`PROFILE_SPACING_MULTIPLIER`)를 곱한 값을 쓴다.
*/
export const STATION_SPACING_PX = tableCellWidthFor(TABLE_TARGET_FONT_PX) + CELL_GAP_PX;
/**
* **측점 간격 기본 배수**. 기준 폭의 1.5배를 한 측점 칸의 기본 간격으로 삼는다.
*
* 이 배수로 펼친 폭이 **최소 폭**이다 — 브라우저가 이보다 넓으면 폭맞춤으로 늘리고, 좁으면
* 이 간격을 유지한 채 스크롤로 훑는다. 넉넉한 기본값은 다음 세션의 **비정규 측점**(`+18` 등)이
* 규칙 칸 안에서 chainage 비례로 자리 잡을 여유도 함께 확보한다.
*/
export const PROFILE_SPACING_MULTIPLIER = 1.5;
/** 유효 표고 샘플 기준의 노선 최대 chainage(m). 그래프·테이블이 같은 값을 써야 X축이 맞물린다. */
export function maxChainageOf(data: LongitudinalSection): number {
const samples = normalizedLongitudinal(data).samples.filter(
(sample) => sample.valid !== false && Number.isFinite(sample.elevation_m ?? NaN),
);
return Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1);
}
/**
* 비정규 측점을 그래프용 `SectionStation`으로 만든다. 그래프 렌더러는 chainage·라벨·kind만
* 쓰므로 월드 좌표는 0으로 둔다(3D 마커용 좌표는 Page가 따로 보간). 범위 밖은 제외.
*/
export function irregularGraphStations(
list: IrregularStation[],
maxChainage: number,
): SectionStation[] {
return list
.filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6)
.map((entry) => ({
station_id: irregularStationId(entry.id),
chainage_m: entry.chainage_m,
label: irregularLabel(entry),
kind: "irregular" as const,
center_z: null,
azimuth_deg: null,
center_x: 0,
center_y: 0,
frame: { left_xy: [0, 0] as [number, number] },
}));
}
/** 종단면도 렌더러와 **같은** chainage → x(px) 매핑을 만든다 (테이블·버튼 정렬 기준). */
export function chainageMapper(
data: LongitudinalSection,
width: number,
originOffset: number,
): (chainage: number) => number {
const maxChainage = maxChainageOf(data);
const plotWidth = width - LONG_PAD.left - LONG_PAD.right - 2 * originOffset;
return (chainage: number) => LONG_PAD.left + originOffset + (chainage / maxChainage) * plotWidth;
}
/** `chainageMapper`의 역변환 — 구조물 라인을 끌 때 화면 x를 누가거리로 되돌린다. */
export function chainageInverter(
data: LongitudinalSection,
width: number,
originOffset: number,
): (px: number) => number {
const maxChainage = maxChainageOf(data);
const plotWidth = width - LONG_PAD.left - LONG_PAD.right - 2 * originOffset;
return (px: number) =>
plotWidth > 0 ? ((px - LONG_PAD.left - originOffset) / plotWidth) * maxChainage : 0;
}
export interface ProfileLayout {
/** 캔버스 폭(px). 화면이 넓으면 폭맞춤으로, 좁으면 최소 폭으로. */
width: number;
/** 0측점·종점을 축 프레임 안으로 반 칸씩 들여쓰는 여백(px) — 그래프·테이블 공통. */
originOffset: number;
/** 이웃 측점과 겹치지 않는 테이블 셀 폭(px). 실제 측점 간격에 맞춰 함께 늘어난다. */
cellWidth: number;
}
/**
* 측점 간격 기본값(기준 폭 × 1.5)으로 노선을 펼치되, 화면이 더 넓으면 폭맞춤으로 늘린다.
*
* 매핑은 `x(c) = LONG_PAD.left + halfCell + c·pxPerMeter`이고, 좌우로 반 칸(halfCell)씩 띄워
* 0측점 셀이 이름표 열 밖으로, 종점 셀이 오른쪽 끝 밖으로 나오게 한다. 좌우 여백을 합치면
* 한 칸(측점간격)이므로 `width = pads + (maxChainage + interval)·pxPerMeter`가 되고, 이를 뒤집어
* pxPerMeter를 구하면 halfCell·셀 폭이 실제 간격과 항상 맞물린다.
*/
export function computeProfileLayout(
data: LongitudinalSection,
stationIntervalM: number,
availableWidth: number,
/** 가로 줌 배수(1 = 현행). 측점 간격 기본값과 폭맞춤 폭에 함께 곱해 넷(그래프·테이블·
* 편집 버튼층·구조물 레인)이 같은 비율로 늘어나게 한다(2026-09-04 사용자 지시). */
zoomX = 1,
): ProfileLayout {
const maxChainage = maxChainageOf(data);
const interval = Math.max(stationIntervalM, 1e-6);
const framePad = LONG_PAD.left + LONG_PAD.right;
const zoom = Math.max(zoomX, 1e-6);
const minSpacing = STATION_SPACING_PX * PROFILE_SPACING_MULTIPLIER * zoom;
// 기본 배수로 펼친 최소 폭 (좌우 반 칸 = 한 칸 여백 포함).
const minWidth = framePad + ((maxChainage + interval) / interval) * minSpacing;
const width = Math.max(minWidth, availableWidth * zoom);
const pxPerMeter = (width - framePad) / (maxChainage + interval);
const spacing = interval * pxPerMeter;
return { width, originOffset: spacing / 2, cellWidth: Math.max(1, spacing - CELL_GAP_PX) };
}