Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_RoadList.ts
T
eomsangdonandClaude Sonnet 5.5 40cecf27a2 feat(D 예정노선): 올린 임도망 묶음 쌓기 · 목록(고르기 · 지움) · 지도 밝은 분홍 + 검은 테 (PLAN 605-1 · 605-2 · 요구 476-1)
- 서버 — 올릴 때마다 묶음 하나(`D03_PlanAnalysis/임도망도/<번호>`) · 옛 한 파일 꼴 = 묶음 legacy · 합친 값 + 묶음 줄 · 목록 GET · 묶음 DELETE · 20 묶음까지
- 화면 — 위치 칸 목록(묶음 줄 · 펼치면 선 줄 · 고름 = 지도 강조 + 맞춤 · 지움 = 물음 뒤) · 색 주황 → 밝은 분홍(`--draw-map-forest-road-file`)
- 시험 584_3 — 묶음 쌓기 · 지움 · legacy · 20 묶음 · 화면 정의

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 21:49:20 +09:00

187 lines
7.0 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_UI_RoadList.ts
* D03 「위치」 칸 올린 임도망 목록(PLAN 605-2 · 요구 476-1) — 묶음 줄(파일 이름 · 가닥 수 · 길이 · 지움) ·
* 펼치면 선 줄 · 줄 고름 = 지도 강조 + 맞춤(페이지가 함) · 지움 = 물음 뒤 그 묶음만 · 자료는 서버 한 번 읽음.
* 부르는 곳 = `D03_PlanAnalysis_UI_Site.ts`.
* ========================================================================== */
import { createIconButton } from "@ui/ui_template_button";
import { createListRow } from "@ui/ui_template_list_row";
import { askConfirm } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import {
fetchPlanForestRoads,
type PlanForestRoads,
type PlanRoadBundle,
} from "./D03_PlanAnalysis_Api";
import type { RoadPick } from "./D03_PlanAnalysis_UI_LayerDefs";
/** 한 묶음에서 줄로 펼치는 선 수 — 넘으면 「외 N가닥」(시군구 한 파일 수천 가닥이 칸을 안 밀게). */
const LINE_ROWS_MAX = 200;
/** 선 이름으로 보는 속성 이름 끝(임도명 · 노선명 · NAME). */
const NAME_KEY = /명$|name$/i;
export interface RoadListParams {
projectId: string;
/** 고름(null = 해제) · 맞춤 상자([서, 남, 동, 북] 경위도). */
onPick: (pick: RoadPick | null, bounds: number[] | null) => void;
/** 묶음 지움(서버 + 지도) — 거짓 = 못 지움(알림은 하는 쪽). */
onDelete: (bundle: string) => Promise<boolean>;
}
export interface RoadList {
root: HTMLElement;
/** 서버에서 다시 읽어 그림 — 올린 뒤 · 지운 뒤 · 처음. */
reload: () => Promise<void>;
/** 지움 단추 — 올릴 수 있는 사람만. */
setEditable: (editable: boolean) => void;
}
const km = (m: number): string =>
m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m`;
/** GeoJSON 좌표 → [서, 남, 동, 북]. */
function boxOf(coordinates: unknown, box = [Infinity, Infinity, -Infinity, -Infinity]): number[] {
if (Array.isArray(coordinates) && typeof coordinates[0] === "number") {
const [x, y] = coordinates as number[];
box[0] = Math.min(box[0], x);
box[1] = Math.min(box[1], y);
box[2] = Math.max(box[2], x);
box[3] = Math.max(box[3], y);
} else if (Array.isArray(coordinates)) for (const part of coordinates) boxOf(part, box);
return box;
}
export function createRoadList(params: RoadListParams): RoadList {
const root = document.createElement("div");
root.className = "d03-plan__pieces";
root.hidden = true;
let info: PlanForestRoads | null = null;
let picked: RoadPick | null = null;
let editable = true;
const open = new Set<string>();
let turn = 0; // 옛 답 버림
const isControl = (event: Event): boolean =>
!!(event.target as Element).closest("input, select, button, .ui-list-row__trailing");
const pickable = (row: HTMLElement, onPick: () => void): HTMLElement => {
row.tabIndex = 0;
row.addEventListener("click", (event) => isControl(event) || onPick());
row.addEventListener("keydown", (event) => {
if (event.key === "Enter" && event.target === row) onPick();
});
return row;
};
function choose(pick: RoadPick, bounds: number[] | null): void {
const same = picked?.bundle === pick.bundle && picked.line === pick.line;
picked = same ? null : pick;
params.onPick(picked, same ? null : bounds);
render();
}
function lineRows(bundle: PlanRoadBundle): HTMLElement[] {
const features = (info?.collection.features ?? []).filter(
(feature) => feature.properties?.["__bundle"] === bundle.id,
);
const rows = features.slice(0, LINE_ROWS_MAX).map((feature, index) => {
const props = feature.properties ?? {};
const line = Number(props["__line"] ?? index);
const key = Object.keys(props).find((k) => NAME_KEY.test(k) && typeof props[k] === "string");
const row = createListRow({
label: (key && String(props[key]).trim()) || `선 ${line + 1}`,
sub: km(Number(props["length_m"] ?? 0)),
size: 28,
active: picked?.bundle === bundle.id && picked.line === line,
});
row.dataset.line = String(line);
return pickable(row, () =>
choose(
{ bundle: bundle.id, line },
boxOf((feature.geometry as { coordinates?: unknown })?.coordinates),
),
);
});
if (features.length > LINE_ROWS_MAX)
rows.push(createText(`외 ${features.length - LINE_ROWS_MAX}가닥`, "muted", 12));
return rows;
}
function render(): void {
const bundles = info?.bundles ?? [];
root.hidden = !bundles.length;
if (!bundles.length) return void root.replaceChildren();
const rows: HTMLElement[] = [createText(`올린 임도망 ${bundles.length}묶음`, "muted", 12)];
for (const bundle of bundles) {
const shown = open.has(bundle.id);
const fold = createIconButton({
icon: shown ? "chevronDown" : "chevronRight",
title: shown ? "선 접기" : "선 펼치기",
size: 24,
onClick: () => {
if (!open.delete(bundle.id)) open.add(bundle.id);
render();
},
});
fold.dataset.role = "road-fold";
const drop = createIconButton({
icon: "trash",
title: "이 묶음 지움",
size: 24,
onClick: async () => {
const ok = await askConfirm({
message: `올린 임도망 「${bundle.name}」 묶음을 지울까요? 지도에서도 빠집니다.`,
okLabel: "지움",
danger: true,
});
if (!ok || !(await params.onDelete(bundle.id))) return;
open.delete(bundle.id);
if (picked?.bundle === bundle.id) picked = null;
await reload();
},
});
drop.dataset.role = "road-drop";
drop.hidden = !editable;
const tail = document.createElement("span");
tail.className = "d03-plan__line-tail";
tail.append(fold, drop);
const head = createListRow({
label: bundle.name,
sub: bundle.count
? `${bundle.count}가닥 · ${km(bundle.length_m)}`
: `범위 안 선 없음 (파일 ${bundle.total}가닥)`,
size: 32,
trailing: tail,
title: bundle.files.join(" · "),
active: picked?.bundle === bundle.id && picked.line === undefined,
});
head.dataset.bundle = bundle.id;
rows.push(pickable(head, () => choose({ bundle: bundle.id }, bundle.bounds)));
if (shown) rows.push(...lineRows(bundle));
}
root.replaceChildren(...rows);
}
async function reload(): Promise<void> {
const at = ++turn;
const got = await fetchPlanForestRoads(params.projectId).catch(() => null); // 올린 적 없음 = 404
if (at !== turn) return;
info = got;
if (picked && !got?.bundles.some((b) => b.id === picked?.bundle)) {
picked = null;
params.onPick(null, null);
}
render();
}
void reload();
return {
root,
reload,
setEditable(next) {
editable = next;
render();
},
};
}