- ui_template_theme.css에 2D 지도 벡터 팔레트(--map-*) 33종을 유일한 정의처로 등록하고, 캔버스에서 CSS 변수를 읽는 공통 유틸 ui_template_palette.ts(themeColor)를 신설. 값은 한 번만 읽어 캐시하고 data-theme 변경 시 비운다(다크 전환 대응). - B04 지도·유역 오버레이·흐름 화살표와 B05 배수유역도·유역선 편집·배관 마커의 하드코딩 색상을 전부 토큰 조회로 교체. 계획선 색·굵기, 후광색은 공용 함수로 일원화. - B05 배수유역 패널의 사용자 문구를 전부 ui_locales로 이관(제목, 레이어 토글 5종, 도구 버튼 5종, 툴팁, 상태 문구 8종, 유역 제원 표기). B04 유역 분석 오버레이의 버튼·갈래 토글·진행/실패 안내도 함께 전환. 관리자 진단용 결과 판독문은 원문 유지. - A02 프로그램 소개의 6단계 제목과 A01 히어로 문구의 단계 나열을 진행단계 이름 (전처리/종단설계/횡단설계/상세설계/수량산출/설계도서)과 일치시킴. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
741 lines
31 KiB
TypeScript
741 lines
31 KiB
TypeScript
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
|
import { createPanelResizer } from "@ui/ui_template_resizer";
|
|
import { themeColor } from "@ui/ui_template_palette";
|
|
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
|
import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
|
import {
|
|
fetchVWorldMeta,
|
|
getVWorldMapUrl,
|
|
type VWorldMeta,
|
|
} from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
|
|
import {
|
|
computeMapRect,
|
|
computeRouteView,
|
|
createNormalizer,
|
|
drawFilledRing,
|
|
drawPreparedLayer,
|
|
drawRidgeRing,
|
|
drawUpstreamLines,
|
|
prepareLayer,
|
|
prepareMetricPolyline,
|
|
routeLineColor,
|
|
ROUTE_LINE_WIDTH,
|
|
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 { drawFlowArrows, type FlowArrow } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows";
|
|
import {
|
|
createBoundaryEditor,
|
|
type BoundaryOverrideEntry,
|
|
} from "./B05_wf2_Route_UI_Drainage_Boundary";
|
|
import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes";
|
|
import { createProgressCircle } from "@ui/ui_template_progress";
|
|
|
|
// 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널.
|
|
// 하단 패널이 닫히면 이 패널도 함께 화면에서 사라진다(부모 안에 들어 있으므로 자동).
|
|
// 지도는 B04에서 분리한 렌더 엔진(B04_wf1_Surface_UI_MapRender)을 그대로 재사용해
|
|
// 사전 투영·LOD·뷰포트 컬링·커서 중심 줌 동작을 동일하게 얻는다.
|
|
|
|
/** 배수유역 산정의 근거가 되는 도엽 레이어. 3D는 쓰지 않는다(사용자 지시).
|
|
* 표고점은 유효 데이터가 적어 산정에서 제외했으므로 배경에도 띄우지 않는다(2026-07-31). */
|
|
const DRAINAGE_LAYERS = DRAINAGE_SHEET_LAYERS;
|
|
type DrainageLayer = (typeof DRAINAGE_LAYERS)[number];
|
|
|
|
/** 색 값의 정의처는 `ui_template_theme.css`(`--map-*`)다. 여기는 이름만 잇는다. */
|
|
const LAYER_COLOR_TOKENS: Record<DrainageLayer, [name: string, fallback: string]> = {
|
|
도엽_등고선: ["--map-sheet-contour", "#a5b4fc"],
|
|
도엽_하천중심선: ["--map-sheet-stream", "#2563eb"],
|
|
};
|
|
|
|
const layerColor = (layer: DrainageLayer): string => themeColor(...LAYER_COLOR_TOKENS[layer]);
|
|
|
|
const LAYER_LABEL_KEYS: Record<DrainageLayer, keyof typeof ui_locales> = {
|
|
도엽_등고선: "B05_Drainage_Layer_Contour",
|
|
도엽_하천중심선: "B05_Drainage_Layer_Stream",
|
|
};
|
|
|
|
/** 도엽 레이어가 아닌 표시 토글의 띠 색 — 지도에 그려지는 선 색과 맞춘다. */
|
|
const arrowToggleColor = (): string => themeColor("--map-flow-arrow", "#7c3aed");
|
|
const upstreamToggleColor = (): string => themeColor("--map-upstream-toggle", "#1d4ed8");
|
|
/** 위성사진은 선이 아니라 배경이라 맞출 선 색이 없다 — 중립 회색을 띠 색으로 쓴다. */
|
|
const satelliteToggleColor = (): string => themeColor("--map-satellite-toggle", "#64748b");
|
|
|
|
const COLLAPSED_KEY = "b05-route-drainage-collapsed";
|
|
/** 드래그로 조절한 패널 폭(px) 보관 키 — 브라우저 세션 동안만 유지한다. */
|
|
const WIDTH_KEY = "b05-route-drainage-width";
|
|
/** 지도가 담기는 최소 폭(px). CSS의 min-width와 같은 값. */
|
|
const MIN_PANEL_WIDTH = 320;
|
|
/** 상한은 하단 패널 폭의 70%까지(사용자 지시) — 종단면도가 최소한 30%는 남아야 한다. */
|
|
const MAX_PANEL_WIDTH_RATIO = 0.7;
|
|
|
|
/** 유역 오버레이 파스텔 색상 8종(정의처: `--map-basin-1`~`-8`). 번호 순으로 돌려쓴다. */
|
|
const BASIN_COLOR_FALLBACKS = [
|
|
"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;
|
|
|
|
/** 유역 번호(1부터)에 대응하는 채움색. 8종을 넘어가면 처음부터 다시 쓴다. */
|
|
function basinColor(index: number): string {
|
|
const slot = (index - 1) % BASIN_COLOR_FALLBACKS.length;
|
|
return themeColor(`--map-basin-${slot + 1}`, BASIN_COLOR_FALLBACKS[slot]);
|
|
}
|
|
|
|
function L(key: keyof typeof ui_locales): string {
|
|
return ui_locales[key][currentLanguageIndex];
|
|
}
|
|
|
|
export interface DrainagePanel {
|
|
root: HTMLElement;
|
|
/** 프로젝트가 정해지면 배경지도·도엽 레이어를 불러온다. */
|
|
load: (projectId: string) => void;
|
|
/** 확정된 노선 평면 선형(사업지 좌표계 m)을 지도 위에 겹친다. */
|
|
setRoute: (points: ReadonlyArray<RoutePoint>) => void;
|
|
/** 이번 화면에서 사용자가 유역선 포인트를 옮겼는지(경로 확정 시 저장 여부를 묻는 근거). */
|
|
hasBoundaryEdits: () => boolean;
|
|
/** 저장 대상 — 원래 자리와 옮긴 자리 짝. 폴리곤 전체가 아니다. */
|
|
boundaryOverrides: () => BoundaryOverrideEntry[];
|
|
/** 저장이 끝났음을 알린다(다시 묻지 않도록). */
|
|
markBoundarySaved: () => 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 = L("B05_Drainage_Title");
|
|
const layerButtons = document.createElement("div");
|
|
layerButtons.className = "b05-drainage__layers";
|
|
header.append(title, layerButtons);
|
|
|
|
// 세부유역 산정 — B04가 미리 분석해 둔 결과를 읽어 관을 보충하고 세부유역만 나눈다.
|
|
// 격자 해석은 하지 않으므로 즉시 끝난다.
|
|
const analyzeButton = document.createElement("button");
|
|
analyzeButton.type = "button";
|
|
analyzeButton.className = "b05-drainage__analyze";
|
|
analyzeButton.textContent = L("B05_Drainage_Btn_Analyze");
|
|
analyzeButton.title = L("B05_Drainage_Btn_Analyze_Tip");
|
|
// 배관 편집 토글 — 켜면 계획선 클릭으로 배관 추가, 마커 드래그로 이동.
|
|
const editButton = document.createElement("button");
|
|
editButton.type = "button";
|
|
editButton.className = "b05-drainage__analyze b05-drainage__tool";
|
|
editButton.textContent = L("B05_Drainage_Btn_PipeEdit");
|
|
editButton.setAttribute("aria-pressed", "false");
|
|
// 선택된 배관 삭제 — 편집 모드에서 마커를 선택해야 활성화된다.
|
|
const deleteButton = document.createElement("button");
|
|
deleteButton.type = "button";
|
|
deleteButton.className = "b05-drainage__analyze b05-drainage__tool";
|
|
deleteButton.textContent = L("B05_Drainage_Btn_DeleteSelected");
|
|
deleteButton.disabled = true;
|
|
// 자동 제안으로 되돌리기 — 편집한 배관 배치를 버리고 백엔드 자동 제안으로 재산정.
|
|
const autoButton = document.createElement("button");
|
|
autoButton.type = "button";
|
|
autoButton.className = "b05-drainage__analyze b05-drainage__tool";
|
|
autoButton.textContent = L("B05_Drainage_Btn_Auto");
|
|
// 유역선 편집 토글 — 켜면 외곽선 위 핸들을 잡아 유역 경계를 손으로 고친다.
|
|
const boundaryButton = document.createElement("button");
|
|
boundaryButton.type = "button";
|
|
boundaryButton.className = "b05-drainage__analyze b05-drainage__tool";
|
|
boundaryButton.textContent = L("B05_Drainage_Btn_BoundaryEdit");
|
|
boundaryButton.title = L("B05_Drainage_Btn_BoundaryEdit_Tip");
|
|
boundaryButton.setAttribute("aria-pressed", "false");
|
|
header.append(analyzeButton, editButton, deleteButton, autoButton, boundaryButton);
|
|
|
|
const viewport = document.createElement("div");
|
|
viewport.className = "b05-drainage__viewport";
|
|
const backgroundImage = document.createElement("img");
|
|
backgroundImage.className = "b05-drainage__image";
|
|
backgroundImage.alt = L("B05_Drainage_ImageAlt");
|
|
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 = L("B05_Drainage_Status_NeedRoute");
|
|
// 지도 정중앙 로딩 서클 — 배경도·도엽 레이어·유역 산정이 끝날 때까지 화면이 비어 보인다.
|
|
const progress = createProgressCircle({ overlay: true });
|
|
progress.root.hidden = true;
|
|
viewport.append(backgroundImage, canvas, status, progress.root);
|
|
|
|
/** 진행률(0~1, 모르면 null)과 문구. label이 null이면 서클을 감춘다. */
|
|
function showProgress(ratio: number | null, label: string | null): void {
|
|
progress.root.hidden = label === null;
|
|
if (label !== null) progress.set(ratio, label);
|
|
}
|
|
// 유역 제원 목록(면적·표고·유하거리·관경). 관경 수식 미확정이라 당분간 "미정"으로 나온다.
|
|
const basinList = document.createElement("div");
|
|
basinList.className = "b05-drainage__basins";
|
|
basinList.hidden = true;
|
|
// 왼쪽 경계를 끌어 폭을 조절한다. 상한은 하단 패널 폭의 70%,
|
|
// 세로는 하단 패널에 그대로 딸려 간다(따로 조절하지 않는다 — 사용자 지시).
|
|
const widthResizer = createPanelResizer({
|
|
axis: "horizontal",
|
|
target: root,
|
|
cssVar: "--b05-drainage-width",
|
|
direction: -1,
|
|
min: MIN_PANEL_WIDTH,
|
|
max: () => (root.parentElement?.clientWidth ?? window.innerWidth) * MAX_PANEL_WIDTH_RATIO,
|
|
storageKey: WIDTH_KEY,
|
|
});
|
|
root.append(panelHandle.root, widthResizer.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();
|
|
});
|
|
// 2차 전체 배수유역 외곽선 = 분수령. 별도 "능선" 레이어를 두지 않고 이 선 하나로 표시한다
|
|
// (전체 유역 외곽선과 능선이 같은 선이므로 — 2026-07-31 사용자 지시).
|
|
let mainBoundary: Array<[number, number]> = [];
|
|
// 평균 흐름 화살표 — B04가 계산해 둔 것을 그대로 받아 그린다(여기서 계산하지 않는다).
|
|
let flowArrows: FlowArrow[] = [];
|
|
let arrowSpacingM = 0;
|
|
let showArrows = true;
|
|
// 유역 안쪽 상류 세류망 — B04가 채택한 기준선을 그대로 받아 강조만 한다.
|
|
let upstreamLines: Array<Array<[number, number]>> = [];
|
|
let showUpstream = true;
|
|
let boundaryMode = false;
|
|
const boundaryEditor = createBoundaryEditor(() => scheduleDraw());
|
|
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;
|
|
|
|
/** 제목 우측 표시 토글 — 등고선·세류·흐름 화살표·상류 세류가 모두 같은 양식을 쓴다. */
|
|
function addLayerToggle(
|
|
label: string,
|
|
color: string,
|
|
initial: boolean,
|
|
onToggle: (next: boolean) => void,
|
|
title?: string,
|
|
): HTMLButtonElement {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "b05-drainage__layer-button" + (initial ? " is-active" : "");
|
|
button.textContent = label;
|
|
button.style.setProperty("--b05-layer-color", color);
|
|
button.setAttribute("aria-pressed", String(initial));
|
|
if (title) button.title = title;
|
|
let active = initial;
|
|
button.addEventListener("click", () => {
|
|
active = !active;
|
|
button.classList.toggle("is-active", active);
|
|
button.setAttribute("aria-pressed", String(active));
|
|
onToggle(active);
|
|
scheduleDraw();
|
|
});
|
|
layerButtons.append(button);
|
|
return button;
|
|
}
|
|
|
|
// 배경 위성사진 — 등고선 앞에 둔다(2026-08-01 사용자 지시). 사진이 어두워 유역 채움색이
|
|
// 묻힐 때 끄고 본다. 캔버스가 아니라 배경 이미지라 표시 여부만 직접 바꾼다.
|
|
addLayerToggle(
|
|
L("B05_Drainage_Layer_Satellite"),
|
|
satelliteToggleColor(),
|
|
true,
|
|
(next) => {
|
|
backgroundImage.hidden = !next;
|
|
},
|
|
L("B05_Drainage_Layer_Satellite_Tip"),
|
|
);
|
|
DRAINAGE_LAYERS.forEach((layer) => {
|
|
addLayerToggle(L(LAYER_LABEL_KEYS[layer]), layerColor(layer), true, (next) => {
|
|
if (next) activeLayers.add(layer);
|
|
else activeLayers.delete(layer);
|
|
});
|
|
});
|
|
// 흐름 화살표 — 도면이 지저분해질 때 끄기 위한 토글(등고선·세류와 같은 줄·같은 양식).
|
|
addLayerToggle(
|
|
L("B05_Drainage_Layer_Arrows"),
|
|
arrowToggleColor(),
|
|
showArrows,
|
|
(next) => {
|
|
showArrows = next;
|
|
},
|
|
L("B05_Drainage_Layer_Arrows_Tip"),
|
|
);
|
|
// 상류 세류선 강조 — 유역 판정의 기준선이라 항상 같은 굵기·색으로 얹는다.
|
|
addLayerToggle(
|
|
L("B05_Drainage_Layer_Upstream"),
|
|
upstreamToggleColor(),
|
|
showUpstream,
|
|
(next) => {
|
|
showUpstream = next;
|
|
},
|
|
L("B05_Drainage_Layer_Upstream_Tip"),
|
|
);
|
|
|
|
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 = basinColor(basin.index);
|
|
drawFilledRing(
|
|
context,
|
|
{ ring: basin.polygon_lonlat, label: String(basin.index) },
|
|
normalizer!,
|
|
view,
|
|
selectedBasin === null || selectedBasin === basin.index
|
|
? color
|
|
: color.replace(/0\.45\)$/, "0.18)"),
|
|
);
|
|
});
|
|
// 전체 유역 외곽선 = 분수령(능선). 세부유역 경계와 구분되게 파선 한 겹만 얹는다.
|
|
// 편집 중이면 사용자가 끌어 옮긴 외곽선을 그대로 보여 준다.
|
|
const boundary = boundaryEditor.points();
|
|
if (boundary.length > 2) drawRidgeRing(context, boundary, 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 = layerColor(layer);
|
|
drawPreparedLayer(context, prepared, view, "dot");
|
|
});
|
|
// 상류 세류선 강조 — 유역 채움 위, 흐름 화살표 아래(2026-08-01 사용자 지시).
|
|
// 그리기는 B04 오버레이와 같은 공용 렌더러를 쓴다.
|
|
if (showUpstream && normalizer && upstreamLines.length > 0) {
|
|
drawUpstreamLines(context, upstreamLines, normalizer, view);
|
|
}
|
|
if (routeLayer) {
|
|
context.lineWidth = ROUTE_LINE_WIDTH;
|
|
context.strokeStyle = routeLineColor();
|
|
drawPreparedLayer(context, routeLayer, view, "dot");
|
|
}
|
|
// 평균 흐름 화살표 — 유역 채움 위, 배관 마커 아래. 좌표는 사업지 CRS(m)라
|
|
// 도엽 메타로 바로 화면에 옮긴다(배관 마커와 같은 변환).
|
|
if (showArrows && meta && flowArrows.length > 0) {
|
|
const spanX = meta.width_meters || 1;
|
|
const spanY = meta.height_meters || 1;
|
|
const pxPerMeter = (view.mapRect.width * view.scale) / spanX;
|
|
const ax = view.mapRect.width * view.scale;
|
|
const bx = view.mapRect.x * view.scale + (width / 2) * (1 - view.scale) + view.offsetX;
|
|
const ay = view.mapRect.height * view.scale;
|
|
const by = view.mapRect.y * view.scale + (height / 2) * (1 - view.scale) + view.offsetY;
|
|
const originX = meta.x_min;
|
|
const originY = meta.y_min;
|
|
drawFlowArrows(
|
|
context,
|
|
flowArrows,
|
|
arrowSpacingM,
|
|
pxPerMeter,
|
|
(x, y) => [((x - originX) / spanX) * ax + bx, (1 - (y - originY) / spanY) * ay + by],
|
|
view,
|
|
);
|
|
}
|
|
// 유역선 편집 핸들 — 편집 모드에서만. 화살표 위, 배관 마커 아래.
|
|
if (normalizer) boundaryEditor.draw(context, normalizer, view);
|
|
// 배관(관 매설) 마커 — 계획선 위 최상단.
|
|
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 themeColor("--map-pipe-orphan", "#e5e7eb");
|
|
return basinColor(basin.index).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 = basinColor(basin.index);
|
|
const metrics = document.createElement("span");
|
|
metrics.className = "b05-drainage__basin-metrics";
|
|
// 관경은 수식 미확정이라 백엔드가 null을 주며, 확정 전까지 "미정"으로 표기한다.
|
|
const pipe =
|
|
basin.pipe_diameter_mm === null
|
|
? L("B05_Drainage_Basin_Undecided")
|
|
: `Ø${Math.round(basin.pipe_diameter_mm)}mm`;
|
|
metrics.textContent = L("B05_Drainage_Basin_Metrics")
|
|
.replace("{area}", formatArea(basin.area_m2))
|
|
.replace("{relief}", basin.relief_m.toFixed(1))
|
|
.replace("{flow}", String(Math.round(basin.flow_length_m)))
|
|
.replace("{pipe}", pipe);
|
|
row.title = L("B05_Drainage_Basin_Chainage").replace(
|
|
"{chainage}",
|
|
basin.chainage_m.toFixed(1),
|
|
);
|
|
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 = L("B05_Drainage_Status_Analyzing");
|
|
showProgress(null, L("B05_Drainage_Status_Analyzing"));
|
|
try {
|
|
const chainages = !auto && pipeEditor.pipes().length > 0 ? pipeEditor.chainages() : undefined;
|
|
const response = await fetchDrainageBasins(projectId, chainages);
|
|
basins = response.basins;
|
|
mainBoundary = response.main_polygon_lonlat ?? [];
|
|
// 외곽선은 편집 핸들 간격으로 다시 찍힌 값이며, 저장된 편집분은 이미 반영돼 있다.
|
|
boundaryEditor.setBoundary(mainBoundary, response.boundary_overrides ?? []);
|
|
upstreamLines = (response.upstream_lonlat ?? []) as Array<Array<[number, number]>>;
|
|
flowArrows = (response.flow_arrows ?? []) as FlowArrow[];
|
|
arrowSpacingM = response.arrow_spacing_m ?? 0;
|
|
// 계획도로선·2차 유역 외곽선은 B04 산출물을 그대로 받는다 — 여기서 다시 계산하지 않는다.
|
|
// 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함).
|
|
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 = L("B05_Drainage_Status_NoBasin");
|
|
scheduleDraw();
|
|
} catch (error) {
|
|
status.hidden = false;
|
|
status.textContent =
|
|
error instanceof Error ? error.message : L("B05_Drainage_Status_AnalyzeFailed");
|
|
} finally {
|
|
analyzeButton.disabled = false;
|
|
showProgress(null, null);
|
|
}
|
|
}
|
|
|
|
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);
|
|
});
|
|
boundaryButton.addEventListener("click", () => {
|
|
boundaryMode = !boundaryMode;
|
|
boundaryButton.classList.toggle("is-active", boundaryMode);
|
|
boundaryButton.setAttribute("aria-pressed", String(boundaryMode));
|
|
boundaryEditor.setEditMode(boundaryMode);
|
|
});
|
|
|
|
/** 노선 전체가 보이도록 배율·중심을 맞춘다(초기 보기 = 도로 기준 줌인).
|
|
*
|
|
* B05는 노선 주변 배수유역을 보는 화면이라 도로에 맞춰 확대한 상태로 연다
|
|
* (2026-08-01 사용자 지시). 배경 전체를 보려면 휠로 축소하면 된다.
|
|
* 노선이 없으면 배경 전체를 그대로 보여준다. */
|
|
function fitToRoute(): void {
|
|
scale = 1;
|
|
offsetX = 0;
|
|
offsetY = 0;
|
|
if (!meta || routePoints.length < 2) return;
|
|
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 rect = viewport.getBoundingClientRect();
|
|
const view = computeRouteView(
|
|
meta,
|
|
{ x_min: minX, x_max: maxX, y_min: minY, y_max: maxY },
|
|
Math.max(rect.width, 1),
|
|
Math.max(rect.height, 1),
|
|
);
|
|
scale = view.scale;
|
|
offsetX = view.offsetX;
|
|
offsetY = view.offsetY;
|
|
}
|
|
|
|
async function loadLayers(): Promise<void> {
|
|
if (!projectId) return;
|
|
const activeProjectId = projectId;
|
|
const sequence = ++loadSequence;
|
|
meta = null;
|
|
preparedLayers.clear();
|
|
backgroundImage.removeAttribute("src");
|
|
status.hidden = false;
|
|
status.textContent = L("B05_Drainage_Status_LoadingBase");
|
|
showProgress(0, L("B05_Drainage_Status_LoadingBase"));
|
|
try {
|
|
const nextMeta = await fetchVWorldMeta(activeProjectId, "satellite");
|
|
showProgress(1 / 3, L("B05_Drainage_Status_LoadingSheets"));
|
|
const loaded = await Promise.all(
|
|
DRAINAGE_LAYERS.map(async (layer) => {
|
|
try {
|
|
// 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다.
|
|
const data = await fetchCachedSheetLayer<GeoJsonCollection>(activeProjectId, layer);
|
|
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!));
|
|
});
|
|
// 주소에 시각을 붙이면 브라우저가 매번 다시 받는다. 서버가 ETag를 주므로 그대로 쓴다.
|
|
backgroundImage.src = getVWorldMapUrl(activeProjectId, "satellite");
|
|
if (routePoints.length > 1) routeLayer = prepareMetricPolyline(routePoints, nextMeta);
|
|
pipeEditor.setContext(nextMeta, routePoints);
|
|
status.hidden = featureCount > 0;
|
|
if (featureCount === 0) status.textContent = L("B05_Drainage_Status_NoSheets");
|
|
fitToRoute();
|
|
scheduleDraw();
|
|
showProgress(2 / 3, L("B05_Drainage_Status_Analyzing"));
|
|
// B04 분석 결과를 읽어 오는 것뿐이라 즉시 끝난다 — 페이지에 들어오면 바로 보여 준다.
|
|
void analyze(true);
|
|
} catch (error) {
|
|
if (sequence !== loadSequence) return;
|
|
status.hidden = false;
|
|
status.textContent =
|
|
error instanceof Error ? error.message : L("B05_Drainage_Status_LoadFailed");
|
|
showProgress(null, null);
|
|
}
|
|
}
|
|
|
|
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 (
|
|
normalizer &&
|
|
boundaryEditor.handleDown(
|
|
normalizer,
|
|
currentView(),
|
|
event.clientX - rect.left,
|
|
event.clientY - rect.top,
|
|
)
|
|
) {
|
|
viewport.setPointerCapture(event.pointerId);
|
|
return;
|
|
}
|
|
// 배관 마커 클릭/추가가 처리되면 지도 팬은 시작하지 않는다.
|
|
if (
|
|
pipeEditor.handleDown(
|
|
currentView(),
|
|
event.clientX - rect.left,
|
|
event.clientY - rect.top,
|
|
editMode,
|
|
)
|
|
) {
|
|
viewport.setPointerCapture(event.pointerId);
|
|
return;
|
|
}
|
|
// 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 유역선 핸들·배관 마커를 고르고 끄는 데만 쓴다
|
|
// (좌버튼이 팬까지 겸하면 마커를 집으려다 지도가 딸려 움직인다 — 2026-08-01 사용자 지시).
|
|
// 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 할 수 있게 둔다.
|
|
if (event.pointerType === "mouse" && event.button !== 1) return;
|
|
dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY };
|
|
viewport.style.cursor = "grabbing";
|
|
viewport.setPointerCapture(event.pointerId);
|
|
});
|
|
viewport.addEventListener("pointermove", (event) => {
|
|
const rect = viewport.getBoundingClientRect();
|
|
// 외곽선 핸들을 끌고 있으면 그것만 처리한다.
|
|
if (
|
|
normalizer &&
|
|
boundaryEditor.handleMove(
|
|
normalizer,
|
|
currentView(),
|
|
event.clientX - rect.left,
|
|
event.clientY - rect.top,
|
|
)
|
|
)
|
|
return;
|
|
// 배관 드래그 중이면 마커 이동(계획선 스냅)만 처리한다.
|
|
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 => {
|
|
boundaryEditor.handleUp();
|
|
pipeEditor.handleUp();
|
|
dragStart = null;
|
|
viewport.style.removeProperty("cursor");
|
|
};
|
|
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();
|
|
},
|
|
hasBoundaryEdits: boundaryEditor.isDirty,
|
|
boundaryOverrides: boundaryEditor.overrides,
|
|
markBoundarySaved: boundaryEditor.markSaved,
|
|
dispose() {
|
|
loadSequence += 1;
|
|
if (frameHandle) {
|
|
window.cancelAnimationFrame(frameHandle);
|
|
frameHandle = 0;
|
|
}
|
|
resizeObserver.disconnect();
|
|
widthResizer.dispose();
|
|
},
|
|
};
|
|
}
|