화면 자리 확보 목적 (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>
133 lines
6.3 KiB
TypeScript
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) };
|
|
}
|