/* ============================================================================= * 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; } export interface RoadList { root: HTMLElement; /** 서버에서 다시 읽어 그림 — 올린 뒤 · 지운 뒤 · 처음. */ reload: () => Promise; /** 지움 단추 — 올릴 수 있는 사람만. */ 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(); 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 { 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(); }, }; }