/* ============================================================================= * D02_PlanBase_UI_Page.ts * D02 1단계 기준 지정(요구서 345 3-2 · PLAN 529-3) — 메인 = 카카오맵 · 좌 = 연도 지점 표. * 지점 = 경계점 목록 P0..Pn(연도 i = P(i) → P(i+1)) · 직전 종점 = 다음 시점이 모양 그대로. * 조작(끌기 · 지도 누름 · 경위도 입력) = sessionStorage 캐시 · [저장] · [>] = PUT 정본(서버가 TM 셈) * · [>] = 저장 성공 뒤 D03 · 자동저장 없음(CLAUDE.md 5장). * 산림청이 아니면(지정 회사 = 보기만) 끌기 · 누름 · 입력 · [저장] 없음 · [>] = 그냥 D03. * ========================================================================== */ import "./D02_PlanBase_UI_Style.css"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { createButton, el, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { createPanelCard } from "@ui/ui_template_collapsible"; import { editMap, type MapPin } from "@ui/ui_template_kakao_map"; import { t } from "@ui/ui_template_locale"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { navigateTo } from "../A00_Common/router"; interface LonLat { lon: number; lat: number; } interface ServerPoint extends LonLat { x: number; y: number; } interface PointsView { year_start: number; year_end: number; epsg: number | null; years: Array<{ year: number; start: ServerPoint | null; end: ServerPoint | null }>; } const YEAR_COLORS = ["#E60046", "#1F6FEB", "#1A9E55", "#D97706", "#8B5CF6", "#0891B2"]; const KOREA_CENTER: [number, number] = [127.8, 36.3]; const DIGITS = 7; // 경위도 소수 7 자리 ≈ 1 cm const cacheKey = (projectId: string): string => `d02_points_${projectId}`; 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; } /** 서버 연도 행 → 경계점 목록(연도 수 + 1) */ function boundaryPoints(view: PointsView): Array { const pick = (p: ServerPoint | null): LonLat | null => (p ? { lon: p.lon, lat: p.lat } : null); return [pick(view.years[0]?.start ?? null), ...view.years.map((y) => pick(y.end))]; } export async function renderD02PlanBase(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; const mapHost = el("div", { className: "d02-map" }); const table = el("div", { className: "d02-points" }); const info = el("p", { className: "d02-info" }); const card = createPanelCard(t("D02_Points_Title"), false); const pointsHint = el("p", { className: "d02-info", text: t("D02_Points_Hint") }); 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 }); let view: PointsView | null = null; let points: Array = []; let active: number | null = null; // 지도 누름이 놓을 지점 let editable = false; // 산림청만 고침 const years = (): number[] => (view ? view.years.map((y) => y.year) : []); const pointName = (i: number): string => { const ys = years(); if (i === 0) return `${ys[0]} ${t("D02_Start")}`; if (i === ys.length) return `${ys[i - 1]} ${t("D02_End")}`; return `${ys[i - 1]} ${t("D02_End")} · ${ys[i]} ${t("D02_Start")}`; }; const firstEmpty = (): number | null => { const i = points.findIndex((p) => !p); return i < 0 ? null : i; }; const remember = (): void => sessionStorage.setItem(cacheKey(projectId), JSON.stringify({ points })); const map = editMap({ hint: t("D02_Map_Hint"), center: () => KOREA_CENTER, onMove: (key, lon, lat) => place(Number(key), { lon, lat }), onClick: (lon, lat) => { if (!editable) return; if (active === null) return showToast(t("D02_Pick_Point"), "info"); place(active, { lon, lat }); }, }); mapHost.append(map.root); function place(index: number, at: LonLat): void { points[index] = { lon: +at.lon.toFixed(DIGITS), lat: +at.lat.toFixed(DIGITS) }; if (index === active) active = firstEmpty(); // 놓은 지점 = 다음 빈 지점으로 remember(); paint(String(index)); } /** focus = 고친 지점(보기 밖이면 그리로) · 빈 값 = 보기 그대로 */ function drawMap(focus = ""): void { const pins: MapPin[] = points.flatMap((p, i) => p ? [{ key: String(i), name: pointName(i), lon: p.lon, lat: p.lat, movable: editable }] : [], ); const lines: Array> = []; const colors: string[] = []; years().forEach((_, i) => { const a = points[i]; const b = points[i + 1]; if (!a || !b) return; lines.push([ [a.lat, a.lon], [b.lat, b.lon], ]); colors.push(YEAR_COLORS[i % YEAR_COLORS.length]); }); map.set(pins, lines, colors, focus); } function drawTable(): void { const rows = points.map((p, i) => { const lon = el("input", { className: "d02-points__input" }) as HTMLInputElement; const lat = el("input", { className: "d02-points__input" }) as HTMLInputElement; lon.type = lat.type = "number"; lon.step = lat.step = "any"; lon.placeholder = t("D02_Lon"); lat.placeholder = t("D02_Lat"); lon.value = p ? String(p.lon) : ""; lat.value = p ? String(p.lat) : ""; lon.disabled = lat.disabled = !editable; lon.dataset.point = lat.dataset.point = String(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; lon.classList.toggle("is-invalid", !ok); lat.classList.toggle("is-invalid", !ok); if (ok) place(i, at); }; lon.addEventListener("change", typed); lat.addEventListener("change", typed); const pick = createButton({ label: "📍", variant: "ghost", size: "sm", onClick: () => { active = i; drawTable(); }, }); pick.title = pick.ariaLabel = t("D02_Pick_Point"); pick.hidden = !editable; const row = el("div", { className: `d02-points__row${active === i ? " is-active" : ""}`, children: [ el("span", { className: "d02-points__name", text: pointName(i) }), pick, lon, lat, ], }); const color = YEAR_COLORS[Math.min(i, years().length - 1) % YEAR_COLORS.length]; row.style.setProperty("--d02-year-color", color); return row; }); table.replaceChildren(...rows); const saved = view?.years.find((y) => y.start); info.textContent = view?.epsg ? `${t("D02_Saved_Crs")} EPSG:${view.epsg}` + (saved?.start ? ` · ${saved.year} X ${saved.start.x.toFixed(3)} Y ${saved.start.y.toFixed(3)}` : "") : 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 { if (!view) return false; showLoadingOverlay(); try { const body = { years: years().map((year, i) => ({ year, start: points[i], end: points[i + 1] })), }; adopt( await request(`/projects/${projectId}/plan/points`, { method: "PUT", body: JSON.stringify(body), }), ); sessionStorage.removeItem(cacheKey(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(); } } // [>] = 저장 성공 뒤 D03(지형 받기 시작은 D03 몫) · 실패면 머묾 left.addEventListener("ui-step-nav", (event) => { if ((event as CustomEvent).detail !== 1) return; event.stopPropagation(); if (!editable) return navigateTo(ROUTES.D03_PLAN_ANALYSIS); void store().then((ok) => ok && navigateTo(ROUTES.D03_PLAN_ANALYSIS)); }); 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 = JSON.parse(sessionStorage.getItem(cacheKey(projectId)) ?? "null") as { points?: Array; } | null; if (editable && cached?.points?.length === points.length) points = cached.points; active = firstEmpty(); paint(); } catch (error) { table.replaceChildren( el("p", { className: "d02-info", text: error instanceof Error ? error.message : "" }), ); } }