- 서버 — 올릴 때마다 묶음 하나(`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>
187 lines
7.0 KiB
TypeScript
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();
|
|
},
|
|
};
|
|
}
|