Merge remote-tracking branch 'origin/dev' into sub_laptop_4 — DrawPaint import 정리

This commit is contained in:
2026-10-30 15:12:19 +09:00
7 changed files with 297 additions and 80 deletions
+9 -2
View File
@@ -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 };
}
+150 -74
View File
@@ -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 TerrainAim,
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<void> {
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<void> {
let ready = false; // 들어올 때 받기 판단이 끝남(그 앞 보기 바뀜은 안 받음)
let busy = false;
let failed: string | null = null;
let note = ""; // 위치 칸 알림(기준점 없음 · 못 불러옴)
let measuring = false;
let lastSaveMs: number | null = null;
@@ -164,7 +179,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
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];
@@ -200,30 +215,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
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);
@@ -241,7 +242,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
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, {
@@ -254,7 +255,27 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
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 = (): TerrainAim["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()
@@ -265,10 +286,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
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();
@@ -276,14 +298,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
}
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();
}
@@ -292,7 +314,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const next = redoStack.pop();
if (!next) return;
undoStack.push(snap());
({ network, points, pointNames } = next);
({ network, points, pointNames, site } = next);
keepDraft();
refresh();
}
@@ -321,14 +343,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
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<void> {
async function ensureCovered(again = false): Promise<void> {
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;
@@ -349,34 +373,89 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
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<void> {
/** 받기 — 멂(받은 범위에서 5 km 넘음) · 다른 TM 띠 = 물음 한 번 뒤 옛 범위 버리고 새로(`reset`) ·
* 아니오 = 안 받음(거짓). 이미 도는 일(다른 창)이면 그것을 지켜봄. */
async function download(extra: Vertex[]): Promise<boolean> {
busy = true; // 요청이 가는 동안 다른 받기를 막음(지켜보기가 이어 받음)
const where: TerrainAim = { 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<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]);
}
/** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */
@@ -438,14 +517,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
? 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<typeof savePlanNetwork>[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;
}
/** [저장] — 서버가 다시 셈 · 실패면 그대로(캐시 남음). 참 = 저장됨. */
@@ -482,7 +563,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
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 = [];
@@ -515,6 +596,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
base: () => points,
have: () => have,
busy: () => busy,
site: () => site,
pickSite,
lineNames: () => lineNames(network.lines),
screenOf: map.screenOf,
screenOfLonLat: map.screenOfLonLat,
@@ -561,26 +644,18 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
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<Draft>("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();
@@ -591,12 +666,13 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
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();
}
}
@@ -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")
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 # 빌림 끊음
+1 -1
View File
@@ -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")
@@ -276,7 +276,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 "<input" not in page and 'createElement("input")' not in page
assert paint.count("base: true,") == paint.count("draw: (context, map) =>") # 겹 모두 바탕
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_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"],