From bcf397ab3d40bbf0bd16e0b4bab9d2cb98505be3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 15:00:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8=EC=84=A0):?= =?UTF-8?q?=20=EC=A7=80=ED=98=95=20=EC=B9=B8=20=E2=86=92=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EC=B9=B8=20=C2=B7=20=EC=A3=BC=EC=86=8C=20=C2=B7=20?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EC=B0=BE=EA=B8=B0=20=E2=86=92=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=E2=86=92=20=EB=B0=9B=EA=B8=B0=20=C2=B7=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=A7=80=EB=8F=84=20=EC=B0=BE=EA=B8=B0=20(PLAN=205?= =?UTF-8?q?96-2=20=C2=B7=20596-5=20=C2=B7=20=EC=9A=94=EA=B5=AC=20463)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 D03_PlanAnalysis_UI_Site — 지금 주소 · 찾기(createFindList · searchPlaces) · 실패 · 끊김 한 줄 + [다시 받기] · [임도망 올리기] - 위치 = 캐시 한 걸음(Snap site · 판 5 · 되돌리기 · 떠남 물음) → [저장] 몸 site · 지점 이름 point_names - 고름 → 지도 이동 → 둘레 받기(center) · 멂 · 다른 TM 띠 = 물음 한 번 뒤 reset · 아니오 = 위치 되돌림 - 임도망 올린 뒤 지도 맞춤 = 주소지 둘레 ∪ 잘린 임도 상자(없으면 주소지) - 조용히 받기 = 기준점 10 km 안 지점 · 선만 · 칸 글 걷음(실패 · 끊김 · 기준점 없음 · 못 불러옴만) - 등록(D01) 지도 머리 [위성] 오른쪽 찾기 = 같은 createPlaceFind · 사전 DE_Reg_Find* - 시험 553_4 위치 칸 · 등록 찾기 부품 · 560_4 savedJson 꼴 · 584_3 올리기 자리(위치 칸) Co-Authored-By: Claude Opus 5.5 --- D01_PlanRegister/D01_PlanRegister_UI_Page.ts | 11 +- D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 224 ++++++++++++------ D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts | 114 +++++++++ resources/tester/test_553_4_de_parts.py | 15 +- resources/tester/test_560_4_leave_save.py | 2 +- .../test_584_3_plan_forest_road_zones.py | 4 +- ui_template/ui_template_locale_de.ts | 7 + 7 files changed, 297 insertions(+), 80 deletions(-) create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts diff --git a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts index 83cc164b6..dad0a66eb 100644 --- a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts +++ b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts @@ -5,6 +5,7 @@ * · 사업연도 끝 · 임도 종류 · 측점은 안 받음(끝 연도 = D03 [저장] · 나머지 = 서버 기본) · 설계속도 없음(591-1). * 기준점(591-1 · 요구 452 · 453) = 카카오 지도(공용 editMap · 마우스 설정 따름) 누름 · 끌기로 한 점 → * GET /api/map/address(VWorld · 출처 표기) → 주소 글 · 사업 지역 「시군구 읍면」 채움(손으로 고친 뒤엔 안 덮음) + * 지도 머리 [위성] 오른쪽 = 주소 · 장소 찾기(596-5 · 예정노선 위치 칸과 같은 `createPlaceFind` · 서버 VWorld) → 고름 = 그 자리로 기준점. * [등록] = POST /api/d01/project(site = 경위도 · 주소는 서버가 다시 찾음) → 현재 프로젝트 · D03 으로. * ========================================================================== */ @@ -20,6 +21,7 @@ import { readByKey, writeByKey } from "../A00_Common/b_page_state"; import { createGroupProject } from "../A00_Common/group_workflow"; import { navigateTo } from "../A00_Common/router"; import { createNameComposer } from "../B02_ProjRegister/B02_ProjRegister_UI_Name"; +import { createPlaceFind } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Site"; const KOREA_CENTER: [number, number] = [127.8, 36.3]; const DIGITS = 6; // 경위도 소수 자리(약 0.1 m) @@ -85,12 +87,17 @@ function sitePicker(onRegion: (region: string) => void): { } draw(); + // 찾기 = 칸은 머리 줄([위성] 오른쪽 · 좁으면 줄바꿈) · 알림 · 후보 줄은 머리 아래 + const find = createPlaceFind((hit) => void moveTo(hit.lon, hit.lat)); + const field = find.search.parentElement!; + field.style.flex = "1 1 12rem"; const head = document.createElement("div"); head.className = "ui-general-block__actions"; - head.append(createText(t("DE_Reg_Site"), "muted", 14), typePick.root); + head.style.flexWrap = "wrap"; + head.append(createText(t("DE_Reg_Site"), "muted", 14), typePick.root, field); const root = document.createElement("div"); root.dataset.d01Site = ""; - root.append(head, map.root, where); + root.append(head, find.root, map.root, where); return { root, value: () => site }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 07b548afc..809917986 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -19,10 +19,8 @@ import "./D03_PlanAnalysis_UI_Style.css"; import { ROUTES } from "@config/config_frontend"; import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; -import { pickFiles } from "@ui/ui_template_drop"; import { askConfirm } from "@ui/ui_template_modal"; import { createProgressCircle } from "@ui/ui_template_progress"; -import { createText } from "@ui/ui_template_text"; import { createButton, hideLoadingOverlay, @@ -42,9 +40,12 @@ import { fetchTerrainExtent, savePlanNetwork, startTerrain, + type PlaceHit, type PlanJob, type PlanNetworkView, type PlanSurface, + type TerrainCenter, + type TerrainStart, } from "./D03_PlanAnalysis_Api"; import { openPlan3d } from "./D03_PlanAnalysis_UI_3D"; import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw"; @@ -59,6 +60,7 @@ import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure"; import { createPlanPoints } from "./D03_PlanAnalysis_UI_Points"; import { createPlanPieces } from "./D03_PlanAnalysis_UI_Pieces"; import { createPlanProps } from "./D03_PlanAnalysis_UI_Props"; +import { createPlanSite } from "./D03_PlanAnalysis_UI_Site"; import { emptyNetwork, fromServer, @@ -75,16 +77,26 @@ const POLL_MS = 1000; const INTERRUPTED = "끊김(서버 다시 켜짐 등)"; /** 처음 맞춤 = 기준점 둘레(서버 `SITE_RADIUS_M`). */ const SITE_RADIUS_M = 1000; +/** 조용히 받기가 덮는 거리 — 기준점에서 이보다 먼 지점 · 선은 안 받음(위치를 멀리 옮긴 뒤 옛 선 = 거대 받기 막음). */ +const COVER_MAX_M = 10000; /** 되돌리기 쌓는 수. */ const UNDO_MAX = 100; -/** 캐시 꼴 판 — 옛 꼴(지점 없음 · 갈래 고름)은 안 읽음. */ -const DRAFT_VERSION = 4; +/** 캐시 꼴 판 — 옛 꼴(지점 없음 · 갈래 고름 · 위치 없음)은 안 읽음. */ +const DRAFT_VERSION = 5; -/** 한 걸음 = 지점(+ 이름 · 차례 같음 · 빈 값 = 「지점 N」) + 노선(되돌리기 · 캐시 · 고침 판정). */ +/** 기준점(위치 칸 · 등록과 한 칸 `projects.site_*`). */ +interface Site { + lon: number; + lat: number; + address: string | null; +} + +/** 한 걸음 = 지점(+ 이름 · 차례 같음 · 빈 값 = 「지점 N」) + 노선 + 위치(되돌리기 · 캐시 · 고침 판정). */ interface Snap { network: PlanNetwork; points: Vertex[]; pointNames: string[]; + site: Site | null; } interface Draft extends Snap { @@ -100,10 +112,12 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { let editable = false; let points: Vertex[] = []; // 지점(긋기에 붙는 점 · 591-2) let pointNames: string[] = []; // 지점 이름(596 · 597-2) + let site: Site | null = null; // 위치(캐시 · [저장] 때 정본 596-2) + let savedSite: Site | null = null; let epsg = 0; let yearStart = new Date().getFullYear(); // 새 선 처음 해 let network: PlanNetwork = emptyNetwork(); - let savedJson = JSON.stringify({ network, points, pointNames }); // 못 읽은 채 떠남 = 고침 없음 + let savedJson = JSON.stringify({ network, points, pointNames, site }); // 못 읽은 채 떠남 = 고침 없음 let undoStack: Snap[] = []; let redoStack: Snap[] = []; let surface: PlanSurface | null = null; @@ -113,6 +127,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { let ready = false; // 들어올 때 받기 판단이 끝남(그 앞 보기 바뀜은 안 받음) let busy = false; let failed: string | null = null; + let note = ""; // 위치 칸 알림(기준점 없음 · 못 불러옴) let measuring = false; let lastSaveMs: number | null = null; @@ -158,7 +173,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { epsg: () => epsg, draw, commit: (nextPoints, nextNames, next) => - step({ network: next, points: nextPoints, pointNames: nextNames }), + step({ network: next, points: nextPoints, pointNames: nextNames, site }), center: () => viewMid, show: (nextPoints, next) => { [points, network] = [nextPoints, next]; @@ -194,30 +209,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { main.className = "d03-plan"; main.append(map.root); - const terrainText = createText("", "muted"); - terrainText.classList.add("d03-plan__hint"); - const retryButton = createButton({ - label: "다시 받기", - variant: "filled", - size: 28, - onClick: () => { + const siteBox = createPlanSite({ + onPick: (hit) => void pickSite(hit), + onRetry: () => { [failed, lastTried, ready] = [null, "", true]; - void ensureCovered(); + void ensureCovered(true); }, + onUpload: (files) => void uploadRoads(files), }); - const terrainCard = createPanelCard("지형", false); - const roadButton = createButton({ - label: "임도망 올리기", - variant: "ghost", - size: 28, - onClick: () => - pickFiles({ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, (files) => { - void backdrop.uploadRoads(files); - }), - }); - roadButton.title = - "임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)"; - terrainCard.append(terrainText, retryButton, roadButton); + const siteCard = createPanelCard("위치", false); + siteCard.append(...siteBox.parts); const pointCard = createPanelCard("지점", false); pointCard.append(marks.panel); const drawCard = createPanelCard("그리기", false); @@ -235,7 +236,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { actions.append(saveButton, resetButton); const panel = document.createElement("div"); panel.className = "ui-sidebar-panel d03-plan__panel"; - panel.append(terrainCard, pointCard, drawCard, pieceCard, actions); + panel.append(siteCard, pointCard, drawCard, pieceCard, actions); attachCollapsible(panel); // 떠날 때(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) 고친 선이 있으면 공용 물음 [취소][폐기][저장](572-3) mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, { @@ -248,7 +249,27 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { root.querySelector(".ui-workflow-layout")?.classList.add("d03-plan-layout"); const allVertices = (): Vertex[] => network.lines.flatMap((line) => line.vertices); - const snap = (): Snap => ({ network, points, pointNames }); + const snap = (): Snap => ({ network, points, pointNames, site }); + /** 위치를 옮기고 [저장] 전 — 받기 · 범위 물음에 새 기준점을 실음. */ + const moved = (): TerrainCenter["center"] => + site && JSON.stringify(site) !== JSON.stringify(savedSite) + ? { lon: site.lon, lat: site.lat } + : null; + /** 기준점 둘레 ±1 km(작업 좌표) — 처음 맞춤 · 위치 옮김 · 임도망 맞춤. */ + const around = (at: Site | null): Vertex[] => { + const tm = tmParams(epsg); + if (!at || !tm) return []; + const [x, y] = lonLatToTm(tm, at.lon, at.lat); + return [ + [x - SITE_RADIUS_M, y - SITE_RADIUS_M], + [x + SITE_RADIUS_M, y + SITE_RADIUS_M], + ]; + }; + const fitTo = (box: Vertex[]): void => { + if (!box.length) return; + map.update({ fit: box }); + map.fit(); + }; const dirty = (): boolean => JSON.stringify(snap()) !== savedJson; const keepDraft = (): void => dirty() @@ -259,10 +280,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { function step(next: Snap): void { undoStack = [...undoStack.slice(1 - UNDO_MAX), snap()]; redoStack = []; - [network, points, pointNames] = [ + [network, points, pointNames, site] = [ normalizePieces(next.network, yearStart), next.points, next.pointNames, + next.site, ]; keepDraft(); refresh(); @@ -270,14 +292,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { } function commit(next: PlanNetwork): void { - step({ network: next, points, pointNames }); + step({ network: next, points, pointNames, site }); } function undo(): void { const last = undoStack.pop(); if (!last) return; redoStack.push(snap()); - ({ network, points, pointNames } = last); + ({ network, points, pointNames, site } = last); keepDraft(); refresh(); } @@ -286,7 +308,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { const next = redoStack.pop(); if (!next) return; undoStack.push(snap()); - ({ network, points, pointNames } = next); + ({ network, points, pointNames, site } = next); keepDraft(); refresh(); } @@ -315,14 +337,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { const inside = (list: Vertex[]): boolean => !!have && list.every(([x, y]) => x >= have![0] && x <= have![2] && y >= have![1] && y <= have![3]); - const outside = (): boolean => !!have && !inside([...points, ...allVertices()]); /** 조용히 받기(요구 453) — 지점 · 선이 받은 범위 밖이면 이어 받음(지도 보기만 옮김 = 안 받음 596-4) · * 단추 · 막는 창 없음 · 실패 · 끊김 = [다시 받기] · 같은 요청은 한 번만. */ - async function ensureCovered(): Promise { + async function ensureCovered(again = false): Promise { if (!ready || !editable || busy || failed || !epsg || !root.isConnected) return; - const extra = [...points, ...allVertices()]; - if (inside(extra)) return; // 받을 때 서버가 여유를 더함 + const [lo, hi] = around(site); + const near = ([x, y]: Vertex): boolean => + !lo || Math.hypot(x - (lo[0] + hi[0]) / 2, y - (lo[1] + hi[1]) / 2) <= COVER_MAX_M; + const extra = [...points, ...allVertices()].filter(near); + if (!again && inside(extra)) return; // 받을 때 서버가 여유를 더함 const key = JSON.stringify([have, extra]); if (key === lastTried) return; lastTried = key; @@ -343,34 +367,89 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { props.render(); pieces.render(); marks.render(); - retryButton.hidden = !editable || busy || !failed; - roadButton.hidden = !editable; - if (!busy) - terrainText.textContent = failed - ? `지형 받기 실패 — ${failed}` - : !surface - ? editable - ? "받은 지형 없음 — 기준점 둘레를 받습니다(길이 · 기울기는 지형을 받은 뒤)." - : "받은 지형 없음" - : outside() - ? "지점 또는 그은 선이 받은 지형 밖 — 이어 받습니다." - : "받은 지형 안"; + siteBox.render({ + address: site?.address ?? null, + note: failed ? `지형 받기 실패 — ${failed}` : note, + retry: !busy && !!failed, + editable, + }); saveButton.hidden = resetButton.hidden = !editable; saveButton.disabled = busy || !dirty(); } - async function download(extra: Vertex[]): Promise { + /** 받기 — 멂(받은 범위에서 5 km 넘음) · 다른 TM 띠 = 물음 한 번 뒤 옛 범위 버리고 새로(`reset`) · + * 아니오 = 안 받음(거짓). 이미 도는 일(다른 창)이면 그것을 지켜봄. */ + async function download(extra: Vertex[]): Promise { busy = true; // 요청이 가는 동안 다른 받기를 막음(지켜보기가 이어 받음) + const where: TerrainCenter = { center: moved() }; try { - await startTerrain(projectId, null, extra); + let answer: TerrainStart = await startTerrain(projectId, null, extra, where); + if (answer.status === "far" || answer.status === "band") { + const band = answer.status === "band"; + const ok = await askConfirm({ + title: band ? "다른 좌표 띠로 옮김" : "멀리 옮김", + message: band + ? `${answer.message} 그은 선 · 지점을 지우고 새 자리 둘레를 받습니다.` + : `${answer.message} 받은 지형을 버리고 새 자리 둘레를 받습니다.`, + okLabel: "새로 받기", + danger: band, + }); + if (!ok) { + busy = false; + refresh(); + return false; + } + if (answer.status === "band") clearForBand(answer.center_epsg); + answer = await startTerrain(projectId, null, [], { ...where, reset: true }); + } } catch (error) { - // 이미 도는 일(다른 창)이면 그것을 지켜봄 if (!/이미 돌고/.test(errorText(error))) { busy = false; - return void showToast(errorText(error), "error"); + refresh(); + showToast(errorText(error), "error"); + return false; } } await follow(); + return true; + } + + /** 다른 TM 띠 — 작업 좌표가 바뀜 · 선 · 지점을 걷고 되돌리기를 비움(옛 좌표라 못 되돌림). */ + function clearForBand(next: number): void { + epsg = next; + [network, points, pointNames] = [emptyNetwork(), [], []]; + [undoStack, redoStack, have] = [[], [], null]; + map.update({ epsg }); + keepDraft(); + } + + /** 위치 칸 고름 — 기준점 캐시(한 걸음 · [저장] 때 정본) → 지도 이동 → 둘레 받기(멂 · 띠 = 물음 · 아니오 = 되돌림). */ + async function pickSite(hit: PlaceHit): Promise { + if (busy) return void showToast("지형을 받는 중 — 끝난 뒤 다시 고르세요.", "info"); + epsg ||= hit.lon < 126 ? 5185 : hit.lon < 128 ? 5186 : 5187; // 기준점 없던 프로젝트(서버 띠 규칙) + [note, failed, lastTried, ready] = ["", null, "", false]; // 걸음의 조용히 받기는 막고 아래에서 한 번 + step({ ...snap(), site: { lon: hit.lon, lat: hit.lat, address: hit.address || hit.title } }); + ready = true; + fitTo(around(site)); + if (await download([])) return; // 새 자리 둘레(center) — 지점 · 선은 끝난 뒤 조용히 받기가 + undo(); // 물음에 아니오 · 받기 못 함 = 위치 그대로 + redoStack.pop(); + fitTo(around(site)); + } + + /** 임도망 올리기 — 지도 맞춤 = 주소지 둘레 ∪ 잘린 임도 상자(없으면 주소지). */ + async function uploadRoads(files: File[]): Promise { + const info = await backdrop.uploadRoads(files); + const tm = tmParams(epsg); + if (!info) return; + const roads: Vertex[] = + tm && info.bounds + ? [ + lonLatToTm(tm, info.bounds[0], info.bounds[1]), + lonLatToTm(tm, info.bounds[2], info.bounds[3]), + ] + : []; + fitTo([...around(site), ...roads]); } /** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */ @@ -432,14 +511,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { ? fromServer(server.lines, server.marks, server.pieces, yearStart) : emptyNetwork(); points = view.base.points.map((p): Vertex => [p.x, p.y]); - pointNames = view.base.points.map(() => ""); // 594-1 이름 칸이 들어오면 `p.name ?? ""` + pointNames = view.base.points.map((p) => p.name ?? ""); + site = savedSite = view.base.site; if (view.base.epsg) epsg = view.base.epsg; savedJson = JSON.stringify(snap()); } function serverBody(): Parameters[1] { const { lines, pieces } = network; - return { epsg, points, lines, marks: marksOf(network), pieces }; + const body = { epsg, points, point_names: pointNames, lines, marks: marksOf(network), pieces }; + return moved() && site ? { ...body, site } : body; } /** [저장] — 서버가 다시 셈 · 실패면 그대로(캐시 남음). 참 = 저장됨. */ @@ -476,7 +557,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { draw.setMode("idle"); showLoadingOverlay(); try { - const body = { epsg, points, lines: [], marks: [], pieces: [] }; + const body = { epsg, points, point_names: pointNames, lines: [], marks: [], pieces: [] }; takeServer(await savePlanNetwork(projectId, body)); clearState("d03-plan", projectId); undoStack = []; @@ -509,6 +590,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { base: () => points, have: () => have, busy: () => busy, + site: () => site, + pickSite, lineNames: () => lineNames(network.lines), screenOf: map.screenOf, screenOfLonLat: map.screenOfLonLat, @@ -555,26 +638,18 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { editable = !!(await fetchSessionUser())?.is_forest_service; draw.setEditable(editable); const view = await fetchPlanNetwork(projectId); - const { epsg: workEpsg, site } = view.base; - if (!workEpsg) { - terrainText.textContent = "기준점이 없습니다 — 예정노선 등록에서 정하세요."; - return; + if (!view.base.epsg) { + note = "기준점이 없습니다 — 주소를 찾아 정하세요."; + ready = true; + return refresh(); } - epsg = workEpsg; + epsg = view.base.epsg; takeServer(view); const draft = readState("d03-plan", projectId); if (draft?.version === DRAFT_VERSION && draft.network && Array.isArray(draft.points)) - ({ network, points, pointNames } = draft); - const tm = tmParams(epsg); - const siteXy = site && tm ? lonLatToTm(tm, site.lon, site.lat) : null; - const around: Vertex[] = siteXy - ? [ - [siteXy[0] - SITE_RADIUS_M, siteXy[1] - SITE_RADIUS_M], - [siteXy[0] + SITE_RADIUS_M, siteXy[1] + SITE_RADIUS_M], - ] - : []; + ({ network, points, pointNames, site } = draft); const drawn = [...points, ...allVertices()]; - map.update({ fit: drawn.length ? drawn : around, epsg }); + map.update({ fit: drawn.length && !moved() ? drawn : around(site), epsg }); refresh(); await loadSurface(); refresh(); @@ -585,12 +660,13 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { await follow(); } else if (editable && !job.error) { // 들어올 때 받은 범위가 기준점 둘레 · 지점을 못 덮으면 바로 받기(서버가 셈 · 실패 뒤엔 [다시 받기]) - const extent = await fetchTerrainExtent(projectId, null, []); + const extent = await fetchTerrainExtent(projectId, null, [], moved()); ready = true; if (!extent.covered) await download([]); else await ensureCovered(); } } catch (error) { - terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`; + note = `불러오지 못했습니다 — ${errorText(error)}`; + refresh(); } } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts new file mode 100644 index 000000000..1670ccf37 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts @@ -0,0 +1,114 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Site.ts + * D03 「위치」 칸(PLAN 596-2 · 요구 463-3~6 · 19) — 지금 기준점 주소 한 줄 · 주소 · 장소 찾기 + * (서버 VWorld `searchPlaces` · 부품 `createFindList` · Enter = 찾기 · ↑ ↓ Enter = 고름) · + * 알림 한 줄(받기 실패 · 끊김 · 기준점 없음 · 못 불러옴만) + [다시 받기] · [임도망 올리기]. + * 고름 = 페이지가 받기 물음(멂 · 다른 TM 띠) → 기준점 캐시 → 지도 이동 → 둘레 받기 · [저장] 때 정본(등록과 한 칸). + * ========================================================================== */ + +import { pickFiles } from "@ui/ui_template_drop"; +import { createButton } from "@ui/ui_template_elements"; +import { createFindList, type FindList } from "@ui/ui_template_find_list"; +import { t } from "@ui/ui_template_locale"; +import { createText } from "@ui/ui_template_text"; +import { searchPlaces, type PlaceHit } from "./D03_PlanAnalysis_Api"; + +/** 찾기 글 최소 길이(서버 VWorld 한 글자 = 쓸모없는 후보). */ +const MIN_QUERY = 2; + +export interface PlanSiteParams { + /** 후보 고름 — 받기 · 캐시 · 이동은 페이지. */ + onPick: (hit: PlaceHit) => void; + onRetry: () => void; + onUpload: (files: File[]) => void; +} + +export interface PlanSiteState { + address: string | null; + /** 알림(실패 · 끊김 · 기준점 없음 · 오류) — 빈 값 = 숨김. */ + note: string; + retry: boolean; + editable: boolean; +} + +export interface PlanSite { + /** 칸 몸(카드에 바로 붙임 · 카드 간격을 그대로 씀). */ + parts: HTMLElement[]; + render: (state: PlanSiteState) => void; +} + +/** 주소 · 장소 찾기 칸(부품 `createFindList`) — Enter = 찾기 · ↑ ↓ Enter · 누름 = 고름 · + * 등록(D01) 지도 머리 · 이 「위치」 칸이 같이 씀. */ +export function createPlaceFind(onPick: (hit: PlaceHit) => void): FindList { + const find = createFindList({ placeholder: t("DE_Reg_Find") }); + let asked = 0; // 옛 찾기 답 버림 + + async function run(): Promise { + const query = find.search.value.trim(); + find.setRows([]); + if (query.length < MIN_QUERY) return void (find.hint.textContent = t("DE_Reg_FindShort")); + const at = ++asked; + find.hint.textContent = t("DE_Reg_FindBusy"); + try { + const hits = await searchPlaces(query); + if (at !== asked) return; + find.hint.textContent = hits.length ? "" : t("DE_Reg_FindNone"); + find.setRows( + hits.map((hit, i) => ({ + key: String(i), + tag: t(hit.kind === "place" ? "DE_Reg_FindPlace" : "DE_Reg_FindAddress"), + name: hit.title, + sub: hit.address !== hit.title ? hit.address : undefined, + onPick: () => { + find.setRows([]); + find.search.value = hit.title; + onPick(hit); + }, + })), + ); + } catch (error) { + if (at === asked) + find.hint.textContent = `${t("DE_Reg_FindFail")} — ${error instanceof Error ? error.message : error}`; + } + } + // 고를 줄이 있으면 부품이 Enter 를 먼저 씀(고름) · 없으면 찾기 · 글을 고치면 옛 줄 걷음 + find.search.addEventListener("input", () => find.setRows([])); + find.search.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || event.defaultPrevented || event.isComposing) return; + event.preventDefault(); + void run(); + }); + return find; +} + +export function createPlanSite(params: PlanSiteParams): PlanSite { + const address = createText("", "muted", 14); + const note = createText("", "error"); + const find = createPlaceFind(params.onPick); + + const retry = createButton({ + label: "다시 받기", + variant: "filled", + size: 28, + onClick: params.onRetry, + }); + const upload = createButton({ + label: "임도망 올리기", + variant: "ghost", + size: 28, + onClick: () => + pickFiles({ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, params.onUpload), + }); + upload.title = "임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)"; + return { + parts: [address, find.root, note, retry, upload], + render(state) { + address.textContent = state.address ?? ""; + address.hidden = !state.address; + note.textContent = state.note; + note.hidden = !state.note; + find.root.hidden = upload.hidden = !state.editable; + retry.hidden = !state.editable || !state.retry; + }, + }; +} diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index 542dd501b..785667b65 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -35,6 +35,13 @@ def test_d03_parts(): layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts") assert 'createElement("progress")' not in ts and "createProgressBar(" not in ts assert "createProgressCircle({ size: 40, corner: true })" in ts # 596-3 받기 = 지도 귀퉁이 서클 + site = _src( + "D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts" + ) # 596-2 위치 칸 · 596-5 등록 찾기 = 같은 부품 + assert "createFindList(" in site and "searchPlaces(" in site and "createPlanSite(" in ts + assert "createPlaceFind(" in _src("D01_PlanRegister/D01_PlanRegister_UI_Page.ts") + for raw in ('createElement("button")', 'createElement("input")'): + assert raw not in site, raw # 562-3 — 날 단추 · 입력 · 고르기 없음 · 도구줄 · 제약 칸 · 연도 고름은 부품 for src in (ts, draw, draw_panel, tools, layers): for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'): @@ -89,8 +96,12 @@ def test_d03_parts(): props = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts") # 585-2 조각 연도 = 속성 판 assert "createSelectField(" in props and "createFloatPanel(" in props assert 'createElement("button")' not in props and 'createElement("select")' not in props - assert 'createElement("select")' not in three and "createSelectField" not in three # 601 선 고름 걷음 - assert "createCheckbox" not in three and "setCorridors(" in three # 601 노선망 예상 코리도 한 번에 + assert ( + 'createElement("select")' not in three and "createSelectField" not in three + ) # 601 선 고름 걷음 + assert ( + "createCheckbox" not in three and "setCorridors(" in three + ) # 601 노선망 예상 코리도 한 번에 for dead in (".d03-plan__bar", ".d03-plan__mode", "is-picked"): assert dead not in css, dead assert "b05-route__3d-modal" not in three and "b05-route__3d-body" not in three # 빌림 끊음 diff --git a/resources/tester/test_560_4_leave_save.py b/resources/tester/test_560_4_leave_save.py index 20fc22a06..b585c2d61 100644 --- a/resources/tester/test_560_4_leave_save.py +++ b/resources/tester/test_560_4_leave_save.py @@ -113,7 +113,7 @@ def test_de_pages_guard_leave(): if name: assert f'discard: () => clearState("{name}", projectId)' in call, path # D03 — 못 읽은 채(프로젝트 없음 · 불러오기 실패) 떠나면 고침 없음 - assert "let savedJson = JSON.stringify({ network, points });" in _src( + assert "let savedJson = JSON.stringify({ network, points, pointNames, site });" in _src( "D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts" ) e03 = _src("E03_StabSection/E03_StabSection_UI_Page.ts") diff --git a/resources/tester/test_584_3_plan_forest_road_zones.py b/resources/tester/test_584_3_plan_forest_road_zones.py index 87ad0b83e..1c05d9549 100644 --- a/resources/tester/test_584_3_plan_forest_road_zones.py +++ b/resources/tester/test_584_3_plan_forest_road_zones.py @@ -255,7 +255,9 @@ def test_screen_defs() -> None: assert entry and entry.group(2) == "false", key # 처음 값 끔 assert re.search(rf"^\s+{entry.group(1)}:", icons, re.M), key assert f"--draw-map-{color}:" in theme and f'"--draw-map-{color}"' in page - assert re.search(r"^\s+upload:", icons, re.M) and "pickFiles(" in page # 부품 상자 고르기 창 + site = (folder / "D03_PlanAnalysis_UI_Site.ts").read_text("utf-8") # 596-2 올리기 = 위치 칸 + assert "pickFiles(" in site and "pickFiles(" not in page # 부품 상자 고르기 창 + assert "uploadRoads" in page and "uploadPlanForestRoads(" in page assert "") # 겹 모두 바탕 assert "임도 짐작" in legend and "올린 임도망도" in legend # 범례 구분 diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index d9f51ecfe..1f6689cbe 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -102,6 +102,13 @@ export const ui_locales_de = { DE_Reg_SiteNoAddress: ["주소를 찾지 못했습니다", "Address not found"], DE_Reg_SiteSource: ["출처", "Source"], DE_Reg_SiteRequired: ["지도에서 기준점을 찍으세요.", "Set the reference point on the map."], + DE_Reg_Find: ["주소 · 장소 찾기 (Enter)", "Find address or place (Enter)"], + DE_Reg_FindShort: ["두 글자 이상", "Two or more characters"], + DE_Reg_FindBusy: ["찾는 중…", "Searching…"], + DE_Reg_FindNone: ["찾은 곳 없음", "Nothing found"], + DE_Reg_FindFail: ["찾지 못함", "Search failed"], + DE_Reg_FindAddress: ["주소", "Address"], + DE_Reg_FindPlace: ["장소", "Place"], DE_Reg_MapType: ["바탕 지도", "Base map"], DE_Reg_MapNormal: ["일반", "Map"], DE_Reg_MapSatellite: ["위성", "Satellite"],