import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; import { computeDetailBasins, fetchDetailPipePoints, getVWorldMapUrl, resetDetailPipePoints, saveDetailPipePoints, type DetailBasin, type DetailBasinResponse, type PipeFacility, type PipeSource, type VWorldMeta, } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { computeMapRect, createNormalizer, 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 } 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 { createFacilityStore, type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome"; import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact"; 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, reconcilePipes, WIDTH_KEY, type DrainageLayer, } from "./B05_Profile_UI_Drainage_Parts"; // 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 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; /** 종단 테이블 우클릭·통합 목록에서 계곡 통과 시설을 넣을 때. * attributes에 시설 종류·구간·부속 옵션이 담긴다(생략 = 맨 배관). */ addPipe: (chainageM: number, attributes?: FacilityAttributes) => void; /** 사이드 폼 [수정] — 기준점 이동·구간·부속 옵션을 정본에 반영하고 재계산한다. */ updatePipeFacility: ( fromChainageM: number, toChainageM: number, attributes: FacilityAttributes, ) => void; removePipe: (chainageM: number) => void; /** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */ savePipes: () => Promise; /** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */ selectBasinByChainage: (chainageM: number | null) => void; /** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다. * 이미 같으면 무시(2026-08-17 전역 선택 동기화). */ selectPipeAtChainage: (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; /** 담당 유역의 설계유량(㎥/s, 100년빈도·2.0배). 물넘이·세월교 개략 단면의 입력이다. */ design_flow_m3s?: number | null; facility: PipeFacility; start_m?: number; end_m?: number; source?: PipeSource; options?: Record; }>, ) => void; /** 유역을 고르거나 풀 때 그 관의 누가거리(없으면 null)를 넘긴다 — 그래프·사이드 패널 동기화용. */ onBasinSelected?: (chainageM: number | null) => void; /** 관 마커 선택이 바뀔 때(클릭·해제) 누가거리를 알린다 — 유역이 없는 관도 전 화면 * (그래프·3D·리스트)이 같은 것을 가리키게 한다(2026-08-17 전역 선택 동기화). */ onPipeSelected?: (chainageM: number | null) => void; /** 배수유역도 우클릭 빈 자리 메뉴 — 사이드 「구조물 배치」와 같은 구조물군 → 종류 * 2단 항목(2026-08-18 일원화). 선택 = 폼 자동 지정(addAt 경유). */ structureMenuItems?: (chainageM: number) => MapContextMenuItem[]; } export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): DrainagePanel { const chrome = createDrainageChrome( { title: L("B05_Drainage_Title"), analyze: L("B05_Drainage_Btn_Analyze"), analyzeTip: L("B05_Drainage_Btn_Analyze_Tip"), deleteSelected: L("B05_Drainage_Btn_DeleteSelected"), deleteSelectedTip: L("B05_Drainage_Btn_DeleteSelected_Tip"), auto: L("B05_Drainage_Btn_Auto"), autoTip: L("B05_Drainage_Btn_Auto_Tip"), imageAlt: L("B05_Drainage_ImageAlt"), statusNeedRoute: L("B05_Drainage_Status_NeedRoute"), }, { minWidth: MIN_PANEL_WIDTH, maxWidthRatio: MAX_PANEL_WIDTH_RATIO, widthStorageKey: WIDTH_KEY, }, ); const { root, panelHandle, layerButtons, analyzeButton, deleteButton, autoButton, viewport, backgroundImage, canvas, status, progress, contextMenu, legend, summary, basinList, widthResizer, } = chrome; const showProgress = createProgressReporter(progress); // 계곡 통과 시설(배관/BOX암거/물넘이/세월교) 확장 정보 — 계산기는 chainage만 다루므로 // 여기 보관해 두고 요청마다 되붙인다(2026-08-17 컨테이너 병합). 편집 폼은 사이드 // 「구조물 배치」 하나뿐이다(자동·수동 배관 = 같은 구조물, 별도 UI 없음 — // 2026-08-17 사용자 지시). 여기는 정본 보관·재계산만 맡는다. const facilityStore = createFacilityStore(); 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; // 마지막으로 밖에 알린 관 선택(누가거리) — 같은 값 재알림을 막는다. let lastNotifiedPipeChainage: 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 frameHandle = 0; let loadSequence = 0; let canvasWidth = 0; let canvasHeight = 0; let canvasDpr = 0; // 위성사진은 기본 꺼짐(2026-08-18 사용자 지시) — 어두운 사진이 유역 채움색을 묻는다. backgroundImage.hidden = true; mountDrainageToggles(layerButtons, { initial: { satellite: false, 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; const picked = index === null ? null : pipeEditor.pipes()[index]; selectBasin(basinIndexOfPipe(basins, picked ?? null)); // 유역 유무와 무관하게 선택 자체를 알린다 — 그래프·3D·리스트 동기화(재진입은 // Page의 selectionSyncing 가드가 끊는다). 값이 안 바뀌면 조용히 — 재계산(apply) // 뒤의 재호출이 다른 화면 선택을 풀어 버리지 않게. const pickedChainage = picked ? picked.chainage_m : null; if (pickedChainage !== lastNotifiedPipeChainage) { lastNotifiedPipeChainage = pickedChainage; callbacks.onPipeSelected?.(pickedChainage); } } 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, })), ); // 시설 확장 정보(종류·구간·옵션)는 응답이 정본 — 통째로 다시 채운다. facilityStore.replaceFromResponse(response.pipe_points); // 재계산 뒤에는 선택을 다시 알려야 한다 — 관을 새로 넣은 직후에는 목록이 아직 // 비어 폼이 열리지 못했고, 중복 억제 가드에 걸려 두 번째 기회를 잃는다. lastNotifiedPipeChainage = null; selectBasin(null, false); renderBasinList(); summaryText(summary, pipeEditor.pipes().length, basins.length, zSource); // 목록 통지가 선택 통지보다 **먼저** 가야 한다 — 사이드 폼은 이 목록에서 관을 // 찾아 편집 화면을 채우므로, 순서가 뒤집히면 새로 넣은 관의 폼이 열리지 않는다. callbacks.onPipesChanged?.( // 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null. // 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고, // 그 지점은 세월교·물넘이로 별도 설계한다(임도설치규정 제12조). // 시설 종류·구간·부속 옵션·출처(source)도 함께 — 통합 목록·사이드 폼이 // 이 목록만으로 편집 화면을 채운다(2026-08-17 단일 폼 통합). pipeEditor.pipes().map((pipe) => { const chainage = Math.round(pipe.chainage_m * 100) / 100; const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.51); const attributes = facilityStore.get(chainage); return { chainage_m: chainage, effective_diameter_mm: basin && !basin.bridge_required ? (basin.pipe_diameter_mm ?? null) : null, // 설계유량은 세월교 검토 유역에도 붙인다 — 물넘이·세월교 단면이 이 값에서 나온다. design_flow_m3s: basin?.design_flow_m3s ?? null, facility: attributes?.facility ?? "pipe", start_m: attributes?.start_m, end_m: attributes?.end_m, source: (pipe.reason || "user") as PipeSource, options: attributes?.options, }; }), ); syncPipeSelection(); } /** 세부유역 요청 한 번을 감싼다 — 버튼 잠금·진행 문구·오류 표기를 한 자리에 모은다. */ 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, facilityStore.attach( 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); } } bindPipeContextMenu(viewport, contextMenu, pipeEditor, currentView, callbacks.structureMenuItems); bindDrainageInteractions({ viewport, contextMenu, pipeEditor, currentView, getScale: () => scale, setScale: (value) => { scale = value; }, getOffset: () => ({ x: offsetX, y: offsetY }), setOffset: (x, y) => { offsetX = x; offsetY = y; }, scheduleDraw, getNormalizer: () => normalizer, getBasins: () => basins, getSelectedBasin: () => selectedBasin, selectBasin: (index) => selectBasin(index), }); // 패널을 늘리면 지도를 더 보여 줄 뿐, 배율은 그대로 둔다 — 늘릴 때마다 확대되면 // 방금 보던 자리를 다시 찾아야 한다(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, attributes) { // 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다. facilityStore.set(chainageM, attributes ?? null); pipeEditor.addAtChainage(chainageM); }, updatePipeFacility(fromChainageM, toChainageM, attributes) { facilityStore.set(fromChainageM, null); facilityStore.set(toChainageM, attributes); if (Math.abs(fromChainageM - toChainageM) > 0.005) { // 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고, // attach가 새 위치로 시설 정보를 승계한다. const index = pipeEditor .pipes() .findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainageM) < 0.51); if (index >= 0) pipeEditor.moveTo(index, toChainageM); else void analyze(); } else { void analyze(); // 위치는 그대로 — 옵션·구간만 정본 경로로 재반영. } }, 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(); }, selectPipeAtChainage(chainageM) { const index = chainageM === null ? null : pipeEditor.pipes().findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.51); const next = index === -1 ? null : index; if (pipeEditor.selected() === next) return; // select()는 onChange를 울리지 않는다 — 폼·유역·재그리기를 직접 맞춘다. pipeEditor.select(next); syncPipeSelection(); scheduleDraw(); }, async savePipes() { if (!projectId) return 0; const response = await saveDetailPipePoints( projectId, // 시설 종류·구간·옵션까지 정본에 남긴다(계산기 왕복에서 사라지므로 되붙임). facilityStore.attach( 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(); }, }; }