/* ============================================================================= * B05_Profile_UI_RouteEdit_Contour.ts * 계획노선 편집 모달이 바탕에 깔 **등고선 한 벌**을 고른다. * * **어느 등고선을 쓰나**(2026-09-12 사용자 지시 ⑥) — 도엽 등고선과 LAS 로 만든 등고선은 * 서로 어긋난다. 노선은 실제 지형 위에 놓여야 하므로 **확정 지표면 모델이 있으면 LAS 쪽**을 * 쓰고, 없는 프로젝트에서만 지금까지처럼 도엽 등고선을 쓴다. * * 둘은 생김새가 다르다 — 도엽은 위경도 GeoJSON(표고는 `등고수치` 속성), LAS 는 사업지 * 좌표(m) 점렬(표고는 `level`)이다. 여기서 **같은 `PreparedLayer` 한 꼴로 맞춰** 내보내 * 그리기·라벨·집기가 출처를 안 가리게 한다. * ========================================================================== */ import { contourAssetUrl, 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/logic/B04_PreProcess_UI_MapRender_Prepare"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { QUIET_MS } from "../A00_Common/design/design_calc_server"; import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { CONTOUR_BAND_M } from "./B05_Profile_UI_RouteEdit_Render"; /** 도엽 등고선의 표고 속성 이름 — 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; } /** 고른 등고선의 높이 — 못 읽었으면 높이 없이 「고른 등고선」만(계획서 0-9 ⑦). */ export function contourHintText(contours: RouteEditContours | null, picked: number): string { if (picked < 0) return "등고선을 누르면 그 줄의 높이가 보입니다."; const level = contours?.layer.features[picked]?.labelValue ?? null; return level === null ? "등고선 한 줄을 골랐습니다." : `고른 등고선 ${level}m.`; } 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 { if (options.surfaceModelId !== null) { // 받아 오는 간격은 프로젝트 설정 그대로(보관함에 이미 있는 파일을 쓰려는 것) — // **보이는 눈금**은 아래에서 5m 로 맞춘다. const interval = options.intervalM > 0 ? options.intervalM : 1; try { // 3D 뷰어가 쓰는 것과 **같은 파일**이다 — 보관함에 있으면 다시 내려받지 않는다. const data = await fetchCachedJson( projectId, contourAssetUrl(projectId, options.surfaceModelId, interval, 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 }; } /** 잘라 받은 도엽(`clip_lonlat`)이 노선 띠(bbox + `bandM`)를 덮나 — 안 잘렸으면 덮음(509-1). * 경위도 ↔ m 는 화면 가정 그대로(메타의 m 사각형 = 경위도 사각형). */ export function sheetCoversBand( collection: GeoJsonCollection | null, meta: VWorldMeta, line: ReadonlyArray, bandM: number, ): boolean { const clip = (collection as { clip_lonlat?: number[] } | null)?.clip_lonlat; if (!clip || line.length < 2) return true; const xs = line.map((point) => point[0]); const ys = line.map((point) => point[1]); const lon = (x: number): number => meta.lon_min + ((x - meta.x_min) / (meta.width_meters || 1)) * (meta.lon_max - meta.lon_min); const lat = (y: number): number => meta.lat_min + ((y - meta.y_min) / (meta.height_meters || 1)) * (meta.lat_max - meta.lat_min); return ( lon(Math.min(...xs) - bandM) >= clip[0] && lat(Math.min(...ys) - bandM) >= clip[1] && lon(Math.max(...xs) + bandM) <= clip[2] && lat(Math.max(...ys) + bandM) <= clip[3] ); } /** 범위 눈금(m) — 서버 `SNAP_M` 과 같음 · 노선을 조금 옮겨도 같은 주소 · 같은 저장본. */ const SHEET_SNAP_M = 50; /** 편집 중 노선 bbox(`x0,y0,x1,y1` m · 눈금 바깥 맞춤) — 서버가 띠 + 여유를 더해 자름(508-2). */ export function routeBboxParam(line: ReadonlyArray): string { const xs = line.map((point) => point[0]); const ys = line.map((point) => point[1]); const down = (value: number): number => Math.floor(value / SHEET_SNAP_M) * SHEET_SNAP_M; const up = (value: number): number => Math.ceil(value / SHEET_SNAP_M) * SHEET_SNAP_M; return [down(Math.min(...xs)), down(Math.min(...ys)), up(Math.max(...xs)), up(Math.max(...ys))] .map(String) .join(","); } export type SheetLayers = { meta: VWorldMeta; layers: ReadonlyArray; /** 지금 노선(띠를 잴 선 · 잴 때 읽음) — 없으면 잘린 그대로 씀. */ line?: () => ReadonlyArray; }; /** 노선 편집이 받는 도엽 — 노선 띠로 자른 것(509-1). `line` = 지금 노선(배경 준비 때 읽음). */ export async function fetchRouteSheets( projectId: string, line: () => ReadonlyArray, ): Promise { return { ...(await fetchDrainageLayers(projectId, () => {}, "route")), line }; } /** 지금 노선 띠를 덮는 도엽 — 덮으면 `clipped` 그대로 · 밖이면 지금 노선 범위로 잘라 다시 받음(508-2 ③). */ export async function coveringSheets( projectId: string, clipped: SheetLayers, ): Promise { const line = clipped.line?.() ?? []; const covered = clipped.layers.every(([, collection]) => sheetCoversBand(collection, clipped.meta, line, CONTOUR_BAND_M), ); if (covered) return clipped; const bbox = routeBboxParam(line); return { ...(await fetchDrainageLayers(projectId, () => {}, "route", bbox)), line: clipped.line }; } /** * 편집 창 바탕 한 벌 — 등고선(LAS 우선)과, 등고선 말고 함께 깔 도엽 레이어(하천중심선). * 도엽 컬렉션은 노선 띠로 잘라 받은 것(`fetchDrainageLayers(…, "route")` · 509-1) — 지금 노선의 * 띠가 그 범위를 벗어나면(저장 노선에서 여유 넘게 옮긴 초안) 지금 노선 범위로 다시 받는다(508-2). * `reuse` = 앞 등고선 — LAS 것이면 그대로(도엽 범위와 상관없음 · 다시 안 읽음). */ export async function loadRouteEditBackground( projectId: string, clipped: SheetLayers, options: { surfaceModelId: number | null; intervalM: number; smooth: boolean }, reuse: RouteEditContours | null = null, ): Promise<{ otherSheets: PreparedLayer[]; contours: RouteEditContours; sheets: SheetLayers }> { const drainage = await coveringSheets(projectId, clipped); 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 = reuse?.source === "las" ? reuse : await loadRouteEditContours(projectId, drainage.meta, normalizer, sheet, options); return { otherSheets, contours, sheets: drainage }; } /** * 놓은 뒤(잠잠 `QUIET_MS`) 편집 노선 띠가 받은 도엽 범위 밖이면 지금 노선 범위로 다시 받아 * 바꿔 끼움(508-2 ③) — 바꿔 끼우는 곳 = `swap` 한 자리. 받는 중 새 걸음은 끝난 뒤 다시 잼. */ export function createSheetFollow( projectId: string, current: () => SheetLayers | null, swap: (next: SheetLayers) => Promise, ): { poke: () => void; dispose: () => void } { let timer: number | undefined; let running = false; let again = false; let disposed = false; const check = async (): Promise => { const sheets = current(); if (!sheets || disposed) return; if (running) return void (again = true); running = true; try { const next = await coveringSheets(projectId, sheets); if (next !== sheets && !disposed) await swap(next); } catch { /* 못 받으면 지금 바탕 그대로 — 다음 놓기에 다시 잼. */ } finally { running = false; if (again && !disposed) ((again = false), void check()); } }; return { poke: () => { window.clearTimeout(timer); timer = window.setTimeout(() => void check(), QUIET_MS); }, dispose: () => { disposed = true; window.clearTimeout(timer); }, }; } /** 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; }