Files
Aislo/D02_PlanBase/D02_PlanBase_UI_Page.ts
eomsangdonandClaude Opus 5.5 f5645c256d feat(D02·D03): 예정노선 지점 하나 · 차례 색 · 노선분석 핀 · 카카오 지도 마우스 설정 따름 (PLAN 585-1 · 요구 445 A1~A3)
- 기준지정 시점 · 종점 목록 → 「지점 목록」 하나 · [+ 지점] · 지점마다 차례 색 여섯 돌림(줄 왼 띠 · 지도 이름표 바탕)
- 저장 꼴 plan_base_json {epsg, points} · 옛 starts + ends · start · end 는 읽을 때 이어 붙임(옮김 없음) · 상한 40
- 노선분석 지점 = 핀 + 「지점 N」 · 기준지정과 같은 색 · 겹 목록 맨 끝(늘 맨 위) · 지점 누름 = 새 본선(No.0 = 그 지점) · 선 놓인 지점 = 갈래
- 공용 카카오 지도 = 휠 설정 방향 한 단(마우스 자리 고정) · 가운데 끌기 옮김 — 기준지정 · 지형 범위 창 · B08 이정표 같이
- 시험: 529-3 · 530-2 새 꼴 · 585-1 새 지킴 시험 · 낡은 B08 가짜 카카오(바탕 종류 · 사건 받기 없음) 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:40:49 +09:00

315 lines
11 KiB
TypeScript

/* =============================================================================
* D02_PlanBase_UI_Page.ts
* D02 1단계 기준 지정(요구서 345 3-2 · 389 3장 1 · 440 1 · 445 A2 · PLAN 560-2 · 579-2 · 585-1) — 메인 = 카카오맵 ·
* 좌 = 지점 목록 하나(최소 한 줄 · 줄마다 🗑 · [+ 지점] · 지점마다 차례 색 `pointColor` = 노선분석 핀과 같음) ·
* 점 잇는 선 없음.
* 조작(끌기 · 지도 누름 · 경위도 입력) = 캐시 · [저장] · [>] = 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 { pointColor } from "@ui/ui_template_palette";
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;
points: ServerPoint[];
}
type Points = Array<LonLat | 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";
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 | null): Points {
const list = view?.points ?? [];
return list.length
? list.map((p) => ({ lon: +p.lon.toFixed(DIGITS), lat: +p.lat.toFixed(DIGITS) }))
: [null];
}
export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
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: number | null = null; // 지도 누름이 놓을 지점 차례
let editable = false; // 산림청만 고침
const pointName = (i: number): string => `${t("D02_Point")} ${i + 1}`;
const firstEmpty = (): number | null => {
const i = points.findIndex((p) => !p);
return i >= 0 ? i : 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(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(i: number, at: LonLat): void {
points[i] = { lon: +at.lon.toFixed(DIGITS), lat: +at.lat.toFixed(DIGITS) };
if (i === active) active = firstEmpty(); // 놓은 지점 = 다음 빈 지점으로
remember();
paint(String(i));
}
/** [+ 지점] — 빈 줄을 더하고 지도 누름이 거기 놓음. */
function addRow(): void {
points.push(null);
active = points.length - 1;
remember();
paint();
}
/** 🗑 — 줄을 지움(마지막 한 줄이면 비움 · 최소 한 줄 · 뒤 번호 · 색이 한 칸씩 당겨짐). */
function removeRow(i: number): void {
if (points.length > 1) points.splice(i, 1);
else points[0] = null;
active = firstEmpty();
remember();
paint();
}
/** focus = 고친 지점(보기 밖이면 그리로) · 빈 값 = 보기 그대로 · 점 잇는 선 없음(요구 440 1). */
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,
color: pointColor(i),
},
]
: [],
);
map.set(pins, [], undefined, focus);
}
function pointRow(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 = 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 drop = createIconButton({
icon: "trash",
title: t("D02_Remove_Point"),
size: 24,
onClick: () => removeRow(i),
});
drop.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,
drop,
],
});
row.style.borderLeftColor = pointColor(i);
return row;
}
function drawTable(): void {
const add = createButton({
label: t("D02_Add_Point"),
iconStart: "plus",
variant: "ghost",
size: 24,
onClick: () => addRow(),
});
add.hidden = !editable;
table.replaceChildren(...points.map((p, i) => pointRow(p, i)), el("div", { children: [add] }));
info.textContent = view?.epsg
? `${t("D02_Point")} ${view.points.length} · ${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<boolean> {
showLoadingOverlay();
try {
const body = { points: points.filter(Boolean) };
adopt(
await request<PointsView>(`/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<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 = readState<{ base?: Points; points?: Points }>("d02-points", projectId);
const sameBase = JSON.stringify(cached?.base) === JSON.stringify(points);
// 옛 캐시(시점 · 종점 두 목록)는 꼴이 달라 버림
if (editable && sameBase && Array.isArray(cached?.points) && cached.points.length)
points = cached.points;
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
}
active = firstEmpty();
paint();
}