- 계획노선 사용 범위: B02 등록에 시작·종료 누가거리 두 칸 추가, B01 수정 모달에서도 변경. projects.route_start_m·route_end_m 신설(015_route_range.sql). load_design_route 가 범위 절단 → 서피스 트림 순서로 적용. 시작 >= 종료는 화면·서버 양쪽에서 차단. 비우면 전 구간으로 종전과 같음. - 서피스 절단 여유 기본값 30m → 3m (SURFACE_ROUTE_EDGE_TRIM_M). - B04 지도·B05 배수유역도 줌 상한을 「화면 폭 20m」 기준으로 계산(고정 8배·16배 폐지). 4배를 넘으면 배경 그림 흐림 보간 해제. - 계획선 위 측점 눈금·번호 표기(측점번호+잔여거리). 관 마커와 겹치면 반대쪽으로 밀고, 되꺾임 구간에서 라벨이 겹치면 건너뜀. 그리기 코드는 두 화면 공용. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
649 lines
26 KiB
TypeScript
649 lines
26 KiB
TypeScript
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,
|
|
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 { 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];
|
|
}
|
|
|
|
// 바깥 계약(창구·콜백)은 `_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;
|
|
// 마지막으로 밖에 알린 관 선택(누가거리) — 같은 값 재알림을 막는다.
|
|
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<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,
|
|
});
|
|
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);
|
|
}
|
|
}
|
|
|
|
/** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 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);
|
|
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();
|
|
},
|
|
};
|
|
}
|