From 75b1627dea4156d8a39b3b3bb08568fd790466f3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 20:10:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(529-3):=20D02=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EC=A7=80=EC=A0=95=20=ED=99=94=EB=A9=B4=20=E2=80=94=20=EC=B9=B4?= =?UTF-8?q?=EC=B9=B4=EC=98=A4=EB=A7=B5=20=EC=97=B0=EB=8F=84=20=EC=A7=80?= =?UTF-8?q?=EC=A0=90=20=C2=B7=20=EC=A2=8C=20=EC=97=B0=EB=8F=84=20=ED=91=9C?= =?UTF-8?q?=20=C2=B7=20[>]=20=EC=A0=80=EC=9E=A5=20=EB=92=A4=20D03?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- D02_PlanBase/D02_PlanBase_UI_Page.ts | 246 +++++++++++++++++++++++ D02_PlanBase/D02_PlanBase_UI_Style.css | 51 +++++ D02_PlanBase/draft/D02_PlanBase_Route.ts | 5 +- ui_template/ui_template_kakao_map.ts | 26 ++- ui_template/ui_template_locale_de.ts | 17 ++ 5 files changed, 335 insertions(+), 10 deletions(-) create mode 100644 D02_PlanBase/D02_PlanBase_UI_Page.ts create mode 100644 D02_PlanBase/D02_PlanBase_UI_Style.css diff --git a/D02_PlanBase/D02_PlanBase_UI_Page.ts b/D02_PlanBase/D02_PlanBase_UI_Page.ts new file mode 100644 index 000000000..65e8533e9 --- /dev/null +++ b/D02_PlanBase/D02_PlanBase_UI_Page.ts @@ -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(path: string, init?: RequestInit): Promise { + 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 { + 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 { + 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 = []; + 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> = []; + 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 { + if (!view) return false; + showLoadingOverlay(); + try { + const body = { + years: years().map((year, i) => ({ year, start: points[i], end: points[i + 1] })), + }; + adopt( + await request(`/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).detail !== 1) return; + event.stopPropagation(); + void store().then((ok) => ok && navigateTo(ROUTES.D03_PLAN_ANALYSIS)); + }); + + try { + adopt(await request(`/projects/${projectId}/plan/points`)); + const cached = JSON.parse(sessionStorage.getItem(cacheKey(projectId)) ?? "null") as { + points?: Array; + } | 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 : "" }), + ); + } +} diff --git a/D02_PlanBase/D02_PlanBase_UI_Style.css b/D02_PlanBase/D02_PlanBase_UI_Style.css new file mode 100644 index 000000000..ff48d662e --- /dev/null +++ b/D02_PlanBase/D02_PlanBase_UI_Style.css @@ -0,0 +1,51 @@ +/* D02 기준 지정(529-3) — 메인 지도 = 본문 높이 가득 · 좌 연도 지점 표 */ +.d02-map { + height: calc(100dvh - var(--app-header-h)); +} + +.d02-map .ui-kakao-map, +.d02-map .ui-kakao-map__box { + height: 100%; +} + +.d02-points { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.d02-points__row { + display: grid; + grid-template-columns: auto 1fr 1fr; + gap: 4px; + align-items: center; + padding: 4px 6px; + border-left: 4px solid var(--d02-year-color); + border-radius: 4px; +} + +.d02-points__row.is-active { + background: var(--color-surface-hover, rgba(0, 0, 0, 0.06)); +} + +.d02-points__name { + grid-column: 1 / -1; + font-size: var(--text-caption); + font-weight: 600; +} + +.d02-points__input { + width: 100%; + min-width: 0; + font-size: var(--text-caption); +} + +.d02-points__input.is-invalid { + outline: 1px solid var(--color-danger, #d92d20); +} + +.d02-info { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} diff --git a/D02_PlanBase/draft/D02_PlanBase_Route.ts b/D02_PlanBase/draft/D02_PlanBase_Route.ts index 1db951dd7..36678ce0d 100644 --- a/D02_PlanBase/draft/D02_PlanBase_Route.ts +++ b/D02_PlanBase/draft/D02_PlanBase_Route.ts @@ -1,7 +1,7 @@ /* ============================================================================= * D02_PlanBase_Route.ts * 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. - * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대) + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = D02 기준 지정(529-3) * ========================================================================== */ import { ROUTES } from "@config/config_frontend"; @@ -12,6 +12,5 @@ export const ROUTE: DraftRoute = { workflow: true, project: true, footerless: true, - load: async () => - (await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.D02_PLAN_BASE), + load: async () => (await import("../D02_PlanBase_UI_Page")).renderD02PlanBase, }; diff --git a/ui_template/ui_template_kakao_map.ts b/ui_template/ui_template_kakao_map.ts index 3c10ed1a6..5b7df7254 100644 --- a/ui_template/ui_template_kakao_map.ts +++ b/ui_template/ui_template_kakao_map.ts @@ -104,8 +104,13 @@ export interface MapPin { export interface EditMap { root: HTMLElement; - /** 표지 · 길 선 다시 놓고 맞춤 */ - set: (pins: MapPin[], lines: Array>) => void; + /** 표지 · 길 선 다시 놓고 맞춤 · colors = 선마다 색 · focus = 고친 표지 key(529-3 · 없으면 지금 규칙) */ + set: ( + pins: MapPin[], + lines: Array>, + colors?: string[], + focus?: string, + ) => void; } /** 고르는 지도 — 움직일 표지 끌기 = onMove · 지도 누름 = onClick(있을 때만) · 점 없으면 center(경도, 위도) */ @@ -122,7 +127,12 @@ export function editMap(opts: { let drawn: KOverlay[] = []; let fitted = false; // 153 — 처음 한 번만 맞춤 · 그 뒤 고름 · 끌기는 보기(중심 · 줌) 그대로 - const set = (pins: MapPin[], lines: Array>): void => { + const set = ( + pins: MapPin[], + lines: Array>, + colors?: string[], + focus?: string, + ): void => { loadKakaoMaps().then( (maps) => requestAnimationFrame(() => { @@ -146,11 +156,11 @@ export function editMap(opts: { drawn.forEach((o) => o.setMap(null)); drawn = [ ...lines.map( - (line) => + (line, index) => new maps.Polyline({ path: line.map(([lat, lon]) => new maps.LatLng(lat, lon)), strokeWeight: 6, - strokeColor: LINE_COLOR, + strokeColor: colors?.[index] ?? LINE_COLOR, strokeOpacity: 0.9, }), ), @@ -185,9 +195,11 @@ export function editMap(opts: { ]; drawn.forEach((o) => o.setMap(map)); if (fitted) { - // 보기 밖 표지만 그쪽으로 옮김(줌 그대로) + // 보기 밖 표지만 그쪽으로 옮김(줌 그대로) · focus 를 주면 그 표지만 봄 const view = map.getBounds(); - const out = moving.find((p) => !view.contain(new maps.LatLng(p.lat, p.lon))); + const out = (focus === undefined ? moving : moving.filter((p) => p.key === focus)).find( + (p) => !view.contain(new maps.LatLng(p.lat, p.lon)), + ); if (out) map.panTo(new maps.LatLng(out.lat, out.lon)); } else if (points.length > 1) { const bounds = new maps.LatLngBounds(); diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index 93d459f31..91e5ed84a 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -62,4 +62,21 @@ export const ui_locales_de = { ], DE_Reg_Submit: ["등록", "Register"], DE_Reg_Success: ["프로젝트를 만들었습니다.", "Project created."], + + /* --- D02 기준 지정(529-3) --- */ + D02_Points_Title: ["연도 지점", "Yearly Points"], + D02_Points_Hint: [ + "📍 로 고른 지점을 지도 누름으로 놓고 · 표지를 끌거나 경도 · 위도를 넣습니다. 직전 종점 = 다음 시점.", + "Pick a point with 📍 and click the map, drag markers, or type lon · lat. Each end is the next start.", + ], + D02_Start: ["시점", "start"], + D02_End: ["종점", "end"], + D02_Lon: ["경도", "Lon"], + D02_Lat: ["위도", "Lat"], + D02_Pick_Point: ["지도에 놓을 지점 고르기", "Pick the point to place on the map"], + D02_Map_Hint: ["지도를 눌러 지점을 놓습니다.", "Click the map to place a point."], + D02_Save: ["저장", "Save"], + D02_Saved: ["지점을 저장했습니다.", "Points saved."], + D02_Saved_Crs: ["저장 좌표계", "Saved CRS"], + D02_Not_Saved: ["아직 저장 안 함", "Not saved yet"], } as const satisfies Record;