- 서버: 15 · 35 · 40° 단계 PNG 길을 걷고 기울기 % 값 그림 한 장(칸 = % 내림 0~250 · 빈 칸 255) + 1 % 칸 수 · 파생 저장본 판 2(열쇠 = 격자 지문) - 화면: 고른 범위대로 값 그림을 칠함(서버 왕복 없음) · 범례 비율 = 칸 수로 바로 셈 · 범례 급경사 줄 ⚙ → 모달(아래 % · 위 % · 색 · ° 환산 · 범위 추가 · 지움 · 기본값 · 빈 값 · 겹침 막음) - 기본 = 지식DB 15 · 35 · 40° 의 % 26.8 · 70.0 · 83.9 · 기억 = 화면 취향 aislo:pref:d03.slope-bands(계정) - 시험: 569_1 급경사 시험을 값 그림 · 칸 수로 고침, 581_1 은 bands 대신 counts(옛 단계 응답이 없어짐) · 새 603 시험(칸 수 합 = 유효 칸 · 기본 비율 = 옛 15 · 35 · 40° 비율) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
237 lines
8.1 KiB
TypeScript
237 lines
8.1 KiB
TypeScript
/* =============================================================================
|
|
* 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<Blob>((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<HTMLElement>(".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,
|
|
);
|
|
},
|
|
});
|
|
}
|