- 종단 상단 표시줄을 법정 판정 3항목으로 축소: 최대 기울기/상한(별표2 Ⅰ.2.라),
절·성토 불균형(Ⅰ.1.나.(4)(다)), 종단곡선 필요 n곳(Ⅰ.2.마 — 대수차 5% 초과인데
곡선이 빠진 변화점). 변화점·종단곡선 개수·기본 곡선길이 L·중복 경고칩은 제거.
- [초기선 복원] → [편집 되돌리기] — 실제 동작은 화면 편집 델타 삭제이지 저장 지점
복귀가 아니다. 툴팁에 동작과 [초기화]와의 차이를 적었다.
- 3D 뷰포트 안내 라벨 삭제(로딩 완료 후 남던 조작법 문구).
- 배수유역 버튼 정비: 유역 다시 나누기 / 선택한 관 삭제 / 배관 배치 초기화 +
각 툴팁에 실제 동작 명시(선택 삭제는 툴팁 자체가 없었다).
- 유토곡선 요약 12칩 → 5칩(절토·성토·잉여|부족·운반·검산). 토질 3종 내역·다짐
환산·블록 수·장거리 운반·사토는 해당 칩 툴팁으로. B05·B06 공용 함수라 동시 반영.
- 설계속도 축 도입(임도는 속도를 낼 수 없는 노선 — 기본 20km/h):
· 임도 종류는 프로젝트 등록값(projects.road_type)을 읽어 B05가 읽기 전용 표시
(main→간선임도, fire→산불진화임도, 그 외→작업임도). 화면에서 고치지 않는다.
· 설계속도 선택(간선·산불진화 20/30/40, 작업 20 고정) 신설 — 종단기울기 상한과
종단곡선 반경이 등급이 아니라 이 값으로 정해진다(지식DB 설계제원_총괄 §6·§7).
· B02 임도 종류 선택지를 현행 3종으로 정리(지선 폐지·계류보전은 사방 구분).
· 백엔드: ROUTE_GRADE_CLASSES 3종+branch 호환, selectable_design_speeds 신설,
resolve_design_speed 신설, legal_grade_limit_pct·resolve_grade_options에
설계속도 인자, sections/context가 road_type 제공(B06도 저장값 승계).
· 계획선 정책은 그릴 때마다 현재 기준으로 동기화 — 저장분의 옛 상한이 남지 않는다.
- 검증: pytest 125 통과(설계속도 12건 신규), tsc, 헤드 브라우저 — 진입 시 상한 9%,
40km/h 전환 시 7% 즉시 반영, 원복 9%, 안내문 '간선임도 · 설계속도 20km/h ·
일반지형', 유토곡선 5칩, 유역 버튼 새 이름·툴팁, 3D 라벨 빈 문자열 확인.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
702 lines
29 KiB
TypeScript
702 lines
29 KiB
TypeScript
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<RoutePoint>) => void;
|
|
/** 현재 관 매설 누가거리 목록(종단 테이블의 "배관" 구조물 라인과 맞추는 데 쓴다). */
|
|
pipeChainages: () => number[];
|
|
/** 종단 테이블에서 배관 라인을 끌었을 때 — 그 자리로 옮기고 세부유역을 다시 나눈다. */
|
|
movePipe: (fromChainage: number, toChainage: number) => void;
|
|
/** 관 목록을 통째로 맞춘다(사이드바 구조물 폼 편집 등 밖에서 바뀐 경우).
|
|
* 현재 목록과 같으면 아무 일도 하지 않는다 — 되먹임 고리를 끊는 지점이다. */
|
|
setPipeChainages: (chainages: ReadonlyArray<number>) => void;
|
|
/** 종단 테이블 우클릭·통합 목록에서 계곡 통과 시설을 넣을 때.
|
|
* attributes에 시설 종류·구간·부속 옵션이 담긴다(생략 = 맨 배관). */
|
|
addPipe: (chainageM: number, attributes?: FacilityAttributes) => void;
|
|
/** 사이드 폼 [수정] — 기준점 이동·구간·부속 옵션을 정본에 반영하고 재계산한다. */
|
|
updatePipeFacility: (
|
|
fromChainageM: number,
|
|
toChainageM: number,
|
|
attributes: FacilityAttributes,
|
|
) => void;
|
|
removePipe: (chainageM: number) => void;
|
|
/** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */
|
|
savePipes: () => Promise<number>;
|
|
/** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */
|
|
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<string, string | number>;
|
|
}>,
|
|
) => 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<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})`;
|
|
}
|
|
|
|
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<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;
|
|
},
|
|
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();
|
|
},
|
|
};
|
|
}
|