- 종단 상단 표시줄을 법정 판정 3항목으로 축소: 최대 기울기/상한(별표2 Ⅰ.2.라),
절·성토 불균형(Ⅰ.1.나.(4)(다)), 종단곡선 필요 n곳(Ⅰ.2.마 — 대수차 5% 초과인데
곡선이 빠진 변화점). 변화점·종단곡선 개수·기본 곡선길이 L·중복 경고칩은 제거.
- [초기선 복원] → [편집 되돌리기] — 실제 동작은 화면 편집 델타 삭제이지 저장 지점
복귀가 아니다. 툴팁에 동작과 [초기화]와의 차이를 적었다.
- 3D 뷰포트 안내 라벨 삭제(로딩 완료 후 남던 조작법 문구).
- 배수유역 버튼 정비: 유역 다시 나누기 / 선택한 관 삭제 / 배관 배치 초기화 +
각 툴팁에 실제 동작 명시(선택 삭제는 툴팁 자체가 없었다).
- 유토곡선 요약 12칩 → 5칩(절토·성토·잉여|부족·운반·검산). 토질 3종 내역·다짐
환산·블록 수·장거리 운반·사토는 해당 칩 툴팁으로. B05·B06 공용 함수라 동시 반영.
- 설계속도 축 도입(임도는 속도를 낼 수 없는 노선 — 기본 20km/h):
· 임도 종류는 프로젝트 등록값(projects.road_type)을 읽어 B05가 읽기 전용 표시
(main→간선임도, fire→산불진화임도, 그 외→작업임도). 화면에서 고치지 않는다.
· 설계속도 선택(간선·산불진화 20/30/40, 작업 20 고정) 신설 — 종단기울기 상한과
종단곡선 반경이 등급이 아니라 이 값으로 정해진다(지식DB 설계제원_총괄 §6·§7).
· B02 임도 종류 선택지를 현행 3종으로 정리(지선 폐지·계류보전은 사방 구분).
· 백엔드: ROUTE_GRADE_CLASSES 3종+branch 호환, selectable_design_speeds 신설,
resolve_design_speed 신설, legal_grade_limit_pct·resolve_grade_options에
설계속도 인자, sections/context가 road_type 제공(B06도 저장값 승계).
· 계획선 정책은 그릴 때마다 현재 기준으로 동기화 — 저장분의 옛 상한이 남지 않는다.
- 검증: pytest 125 통과(설계속도 12건 신규), tsc, 헤드 브라우저 — 진입 시 상한 9%,
40km/h 전환 시 7% 즉시 반영, 원복 9%, 안내문 '간선임도 · 설계속도 20km/h ·
일반지형', 유토곡선 5칩, 유역 버튼 새 이름·툴팁, 3D 라벨 빈 문자열 확인.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
201 lines
8.3 KiB
TypeScript
201 lines
8.3 KiB
TypeScript
/* =============================================================================
|
|
* 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 };
|
|
}
|
|
|
|
/**
|
|
* 비정규 측점을 규칙 측점 좌표 사이 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: {
|
|
left_xy: [
|
|
lerp(lo.frame.left_xy[0], hi.frame.left_xy[0]),
|
|
lerp(lo.frame.left_xy[1], hi.frame.left_xy[1]),
|
|
],
|
|
},
|
|
};
|
|
};
|
|
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);
|
|
return {
|
|
...anchor,
|
|
...(onRoute ? { center_x: onRoute.x, center_y: onRoute.y } : {}),
|
|
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: "세월교",
|
|
};
|