Files
Aislo/B05_Profile/B05_Profile_UI_Drainage_Panel.ts
T
eomsangdonandClaude Opus 5 f659aec8a1 feat(B05): 평면(지도)에도 구간형 구조물을 띠로 — 종단과 같은 자료
사용자 지시(2026-09-07). 계획서 3-6 마지막 항목 — 산마루측구·도수로·옹벽처럼 **구간**으로
놓이는 시설이 종단에는 띠로 보이는데 **평면(배수유역도)에는 아무 표시가 없었음**.

**「부품이 없어 새로 짜야 함」이 사실이 아니었음** — 계획선 표본(`strengthSamples`)이 **1m 간격**
이라 **배열 인덱스가 곧 누가거리**임(`resampleRoute`). 구간 → 화면 선은 그 토막을 잘라 굵게
긋기만 하면 됨. 새 파일 하나(88줄)로 끝남.

- `B05_Profile_UI_Drainage_Spans.ts` — 띠 그리기(`drawRouteSpans`)와 자료 만들기
  (`routeSpansFromStructures`). 구간형만 띠가 되고, 색은 **레지스트리 표시색**을 그대로 씀.
- 그리는 자리 — **계획선 바로 위, 강도 색칠·마커 아래**. 굵기 7px·투명도 0.45 라 계획선을
  덮지 않음. 이름은 안 적음(종단에 이미 있고, 지도 글자는 눈금·유역 번호와 겹침).
- 목록이 바뀌면 따라옴 — `setStructures`·`setStructureTypes` 가 띠를 다시 넘김.
- 저장분에 노선보다 긴 구간이 남아 있어도 잘라서 그리고, 시작=끝인 구간도 한 칸은 보임.

⚠ 프로필 패널이 이미 700줄을 넘겨 있어(843) **매핑 로직은 띠 모듈로 뺐음** — 패널에는 3줄만
남김(827줄). 그 파일의 초과는 이전부터임.

시험 5건 — 부품 존재 · 범위 밖 구간 안전 · **쌓는 순서**(계획선 → 띠 → 강도 색칠) ·
종단과 같은 자료에서 뽑는지 · 목록 변화가 따라오는지.

⚠ 실화면 확인은 아직임 — 공용 브라우저가 소단 무한루프(별건, `80db17d4` 로 고침) 로 멈춰 있어
사용자 새로고침 대기 중임.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 20:14:09 +09:00

678 lines
27 KiB
TypeScript

import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
import {
computeDetailBasins,
fetchDetailPipePoints,
getVWorldMapUrl,
resetDetailPipePoints,
saveDetailPipePoints,
type DetailBasin,
type DetailBasinResponse,
type PipeSource,
type VWorldMeta,
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
computeMapRect,
MAP_STATION_INTERVAL_M,
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 } from "./B05_Profile_UI_Drainage_Facility";
import { writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render";
import {
mountDrainageToggles,
DRAINAGE_LAYERS,
fetchDrainageLayers,
createProgressReporter,
fitCanvasToViewport,
fitViewToRoute,
observeViewportSize,
bindPipeContextMenu,
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];
}
// 바깥 계약(창구·콜백)은 `_Types.ts` 에 있다(2026-09-02 분리) — 호출부 경로 유지를 위해 재수출.
export type { DrainagePanel, DrainagePanelCallbacks } from "./B05_Profile_UI_Drainage_Panel_Types";
import type { DrainagePanel, DrainagePanelCallbacks } from "./B05_Profile_UI_Drainage_Panel_Types";
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<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: DetailBasin[] = [];
let selectedBasin: number | null = null;
// 측점 선택 마킹(계획선 위 누가거리). 유역이 없는 구조물 측점도 위치를 보여 준다.
let markedChainage: number | null = null;
// 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). 밖(종단 패널)이
// 구조물 목록을 받을 때마다 넣어 준다. 여기서는 그리기만 하고 판정하지 않는다.
let intervalSpans: ReadonlyArray<RouteSpanBand> = [];
// 마지막으로 밖에 알린 관 선택(누가거리) — 같은 값 재알림을 막는다.
let lastNotifiedPipeChainage: number | null = null;
// 배관 편집기 — 마커 선택/추가/이동/삭제 시 재그리기와 버튼 상태만 갱신한다.
const pipeEditor = createPipeEditor(
() => {
syncPipeSelection();
scheduleDraw();
},
// 배치가 실제로 바뀐 순간(추가·삭제·이동 완료)에만 세부유역을 다시 나눈다.
// 같은 순간에 **세션 초안**에도 담는다 — 예전에는 패널 메모리에만 있어 B06 으로
// 넘어가 저장하면 편집이 사라졌다(2026-09-06 대응표 조사).
() => {
rememberPipes();
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<Array<[number, number]>> = [];
let showUpstream = true;
// 계획선 위 유입 강도 색칠 — B04 지도와 같은 색띠·같은 값(2026-08-01 사용자 지시).
let strengthSamples: RoutePoint[] = [];
let strength: Float64Array<ArrayBuffer> = 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})`;
// 크게 당기면 도엽 그림이 뭉개진다 — 흐림 보간을 끄고 픽셀을 그대로 보인다
// (2026-09-04 사용자 지시). 축척 막대가 실제 크기를 알려 준다.
backgroundImage.style.imageRendering = scale > 4 ? "pixelated" : "auto";
}
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,
stationIntervalM: MAP_STATION_INTERVAL_M,
intervalSpans,
});
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<Array<[number, number]>>;
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<readonly [number, number]>,
);
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<DetailBasinResponse>): 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 {
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);
}
}
/** 지금 화면의 관 목록을 세션 초안에 담는다 — 정본 쓰기는 [저장]·[확정] 몫이다. */
function rememberPipes(): void {
if (!projectId) return;
writePendingPipes(
projectId,
facilityStore.attach(
pipeEditor.pipes().map((pipe) => ({
chainage_m: pipe.chainage_m,
source: (pipe.reason || "user") as PipeSource,
})),
),
);
}
/** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. */
const loadSaved = (): Promise<void> => run(() => fetchDetailPipePoints(projectId as string));
/** 저장분까지 버리고 자동 배치로 되돌린다("초기화"). 화면만 되돌리면 다시 들어왔을 때
* 옛 관이 살아난다(2026-08-02 사용자 보고). */
const resetPipes = (): Promise<void> => run(() => resetDetailPipePoints(projectId as string));
/** 세부유역을 다시 나눈다. 격자 해석은 하지 않으므로 즉시 끝난다.
* 시설 종류·구간·옵션은 계산기에서 사라지므로 요청에 되붙인다. */
const analyze = (): Promise<void> =>
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<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 { 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;
},
getMeta: () => meta,
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);
writeStateRaw("drainage-collapsed", String(collapsed));
if (!collapsed) scheduleDraw();
}
panelHandle.root.addEventListener("click", () =>
setCollapsed(!root.classList.contains("is-collapsed")),
);
// 상세 배수유역 정보는 페이지에 들어오면 바로 보여야 한다 — 저장값이 없으면 펼침이 기본이다
// (같은 페이지의 하단 종단 패널과 같은 규칙).
setCollapsed(readStateRaw("drainage-collapsed") === "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();
},
setIntervalSpans(spans) {
intervalSpans = spans;
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();
},
};
}