Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts
T
eomsangdonandClaude Opus 5.5 bcf397ab3d 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>
2026-10-30 15:00:32 +09:00

673 lines
26 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_UI_Page.ts
* D 예정노선 1단계 노선분석(요구서 389 3장 6~13 · 440 · 452 · PLAN 562-3 · 563-1 · 579-3 · 580 · 591-2) — B05~B09 틀(`mountGroupStep`).
*
* 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 연도 구분) + 도구 여섯.
* 좌 = 지형(조용히 받기 — 처음 = 등록 기준점 둘레 1 km · 지점 · 선 · 지도 보기 가운데가 받은 범위 밖이면 이어 받음 ·
* 단추 · 막는 창 없음 · 진행 = 이 칸 작은 막대 · 실패만 [다시 받기]) · 지점(목록 · 핀 · 우클릭 메뉴 =
* `D03_PlanAnalysis_UI_Points`) · 그리기([긋기] · 제약 칸 · 연도 = 도구줄 「연도 마커」 끌어 놓기 585-3) ·
* 구역 · 연도(해 · 선 · 구간 줄 = 고르기 · 고른 줄 밝힘) · [저장] [초기화].
* 고른 구간 속성(연도 · 넓이 · 방향 · 지움) = 지도 위 속성 판 `D03_PlanAnalysis_UI_Props`(585-2).
* 지점 = 긋기에 붙는 점(핀 · 늘 맨 위) · 기준점 = 처음 받는 기준일 뿐(노선 무관) · 고르기 · 고치기 = `D03_PlanAnalysis_UI_Edit`.
*
* 데이터 3층(CLAUDE.md 5장) — 지점 · 긋기 · 마크 · 연도 = 캐시(`d03-plan` {version: 3, network, points}) · [저장] =
* PUT plan/network(지점 함께 · 서버가 길이 · 측점 · 조각 · 연도 행을 다시 셈 · 응답으로 바꿈) · [초기화] = 빈 선 PUT(지점 남김).
* 되돌리기 · 다시하기 = 이 화면 스택(걸음 = `commit` 한 번). 화면 전체 잠금 = [저장] · [초기화]만.
* 공유 회사(산림청 아님) = 보기만 — 긋기 · 받기 · [저장] 없음.
* ========================================================================== */
import "./D03_PlanAnalysis_UI_Style.css";
import { ROUTES } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { askConfirm } from "@ui/ui_template_modal";
import { createProgressCircle } from "@ui/ui_template_progress";
import {
createButton,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { currentProjectId } from "../A00_Common/b_project_switch";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { keyIs } from "../A00_Common/key_registry";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler";
import {
fetchPlanNetwork,
fetchPlanProgress,
fetchPlanSurface,
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";
import { createPlanEdit } from "./D03_PlanAnalysis_UI_Edit";
import { createPlanLayers } from "./D03_PlanAnalysis_UI_Layers";
import {
createPlanMap,
PLAN_STATION_INTERVAL_M,
type PlanMapLayer,
} from "./D03_PlanAnalysis_UI_Map";
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,
lineNames,
marksOf,
normalizePieces,
type PlanNetwork,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
import { lonLatToTm, tmParams } from "./logic/D03_PlanAnalysis_Tm";
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 = 5;
/** 기준점(위치 칸 · 등록과 한 칸 `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 {
version: number;
}
const sleep = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms));
const errorText = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const projectId = currentProjectId();
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, site }); // 못 읽은 채 떠남 = 고침 없음
let undoStack: Snap[] = [];
let redoStack: Snap[] = [];
let surface: PlanSurface | null = null;
let have: [number, number, number, number] | null = null;
let viewMid: Vertex | null = null; // 지도 보기 가운데(지점 [추가] 자리)
let lastTried = ""; // 같은 받기를 거듭하지 않음
let ready = false; // 들어올 때 받기 판단이 끝남(그 앞 보기 바뀜은 안 받음)
let busy = false;
let failed: string | null = null;
let note = ""; // 위치 칸 알림(기준점 없음 · 못 불러옴)
let measuring = false;
let lastSaveMs: number | null = null;
const z = (x: number, y: number): number => (surface ? gridHeightAt(surface.grid, x, y) : NaN);
const draw = createPlanDraw({
base: () => points,
network: () => network,
heightAt: () => z,
commit,
onMode: () => {
if (draw.mode() !== "idle") setMeasure(false);
refresh();
},
});
const edit = createPlanEdit({
network: () => network,
base: () => points,
commit,
show: (next) => {
network = normalizePieces(next, yearStart);
map.redraw();
},
enabled: () => draw.mode() === "idle" && !measuring,
editable: () => editable,
onSelect: () => refresh(),
});
const props = createPlanProps({
network: () => network,
edit,
editable: () => editable,
yearStart: () => yearStart,
commit,
});
const pieces = createPlanPieces({ network: () => network, edit });
const measure = createPlanMeasure(
() => z,
() => network,
);
const marks = createPlanPoints({
points: () => points,
network: () => network,
names: () => pointNames,
epsg: () => epsg,
draw,
commit: (nextPoints, nextNames, next) =>
step({ network: next, points: nextPoints, pointNames: nextNames, site }),
center: () => viewMid,
show: (nextPoints, next) => {
[points, network] = [nextPoints, next];
map.redraw();
},
idle: () => draw.mode() === "idle" && !measuring,
editable: () => editable,
});
// 지도 보기 가운데 — 그릴 때마다 작업 좌표로(지점 [추가] 자리 · 보기만 옮김 = 안 받음 596-4)
const viewWatch: PlanMapLayer = {
draw(context, screen) {
const [w, h] = [context.canvas.clientWidth, context.canvas.clientHeight];
viewMid = w ? screen.toMetric(...screen.unrotate(w / 2, h / 2)) : null;
},
};
const map = createPlanMap({
onUndo: undo,
onRedo: redo,
on3d: open3d,
onMeasure: () => setMeasure(!measuring),
});
const backdrop = createPlanLayers({
projectId,
map,
points: () => [...points, ...allVertices()],
have: () => have,
});
// 받기 진행 = 지도 오른쪽 아래 작은 서클(받을 때만 · 지도 조작 안 막음 596-3)
const circle = createProgressCircle({ size: 40, corner: true });
circle.root.hidden = true;
map.root.append(backdrop.bar, backdrop.legend, props.panel, circle.root);
const main = document.createElement("div");
main.className = "d03-plan";
main.append(map.root);
const siteBox = createPlanSite({
onPick: (hit) => void pickSite(hit),
onRetry: () => {
[failed, lastTried, ready] = [null, "", true];
void ensureCovered(true);
},
onUpload: (files) => void uploadRoads(files),
});
const siteCard = createPanelCard("위치", false);
siteCard.append(...siteBox.parts);
const pointCard = createPanelCard("지점", false);
pointCard.append(marks.panel);
const drawCard = createPanelCard("그리기", false);
drawCard.append(draw.panel);
const pieceCard = createPanelCard("구역 · 연도", false);
pieceCard.append(pieces.list);
const saveButton = createButton({ label: "저장", variant: "filled", onClick: () => void save() });
const resetButton = createButton({
label: "초기화",
variant: "ghost",
onClick: () => void reset(),
});
const actions = document.createElement("div");
actions.className = "ui-sidebar-actions";
actions.append(saveButton, resetButton);
const panel = document.createElement("div");
panel.className = "ui-sidebar-panel d03-plan__panel";
panel.append(siteCard, pointCard, drawCard, pieceCard, actions);
attachCollapsible(panel);
// 떠날 때(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) 고친 선이 있으면 공용 물음 [취소][폐기][저장](572-3)
mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, {
mainContent: main,
leftPanel: panel,
dirty: () => editable && dirty(),
save,
discard: () => clearState("d03-plan", projectId),
});
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, 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()
? writeState("d03-plan", { version: DRAFT_VERSION, ...snap() } satisfies Draft, projectId)
: clearState("d03-plan", projectId);
/** 한 걸음 — 조각을 맞춰 넣고 되돌리기에 쌓음 · 캐시 · 받은 범위 밖이면 조용히 이어 받음. */
function step(next: Snap): void {
undoStack = [...undoStack.slice(1 - UNDO_MAX), snap()];
redoStack = [];
[network, points, pointNames, site] = [
normalizePieces(next.network, yearStart),
next.points,
next.pointNames,
next.site,
];
keepDraft();
refresh();
void ensureCovered();
}
function commit(next: PlanNetwork): void {
step({ network: next, points, pointNames, site });
}
function undo(): void {
const last = undoStack.pop();
if (!last) return;
redoStack.push(snap());
({ network, points, pointNames, site } = last);
keepDraft();
refresh();
}
function redo(): void {
const next = redoStack.pop();
if (!next) return;
undoStack.push(snap());
({ network, points, pointNames, site } = next);
keepDraft();
refresh();
}
function setMeasure(on: boolean): void {
if (on === measuring) return;
measuring = on;
measure.clear();
if (on) draw.setMode("idle");
map.setLayers(layerList());
refresh();
}
/** 겹 차례 — 핀 누름(`marks.input`)은 고르기보다 앞 · 핀 그림 · 우클릭 메뉴(`marks.pins`)는 맨 끝(맨 위). */
const layerList = (): PlanMapLayer[] => [
...backdrop.layers,
draw.layer,
marks.input,
edit.layer,
...(measuring ? [measure.layer] : []),
viewWatch,
marks.pins,
];
/** 점들이 받은 범위 안인가. */
const inside = (list: Vertex[]): boolean =>
!!have &&
list.every(([x, y]) => x >= have![0] && x <= have![2] && y >= have![1] && y <= have![3]);
/** 조용히 받기(요구 453) — 지점 · 선이 받은 범위 밖이면 이어 받음(지도 보기만 옮김 = 안 받음 596-4) ·
* 단추 · 막는 창 없음 · 실패 · 끊김 = [다시 받기] · 같은 요청은 한 번만. */
async function ensureCovered(again = false): Promise<void> {
if (!ready || !editable || busy || failed || !epsg || !root.isConnected) 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;
await download(extra);
}
function refresh(): void {
draw.sync();
edit.sync();
map.setTools({
undo: editable && undoStack.length > 0,
redo: editable && redoStack.length > 0,
view3d: !!surface && network.lines.some((line) => line.vertices.length > 1),
measure: measuring,
yearMark: editable && network.lines.some((line) => line.vertices.length > 1),
});
map.redraw();
props.render();
pieces.render();
marks.render();
siteBox.render({
address: site?.address ?? null,
note: failed ? `지형 받기 실패 — ${failed}` : note,
retry: !busy && !!failed,
editable,
});
saveButton.hidden = resetButton.hidden = !editable;
saveButton.disabled = busy || !dirty();
}
/** 받기 — 멂(받은 범위에서 5 km 넘음) · 다른 TM 띠 = 물음 한 번 뒤 옛 범위 버리고 새로(`reset`) ·
* 아니오 = 안 받음(거짓). 이미 도는 일(다른 창)이면 그것을 지켜봄. */
async function download(extra: Vertex[]): Promise<boolean> {
busy = true; // 요청이 가는 동안 다른 받기를 막음(지켜보기가 이어 받음)
const where: TerrainCenter = { center: moved() };
try {
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;
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]);
}
/** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */
async function follow(): Promise<void> {
const job = await watch();
if (!job || !root.isConnected) return;
failed = job.error;
await Promise.all([loadSurface(), backdrop.load()]);
refresh();
await ensureCovered(); // 받는 동안 옮긴 지점 · 선 · 보기
}
/** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 지도 귀퉁이 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 ·
* 진행을 못 읽음) = 실패 꼴 + [다시 받기]. */
async function watch(): Promise<PlanJob | null> {
busy = true;
circle.set(null, "지형");
circle.root.hidden = false;
refresh();
try {
for (;;) {
if (!root.isConnected) return null;
const job = (await fetchPlanProgress(projectId)).terrain;
circle.set(job.percent / 100, `지형 ${job.message ?? ""}`.trim());
circle.root.title = `지형 받기 ${job.percent}% · ${job.message ?? ""}`;
if (!job.running) {
const cut = !job.error && (job.interrupted || job.percent < 100);
if (job.error) showToast(job.error, "error");
return cut ? { ...job, error: INTERRUPTED } : job;
}
await sleep(POLL_MS);
}
} catch (error) {
showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error");
return { running: false, percent: 0, message: null, error: INTERRUPTED };
} finally {
busy = false;
circle.root.hidden = true;
refresh();
}
}
async function loadSurface(): Promise<void> {
try {
surface = await fetchPlanSurface(projectId);
const extent = await fetchTerrainExtent(projectId, null, []);
have = extent.have_m;
} catch (error) {
showToast(`지형면을 읽지 못했습니다 — ${errorText(error)}`, "error");
}
}
/** 서버 응답 → 지금 값 · 저장본(지점 · 선). */
function takeServer(view: PlanNetworkView): void {
if (view.year_start) yearStart = view.year_start;
const server = view.network;
network =
server && server.lines.length
? 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((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;
const body = { epsg, points, point_names: pointNames, lines, marks: marksOf(network), pieces };
return moved() && site ? { ...body, site } : body;
}
/** [저장] — 서버가 다시 셈 · 실패면 그대로(캐시 남음). 참 = 저장됨. */
async function save(): Promise<boolean> {
draw.setMode("idle");
showLoadingOverlay();
try {
const began = performance.now();
takeServer(await savePlanNetwork(projectId, serverBody()));
lastSaveMs = Math.round(performance.now() - began);
clearState("d03-plan", projectId);
undoStack = [];
redoStack = [];
refresh();
showToast("예정노선을 저장했습니다.", "success");
return true;
} catch (error) {
showToast(`저장하지 못했습니다 — ${errorText(error)}`, "error");
return false;
} finally {
hideLoadingOverlay();
}
}
/** [초기화] = 선만 지운 빈 노선을 정본에 · 지점은 남김(요구 453). */
async function reset(): Promise<void> {
const ok = await askConfirm({
title: "초기화",
message: "그은 선 · 갈래 · 구역을 모두 지웁니다(지점은 남김). 저장된 노선도 바뀝니다.",
okLabel: "초기화",
danger: true,
});
if (!ok) return;
draw.setMode("idle");
showLoadingOverlay();
try {
const body = { epsg, points, point_names: pointNames, lines: [], marks: [], pieces: [] };
takeServer(await savePlanNetwork(projectId, body));
clearState("d03-plan", projectId);
undoStack = [];
redoStack = [];
refresh();
showToast("선을 지웠습니다(지점은 남김).", "success");
} catch (error) {
showToast(`초기화하지 못했습니다 — ${errorText(error)}`, "error");
} finally {
hideLoadingOverlay();
}
}
function open3d(): void {
if (!surface) return;
const names = lineNames(network.lines);
const lines = network.lines
.map((line, index) => ({
name: names[index],
line: line.vertices,
branch: line.branch !== null,
}))
.filter((item) => item.line.length > 1);
openPlan3d(projectId, surface, lines, PLAN_STATION_INTERVAL_M);
}
// 검증 창구 — 꼴 · 미리보기 · 제약 · 화면 자리 · 재기 · 회전 · 저장 시간(ORCA 판정)
(window as unknown as { __d03Plan?: unknown }).__d03Plan = {
network: () => network,
base: () => points,
have: () => have,
busy: () => busy,
site: () => site,
pickSite,
lineNames: () => lineNames(network.lines),
screenOf: map.screenOf,
screenOfLonLat: map.screenOfLonLat,
preview: draw.preview,
stroke: draw.stroke,
selected: edit.selected,
select: edit.select,
setScope: edit.setScope,
box: edit.box,
constraint: draw.constraint,
setConstraint: draw.setConstraint,
mode: draw.mode,
setMode: draw.setMode,
measure: measure.result,
rotationDeg: map.rotationDeg,
history: () => ({ undo: undoStack.length, redo: redoStack.length }),
dirty,
saveMs: () => lastSaveMs,
heightAt: z,
layers: backdrop.state,
};
const onKey = (event: KeyboardEvent): void => {
if (!root.isConnected) return window.removeEventListener("keydown", onKey);
const typing = (event.target as HTMLElement | null)?.closest?.("input, textarea, select");
if (typing || !editable) return;
// 키 = 등록부(588-3 · 한글 자판도 자판 자리로)
const handled = keyIs(event, "plan.undo")
? (undo(), true)
: keyIs(event, "plan.redo")
? (redo(), true)
: measuring && event.key === "Escape"
? (setMeasure(false), true)
: draw.key(event) || edit.key(event);
if (handled) {
event.preventDefault();
map.redraw();
}
};
window.addEventListener("keydown", onKey);
map.setLayers(layerList());
marks.mount(map.root);
try {
editable = !!(await fetchSessionUser())?.is_forest_service;
draw.setEditable(editable);
const view = await fetchPlanNetwork(projectId);
if (!view.base.epsg) {
note = "기준점이 없습니다 — 주소를 찾아 정하세요.";
ready = true;
return refresh();
}
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, site } = draft);
const drawn = [...points, ...allVertices()];
map.update({ fit: drawn.length && !moved() ? drawn : around(site), epsg });
refresh();
await loadSurface();
refresh();
await backdrop.load();
const job = (await fetchPlanProgress(projectId)).terrain;
if (job.running) {
ready = true;
await follow();
} else if (editable && !job.error) {
// 들어올 때 받은 범위가 기준점 둘레 · 지점을 못 덮으면 바로 받기(서버가 셈 · 실패 뒤엔 [다시 받기])
const extent = await fetchTerrainExtent(projectId, null, [], moved());
ready = true;
if (!extent.covered) await download([]);
else await ensureCovered();
}
} catch (error) {
note = `불러오지 못했습니다 — ${errorText(error)}`;
refresh();
}
}