Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts
T
eomsangdonandClaude Opus 5.5 207c2e40eb feat(B05): 계획노선 편집 창 — 측점 사이 기울기 라벨 · 1m 등고선 켬끔 · 보던 화면 기억
- 측점 사이 종단기울기(지반고 %) 라벨 — 측점 간격이 화면 90px 이상일 때만 · 측점 라벨 맞은편 · 회전해도 글 바로 섬 · 끄는 동안 안 물음
- 「1m 등고선」 단추(기본 꺼짐 · LAS 없으면 잠김) — 1m 선 얇고 옅게 · 켰을 때 처음 한 번만 준비 · 5m 선은 한 단 짙게(--map-contour-major)
- 회전 · 가운데 · 배율을 화면 취향(routeedit-view · 프로젝트마다 · 계정에도 올라감)에 적고 다시 열면 그대로
- 700줄 한도 — 화면 셈·기억은 _View, 재기 배선은 _Measure, 바탕 읽기는 _Contour, 노드 끼우기·빼기는 _Edits 로 나눔
- 시험 test_b05_routeedit_view.py (창 크기 달라도 같은 자리 · 저장소 왕복 · 노드 편집값 자리)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7
2026-09-29 09:33:57 +09:00

166 lines
7.3 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_RouteEdit_Contour.ts
* 계획노선 편집 모달이 바탕에 깔 **등고선 한 벌**을 고른다.
*
* **어느 등고선을 쓰나**(2026-09-12 사용자 지시 ⑥) — 도엽 등고선과 LAS 로 만든 등고선은
* 서로 어긋난다. 노선은 실제 지형 위에 놓여야 하므로 **확정 지표면 모델이 있으면 LAS 쪽**을
* 쓰고, 없는 프로젝트에서만 지금까지처럼 도엽 등고선을 쓴다.
*
* 둘은 생김새가 다르다 — 도엽은 위경도 GeoJSON(표고는 `등고수치` 속성), LAS 는 사업지
* 좌표(m) 점렬(표고는 `level`)이다. 여기서 **같은 `PreparedLayer` 한 꼴로 맞춰** 내보내
* 그리기·라벨·집기가 출처를 안 가리게 한다.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedJson } from "../A00_Common/b_asset_cache";
import {
createNormalizer,
type GeoJsonCollection,
type Normalizer,
type PreparedLayer,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import {
prepareLayer,
prepareMetricPolylines,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
/** 도엽 등고선의 표고 속성 이름 — B04 지도가 쓰는 것과 같은 키. */
const SHEET_ELEVATION_KEYS = ["등고수치"];
/** 표고를 못 읽었을 때 라벨 솎기에 쓸 간격(m). */
const FALLBACK_INTERVAL_M = 5;
/** LAS 등고선을 **몇 m 단위로** 낼지 — 도엽 등고선과 같은 눈금(2026-09-12 사용자 지시 ⑭).
*
* LAS 자료는 1m 간격으로 뽑혀 있어 그대로 쓰면 확대할 때 1m·2m 짜리까지 나온다. 서버에 5m
* 로 다시 뽑아 달라고 하면 첫 한 번이 오래 걸리므로 **받아 둔 1m 자료에서 5의 배수만 고른다**
* — 그림도 라벨도 도엽 쪽과 같은 눈금이 된다. */
const LAS_CONTOUR_UNIT_M = 5;
export interface RouteEditContours {
layer: PreparedLayer;
/** 등고선 간격(m) — 라벨을 몇 줄마다 낼지 정하는 기준. */
intervalM: number;
source: "las" | "sheet";
/** 5m 배수가 아닌 **1m 간격 선**(계획서 18 ②) — 켰을 때 처음 한 번 준비한다(무거워서).
* LAS 자료가 없으면(도엽 갈래) null — 도엽에는 1m 선이 없다. */
minor: (() => PreparedLayer) | null;
}
interface ContourResponse {
contours: Array<{ level: number; coordinates: Array<[number, number, number]> }>;
}
/**
* 바탕 등고선을 읽는다. 확정 지표면 모델이 있으면 LAS, 없으면 이미 받아 둔 도엽 컬렉션.
*
* LAS 쪽을 못 읽으면 **조용히 도엽으로 내려앉는다** — 등고선이 아예 없는 화면보다 낫고,
* 어느 쪽을 쓰고 있는지는 `source` 로 나가 상태줄에 적힌다.
*/
export async function loadRouteEditContours(
projectId: string,
meta: VWorldMeta,
normalizer: Normalizer,
sheet: GeoJsonCollection | null,
options: { surfaceModelId: number | null; intervalM: number; smooth: boolean },
): Promise<RouteEditContours> {
if (options.surfaceModelId !== null) {
// 받아 오는 간격은 프로젝트 설정 그대로(보관함에 이미 있는 파일을 쓰려는 것) —
// **보이는 눈금**은 아래에서 5m 로 맞춘다.
const interval = options.intervalM > 0 ? options.intervalM : 1;
try {
// 3D 뷰어가 쓰는 것과 **같은 파일**이다 — 보관함에 있으면 다시 내려받지 않는다.
const data = await fetchCachedJson<ContourResponse>(
projectId,
`${API_BASE_URL}/projects/${projectId}/surface/models/${options.surfaceModelId}` +
`/contour?interval=${interval}&smooth=${options.smooth}`,
);
const all = data.contours ?? [];
const onUnit = (level: number): boolean => Math.abs(level % LAS_CONTOUR_UNIT_M) < 1e-6;
const toLines = (picked: typeof all) =>
picked
.map((contour) => ({
points: contour.coordinates.map(([x, y]) => [x, y] as const),
label: contour.level,
}))
.filter((line) => line.points.length >= 2);
// 5m 단위만 바로 준비한다 — 1m 자료를 다 들고 있으면 그리기·집기가 다섯 배로 무겁다.
const lines = toLines(all.filter((contour) => onUnit(contour.level)));
if (lines.length > 0) {
// 나머지(1m 간격 선)는 켰을 때 처음 한 번만 준비한다 — 안 켜는 사람은 값을 안 치른다.
const rest = all.filter((contour) => !onUnit(contour.level));
let minor: PreparedLayer | null = null;
return {
layer: prepareMetricPolylines(lines, meta),
intervalM: LAS_CONTOUR_UNIT_M,
source: "las",
minor: rest.length ? () => (minor ??= prepareMetricPolylines(toLines(rest), meta)) : null,
};
}
} catch {
/* 내려앉는다 — 아래 도엽 갈래로 이어 간다. */
}
}
const layer = prepareLayer(sheet ?? undefined, normalizer, SHEET_ELEVATION_KEYS);
return { layer, intervalM: inferIntervalM(layer), source: "sheet", minor: null };
}
/**
* 편집 창 바탕 한 벌 — 등고선(LAS 우선)과, 등고선 말고 함께 깔 도엽 레이어(하천중심선).
* 도엽 컬렉션은 배수유역도가 받는 것(`fetchDrainageLayers`)을 그대로 받는다.
*/
export async function loadRouteEditBackground(
projectId: string,
drainage: {
meta: VWorldMeta;
layers: ReadonlyArray<readonly [string, GeoJsonCollection | null]>;
},
options: { surfaceModelId: number | null; intervalM: number; smooth: boolean },
): Promise<{ otherSheets: PreparedLayer[]; contours: RouteEditContours }> {
const normalizer = createNormalizer(drainage.meta);
const otherSheets = drainage.layers
.filter(([layer]) => layer !== "도엽_등고선")
.map(([, collection]) => (collection ? prepareLayer(collection, normalizer) : null))
.filter((layer): layer is PreparedLayer => layer !== null);
const sheet = drainage.layers.find(([layer]) => layer === "도엽_등고선")?.[1] ?? null;
const contours = await loadRouteEditContours(
projectId,
drainage.meta,
normalizer,
sheet,
options,
);
return { otherSheets, contours };
}
/** 1m 등고선 켬끔 단추를 배선한다 — 기본 꺼짐. 지금 켜져 있나를 돌려주는 함수를 낸다. */
export function bindMinorContourToggle(
button: HTMLButtonElement,
onChange: () => void,
): () => boolean {
let on = false;
button.addEventListener("click", () => {
on = !on;
button.classList.toggle("is-active", on);
button.setAttribute("aria-pressed", String(on));
onChange();
});
return () => on;
}
/** 도엽 등고선의 간격(m) — 표고 값들의 **가장 좁은 칸**을 간격으로 본다. */
function inferIntervalM(layer: PreparedLayer): number {
const levels = [
...new Set(
layer.features
.map((feature) => feature.labelValue)
.filter((value): value is number => value !== null),
),
].sort((a, b) => a - b);
let smallest = Infinity;
for (let index = 1; index < levels.length; index += 1) {
const gap = levels[index] - levels[index - 1];
if (gap > 0 && gap < smallest) smallest = gap;
}
return Number.isFinite(smallest) ? smallest : FALLBACK_INTERVAL_M;
}