/* ============================================================================= * D03_PlanAnalysis_UI_SlopeBands.ts * D03 급경사 범위 옵션(PLAN 603 · 요구 466 · 467 · 468) — 범례 급경사 줄 ⚙ → 모달 * (범위 줄 = 아래 % · 위 % · 색 · 지움 · 칸 옆 ° 환산 · [범위 추가] · [기본값] · [적용] · 빈 값 · 겹침 막음). * 기억 = 화면 취향(`aislo:pref:d03.slope-bands` · 계정에 붙음 · 프로젝트 무관). * 칠하기 = 서버 값 그림(회색 · 칸 = 기울기 % 내림) → 범위 색 그림(서버 왕복 없음 · CLAUDE.md 5장). * 범위 · 비율 셈 = `logic/D03_PlanAnalysis_SlopeBands.ts` · 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`. * ========================================================================== */ import { createButton, createIconButton } from "@ui/ui_template_button"; import { createInputField, createSelectField } from "@ui/ui_template_input"; import { createLegend } from "@ui/ui_template_flow_legend"; import { openModal } from "@ui/ui_template_modal"; import { themeColor } from "@ui/ui_template_palette"; import { createText } from "@ui/ui_template_text"; import { readByKey, writeByKey } from "../A00_Common/b_page_state"; import { loadImage } from "./D03_PlanAnalysis_UI_LayerDefs"; import { DEFAULT_SLOPE_BANDS, SLOPE_COLORS, SLOPE_MAX_BANDS, SLOPE_MAX_PCT, bandOf, checkBands, parseBands, pctToDeg, type SlopeBand, } from "./logic/D03_PlanAnalysis_SlopeBands"; 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)); /** 값 그림 → 칸 값(빨강 자리 = 회색 값). */ 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); return context.getImageData(0, 0, canvas.width, canvas.height); } /** 범위대로 칠한 그림(주소 = blob · 다 쓰면 부르는 쪽이 `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 blob = await new Promise((done, fail) => canvas.toBlob( (got) => (got ? done(got) : fail(new Error("그림을 만들지 못했습니다"))), "image/png", ), ); const url = URL.createObjectURL(blob); 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; }; const degText = (value: string): string => { const pct = Number(value); return value.trim() && Number.isFinite(pct) ? `≈ ${pctToDeg(pct).toFixed(1)}°` : "—"; }; /** 옵션 모달 — [적용] = 막을 것 없으면 `onApply` 후 닫음. */ export function openSlopeBands(current: SlopeBand[], onApply: (bands: SlopeBand[]) => void): void { let rows: Array<{ root: HTMLElement; read: () => SlopeBand }> = []; const list = document.createElement("div"); const error = createText("", "error"); error.hidden = true; const addButton = createButton({ label: "범위 추가", variant: "ghost", size: 28, iconStart: "plus", onClick: () => { const last = rows.at(-1)?.read(); const low = last && Number.isFinite(last.high) ? Math.min(last.high, SLOPE_MAX_PCT - 1) : 0; const color = SLOPE_COLORS[rows.length % SLOPE_COLORS.length].color; add({ low, high: SLOPE_MAX_PCT, color }); }, }); addButton.dataset.role = "slope-band-add"; function refresh(): void { addButton.disabled = rows.length >= SLOPE_MAX_BANDS; error.hidden = true; } function add(band: SlopeBand): void { const field = (label: string, value: number) => { const got = createInputField({ label, type: "number", unit: "%", min: 0, max: SLOPE_MAX_PCT, size: 28, value: Number.isFinite(value) ? String(value) : "", }); const deg = createText(degText(got.input.value), "muted"); got.input.addEventListener("input", () => { deg.textContent = degText(got.input.value); refresh(); }); got.root.append(deg); return got; }; const low = field("아래", band.low); const high = field("위", band.high); const color = createSelectField({ label: "색", size: 28, options: SLOPE_COLORS.map((c) => ({ value: c.color, text: c.name })), value: band.color, }); const chip = createLegend([{ label: "", color: `var(${band.color})` }]); color.select.addEventListener("change", () => chip .querySelector(".ui-legend__mark")! .style.setProperty("--legend-color", `var(${color.select.value})`), ); const root = document.createElement("div"); root.className = "d03-plan__row"; root.dataset.role = "slope-band"; const entry = { root, read: (): SlopeBand => ({ low: low.input.value.trim() === "" ? NaN : Number(low.input.value), high: high.input.value.trim() === "" ? NaN : Number(high.input.value), color: color.select.value, }), }; const remove = createIconButton({ icon: "trash", title: "이 범위 지움", size: 28, onClick: () => { rows = rows.filter((row) => row !== entry); root.remove(); refresh(); }, }); root.append(low.root, high.root, color.root, chip, remove); rows.push(entry); list.append(root); refresh(); } function fill(bands: SlopeBand[]): void { rows = []; list.replaceChildren(); bands.forEach(add); } openModal({ title: "급경사 범위(기울기 %)", closeLabel: "닫기", width: "40rem", mount: (body, close, actions) => { fill(current); const reset = createButton({ label: "기본값", variant: "ghost", size: 28, onClick: () => fill(DEFAULT_SLOPE_BANDS), }); reset.dataset.role = "slope-band-default"; const apply = createButton({ label: "적용", size: 28, onClick: () => { const bands = rows.map((row) => row.read()); const why = checkBands(bands); if (why) { error.lastChild!.textContent = why; error.hidden = false; return; } onApply(bands.sort((a, b) => a.low - b.low)); close(); }, }); apply.dataset.role = "slope-band-apply"; actions.append(apply); const foot = document.createElement("div"); foot.className = "d03-plan__row"; foot.append(addButton, reset); body.append( createText( `0~${SLOPE_MAX_PCT} % · 아래 포함 · 위 뺌 · 겹치면 안 됨 · 기본 = 15 · 35 · 40°(지식DB)`, "hint", ), list, foot, error, ); }, }); }