- 한몸으로 동작하는 두 페이지라 한 커밋으로 처리 (상호 참조 다수) - B05 37파일 + B06 20파일 접두사 개명 (git mv, 이력 보존) - 참조 치환 91파일: import 경로, 라우트 슬러그(b05-profile/b06-section), 라우트 키(B05_PROFILE/B06_SECTION), B03 자동 체인, storage 상수, pyproject 제외 경로 - 로직 변경 없음. typecheck·백엔드 import 검증 통과 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
735 lines
31 KiB
TypeScript
735 lines
31 KiB
TypeScript
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<RoutePoint>) => void;
|
|
/** 현재 관 매설 누가거리 목록(종단 테이블의 "배관" 구조물 라인과 맞추는 데 쓴다). */
|
|
pipeChainages: () => number[];
|
|
/** 종단 테이블에서 배관 라인을 끌었을 때 — 그 자리로 옮기고 세부유역을 다시 나눈다. */
|
|
movePipe: (fromChainage: number, toChainage: number) => void;
|
|
/** 관 목록을 통째로 맞춘다(사이드바 구조물 폼 편집 등 밖에서 바뀐 경우).
|
|
* 현재 목록과 같으면 아무 일도 하지 않는다 — 되먹임 고리를 끊는 지점이다. */
|
|
setPipeChainages: (chainages: ReadonlyArray<number>) => void;
|
|
/** 종단 테이블 우클릭으로 배관을 넣거나 지울 때. */
|
|
addPipe: (chainageM: number) => void;
|
|
removePipe: (chainageM: number) => void;
|
|
/** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */
|
|
savePipes: () => Promise<number>;
|
|
/** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */
|
|
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<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;
|
|
// 배관 편집기 — 마커 선택/추가/이동/삭제 시 재그리기와 버튼 상태만 갱신한다.
|
|
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 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<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,
|
|
})),
|
|
);
|
|
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<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,
|
|
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);
|
|
}
|
|
}
|
|
|
|
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();
|
|
},
|
|
};
|
|
}
|