Files
Aislo/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common.ts
T
2026-07-22 20:28:33 +09:00

148 lines
5.6 KiB
TypeScript

/* =============================================================================
* B06_wf3_ProfileCross_UI_Section_Common.ts
* 종·횡단 SVG 렌더러가 공유하는 상수·타입·유틸리티.
*
* 700줄 제한 대응으로 기존 단일 렌더러 파일에서 공통 부분만 분리했다. 종단 렌더러
* (`_UI_Longitudinal`), 횡단 카드 렌더러(`_UI_Cross_View`), 뷰 컨트롤러(`_UI_Section_View`)가
* 이 모듈을 참조한다. 색상 하드코딩 금지 원칙은 그대로이며 여기서는 지오메트리 계산만 다룬다.
* ========================================================================== */
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import type { CrossDesignChange } from "./B06_wf3_ProfileCross_UI_Cross_Design";
import type {
DesignProfile,
SectionDetailResponse,
SectionSample,
} from "./B06_wf3_ProfileCross_Api_Fetch";
export type { CrossDesignChange };
export type DesignChangeHandler = (chainageM: number, change: CrossDesignChange) => void;
export const SVG_NS = "http://www.w3.org/2000/svg";
export const LONG_WIDTH = 1200;
export const LONG_HEIGHT = 220;
export const CROSS_WIDTH = 560;
export const CROSS_HEIGHT = 250;
export const CROSS_GRID_MIN_WIDTH = 480;
export const CROSS_GRID_GAP = 16;
export const LONG_PAD = { left: 62, right: 24, top: 30, bottom: 52 };
export const CROSS_PAD = { left: 58, right: 20, top: 20, bottom: 52 };
export interface YScaleOptions {
pixelsPerMeter: number;
globalMinElevation: number;
globalMaxElevation: number;
}
export function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export function svgElement<K extends keyof SVGElementTagNameMap>(
tag: K,
attributes: Record<string, string | number> = {},
): SVGElementTagNameMap[K] {
const element = document.createElementNS(SVG_NS, tag);
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value)));
return element;
}
export function svgText(
value: string,
attributes: Record<string, string | number>,
): SVGTextElement {
const text = svgElement("text", attributes);
text.textContent = value;
return text;
}
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를 반환해 지반고 폴백을 유도한다.
*/
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;
}
export function calculateYScale(detail: SectionDetailResponse): 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 = LONG_HEIGHT - LONG_PAD.top - LONG_PAD.bottom;
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;
}
export function inferStationInterval(stations: Array<{ chainage_m: number }>): number {
const counts = new Map<number, number>();
for (let index = 1; index < stations.length; index += 1) {
const difference = stations[index].chainage_m - stations[index - 1].chainage_m;
if (difference <= 0) continue;
const rounded = Math.round(difference * 10) / 10;
counts.set(rounded, (counts.get(rounded) ?? 0) + 1);
}
return (
[...counts.entries()].sort(
([intervalA, countA], [intervalB, countB]) => countB - countA || intervalB - intervalA,
)[0]?.[0] ?? 1
);
}
export function stationLabel(chainage: number, interval: number): string {
const safeInterval = interval > 0 ? interval : 1;
let stationNumber = Math.floor((chainage + 1e-6) / safeInterval);
let remainder = chainage - stationNumber * safeInterval;
if (Math.abs(remainder) < 0.05) remainder = 0;
if (remainder >= safeInterval - 0.05) {
stationNumber += 1;
remainder = 0;
}
return `${stationNumber}+${remainder.toFixed(1)}`;
}