652 lines
24 KiB
TypeScript
652 lines
24 KiB
TypeScript
/* =============================================================================
|
|
* D03_PlanAnalysis_UI_Page.ts
|
|
* D 예정노선 2단계 노선분석(요구서 389 3장 6~13 · 440 · PLAN 562-3 · 563-1 · 579-3 · 580) — B05~B09 틀(`mountGroupStep`).
|
|
*
|
|
* 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 연도 구분) + 도구 여섯.
|
|
* 좌 = 지형(들어올 때 받은 범위가 시점 · 종점을 못 덮으면 바로 받기 · [범위 밖 더 받기]는 시점 · 종점 · 선이
|
|
* 받은 범위 밖일 때만) · 그리기([긋기] · [연도 구분] · 제약 칸 · 긋는 해 · 고른 선) ·
|
|
* 구역 · 연도(선 통째 해 · 조각마다 해 · 고른 줄 밝힘) · [저장] [초기화].
|
|
* 지점 = D02 지점 목록(핀 · 늘 맨 위 · 585-1) · 고르기 · 고치기 = `D03_PlanAnalysis_UI_Edit`.
|
|
*
|
|
* 데이터 3층(CLAUDE.md 5장) — 긋기 · 마크 · 연도 = 캐시(`d03-plan` {version: 2, network}) · [저장] =
|
|
* 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 { createInputField, createSelectField } from "@ui/ui_template_input";
|
|
import { editMap } from "@ui/ui_template_kakao_map";
|
|
import { askConfirm, openModal } from "@ui/ui_template_modal";
|
|
import { createProgressBar } from "@ui/ui_template_progress";
|
|
import { createText } from "@ui/ui_template_text";
|
|
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 { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
|
import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler";
|
|
import { stationLabel } from "../common_util/common_util_station";
|
|
import {
|
|
fetchPlanNetwork,
|
|
fetchPlanProgress,
|
|
fetchPlanSurface,
|
|
fetchTerrainExtent,
|
|
savePlanNetwork,
|
|
startTerrain,
|
|
type PlanJob,
|
|
type PlanNetworkView,
|
|
type PlanSurface,
|
|
type TerrainExtent,
|
|
} from "./D03_PlanAnalysis_Api";
|
|
import { openPlan3d } from "./D03_PlanAnalysis_UI_3D";
|
|
import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw";
|
|
import { pointLayer } from "./D03_PlanAnalysis_UI_DrawPaint";
|
|
import { createPlanEdit } from "./D03_PlanAnalysis_UI_Edit";
|
|
import { createPlanLayers } from "./D03_PlanAnalysis_UI_Layers";
|
|
import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
|
|
import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure";
|
|
import { lineLength, stationChainages } from "./logic/D03_PlanAnalysis_Draw";
|
|
import {
|
|
emptyNetwork,
|
|
fromServer,
|
|
lineNames,
|
|
marksOf,
|
|
normalizePieces,
|
|
setLineYear,
|
|
setPieceYear,
|
|
type PlanNetwork,
|
|
type Vertex,
|
|
} from "./logic/D03_PlanAnalysis_Plan";
|
|
|
|
const POLL_MS = 1000;
|
|
/** 되돌리기 쌓는 수. */
|
|
const UNDO_MAX = 100;
|
|
/** 조각 연도 고를 수 — 사업연도 시작부터. */
|
|
const YEAR_SPAN = 10;
|
|
/** 캐시 꼴 판 — 옛 꼴(지점 · 갈래 고름)은 안 읽음. */
|
|
const DRAFT_VERSION = 2;
|
|
|
|
interface Draft {
|
|
version: number;
|
|
network: PlanNetwork;
|
|
}
|
|
|
|
const sleep = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms));
|
|
const errorText = (error: unknown): string =>
|
|
error instanceof Error ? error.message : String(error);
|
|
const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M, 1);
|
|
|
|
export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
|
const projectId = currentProjectId();
|
|
let editable = false;
|
|
let points: Vertex[] = []; // 기준 지점(D02 · 585-1)
|
|
let epsg = 0;
|
|
let yearStart = new Date().getFullYear();
|
|
let drawYear = yearStart; // 긋는 해 — 새 조각 처음 해
|
|
let network: PlanNetwork = emptyNetwork();
|
|
let savedJson = JSON.stringify(network); // 못 읽은 채 떠남 = 고침 없음
|
|
let undoStack: PlanNetwork[] = [];
|
|
let redoStack: PlanNetwork[] = [];
|
|
let surface: PlanSurface | null = null;
|
|
let have: [number, number, number, number] | null = null;
|
|
let busy = false;
|
|
let failed: string | null = null;
|
|
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,
|
|
year: () => drawYear,
|
|
onMode: () => {
|
|
if (draw.mode() !== "idle") setMeasure(false);
|
|
refresh();
|
|
},
|
|
onMark: (piece) => {
|
|
edit.select(network.pieces.find((p) => p.id === piece)?.line ?? null, piece);
|
|
openYear(piece);
|
|
},
|
|
});
|
|
const edit = createPlanEdit({
|
|
network: () => network,
|
|
base: () => points,
|
|
commit,
|
|
show: (next) => {
|
|
network = normalizePieces(next, drawYear);
|
|
map.redraw();
|
|
},
|
|
enabled: () => draw.mode() === "idle" && !measuring,
|
|
editable: () => editable,
|
|
onSelect: () => refresh(),
|
|
});
|
|
const measure = createPlanMeasure(() => z);
|
|
const pins = pointLayer(() => points);
|
|
const map = createPlanMap({
|
|
onUndo: undo,
|
|
onRedo: redo,
|
|
on3d: open3d,
|
|
onMeasure: () => setMeasure(!measuring),
|
|
});
|
|
const backdrop = createPlanLayers({
|
|
projectId,
|
|
map,
|
|
points: () => [...points, ...allVertices()],
|
|
});
|
|
map.root.append(backdrop.bar, backdrop.legend);
|
|
const main = document.createElement("div");
|
|
main.className = "d03-plan";
|
|
main.append(map.root);
|
|
|
|
const terrainText = createText("", "muted");
|
|
terrainText.classList.add("d03-plan__hint");
|
|
const bar = createProgressBar();
|
|
bar.root.hidden = true;
|
|
const extentButton = createButton({
|
|
label: "지형 범위 보기",
|
|
variant: "filled",
|
|
size: 28,
|
|
onClick: () => void openExtent([]),
|
|
});
|
|
const moreButton = createButton({
|
|
label: "범위 밖 더 받기",
|
|
variant: "filled",
|
|
size: 28,
|
|
onClick: () => void openExtent(allVertices()),
|
|
});
|
|
const terrainCard = createPanelCard("지형", false);
|
|
terrainCard.append(terrainText, bar.root, extentButton, moreButton);
|
|
const drawYearPick = createSelectField({
|
|
label: "긋는 해",
|
|
options: [],
|
|
size: 24,
|
|
ariaLabel: "긋는 해",
|
|
onChange: (value) => (drawYear = Number(value)),
|
|
});
|
|
drawYearPick.select.dataset.role = "draw-year";
|
|
const drawCard = createPanelCard("그리기", false);
|
|
drawCard.append(draw.panel, drawYearPick.root, edit.panel);
|
|
const pieceList = document.createElement("div");
|
|
pieceList.className = "d03-plan__pieces";
|
|
const pieceCard = createPanelCard("구역 · 연도", false);
|
|
pieceCard.append(pieceList);
|
|
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(terrainCard, 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 dirty = (): boolean => JSON.stringify(network) !== savedJson;
|
|
const keepDraft = (): void =>
|
|
dirty()
|
|
? writeState("d03-plan", { version: DRAFT_VERSION, network } satisfies Draft, projectId)
|
|
: clearState("d03-plan", projectId);
|
|
|
|
/** 한 걸음 — 조각을 맞춰 넣고 되돌리기에 쌓음 · 캐시. */
|
|
function commit(next: PlanNetwork): void {
|
|
undoStack = [...undoStack.slice(1 - UNDO_MAX), network];
|
|
redoStack = [];
|
|
network = normalizePieces(next, drawYear);
|
|
keepDraft();
|
|
refresh();
|
|
}
|
|
|
|
function undo(): void {
|
|
const last = undoStack.pop();
|
|
if (!last) return;
|
|
redoStack.push(network);
|
|
network = last;
|
|
keepDraft();
|
|
refresh();
|
|
}
|
|
|
|
function redo(): void {
|
|
const next = redoStack.pop();
|
|
if (!next) return;
|
|
undoStack.push(network);
|
|
network = next;
|
|
keepDraft();
|
|
refresh();
|
|
}
|
|
|
|
function setMeasure(on: boolean): void {
|
|
if (on === measuring) return;
|
|
measuring = on;
|
|
measure.clear();
|
|
if (on) draw.setMode("idle");
|
|
map.setLayers([
|
|
...backdrop.layers,
|
|
draw.layer,
|
|
edit.layer,
|
|
...(measuring ? [measure.layer] : []),
|
|
pins,
|
|
]);
|
|
refresh();
|
|
}
|
|
|
|
/** 지점 · 꺾임점이 받은 범위 밖인가(받은 범위 없음 = 밖 아님 — 그땐 [지형 범위 보기]). */
|
|
const outside = (): boolean =>
|
|
!!have &&
|
|
[...points, ...allVertices()].some(
|
|
([x, y]) => x < have![0] || x > have![2] || y < have![1] || y > have![3],
|
|
);
|
|
|
|
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,
|
|
});
|
|
map.redraw();
|
|
renderPieces();
|
|
extentButton.hidden = !editable || busy || !!surface;
|
|
moreButton.hidden = !editable || busy || !surface || !outside();
|
|
if (!busy)
|
|
terrainText.textContent = failed
|
|
? `지형 받기 실패 — ${failed}`
|
|
: !surface
|
|
? "받은 지형 없음 — 범위를 보고 받으세요(미리보기 길이 · 기울기는 지형을 받은 뒤)."
|
|
: outside()
|
|
? "지점 또는 그은 선이 받은 지형 밖입니다 — 더 받아야 기울기를 셉니다."
|
|
: "받은 지형 안";
|
|
if (failed && editable && !busy) extentButton.hidden = false;
|
|
extentButton.querySelector(".ui-btn__label")!.textContent = failed
|
|
? "다시 받기"
|
|
: "지형 범위 보기";
|
|
saveButton.hidden = resetButton.hidden = !editable;
|
|
saveButton.disabled = busy || !dirty();
|
|
}
|
|
|
|
/** 고를 해 — 사업연도 시작부터 열 해 + 조각에 있는 해. */
|
|
function yearOptions(): Array<{ value: string; text: string }> {
|
|
const years = new Set(Array.from({ length: YEAR_SPAN }, (_, i) => yearStart + i));
|
|
network.pieces.forEach((piece) => years.add(piece.year));
|
|
return [...years].sort().map((year) => ({ value: String(year), text: `${year}년` }));
|
|
}
|
|
|
|
/** 연도 구분 마크를 놓은 뒤 — 뒤 조각 해 고름을 엶. */
|
|
function openYear(piece: string): void {
|
|
// 지도 누름의 기본 동작(초점 걷음)이 끝난 뒤
|
|
setTimeout(() => {
|
|
const pick = pieceList.querySelector<HTMLSelectElement>(`[data-piece="${piece}"] select`);
|
|
if (!pick) return;
|
|
pick.scrollIntoView({ block: "nearest" });
|
|
pick.focus();
|
|
try {
|
|
pick.showPicker();
|
|
} catch {
|
|
// 사용자 누름 밖이면 못 엶 — 초점만
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderPieces(): void {
|
|
const options = yearOptions();
|
|
const names = lineNames(network.lines);
|
|
const picked = edit.selected();
|
|
const rows: HTMLElement[] = [];
|
|
network.lines.forEach((line, index) => {
|
|
const pieces = network.pieces.filter((piece) => piece.line === line.id);
|
|
if (!pieces.length) return;
|
|
const years = new Set(pieces.map((piece) => piece.year));
|
|
const whole = createSelectField({
|
|
options: years.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options,
|
|
value: years.size > 1 ? "" : String(pieces[0].year),
|
|
size: 24,
|
|
ariaLabel: "선 통째 연도",
|
|
disabled: !editable,
|
|
onChange: (value) => value && commit(setLineYear(network, line.id, Number(value))),
|
|
});
|
|
const head = document.createElement("div");
|
|
head.className = "d03-plan__piece d03-plan__piece-head";
|
|
head.classList.toggle("is-selected", picked?.line === line.id && !picked.piece);
|
|
head.dataset.line = line.id;
|
|
head.append(
|
|
createText(`${names[index]} · ${lineLength(line.vertices).toFixed(1)} m`, "muted"),
|
|
whole.root,
|
|
);
|
|
rows.push(head);
|
|
for (const piece of pieces) {
|
|
const row = document.createElement("div");
|
|
row.className = "d03-plan__piece";
|
|
row.classList.toggle("is-selected", picked?.piece === piece.id);
|
|
row.dataset.piece = piece.id;
|
|
const span = createText(
|
|
`${station(piece.from_m)} ~ ${station(piece.to_m)} (${(piece.to_m - piece.from_m).toFixed(1)} m)`,
|
|
"muted",
|
|
);
|
|
const pick = createSelectField({
|
|
options,
|
|
value: String(piece.year),
|
|
size: 24,
|
|
ariaLabel: "연도",
|
|
disabled: !editable,
|
|
onChange: (value) =>
|
|
commit(setPieceYear(network, network.pieces.indexOf(piece), Number(value))),
|
|
});
|
|
row.append(span, pick.root);
|
|
rows.push(row);
|
|
}
|
|
});
|
|
if (!rows.length) rows.push(createText("선을 그으면 구역이 생깁니다.", "muted"));
|
|
pieceList.replaceChildren(...rows);
|
|
}
|
|
|
|
/** 받기 전 범위를 카카오 지도에 보임 → [이 범위 받기]. */
|
|
async function openExtent(extra: Vertex[]): Promise<void> {
|
|
let extent: TerrainExtent;
|
|
try {
|
|
extent = await fetchTerrainExtent(projectId, null, extra);
|
|
} catch (error) {
|
|
return void showToast(`받을 범위를 읽지 못했습니다 — ${errorText(error)}`, "error");
|
|
}
|
|
const info = createText("", "muted");
|
|
const kakao = editMap({
|
|
onMove: () => undefined,
|
|
hint: "받을 범위(빨강) · 받은 범위(초록) · 도엽(옅게)",
|
|
});
|
|
const show = (view: TerrainExtent): void => {
|
|
const ring = (points: Array<[number, number]>): Array<[number, number]> =>
|
|
[...points, points[0]].map(([lon, lat]) => [lat, lon]);
|
|
const lines = [
|
|
ring(view.box),
|
|
...(view.have ? [ring(view.have)] : []),
|
|
...view.sheets.map((s) => ring(s.ring)),
|
|
];
|
|
const colors = [
|
|
"#E60046",
|
|
...(view.have ? ["#16a34a"] : []),
|
|
...view.sheets.map(() => "#94a3b8"),
|
|
];
|
|
kakao.set([], lines, colors);
|
|
const newSheets = view.sheets.filter((sheet) => !sheet.stored).length;
|
|
info.textContent =
|
|
`여유 ${view.margin_m.toFixed(0)} m · 도엽 ${view.sheets.length}매(새로 ${newSheets}) · ` +
|
|
`지형 칸 약 ${(view.cells / 1e6).toFixed(1)}백만` +
|
|
(view.covered ? " · 이미 받은 범위 안" : "");
|
|
};
|
|
const margin = createInputField({
|
|
label: "여유",
|
|
type: "number",
|
|
value: extent.margin_m.toFixed(0),
|
|
unit: "m",
|
|
size: 24,
|
|
});
|
|
const applyMargin = createButton({
|
|
label: "다시 보기",
|
|
variant: "ghost",
|
|
size: 24,
|
|
onClick: () =>
|
|
void fetchTerrainExtent(projectId, Number(margin.input.value) || null, extra)
|
|
.then((view) => show((extent = view)))
|
|
.catch((error) => showToast(errorText(error), "error")),
|
|
});
|
|
const row = document.createElement("div");
|
|
row.className = "d03-plan__row";
|
|
row.append(margin.root, applyMargin);
|
|
let close = (): void => undefined;
|
|
const go = createButton({
|
|
label: "이 범위 받기",
|
|
variant: "filled",
|
|
onClick: () => {
|
|
close();
|
|
void download(extent.margin_m, extra);
|
|
},
|
|
});
|
|
({ close } = openModal({
|
|
title: "지형 받기 — 범위 미리보기",
|
|
closeLabel: "닫기",
|
|
width: "960px",
|
|
dialogClass: "d03-plan__extent-modal",
|
|
actions: [go],
|
|
mount: (body) => {
|
|
body.classList.add("d03-plan__extent");
|
|
body.append(row, info, kakao.root);
|
|
show(extent);
|
|
},
|
|
}));
|
|
}
|
|
|
|
async function download(marginM: number, extra: Vertex[]): Promise<void> {
|
|
try {
|
|
await startTerrain(projectId, marginM, extra);
|
|
} catch (error) {
|
|
// 이미 도는 일(다른 창)이면 그것을 지켜봄
|
|
if (!/이미 돌고/.test(errorText(error))) return void showToast(errorText(error), "error");
|
|
}
|
|
await follow();
|
|
}
|
|
|
|
/** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */
|
|
async function follow(): Promise<void> {
|
|
const job = await watch();
|
|
if (!job || !root.isConnected) return;
|
|
failed = job.error;
|
|
await Promise.all([loadSurface(), backdrop.load()]);
|
|
refresh();
|
|
}
|
|
|
|
/** 지형 받기를 끝날 때까지 지켜봄 — 진행은 지형 칸에. */
|
|
async function watch(): Promise<PlanJob | null> {
|
|
busy = true;
|
|
bar.root.hidden = false;
|
|
refresh();
|
|
try {
|
|
for (;;) {
|
|
if (!root.isConnected) return null;
|
|
const job = (await fetchPlanProgress(projectId)).terrain;
|
|
bar.set(job.percent / 100);
|
|
terrainText.textContent = `지형 받기 ${job.percent}% · ${job.message ?? ""}`;
|
|
if (!job.running) {
|
|
if (job.error) showToast(job.error, "error");
|
|
return job;
|
|
}
|
|
await sleep(POLL_MS);
|
|
}
|
|
} catch (error) {
|
|
showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error");
|
|
return null;
|
|
} finally {
|
|
busy = false;
|
|
bar.root.hidden = true;
|
|
refresh();
|
|
}
|
|
}
|
|
|
|
async function loadSurface(): Promise<void> {
|
|
try {
|
|
surface = await fetchPlanSurface(projectId);
|
|
const extent = surface ? await fetchTerrainExtent(projectId, null, []) : null;
|
|
have = extent?.have_m ?? null;
|
|
} 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();
|
|
if (server?.epsg) epsg = server.epsg;
|
|
savedJson = JSON.stringify(network);
|
|
}
|
|
|
|
function serverBody(): Parameters<typeof savePlanNetwork>[1] {
|
|
return { epsg, lines: network.lines, marks: marksOf(network), pieces: network.pieces };
|
|
}
|
|
|
|
/** [저장] — 서버가 다시 셈 · 실패면 그대로(캐시 남음). 참 = 저장됨. */
|
|
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();
|
|
}
|
|
}
|
|
|
|
/** [초기화] = 시점 · 종점만 남은 빈 선을 정본에. */
|
|
async function reset(): Promise<void> {
|
|
const ok = await askConfirm({
|
|
title: "초기화",
|
|
message:
|
|
"그은 선 · 갈래 · 구역을 모두 지우고 시점 · 종점만 남깁니다. 저장된 노선도 바뀝니다.",
|
|
okLabel: "초기화",
|
|
danger: true,
|
|
});
|
|
if (!ok) return;
|
|
draw.setMode("idle");
|
|
showLoadingOverlay();
|
|
try {
|
|
takeServer(await savePlanNetwork(projectId, { epsg, lines: [], marks: [], pieces: [] }));
|
|
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,
|
|
stations: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M),
|
|
}))
|
|
.filter((item) => item.line.length > 1);
|
|
openPlan3d(projectId, surface, lines);
|
|
}
|
|
|
|
// 검증 창구 — 꼴 · 미리보기 · 제약 · 화면 자리 · 재기 · 회전 · 저장 시간(ORCA 판정)
|
|
(window as unknown as { __d03Plan?: unknown }).__d03Plan = {
|
|
network: () => network,
|
|
base: () => points,
|
|
lineNames: () => lineNames(network.lines),
|
|
screenOf: map.screenOf,
|
|
screenOfLonLat: map.screenOfLonLat,
|
|
preview: draw.preview,
|
|
stroke: draw.stroke,
|
|
selected: edit.selected,
|
|
select: edit.select,
|
|
drawYear: () => drawYear,
|
|
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;
|
|
const key = event.key.toLowerCase();
|
|
const ctrl = event.ctrlKey || event.metaKey;
|
|
const handled =
|
|
ctrl && key === "z" && !event.shiftKey
|
|
? (undo(), true)
|
|
: ctrl && (key === "y" || (key === "z" && event.shiftKey))
|
|
? (redo(), true)
|
|
: measuring && event.key === "Escape"
|
|
? (setMeasure(false), true)
|
|
: !ctrl && (draw.key(event) || edit.key(event));
|
|
if (handled) {
|
|
event.preventDefault();
|
|
map.redraw();
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
map.setLayers([...backdrop.layers, draw.layer, edit.layer, pins]);
|
|
|
|
try {
|
|
editable = !!(await fetchSessionUser())?.is_forest_service;
|
|
draw.setEditable(editable);
|
|
const view = await fetchPlanNetwork(projectId);
|
|
const base = view.base;
|
|
if (!base.points.length || !base.epsg) {
|
|
terrainText.textContent = "지점이 비었습니다 — 1단계(기준 지정)에서 정하세요.";
|
|
return;
|
|
}
|
|
points = base.points.map((p): Vertex => [p.x, p.y]);
|
|
epsg = base.epsg;
|
|
takeServer(view);
|
|
drawYear = yearStart;
|
|
drawYearPick.setOptions(yearOptions(), String(drawYear));
|
|
const draft = readState<Draft>("d03-plan", projectId);
|
|
if (draft?.version === DRAFT_VERSION && draft.network?.lines?.length) network = draft.network;
|
|
map.update({ fit: [...points, ...allVertices()], epsg });
|
|
refresh();
|
|
await loadSurface();
|
|
refresh();
|
|
await backdrop.load();
|
|
const job = (await fetchPlanProgress(projectId)).terrain;
|
|
if (job.running) await follow();
|
|
else if (editable && !job.error) {
|
|
// 들어올 때 받은 범위가 시점 · 종점을 못 덮으면 바로 받기(요구 441 1 · 실패한 뒤엔 [다시 받기])
|
|
const extent = await fetchTerrainExtent(projectId, null, []);
|
|
if (!extent.covered) await download(extent.margin_m, []);
|
|
}
|
|
} catch (error) {
|
|
terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`;
|
|
}
|
|
}
|