- 5등분 고정(소수 표기) → 종단면도와 같은 niceTickStep 규칙 적용. - niceTickStep을 _UI_Section_Common으로 옮겨 종·횡단이 공유(정의 한 곳). - 횡단은 카드 높이가 낮아 라벨 간격 하한 14px(종단 16px)로 개수 상한 산정. - 횡단 표고 여유는 기존 crossPlotMetrics의 8% 패딩 그대로. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
192 lines
8.5 KiB
TypeScript
192 lines
8.5 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_UI_Section_Common.ts
|
|
* 종·횡단 SVG 렌더러가 공유하는 상수·타입·유틸리티.
|
|
*
|
|
* 700줄 제한 대응으로 기존 단일 렌더러 파일에서 공통 부분만 분리했다. 종단 렌더러
|
|
* (`_UI_Longitudinal`), 횡단 카드 렌더러(`_UI_Cross_View`), 뷰 컨트롤러(`_UI_Section_View`)가
|
|
* 이 모듈을 참조한다. 색상 하드코딩 금지 원칙은 그대로이며 여기서는 지오메트리 계산만 다룬다.
|
|
* ========================================================================== */
|
|
|
|
import type { CrossDesignChange } from "./B06_Section_UI_Cross_Design";
|
|
import type {
|
|
DesignProfile,
|
|
LongitudinalSection,
|
|
SectionDetailResponse,
|
|
SectionSample,
|
|
} from "./B06_Section_Api_Fetch";
|
|
|
|
export type { CrossDesignChange };
|
|
export type DesignChangeHandler = (chainageM: number, change: CrossDesignChange) => void;
|
|
|
|
// SVG 생성·측점 표기 유틸은 B05 계획 유토곡선과 공유하려고 `@util/common_util_svg`로 옮겼다.
|
|
// 여기서 재수출해 기존 B06 임포트 경로를 그대로 둔다(정의는 저쪽 한 곳뿐).
|
|
export {
|
|
SVG_NS,
|
|
L,
|
|
svgElement,
|
|
svgText,
|
|
inferStationInterval,
|
|
stationLabel,
|
|
} from "@util/common_util_svg";
|
|
|
|
export const LONG_WIDTH = 1200;
|
|
export const LONG_HEIGHT = 220;
|
|
export const CROSS_WIDTH = 560;
|
|
export const CROSS_HEIGHT = 250;
|
|
// 한 행 맞춤 기준 최소 카드 폭 — 조금 더 넓은 화면 필요(480→560, 약 +17%).
|
|
export const CROSS_GRID_MIN_WIDTH = 560;
|
|
export const CROSS_GRID_GAP = 16;
|
|
// bottom은 측점 라벨 한 줄 몫이다. X축 제목을 그래프 상단으로 올린 뒤 남던 여백을 걷어내,
|
|
// 종단면도 아래 라벨이 그 밑 유토곡선과의 틈 **가운데**에 오도록 좁혔다(2026-08-02 사용자 지시).
|
|
// left/right는 유토곡선과 X축을 맞추는 값이라 함부로 바꾸면 두 그래프 측점선이 어긋난다.
|
|
// top은 그래프 이름표가 겹쳐 놓이는 자리다. 이름표를 플롯 안 오버레이로 돌린 뒤 남던 위쪽
|
|
// 여백을 걷어 그래프 몫으로 돌렸다(2026-08-02 사용자 지시).
|
|
// bottom은 측점 라벨 한 줄 몫이다. X축 제목을 이름표로 합치며 남던 여백을 걷어내,
|
|
// 종단면도 아래 라벨이 그 밑 유토곡선과의 틈 **가운데**에 오도록 좁혔다.
|
|
// left/right는 유토곡선과 X축을 맞추는 값이라 함부로 바꾸면 두 그래프 측점선이 어긋난다.
|
|
// left 62→78: B05 좌측 패널 접기 버튼이 Y축 라벨·행제목을 가려 여유를 더 줬다
|
|
// (2026-08-05 사용자 보고). sticky Y축 마스크·테이블 행제목 폭이 모두 이 값에서
|
|
// 파생되므로 함께 넓어져 가로 스크롤 시 값 누출이 없다.
|
|
export const LONG_PAD = { left: 78, right: 24, top: 12, bottom: 26 };
|
|
export const CROSS_PAD = { left: 58, right: 20, top: 10, bottom: 52 };
|
|
|
|
export interface YScaleOptions {
|
|
pixelsPerMeter: number;
|
|
globalMinElevation: number;
|
|
globalMaxElevation: number;
|
|
}
|
|
|
|
export function validElevation(
|
|
sample: SectionSample,
|
|
): sample is SectionSample & { elevation_m: number } {
|
|
return (
|
|
sample.valid !== false && sample.elevation_m !== null && Number.isFinite(sample.elevation_m)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 측점 chainage 위치의 계획고를 계획선 샘플에서 선형보간한다.
|
|
* 범위를 벗어나면 양 끝값으로 클램프하며, 계획선이 없으면 undefined를 반환해 지반고 폴백을 유도한다.
|
|
*/
|
|
/**
|
|
* 저장된 횡단 설계가 **현재 계획선과 어긋난 측점**을 찾는다(B05·B06 공용 규칙).
|
|
*
|
|
* 횡단 설계는 계산 당시 계획고(`design.design_elevation_m`)를 기준으로 설계선 좌표를
|
|
* 굳혀 둔다. 계획선이 그 뒤에 바뀌면(사용자 편집·확정, 배수 최소고 같은 백엔드 규칙
|
|
* 도입) 설계선은 옛 계획고 자리에 남는데 화면의 계획고 십자선·3D는 최신 계획선을 쓴다
|
|
* — 두 기준이 어긋나 횡단도·3D가 종단을 안 따라오는 것처럼 보인다(2026-08-23 실측:
|
|
* 한 노선에서 최대 2.21m 어긋남). 재계산 대상을 한 규칙으로 판정해 두 화면이 같은
|
|
* 시점에 같은 조치를 하게 한다.
|
|
*/
|
|
export function staleDesignChainages(
|
|
detail: {
|
|
longitudinal: { design_profiles?: DesignProfile[] };
|
|
cross_sections: Array<{ chainage_m: number; design?: { design_elevation_m: number } | null }>;
|
|
},
|
|
toleranceM = 1e-3,
|
|
): number[] {
|
|
const profiles = detail.longitudinal.design_profiles;
|
|
if (!profiles?.length) return [];
|
|
return detail.cross_sections
|
|
.filter((section) => {
|
|
const design = section.design;
|
|
if (!design) return false;
|
|
const planned = designElevationAt(profiles, section.chainage_m);
|
|
return planned !== undefined && Math.abs(planned - design.design_elevation_m) > toleranceM;
|
|
})
|
|
.map((section) => section.chainage_m);
|
|
}
|
|
|
|
export function designElevationAt(
|
|
designProfiles: DesignProfile[] | undefined,
|
|
chainageM: number,
|
|
): number | undefined {
|
|
const samples = designProfiles?.[0]?.samples?.filter((sample) =>
|
|
Number.isFinite(sample.elevation_m),
|
|
);
|
|
if (!samples?.length) return undefined;
|
|
if (chainageM <= samples[0].chainage_m) return samples[0].elevation_m;
|
|
const last = samples[samples.length - 1];
|
|
if (chainageM >= last.chainage_m) return last.elevation_m;
|
|
for (let index = 1; index < samples.length; index += 1) {
|
|
const previous = samples[index - 1];
|
|
const current = samples[index];
|
|
if (chainageM > current.chainage_m) continue;
|
|
const span = current.chainage_m - previous.chainage_m;
|
|
if (span <= 0) return current.elevation_m;
|
|
const ratio = (chainageM - previous.chainage_m) / span;
|
|
return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio;
|
|
}
|
|
return last.elevation_m;
|
|
}
|
|
|
|
/**
|
|
* 종단 X축이 덮는 누가거리의 최댓값.
|
|
*
|
|
* 종단면도와 유토곡선이 **같은 X 매핑**을 써야 측점 위치가 어긋나지 않으므로,
|
|
* 두 렌더러가 이 함수 하나만 보게 한다(각자 계산하면 조용히 틀어진다).
|
|
*/
|
|
export function longitudinalMaxChainage(data: LongitudinalSection): number {
|
|
const samples = data.samples.filter(validElevation);
|
|
return Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1);
|
|
}
|
|
|
|
/**
|
|
* 종·횡단 공통 Y스케일. `longHeightPx`는 종단면도의 실제 렌더 높이로, 패널 리사이즈로
|
|
* 종단도가 줄면 그 높이를 넘겨야 표고 범위가 잘리지 않는다(기본은 고정 높이).
|
|
*/
|
|
export function calculateYScale(
|
|
detail: SectionDetailResponse,
|
|
longHeightPx: number = LONG_HEIGHT,
|
|
): YScaleOptions | undefined {
|
|
const elevations = [
|
|
...detail.longitudinal.samples.map((sample) => sample.elevation_m),
|
|
...detail.cross_sections.flatMap((section) =>
|
|
section.samples.map((sample) => sample.elevation_m),
|
|
),
|
|
...(detail.longitudinal.design_profiles ?? []).flatMap((profile) =>
|
|
profile.samples.map((sample) => sample.elevation_m),
|
|
),
|
|
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
|
if (!elevations.length) return undefined;
|
|
const globalMinElevation = Math.min(...elevations);
|
|
const globalMaxElevation = Math.max(...elevations);
|
|
const plotHeight = Math.max(longHeightPx - LONG_PAD.top - LONG_PAD.bottom, 1);
|
|
return {
|
|
pixelsPerMeter: plotHeight / Math.max(globalMaxElevation - globalMinElevation, 1),
|
|
globalMinElevation,
|
|
globalMaxElevation,
|
|
};
|
|
}
|
|
|
|
export function emptyView(message: string): HTMLElement {
|
|
const empty = document.createElement("div");
|
|
empty.className = "b06-section__empty";
|
|
empty.textContent = message;
|
|
return empty;
|
|
}
|
|
|
|
/**
|
|
* 눈금 간격을 1·2·5 ×10^n 중에서 고른다 — 눈금값이 딱 떨어지면서 눈금 개수가
|
|
* 목표(10칸)에 가장 가까운 것. 다만 라벨이 겹치지 않게 최대 개수(maxCount)를 넘지 않는다.
|
|
*/
|
|
export function niceTickStep(span: number, targetCount: number, maxCount: number): number {
|
|
if (!(span > 0)) return 1;
|
|
const exponent = Math.floor(Math.log10(span / targetCount));
|
|
let best = Math.pow(10, exponent + 2);
|
|
let bestError = Infinity;
|
|
for (const power of [exponent - 1, exponent, exponent + 1, exponent + 2]) {
|
|
for (const mantissa of [1, 2, 5]) {
|
|
const step = mantissa * Math.pow(10, power);
|
|
const count = Math.floor(span / step) + 1;
|
|
if (count > Math.max(2, maxCount)) continue;
|
|
const error = Math.abs(count - targetCount);
|
|
if (error < bestError) {
|
|
bestError = error;
|
|
best = step;
|
|
}
|
|
}
|
|
}
|
|
return best;
|
|
}
|