feat(D02): 기준지정 시점 목록 · 종점 목록 화면 — [+ 시점] [+ 종점] · 줄마다 지움 · 점 잇는 붉은 선 걷음 (PLAN 579-2 · 요구 440)
- 표 = 시점 묶음 · 종점 묶음(짝 없이 따로 · 최소 한 줄 · 마지막 줄 지움 = 비움) · 표지 이름 「시점 1」「종점 2」
- [+] = 빈 줄 더하고 지도 누름이 거기 놓음 · 캐시 꼴 {base, points: {starts, ends}} · 옛 두 칸 캐시는 버림
- 정보 줄 = 시점 n · 종점 m · 저장 좌표계
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* D02_PlanBase_UI_Page.ts
|
||||
* D02 1단계 기준 지정(요구서 345 3-2 · 389 3장 1 · PLAN 560-2) — 메인 = 카카오맵 · 좌 = 전체 시점 · 종점.
|
||||
* D02 1단계 기준 지정(요구서 345 3-2 · 389 3장 1 · 440 1 · PLAN 560-2 · 579-2) — 메인 = 카카오맵 ·
|
||||
* 좌 = 시점 목록 · 종점 목록(짝 없이 따로 · 최소 한 줄씩 · 줄마다 🗑 · [+ 시점] [+ 종점]) · 점 잇는 선 없음.
|
||||
* 조작(끌기 · 지도 누름 · 경위도 입력) = 캐시 · [저장] · [>] = PUT 정본(서버가 TM 셈)
|
||||
* · 떠남(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) = 바뀐 것 있으면 공용 물음 [취소][폐기][저장](572-3)
|
||||
* · 자동저장 없음(CLAUDE.md 5장) · 캐시는 그때 서버 값(base)이 지금과 같을 때만 씀(D03 이 고친 값 우선).
|
||||
@@ -14,6 +15,7 @@ import "./D02_PlanBase_UI_Style.css";
|
||||
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
|
||||
import {
|
||||
createButton,
|
||||
createIconButton,
|
||||
createInputField,
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
@@ -46,9 +48,11 @@ interface ServerPoint extends LonLat {
|
||||
}
|
||||
interface PointsView {
|
||||
epsg: number | null;
|
||||
start: ServerPoint | null;
|
||||
end: ServerPoint | null;
|
||||
starts: ServerPoint[];
|
||||
ends: ServerPoint[];
|
||||
}
|
||||
type Side = "starts" | "ends";
|
||||
type Points = Record<Side, Array<LonLat | null>>;
|
||||
|
||||
const KOREA_CENTER: [number, number] = [127.8, 36.3];
|
||||
const DIGITS = 7; // 경위도 소수 7 자리 ≈ 1 cm
|
||||
@@ -66,11 +70,15 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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)];
|
||||
const SIDES: Side[] = ["starts", "ends"];
|
||||
|
||||
/** 서버 목록 → 표 줄(빈 목록 = 빈 줄 하나) */
|
||||
function boundaryPoints(view: PointsView | null): Points {
|
||||
const pick = (list: ServerPoint[] = []): Array<LonLat | null> =>
|
||||
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<void> {
|
||||
@@ -99,16 +107,25 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
|
||||
});
|
||||
|
||||
let view: PointsView | null = null;
|
||||
let points: Array<LonLat | null> = [null, null];
|
||||
let active: number | null = null; // 지도 누름이 놓을 지점
|
||||
let points: Points = boundaryPoints(null);
|
||||
let active: string | null = null; // 지도 누름이 놓을 지점(`starts:0` 꼴)
|
||||
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 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 serverPoints = (): Array<LonLat | null> => (view ? boundaryPoints(view) : [null, null]);
|
||||
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);
|
||||
@@ -116,7 +133,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
|
||||
const map = editMap({
|
||||
hint: t("D02_Map_Hint"),
|
||||
center: () => KOREA_CENTER,
|
||||
onMove: (key, lon, lat) => place(Number(key), { lon, lat }),
|
||||
onMove: (key, lon, lat) => place(key, { lon, lat }),
|
||||
onClick: (lon, lat) => {
|
||||
if (!editable) return;
|
||||
if (active === null) return showToast(t("D02_Pick_Point"), "info");
|
||||
@@ -140,83 +157,123 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
|
||||
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(); // 놓은 지점 = 다음 빈 지점으로
|
||||
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(String(index));
|
||||
paint(key);
|
||||
}
|
||||
|
||||
/** focus = 고친 지점(보기 밖이면 그리로) · 빈 값 = 보기 그대로 */
|
||||
/** [+ 시점] · [+ 종점] — 빈 줄을 더하고 지도 누름이 거기 놓음. */
|
||||
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[] = points.flatMap((p, i) =>
|
||||
p ? [{ key: String(i), name: pointName(i), lon: p.lon, lat: p.lat, movable: editable }] : [],
|
||||
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,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
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);
|
||||
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 = 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: "📍",
|
||||
const rows = SIDES.flatMap((side) => {
|
||||
const add = createButton({
|
||||
label: t(side === "starts" ? "D02_Add_Start" : "D02_Add_End"),
|
||||
iconStart: "plus",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
active = i;
|
||||
drawTable();
|
||||
},
|
||||
size: 24,
|
||||
onClick: () => addRow(side),
|
||||
});
|
||||
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;
|
||||
add.hidden = !editable;
|
||||
return [...points[side].map((p, i) => pointRow(side, p, i)), el("div", { children: [add] })];
|
||||
});
|
||||
table.replaceChildren(...rows);
|
||||
const saved = view?.start;
|
||||
const count = (side: Side): number => view?.[side].length ?? 0;
|
||||
info.textContent = view?.epsg
|
||||
? `${t("D02_Saved_Crs")} EPSG:${view.epsg}` +
|
||||
(saved ? ` · X ${saved.x.toFixed(3)} Y ${saved.y.toFixed(3)}` : "")
|
||||
? `${t("D02_Start")} ${count("starts")} · ${t("D02_End")} ${count("ends")} · ` +
|
||||
`${t("D02_Saved_Crs")} EPSG:${view.epsg}`
|
||||
: t("D02_Not_Saved");
|
||||
}
|
||||
|
||||
@@ -233,7 +290,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
|
||||
async function store(): Promise<boolean> {
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const body = { start: points[0], end: points[1] };
|
||||
const body = { starts: points.starts.filter(Boolean), ends: points.ends.filter(Boolean) };
|
||||
adopt(
|
||||
await request<PointsView>(`/projects/${projectId}/plan/points`, {
|
||||
method: "PUT",
|
||||
@@ -262,12 +319,11 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
|
||||
if (mapHint) mapHint.textContent = t("D02_View_Only");
|
||||
}
|
||||
adopt(await request<PointsView>(`/projects/${projectId}/plan/points`));
|
||||
const cached = readState<{
|
||||
base?: Array<LonLat | null>;
|
||||
points?: Array<LonLat | null>;
|
||||
}>("d02-points", projectId);
|
||||
const cached = readState<{ base?: Points; points?: Points }>("d02-points", projectId);
|
||||
const sameBase = JSON.stringify(cached?.base) === JSON.stringify(points);
|
||||
if (editable && sameBase && cached?.points?.length === 2) points = cached.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");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* D02 기준 지정(529-3 · 560-2) — 메인 지도 = 본문 높이 가득 · 좌 시점 · 종점 표 */
|
||||
/* D02 기준 지정(529-3 · 560-2 · 579-2) — 메인 지도 = 본문 높이 가득 · 좌 시점 목록 · 종점 목록 */
|
||||
.d02-map {
|
||||
position: relative;
|
||||
height: calc(100dvh - var(--app-header-h));
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
.d02-points__row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr 1fr;
|
||||
grid-template-columns: auto 1fr 1fr auto;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 4px 6px;
|
||||
|
||||
Reference in New Issue
Block a user