Files
Aislo/A00_Common/map/logic/map_slope_bands.ts
T

171 lines
7.2 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.
/* =============================================================================
* 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<SlopeBand>) => ({
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),
};
}