Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
* 서버 값 그림 칸 = 기울기 % 내림(0~250 · 250 = 250 % 이상 · `D03_PlanAnalysis_Layers.slope_values_png`) ·
|
||||
* 칸 v 는 가운데(v + 0.5)가 [아래, 위) 안이면 그 범위(위 = 250 이면 250 % 이상도) · 앞 범위 먼저.
|
||||
* 기본 = 지식DB 15 · 35 · 40° 의 % — `임도망_편성.md:20` · `타당성평가.md:59` · `노선_배치.md:29`.
|
||||
* 수평 바(631) = 경계 한 줄 ↔ 범위 · 칸 나눔 · 합침 · 경계 검사.
|
||||
* 부르는 곳 = `map_slope_bands.ts` · `map_legend.ts` · D03 `UI_LayerLegend`.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -96,3 +97,74 @@ export function bandRatios(counts: number[], bands: SlopeBand[]): number[] {
|
||||
});
|
||||
return out.map((n) => n / total);
|
||||
}
|
||||
|
||||
/** 수평 바 꼴(631 · 요구 537) — 경계 = [0, …, 250] 한 줄 · 칸 i = [경계 i, 경계 i+1) · 색 빈 글 = 칠 안 함. */
|
||||
export interface SlopeBar {
|
||||
edges: number[];
|
||||
colors: string[];
|
||||
}
|
||||
|
||||
/** 범위 → 바(사이 빈 곳 = 칠 안 함 칸). */
|
||||
export function bandsToBar(bands: SlopeBand[]): SlopeBar {
|
||||
const edges = [0];
|
||||
const colors: string[] = [];
|
||||
for (const band of [...bands].sort((a, b) => a.low - b.low)) {
|
||||
if (band.low > edges.at(-1)!) {
|
||||
edges.push(band.low);
|
||||
colors.push("");
|
||||
}
|
||||
edges.push(band.high);
|
||||
colors.push(band.color);
|
||||
}
|
||||
if (edges.at(-1)! < SLOPE_MAX_PCT) {
|
||||
edges.push(SLOPE_MAX_PCT);
|
||||
colors.push("");
|
||||
}
|
||||
return { edges, colors };
|
||||
}
|
||||
|
||||
/** 바 → 범위(칠 안 함 칸은 뺌). */
|
||||
export const barToBands = (bar: SlopeBar): SlopeBand[] =>
|
||||
bar.colors.flatMap((color, i) =>
|
||||
color ? [{ low: bar.edges[i], high: bar.edges[i + 1], color }] : [],
|
||||
);
|
||||
|
||||
/** 경계 고칠 거리 — 없으면 null(빈 값 · 0 < 값 < 250 밖 · 같은 값 · 거꾸로). */
|
||||
export function checkEdges(edges: number[]): string | null {
|
||||
for (let i = 1; i < edges.length - 1; i++) {
|
||||
const at = `${i}번째 경계`;
|
||||
if (!Number.isFinite(edges[i])) return `${at} — 값이 비었습니다`;
|
||||
if (edges[i] <= 0 || edges[i] >= SLOPE_MAX_PCT)
|
||||
return `${at} — 0 보다 크고 ${SLOPE_MAX_PCT} % 보다 작게`;
|
||||
if (edges[i] === edges[i - 1]) return `${at} — 옆 경계와 같은 값입니다`;
|
||||
if (edges[i] < edges[i - 1]) return `${at} — 왼쪽 경계보다 커야 합니다`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const colored = (bar: SlopeBar): number => bar.colors.filter(Boolean).length;
|
||||
|
||||
/** 칸 i 를 반으로(위 반 = 새 색) — 못 나누면 null(색 칸 SLOPE_MAX_BANDS · 너무 좁음 · 경계 틀림). */
|
||||
export function splitBar(bar: SlopeBar, i: number): SlopeBar | null {
|
||||
if (colored(bar) >= SLOPE_MAX_BANDS || checkEdges(bar.edges)) return null;
|
||||
const [low, high] = [bar.edges[i], bar.edges[i + 1]];
|
||||
const mid = Math.round((low + high) * 5) / 10;
|
||||
if (!(mid > low && mid < high)) return null;
|
||||
const color =
|
||||
SLOPE_COLORS.find((c) => !bar.colors.includes(c.color))?.color ??
|
||||
SLOPE_COLORS[colored(bar) % SLOPE_COLORS.length].color;
|
||||
return {
|
||||
edges: [...bar.edges.slice(0, i + 1), mid, ...bar.edges.slice(i + 1)],
|
||||
colors: [...bar.colors.slice(0, i + 1), color, ...bar.colors.slice(i + 1)],
|
||||
};
|
||||
}
|
||||
|
||||
/** 칸 i 를 이웃과 합침(왼쪽 · 첫 칸은 오른쪽 · 이웃 색 남음) — 칸 하나면 null. */
|
||||
export function mergeBar(bar: SlopeBar, i: number): SlopeBar | null {
|
||||
if (bar.colors.length <= 1) return null;
|
||||
const cut = i > 0 ? i : 1;
|
||||
return {
|
||||
edges: bar.edges.filter((_, k) => k !== cut),
|
||||
colors: bar.colors.filter((_, k) => k !== i),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
/* =============================================================================
|
||||
* 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 { 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 { 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";
|
||||
@@ -21,10 +21,16 @@ import {
|
||||
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";
|
||||
@@ -89,127 +95,89 @@ export const slopeGear = (onClick: () => void): HTMLButtonElement => {
|
||||
return button;
|
||||
};
|
||||
|
||||
const degText = (value: string): string => {
|
||||
const pct = Number(value);
|
||||
return value.trim() && Number.isFinite(pct) ? `≈ ${pctToDeg(pct).toFixed(1)}°` : "—";
|
||||
};
|
||||
|
||||
/** 옵션 모달 — [적용] = 막을 것 없으면 `onApply` 후 닫음. */
|
||||
/** 옵션 모달 — 수평 바(631 · 요구 537) · [적용] = 막을 것 없으면 `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 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: "범위 추가",
|
||||
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 });
|
||||
},
|
||||
onClick: step(splitBar, (i) => i + 1),
|
||||
});
|
||||
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);
|
||||
}
|
||||
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: "40rem",
|
||||
width: "960px",
|
||||
mount: (body, close, actions) => {
|
||||
fill(current);
|
||||
const reset = createButton({
|
||||
label: "기본값",
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () => fill(DEFAULT_SLOPE_BANDS),
|
||||
onClick: () => {
|
||||
bar.set(bandsToBar(DEFAULT_SLOPE_BANDS), 0);
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
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));
|
||||
refresh();
|
||||
if (problem()) return;
|
||||
onApply(barToBands(bar.get()));
|
||||
close();
|
||||
},
|
||||
});
|
||||
@@ -217,16 +185,17 @@ export function openSlopeBands(current: SlopeBand[], onApply: (bands: SlopeBand[
|
||||
actions.append(apply);
|
||||
const foot = document.createElement("div");
|
||||
foot.className = "d03-plan__row";
|
||||
foot.append(addButton, reset);
|
||||
foot.append(addButton, removeButton, reset);
|
||||
body.append(
|
||||
createText(
|
||||
`0~${SLOPE_MAX_PCT} % · 아래 포함 · 위 뺌 · 겹치면 안 됨 · 기본 = 15 · 35 · 40°(지식DB)`,
|
||||
`칸 사이 = 경계값(아래 칸 끝 = 위 칸 처음 · 아래 포함) · 칸 눌러 고르고 색 · 단계 ${SLOPE_MAX_BANDS}개까지 · 기본 = 15 · 35 · 40°(지식DB)`,
|
||||
"hint",
|
||||
),
|
||||
list,
|
||||
bar.root,
|
||||
foot,
|
||||
error,
|
||||
);
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user