Files
Aislo/B05_Profile/B05_Profile_UI_Page_Helpers.ts
T
eomsangdonandClaude Fable 5 738e09e7b1 feat(B05/B06): 구조물 선택 화면 전체 활성화 + 세션 상시 공유
- 바깥 선택이 폼에 실릴 때 접힌 좌측 패널 자동 펼침(onReveal 창구)
- 계곡 통과 시설 목록 행도 보이는 자리로 스크롤(rAF)
- 종단 테이블 구조물 구간 판정을 허용오차 비교로 교체 + 병합 측점 포함(강조 0건→6건)
- 구조물 선택 세션을 소비형에서 유지형으로 — B06 카드·목록·부재 선택도 같은 칸에 기록,
  양쪽 진입 시 복원(목록 지연 로드 대비 이중 복원)
- 조정창 닫기가 세션 선택까지 지우던 결함 수정(부재 해제와 측점 해제 분리)
- B05 페이지 700줄 유지 위해 상단측 세션 보관을 Page_Helpers로 이동(동작 불변)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 16:27:35 +09:00

282 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B05_Profile_UI_Page_Helpers.ts
* B05 화면의 순수 도우미 — 설계폭 조회, 모델 경계 변환, 저장분 → 마커 복원,
* 비정규 측점의 3D 좌표 선형보간.
*
* 화면 본체(B05_Profile_UI_Page)가 700줄 한계에 닿아 분리했다.
* 전부 상태를 갖지 않는 변환 함수라 화면 흐름과 독립적이다.
* ========================================================================== */
import type { RoutePanelValues } from "./B05_Profile_UI_Panel";
import type {
ModelBounds,
PlacedRoutePoint,
RouteDesignPoints,
RoutePointKind,
} from "./B05_Profile_UI_Markers";
import type { CirclePoint, RouteLatestResponse, RoutePoint } from "./B05_Profile_Api_Fetch";
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { SectionStation } from "../B06_Section/B06_Section_Api_Fetch";
import {
irregularLabel,
irregularStationId,
type IrregularStation,
} from "./B05_Profile_UI_IrregularStations";
type GradeClass = RoutePanelValues["gradeClass"];
export type RoadWidths = Record<GradeClass, number>;
/** 임도 종류별 유효너비(m) — 간선·산불진화 3m, 작업 2.5m(설계제원_총괄 §3).
* branch(지선)는 폐지됐지만 기존 저장분이 남아 있어 값은 유지한다(2026-08-19). */
export const DEFAULT_ROAD_WIDTHS: RoadWidths = { trunk: 3, fire: 3, branch: 3, work: 2.5 };
/** 프로젝트 등록(B02)의 임도 종류 값을 계획선 기준 코드로 옮긴다(2026-08-19).
* B02 값: main(간선)·fire(산불진화)·branch(구 지선)·stream(계류보전).
* 현행 규칙의 3종은 간선·산불진화·작업이고 지선은 폐지됐으므로, 간선·산불진화가
* 아닌 값은 모두 작업임도로 본다(별표2 부칙 경과조치와 같은 방향). */
export function toGradeClass(roadType: string | null | undefined): GradeClass {
if (roadType === "main" || roadType === "trunk") return "trunk";
if (roadType === "fire") return "fire";
return "work";
}
export async function fetchRoadWidths(projectId: string): Promise<RoadWidths> {
const response = await fetch(`/api/projects/${projectId}/sections/road-widths`);
if (!response.ok) return DEFAULT_ROAD_WIDTHS;
const payload = (await response.json()) as { forest_road_min_width_m?: Partial<RoadWidths> };
return { ...DEFAULT_ROAD_WIDTHS, ...payload.forest_road_min_width_m };
}
export function toBounds(bounds: {
x_min: number;
x_max: number;
y_min: number;
y_max: number;
z_min: number;
z_max: number;
}): ModelBounds {
return {
x: [bounds.x_min, bounds.x_max],
y: [bounds.y_min, bounds.y_max],
z: [bounds.z_min, bounds.z_max],
};
}
export function placed(
type: RoutePointKind,
point: RoutePoint | CirclePoint,
index = 0,
): PlacedRoutePoint {
return {
id: `${type}-restored-${index}`,
type,
x: point.x,
y: point.y,
// 표고를 모르면 모르는 채로 넘긴다 — 0으로 눕히면 마커가 지형 한참 아래 평면에 깔린다.
z: point.z ?? null,
...(type === "ap" || type === "fp" ? { radius_m: (point as CirclePoint).radius_m ?? 25 } : {}),
};
}
export function restorePoints(latest: RouteLatestResponse): RouteDesignPoints {
const points = latest.route_params?.points;
return {
bp: points?.bp ? placed("bp", points.bp) : null,
ep: points?.ep ? placed("ep", points.ep) : null,
cp: (points?.cp ?? []).map((point, index) => placed("cp", point, index)),
ap: (points?.ap ?? []).map((point, index) => placed("ap", point, index)),
fp: (points?.fp ?? []).map((point, index) => placed("fp", point, index)),
};
}
export function routePoint(point: PlacedRoutePoint): RoutePoint {
// 서버 스키마는 표고를 빼면 "모름"으로 받는다 — null은 undefined로 바꿔 보낸다.
return { x: point.x, y: point.y, z: point.z ?? undefined };
}
export function circlePoint(point: PlacedRoutePoint): CirclePoint {
return { ...routePoint(point), radius_m: point.radius_m ?? 25 };
}
/** 노선 폴리라인 위 chainage 지점의 (x, y) — 세그먼트 누적 길이로 정확히 되짚는다.
* 규칙 측점(20m 간격) 사이 직선보간은 곡선 구간에서 모서리를 잘라 3D 마커가 노선을
* 벗어난다(2026-08-19 사용자 보고 — 도엽 등고선 분석 좌표와 라이다 노선의 미세 차).
* 폴리라인은 노선 정본이라 이 좌표가 3D 노선 선 위에 정확히 얹힌다. */
export function chainageToPolylineXY(
polyline: ReadonlyArray<{ x: number; y: number }>,
chainage: number,
): { x: number; y: number } | null {
if (polyline.length < 2) return null;
let travelled = 0;
for (let index = 1; index < polyline.length; index += 1) {
const from = polyline[index - 1];
const to = polyline[index];
const span = Math.hypot(to.x - from.x, to.y - from.y);
if (span < 1e-9) continue;
if (travelled + span >= chainage - 1e-6) {
const t = Math.min(Math.max((chainage - travelled) / span, 0), 1);
return { x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t };
}
travelled += span;
}
const last = polyline[polyline.length - 1];
return { x: last.x, y: last.y };
}
/** 단위벡터 두 개를 각도로 보간 — 선형보간과 달리 길이가 1로 유지된다. */
function slerpUnit(a: [number, number], b: [number, number], t: number): [number, number] {
const angleA = Math.atan2(a[1], a[0]);
let delta = Math.atan2(b[1], b[0]) - angleA;
if (delta > Math.PI) delta -= Math.PI * 2;
if (delta < -Math.PI) delta += Math.PI * 2;
const angle = angleA + delta * t;
return [Math.cos(angle), Math.sin(angle)];
}
/**
* 노선 폴리라인 위 chainage 지점의 **좌향 단위벡터**(횡단 법선) — 백엔드 프레임과
* 같은 규약 `left = (ty, tx)`(`B05_Profile_Engine_Sections_Core.py`).
*
* 비정규(구조물) 측점 프레임을 규칙 측점 두 개의 left_xy **선형보간**으로 만들면
* 곡선 구간에서 단위길이가 깨지고 방향도 현(chord) 쪽으로 눕는다 — 그 측점의 횡단
* 가로선·구조물이 노선 법선과 어긋나 보인다(2026-08-23 사용자 지적: 4+4.3 법선).
* 폴리라인 접선에서 직접 뽑으면 그 자리의 실제 법선이 된다.
*/
export function chainageToPolylineLeft(
polyline: ReadonlyArray<{ x: number; y: number }>,
chainage: number,
): [number, number] | null {
if (polyline.length < 2) return null;
let travelled = 0;
let fallback: [number, number] | null = null;
for (let index = 1; index < polyline.length; index += 1) {
const from = polyline[index - 1];
const to = polyline[index];
const span = Math.hypot(to.x - from.x, to.y - from.y);
if (span < 1e-9) continue;
const left: [number, number] = [-(to.y - from.y) / span, (to.x - from.x) / span];
fallback = left;
if (travelled + span >= chainage - 1e-6) return left;
travelled += span;
}
return fallback;
}
/**
* 비정규 측점을 규칙 측점 좌표 사이 chainage로 선형보간해 `SectionStation`(월드 좌표·프레임 포함)으로
* 만든다. 백엔드가 아직 이 측점의 횡단을 생성하지 않으므로, 3D 표시에 필요한 위치만 근사한다.
* 노선 범위를 벗어난 chainage는 제외한다. `routePolyline`을 주면 중심 (x, y)는 노선
* 폴리라인에서 정확히 되짚는다(곡선 모서리 잘림 방지, 2026-08-19) — 표고·프레임은
* 여전히 규칙 측점 보간값이다.
*/
export function interpolateIrregularStations(
base: SectionStation[],
list: IrregularStation[],
maxChainage: number,
routePolyline: ReadonlyArray<{ x: number; y: number }> = [],
): SectionStation[] {
const sorted = [...base].sort((a, b) => a.chainage_m - b.chainage_m);
if (!sorted.length) return [];
const anchorAt = (chainage: number): SectionStation => {
if (chainage <= sorted[0].chainage_m) return sorted[0];
const last = sorted[sorted.length - 1];
if (chainage >= last.chainage_m) return last;
let lo = sorted[0];
let hi = last;
for (let index = 1; index < sorted.length; index += 1) {
if (sorted[index].chainage_m >= chainage) {
lo = sorted[index - 1];
hi = sorted[index];
break;
}
}
const span = hi.chainage_m - lo.chainage_m;
const t = span > 1e-9 ? (chainage - lo.chainage_m) / span : 0;
const lerp = (a: number, b: number): number => a + (b - a) * t;
const centerZ =
lo.center_z !== null && hi.center_z !== null
? lerp(lo.center_z, hi.center_z)
: (lo.center_z ?? hi.center_z);
return {
...lo,
center_x: lerp(lo.center_x, hi.center_x),
center_y: lerp(lo.center_y, hi.center_y),
center_z: centerZ,
frame: {
// 단위벡터는 선형보간하면 길이가 줄고 방향이 현 쪽으로 눕는다 — 각도로
// 보간해 단위길이를 지킨다(2026-08-23). 폴리라인이 있으면 아래에서 실제
// 접선 법선으로 다시 덮는다.
left_xy: slerpUnit(lo.frame.left_xy, hi.frame.left_xy, t),
},
};
};
return list
.filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6)
.map((entry) => {
const anchor = anchorAt(entry.chainage_m);
// 중심 좌표는 노선 폴리라인에서 정확히 — 곡선에서 규칙 측점 직선보간이 만드는
// 모서리 잘림(3D 마커가 노선 옆으로 새는 현상)을 없앤다(2026-08-19).
const onRoute = chainageToPolylineXY(routePolyline, entry.chainage_m);
// 법선(좌향)도 같은 폴리라인에서 뽑는다 — 중심만 노선 위에 얹고 프레임은
// 측점 보간값을 쓰면 그 측점의 횡단이 노선과 어긋난다(2026-08-23 사용자).
const onRouteLeft = chainageToPolylineLeft(routePolyline, entry.chainage_m);
return {
...anchor,
...(onRoute ? { center_x: onRoute.x, center_y: onRoute.y } : {}),
...(onRouteLeft ? { frame: { left_xy: onRouteLeft } } : {}),
station_id: irregularStationId(entry.id),
chainage_m: entry.chainage_m,
label: irregularLabel(entry),
kind: "irregular" as const,
// 3D 측점 라벨이 `측점번호 구조물명`으로 표기할 수 있게 구조물 이름을 실어 보낸다.
structure: entry.structure,
};
});
}
/** 시설 종류별 표시 이름 — 그래프·3D 라벨용(배관은 관종·관경까지 따로 붙인다). */
export const FACILITY_NAMES: Record<PipeFacility, string> = {
pipe: "배수관",
box_culvert: "BOX암거",
ford_pavement: "물넘이포장",
ford_bridge: "세월교",
revetment: "기슭막이",
};
/* ── 측점 상단측(=측구 방향) 사용자 변경분 세션 보관 ─────────────────────
* 3D 램프 클릭으로 바꾼 값. 경로 확정 때 uphill_overrides로 백엔드에 병합한다.
* 화면 본체가 700줄에 닿아 읽기·쓰기만 여기로 뺐다(2026-09-04, 동작 불변). */
const uphillSessionKey = (projectId: string): string => `b05:uphill:${projectId}`;
/** 세션에 남은 상단측 변경분을 읽는다. 손상된 값은 무시하고 빈 것으로 시작한다. */
export function loadUphillOverrides(projectId: string): Map<string, "left" | "right"> {
const overrides = new Map<string, "left" | "right">();
try {
const raw = window.sessionStorage.getItem(uphillSessionKey(projectId));
if (!raw) return overrides;
Object.entries(JSON.parse(raw) as Record<string, "left" | "right">).forEach(
([chainage, side]) => {
if (side === "left" || side === "right") overrides.set(chainage, side);
},
);
} catch {
/* 손상된 세션 값은 무시 — 자동 판정값으로 재시작. */
}
return overrides;
}
/** 상단측 변경분을 세션에 적는다. 저장 실패는 무시한다(값은 메모리에 남는다). */
export function saveUphillOverrides(
projectId: string,
overrides: ReadonlyMap<string, "left" | "right">,
): void {
try {
window.sessionStorage.setItem(
uphillSessionKey(projectId),
JSON.stringify(Object.fromEntries(overrides)),
);
} catch {
/* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */
}
}