469 lines
17 KiB
TypeScript
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)}`;
|
|
}
|
|
}
|