Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts
T

469 lines
17 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_UI_Page.ts
* D 예정노선 2단계 분석 화면(요구서 345 3-3 · PLAN 530-3) — B05~B09 틀(`mountGroupStep`).
*
* 메인 = 평면(위성 + 도엽 등고선 + 지점 + 연도 구간 노선 · 측점) · 하 = 누른 측점 지반선 횡단 ·
* 좌 = 지형 받기 진행 · 연도별 갈래 넷 카드 · [분석] [저장] [3D].
* 열리면 지형 받기(뒤에서 · 받은 지형 안이면 건너뜀) · 끈 지점이 받은 범위 밖 = 「범위 밖 — 더 받기」.
*
* 데이터 3층(CLAUDE.md 5장) — 지점 끌기 · 갈래 고름 = 캐시(`d03-plan`) · [저장] = PUT plan/analysis
* (끈 지점은 서버가 D02 규칙으로 정본에 씀 · 노선은 서버 저장본으로 다시 셈). 화면 전체 잠금 = [저장]만.
* 공유 회사(산림청 아님) = 보기만 — 끌기 · [분석] · [저장] · 받기 없음.
* ========================================================================== */
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 {
fetchPlanAnalysis,
fetchPlanPoints,
fetchPlanProgress,
fetchYearSurfaces,
savePlanAnalysis,
startPlanJob,
type PlanAnalysisView,
type PlanJob,
type YearSurface,
} from "./D03_PlanAnalysis_Api";
import { openPlan3d } from "./D03_PlanAnalysis_UI_3D";
import { createPlanCross } from "./D03_PlanAnalysis_UI_Cross";
import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
import {
chainRows,
joinedLine,
nodesInReach,
nodesOf,
PLAN_MODES,
pointsOf,
rowAt,
sameNodes,
stationChainages,
type ChainRow,
type Vertex,
type YearAnalysis,
} from "./logic/D03_PlanAnalysis_Plan";
const MODE_LABEL: Record<string, string> = {
gradeline: "영선",
earthwork: "토공 최소",
gentle: "기울기 최소",
weighted: "비중 계산",
};
const POLL_MS = 1000;
/** 등고선을 받아 올 지점 둘레(m). */
const CONTOUR_PAD_M = 600;
interface Draft {
nodes: Vertex[] | null;
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 map = createPlanMap({ onDrag, onDragEnd, onStation });
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 actions = document.createElement("div");
actions.className = "ui-sidebar-actions";
actions.append(analyzeButton, saveButton, viewButton);
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 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;
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 } satisfies Draft, projectId);
/** 저장본 · 고름 → 이은 누가 · 중심선 · 측점 → 평면 · 카드 · 단추. */
function refresh(): void {
results = years.map(
(year, i) =>
analysis?.years.find((entry) => entry.year === year)?.analysis ?? {
year,
start: nodes[i],
end: nodes[i + 1],
routes: {},
},
);
rows = chainRows(results, choices, 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));
}
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);
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.length_m.toFixed(1)} m · 최대 ${(metrics.max_grade_pct ?? 0).toFixed(1)} % · 위반 ${metrics.over_limit?.total ?? 0}`
: "노선 없음";
card.append(name, values);
card.addEventListener("click", () => {
choices = { ...choices, [result.year]: mode };
keepDraft();
refresh();
});
box.append(card);
}
return box;
}),
);
}
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 {
// 끈 지점의 앞 · 뒤 연도 노선은 지점이 달라 빔 — 다시 [분석]
if (analysis)
analysis = {
...analysis,
years: analysis.years.map((entry, i) =>
i === index - 1 || i === index ? { ...entry, analysis: null } : entry,
),
};
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 });
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;
} 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);
}
} finally {
busy = null;
bar.hidden = true;
}
}
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]),
);
showLoadingOverlay();
try {
await savePlanAnalysis(projectId, picked, sentPoints());
clearState("d03-plan", projectId);
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 ?? {};
return true;
}
// 검증 창구 — 측점 화면 자리 · 지금 누가 · 마디(ORCA 판정)
(window as unknown as { __d03Plan?: unknown }).__d03Plan = {
stationScreen: map.stationScreen,
rows: () => rows,
nodes: () => nodes,
pick: onStation,
};
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)}`;
}
}