Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-10-31 22:46:44 +09:00
6 changed files with 618 additions and 103 deletions
+72
View File
@@ -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),
};
}
+72 -103
View File
@@ -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();
},
});
}