269 lines
10 KiB
TypeScript
269 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* D02_PlanBase_UI_Page.ts
|
|
* D02 1단계 기준 지정(요구서 345 3-2 · 389 3장 1 · PLAN 560-2) — 메인 = 카카오맵 · 좌 = 전체 시점 · 종점.
|
|
* 조작(끌기 · 지도 누름 · 경위도 입력) = sessionStorage 캐시 · [저장] · [>] = PUT 정본(서버가 TM 셈)
|
|
* · 떠남(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) = 바뀐 것 있으면 저장 뒤 · 실패면 머묾(560-4)
|
|
* · 자동저장 없음(CLAUDE.md 5장) · 캐시는 그때 서버 값(base)이 지금과 같을 때만 씀(D03 이 고친 값 우선).
|
|
* 산림청이 아니면(지정 회사 = 보기만) 끌기 · 누름 · 입력 · [저장] 없음 · 떠남 = 그냥.
|
|
* 첫 불러오기가 실패해도 지도 · 표는 세움(빈 화면 없음).
|
|
* 지도 [일반][위성](560-3) = 카카오 HYBRID · 고른 값 = 화면 취향(`aislo:pref:`) 기억.
|
|
* ========================================================================== */
|
|
|
|
import "./D02_PlanBase_UI_Style.css";
|
|
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
|
|
import {
|
|
createButton,
|
|
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 { readByKey, writeByKey } from "../A00_Common/b_page_state";
|
|
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;
|
|
start: ServerPoint | null;
|
|
end: ServerPoint | null;
|
|
}
|
|
|
|
const KOREA_CENTER: [number, number] = [127.8, 36.3];
|
|
const DIGITS = 7; // 경위도 소수 7 자리 ≈ 1 cm
|
|
const PREF_MAP_TYPE = "aislo:pref:d02.map.type";
|
|
|
|
const cacheKey = (projectId: string): string => `d02_points_${projectId}`;
|
|
|
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
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;
|
|
}
|
|
|
|
/** 서버 두 점 → [시점, 종점] */
|
|
function boundaryPoints(view: PointsView): Array<LonLat | null> {
|
|
const pick = (p: ServerPoint | null): LonLat | null =>
|
|
p ? { lon: +p.lon.toFixed(DIGITS), lat: +p.lat.toFixed(DIGITS) } : null;
|
|
return [pick(view.start), pick(view.end)];
|
|
}
|
|
|
|
export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
|
|
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
|
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,
|
|
beforeLeave: async () => !editable || !dirty() || store(),
|
|
});
|
|
|
|
let view: PointsView | null = null;
|
|
let points: Array<LonLat | null> = [null, null];
|
|
let active: number | null = null; // 지도 누름이 놓을 지점
|
|
let editable = false; // 산림청만 고침
|
|
|
|
const pointName = (i: number): string => t(i === 0 ? "D02_Start" : "D02_End");
|
|
const firstEmpty = (): number | null => {
|
|
const i = points.findIndex((p) => !p);
|
|
return i < 0 ? null : i;
|
|
};
|
|
const serverPoints = (): Array<LonLat | null> => (view ? boundaryPoints(view) : [null, null]);
|
|
const dirty = (): boolean => JSON.stringify(points) !== JSON.stringify(serverPoints());
|
|
const remember = (): void =>
|
|
sessionStorage.setItem(cacheKey(projectId), JSON.stringify({ base: serverPoints(), 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 });
|
|
},
|
|
});
|
|
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(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 [a, b] = points;
|
|
const lines: Array<Array<[number, number]>> =
|
|
a && b
|
|
? [
|
|
[
|
|
[a.lat, a.lon],
|
|
[b.lat, b.lon],
|
|
],
|
|
]
|
|
: [];
|
|
map.set(pins, lines, undefined, focus);
|
|
}
|
|
|
|
function drawTable(): void {
|
|
const rows = points.map((p, i) => {
|
|
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 = 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;
|
|
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(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,
|
|
],
|
|
});
|
|
return row;
|
|
});
|
|
table.replaceChildren(...rows);
|
|
const saved = view?.start;
|
|
info.textContent = view?.epsg
|
|
? `${t("D02_Saved_Crs")} EPSG:${view.epsg}` +
|
|
(saved ? ` · X ${saved.x.toFixed(3)} Y ${saved.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<boolean> {
|
|
showLoadingOverlay();
|
|
try {
|
|
const body = { start: points[0], end: points[1] };
|
|
adopt(
|
|
await request<PointsView>(`/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();
|
|
}
|
|
}
|
|
|
|
try {
|
|
editable = !!(await fetchSessionUser())?.is_forest_service;
|
|
save.hidden = !editable;
|
|
if (!editable) {
|
|
const mapHint = map.root.querySelector<HTMLElement>("[data-kakao-map-hint]");
|
|
pointsHint.textContent = t("D02_View_Only");
|
|
if (mapHint) mapHint.textContent = t("D02_View_Only");
|
|
}
|
|
adopt(await request<PointsView>(`/projects/${projectId}/plan/points`));
|
|
const cached = JSON.parse(sessionStorage.getItem(cacheKey(projectId)) ?? "null") as {
|
|
base?: Array<LonLat | null>;
|
|
points?: Array<LonLat | null>;
|
|
} | null;
|
|
const sameBase = JSON.stringify(cached?.base) === JSON.stringify(points);
|
|
if (editable && sameBase && cached?.points?.length === 2) points = cached.points;
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : String(error), "error");
|
|
}
|
|
active = firstEmpty();
|
|
paint();
|
|
}
|