Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts
T

694 lines
26 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_UI_Page.ts
* D 예정노선 2단계 분석 화면(요구서 345 3-3 · PLAN 530-3) — B05~B09 틀(`mountGroupStep`).
*
* 메인 = 평면(위성 + 도엽 등고선 + 지점 + 연도 구간 노선 · 측점) · 하 = 누른 측점 지반선 횡단 ·
* 좌 = 지형 받기 진행 · 연도별 갈래 넷 카드 · [분석] [저장] [3D].
* 열리면 지형 받기(뒤에서 · 받은 지형 안이면 건너뜀) · 끈 지점이 받은 범위 밖 = 「범위 밖 — 더 받기」.
*
* 데이터 3층(CLAUDE.md 5장) — 지점 끌기 · 갈래 고름 · 꺾임점 고침(543) = 캐시(`d03-plan`) · [저장] =
* PUT plan/analysis(끈 지점은 서버가 D02 규칙으로 정본에 씀 · 노선은 서버 저장본으로 · 고친 노선은
* 마디로 서버가 다시 셈). 꺾임점 고침 = 브라우저 즉시 다시 그음 → 길이 · 측점 · 누른 측점 횡단 ·
* 되돌리기(Ctrl+Z). 화면 전체 잠금 = [저장]만.
* 공유 회사(산림청 아님) = 보기만 — 끌기 · [분석] · [저장] · 받기 없음.
* ========================================================================== */
import "./D03_PlanAnalysis_UI_Style.css";
import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import {
createButton,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { fetchCachedSheetLayer, DRAINAGE_SHEET_LAYERS } from "../A00_Common/b_asset_cache";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import {
fetchVWorldMeta,
getVWorldMapUrl,
type VWorldMeta,
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
createNormalizer,
type GeoJsonCollection,
type PreparedLayer,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { prepareLayer } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare";
import { stationLabel } from "../common_util/common_util_station";
import type { StationGround } from "../B05_Profile/logic/B05_Profile_Sections_Ground";
import {
fetchPlanAnalysis,
fetchPlanPoints,
fetchPlanProgress,
fetchYearSurfaces,
savePlanAnalysis,
startPlanJob,
type PlanAnalysisView,
type SavedEdit,
type PlanJob,
type YearSurface,
} from "./D03_PlanAnalysis_Api";
import { openPlan3d } from "./D03_PlanAnalysis_UI_3D";
import { createPlanCross } from "./D03_PlanAnalysis_UI_Cross";
import { createPlanEditLayer, type EditTarget } from "./D03_PlanAnalysis_UI_Edit";
import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
import {
chainRows,
joinedLine,
nodesInReach,
nodesOf,
PLAN_MODES,
pointsOf,
rowAt,
sameNodes,
stationChainages,
withEdits,
type ChainRow,
type RouteEdit,
type Vertex,
type YearAnalysis,
} from "./logic/D03_PlanAnalysis_Plan";
import {
buildEdit,
editFromRoute,
moveNode,
sameEdit,
type PlanEdit,
} from "./logic/D03_PlanAnalysis_Edit";
const MODE_LABEL: Record<string, string> = {
gradeline: "영선",
earthwork: "토공 최소",
gentle: "기울기 최소",
weighted: "비중 계산",
};
const POLL_MS = 1000;
/** 등고선을 받아 올 지점 둘레(m). */
const CONTOUR_PAD_M = 600;
/** 되돌리기 쌓는 수. */
const UNDO_MAX = 50;
interface Draft {
nodes: Vertex[] | null;
choices: Record<number, string>;
/** 꺾임점 고침(연도 → 고침) — 있으면 이것이 지금 값(서버 저장 고침보다 먼저). */
edits?: Record<number, PlanEdit>;
}
type Snapshot = { edits: Record<number, PlanEdit>; choices: Record<number, string> };
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 = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
const editLayer = createPlanEditLayer({
begin: remember,
change: onEdit,
blocked: (message) => showToast(message, "info"),
});
const map = createPlanMap({ onDrag, onDragEnd, onStation, overlay: editLayer });
const cross = createPlanCross();
const main = document.createElement("div");
main.className = "d03-plan";
main.append(map.root, cross.root);
const terrainText = document.createElement("p");
terrainText.className = "d03-plan__hint";
const bar = document.createElement("progress");
bar.className = "d03-plan__bar";
bar.max = 100;
bar.hidden = true;
const more = createButton({
label: "범위 밖 — 더 받기",
variant: "filled",
onClick: () => void download(),
});
more.hidden = true;
const yearList = document.createElement("div");
yearList.className = "d03-plan__years";
const analyzeButton = createButton({
label: "분석",
variant: "ghost",
onClick: () => void analyze(),
});
const saveButton = createButton({ label: "저장", variant: "filled", onClick: () => void save() });
const viewButton = createButton({ label: "3D", variant: "ghost", onClick: () => open3d() });
const undoButton = createButton({ label: "되돌리기", variant: "ghost", onClick: () => undo() });
undoButton.title = "꺾임점 고침 · 갈래 고름 한 걸음 되돌림(Ctrl+Z)";
const actions = document.createElement("div");
actions.className = "ui-sidebar-actions";
actions.append(analyzeButton, saveButton, viewButton, undoButton);
const terrainCard = createPanelCard("지형", false);
terrainCard.append(terrainText, bar, more);
const routeCard = createPanelCard("연도별 노선", false);
routeCard.append(yearList);
const panel = document.createElement("div");
panel.className = "ui-sidebar-panel d03-plan__panel";
panel.append(terrainCard, routeCard, actions);
attachCollapsible(panel);
mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, { mainContent: main, leftPanel: panel });
root.querySelector(".ui-workflow-layout")?.classList.add("d03-plan-layout");
let years: number[] = [];
let savedNodes: Vertex[] = [];
let nodes: Vertex[] = [];
let choices: Record<number, string> = {};
let startM = 0;
let targetM: number | null = null; // 등록 목표 끝 누가
let analysis: PlanAnalysisView | null = null;
let surfaces = new Map<number, YearSurface>();
let rows: ChainRow[] = [];
let results: YearAnalysis[] = [];
let selected: number | null = null;
let editable = false;
let busy: "terrain" | "analyze" | null = null;
let lastCross: StationGround | null = null;
// 꺾임점 고침 — 지금 값 · 서버 저장 고침 · 캐시에서 읽은 값(없으면 서버 값으로) · 되돌리기
let edits: Record<number, PlanEdit> = {};
let serverEdits: Record<number, SavedEdit> = {};
let draftEdits: Record<number, PlanEdit> | undefined;
let history: Snapshot[] = [];
let lastSaveMs: number | null = null;
const moved = (): boolean => !sameNodes(nodes, savedNodes);
const sentPoints = () => (moved() ? pointsOf(years, nodes) : null);
const reach = (): boolean[] =>
nodesInReach(
nodes,
years.map((year) => {
const grid = surfaces.get(year)?.grid;
return grid
? {
x0: grid.x[0],
x1: grid.x[grid.x.length - 1],
y0: grid.y[0],
y1: grid.y[grid.y.length - 1],
}
: null;
}),
);
const keepDraft = (): void =>
writeState(
"d03-plan",
{ nodes: moved() ? nodes : null, choices, edits } satisfies Draft,
projectId,
);
/** 고치기 직전 — 되돌리기에 지금 값을 쌓음. */
function remember(): void {
history = [...history.slice(1 - UNDO_MAX), structuredClone({ edits, choices })];
}
function undo(): void {
const last = history.pop();
if (!last) return;
({ edits, choices } = last);
keepDraft();
refresh();
repick();
}
/** 누른 측점 횡단을 지금 노선으로 다시. */
function repick(): void {
if (selected !== null && rowAt(rows, selected)) onStation(selected);
}
function onEdit(year: number, edit: PlanEdit, final: boolean): void {
edits = { ...edits, [year]: edit };
refresh();
if (!final) return;
keepDraft();
repick();
}
/** 연도의 지금 고침 — 고른 갈래 · 분석 지문이 같을 때만. */
const editOf = (result: YearAnalysis, mode: string | null): PlanEdit | null => {
const own = edits[result.year];
return own && own.base_mode === mode && own.base_fingerprint === result.fingerprint
? own
: null;
};
/** 연도 목록에서 뺌. */
const without = (from: Record<number, PlanEdit>, drop: number[]): Record<number, PlanEdit> =>
Object.fromEntries(Object.entries(from).filter(([year]) => !drop.includes(Number(year))));
/** 저장본 · 고름 → 이은 누가 · 중심선 · 측점 → 평면 · 카드 · 단추. */
function refresh(): void {
const raw: YearAnalysis[] = years.map(
(year, i) =>
analysis?.years.find((entry) => entry.year === year)?.analysis ?? {
year,
start: nodes[i],
end: nodes[i + 1],
routes: {},
},
);
// 고름 = 저장된 갈래(서버 이은 누가) 위에 캐시 고름
const saved = Object.fromEntries((analysis?.chain ?? []).map((row) => [row.year, row.mode]));
const picks = { ...saved, ...choices };
// 꺾임점 고침 — 고른 갈래 노선의 마디(고침 · 없으면 엔진 노선) → 선 → 그 갈래 자리에 갈아 끼움
const targets: EditTarget[] = [];
const routed: Record<number, RouteEdit> = {};
chainRows(raw, picks, startM).forEach((row, i) => {
const result = raw[i];
const route = row.mode ? result.routes[row.mode] : undefined;
if (!row.mode || !route?.nodes || !result.fingerprint) return;
const minRadiusM = result.criteria?.min_radius_m ?? 0;
const own = editOf(result, row.mode);
const built = buildEdit(
own ?? editFromRoute(route, row.mode, result.fingerprint),
minRadiusM,
);
targets.push({ year: result.year, ...built, minRadiusM });
if (own)
routed[result.year] = {
mode: row.mode,
planned: built.line.vertices,
metrics: sameEdit(own, serverEdits[result.year])
? serverEdits[result.year].metrics
: null,
};
});
editLayer.setTargets(editable ? targets : []);
results = withEdits(raw, routed);
rows = chainRows(results, picks, startM);
const line = joinedLine(results, rows);
const others: Vertex[][] = results.flatMap((result, i) => {
const routes = PLAN_MODES.filter((mode) => mode !== rows[i].mode)
.map((mode) => result.routes[mode]?.planned)
.filter((planned): planned is Vertex[] => !!planned && planned.length > 1);
return rows[i].mode ? routes : [[nodes[i], nodes[i + 1]]];
});
const inReach = reach();
map.update({
nodes,
reach: inReach,
others,
line,
startM,
stations: stationChainages(rows, PLAN_STATION_INTERVAL_M),
selected,
editable,
});
renderYears();
const outside = inReach.some((ok) => !ok);
const missing = rows.some((row) => !row.mode);
more.hidden = !editable || !outside || busy !== null;
if (busy === null)
terrainText.textContent = outside
? "빨간 고리 지점이 받은 지형 밖입니다 — 더 받아야 분석합니다."
: `받은 지형 안 · 연도 지형면 ${surfaces.size}/${years.length}`;
analyzeButton.hidden = saveButton.hidden = !editable;
analyzeButton.disabled = busy !== null || outside;
saveButton.disabled = busy !== null || missing;
saveButton.title = missing
? "모든 연도에 노선이 있어야 저장합니다 — [분석]을 먼저 하세요."
: "";
viewButton.disabled = !rows.some((row) => row.mode && surfaces.has(row.year));
undoButton.hidden = !editable;
undoButton.disabled = !history.length;
}
function renderYears(): void {
yearList.replaceChildren(
...results.map((result, i) => {
const row = rows[i];
const box = document.createElement("div");
box.className = "d03-plan__year";
box.dataset.year = String(result.year);
const head = document.createElement("div");
head.className = "d03-plan__year-head";
head.textContent = row.mode
? `${result.year}년 · ${stationLabel(row.start_chainage_m, PLAN_STATION_INTERVAL_M, 1)} ~ ` +
`${stationLabel(row.end_chainage_m, PLAN_STATION_INTERVAL_M, 1)} (${row.length_m.toFixed(1)} m)`
: `${result.year}년 · 분석 전 — [분석]을 누르세요`;
box.append(head);
if (row.mode && editOf(result, row.mode)) box.append(editLine(result.year));
for (const mode of PLAN_MODES) {
const route = result.routes[mode];
if (!route) continue;
const card = document.createElement("button");
card.type = "button";
card.className = "d03-plan__mode" + (row.mode === mode ? " is-picked" : "");
card.dataset.mode = mode;
const metrics = route.metrics;
card.disabled = !!route.error || !metrics;
card.title = route.error ?? "";
const name = document.createElement("strong");
name.textContent = MODE_LABEL[mode];
const values = document.createElement("span");
values.textContent = !metrics
? "노선 없음"
: metrics.max_grade_pct === undefined
? `연장 ${metrics.length_m.toFixed(1)} m · 기울기 · 위반 = 저장 때 셈`
: `연장 ${metrics.length_m.toFixed(1)} m · 최대 ${metrics.max_grade_pct.toFixed(1)} % · 위반 ${metrics.over_limit?.total ?? 0}`;
card.append(name, values);
card.addEventListener("click", () => {
if (row.mode === mode) return;
remember();
choices = { ...choices, [result.year]: mode };
keepDraft();
refresh();
});
box.append(card);
}
return box;
}),
...targetLine(),
);
}
/** 고친 연도 한 줄 — 「꺾임점 고침」 · [원래 노선](고침 버림 · 되돌리기 됨). */
function editLine(year: number): HTMLElement {
const line = document.createElement("div");
line.className = "d03-plan__year-head";
line.textContent = sameEdit(edits[year], serverEdits[year])
? "꺾임점 고침(저장됨) "
: "꺾임점 고침 — [저장] 전 ";
const reset = createButton({
label: "원래 노선",
variant: "ghost",
size: "sm",
onClick: () => {
remember();
edits = without(edits, [year]);
keepDraft();
refresh();
repick();
},
});
reset.hidden = !editable;
line.append(reset);
return line;
}
/** 이은 끝 누가 대 등록 목표(요구서 3-1) — 모든 연도에 노선이 있을 때만 */
function targetLine(): HTMLElement[] {
const last = rows[rows.length - 1];
if (targetM === null || !last || rows.some((row) => !row.mode)) return [];
const diff = last.end_chainage_m - targetM;
const line = document.createElement("div");
line.className = "d03-plan__year-head d03-plan__target";
line.textContent =
`끝 ${stationLabel(last.end_chainage_m, PLAN_STATION_INTERVAL_M, 1)} · ` +
`목표 ${stationLabel(targetM, PLAN_STATION_INTERVAL_M, 1)} · ` +
`차이 ${diff >= 0 ? "+" : ""}${diff.toFixed(1)} m`;
return [line];
}
function onDrag(index: number, point: Vertex): void {
nodes = nodes.map((node, i) => (i === index ? point : node));
map.update({ nodes, reach: reach() });
}
function onDragEnd(index: number): void {
// 끈 지점의 앞 · 뒤 연도 노선은 지점이 달라 빔 — 다시 [분석] · 그 연도 꺾임점 고침도 버림
const lost = [years[index - 1], years[index]].filter(
(year) => year !== undefined && edits[year],
);
if (lost.length) {
edits = without(edits, lost);
history = [];
showToast(`지점을 옮겨 ${lost.join(" · ")}년 꺾임점 고침을 버렸습니다.`, "info");
}
if (analysis)
analysis = {
...analysis,
years: analysis.years.map((entry, i) =>
i === index - 1 || i === index ? { ...entry, analysis: null } : entry,
),
};
selected = null;
lastCross = null;
cross.clear("지점을 옮겼습니다 — [분석] 뒤 측점을 누르세요.");
keepDraft();
refresh();
}
function onStation(chainage: number): void {
const row = rowAt(rows, chainage);
const surface = row ? surfaces.get(row.year) : undefined;
if (!row?.mode || !surface) return;
const result = results.find((entry) => entry.year === row.year)!;
selected = chainage;
map.update({ selected });
lastCross = cross.show(
chainage,
row.year,
result.routes[row.mode].planned ?? [],
Math.max(0, chainage - row.start_chainage_m),
surface.grid,
);
}
async function loadBackground(): Promise<void> {
let meta: VWorldMeta | null = null;
try {
meta = await fetchVWorldMeta(projectId, "satellite");
} catch {
meta = null; // 위성 못 받음 — 가짜 도엽 위에 그림
}
let image: HTMLImageElement | null = null;
let contours: PreparedLayer | null = null;
if (meta) {
image = new Image();
image.onload = () => map.update({});
// 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀
image.src = `${getVWorldMapUrl(projectId, "satellite")}&v=${meta.x_min}_${meta.x_max}_${meta.y_min}_${meta.y_max}`;
const xs = nodes.map(([x]) => x);
const ys = nodes.map(([, y]) => y);
const bbox = [
Math.min(...xs) - CONTOUR_PAD_M,
Math.min(...ys) - CONTOUR_PAD_M,
Math.max(...xs) + CONTOUR_PAD_M,
Math.max(...ys) + CONTOUR_PAD_M,
].map((v) => v.toFixed(0));
try {
const collection = await fetchCachedSheetLayer<GeoJsonCollection>(
projectId,
DRAINAGE_SHEET_LAYERS[0],
"route",
bbox.join(","),
);
contours = prepareLayer(collection, createNormalizer(meta), ["등고수치"]);
} catch {
contours = null;
}
}
map.update({ meta, image, contours });
}
async function loadSurfaces(): Promise<void> {
try {
surfaces = await fetchYearSurfaces(projectId);
} catch (error) {
showToast(`지형면을 읽지 못했습니다 — ${errorText(error)}`, "error");
}
}
async function loadAnalysis(): Promise<void> {
try {
analysis = await fetchPlanAnalysis(projectId, sentPoints());
startM = analysis.start_chainage_m;
targetM = analysis.end_chainage_target_m ?? null;
serverEdits = Object.fromEntries(
analysis.years.flatMap((entry) => (entry.edit ? [[entry.year, entry.edit]] : [])),
);
edits =
draftEdits ??
Object.fromEntries(
Object.entries(serverEdits).map(([year, { metrics: _m, ...edit }]) => [year, edit]),
);
// 분석이 바뀐 연도(지문 다름)의 고침은 버림
const prints = new Map(analysis.years.map((e) => [e.year, e.analysis?.fingerprint]));
const stale = Object.keys(edits)
.map(Number)
.filter((year) => prints.get(year) !== edits[year].base_fingerprint);
if (stale.length) {
edits = without(edits, stale);
if (draftEdits)
showToast(`분석이 바뀌어 ${stale.join(" · ")}년 꺾임점 고침을 버렸습니다.`, "info");
}
draftEdits = edits;
} catch (error) {
analysis = null;
showToast(`분석 저장본을 읽지 못했습니다 — ${errorText(error)}`, "error");
}
}
/** 뒤에서 도는 일을 끝날 때까지 지켜봄 — 진행은 지형 칸에. */
async function watch(kind: "terrain" | "analyze"): Promise<PlanJob | null> {
busy = kind;
bar.hidden = false;
refresh();
try {
for (;;) {
if (!root.isConnected) return null;
const job = (await fetchPlanProgress(projectId))[kind];
bar.value = job.percent;
terrainText.textContent = `${kind === "terrain" ? "지형 받기" : "분석"} ${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 = null;
bar.hidden = true;
refresh();
}
}
async function run(kind: "terrain" | "analyze"): Promise<PlanJob | null> {
try {
await startPlanJob(projectId, kind, sentPoints());
} catch (error) {
// 이미 도는 일(다른 창 · D02 [>])이면 그것을 지켜봄
if (!/이미 돌고/.test(errorText(error))) {
showToast(errorText(error), "error");
return null;
}
}
return watch(kind);
}
async function download(): Promise<void> {
const job = await run("terrain");
if (!job || !root.isConnected) return;
await Promise.all([loadSurfaces(), loadBackground()]);
refresh();
}
async function analyze(): Promise<void> {
const job = await run("analyze");
if (!job || !root.isConnected) return;
await loadAnalysis();
refresh();
}
async function save(): Promise<void> {
const picked = Object.fromEntries(
rows.filter((row) => row.mode).map((row) => [row.year, row.mode as string]),
);
const sent = Object.fromEntries(
results.flatMap((result, i) => {
const edit = editOf(result, rows[i].mode);
return edit ? [[result.year, edit]] : [];
}),
);
showLoadingOverlay();
try {
const began = performance.now();
await savePlanAnalysis(projectId, picked, sentPoints(), sent);
lastSaveMs = Math.round(performance.now() - began);
clearState("d03-plan", projectId);
draftEdits = undefined;
history = [];
await loadPoints();
await loadAnalysis();
refresh();
showToast("예정노선을 저장했습니다.", "success");
} catch (error) {
showToast(`저장하지 못했습니다 — ${errorText(error)}`, "error");
} finally {
hideLoadingOverlay();
}
}
function open3d(): void {
const items = rows.flatMap((row, i) => {
const surface = surfaces.get(row.year);
const line = row.mode ? results[i].routes[row.mode].planned : undefined;
if (!surface || !line) return [];
const stations = stationChainages([row], PLAN_STATION_INTERVAL_M).map((chainage) => ({
chainage,
localM: chainage - row.start_chainage_m,
}));
return [{ year: row.year, modelId: surface.modelId, grid: surface.grid, line, stations }];
});
const first = selected !== null ? rowAt(rows, selected)?.year : undefined;
openPlan3d(projectId, items, first ?? items[0]?.year ?? 0);
}
async function loadPoints(): Promise<boolean> {
const view = await fetchPlanPoints(projectId);
const empty = view.years.find((entry) => !entry.start || !entry.end);
if (empty) {
terrainText.textContent = `${empty.year}년 지점이 비었습니다 — 1단계(기준 지정)에서 정하세요.`;
return false;
}
years = view.years.map((entry) => entry.year);
savedNodes = nodesOf(
view.years.map((entry) => ({
start: [entry.start!.x, entry.start!.y] as Vertex,
end: [entry.end!.x, entry.end!.y] as Vertex,
})),
);
const draft = readState<Draft>("d03-plan", projectId);
nodes = draft?.nodes?.length === savedNodes.length ? draft.nodes : savedNodes;
choices = draft?.choices ?? {};
draftEdits = draft?.edits;
return true;
}
// 검증 창구 — 측점 화면 자리 · 지금 누가 · 마디 · 꺾임점 고침 · 저장 시간(ORCA 판정)
(window as unknown as { __d03Plan?: unknown }).__d03Plan = {
stationScreen: map.stationScreen,
screenOf: map.screenOf,
rows: () => rows,
nodes: () => nodes,
pick: onStation,
cross: () => lastCross,
edits: () => edits,
targets: () => editLayer.targets(),
results: () => results,
saveMs: () => lastSaveMs,
// 가이드 그림 · 검증 — 첫 고침 연도 마디 하나를 (dx, dy) m 옮김(끌기와 같은 길)
nudge: (index: number, dx: number, dy: number) => {
const target = editLayer.targets()[0];
const node = target?.edit.nodes[index];
if (!node) return;
const step = moveNode(target.edit, index, [node[0] + dx, node[1] + dy], target.minRadiusM);
if ("blocked" in step) return showToast(step.blocked, "info");
remember();
onEdit(target.year, step.edit, true);
},
};
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 (editable && !typing && (event.ctrlKey || event.metaKey) && event.key === "z") {
event.preventDefault();
undo();
}
};
window.addEventListener("keydown", onKey);
try {
editable = !!(await fetchSessionUser())?.is_forest_service;
if (!(await loadPoints())) return;
await Promise.all([loadSurfaces(), loadAnalysis()]);
refresh();
map.fit();
await loadBackground();
const progress = await fetchPlanProgress(projectId);
if (progress.analyze.running) await analyze();
if (progress.terrain.running) await download();
else if (editable && reach().some((ok) => !ok)) await download();
} catch (error) {
terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`;
}
}