feat(529-3): D02 기준 지정 화면 — 카카오맵 연도 지점 · 좌 연도 표 · [>] 저장 뒤 D03
- D02_PlanBase_UI_Page: 경계점 P0..Pn(연도 i = P(i) → P(i+1)) · 지도 누름 = 고른 지점 놓기 · 표지 끌기 · 경위도 입력 → 표지 · 연도마다 선 색 · 조작 = sessionStorage 캐시 · [저장] · [>] = PUT 정본 - 공용 editMap set(): 셋째 인자 colors(선마다 색) · 넷째 인자 focus(고친 표지만 보기 밖이면 따라감) — 안 주면 지금 규칙(B08 그대로) - D02 길 = 새 페이지 · 문구 D02_* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
/* =============================================================================
|
||||
* 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장).
|
||||
* ========================================================================== */
|
||||
|
||||
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 { 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<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;
|
||||
}
|
||||
|
||||
/** 서버 연도 행 → 경계점 목록(연도 수 + 1) */
|
||||
function boundaryPoints(view: PointsView): Array<LonLat | null> {
|
||||
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<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 = el("p", { className: "d02-info" });
|
||||
const card = createPanelCard(t("D02_Points_Title"), false);
|
||||
card.append(el("p", { className: "d02-info", text: t("D02_Points_Hint") }), 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<LonLat | null> = [];
|
||||
let active: number | null = null; // 지도 누름이 놓을 지점
|
||||
|
||||
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 (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: true }] : [],
|
||||
);
|
||||
const lines: Array<Array<[number, number]>> = [];
|
||||
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.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");
|
||||
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<boolean> {
|
||||
if (!view) return false;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const body = {
|
||||
years: years().map((year, i) => ({ year, start: points[i], end: points[i + 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();
|
||||
}
|
||||
}
|
||||
|
||||
// [>] = 저장 성공 뒤 D03(지형 받기 시작은 D03 몫) · 실패면 머묾
|
||||
left.addEventListener("ui-step-nav", (event) => {
|
||||
if ((event as CustomEvent<number>).detail !== 1) return;
|
||||
event.stopPropagation();
|
||||
void store().then((ok) => ok && navigateTo(ROUTES.D03_PLAN_ANALYSIS));
|
||||
});
|
||||
|
||||
try {
|
||||
adopt(await request<PointsView>(`/projects/${projectId}/plan/points`));
|
||||
const cached = JSON.parse(sessionStorage.getItem(cacheKey(projectId)) ?? "null") as {
|
||||
points?: Array<LonLat | null>;
|
||||
} | null;
|
||||
if (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 : "" }),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user