/* ============================================================================= * map_slope_bands.ts * 공용 지도 급경사 범위 옵션(D03 · B05 · 624-3 공용으로 옮김)(PLAN 603 · 요구 466 · 467 · 468) — 범례 급경사 줄 ⚙ → 모달 * (631 · 요구 537 = 수평 바 `ui_template_range_bar` — 칸 사이 경계값 한 번 · 아래 ° 환산 · 칸 눌러 색 · 칠 안 함 칸 · * [단계 추가] = 고른 칸 반으로 · [단계 지움] = 이웃과 합침 · [기본값] · [적용] · 빈 값 · 같은 값 · 거꾸로 막음). * 기억 = 화면 취향(`aislo:pref:d03.slope-bands` · 계정에 붙음 · 프로젝트 무관). * 칠하기 = 서버 값 그림(회색 · 칸 = 기울기 % 내림) → 범위 색 그림(서버 왕복 없음 · CLAUDE.md 5장). * 범위 · 비율 셈 = `logic/map_slope_bands.ts` · 부르는 곳 = D03 `UI_Layers` · B05 `UI_RouteEdit_Layers`. * ========================================================================== */ import { createButton, createIconButton } from "@ui/ui_template_button"; import { openModal } from "@ui/ui_template_modal"; import { themeColor } from "@ui/ui_template_palette"; import { createRangeBar } from "@ui/ui_template_range_bar"; import { createText } from "@ui/ui_template_text"; import { readByKey, writeByKey } from "../b_page_state"; import { loadImage } from "./map_layer_data"; import { DEFAULT_SLOPE_BANDS, SLOPE_COLORS, SLOPE_MAX_BANDS, SLOPE_MAX_PCT, bandOf, bandsToBar, barToBands, checkBands, checkEdges, mergeBar, parseBands, pctToDeg, splitBar, type SlopeBand, type SlopeBar, } from "./logic/map_slope_bands"; const PREF_KEY = "aislo:pref:d03.slope-bands"; /** 칠한 칸 투명도(겹 투명도 0.55 는 따로). */ const ALPHA = 150; export const readSlopeBands = (): SlopeBand[] => parseBands(readByKey(PREF_KEY)); export const writeSlopeBands = (bands: SlopeBand[]): void => writeByKey(PREF_KEY, JSON.stringify(bands)); /** 값 그림 → 칸 값(빨강 자리 = 회색 값) · 받은 blob 주소는 다 읽었으니 놓음(616-4). */ export function slopeValues(image: HTMLImageElement): ImageData { const canvas = document.createElement("canvas"); canvas.width = image.naturalWidth; canvas.height = image.naturalHeight; const context = canvas.getContext("2d", { willReadFrequently: true })!; context.drawImage(image, 0, 0); if (image.src.startsWith("blob:")) URL.revokeObjectURL(image.src); return context.getImageData(0, 0, canvas.width, canvas.height); } /** 범위대로 칠한 그림(주소 = data URL · toBlob 의 한가 작업 기다림 최대 1 s 없앰 616-4 · * 부르는 쪽 `URL.revokeObjectURL` 은 그대로 둬도 됨). */ export async function paintSlope( values: ImageData, bands: SlopeBand[], ): Promise<{ url: string; image: HTMLImageElement }> { const rgb = bands.map((band) => hexRgb(themeColor(band.color, "#facc15"))); const lut = new Uint32Array(256); const view = new DataView(lut.buffer); for (let v = 0; v < 256; v++) { const i = bandOf(v, bands); if (i >= 0) view.setUint32(v * 4, ((rgb[i] << 8) | ALPHA) >>> 0, false); } const out = new ImageData(values.width, values.height); const src = values.data; const dst = new Uint32Array(out.data.buffer); for (let p = 0; p < dst.length; p++) dst[p] = lut[src[p * 4]]; const canvas = document.createElement("canvas"); canvas.width = values.width; canvas.height = values.height; canvas.getContext("2d")!.putImageData(out, 0, 0); const url = canvas.toDataURL("image/png"); return { url, image: await loadImage(url) }; } /** `#rrggbb` → 0xrrggbb(못 읽으면 노랑). */ function hexRgb(color: string): number { const hex = /^#?([0-9a-f]{6})$/i.exec(color.trim())?.[1]; return hex ? parseInt(hex, 16) : 0xfacc15; } /** 범례 급경사 줄 ⚙. */ export const slopeGear = (onClick: () => void): HTMLButtonElement => { const button = createIconButton({ icon: "settings", title: "급경사 범위 바꾸기(기울기 %)", size: 20, onClick, }); button.dataset.role = "slope-bands"; return button; }; /** 옵션 모달 — 수평 바(631 · 요구 537) · [적용] = 막을 것 없으면 `onApply` 후 닫음. */ export function openSlopeBands(current: SlopeBand[], onApply: (bands: SlopeBand[]) => void): void { const error = createText("", "error"); error.hidden = true; const bar = createRangeBar({ ...bandsToBar(current), palette: SLOPE_COLORS.map((c) => ({ value: c.color, name: c.name, color: `var(${c.color})` })), noneLabel: "칠 안 함", unit: "%", ends: ["0 %", `${SLOPE_MAX_PCT} % 이상`], hint: (pct) => `≈ ${pctToDeg(pct).toFixed(1)}°`, ariaLabel: "급경사 범위", onChange: () => refresh(), onSelect: () => refresh(), }); bar.root.dataset.role = "slope-bar"; /** 고칠 거리 — 경계 먼저 · 다음 범위(색 칸 없음 · 너무 많음). */ function problem(): string | null { const state = bar.get(); return checkEdges(state.edges) ?? checkBands(barToBands(state)); } function refresh(): void { const state = bar.get(); const why = problem(); error.lastChild!.textContent = why ?? ""; error.hidden = !why; const bad = checkEdges(state.edges) ? /(\d+)번째 경계/.exec(why ?? "")?.[1] : undefined; bar.setError(bad ? Number(bad) : undefined); addButton.disabled = !splitBar(state, bar.selected()); removeButton.disabled = !mergeBar(state, bar.selected()); } const step = (make: (state: SlopeBar, i: number) => SlopeBar | null, next: (i: number) => number) => () => { const i = bar.selected(); const got = make(bar.get(), i); if (!got) return; bar.set(got, next(i)); refresh(); }; const addButton = createButton({ label: "단계 추가", variant: "ghost", size: 28, iconStart: "plus", onClick: step(splitBar, (i) => i + 1), }); addButton.dataset.role = "slope-band-add"; addButton.title = "고른 칸을 반으로 나눔"; const removeButton = createButton({ label: "단계 지움", variant: "ghost", size: 28, iconStart: "trash", onClick: step(mergeBar, (i) => Math.max(0, i - 1)), }); removeButton.dataset.role = "slope-band-remove"; removeButton.title = "고른 칸을 이웃 칸과 합침"; openModal({ title: "급경사 범위(기울기 %)", closeLabel: "닫기", width: "960px", mount: (body, close, actions) => { const reset = createButton({ label: "기본값", variant: "ghost", size: 28, onClick: () => { bar.set(bandsToBar(DEFAULT_SLOPE_BANDS), 0); refresh(); }, }); reset.dataset.role = "slope-band-default"; const apply = createButton({ label: "적용", size: 28, onClick: () => { refresh(); if (problem()) return; onApply(barToBands(bar.get())); close(); }, }); apply.dataset.role = "slope-band-apply"; actions.append(apply); const foot = document.createElement("div"); foot.className = "d03-plan__row"; foot.append(addButton, removeButton, reset); body.append( createText( `칸 사이 = 경계값(아래 칸 끝 = 위 칸 처음 · 아래 포함) · 칸 눌러 고르고 색 · 단계 ${SLOPE_MAX_BANDS}개까지 · 기본 = 15 · 35 · 40°(지식DB)`, "hint", ), bar.root, foot, error, ); refresh(); }, }); }