Files
Aislo/B06_Section/B06_Section_UI_Section_Common.ts
eomsangdonandClaude Opus 5 b51c887549 feat(B05/B06): 종단 그래프 줌 버튼 단순화 + 세로 자동 맞춤
- 줌 버튼을 셋(줌인·줌아웃·초기화)으로 줄임. 세로 배율·창 이동 버튼 제거
- 배율 1 = 기본값이자 축소 한계 — 한계에 닿은 버튼은 흐리게 죽임
- 세로는 보이는 누가거리 구간의 지반·계획선 범위로 자동(공통 함수
  windowElevationRange, B05·B06 종단이 함께 씀). 스크롤이 멈춘 뒤 0.16초에 갱신
- 계획고 편집 버튼을 누르고 있는 동안 Y 축 고정, 손을 떼면 다시 맞춤
- 유토곡선 Y 도 같은 창 기준(전 구간 ±200㎥ 고정 해제)
- B06 종단이 쓰던 공통 Y 스케일(calculateYScale) 제거 — 횡단 카드는 원래 안 쓰던 값

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

297 lines
13 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Section_Common.ts
* 종·횡단 SVG 렌더러가 공유하는 상수·타입·유틸리티.
*
* 700줄 제한 대응으로 기존 단일 렌더러 파일에서 공통 부분만 분리했다. 종단 렌더러
* (`_UI_Longitudinal`), 횡단 카드 렌더러(`_UI_Cross_View`), 뷰 컨트롤러(`_UI_Section_View`)가
* 이 모듈을 참조한다. 색상 하드코딩 금지 원칙은 그대로이며 여기서는 지오메트리 계산만 다룬다.
* ========================================================================== */
import { PIPE_TYPES } from "@config/config_frontend";
import { PIPE_DISPLAY_NAME } from "../B05_Profile/B05_Profile_UI_IrregularStations";
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: 26 };
export interface YScaleOptions {
pixelsPerMeter: number;
globalMinElevation: number;
globalMaxElevation: number;
}
/**
* 구조물 라벨의 **표시 이름** — 배수관은 관종·관경(`파형강관 D1200`) 대신 `배수관` 하나로
* 적는다(2026-08-17 확정한 `PIPE_DISPLAY_NAME` 규칙, 2026-09-03 종·횡단 표기에 적용).
*
* 저장 라벨은 그대로 두어야 한다 — 재진입 이관(`B05_Profile_Structures_Migration`)이 라벨
* 문자열로 구조물 종류를 되짚는다. 그래서 바꾸는 것은 화면에 찍는 순간뿐이다.
*/
export function structureDisplayName(label: string): string {
return PIPE_TYPES.some((kind) => label.startsWith(`${kind} D`)) ? PIPE_DISPLAY_NAME : label;
}
export function validElevation(
sample: SectionSample,
): sample is SectionSample & { elevation_m: number } {
return (
sample.valid !== false && sample.elevation_m !== null && Number.isFinite(sample.elevation_m)
);
}
/**
* 저장된 횡단 설계가 **현재 계획선과 어긋난 측점**이 하나라도 있는가(B05·B06 공용 규칙).
*
* 횡단 설계는 계산 당시 계획고(`design.design_elevation_m`)를 기준으로 설계선 좌표를
* 굳혀 둔다. 계획선이 그 뒤에 바뀌면(사용자 편집·확정, 배수 최소고 같은 백엔드 규칙
* 도입) 설계선은 옛 계획고 자리에 남는데 화면의 계획고 십자선·3D는 최신 계획선을 쓴다
* — 두 기준이 어긋나 횡단도·3D가 종단을 안 따라오는 것처럼 보인다(2026-08-23 실측:
* 한 노선에서 최대 2.21m 어긋남). 재계산 대상을 한 규칙으로 판정해 두 화면이 같은
* 시점에 같은 조치를 하게 한다.
*
* 그리기마다 불리므로 **첫 건에서 멈춘다** — 전 측점을 훑어 목록을 만들던 옛 판은
* 프레임당 2.33ms 를 먹어 계획선 편집을 굼뜨게 했다(2026-09-03 실측).
*/
export function hasStaleDesigns(
detail: {
longitudinal: { design_profiles?: DesignProfile[] };
cross_sections: StaleCandidate[];
},
toleranceM = 1e-3,
): boolean {
const profiles = detail.longitudinal.design_profiles;
return detail.cross_sections.some((section) => isStaleSection(section, profiles, toleranceM));
}
interface StaleCandidate {
chainage_m: number;
design?: {
design_elevation_m: number;
geometry_preset?: string;
two_stage_slope?: boolean;
surface_drop_m?: number;
} | null;
}
function isStaleSection(
section: StaleCandidate,
profiles: DesignProfile[] | undefined,
toleranceM: number,
): boolean {
const design = section.design;
if (!design) return false;
// 옛 암 측점: 2단계 절토 경사 필드가 없으면 절토 면적이 최신 엔진과 다르다.
// 이 조건이 B06 페이지에만 있어 B05는 재계산을 건너뛰었고, 같은 데이터인데 두 화면의
// 절토량이 갈렸다(2026-09-03 실측 228.52㎡ ↔ 270.12㎡). 판정을 여기 한 곳으로 모은다.
if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true;
if (!profiles?.length) return false;
const planned = designElevationAt(profiles, section.chainage_m);
if (planned === undefined) return false;
// 세월교가 앉은 측점의 노면은 월류 높이만큼 **일부러** 내려 앉는다 — 그 차이를 빼지
// 않으면 계획선을 안 건드려도 늘 어긋난 것으로 나온다(2026-09-03 사용자 보고).
const designed = design.design_elevation_m + (design.surface_drop_m ?? 0);
return Math.abs(planned - designed) > toleranceM;
}
/**
* 계획선 샘플에서 유효분만 걸러 둔 사본 — **샘플 배열 하나당 한 번만** 만든다.
*
* 이 함수는 측점마다 불린다(측점 128 × 샘플 2,159 규모). 호출마다 `filter()` 로 새
* 배열을 만들면 그리기 한 프레임이 통째로 그 비용에 먹힌다(2026-09-03 실측: 프레임당
* 2.33ms, 계획선 편집이 굼떠지는 원인). 원본 배열이 바뀌면 새 객체가 오므로 키로 쓴다.
*/
const validSamplesCache = new WeakMap<object, Array<{ chainage_m: number; elevation_m: number }>>();
function validPlanSamples(
designProfiles: DesignProfile[] | undefined,
): Array<{ chainage_m: number; elevation_m: number }> | undefined {
const samples = designProfiles?.[0]?.samples;
if (!samples?.length) return undefined;
const cached = validSamplesCache.get(samples);
if (cached) return cached.length ? cached : undefined;
const filtered = samples.filter((sample) => Number.isFinite(sample.elevation_m)) as Array<{
chainage_m: number;
elevation_m: number;
}>;
validSamplesCache.set(samples, filtered);
return filtered.length ? filtered : undefined;
}
/**
* 측점 chainage 위치의 계획고를 계획선 샘플에서 선형보간한다.
* 범위를 벗어나면 양 끝값으로 클램프하며, 계획선이 없으면 undefined를 반환해 지반고 폴백을 유도한다.
*/
export function designElevationAt(
designProfiles: DesignProfile[] | undefined,
chainageM: number,
): number | undefined {
const samples = validPlanSamples(designProfiles);
if (!samples) 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;
// 누가거리 순 배열이라 이진탐색으로 구간을 고른다(선형 훑기와 결과는 같다).
let low = 1;
let high = samples.length - 1;
while (low < high) {
const mid = (low + high) >> 1;
if (samples[mid].chainage_m < chainageM) low = mid + 1;
else high = mid;
}
const previous = samples[low - 1];
const current = samples[low];
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;
}
/**
* 종단 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,
};
}
/**
* **보이는 구간의 표고 최저·최고**(종단 그래프 세로 자동 맞춤, 2026-09-04 사용자 확정).
*
* 가로로 확대하면 화면에는 노선의 일부만 남는데 Y 축은 전 구간 범위로 잡혀 있어 곡선이
* 납작하게 눌린다. 보이는 누가거리 구간만 훑어 그 구간의 범위를 돌려준다 — B05 종단과
* B06 종단이 같은 함수를 쓴다.
*
* 창 밖 **이웃 한 점**까지 함께 본다. 창 경계를 걸친 선분이 창 안에서 위로 솟는데 그
* 바깥 끝점을 빼면 선이 축 위로 삐져나온다.
*/
export function windowElevationRange(
series: ReadonlyArray<ReadonlyArray<{ chainage_m?: number; elevation_m?: number | null }>>,
fromM: number,
toM: number,
): { min: number; max: number } | undefined {
let min = Infinity;
let max = -Infinity;
for (const list of series) {
let first = -1;
let last = -1;
for (let index = 0; index < list.length; index += 1) {
const chainage = list[index].chainage_m ?? 0;
if (chainage < fromM || chainage > toM) continue;
if (first < 0) first = index;
last = index;
}
if (first < 0) continue;
for (
let index = Math.max(0, first - 1);
index <= Math.min(list.length - 1, last + 1);
index += 1
) {
const elevation = list[index].elevation_m;
if (typeof elevation !== "number" || !Number.isFinite(elevation)) continue;
if (elevation < min) min = elevation;
if (elevation > max) max = elevation;
}
}
return min <= max ? { min, max } : undefined;
}
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;
}