From 23aacda55e2eb9a33961f16111a9996e1969e92f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 29 Oct 2026 14:32:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(D02):=20=EA=B8=B0=EC=A4=80=EC=A7=80?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=EC=A0=90=20=EB=AA=A9=EB=A1=9D=20=C2=B7=20?= =?UTF-8?q?=EC=A2=85=EC=A0=90=20=EB=AA=A9=EB=A1=9D=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=E2=80=94=20[+=20=EC=8B=9C=EC=A0=90]=20[+=20=EC=A2=85=EC=A0=90]?= =?UTF-8?q?=20=C2=B7=20=EC=A4=84=EB=A7=88=EB=8B=A4=20=EC=A7=80=EC=9B=80=20?= =?UTF-8?q?=C2=B7=20=EC=A0=90=20=EC=9E=87=EB=8A=94=20=EB=B6=89=EC=9D=80=20?= =?UTF-8?q?=EC=84=A0=20=EA=B1=B7=EC=9D=8C=20(PLAN=20579-2=20=C2=B7=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20440)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 표 = 시점 묶음 · 종점 묶음(짝 없이 따로 · 최소 한 줄 · 마지막 줄 지움 = 비움) · 표지 이름 「시점 1」「종점 2」 - [+] = 빈 줄 더하고 지도 누름이 거기 놓음 · 캐시 꼴 {base, points: {starts, ends}} · 옛 두 칸 캐시는 버림 - 정보 줄 = 시점 n · 종점 m · 저장 좌표계 Co-Authored-By: Claude Opus 5.5 --- D02_PlanBase/D02_PlanBase_UI_Page.ts | 228 +++++++++++++++---------- D02_PlanBase/D02_PlanBase_UI_Style.css | 4 +- ui_template/ui_template_locale_de.ts | 5 +- 3 files changed, 148 insertions(+), 89 deletions(-) diff --git a/D02_PlanBase/D02_PlanBase_UI_Page.ts b/D02_PlanBase/D02_PlanBase_UI_Page.ts index fe583cb36..57fe084df 100644 --- a/D02_PlanBase/D02_PlanBase_UI_Page.ts +++ b/D02_PlanBase/D02_PlanBase_UI_Page.ts @@ -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>; const KOREA_CENTER: [number, number] = [127.8, 36.3]; const DIGITS = 7; // 경위도 소수 7 자리 ≈ 1 cm @@ -66,11 +70,15 @@ async function request(path: string, init?: RequestInit): Promise { return data as T; } -/** 서버 두 점 → [시점, 종점] */ -function boundaryPoints(view: PointsView): Array { - 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 => + 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 { @@ -99,16 +107,25 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { }); let view: PointsView | null = null; - let points: Array = [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 => (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 { 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 { 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> = - 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 { async function store(): Promise { 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(`/projects/${projectId}/plan/points`, { method: "PUT", @@ -262,12 +319,11 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { if (mapHint) mapHint.textContent = t("D02_View_Only"); } adopt(await request(`/projects/${projectId}/plan/points`)); - const cached = readState<{ - base?: Array; - points?: Array; - }>("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"); } diff --git a/D02_PlanBase/D02_PlanBase_UI_Style.css b/D02_PlanBase/D02_PlanBase_UI_Style.css index 433cfb9b4..7c4c9f289 100644 --- a/D02_PlanBase/D02_PlanBase_UI_Style.css +++ b/D02_PlanBase/D02_PlanBase_UI_Style.css @@ -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; diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index 2265867cc..bdb9cfcfd 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -118,13 +118,16 @@ export const ui_locales_de = { DE_Link_NoRoad: ["우리 회사 임도 프로젝트가 없습니다.", "No road project in our company."], /* --- D02 기준 지정(529-3) --- */ - D02_Points_Title: ["전체 시점 · 종점", "Overall Start · End"], + D02_Points_Title: ["시점 · 종점", "Starts · Ends"], D02_Points_Hint: [ "📍 로 고른 지점을 지도 누름으로 놓고 · 표지를 끌거나 경도 · 위도를 넣습니다. 연도 나누기는 노선분석에서.", "Pick a point with 📍 and click the map, drag markers, or type lon · lat. Split by year in analysis.", ], D02_Start: ["시점", "start"], D02_End: ["종점", "end"], + D02_Add_Start: ["시점", "Start"], + D02_Add_End: ["종점", "End"], + D02_Remove_Point: ["이 지점 지움", "Remove this point"], D02_Lon: ["경도", "Lon"], D02_Lat: ["위도", "Lat"], D02_Pick_Point: ["지도에 놓을 지점 고르기", "Pick the point to place on the map"],