Files
Aislo/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts
T

531 lines
21 KiB
TypeScript

import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import {
fetchGisGeoJson,
fetchVWorldMeta,
getVWorldMapUrl,
type VWorldMeta,
} from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import {
computeMapRect,
createNormalizer,
drawFilledRing,
drawPreparedLayer,
drawRidgeRing,
prepareLayer,
prepareMetricPolyline,
type GeoJsonCollection,
type MapRect,
type Normalizer,
type PreparedLayer,
type ViewState,
} from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender";
import {
fetchDrainageBasins,
type DrainageBasin,
type RoutePoint,
} from "./B05_wf2_Route_Api_Fetch";
import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes";
// 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널.
// 하단 패널이 닫히면 이 패널도 함께 화면에서 사라진다(부모 안에 들어 있으므로 자동).
// 지도는 B04에서 분리한 렌더 엔진(B04_wf1_Surface_UI_MapRender)을 그대로 재사용해
// 사전 투영·LOD·뷰포트 컬링·커서 중심 줌 동작을 동일하게 얻는다.
/** 배수유역 산정의 근거가 되는 도엽 레이어. 3D는 쓰지 않는다(사용자 지시). */
const DRAINAGE_LAYERS = ["도엽_등고선", "도엽_하천중심선", "도엽_표고점"] as const;
type DrainageLayer = (typeof DRAINAGE_LAYERS)[number];
const LAYER_COLORS: Record<DrainageLayer, string> = {
도엽_등고선: "#a5b4fc",
도엽_하천중심선: "#2563eb",
도엽_표고점: "#f9a8d4",
};
const LAYER_LABELS: Record<DrainageLayer, string> = {
도엽_등고선: "등고선",
도엽_하천중심선: "세류",
도엽_표고점: "표고점",
};
const ROUTE_COLOR = "#f97316";
const COLLAPSED_KEY = "b05-route-drainage-collapsed";
/** 유역 오버레이 파스텔 색상. 번호 순으로 돌려쓴다(사용자 지시: 파스텔톤). */
const BASIN_COLORS = [
"rgba(167, 216, 199, 0.45)",
"rgba(247, 208, 168, 0.45)",
"rgba(186, 199, 240, 0.45)",
"rgba(241, 183, 199, 0.45)",
"rgba(214, 226, 168, 0.45)",
"rgba(202, 186, 227, 0.45)",
"rgba(168, 214, 232, 0.45)",
"rgba(240, 219, 168, 0.45)",
] as const;
export interface DrainagePanel {
root: HTMLElement;
/** 프로젝트가 정해지면 배경지도·도엽 레이어를 불러온다. */
load: (projectId: string) => void;
/** 확정된 노선 평면 선형(사업지 좌표계 m)을 지도 위에 겹친다. */
setRoute: (points: ReadonlyArray<RoutePoint>) => void;
dispose: () => void;
}
export function createDrainagePanel(): DrainagePanel {
const root = document.createElement("aside");
root.className = "b05-drainage";
const panelHandle = createWorkflowPanelHandle("side");
const header = document.createElement("div");
header.className = "b05-drainage__header";
const title = document.createElement("h3");
title.textContent = "배수유역도";
const layerButtons = document.createElement("div");
layerButtons.className = "b05-drainage__layers";
header.append(title, layerButtons);
// 유역 산정 실행 버튼 — 후보 제안·유역 산정을 한 번에 돌린다(자동 제안 + 사용자 확인 흐름).
const analyzeButton = document.createElement("button");
analyzeButton.type = "button";
analyzeButton.className = "b05-drainage__analyze";
analyzeButton.textContent = "유역 산정";
// 배관 편집 토글 — 켜면 계획선 클릭으로 배관 추가, 마커 드래그로 이동.
const editButton = document.createElement("button");
editButton.type = "button";
editButton.className = "b05-drainage__analyze b05-drainage__tool";
editButton.textContent = "배관 편집";
editButton.setAttribute("aria-pressed", "false");
// 선택된 배관 삭제 — 편집 모드에서 마커를 선택해야 활성화된다.
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.className = "b05-drainage__analyze b05-drainage__tool";
deleteButton.textContent = "선택 삭제";
deleteButton.disabled = true;
// 자동 제안으로 되돌리기 — 편집한 배관 배치를 버리고 백엔드 자동 제안으로 재산정.
const autoButton = document.createElement("button");
autoButton.type = "button";
autoButton.className = "b05-drainage__analyze b05-drainage__tool";
autoButton.textContent = "자동 제안";
header.append(analyzeButton, editButton, deleteButton, autoButton);
const viewport = document.createElement("div");
viewport.className = "b05-drainage__viewport";
const backgroundImage = document.createElement("img");
backgroundImage.className = "b05-drainage__image";
backgroundImage.alt = "배경 위성지도";
backgroundImage.draggable = false;
const canvas = document.createElement("canvas");
canvas.className = "b05-drainage__canvas";
const status = document.createElement("span");
status.className = "b05-drainage__status";
status.textContent = "노선을 확정하면 배수유역 배경도가 표시됩니다.";
viewport.append(backgroundImage, canvas, status);
// 유역 제원 목록(면적·표고·유하거리·관경). 관경 수식 미확정이라 당분간 "미정"으로 나온다.
const basinList = document.createElement("div");
basinList.className = "b05-drainage__basins";
basinList.hidden = true;
root.append(panelHandle.root, header, viewport, basinList);
let projectId: string | null = null;
let meta: VWorldMeta | null = null;
const preparedLayers = new Map<DrainageLayer, PreparedLayer>();
const activeLayers = new Set<DrainageLayer>(DRAINAGE_LAYERS);
let routeLayer: PreparedLayer | null = null;
let routePoints: ReadonlyArray<RoutePoint> = [];
let normalizer: Normalizer | null = null;
let basins: DrainageBasin[] = [];
let selectedBasin: number | null = null;
let editMode = false;
// 배관 편집기 — 마커 선택/추가/이동/삭제 시 재그리기와 버튼 상태만 갱신한다.
const pipeEditor = createPipeEditor(() => {
syncPipeSelection();
scheduleDraw();
});
// 유역 경계 외곽선 = 분수령(능선). 사용자 지시로 기본 표시.
let showRidge = true;
let scale = 1;
let offsetX = 0;
let offsetY = 0;
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
let frameHandle = 0;
let loadSequence = 0;
let canvasWidth = 0;
let canvasHeight = 0;
let canvasDpr = 0;
DRAINAGE_LAYERS.forEach((layer) => {
const button = document.createElement("button");
button.type = "button";
button.className = "b05-drainage__layer-button is-active";
button.textContent = LAYER_LABELS[layer];
button.style.setProperty("--b05-layer-color", LAYER_COLORS[layer]);
button.setAttribute("aria-pressed", "true");
button.addEventListener("click", () => {
if (activeLayers.has(layer)) activeLayers.delete(layer);
else activeLayers.add(layer);
const isActive = activeLayers.has(layer);
button.classList.toggle("is-active", isActive);
button.setAttribute("aria-pressed", String(isActive));
scheduleDraw();
});
layerButtons.append(button);
});
// 능선(분수령) 표시 토글 — 유역 경계 파선. 기본 켜짐(사용자 지시).
const ridgeButton = document.createElement("button");
ridgeButton.type = "button";
ridgeButton.className = "b05-drainage__layer-button is-active";
ridgeButton.textContent = "능선";
ridgeButton.style.setProperty("--b05-layer-color", "#92400e");
ridgeButton.setAttribute("aria-pressed", "true");
ridgeButton.addEventListener("click", () => {
showRidge = !showRidge;
ridgeButton.classList.toggle("is-active", showRidge);
ridgeButton.setAttribute("aria-pressed", String(showRidge));
scheduleDraw();
});
layerButtons.append(ridgeButton);
function updateImageTransform(): void {
backgroundImage.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
}
function draw(): void {
const rect = viewport.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width));
const height = Math.max(1, Math.floor(rect.height));
const dpr = window.devicePixelRatio || 1;
if (width !== canvasWidth || height !== canvasHeight || dpr !== canvasDpr) {
canvasWidth = width;
canvasHeight = height;
canvasDpr = dpr;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
}
const context = canvas.getContext("2d");
if (!context) return;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, width, height);
const mapRect: MapRect = computeMapRect(meta, width, height);
const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect };
// 유역 채움을 가장 아래에 깔아 등고선·세류 판독을 가리지 않게 한다.
if (normalizer) {
basins.forEach((basin) => {
const color = BASIN_COLORS[(basin.index - 1) % BASIN_COLORS.length];
drawFilledRing(
context,
{ ring: basin.polygon_lonlat, label: String(basin.index) },
normalizer!,
view,
selectedBasin === null || selectedBasin === basin.index
? color
: color.replace(/0\.45\)$/, "0.18)"),
);
// 유역 경계 = 분수령이므로 그 외곽선을 능선 파선으로 강조한다.
if (showRidge) drawRidgeRing(context, basin.polygon_lonlat, normalizer!, view);
});
}
// 등고선을 얇게 깔고 세류·표고점을 그 위에, 노선을 맨 위에 둔다.
DRAINAGE_LAYERS.forEach((layer) => {
if (!activeLayers.has(layer)) return;
const prepared = preparedLayers.get(layer);
if (!prepared) return;
context.lineWidth = layer === "도엽_등고선" ? 0.7 : 1.5;
context.strokeStyle = LAYER_COLORS[layer];
drawPreparedLayer(context, prepared, view, layer === "도엽_표고점" ? "x" : "dot");
});
if (routeLayer) {
context.lineWidth = 2.4;
context.strokeStyle = ROUTE_COLOR;
drawPreparedLayer(context, routeLayer, view, "dot");
}
// 배관(관 매설) 마커 — 계획선 위 최상단.
pipeEditor.draw(context, view, pipeColor);
updateImageTransform();
}
/** 현재 프레임 뷰 상태 (draw()와 동일 계산 — 포인터 히트 판정용). */
function currentView(): ViewState {
const rect = viewport.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width));
const height = Math.max(1, Math.floor(rect.height));
return { width, height, scale, offsetX, offsetY, mapRect: computeMapRect(meta, width, height) };
}
/** 배관 마커 색 — 같은 누가거리 유역의 파스텔색(불투명). 유역이 없으면 회색. */
function pipeColor(chainage: number): string {
const basin = basins.find((item) => Math.abs(item.chainage_m - chainage) < 0.51);
if (!basin) return "#e5e7eb";
return BASIN_COLORS[(basin.index - 1) % BASIN_COLORS.length].replace(/0\.45\)$/, "1)");
}
/** 마커 선택 ↔ 유역 목록 선택 동기화 + 삭제 버튼 활성화. */
function syncPipeSelection(): void {
const index = pipeEditor.selected();
deleteButton.disabled = !editMode || index === null;
const pipe = index === null ? null : pipeEditor.pipes()[index];
const basin = pipe
? basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < 0.51)
: null;
selectedBasin = basin ? basin.index : null;
renderBasinList();
}
function scheduleDraw(): void {
if (frameHandle) return;
frameHandle = window.requestAnimationFrame(() => {
frameHandle = 0;
draw();
});
}
/** 유역 제원 목록을 다시 그린다. 항목을 누르면 해당 유역만 진하게 강조한다. */
function renderBasinList(): void {
basinList.textContent = "";
basinList.hidden = basins.length === 0;
basins.forEach((basin) => {
const row = document.createElement("button");
row.type = "button";
row.className = "b05-drainage__basin" + (selectedBasin === basin.index ? " is-selected" : "");
const badge = document.createElement("span");
badge.className = "b05-drainage__basin-index";
badge.textContent = String(basin.index);
badge.style.background = BASIN_COLORS[(basin.index - 1) % BASIN_COLORS.length];
const metrics = document.createElement("span");
metrics.className = "b05-drainage__basin-metrics";
// 관경은 수식 미확정이라 백엔드가 null을 주며, 확정 전까지 "미정"으로 표기한다.
const pipe =
basin.pipe_diameter_mm === null ? "미정" : `Ø${Math.round(basin.pipe_diameter_mm)}mm`;
metrics.textContent =
`면적 ${formatArea(basin.area_m2)} · 표고 ${basin.relief_m.toFixed(1)}m · ` +
`유하 ${Math.round(basin.flow_length_m)}m · 관경 ${pipe}`;
row.title = `측점 누가거리 ${basin.chainage_m.toFixed(1)}m`;
row.append(badge, metrics);
row.addEventListener("click", () => {
selectedBasin = selectedBasin === basin.index ? null : basin.index;
renderBasinList();
scheduleDraw();
});
basinList.append(row);
});
}
function formatArea(areaM2: number): string {
return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`;
}
/** 구조물 측점 후보 제안 + 유역 산정을 백엔드에 요청한다(계산은 전부 백엔드).
* 편집된 배관이 있으면 그 누가거리로 확정 산정하고, auto=true면 자동 제안으로 되돌린다. */
async function analyze(auto = false): Promise<void> {
if (!projectId) return;
analyzeButton.disabled = true;
status.hidden = false;
status.textContent = "배수유역을 산정하는 중…";
try {
const chainages = !auto && pipeEditor.pipes().length > 0 ? pipeEditor.chainages() : undefined;
const response = await fetchDrainageBasins(projectId, chainages);
basins = response.basins;
// 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함).
pipeEditor.setPipes(
(response.pipes ?? []).map((pipe) => ({
chainage_m: pipe.chainage_m,
reason: pipe.reason,
})),
);
selectedBasin = null;
renderBasinList();
syncPipeSelection();
status.hidden = basins.length > 0;
if (basins.length === 0) status.textContent = "산정된 배수유역이 없습니다.";
scheduleDraw();
} catch (error) {
status.hidden = false;
status.textContent = error instanceof Error ? error.message : "배수유역 산정에 실패했습니다.";
} finally {
analyzeButton.disabled = false;
}
}
analyzeButton.addEventListener("click", () => void analyze());
editButton.addEventListener("click", () => {
editMode = !editMode;
editButton.classList.toggle("is-active", editMode);
editButton.setAttribute("aria-pressed", String(editMode));
syncPipeSelection();
});
deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected());
autoButton.addEventListener("click", () => {
pipeEditor.setPipes([]);
void analyze(true);
});
/** 노선 전체가 보이도록 배율·중심을 맞춘다. 노선이 없으면 도엽 전체를 그대로 보여준다. */
function fitToRoute(): void {
scale = 1;
offsetX = 0;
offsetY = 0;
if (!meta || routePoints.length < 2) return;
const rect = viewport.getBoundingClientRect();
const width = Math.max(rect.width, 1);
const height = Math.max(rect.height, 1);
const mapRect = computeMapRect(meta, width, height);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
routePoints.forEach((point) => {
if (point.x < minX) minX = point.x;
if (point.x > maxX) maxX = point.x;
if (point.y < minY) minY = point.y;
if (point.y > maxY) maxY = point.y;
});
const routeWidth = Math.max(maxX - minX, 1);
const routeHeight = Math.max(maxY - minY, 1);
scale = Math.min(meta.width_meters / routeWidth, meta.height_meters / routeHeight) * 0.85;
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width;
const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height;
offsetX = -(baseX - width / 2) * scale;
offsetY = -(baseY - height / 2) * scale;
}
async function loadLayers(): Promise<void> {
if (!projectId) return;
const activeProjectId = projectId;
const sequence = ++loadSequence;
meta = null;
preparedLayers.clear();
routeLayer = null;
backgroundImage.removeAttribute("src");
status.hidden = false;
status.textContent = "배경도를 불러오는 중…";
try {
const nextMeta = await fetchVWorldMeta(activeProjectId, "satellite");
const loaded = await Promise.all(
DRAINAGE_LAYERS.map(async (layer) => {
try {
const data = (await fetchGisGeoJson(activeProjectId, layer)) as GeoJsonCollection;
return [layer, data] as const;
} catch {
return [layer, null] as const;
}
}),
);
if (sequence !== loadSequence) return;
meta = nextMeta;
normalizer = createNormalizer(nextMeta);
let featureCount = 0;
loaded.forEach(([layer, data]) => {
if (!data) return;
featureCount += data.features?.length ?? 0;
preparedLayers.set(layer, prepareLayer(data, normalizer!));
});
backgroundImage.src = `${getVWorldMapUrl(activeProjectId, "satellite")}&_t=${Date.now()}`;
if (routePoints.length > 1) routeLayer = prepareMetricPolyline(routePoints, nextMeta);
pipeEditor.setContext(nextMeta, routePoints);
status.hidden = featureCount > 0;
if (featureCount === 0) status.textContent = "도엽 레이어가 없습니다. B04에서 임포트하세요.";
fitToRoute();
scheduleDraw();
} catch (error) {
if (sequence !== loadSequence) return;
status.hidden = false;
status.textContent = error instanceof Error ? error.message : "배경도를 불러오지 못했습니다.";
}
}
viewport.addEventListener(
"wheel",
(event) => {
event.preventDefault();
const prevScale = scale;
scale = Math.min(16, Math.max(0.5, scale * (event.deltaY < 0 ? 1.15 : 0.87)));
// 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작).
const ratio = scale / prevScale;
const rect = viewport.getBoundingClientRect();
const cursorX = event.clientX - rect.left - rect.width / 2;
const cursorY = event.clientY - rect.top - rect.height / 2;
offsetX = cursorX * (1 - ratio) + offsetX * ratio;
offsetY = cursorY * (1 - ratio) + offsetY * ratio;
scheduleDraw();
},
{ passive: false },
);
viewport.addEventListener("pointerdown", (event) => {
// 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다.
if (event.button === 1) event.preventDefault();
const rect = viewport.getBoundingClientRect();
// 배관 마커 클릭/추가가 처리되면 지도 팬은 시작하지 않는다.
if (
pipeEditor.handleDown(
currentView(),
event.clientX - rect.left,
event.clientY - rect.top,
editMode,
)
) {
viewport.setPointerCapture(event.pointerId);
return;
}
dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY };
viewport.setPointerCapture(event.pointerId);
});
viewport.addEventListener("pointermove", (event) => {
const rect = viewport.getBoundingClientRect();
// 배관 드래그 중이면 마커 이동(계획선 스냅)만 처리한다.
if (pipeEditor.handleMove(currentView(), event.clientX - rect.left, event.clientY - rect.top))
return;
if (!dragStart) return;
offsetX = dragStart.offsetX + event.clientX - dragStart.x;
offsetY = dragStart.offsetY + event.clientY - dragStart.y;
scheduleDraw();
});
const stopDragging = (): void => {
pipeEditor.handleUp();
dragStart = null;
};
viewport.addEventListener("pointerup", stopDragging);
viewport.addEventListener("pointercancel", stopDragging);
const resizeObserver = new ResizeObserver(scheduleDraw);
resizeObserver.observe(viewport);
function setCollapsed(collapsed: boolean): void {
root.classList.toggle("is-collapsed", collapsed);
panelHandle.setOpen(!collapsed);
sessionStorage.setItem(COLLAPSED_KEY, String(collapsed));
if (!collapsed) scheduleDraw();
}
panelHandle.root.addEventListener("click", () =>
setCollapsed(!root.classList.contains("is-collapsed")),
);
setCollapsed(sessionStorage.getItem(COLLAPSED_KEY) !== "false");
return {
root,
load(nextProjectId: string) {
if (projectId === nextProjectId && meta) return;
projectId = nextProjectId;
void loadLayers();
},
setRoute(points) {
routePoints = points;
routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null;
pipeEditor.setContext(meta, points);
if (routeLayer) fitToRoute();
scheduleDraw();
},
dispose() {
loadSequence += 1;
if (frameHandle) {
window.cancelAnimationFrame(frameHandle);
frameHandle = 0;
}
resizeObserver.disconnect();
},
};
}