/* ============================================================================= * D02_PlanBase_UI_Page.ts * D02 1단계 기준 지정(요구서 345 3-2 · 389 3장 1 · 440 1 · PLAN 560-2 · 579-2) — 메인 = 카카오맵 · * 좌 = 시점 목록 · 종점 목록(짝 없이 따로 · 최소 한 줄씩 · 줄마다 🗑 · [+ 시점] [+ 종점]) · 점 잇는 선 없음. * 조작(끌기 · 지도 누름 · 경위도 입력) = 캐시 · [저장] · [>] = PUT 정본(서버가 TM 셈) * · 떠남(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) = 바뀐 것 있으면 공용 물음 [취소][폐기][저장](572-3) * · 자동저장 없음(CLAUDE.md 5장) · 캐시는 그때 서버 값(base)이 지금과 같을 때만 씀(D03 이 고친 값 우선). * 산림청이 아니면(지정 회사 = 보기만) 끌기 · 누름 · 입력 · [저장] 없음 · 떠남 = 그냥. * 캐시 = 등록표 `d02-points`(프로젝트 칸 · 떠남 비우기에 걸림). * 첫 불러오기가 실패해도 지도 · 표는 세움(빈 화면 없음). * 지도 [일반][위성](560-3) = 카카오 HYBRID · 고른 값 = 화면 취향(`aislo:pref:`) 기억. * ========================================================================== */ import "./D02_PlanBase_UI_Style.css"; import { API_BASE_URL, ROUTES } from "@config/config_frontend"; import { createButton, createIconButton, createInputField, el, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { createPanelCard } from "@ui/ui_template_collapsible"; import { createText } from "@ui/ui_template_text"; import { editMap, type MapPin, type MapType } from "@ui/ui_template_kakao_map"; import { createSegment } from "@ui/ui_template_segment"; import { t } from "@ui/ui_template_locale"; import { clearState, readByKey, readState, writeByKey, writeState, } from "../A00_Common/b_page_state"; import { currentProjectId } from "../A00_Common/b_project_switch"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; interface LonLat { lon: number; lat: number; } interface ServerPoint extends LonLat { x: number; y: number; } interface PointsView { epsg: number | null; starts: ServerPoint[]; ends: ServerPoint[]; } type Side = "starts" | "ends"; type Points = Record>; const KOREA_CENTER: [number, number] = [127.8, 36.3]; const DIGITS = 7; // 경위도 소수 7 자리 ≈ 1 cm const PREF_MAP_TYPE = "aislo:pref:d02.map.type"; async function request(path: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include", headers: { "Content-Type": "application/json" }, ...init, }); const data = (await response.json().catch(() => ({}))) as { detail?: unknown }; if (!response.ok) throw new Error(typeof data.detail === "string" ? data.detail : `HTTP ${response.status}`); return data as T; } const SIDES: Side[] = ["starts", "ends"]; /** 서버 목록 → 표 줄(빈 목록 = 빈 줄 하나) */ function boundaryPoints(view: PointsView | null): Points { const pick = (list: ServerPoint[] = []): Array => list.length ? list.map((p) => ({ lon: +p.lon.toFixed(DIGITS), lat: +p.lat.toFixed(DIGITS) })) : [null]; return { starts: pick(view?.starts), ends: pick(view?.ends) }; } export async function renderD02PlanBase(root: HTMLElement): Promise { const projectId = currentProjectId(); const mapHost = el("div", { className: "d02-map" }); const table = el("div", { className: "d02-points" }); const info = createText("", "muted"); info.classList.add("d02-info"); const card = createPanelCard(t("D02_Points_Title"), false); const pointsHint = createText(t("D02_Points_Hint"), "muted"); pointsHint.classList.add("d02-info"); card.append(pointsHint, table, info); const save = createButton({ label: t("D02_Save"), variant: "filled", onClick: () => void store(), }); const actions = el("div", { className: "ui-sidebar-actions", children: [save] }); const left = el("div", { className: "ui-sidebar-panel d02-panel", children: [card, actions] }); mountGroupStep(root, ROUTES.D02_PLAN_BASE, { mainContent: mapHost, leftPanel: left, dirty: () => editable && dirty(), save: () => store(), discard: () => clearState("d02-points", projectId), }); let view: PointsView | null = null; let points: Points = boundaryPoints(null); let active: string | null = null; // 지도 누름이 놓을 지점(`starts:0` 꼴) let editable = false; // 산림청만 고침 const keyOf = (side: Side, i: number): string => `${side}:${i}`; const fromKey = (key: string): [Side, number] => { const [side, i] = key.split(":"); return [side as Side, Number(i)]; }; const pointName = (side: Side, i: number): string => `${t(side === "starts" ? "D02_Start" : "D02_End")} ${i + 1}`; const firstEmpty = (): string | null => { for (const side of SIDES) { const i = points[side].findIndex((p) => !p); if (i >= 0) return keyOf(side, i); } return null; }; const serverPoints = (): Points => boundaryPoints(view); const dirty = (): boolean => JSON.stringify(points) !== JSON.stringify(serverPoints()); const remember = (): void => writeState("d02-points", { base: serverPoints(), points }, projectId); const map = editMap({ hint: t("D02_Map_Hint"), center: () => KOREA_CENTER, onMove: (key, lon, lat) => place(key, { lon, lat }), onClick: (lon, lat) => { if (!editable) return; if (active === null) return showToast(t("D02_Pick_Point"), "info"); place(active, { lon, lat }); }, }); const mapType = (readByKey(PREF_MAP_TYPE) === "HYBRID" ? "HYBRID" : "ROADMAP") as MapType; map.setType(mapType); const typePick = createSegment({ options: [ { value: "ROADMAP", label: t("D02_Map_Normal") }, { value: "HYBRID", label: t("D02_Map_Satellite") }, ], value: mapType, ariaLabel: t("D02_Map_Type"), onChange: (value) => { map.setType(value as MapType); writeByKey(PREF_MAP_TYPE, value); }, }); typePick.root.classList.add("d02-map__type"); mapHost.append(map.root, typePick.root); function place(key: string, at: LonLat): void { const [side, i] = fromKey(key); points[side][i] = { lon: +at.lon.toFixed(DIGITS), lat: +at.lat.toFixed(DIGITS) }; if (key === active) active = firstEmpty(); // 놓은 지점 = 다음 빈 지점으로 remember(); paint(key); } /** [+ 시점] · [+ 종점] — 빈 줄을 더하고 지도 누름이 거기 놓음. */ function addRow(side: Side): void { points[side].push(null); active = keyOf(side, points[side].length - 1); remember(); paint(); } /** 🗑 — 줄을 지움(마지막 한 줄이면 비움 · 최소 한 줄). */ function removeRow(side: Side, i: number): void { if (points[side].length > 1) points[side].splice(i, 1); else points[side][0] = null; active = firstEmpty(); remember(); paint(); } /** focus = 고친 지점(보기 밖이면 그리로) · 빈 값 = 보기 그대로 · 점 잇는 선 없음(요구 440 1). */ function drawMap(focus = ""): void { const pins: MapPin[] = SIDES.flatMap((side) => points[side].flatMap((p, i) => p ? [ { key: keyOf(side, i), name: pointName(side, i), lon: p.lon, lat: p.lat, movable: editable, }, ] : [], ), ); map.set(pins, [], undefined, focus); } function pointRow(side: Side, p: LonLat | null, i: number): HTMLElement { const lon = createInputField({ type: "number", placeholder: t("D02_Lon"), size: 24 }).input; const lat = createInputField({ type: "number", placeholder: t("D02_Lat"), size: 24 }).input; lon.classList.add("d02-points__input"); lat.classList.add("d02-points__input"); lon.step = lat.step = "any"; lon.value = p ? String(p.lon) : ""; lat.value = p ? String(p.lat) : ""; lon.disabled = lat.disabled = !editable; lon.dataset.point = lat.dataset.point = keyOf(side, i); lon.dataset.axis = "lon"; lat.dataset.axis = "lat"; const typed = (): void => { const at = { lon: Number.parseFloat(lon.value), lat: Number.parseFloat(lat.value) }; const ok = at.lon >= 120 && at.lon <= 135 && at.lat >= 30 && at.lat <= 45; for (const box of [lon, lat]) { box.classList.toggle("is-error", !ok); if (ok) box.removeAttribute("aria-invalid"); else box.setAttribute("aria-invalid", "true"); } if (ok) place(keyOf(side, i), at); }; lon.addEventListener("change", typed); lat.addEventListener("change", typed); const pick = createButton({ label: "📍", variant: "ghost", size: "sm", onClick: () => { active = keyOf(side, i); drawTable(); }, }); pick.title = pick.ariaLabel = t("D02_Pick_Point"); pick.hidden = !editable; const drop = createIconButton({ icon: "trash", title: t("D02_Remove_Point"), size: 24, onClick: () => removeRow(side, i), }); drop.hidden = !editable; const row = el("div", { className: `d02-points__row${active === keyOf(side, i) ? " is-active" : ""}`, children: [ el("span", { className: "d02-points__name", text: pointName(side, i) }), pick, lon, lat, drop, ], }); return row; } function drawTable(): void { const rows = SIDES.flatMap((side) => { const add = createButton({ label: t(side === "starts" ? "D02_Add_Start" : "D02_Add_End"), iconStart: "plus", variant: "ghost", size: 24, onClick: () => addRow(side), }); add.hidden = !editable; return [...points[side].map((p, i) => pointRow(side, p, i)), el("div", { children: [add] })]; }); table.replaceChildren(...rows); const count = (side: Side): number => view?.[side].length ?? 0; info.textContent = view?.epsg ? `${t("D02_Start")} ${count("starts")} · ${t("D02_End")} ${count("ends")} · ` + `${t("D02_Saved_Crs")} EPSG:${view.epsg}` : t("D02_Not_Saved"); } function paint(focus = ""): void { drawTable(); drawMap(focus); } function adopt(next: PointsView): void { view = next; points = boundaryPoints(next); } async function store(): Promise { showLoadingOverlay(); try { const body = { starts: points.starts.filter(Boolean), ends: points.ends.filter(Boolean) }; adopt( await request(`/projects/${projectId}/plan/points`, { method: "PUT", body: JSON.stringify(body), }), ); clearState("d02-points", projectId); active = firstEmpty(); paint(); showToast(t("D02_Saved"), "success"); return true; } catch (error) { showToast(error instanceof Error ? error.message : String(error), "error"); return false; } finally { hideLoadingOverlay(); } } try { editable = !!(await fetchSessionUser())?.is_forest_service; save.hidden = !editable; if (!editable) { const mapHint = map.root.querySelector("[data-kakao-map-hint]"); pointsHint.textContent = t("D02_View_Only"); if (mapHint) mapHint.textContent = t("D02_View_Only"); } adopt(await request(`/projects/${projectId}/plan/points`)); const cached = readState<{ base?: Points; points?: Points }>("d02-points", projectId); const sameBase = JSON.stringify(cached?.base) === JSON.stringify(points); // 옛 캐시(두 칸 배열)는 꼴이 달라 버림 if (editable && sameBase && cached?.points?.starts?.length && cached.points.ends?.length) points = cached.points; } catch (error) { showToast(error instanceof Error ? error.message : String(error), "error"); } active = firstEmpty(); paint(); }