import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { computeDetailBasins, fetchDetailPipePoints, getVWorldMapUrl, resetDetailPipePoints, saveDetailPipePoints, type DetailBasin, type DetailBasinResponse, type PipeSource, type VWorldMeta, } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { computeMapRect, createNormalizer, lonLatToScreen, prepareLayer, prepareMetricPolyline, type MapRect, type Normalizer, type PreparedLayer, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { type RoutePoint } from "./B05_Profile_Api_Fetch"; import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows"; import { buildStrengthArray, createFlowLegend, } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp"; import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples"; import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; import { createProgressCircle } from "@ui/ui_template_progress"; import { createMapContextMenu } from "@ui/ui_template_context_menu"; import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render"; import { mountDrainageToggles, DRAINAGE_LAYERS, fetchDrainageLayers, createProgressReporter, fitCanvasToViewport, fitViewToRoute, observeViewportSize, bindPipeContextMenu, COLLAPSED_KEY, MAX_PANEL_WIDTH_RATIO, MIN_PANEL_WIDTH, renderBasinRows, summaryText, basinIndexOfPipe, pipeMarkerColor, pointInRing, reconcilePipes, WIDTH_KEY, type DrainageLayer, } from "./B05_Profile_UI_Drainage_Parts"; /** 이만큼(px) 이하로 움직였다 뗐으면 클릭으로 본다 — 손떨림으로 선택이 안 되는 일을 막는다. */ const CLICK_SLOP_PX = 4; // 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널. // 하단 패널이 닫히면 이 패널도 함께 화면에서 사라진다(부모 안에 들어 있으므로 자동). // 지도는 B04에서 분리한 렌더 엔진(B04_PreProcess_UI_MapRender)을 그대로 재사용해 // 사전 투영·LOD·뷰포트 컬링·커서 중심 줌 동작을 동일하게 얻는다. 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) => void; /** 현재 관 매설 누가거리 목록(종단 테이블의 "배관" 구조물 라인과 맞추는 데 쓴다). */ pipeChainages: () => number[]; /** 종단 테이블에서 배관 라인을 끌었을 때 — 그 자리로 옮기고 세부유역을 다시 나눈다. */ movePipe: (fromChainage: number, toChainage: number) => void; /** 관 목록을 통째로 맞춘다(사이드바 구조물 폼 편집 등 밖에서 바뀐 경우). * 현재 목록과 같으면 아무 일도 하지 않는다 — 되먹임 고리를 끊는 지점이다. */ setPipeChainages: (chainages: ReadonlyArray) => void; /** 종단 테이블 우클릭으로 배관을 넣거나 지울 때. */ addPipe: (chainageM: number) => void; removePipe: (chainageM: number) => void; /** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */ savePipes: () => Promise; /** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */ selectBasinByChainage: (chainageM: number | null) => void; /** 측점 선택 마킹 — 계획선 위 해당 누가거리에 표식을 그린다(null이면 지움). */ markStation: (chainageM: number | null) => void; dispose: () => void; } export interface DrainagePanelCallbacks { /** 관 목록이 바뀔 때마다 누가거리 + 담당 유역의 배수 유효직경(mm)을 넘긴다 — * 종단 테이블 구조물 라인 동기화 및 관경 자동 지정(D800 기본)용. */ onPipesChanged?: ( pipes: Array<{ chainage_m: number; effective_diameter_mm: number | null }>, ) => void; /** 유역을 고르거나 풀 때 그 관의 누가거리(없으면 null)를 넘긴다 — 그래프·사이드 패널 동기화용. */ onBasinSelected?: (chainageM: number | null) => void; /** 배수유역도 우클릭으로 배관 외 구조물을 넣을 때(누가거리는 계획선 투영값). */ onStructureAdd?: (chainageM: number, type: "기성막이" | "대피로" | "기타") => void; } export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): DrainagePanel { const root = document.createElement("aside"); root.className = "b05-drainage"; // 이 패널은 하단 패널의 **오른쪽**에 도킹해 오른쪽으로 접힌다(제목 카드와 반대). // 방향을 넘기지 않으면 화살표가 정확히 거꾸로 나온다. // 이름표 "배수유역" — 세로(90°)로 화살표 위에 얹힌다(2026-08-04 사용자 지시). const panelHandle = createWorkflowPanelHandle("side", "right", "배수유역"); 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 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"); autoButton.title = L("B05_Drainage_Btn_Auto_Tip"); header.append(analyzeButton, deleteButton, autoButton); 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; // 배관 추가·삭제 우클릭 메뉴 — 편집 토글을 없앤 대신 이쪽으로 옮겼다(B04 지도와 같은 조작). const contextMenu = createMapContextMenu("b05-drainage"); // 유입 강도 색띠 범례 — CSS가 지도 우측 세로로 세운다. const legend = createFlowLegend(); viewport.append(backgroundImage, canvas, status, progress.root, contextMenu.element, legend.root); const showProgress = createProgressReporter(progress); // 유역 제원 목록(면적·표고·유하거리·관경). 관경 수식 미확정이라 당분간 "미정"으로 나온다. // 관 개수·세부유역 수·종단 Z 출처 — 세부유역이 갈리는 근거라 목록 위에 한 줄로 남긴다. const summary = document.createElement("div"); summary.className = "b05-drainage__summary"; summary.hidden = true; 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, summary, basinList); let projectId: string | null = null; let meta: VWorldMeta | null = null; const preparedLayers = new Map(); const activeLayers = new Set(DRAINAGE_LAYERS); let routeLayer: PreparedLayer | null = null; let routePoints: ReadonlyArray = []; let normalizer: Normalizer | null = null; let basins: DetailBasin[] = []; let selectedBasin: number | null = null; // 측점 선택 마킹(계획선 위 누가거리). 유역이 없는 구조물 측점도 위치를 보여 준다. let markedChainage: number | null = null; // 배관 편집기 — 마커 선택/추가/이동/삭제 시 재그리기와 버튼 상태만 갱신한다. const pipeEditor = createPipeEditor( () => { syncPipeSelection(); scheduleDraw(); }, // 배치가 실제로 바뀐 순간(추가·삭제·이동 완료)에만 세부유역을 다시 나눈다. () => void analyze(), ); // 2차 전체 배수유역 외곽선 = 분수령. 별도 "능선" 레이어를 두지 않고 이 선 하나로 표시한다 // (전체 유역 외곽선과 능선이 같은 선이므로 — 2026-07-31 사용자 지시). let mainBoundary: Array<[number, number]> = []; // 평균 흐름 화살표 — B04가 계산해 둔 것을 그대로 받아 그린다(여기서 계산하지 않는다). let flowArrows: FlowArrow[] = []; let arrowSpacingM = 0; let showArrows = true; // 유입 집중점 — 관 자리를 판단하는 근거. 기본 꺼짐(마커가 관 마커와 겹쳐 읽기 어렵다). let hotspots: Array<{ chainage: number; area: number }> = []; let maxHotspotArea = 0; let showHotspots = false; /** 종단 Z 출처 — 세부유역이 갈리는 근거라 화면에서 확인 가능해야 한다. */ let zSource = ""; // 유역 안쪽 상류 세류망 — B04가 채택한 기준선을 그대로 받아 강조만 한다. let upstreamLines: Array> = []; let showUpstream = true; // 계획선 위 유입 강도 색칠 — B04 지도와 같은 색띠·같은 값(2026-08-01 사용자 지시). let strengthSamples: RoutePoint[] = []; let strength: Float64Array = new Float64Array(0); let maxStrength = 0; let showStrength = true; let scale = 1; let offsetX = 0; let offsetY = 0; let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; /** 좌클릭을 시작한 자리 — 끌지 않고 뗐을 때만 유역 고르기로 본다. */ let basinClickStart: { x: number; y: number } | null = null; /** 마커를 잡은 좌클릭 — 이 경우 유역 고르기로 넘기지 않는다. */ let pipeClickStart: { x: number; y: number } | null = null; let frameHandle = 0; let loadSequence = 0; let canvasWidth = 0; let canvasHeight = 0; let canvasDpr = 0; mountDrainageToggles(layerButtons, { initial: { arrows: showArrows, strength: showStrength, hotspots: showHotspots, upstream: showUpstream, }, onSatellite: (next) => { backgroundImage.hidden = !next; scheduleDraw(); }, onSheetLayer: (layer, next) => { if (next) activeLayers.add(layer); else activeLayers.delete(layer); scheduleDraw(); }, onArrows: (next) => { showArrows = next; scheduleDraw(); }, onStrength: (next) => { showStrength = next; legend.update(maxStrength, showStrength); scheduleDraw(); }, onHotspots: (next) => { showHotspots = next; scheduleDraw(); }, onUpstream: (next) => { showUpstream = next; scheduleDraw(); }, }); 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 fitted = fitCanvasToViewport(canvas, width, height, { width: canvasWidth, height: canvasHeight, dpr: canvasDpr, }); canvasWidth = fitted.width; canvasHeight = fitted.height; canvasDpr = fitted.dpr; const context = canvas.getContext("2d"); if (!context) return; context.setTransform(fitted.dpr, 0, 0, fitted.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 }; drawDrainageScene(context, view, { meta, normalizer, basins, selectedBasin, mainBoundary, preparedLayers, activeLayers, routeLayer, upstreamLines, showUpstream, strengthSamples, strength, maxStrength, showStrength, flowArrows, arrowSpacingM, showArrows, hotspots, maxHotspotArea, showHotspots, pipeEditor, pipeColor, markedChainage, }); 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) }; } const pipeColor = (chainage: number): string => pipeMarkerColor(basins, chainage); /** 마커 선택 ↔ 유역 강조 동기화 + 삭제 버튼 활성화. */ function syncPipeSelection(): void { const index = pipeEditor.selected(); deleteButton.disabled = index === null; selectBasin(basinIndexOfPipe(basins, index === null ? null : pipeEditor.pipes()[index])); } function scheduleDraw(): void { if (frameHandle) return; frameHandle = window.requestAnimationFrame(() => { frameHandle = 0; draw(); }); } /** 유역 제원 목록. 항목을 누르면 그 유역만 진하게 강조한다. */ const renderBasinList = (): void => renderBasinRows(basinList, basins, selectedBasin, (index) => selectBasin(selectedBasin === index ? null : index), ); /** 유역 강조를 바꾸는 **유일한 자리**. 지도 클릭·목록 클릭·마커 선택·밖에서 온 요청이 전부 * 여기를 거쳐야 그래프 측점선·사이드 패널과 어긋나지 않는다(2026-08-02 사용자 지시). * `notify=false`면 밖으로 되돌려 보내지 않는다 — 되먹임 고리를 끊는 지점이다. */ function selectBasin(index: number | null, notify = true): void { if (selectedBasin === index) return; selectedBasin = index; renderBasinList(); scheduleDraw(); if (!notify) return; const picked = basins.find((basin) => basin.index === index); callbacks.onBasinSelected?.(picked ? picked.chainage_m : null); } /** 응답을 화면 상태로 옮긴다. B04 지도와 **같은 엔드포인트·같은 응답**을 쓴다 * — 두 화면이 다른 결과를 보이면 안 되기 때문이다(2026-08-01 일원화). */ function apply(response: DetailBasinResponse): void { basins = response.basins; mainBoundary = response.main_polygon_lonlat ?? []; upstreamLines = (response.upstream_lonlat ?? []) as Array>; flowArrows = (response.flow_arrows ?? []) as FlowArrow[]; arrowSpacingM = response.arrow_spacing_m ?? 0; hotspots = (response.inflow_hotspots ?? []).map(([chainage, area]) => ({ chainage, area })); zSource = response.z_source ?? ""; const built = buildStrengthArray( (response.strength_profile ?? []) as ReadonlyArray, ); strength = built.strength; maxStrength = built.maximum; legend.update(maxStrength, showStrength); maxHotspotArea = hotspots.reduce((max, spot) => (spot.area > max ? spot.area : max), 0); // 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함). pipeEditor.setPipes( response.pipe_points.map((pipe) => ({ chainage_m: pipe.chainage_m, reason: pipe.source, })), ); selectBasin(null, false); renderBasinList(); syncPipeSelection(); summaryText(summary, pipeEditor.pipes().length, basins.length, zSource); callbacks.onPipesChanged?.( // 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null. // 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고, // 그 지점은 세월교·물넘이로 별도 설계한다(임도설치규정 제12조). pipeEditor.chainages().map((chainage) => { const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.51); return { chainage_m: chainage, effective_diameter_mm: basin && !basin.bridge_required ? (basin.pipe_diameter_mm ?? null) : null, }; }), ); } /** 세부유역 요청 한 번을 감싼다 — 버튼 잠금·진행 문구·오류 표기를 한 자리에 모은다. */ async function run(request: () => Promise): Promise { if (!projectId) return; analyzeButton.disabled = true; status.hidden = false; status.textContent = L("B05_Drainage_Status_Analyzing"); showProgress(null, L("B05_Drainage_Status_Analyzing")); try { apply(await request()); 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); } } /** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. */ const loadSaved = (): Promise => run(() => fetchDetailPipePoints(projectId as string)); /** 저장분까지 버리고 자동 배치로 되돌린다("초기화"). 화면만 되돌리면 다시 들어왔을 때 * 옛 관이 살아난다(2026-08-02 사용자 보고). */ const resetPipes = (): Promise => run(() => resetDetailPipePoints(projectId as string)); /** 세부유역을 다시 나눈다. 격자 해석은 하지 않으므로 즉시 끝난다. */ const analyze = (): Promise => run(() => computeDetailBasins( projectId as string, pipeEditor.pipes().map((pipe) => ({ chainage_m: pipe.chainage_m, source: (pipe.reason || "user") as PipeSource, })), ), ); analyzeButton.addEventListener("click", () => void analyze()); deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected()); autoButton.addEventListener("click", () => void resetPipes()); /** 노선 전체가 보이도록 배율·중심을 맞춘다(초기 보기 = 도로 기준 줌인). * * B05는 노선 주변 배수유역을 보는 화면이라 도로에 맞춰 확대한 상태로 연다 * (2026-08-01 사용자 지시). 배경 전체를 보려면 휠로 축소하면 된다. * 노선이 없으면 배경 전체를 그대로 보여준다. */ /** 아직 레이아웃 전(폭·높이 0)이라 화면 맞춤을 미뤄 둔 상태. 첫 배치 때 다시 맞춘다. */ let fitPending = false; function fitToRoute(): void { const rect = viewport.getBoundingClientRect(); // 대시보드에서 곧장 들어오면 패널이 아직 배치되기 전이라 0×0이다. 그 상태로 맞추면 // 엉뚱한 배율이 굳어 지도가 보이지 않는다(새로고침하면 보이던 원인 — 2026-08-02 사용자 보고). if (rect.width < 2 || rect.height < 2) { fitPending = true; return; } fitPending = false; const fitted = fitViewToRoute(meta, routePoints, rect.width, rect.height); scale = fitted.scale; offsetX = fitted.offsetX; offsetY = fitted.offsetY; } async function loadLayers(): Promise { 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 { meta: nextMeta, layers: loaded } = await fetchDrainageLayers(activeProjectId, () => showProgress(1 / 3, L("B05_Drainage_Status_LoadingSheets")), ); 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가 확정해 둔 관 지점을 그대로 불러온다 — 저장분이 없으면 백엔드가 자동 배치를 준다. // 두 화면이 같은 파일을 보므로 B04에서 옮긴 관이 여기서도 같은 자리에 있다. void loadSaved(); } 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; // 휠을 **당기면 확대**, 밀면 축소한다(2026-08-02 사용자 지시). B04 2D 지도와 같은 방향이다. 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 }, ); bindPipeContextMenu(viewport, contextMenu, pipeEditor, currentView, (chainage, type) => callbacks.onStructureAdd?.(chainage, type), ); viewport.addEventListener("pointerdown", (event) => { if (contextMenu.contains(event.target)) return; contextMenu.close(); // 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다. if (event.button === 1) event.preventDefault(); const rect = viewport.getBoundingClientRect(); // 배관 마커는 **좌클릭으로만** 잡는다 — 우클릭은 메뉴, 가운데 버튼은 팬 전용이다 // (2026-08-01 사용자 지시). if ( event.button === 0 && pipeEditor.handleDown(currentView(), event.clientX - rect.left, event.clientY - rect.top) ) { // 마커를 잡은 좌클릭이므로 유역 고르기로는 넘기지 않는다. pipeClickStart = { x: event.clientX, y: event.clientY }; viewport.setPointerCapture(event.pointerId); return; } // 마커를 못 잡은 좌클릭은 유역 고르기 후보로 기억한다(끌지 않고 뗐을 때만 고른다). if (event.button === 0) basinClickStart = { x: event.clientX, y: event.clientY }; // 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 유역선 핸들·배관 마커를 고르고 끄는 데만 쓴다 // (좌버튼이 팬까지 겸하면 마커를 집으려다 지도가 딸려 움직인다 — 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 (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(); }); /** 유역 폴리곤을 눌러 고른다. 유역 밖을 누르면 강조를 푼다. */ function pickBasinAt(x: number, y: number): void { if (!normalizer) return; const view = currentView(); let hit: number | null = null; let smallest = Number.POSITIVE_INFINITY; basins.forEach((basin) => { if (basin.polygon_lonlat.length < 3) return; const ring = basin.polygon_lonlat.map(([lon, lat]) => lonLatToScreen(normalizer as Normalizer, view, lon, lat), ); if (!pointInRing(ring, x, y)) return; // 겹치면 면적이 작은 쪽을 고른다(안쪽 조각 우선). if (basin.area_m2 < smallest) { smallest = basin.area_m2; hit = basin.index; } }); selectBasin(hit === selectedBasin ? null : hit); } const stopDragging = (): void => { pipeEditor.handleUp(); dragStart = null; viewport.style.removeProperty("cursor"); }; viewport.addEventListener("pointerup", (event) => { const start = basinClickStart; basinClickStart = null; const dragged = pipeClickStart !== null; pipeClickStart = null; if (!dragged && start && event.button === 0) { const rect = viewport.getBoundingClientRect(); // 끌었으면 지도 조작이지 고르기가 아니다. if (Math.hypot(event.clientX - start.x, event.clientY - start.y) <= CLICK_SLOP_PX) { pickBasinAt(event.clientX - rect.left, event.clientY - rect.top); } } stopDragging(); }); viewport.addEventListener("pointercancel", stopDragging); // 패널을 늘리면 지도를 더 보여 줄 뿐, 배율은 그대로 둔다 — 늘릴 때마다 확대되면 // 방금 보던 자리를 다시 찾아야 한다(2026-08-02 사용자 지시). const resizeObserver = observeViewportSize(viewport, { meta: () => meta, anchor: () => ({ width: 0, height: 0, scale, offsetX, offsetY }), apply: (next) => { scale = next.scale; offsetX = next.offsetX; offsetY = next.offsetY; }, redraw: () => { // 배치가 잡히면 미뤄 둔 화면 맞춤을 그때 수행한다. if (fitPending) fitToRoute(); scheduleDraw(); }, }); 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) === "true"); return { root, load(nextProjectId: string) { if (projectId === nextProjectId && meta) return; projectId = nextProjectId; void loadLayers(); }, pipeChainages: () => pipeEditor.chainages(), movePipe(fromChainage, toChainage) { const index = pipeEditor .pipes() .findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainage) < 0.51); if (index < 0) return; pipeEditor.moveTo(index, toChainage); }, selectBasinByChainage(chainageM) { const picked = chainageM === null ? null : (basins.find((basin) => Math.abs(basin.chainage_m - chainageM) < 0.51) ?? null); // 밖에서 온 요청이므로 되돌려 보내지 않는다(그래프 ↔ 유역 순환 차단). selectBasin(picked ? picked.index : null, false); }, setPipeChainages(chainages) { const next = reconcilePipes(pipeEditor.pipes(), chainages); if (!next) return; // 같은 목록 — 되돌아온 것이므로 여기서 끊는다 pipeEditor.setPipes(next); void analyze(); }, markStation(chainageM) { if (markedChainage === chainageM) return; markedChainage = chainageM; scheduleDraw(); }, addPipe(chainageM) { pipeEditor.addAtChainage(chainageM); }, removePipe(chainageM) { const index = pipeEditor .pipes() .findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.51); if (index < 0) return; pipeEditor.select(index); pipeEditor.deleteSelected(); }, async savePipes() { if (!projectId) return 0; const response = await saveDetailPipePoints( projectId, pipeEditor.pipes().map((pipe) => ({ chainage_m: pipe.chainage_m, source: (pipe.reason || "user") as PipeSource, })), ); apply(response); scheduleDraw(); return response.pipe_count; }, setRoute(points) { routePoints = points; routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null; // 강도 색칠은 백엔드가 준 1m 구간 값과 인덱스를 맞춰야 하므로 같은 규칙으로 다시 찍는다. strengthSamples = resampleRoute(points); pipeEditor.setContext(meta, points); if (routeLayer) fitToRoute(); scheduleDraw(); }, dispose() { loadSequence += 1; if (frameHandle) { window.cancelAnimationFrame(frameHandle); frameHandle = 0; } resizeObserver.disconnect(); widthResizer.dispose(); }, }; }