feat(D 예정노선): 지형 칸 → 위치 칸 · 주소 · 장소 찾기 → 이동 → 받기 · 등록 지도 찾기 (PLAN 596-2 · 596-5 · 요구 463)

- 새 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 15:00:32 +09:00
co-authored by Claude Opus 5.5
parent 3a32b51b06
commit bcf397ab3d
7 changed files with 297 additions and 80 deletions
+9 -2
View File
@@ -5,6 +5,7 @@
* · 사업연도 끝 · 임도 종류 · 측점은 안 받음(끝 연도 = D03 [저장] · 나머지 = 서버 기본) · 설계속도 없음(591-1). * · 사업연도 끝 · 임도 종류 · 측점은 안 받음(끝 연도 = D03 [저장] · 나머지 = 서버 기본) · 설계속도 없음(591-1).
* 기준점(591-1 · 요구 452 · 453) = 카카오 지도(공용 editMap · 마우스 설정 따름) 누름 · 끌기로 한 점 → * 기준점(591-1 · 요구 452 · 453) = 카카오 지도(공용 editMap · 마우스 설정 따름) 누름 · 끌기로 한 점 →
* GET /api/map/address(VWorld · 출처 표기) → 주소 글 · 사업 지역 「시군구 읍면」 채움(손으로 고친 뒤엔 안 덮음) * GET /api/map/address(VWorld · 출처 표기) → 주소 글 · 사업 지역 「시군구 읍면」 채움(손으로 고친 뒤엔 안 덮음)
* 지도 머리 [위성] 오른쪽 = 주소 · 장소 찾기(596-5 · 예정노선 위치 칸과 같은 `createPlaceFind` · 서버 VWorld) → 고름 = 그 자리로 기준점.
* [등록] = POST /api/d01/project(site = 경위도 · 주소는 서버가 다시 찾음) → 현재 프로젝트 · D03 으로. * [등록] = 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 { createGroupProject } from "../A00_Common/group_workflow";
import { navigateTo } from "../A00_Common/router"; import { navigateTo } from "../A00_Common/router";
import { createNameComposer } from "../B02_ProjRegister/B02_ProjRegister_UI_Name"; 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 KOREA_CENTER: [number, number] = [127.8, 36.3];
const DIGITS = 6; // 경위도 소수 자리(약 0.1 m) const DIGITS = 6; // 경위도 소수 자리(약 0.1 m)
@@ -85,12 +87,17 @@ function sitePicker(onRegion: (region: string) => void): {
} }
draw(); 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"); const head = document.createElement("div");
head.className = "ui-general-block__actions"; 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"); const root = document.createElement("div");
root.dataset.d01Site = ""; root.dataset.d01Site = "";
root.append(head, map.root, where); root.append(head, find.root, map.root, where);
return { root, value: () => site }; return { root, value: () => site };
} }
+150 -74
View File
@@ -19,10 +19,8 @@
import "./D03_PlanAnalysis_UI_Style.css"; import "./D03_PlanAnalysis_UI_Style.css";
import { ROUTES } from "@config/config_frontend"; import { ROUTES } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { pickFiles } from "@ui/ui_template_drop";
import { askConfirm } from "@ui/ui_template_modal"; import { askConfirm } from "@ui/ui_template_modal";
import { createProgressCircle } from "@ui/ui_template_progress"; import { createProgressCircle } from "@ui/ui_template_progress";
import { createText } from "@ui/ui_template_text";
import { import {
createButton, createButton,
hideLoadingOverlay, hideLoadingOverlay,
@@ -42,9 +40,12 @@ import {
fetchTerrainExtent, fetchTerrainExtent,
savePlanNetwork, savePlanNetwork,
startTerrain, startTerrain,
type PlaceHit,
type PlanJob, type PlanJob,
type PlanNetworkView, type PlanNetworkView,
type PlanSurface, type PlanSurface,
type TerrainCenter,
type TerrainStart,
} from "./D03_PlanAnalysis_Api"; } from "./D03_PlanAnalysis_Api";
import { openPlan3d } from "./D03_PlanAnalysis_UI_3D"; import { openPlan3d } from "./D03_PlanAnalysis_UI_3D";
import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw"; 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 { createPlanPoints } from "./D03_PlanAnalysis_UI_Points";
import { createPlanPieces } from "./D03_PlanAnalysis_UI_Pieces"; import { createPlanPieces } from "./D03_PlanAnalysis_UI_Pieces";
import { createPlanProps } from "./D03_PlanAnalysis_UI_Props"; import { createPlanProps } from "./D03_PlanAnalysis_UI_Props";
import { createPlanSite } from "./D03_PlanAnalysis_UI_Site";
import { import {
emptyNetwork, emptyNetwork,
fromServer, fromServer,
@@ -75,16 +77,26 @@ const POLL_MS = 1000;
const INTERRUPTED = "끊김(서버 다시 켜짐 등)"; const INTERRUPTED = "끊김(서버 다시 켜짐 등)";
/** 처음 맞춤 = 기준점 둘레(서버 `SITE_RADIUS_M`). */ /** 처음 맞춤 = 기준점 둘레(서버 `SITE_RADIUS_M`). */
const SITE_RADIUS_M = 1000; const SITE_RADIUS_M = 1000;
/** 조용히 받기가 덮는 거리 — 기준점에서 이보다 먼 지점 · 선은 안 받음(위치를 멀리 옮긴 뒤 옛 선 = 거대 받기 막음). */
const COVER_MAX_M = 10000;
/** 되돌리기 쌓는 수. */ /** 되돌리기 쌓는 수. */
const UNDO_MAX = 100; 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 { interface Snap {
network: PlanNetwork; network: PlanNetwork;
points: Vertex[]; points: Vertex[];
pointNames: string[]; pointNames: string[];
site: Site | null;
} }
interface Draft extends Snap { interface Draft extends Snap {
@@ -100,10 +112,12 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
let editable = false; let editable = false;
let points: Vertex[] = []; // 지점(긋기에 붙는 점 · 591-2) let points: Vertex[] = []; // 지점(긋기에 붙는 점 · 591-2)
let pointNames: string[] = []; // 지점 이름(596 · 597-2) let pointNames: string[] = []; // 지점 이름(596 · 597-2)
let site: Site | null = null; // 위치(캐시 · [저장] 때 정본 596-2)
let savedSite: Site | null = null;
let epsg = 0; let epsg = 0;
let yearStart = new Date().getFullYear(); // 새 선 처음 해 let yearStart = new Date().getFullYear(); // 새 선 처음 해
let network: PlanNetwork = emptyNetwork(); let network: PlanNetwork = emptyNetwork();
let savedJson = JSON.stringify({ network, points, pointNames }); // 못 읽은 채 떠남 = 고침 없음 let savedJson = JSON.stringify({ network, points, pointNames, site }); // 못 읽은 채 떠남 = 고침 없음
let undoStack: Snap[] = []; let undoStack: Snap[] = [];
let redoStack: Snap[] = []; let redoStack: Snap[] = [];
let surface: PlanSurface | null = null; let surface: PlanSurface | null = null;
@@ -113,6 +127,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
let ready = false; // 들어올 때 받기 판단이 끝남(그 앞 보기 바뀜은 안 받음) let ready = false; // 들어올 때 받기 판단이 끝남(그 앞 보기 바뀜은 안 받음)
let busy = false; let busy = false;
let failed: string | null = null; let failed: string | null = null;
let note = ""; // 위치 칸 알림(기준점 없음 · 못 불러옴)
let measuring = false; let measuring = false;
let lastSaveMs: number | null = null; let lastSaveMs: number | null = null;
@@ -158,7 +173,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
epsg: () => epsg, epsg: () => epsg,
draw, draw,
commit: (nextPoints, nextNames, next) => commit: (nextPoints, nextNames, next) =>
step({ network: next, points: nextPoints, pointNames: nextNames }), step({ network: next, points: nextPoints, pointNames: nextNames, site }),
center: () => viewMid, center: () => viewMid,
show: (nextPoints, next) => { show: (nextPoints, next) => {
[points, network] = [nextPoints, next]; [points, network] = [nextPoints, next];
@@ -194,30 +209,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
main.className = "d03-plan"; main.className = "d03-plan";
main.append(map.root); main.append(map.root);
const terrainText = createText("", "muted"); const siteBox = createPlanSite({
terrainText.classList.add("d03-plan__hint"); onPick: (hit) => void pickSite(hit),
const retryButton = createButton({ onRetry: () => {
label: "다시 받기",
variant: "filled",
size: 28,
onClick: () => {
[failed, lastTried, ready] = [null, "", true]; [failed, lastTried, ready] = [null, "", true];
void ensureCovered(); void ensureCovered(true);
}, },
onUpload: (files) => void uploadRoads(files),
}); });
const terrainCard = createPanelCard("지형", false); const siteCard = createPanelCard("위치", false);
const roadButton = createButton({ siteCard.append(...siteBox.parts);
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 pointCard = createPanelCard("지점", false); const pointCard = createPanelCard("지점", false);
pointCard.append(marks.panel); pointCard.append(marks.panel);
const drawCard = createPanelCard("그리기", false); const drawCard = createPanelCard("그리기", false);
@@ -235,7 +236,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
actions.append(saveButton, resetButton); actions.append(saveButton, resetButton);
const panel = document.createElement("div"); const panel = document.createElement("div");
panel.className = "ui-sidebar-panel d03-plan__panel"; panel.className = "ui-sidebar-panel d03-plan__panel";
panel.append(terrainCard, pointCard, drawCard, pieceCard, actions); panel.append(siteCard, pointCard, drawCard, pieceCard, actions);
attachCollapsible(panel); attachCollapsible(panel);
// 떠날 때(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) 고친 선이 있으면 공용 물음 [취소][폐기][저장](572-3) // 떠날 때(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) 고친 선이 있으면 공용 물음 [취소][폐기][저장](572-3)
mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, { mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, {
@@ -248,7 +249,27 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
root.querySelector(".ui-workflow-layout")?.classList.add("d03-plan-layout"); root.querySelector(".ui-workflow-layout")?.classList.add("d03-plan-layout");
const allVertices = (): Vertex[] => network.lines.flatMap((line) => line.vertices); 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 dirty = (): boolean => JSON.stringify(snap()) !== savedJson;
const keepDraft = (): void => const keepDraft = (): void =>
dirty() dirty()
@@ -259,10 +280,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
function step(next: Snap): void { function step(next: Snap): void {
undoStack = [...undoStack.slice(1 - UNDO_MAX), snap()]; undoStack = [...undoStack.slice(1 - UNDO_MAX), snap()];
redoStack = []; redoStack = [];
[network, points, pointNames] = [ [network, points, pointNames, site] = [
normalizePieces(next.network, yearStart), normalizePieces(next.network, yearStart),
next.points, next.points,
next.pointNames, next.pointNames,
next.site,
]; ];
keepDraft(); keepDraft();
refresh(); refresh();
@@ -270,14 +292,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
} }
function commit(next: PlanNetwork): void { function commit(next: PlanNetwork): void {
step({ network: next, points, pointNames }); step({ network: next, points, pointNames, site });
} }
function undo(): void { function undo(): void {
const last = undoStack.pop(); const last = undoStack.pop();
if (!last) return; if (!last) return;
redoStack.push(snap()); redoStack.push(snap());
({ network, points, pointNames } = last); ({ network, points, pointNames, site } = last);
keepDraft(); keepDraft();
refresh(); refresh();
} }
@@ -286,7 +308,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const next = redoStack.pop(); const next = redoStack.pop();
if (!next) return; if (!next) return;
undoStack.push(snap()); undoStack.push(snap());
({ network, points, pointNames } = next); ({ network, points, pointNames, site } = next);
keepDraft(); keepDraft();
refresh(); refresh();
} }
@@ -315,14 +337,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const inside = (list: Vertex[]): boolean => const inside = (list: Vertex[]): boolean =>
!!have && !!have &&
list.every(([x, y]) => x >= have![0] && x <= have![2] && y >= have![1] && y <= have![3]); 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) · /** 조용히 받기(요구 453) — 지점 · 선이 받은 범위 밖이면 이어 받음(지도 보기만 옮김 = 안 받음 596-4) ·
* 단추 · 막는 창 없음 · 실패 · 끊김 = [다시 받기] · 같은 요청은 한 번만. */ * 단추 · 막는 창 없음 · 실패 · 끊김 = [다시 받기] · 같은 요청은 한 번만. */
async function ensureCovered(): Promise<void> { async function ensureCovered(again = false): Promise<void> {
if (!ready || !editable || busy || failed || !epsg || !root.isConnected) return; if (!ready || !editable || busy || failed || !epsg || !root.isConnected) return;
const extra = [...points, ...allVertices()]; const [lo, hi] = around(site);
if (inside(extra)) return; // 받을 때 서버가 여유를 더함 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]); const key = JSON.stringify([have, extra]);
if (key === lastTried) return; if (key === lastTried) return;
lastTried = key; lastTried = key;
@@ -343,34 +367,89 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
props.render(); props.render();
pieces.render(); pieces.render();
marks.render(); marks.render();
retryButton.hidden = !editable || busy || !failed; siteBox.render({
roadButton.hidden = !editable; address: site?.address ?? null,
if (!busy) note: failed ? `지형 받기 실패 — ${failed}` : note,
terrainText.textContent = failed retry: !busy && !!failed,
? `지형 받기 실패 — ${failed}` editable,
: !surface });
? editable
? "받은 지형 없음 — 기준점 둘레를 받습니다(길이 · 기울기는 지형을 받은 뒤)."
: "받은 지형 없음"
: outside()
? "지점 또는 그은 선이 받은 지형 밖 — 이어 받습니다."
: "받은 지형 안";
saveButton.hidden = resetButton.hidden = !editable; saveButton.hidden = resetButton.hidden = !editable;
saveButton.disabled = busy || !dirty(); saveButton.disabled = busy || !dirty();
} }
async function download(extra: Vertex[]): Promise<void> { /** 받기 — 멂(받은 범위에서 5 km 넘음) · 다른 TM 띠 = 물음 한 번 뒤 옛 범위 버리고 새로(`reset`) ·
* 아니오 = 안 받음(거짓). 이미 도는 일(다른 창)이면 그것을 지켜봄. */
async function download(extra: Vertex[]): Promise<boolean> {
busy = true; // 요청이 가는 동안 다른 받기를 막음(지켜보기가 이어 받음) busy = true; // 요청이 가는 동안 다른 받기를 막음(지켜보기가 이어 받음)
const where: TerrainCenter = { center: moved() };
try { 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) { } catch (error) {
// 이미 도는 일(다른 창)이면 그것을 지켜봄
if (!/이미 돌고/.test(errorText(error))) { if (!/이미 돌고/.test(errorText(error))) {
busy = false; busy = false;
return void showToast(errorText(error), "error"); refresh();
showToast(errorText(error), "error");
return false;
} }
} }
await follow(); 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<void> {
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<void> {
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<void> {
? fromServer(server.lines, server.marks, server.pieces, yearStart) ? fromServer(server.lines, server.marks, server.pieces, yearStart)
: emptyNetwork(); : emptyNetwork();
points = view.base.points.map((p): Vertex => [p.x, p.y]); 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; if (view.base.epsg) epsg = view.base.epsg;
savedJson = JSON.stringify(snap()); savedJson = JSON.stringify(snap());
} }
function serverBody(): Parameters<typeof savePlanNetwork>[1] { function serverBody(): Parameters<typeof savePlanNetwork>[1] {
const { lines, pieces } = network; 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<void> {
draw.setMode("idle"); draw.setMode("idle");
showLoadingOverlay(); showLoadingOverlay();
try { try {
const body = { epsg, points, lines: [], marks: [], pieces: [] }; const body = { epsg, points, point_names: pointNames, lines: [], marks: [], pieces: [] };
takeServer(await savePlanNetwork(projectId, body)); takeServer(await savePlanNetwork(projectId, body));
clearState("d03-plan", projectId); clearState("d03-plan", projectId);
undoStack = []; undoStack = [];
@@ -509,6 +590,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
base: () => points, base: () => points,
have: () => have, have: () => have,
busy: () => busy, busy: () => busy,
site: () => site,
pickSite,
lineNames: () => lineNames(network.lines), lineNames: () => lineNames(network.lines),
screenOf: map.screenOf, screenOf: map.screenOf,
screenOfLonLat: map.screenOfLonLat, screenOfLonLat: map.screenOfLonLat,
@@ -555,26 +638,18 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
editable = !!(await fetchSessionUser())?.is_forest_service; editable = !!(await fetchSessionUser())?.is_forest_service;
draw.setEditable(editable); draw.setEditable(editable);
const view = await fetchPlanNetwork(projectId); const view = await fetchPlanNetwork(projectId);
const { epsg: workEpsg, site } = view.base; if (!view.base.epsg) {
if (!workEpsg) { note = "기준점이 없습니다 — 주소를 찾아 정하세요.";
terrainText.textContent = "기준점이 없습니다 — 예정노선 등록에서 정하세요."; ready = true;
return; return refresh();
} }
epsg = workEpsg; epsg = view.base.epsg;
takeServer(view); takeServer(view);
const draft = readState<Draft>("d03-plan", projectId); const draft = readState<Draft>("d03-plan", projectId);
if (draft?.version === DRAFT_VERSION && draft.network && Array.isArray(draft.points)) if (draft?.version === DRAFT_VERSION && draft.network && Array.isArray(draft.points))
({ network, points, pointNames } = draft); ({ network, points, pointNames, site } = 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],
]
: [];
const drawn = [...points, ...allVertices()]; const drawn = [...points, ...allVertices()];
map.update({ fit: drawn.length ? drawn : around, epsg }); map.update({ fit: drawn.length && !moved() ? drawn : around(site), epsg });
refresh(); refresh();
await loadSurface(); await loadSurface();
refresh(); refresh();
@@ -585,12 +660,13 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
await follow(); await follow();
} else if (editable && !job.error) { } else if (editable && !job.error) {
// 들어올 때 받은 범위가 기준점 둘레 · 지점을 못 덮으면 바로 받기(서버가 셈 · 실패 뒤엔 [다시 받기]) // 들어올 때 받은 범위가 기준점 둘레 · 지점을 못 덮으면 바로 받기(서버가 셈 · 실패 뒤엔 [다시 받기])
const extent = await fetchTerrainExtent(projectId, null, []); const extent = await fetchTerrainExtent(projectId, null, [], moved());
ready = true; ready = true;
if (!extent.covered) await download([]); if (!extent.covered) await download([]);
else await ensureCovered(); else await ensureCovered();
} }
} catch (error) { } catch (error) {
terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`; note = `불러오지 못했습니다 — ${errorText(error)}`;
refresh();
} }
} }
@@ -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<void> {
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;
},
};
}
+13 -2
View File
@@ -35,6 +35,13 @@ def test_d03_parts():
layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts") layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
assert 'createElement("progress")' not in ts and "createProgressBar(" not in ts assert 'createElement("progress")' not in ts and "createProgressBar(" not in ts
assert "createProgressCircle({ size: 40, corner: true })" in ts # 596-3 받기 = 지도 귀퉁이 서클 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 — 날 단추 · 입력 · 고르기 없음 · 도구줄 · 제약 칸 · 연도 고름은 부품 # 562-3 — 날 단추 · 입력 · 고르기 없음 · 도구줄 · 제약 칸 · 연도 고름은 부품
for src in (ts, draw, draw_panel, tools, layers): for src in (ts, draw, draw_panel, tools, layers):
for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'): 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 조각 연도 = 속성 판 props = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts") # 585-2 조각 연도 = 속성 판
assert "createSelectField(" in props and "createFloatPanel(" in props assert "createSelectField(" in props and "createFloatPanel(" in props
assert 'createElement("button")' not in props and 'createElement("select")' not 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 (
assert "createCheckbox" not in three and "setCorridors(" in three # 601 노선망 예상 코리도 한 번에 '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"): for dead in (".d03-plan__bar", ".d03-plan__mode", "is-picked"):
assert dead not in css, dead assert dead not in css, dead
assert "b05-route__3d-modal" not in three and "b05-route__3d-body" not in three # 빌림 끊음 assert "b05-route__3d-modal" not in three and "b05-route__3d-body" not in three # 빌림 끊음
+1 -1
View File
@@ -113,7 +113,7 @@ def test_de_pages_guard_leave():
if name: if name:
assert f'discard: () => clearState("{name}", projectId)' in call, path assert f'discard: () => clearState("{name}", projectId)' in call, path
# D03 — 못 읽은 채(프로젝트 없음 · 불러오기 실패) 떠나면 고침 없음 # 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" "D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts"
) )
e03 = _src("E03_StabSection/E03_StabSection_UI_Page.ts") e03 = _src("E03_StabSection/E03_StabSection_UI_Page.ts")
@@ -255,7 +255,9 @@ def test_screen_defs() -> None:
assert entry and entry.group(2) == "false", key # 처음 값 끔 assert entry and entry.group(2) == "false", key # 처음 값 끔
assert re.search(rf"^\s+{entry.group(1)}:", icons, re.M), 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 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 "<input" not in page and 'createElement("input")' not in page assert "<input" not in page and 'createElement("input")' not in page
assert paint.count("base: true,") == paint.count("draw: (context, map) =>") # 겹 모두 바탕 assert paint.count("base: true,") == paint.count("draw: (context, map) =>") # 겹 모두 바탕
assert "임도 짐작" in legend and "올린 임도망도" in legend # 범례 구분 assert "임도 짐작" in legend and "올린 임도망도" in legend # 범례 구분
+7
View File
@@ -102,6 +102,13 @@ export const ui_locales_de = {
DE_Reg_SiteNoAddress: ["주소를 찾지 못했습니다", "Address not found"], DE_Reg_SiteNoAddress: ["주소를 찾지 못했습니다", "Address not found"],
DE_Reg_SiteSource: ["출처", "Source"], DE_Reg_SiteSource: ["출처", "Source"],
DE_Reg_SiteRequired: ["지도에서 기준점을 찍으세요.", "Set the reference point on the map."], 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_MapType: ["바탕 지도", "Base map"],
DE_Reg_MapNormal: ["일반", "Map"], DE_Reg_MapNormal: ["일반", "Map"],
DE_Reg_MapSatellite: ["위성", "Satellite"], DE_Reg_MapSatellite: ["위성", "Satellite"],