Files
Aislo/B05_Profile/B05_Profile_UI_Profile_Layout.ts
T
eomsangdonandClaude Opus 5 2d49c59342 feat(B05): 종단도 줌·Y 표시 표고창 조작구
- B05_Profile_UI_Profile_Zoom.ts 신규 — 배율 상태(가로 폭 배수·세로 배율·창 중심)와
  버튼 7개(+ − ⇕+ ⇕− ▲ ▼ ⤢). 자리는 요약줄 오른쪽 끝(그래프 우측 상단),
  설명은 툴팁, 클릭은 stopPropagation 으로 측점 선택에 번지지 않게 함
- X 줌은 폭 배수 — computeProfileLayout 에 zoomX 를 넘겨 측점 간격·폭맞춤 폭에 함께 곱함.
  그래프·테이블·편집 버튼층·구조물 레인이 같은 매핑을 쓰므로 넷의 x 정렬이 유지됨
- Y 는 표시 표고창(제안 A) — createLongitudinalProfile 에 elevationOffsetRatio 인자 추가,
  창 높이 = 전범위 ÷ 배율, ▲▼ 가 창 높이의 10%씩 중심 이동. 세로 스크롤 없음
- 지반선·계획선·절성토 음영을 플롯 사각형 clipPath 안으로 옮김 — 배율을 올려도
  선이 축·측점 라벨 위로 흘러나오지 않음 (B06 은 배율 1·오프셋 0 이라 표시 불변)
- 요약줄 렌더러에 trailing 자리 추가, .b05-profile__zoom* CSS 추가

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

132 lines
6.2 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";
/** 테이블 12행. 행 높이와 셀 폭에서 글자 크기를 정하는 데 쓴다. */
export const TABLE_ROW_COUNT = 12;
/** 측점 사이 여백 — 이웃 셀끼리 붙어 보이지 않게 띄운다. */
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) };
}