Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015uTVHEAjkLLn21sdLiKAkn
202 lines
7.4 KiB
TypeScript
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();
|
|
},
|
|
});
|
|
}
|