/* ============================================================================= * map_slope_bands.ts(logic) * 공용 지도 급경사 범위(624-3 공용으로 옮김)(PLAN 603 · 요구 466 · 467 · 468) — 기울기 % 범위 · 색 · 비율 셈(화면 · 서버 없이). * 서버 값 그림 칸 = 기울기 % 내림(0~250 · 250 = 250 % 이상 · `D03_PlanAnalysis_Layers.slope_values_png`) · * 칸 v 는 가운데(v + 0.5)가 [아래, 위) 안이면 그 범위(위 = 250 이면 250 % 이상도) · 앞 범위 먼저. * 기본 = 지식DB 15 · 35 · 40° 의 % — `임도망_편성.md:20` · `타당성평가.md:59` · `노선_배치.md:29`. * 수평 바(631) = 경계 한 줄 ↔ 범위 · 칸 나눔 · 합침 · 경계 검사. * 부르는 곳 = `map_slope_bands.ts` · `map_legend.ts` · D03 `UI_LayerLegend`. * ========================================================================== */ export interface SlopeBand { /** 아래 % (포함) */ low: number; /** 위 % (뺌 · 250 = 끝까지) */ high: number; /** 그림 색 이름(`--draw-map-*`) — `SLOPE_COLORS` 중 하나 */ color: string; } export const SLOPE_MAX_PCT = 250; export const SLOPE_EMPTY = 255; export const SLOPE_MAX_BANDS = 8; /** 고를 색(이름 · 그림 색 값). */ export const SLOPE_COLORS: Array<{ name: string; color: string }> = [ { name: "노랑", color: "--draw-map-landslide-3" }, { name: "주황", color: "--draw-map-landslide-2" }, { name: "빨강", color: "--draw-map-landslide-1" }, { name: "연두", color: "--draw-map-landslide-4" }, { name: "초록", color: "--draw-map-landslide-5" }, { name: "하늘", color: "--draw-map-stream" }, { name: "파랑", color: "--draw-map-zone-water" }, { name: "보라", color: "--draw-map-zone-heritage" }, { name: "분홍", color: "--draw-map-parcel" }, ]; export const DEFAULT_SLOPE_BANDS: SlopeBand[] = [ { low: 26.8, high: 70.0, color: "--draw-map-landslide-3" }, { low: 70.0, high: 83.9, color: "--draw-map-landslide-2" }, { low: 83.9, high: SLOPE_MAX_PCT, color: "--draw-map-landslide-1" }, ]; export const pctToDeg = (pct: number): number => (Math.atan(pct / 100) * 180) / Math.PI; /** 범위 고칠 거리 — 없으면 null(빈 값 · 0~250 밖 · 아래 ≥ 위 · 겹침 · 없음 · 너무 많음). */ export function checkBands(bands: SlopeBand[]): string | null { if (!bands.length) return "범위가 하나는 있어야 합니다"; if (bands.length > SLOPE_MAX_BANDS) return `범위는 ${SLOPE_MAX_BANDS}개까지입니다`; for (const [i, band] of bands.entries()) { const at = `${i + 1}번째 범위`; if (!Number.isFinite(band.low) || !Number.isFinite(band.high)) return `${at} — 값이 비었습니다`; if (band.low < 0 || band.high > SLOPE_MAX_PCT) return `${at} — 0~${SLOPE_MAX_PCT} % 안으로`; if (band.low >= band.high) return `${at} — 아래가 위보다 작아야 합니다`; if (!SLOPE_COLORS.some((c) => c.color === band.color)) return `${at} — 색을 고르세요`; } const sorted = [...bands].sort((a, b) => a.low - b.low); for (let i = 1; i < sorted.length; i++) if (sorted[i].low < sorted[i - 1].high) return `범위가 겹칩니다 — ${sorted[i - 1].low}~${sorted[i - 1].high} % 와 ${sorted[i].low}~${sorted[i].high} %`; return null; } /** 저장 글 → 범위(깨졌거나 맞지 않으면 기본). */ export function parseBands(raw: string | null): SlopeBand[] { try { const got = JSON.parse(raw ?? "null") as unknown; if (Array.isArray(got)) { const bands = got.map((b: Partial) => ({ low: Number(b?.low), high: Number(b?.high), color: String(b?.color), })); if (!checkBands(bands)) return bands; } } catch { // 깨진 값 = 기본 } return DEFAULT_SLOPE_BANDS.map((b) => ({ ...b })); } /** 칸 값 v(0~255) → 범위 차례(없으면 −1). */ export function bandOf(v: number, bands: SlopeBand[]): number { if (v > SLOPE_MAX_PCT) return -1; const mid = v + 0.5; return bands.findIndex( (b) => b.low <= mid && (mid < b.high || (b.high >= SLOPE_MAX_PCT && v === SLOPE_MAX_PCT)), ); } /** 1 % 칸 수 → 범위별 비율(유효 칸 대비). */ export function bandRatios(counts: number[], bands: SlopeBand[]): number[] { const total = counts.reduce((sum, n) => sum + n, 0) || 1; const out = bands.map(() => 0); counts.forEach((n, v) => { const i = bandOf(v, bands); if (i >= 0) out[i] += n; }); return out.map((n) => n / total); } /** 수평 바 꼴(631 · 요구 537) — 경계 = [0, …, 250] 한 줄 · 칸 i = [경계 i, 경계 i+1) · 색 빈 글 = 칠 안 함. */ export interface SlopeBar { edges: number[]; colors: string[]; } /** 범위 → 바(사이 빈 곳 = 칠 안 함 칸). */ export function bandsToBar(bands: SlopeBand[]): SlopeBar { const edges = [0]; const colors: string[] = []; for (const band of [...bands].sort((a, b) => a.low - b.low)) { if (band.low > edges.at(-1)!) { edges.push(band.low); colors.push(""); } edges.push(band.high); colors.push(band.color); } if (edges.at(-1)! < SLOPE_MAX_PCT) { edges.push(SLOPE_MAX_PCT); colors.push(""); } return { edges, colors }; } /** 바 → 범위(칠 안 함 칸은 뺌). */ export const barToBands = (bar: SlopeBar): SlopeBand[] => bar.colors.flatMap((color, i) => color ? [{ low: bar.edges[i], high: bar.edges[i + 1], color }] : [], ); /** 경계 고칠 거리 — 없으면 null(빈 값 · 0 < 값 < 250 밖 · 같은 값 · 거꾸로). */ export function checkEdges(edges: number[]): string | null { for (let i = 1; i < edges.length - 1; i++) { const at = `${i}번째 경계`; if (!Number.isFinite(edges[i])) return `${at} — 값이 비었습니다`; if (edges[i] <= 0 || edges[i] >= SLOPE_MAX_PCT) return `${at} — 0 보다 크고 ${SLOPE_MAX_PCT} % 보다 작게`; if (edges[i] === edges[i - 1]) return `${at} — 옆 경계와 같은 값입니다`; if (edges[i] < edges[i - 1]) return `${at} — 왼쪽 경계보다 커야 합니다`; } return null; } const colored = (bar: SlopeBar): number => bar.colors.filter(Boolean).length; /** 칸 i 를 반으로(위 반 = 새 색) — 못 나누면 null(색 칸 SLOPE_MAX_BANDS · 너무 좁음 · 경계 틀림). */ export function splitBar(bar: SlopeBar, i: number): SlopeBar | null { if (colored(bar) >= SLOPE_MAX_BANDS || checkEdges(bar.edges)) return null; const [low, high] = [bar.edges[i], bar.edges[i + 1]]; const mid = Math.round((low + high) * 5) / 10; if (!(mid > low && mid < high)) return null; const color = SLOPE_COLORS.find((c) => !bar.colors.includes(c.color))?.color ?? SLOPE_COLORS[colored(bar) % SLOPE_COLORS.length].color; return { edges: [...bar.edges.slice(0, i + 1), mid, ...bar.edges.slice(i + 1)], colors: [...bar.colors.slice(0, i + 1), color, ...bar.colors.slice(i + 1)], }; } /** 칸 i 를 이웃과 합침(왼쪽 · 첫 칸은 오른쪽 · 이웃 색 남음) — 칸 하나면 null. */ export function mergeBar(bar: SlopeBar, i: number): SlopeBar | null { if (bar.colors.length <= 1) return null; const cut = i > 0 ? i : 1; return { edges: bar.edges.filter((_, k) => k !== cut), colors: bar.colors.filter((_, k) => k !== i), }; }