Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts
T
eomsangdonandClaude Opus 5.5 edc20df849 feat(D 예정노선): 급경사 범위 옵션 모달 — 기울기 % 값 그림 · 1 % 칸 수 · 화면 칠하기 · 범례 ⚙ (PLAN 603 · 요구 466~468)
- 서버: 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>
2026-10-30 15:12:27 +09:00

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,
);
},
});
}