Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts
T
eomsangdonandClaude Opus 5.5 cdcdd5f597 feat(562-3 · 563-1): D03 손 긋기 화면 — 도구 여섯 · 그리기 · 거리재기 · 구역 연도 · 횡단 판 · 예상 코리도 걷음
- UI_Map: 바탕(위성 · 등고선) + 겹(그리기 · 거리재기) · 도구줄 부품(되돌리기 · 다시하기 · 3D · 좌 · 우 회전 · 거리재기) · B05 createMapRotation · 왼쪽 끌기 이동 끔(가운데 단추 · 휠 방향 = 설정)
- 새 UI_Draw: 시점에서 점 찍어 직선 · 미리보기 길이 · 기울기 · 제약 칸(자유 · 길이 고정 · 기울기 고정 · 둘 다 · 20 m · 9 %) · 종점 붙기 · 갈래 · 구역 마크 · Enter · Esc · Backspace
- 새 UI_Measure: 두 점 길이 · 기울기 · 높이 차(지형 heightAt)
- logic/Plan: 선 · 갈래 · 조각 꼴 다루기로 다시 씀(옛 갈래 넷 · 이은 누가 걷음)
- UI_Page · Api: 지형 범위 카카오 지도 미리보기 → [이 범위 받기] · [범위 밖 더 받기] · GET · PUT plan/network · [초기화] · 조각 연도 고름 · 캐시 판 2
- UI_3D: 지형 + 그은 선(선 고름) · 예상 코리도 · 체크 칸 걷음
- 지움: UI_Cross · UI_Edit · logic/Expect · logic/Edit · test_544_1(예상 횡단 · 예상 코리도 셈이 화면에서 걷혀 지킬 대상 없음)
- 시험: 530_3 = 손 긋기 꼴 다루기 + 서버 derive 대조로 다시 씀 · 553_4 D03 부품 단언 새 화면으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:16:08 +09:00

