Files
Aislo/A00_Common/map/map_slope_bands.ts
T

202 lines
7.4 KiB
TypeScript

/* =============================================================================
* 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();
},
});
}