582 lines
21 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_UI_Page.ts
* D 예정노선 2단계 노선분석(요구서 389 3장 6~13 · PLAN 562-3 · 563-1) — B05~B09 틀(`mountGroupStep`).
*
* 메인 = 평면(위성 + 도엽 등고선 + 그은 선 · 측점 · 구역 마크) + 도구 여섯.
* 좌 = 지형(범위를 카카오 지도에 먼저 보임 → [이 범위 받기] · [범위 밖 더 받기]는 선이 받은 범위 밖일 때만) ·
* 그리기(본선 · 갈래 · 구역 마크 · 제약 칸) · 구역 · 연도(조각마다 연도) · [저장] [초기화].
*
* 데이터 3층(CLAUDE.md 5장) — 긋기 · 마크 · 연도 = 캐시(`d03-plan` {version: 2, network}) · [저장] =
* PUT plan/network(서버가 길이 · 측점 · 조각 · 연도 행을 다시 셈 · 응답으로 바꿈) · [초기화] = 빈 선 PUT.
* 되돌리기 · 다시하기 = 이 화면 스택(걸음 = `commit` 한 번). 화면 전체 잠금 = [저장] · [초기화]만.
* 공유 회사(산림청 아님) = 보기만 — 긋기 · 받기 · [저장] 없음.
* ========================================================================== */
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 { 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 { 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 } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
createNormalizer,
type GeoJsonCollection,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { prepareLayer } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare";
import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler";
import { stationLabel } from "../common_util/common_util_station";
import {
fetchPlanBase,
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 { 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,
lineName,
marksOf,
normalizePieces,
setPieceYear,
type PlanNetwork,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
const POLL_MS = 1000;
/** 등고선을 받아 올 그은 선 둘레(m). */
const CONTOUR_PAD_M = 600;
/** 되돌리기 쌓는 수. */
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 = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
let editable = false;
let start: Vertex = [0, 0];
let end: Vertex = [0, 0];
let epsg = 0;
let yearStart = new Date().getFullYear();
let network: PlanNetwork = emptyNetwork(start);
let savedJson = "";
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: () => ({ start, end }),
network: () => network,
heightAt: () => z,
commit,
onMode: () => {
if (draw.mode() !== "idle") setMeasure(false);
refresh();
},
});
const measure = createPlanMeasure(() => z);
const map = createPlanMap({
onUndo: undo,
onRedo: redo,
on3d: open3d,
onMeasure: () => setMeasure(!measuring),
});
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 drawCard = createPanelCard("그리기", false);
drawCard.append(draw.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);
mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, { mainContent: main, leftPanel: panel });
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, yearStart);
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(measuring ? [draw.layer, measure.layer] : [draw.layer]);
refresh();
}
/** 꺾임점이 받은 범위 밖인가(받은 범위 없음 = 밖 아님 — 그땐 [지형 범위 보기]). */
const outside = (): boolean =>
!!have &&
allVertices().some(([x, y]) => x < have![0] || x > have![2] || y < have![1] || y > have![3]);
function refresh(): void {
draw.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 renderPieces(): void {
const years = new Set(Array.from({ length: YEAR_SPAN }, (_, i) => yearStart + i));
network.pieces.forEach((piece) => years.add(piece.year));
const options = [...years].sort().map((year) => ({ value: String(year), text: `${year}년` }));
const rows: HTMLElement[] = [];
network.lines.forEach((line, index) => {
const pieces = network.pieces.filter((piece) => piece.line === line.id);
if (!pieces.length) return;
const head = createText(
`${lineName(index)} · ${lineLength(line.vertices).toFixed(1)} m`,
"muted",
);
head.classList.add("d03-plan__piece-head");
rows.push(head);
for (const piece of pieces) {
const row = document.createElement("div");
row.className = "d03-plan__piece";
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(), loadBackground()]);
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");
}
}
async function loadBackground(): Promise<void> {
const meta = await fetchVWorldMeta(projectId, "satellite").catch(() => null);
if (!meta) return map.update({ meta: null, image: null, contours: null });
const image = new Image();
image.onload = () => map.redraw();
// 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀
image.src = `${getVWorldMapUrl(projectId, "satellite")}&v=${meta.x_min}_${meta.x_max}_${meta.y_min}_${meta.y_max}`;
const points = [start, end, ...allVertices()];
const xs = points.map(([x]) => x);
const ys = points.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));
const contours = await fetchCachedSheetLayer<GeoJsonCollection>(
projectId,
DRAINAGE_SHEET_LAYERS[0],
"route",
bbox.join(","),
)
.then((collection) => prepareLayer(collection, createNormalizer(meta), ["등고수치"]))
.catch(() => null);
map.update({ meta, image, contours });
}
/** 서버 응답 → 지금 값 · 저장본(빈 선 = 시점만). */
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(start);
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 lines = network.lines
.map((line, index) => ({
name: lineName(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: () => ({ start, end }),
screenOf: map.screenOf,
preview: draw.preview,
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,
};
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);
if (handled) {
event.preventDefault();
map.redraw();
}
};
window.addEventListener("keydown", onKey);
map.setLayers([draw.layer]);
try {
editable = !!(await fetchSessionUser())?.is_forest_service;
draw.setEditable(editable);
const base = await fetchPlanBase(projectId);
if (!base.start || !base.end || !base.epsg) {
terrainText.textContent = "시점 · 종점이 비었습니다 — 1단계(기준 지정)에서 정하세요.";
return;
}
start = [base.start.x, base.start.y];
end = [base.end.x, base.end.y];
epsg = base.epsg;
takeServer(await fetchPlanNetwork(projectId));
const draft = readState<Draft>("d03-plan", projectId);
if (draft?.version === DRAFT_VERSION && draft.network?.lines?.length) network = draft.network;
map.update({ fit: [start, end, ...allVertices()] });
refresh();
await loadSurface();
refresh();
await loadBackground();
if ((await fetchPlanProgress(projectId)).terrain.running) await follow();
} catch (error) {
terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`;
}
}