SPipe 데모 v2 (2026-10-30) 서버 배포본

- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음)
- README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리
- 패치내역을 docs/로 이동, _worklog.md 추가
- 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
This commit is contained in:
bjkim1 committed 2026-10-30 11:00:46 +09:00
commit 21ebb16fb7
181 files changed
+57207

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
*
!package.json
!package-lock.json
!tsconfig.json
!vite.config.ts
!index.html
!src/
!src/**
!public/
!public/**
!backend/
!backend/__init__.py
!backend/main.py
!backend/epanet.py
!backend/columnar.py
!deploy/
!deploy/**
+5
View File
@@ -0,0 +1,5 @@
# 지도 서비스 키 (이 파일을 .env.local 로 복사해 값을 채움. .env.local 은 커밋 금지)
# 브이월드 배경지도 API 키
VITE_VWORLD_API_KEY=
# 카카오 JavaScript 키 (주소 검색). 콘솔의 JavaScript SDK 도메인에 접속 주소를 등록해야 함
VITE_KAKAO_JS_KEY=
+33
View File
@@ -0,0 +1,33 @@
node_modules/
dist/
.venv/
__pycache__/
*.pyc
*.rpt
*.out
.vite/
# wntr / EPANET scratch outputs
backend/temp.*
temp.bin
temp.inp
# Local-only material (never part of the deployable app)
promo-video/
.qa-tmp/
.claude/
*.log
*.hwp
*.hwpx
*.pdf
samples/
# Real customer network data stays off the repository
1.0상리*.inp
# Server-side DEM store (uploaded rasters)
data/
# Map service keys (copy .env.example to .env.local)
.env
.env.local
.env.*.local
BIN
View File
Binary file not shown.
+409
View File
@@ -0,0 +1,409 @@
# SPipe
## 데모 v2 서버 업데이트 (2026-10-30)
이전 배포: 데모 v1 (2026-10-15). 변경 내역은 `docs/SPipe_패치내역_v2_2026-10-30.txt`를 확인합니다.
1. 저장소의 소스와 실행 파일을 기존 SPipe 폴더에 반영합니다. 기존 `.venv`, `node_modules`, `data`와 `.env.local`은 유지합니다.
2. SPipe 폴더에서 새 라이브러리를 설치합니다. scipy, rasterio 등이 추가되었으므로 반드시 실행합니다.
```powershell
.\.venv\Scripts\python.exe -m pip install -r backend\requirements.txt
```
3. 화면을 다시 빌드하면서 서버를 켭니다. `-Rebuild` 없이 실행하면 예전 화면이 남을 수 있습니다.
```powershell
powershell -ExecutionPolicy Bypass -File .\start-lan.ps1 -Rebuild
```
처음 설치하는 서버 PC는 `SERVER_SETUP.md` 순서를 따릅니다. 패치내역에서 (시험)으로 표시된 기능은 기존 방식과 결과를 비교해 사용합니다.
## 프로젝트 개요
웹 브라우저에서 쓰는 상수도 관망 수리해석·관리 플랫폼.
EPANET 2.3.5 엔진으로 관망을 해석하고, GIS·DEM·자산 정보를 한 화면에서 함께 다룬다.
주요 기능:
- EPANET INP 관망 불러오기·편집·해석 (Native DLL과 WASM 두 엔진 교차 검증)
- 지도 기반 관망 편집 (절점·관로·밸브·펌프, 꺾임점, 분할·병합)
- 해석 결과 지도·그래프·종단·보고서, 실측 보정
- 분석 도구: 소화전 모의, 관로 중요도, 급수 구역, VSP, 등치선, 에너지 비용, 자동 보정, 펌프 운전 최적화
- GIS 연동: SHP·DXF·DGN·MIF·GML·KML·GPKG 불러오기·내보내기, 좌표계 변환, 도형 연산
- DEM 기반 절점 표고 입력·검증, 유역·하천 분석, 3D 보기
- 프로젝트 파일(.spipe), 시나리오, 자동 백업
## 웹 기반으로 만드는 이유
### 기존 운영 방식
설치형 프로그램(EPANET, GISpipe 등)을 PC마다 설치하고, 관망 파일·GIS 자료·DEM·엑셀 자료를 파일 단위로 주고받으며 작업하는 구조.
### 기존 문제
- PC마다 프로그램 설치·버전 관리 필요
- 관망 INP, GIS 레이어, 표고 자료, 자산 대장이 서로 다른 프로그램·파일에 분산
- 같은 관망을 여러 사람이 볼 때 파일 사본이 늘어나고 최신본 확인이 어려움
- 수리해석 결과를 지도·자산 정보와 함께 보려면 여러 프로그램을 오가야 함
### 웹 기반 통합의 필요성
사내망 서버 한 곳에 해석 엔진과 GIS 처리를 두고, 사용자는 브라우저로 접속해 같은 기능을 쓴다.
관망 모델(INP)을 기준으로 GIS·DEM·자산 정보를 연결해 하나의 작업 흐름으로 관리한다.
### 기대 효과
- 설치 없이 브라우저 접속만으로 사용
- 관망·지도·표고·자산 정보를 한 화면에서 확인
- 해석 결과를 두 엔진으로 교차 검증해 신뢰도 확보
- 분석 결과를 표준 형식(INP, SHP, GPKG, CSV)으로 내보내 기존 업무와 연계
## 핵심 용어
| 용어 | 의미 |
|---|---|
| INP | EPANET 관망 입력 파일. SPipe 관망 모델의 원본 형식 |
| 절점 / 링크 | 접합점·배수지·탱크(절점)와 관로·펌프·밸브(링크) |
| EPS | 연장 기간 해석. 시간대별 수요 패턴에 따라 여러 시점을 해석 |
| Native / WASM 엔진 | 서버의 EPANET DLL과 브라우저의 epanet-js. 같은 관망을 둘 다 해석해 결과를 비교 |
| 시나리오 | 원본 관망에서 갈라져 나온 대안 관망 |
| 자산 속성 | INP에 없는 관재질·설치년도·매설깊이·소블록 등 관리 정보 |
| DEM | 수치표고모델. 절점 표고 입력과 유역 분석에 사용 |
| 일괄 해석(Job) | 관로 중요도·소화전 모의처럼 해석을 여러 번 반복하는 서버 작업 |
| .spipe | 관망·시나리오·GIS 레이어·스타일·보정 자료를 묶은 프로젝트 파일 |
## 처리 흐름
```
INP 불러오기 또는 GIS 레이어로 관망 만들기
↓
지도에서 관망 편집 · 자산 속성 · DEM 표고 입력
↓
수리해석 (Native DLL + WASM 동시 실행, 결과 비교)
↓
결과 확인 (지도 · 그래프 · 종단 · 보고서 · 실측 보정)
↓
분석 도구로 검토 (중요도 · 급수 구역 · 소화전 · 최적화 ...)
↓
INP · SHP · 엑셀 · .spipe 로 저장·공유
```
핵심 원칙:
- 관망 모델의 원본은 INP 구조이며, 편집은 모두 INP로 다시 생성 가능한 형태로 저장
- INP에 없는 정보(자산 속성, 화면 설정)는 별도 저장소에 두고 요소 ID로만 연결
- 해석 결과는 엔진 출력이 원본이며, 화면은 단위 변환만 수행
- 모든 편집은 실행 취소(Ctrl+Z) 가능
이 문서는 저장소의 시작점입니다. 상세 설계와 점검 기록은 별도 문서에 두고, 여기에는 실행·검증·배포 순서와 문서 선택 기준을 정리합니다.
---
## 1. 빠른 시작
### 로컬 실행 (Windows)
필요 조건:
- Node.js 24.x (검증 버전 24.14.1)
- Python 3.12 64비트
- EPANET 2.3.5 (`64bit/epanet2.dll` 포함)
```powershell
npm install
py -3.12 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r backend\requirements.txt pytest httpx
```
가장 간단한 실행 방법은 `start-gispipe.bat` 더블클릭입니다. 해석 API(8000)와 웹 개발 서버(5173)를 함께 띄우고 브라우저를 엽니다. 종료는 `stop-gispipe.bat`.
수동 실행:
```powershell
npm run api
```
```powershell
npm run dev
```
접속 주소:
- Web: http://127.0.0.1:5173
- 해석 API: http://127.0.0.1:8000
- API 상태 확인: http://127.0.0.1:8000/api/health
지도 서비스 키 설정 (`.env.example`을 `.env.local`로 복사해 값 입력. 없으면 배경지도·주소 검색만 동작하지 않음):
```powershell
Copy-Item .env.example .env.local
```
개발 서버는 `127.0.0.1`에만 바인딩됩니다. 사내망에 공개할 때는 아래 "8. 사내 서버 배포"를 따릅니다.
### 로컬 검증
```powershell
npm run typecheck
```
```powershell
npm test
```
```powershell
npm run test:api
```
```powershell
npm run build
```
- `npm test`: 모델·단위·비교 로직, 편집·분석 도구 회귀 테스트 (Node 테스트)
- `npm run test:api`: FastAPI·EPANET DLL·GIS·DEM 테스트 (pytest)
CI는 `.github/workflows/ci.yml` (windows-latest).
## 2. 서비스 구조
```
브라우저
│
▼
Web (React + Vite) 지도·편집·결과 화면, WASM 해석(Web Worker)
│ /api 프록시
▼
해석 API (FastAPI) EPANET DLL(ctypes) 해석, 일괄 해석, GIS·DEM 처리
├─▶ 64bit/epanet2.dll EPANET 2.3.5 엔진 (프로세스 풀)
├─▶ GDAL (pyogrio, rasterio) 벡터·래스터 입출력
└─▶ data/dem 업로드한 DEM 보관
외부 연동(브라우저에서 직접 호출): 브이월드 배경지도 · 카카오 주소 검색 · Open-Meteo 표고(90m) · OpenStreetMap 건물
```
| 구성요소 | 책임 | 기본 포트 |
|---|---|---|
| `src/` Web | 관망 편집, 결과 표시, WASM 해석, 엔진 결과 비교 | 5173 개발 / 8080 사내 |
| `backend/` 해석 API | 해석, 일괄 분석 Job, GIS 변환·연산, DEM | 8000 (내부 전용) |
| `64bit/epanet2.dll` | EPANET 2.3.5 엔진 | - |
| `data/dem/` | 서버 DEM 보관 (최근 8개) | - |
## 3. 데이터와 원본 원칙
관망의 원본은 **INP 구조**입니다. 화면 편집은 내부 모델(`src/inpModel.ts`)을 바꾸고, 해석·저장 시 항상 INP로 다시 생성합니다.
INP로 저장되는 것:
- 절점·관로·펌프·밸브, 패턴·곡선·컨트롤·규칙, 해석 옵션
- 다중 수요, 이미터, 수질·수원, 탱크 혼합, 에너지, 초기 상태, 반응 계수, 지도 라벨
- 관로 꺾임점(`[VERTICES]`) — 이름 변경·삭제·분할·방향 전환을 따라감
INP 밖에 별도로 두는 것:
- 자산 속성(관재질·설치년도·매설깊이·소블록 등): 요소 ID로 연결, `.spipe`에 함께 저장
- 화면 설정(레이어 스타일·범례·라벨): `.spipe`에 저장
- DEM 원본: 서버 `data/dem`에 보관, 브라우저에는 식별자만 보관
원칙:
- 해석 결과는 엔진 출력을 그대로 쓰고, 화면은 표시 단위 변환만 수행
- 재생성한 INP가 원본 INP와 같은 해석 결과를 내는지 회귀 테스트로 확인
- 실무 관망 원본 파일(INP)은 저장소에 커밋하지 않음
## 4. 해석 흐름
1. 사용자가 해석 실행을 누르면 현재 모델로 INP를 생성합니다.
2. 같은 INP를 서버(Native DLL)와 브라우저(WASM)에서 동시에 해석합니다.
3. 두 결과를 시간대별·요소별로 비교해 허용오차 안에서 일치하는지 표시합니다 (`src/comparisonEngine.ts`).
4. EPANET 경고 코드(1~6)와 비정상 압력을 별도로 점검해 표시합니다 (`src/convergence.ts`).
5. 분석 탭의 일괄 해석(관로 중요도, 소화전, 보정 등)은 서버 Job으로 실행합니다 (`backend/jobs.py`).
관련 구현 위치:
- INP 파싱·생성: `src/inpModel.ts`, `src/inpExtras.ts`
- 서버 해석: `backend/main.py`, `backend/epanet.py`
- 일괄 해석 프레임워크: `backend/batch.py`, `backend/jobs.py`
- WASM 해석: `src/worker.ts`
## 5. 주요 기능 사용 가이드
### 관망 편집
상단 [편집] 모드에서 절점·관로를 추가하고 지도에서 끌어 옮깁니다. 관로를 선택하면 꺾임점 핸들이 나타나며, 주황 점을 끌면 이동, 흰 점을 끌면 추가, 더블클릭하면 삭제됩니다. 속성창 아래 "추가 속성"에서 다중 수요·이미터·수질·탱크 혼합·펌프 에너지를 편집합니다.
### 결과 확인
[결과] 창에서 표·시계열·누적 빈도·종단(시간별)·실측 보정을 확인합니다. 표의 행을 누르면 지도가 해당 요소로 이동합니다.
### 분석 탭
| 분석 | 내용 |
|---|---|
| 소화전 모의 | 시간대별 소화 유량 추가 해석, 확보 가능 유량 |
| 관로 중요도 | 관로를 하나씩 닫아 영향 절점·수요 순위 |
| 급수 구역 | 배수지별 공급 비율 추적 |
| 흐름 방향 | 역류·무흐름 관로 |
| VSP | 목표 압력용 시간별 펌프 회전수 |
| 등치선 | 압력·동수두·표고 등치선 |
| 에너지 비용 | 펌프별 가동률·효율·원단위·전력비 |
| 관로 통계 | 관경·재질·설치 연대별 집계 |
| 자동 보정 (시험) | 실측값으로 그룹별 C값 배율·관벽 계수 추정 |
| 펌프 운전 최적화 (시험) | 압력·탱크 조건을 지키는 저비용 운전 일정 |
| 편집 도구 | 이동·회전·2점 보정, 분할, 절점 병합, 관로 점검, 추적 |
| 지형(DEM) | DEM 표고 입력·검증, 인터넷 90m 표고, TIN, 유역·하천, 3D |
| GIS 도구 | 레이어 좌표계·내보내기, 도형 연산, 밸브·계량기·소블록 반영 |
### 저장과 백업
프로젝트 메뉴에서 `.spipe`로 저장하면 관망·시나리오·레이어·보정 자료가 한 파일에 담깁니다. 수정 중에는 10분마다 브라우저에 자동 백업되며(최근 30개), 프로젝트 메뉴 > 백업 기록에서 복원합니다.
## 6. API
브라우저는 Web 서버의 `/api` 프록시를 통해 해석 API를 호출합니다. 해석 API 포트는 외부에 공개하지 않습니다.
| 메서드 | 경로 | 설명 |
|---|---|---|
| GET | `/api/health` | 상태 확인 |
| POST | `/api/analyze` | multipart `file`(.inp) → 전체 EPS 해석 결과 JSON (`?format=columnar` 압축 형식) |
| POST | `/api/jobs/{name}` | 일괄 해석: `fireflow`, `criticality`, `servicearea`, `vsp`, `energy`, `calibrate`, `pumpschedule` |
| POST | `/api/gis/vector` | GDAL 벡터 파일 → WGS84 GeoJSON |
| POST | `/api/gis/export` | 레이어 → SHP·GPKG·DXF·GeoJSON·KML (좌표계 지정) |
| POST | `/api/gis/op` | 버퍼·병합·잘라내기·차이·보로노이·격자 |
| POST | `/api/gis/reproject` | 레이어 좌표계 다시 지정 |
| POST | `/api/dem` | DEM 업로드 (GeoTIFF·ASC·IMG, zip) |
| GET | `/api/dem/{id}/preview.png` | DEM 음영 미리보기 |
| GET | `/api/dem/{id}/terrain/{z}/{x}/{y}.png` | 3D 지형 타일 (terrarium) |
| POST | `/api/dem/{id}/sample` | 지점 표고 (쌍선형 보간) |
| POST | `/api/dem/{id}/hydro` | 하천선 추출 / 유역 |
| POST | `/api/dem/from-points` | 점 표고로 DEM 생성 (TIN) |
해석 결과의 필드 정의는 `src/types.ts`의 `SimulationResult`를 기준으로 합니다. 오류는 `{"detail": "..."}` 형식입니다.
| 상태 | 의미 |
|---|---|
| 400 | 입력 형식 오류 (확장자, 파라미터, 좌표계) |
| 413 | 업로드 한도 또는 해석 규모 상한 초과 |
| 500 | EPANET 입력·해석 오류 |
| 504 | 해석 시간 초과 |
## 7. 환경변수와 비밀값
| 변수 | 기본값 | 의미 |
|---|---|---|
| `SPIPE_MAX_UPLOAD_MB` | 20 | INP·GIS 업로드 한도. nginx `client_max_body_size`와 맞출 것 |
| `SPIPE_MAX_DEM_MB` | 300 | DEM 업로드 한도. nginx `location /api/dem`과 맞출 것 |
| `SPIPE_ANALYZE_TIMEOUT_S` | 120 | 해석 1건 시간 제한 |
| `SPIPE_JOB_TIMEOUT_S` | 900 | 일괄 해석 1건 시간 제한 |
| `SPIPE_MAX_PERIODS` | 10000 | 수리 스텝 수 상한 |
| `SPIPE_MAX_RESULT_CELLS` | 50000000 | (노드+링크) × 스텝 상한 |
| `SPIPE_WORKERS` | min(4, CPU-1) | 해석 프로세스 수 |
| `SPIPE_DEM_DIR` | `data/dem` | DEM 보관 폴더 |
| `SPIPE_DEM_KEEP` | 8 | 보관할 DEM 개수 |
| `EPANET_LIBRARY_PATH` | `64bit/epanet2.dll` | EPANET 라이브러리 경로 |
비밀값 원칙:
- API 키·토큰은 저장소에 커밋하지 않습니다.
- 지도 서비스 키는 `.env.local`(커밋 제외)에 넣습니다. `.env.example`을 복사해 채우고 다시 빌드합니다.
- `VITE_VWORLD_API_KEY`: 브이월드 배경지도
- `VITE_KAKAO_JS_KEY`: 카카오 주소 검색 (콘솔의 JavaScript SDK 도메인에 접속 주소 등록 필요)
- 이 키들은 빌드된 화면 코드에 포함되어 브라우저에 노출되므로, 각 콘솔에서 사용 도메인을 제한합니다.
- 이전 커밋 기록에는 키가 남아 있습니다. 저장소를 공개하려면 키를 재발급합니다.
- 실무 관망 INP, HWP·PDF 보고서, 화면 캡처는 커밋하지 않습니다 (`.gitignore` 참고).
## 8. 사내 서버 배포
### Windows 서버 PC (현재 운영 방식)
저장소의 파일(또는 배포용 압축 파일 `SPipe_server_*.zip`)을 서버 PC의 SPipe 폴더에 반영하고 다음 순서로 실행합니다. 최초 설치 절차는 `SERVER_SETUP.md`를 따릅니다.
```powershell
.\.venv\Scripts\python.exe -m pip install -r backend\requirements.txt
```
```powershell
powershell -ExecutionPolicy Bypass -File .\start-lan.ps1 -Rebuild
```
- 웹 화면: `http://<서버 IP>:8080` (사내망 공개)
- 해석 API: `127.0.0.1:8000` (서버 내부 전용, 웹 서버가 `/api`만 전달)
- 코드 갱신 후에는 반드시 `-Rebuild`로 화면을 다시 빌드합니다.
### Docker (nginx + API)
```bash
docker compose up -d --build
```
- 구성: `compose.yaml`, `deploy/Dockerfile`, `deploy/nginx.conf`
- DEM 보관 폴더는 컨테이너의 `/app/data/dem` (필요하면 볼륨으로 유지)
- 업로드 한도를 바꿀 때는 환경변수와 nginx 설정을 함께 바꿉니다.
배포 전 확인:
```powershell
git status
```
```powershell
npm run typecheck
```
```powershell
npm test
```
```powershell
npm run test:api
```
```powershell
npm run build
```
## 9. 문제 해결 순서
1. 브라우저 주소가 `127.0.0.1:5173`(개발)인지 `<서버 IP>:8080`(사내)인지 확인합니다.
2. `/api/health`가 `{"status":"ok"}`를 돌려주는지 확인합니다. 아니면 해석 API가 꺼진 상태입니다.
3. 해석 오류는 메시지의 `Error 2xx` 줄(EPANET 입력 오류)을 먼저 확인합니다.
4. 지도가 엉뚱한 곳에 그려지면 상단 좌표계 설정과 GIS 레이어의 원본 좌표계를 확인합니다.
5. 새 기능이 화면에 안 보이면 `start-lan.ps1 -Rebuild`로 다시 빌드했는지 확인합니다.
6. DEM·GIS 기능이 서버 오류를 내면 `pip install -r backend\requirements.txt`를 다시 실행했는지 확인합니다.
7. 인터넷 표고(90m)·OSM 건물·배경지도만 실패하면 사용자 PC의 외부 인터넷 차단 여부를 확인합니다.
자료가 이상하다고 관망 파일이나 백업을 지우지 않습니다. 먼저 프로젝트 메뉴 > 백업 기록과 실행 취소로 되돌릴 수 있는지 확인합니다.
## 10. 상세 문서 목차
- `SERVER_SETUP.md`: 다른 Windows PC를 사내 서버로 설치하는 절차
- `deploy/README.md`: Docker·nginx 배포
- `docs/SPipe_패치내역_v2_2026-10-30.txt`: 데모 v2 변경 내역 (공지·첨부용)
- `public/EXAMPLE_NETWORKS.md`: 예제 관망 설명
- `_worklog.md`: 배포본 등록·변경 작업 기록
## 11. 저장소 구조
```
src/ React 웹 앱
App.tsx 상태와 화면 조립
inpModel.ts INP 파싱·생성, 편집 함수
inpExtras.ts 다중 수요·수질·에너지 등 구조화 섹션
worker.ts WASM 해석 (Web Worker)
Map.tsx 지도 표시·편집 (MapLibre)
*Section.tsx 분석 탭 화면 (편집 도구, DEM, GIS, 최적화 ...)
*Tools.ts 분석·편집·GIS 순수 로직 (Node 테스트 대상)
backend/ FastAPI 해석 API
main.py API 진입점
epanet.py EPANET DLL ctypes 바인딩
batch.py, jobs.py 일괄 해석 프레임워크와 분석 Job
gisio.py, gisops.py GIS 입출력·연산 (GDAL)
dem.py DEM 보관·표고·지형 타일·유역
blocksys/ 블록 분할 CLI
tests/ pytest
64bit/ EPANET 2.3.5 DLL
public/ 예제 관망 (Net1~3, 한글 CP949, 합성망)
tests/ Node 회귀 테스트
deploy/ Dockerfile, nginx.conf
data/ 서버 실행 중 생성 (DEM 보관, 커밋 제외)
```
+93
View File
@@ -0,0 +1,93 @@
# SPipe 사내 서버 설치 안내
다른 Windows PC를 SPipe 서버로 쓰기 위한 절차입니다. 사내 사용자는 브라우저에서 `http://<서버 IP>:8080` 으로 접속합니다.
- 웹 화면: 서버의 8080 포트 (사내망에 공개)
- 해석 API: 서버 내부 127.0.0.1:8000 (외부 비공개, 웹 서버가 `/api` 요청만 전달)
## 1. 준비물
| 항목 | 버전 / 비고 |
|---|---|
| Windows x64 PC | 해석 엔진 `64bit/epanet2.dll` 사용 |
| Node.js | 24.x (검증 버전 24.14.1) |
| Python | 3.12 64비트 (검증 버전 3.12.10), Python Launcher(`py`) 포함 |
| 인터넷 | 최초 패키지 설치, 배경지도·주소 검색 |
## 2. 설치 (최초 1회)
전달받은 `SPipe_server_*.zip`을 `C:\SPipe`에 압축 해제한 뒤 PowerShell에서 실행합니다. 경로는 원하는 위치로 바꿔도 됩니다.
```powershell
cd C:\SPipe
npm.cmd ci
py -3.12 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r backend\requirements.txt
.\.venv\Scripts\python.exe -m pip check
Copy-Item .env.example .env.local
notepad .env.local
npm.cmd test
npm.cmd run build
```
`.env.local`에는 브이월드 API 키와 카카오 JavaScript 키를 넣습니다. 키는 저장소·압축 파일에 들어 있지 않으니 담당자에게 따로 받습니다. 키를 넣지 않으면 브이월드 배경지도와 주소 검색만 동작하지 않고, 키를 바꾼 뒤에는 다시 빌드(`npm.cmd run build` 또는 `start-lan.ps1 -Rebuild`)해야 반영됩니다.
`npm test`가 모두 통과하고 빌드가 성공하면 다음 단계로 넘어갑니다.
## 3. 방화벽 (관리자 PowerShell, 최초 1회)
같은 사내 서브넷에서만 8080 포트 접속을 허용합니다. 회사 네트워크가 "공용"으로 잡혀 있어도 적용되도록 세 프로필을 모두 지정합니다.
```powershell
New-NetFirewallRule -DisplayName "SPipe 8080" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Domain,Private,Public -RemoteAddress LocalSubnet
```
다른 층·지사 등 서브넷이 다른 곳에서도 접속해야 하면 `LocalSubnet` 대신 사내 IP 대역(예: `10.0.0.0/8`)을 넣습니다. 8000 포트는 열지 않습니다.
## 4. 실행
```powershell
cd C:\SPipe
powershell -ExecutionPolicy Bypass -File .\start-lan.ps1
```
화면에 표시되는 `http://<서버 IP>:8080` 주소를 사내에 공유합니다. 창을 닫거나 Ctrl+C를 누르면 웹·API 서버가 함께 종료됩니다.
재부팅 후 자동으로 켜려면 작업 스케줄러에 "로그온 시" 트리거로 위 명령을 등록합니다.
## 5. 카카오 주소 검색 도메인 등록
주소 검색은 카카오 JavaScript 키를 쓰며, **등록된 주소에서만 동작합니다.** 카카오 개발자 콘솔 → 앱 → 플랫폼 키 → JavaScript 키 → "JavaScript SDK 도메인"에 서버 주소를 추가합니다.
- 예: `http://10.12.30.5:8080` (서버의 실제 IP)
- 서버 PC에서 직접 열 경우: `http://localhost:8080`
서버 IP가 바뀌면 이 등록도 바꿔야 하므로, 서버 PC는 고정 IP를 받아 두는 것을 권장합니다.
## 6. 업데이트
새 zip을 받으면 서버를 끄고(Ctrl+C), `C:\SPipe`에 덮어써 압축을 푼 뒤 실행합니다. `.venv`, `node_modules` 폴더는 지우지 않아도 됩니다.
```powershell
cd C:\SPipe
npm.cmd ci
.\.venv\Scripts\python.exe -m pip install -r backend\requirements.txt
powershell -ExecutionPolicy Bypass -File .\start-lan.ps1 -Rebuild
```
`start-lan.ps1`은 `dist` 폴더가 있으면 기존 빌드를 그대로 씁니다. 코드를 받은 뒤에는 반드시 `-Rebuild`를 붙입니다.
## 7. 사용자 안내 사항
- 작업 내용 자동 저장은 각자의 브라우저에만 남습니다. 서버에 저장되지 않으므로 INP와 결과물은 파일로 보관합니다.
- 배경지도·주소 검색·표고 조회는 외부 인터넷이 필요합니다.
- `.net` 내보내기는 지원하지 않습니다. `.inp`로 저장합니다.
- 로그인 기능이 없어 사내망의 누구나 접속할 수 있습니다.
## 8. 알려진 보류 항목 (2026-10-15 점검)
- `vite preview`는 상시 운영용 서버가 아닙니다. 사용자가 늘면 nginx 구성(`deploy/`)으로 옮깁니다.
- API 업로드 의존성(python-multipart 0.0.20, Starlette 0.47.3)에 알려진 보안 문제가 있어 수정 버전으로 갱신이 예정되어 있습니다. 갱신 전까지는 사내망에서만 운영합니다.
+13
View File
@@ -0,0 +1,13 @@
# SPipe 작업 기록
## 2026-10-30 데모 v2 배포본 등록
- 원본: `SPipe_server_2026-10-30.zip` (파일 181개). 압축은 저장소에 올리지 않고 풀어서 등록
- 변경 내역: `docs/SPipe_패치내역_v2_2026-10-30.txt` (이전 배포 데모 v1, 2026-10-15)
- 등록 제외
- `backend/temp.bin`, `backend/temp.inp`: 해석 중 생기는 임시 파일
- `.env.local`, 배포 전달 메모: 브이월드·카카오 API 키 포함
- 실데이터(상리 INP), HWP, 화면 캡처, 테스트 DEM: 배포본에 포함되지 않음
- README: 원본 README에 데모 v2 서버 업데이트 순서를 추가하고, 저장소에 없는 문서 목차를 정리
- 서버 반영: 기존 폴더에 덮어쓰기(`.venv`·`node_modules`·`data`·`.env.local` 유지) → `pip install -r backend\requirements.txt` → `start-lan.ps1 -Rebuild`
- 남은 일: 서버 PC IP 확정 시 카카오 JavaScript SDK 도메인에 `http://<서버 IP>:8080` 추가
+1
View File
@@ -0,0 +1 @@
# backend module
+332
View File
@@ -0,0 +1,332 @@
"""Repeated-solve helper for batch analyses (fire flow, criticality, VSP,
calibration ...).
`Network` opens one INP with the EPANET 2.3 DLL and keeps it open, so an
analysis can change values (demands, statuses, settings, controls) and
re-solve hydraulics many times without re-reading the file. Results are
returned at the reporting periods only ([TIMES] REPORT START / REPORT STEP),
the same periods the main /api/analyze endpoint reports, as numpy arrays.
Runs inside the API process pool (one Network per call, never shared).
"""
from __future__ import annotations
import ctypes
import os
import tempfile
from dataclasses import dataclass, field
import numpy as np
from .epanet import EN, EpanetAPI, EpanetException
_api: EpanetAPI | None = None
def get_api() -> EpanetAPI:
"""One DLL handle per process (ctypes handles must not cross processes)."""
global _api
if _api is None:
_api = EpanetAPI()
return _api
class BatchError(Exception):
"""User-facing input problem (unknown ID, bad parameter)."""
@dataclass
class SimResult:
times: list[int]
nodes: dict[int, np.ndarray] = field(default_factory=dict) # prop -> [period, node]
links: dict[int, np.ndarray] = field(default_factory=dict) # prop -> [period, link]
warnings: list[tuple[int, int]] = field(default_factory=list) # (time, code)
def _decode(raw: bytes) -> str:
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
return raw.decode("cp949", errors="replace")
class Network:
def __init__(self, inp_bytes: bytes):
self.api = get_api()
self.lib = self.api.lib
self._tmp = tempfile.TemporaryDirectory()
inp = os.path.join(self._tmp.name, "n.inp")
with open(inp, "wb") as f:
f.write(inp_bytes)
self.ph = self.api.EN_Project()
self.lib.EN_createproject(ctypes.byref(self.ph))
self._opened = False
try:
self._opened = True
self.lib.EN_open(self.ph, inp.encode(), os.path.join(self._tmp.name, "n.rpt").encode(), b"")
except EpanetException as e:
self.close()
raise BatchError(str(e)) from e
n, l = ctypes.c_int(), ctypes.c_int()
self.lib.EN_getcount(self.ph, EN.NODECOUNT, ctypes.byref(n))
self.lib.EN_getcount(self.ph, EN.LINKCOUNT, ctypes.byref(l))
self.node_count, self.link_count = n.value, l.value
buf = ctypes.create_string_buffer(256)
t = ctypes.c_int()
self.node_ids: list[str] = []
self.node_types: list[int] = []
for i in range(1, self.node_count + 1):
self.lib.EN_getnodeid(self.ph, i, buf)
self.node_ids.append(_decode(buf.value))
self.lib.EN_getnodetype(self.ph, i, ctypes.byref(t))
self.node_types.append(t.value)
self.link_ids: list[str] = []
self.link_types: list[int] = []
self.link_nodes: list[tuple[int, int]] = []
a, b = ctypes.c_int(), ctypes.c_int()
for i in range(1, self.link_count + 1):
self.lib.EN_getlinkid(self.ph, i, buf)
self.link_ids.append(_decode(buf.value))
self.lib.EN_getlinktype(self.ph, i, ctypes.byref(t))
self.link_types.append(t.value)
self.lib.EN_getlinknodes(self.ph, i, ctypes.byref(a), ctypes.byref(b))
self.link_nodes.append((a.value, b.value))
self._node_index = {nid: i + 1 for i, nid in enumerate(self.node_ids)}
self._link_index = {lid: i + 1 for i, lid in enumerate(self.link_ids)}
self.duration = self.time_param(EN.DURATION)
self.hyd_step = self.time_param(EN.HYDSTEP)
self.report_step = max(1, self.time_param(EN.REPORTSTEP))
self.report_start = self.time_param(EN.REPORTSTART)
self.pattern_step = max(1, self.time_param(EN.PATTERNSTEP))
# ---- lifecycle ----
def close(self) -> None:
if getattr(self, "ph", None) is None:
return
try:
if self._opened:
self.lib.EN_close(self.ph)
except EpanetException:
pass
try:
self.lib.EN_deleteproject(self.ph)
except EpanetException:
pass
self.ph = None
self._tmp.cleanup()
def __enter__(self) -> "Network":
return self
def __exit__(self, *exc) -> None:
self.close()
# ---- lookups ----
def node_index(self, node_id: str) -> int:
try:
return self._node_index[node_id]
except KeyError:
raise BatchError(f"절점 {node_id}가 모델에 없습니다.") from None
def link_index(self, link_id: str) -> int:
try:
return self._link_index[link_id]
except KeyError:
raise BatchError(f"링크 {link_id}가 모델에 없습니다.") from None
def junction_indices(self) -> list[int]:
return [i + 1 for i, t in enumerate(self.node_types) if t == EN.JUNCTION]
# ---- values ----
def time_param(self, code: int) -> int:
v = ctypes.c_long()
self.lib.EN_gettimeparam(self.ph, code, ctypes.byref(v))
return v.value
def set_time_param(self, code: int, value: int) -> None:
self.lib.EN_settimeparam(self.ph, code, int(value))
def get_node(self, index: int, prop: int) -> float:
v = ctypes.c_double()
self.lib.EN_getnodevalue(self.ph, index, prop, ctypes.byref(v))
return v.value
def set_node(self, index: int, prop: int, value: float) -> None:
self.lib.EN_setnodevalue(self.ph, index, prop, float(value))
def get_link(self, index: int, prop: int) -> float:
v = ctypes.c_double()
self.lib.EN_getlinkvalue(self.ph, index, prop, ctypes.byref(v))
return v.value
def set_link(self, index: int, prop: int, value: float) -> None:
self.lib.EN_setlinkvalue(self.ph, index, prop, float(value))
def add_pattern(self, pid: str, values: list[float]) -> None:
"""Create the pattern, or overwrite its values if the ID already exists."""
idx = ctypes.c_int()
try:
self.lib.EN_getpatternindex(self.ph, pid.encode(), ctypes.byref(idx))
except EpanetException:
self.lib.EN_addpattern(self.ph, pid.encode())
self.lib.EN_getpatternindex(self.ph, pid.encode(), ctypes.byref(idx))
arr = (ctypes.c_double * len(values))(*values)
self.lib.EN_setpattern(self.ph, idx.value, arr, len(values))
def window_pattern(self, pid: str, start_s: int, end_s: int) -> None:
"""Pattern that is 1 on [start, end) and 0 elsewhere, over the duration."""
# EPANET uses period (t + PATTERN START) // PATTERN STEP, so period k
# starts at simulation time k * step - pattern_start.
pstart = self.time_param(EN.PATTERNSTART)
n = max(1, -(-(max(self.duration, 1) + pstart) // self.pattern_step) + 1)
vals = []
for k in range(n):
t0 = k * self.pattern_step - pstart
vals.append(1.0 if start_s <= t0 < end_s else 0.0)
self.add_pattern(pid, vals)
def add_demand(self, index: int, base: float, pattern: str = "", name: str = "") -> int:
"""Adds a demand category; returns its 1-based category index."""
self.lib.EN_adddemand(self.ph, index, float(base), pattern.encode(), name.encode())
k = ctypes.c_int()
self.lib.EN_getnumdemands(self.ph, index, ctypes.byref(k))
return k.value
def delete_demand(self, index: int, category: int) -> None:
self.lib.EN_deletedemand(self.ph, index, category)
def add_timer_control(self, link_index: int, setting: float, at_s: int) -> int:
"""setting: 0 = CLOSED, 1 = OPEN (pipes) or a speed/valve setting."""
out = ctypes.c_int()
self.lib.EN_addcontrol(self.ph, EN.TIMER, link_index, float(setting), 0, float(at_s), ctypes.byref(out))
return out.value
def delete_control(self, index: int) -> None:
self.lib.EN_deletecontrol(self.ph, index)
# ---- solve ----
def run_quality(self, nodes: list[int] | None = None) -> tuple[list[int], np.ndarray]:
"""Quality run on hydraulics saved by EN_solveH; QUALITY at report periods [period, node]."""
node_idx = nodes if nodes is not None else list(range(1, self.node_count + 1))
t, tstep = ctypes.c_long(), ctypes.c_long(1)
v = ctypes.c_double()
times: list[int] = []
rows: list[list[float]] = []
ph, get_n = self.ph, self.lib.EN_getnodevalue
self.lib.EN_openQ(ph)
try:
self.lib.EN_initQ(ph, EN.NOSAVE)
while True:
self.lib.EN_runQ(ph, ctypes.byref(t))
if self.is_report_time(t.value) and (not times or times[-1] != t.value):
times.append(t.value)
row = []
for i in node_idx:
get_n(ph, i, EN.QUALITY, ctypes.byref(v))
row.append(v.value)
rows.append(row)
self.lib.EN_nextQ(ph, ctypes.byref(tstep))
if tstep.value <= 0:
break
finally:
self.lib.EN_closeQ(ph)
return times, np.asarray(rows, dtype=float).reshape(len(times), len(node_idx))
def pattern_value(self, pattern_index: int, t: int, pattern_start: int = 0) -> float:
"""Multiplier of a pattern at simulation time t (1.0 without a pattern)."""
if pattern_index <= 0:
return 1.0
n = ctypes.c_int()
self.lib.EN_getpatternlen(self.ph, pattern_index, ctypes.byref(n))
if n.value <= 0:
return 1.0
period = ((t + pattern_start) // self.pattern_step) % n.value
v = ctypes.c_double()
self.lib.EN_getpatternvalue(self.ph, pattern_index, period + 1, ctypes.byref(v))
return v.value
def option(self, code: int) -> float:
v = ctypes.c_double()
self.lib.EN_getoption(self.ph, code, ctypes.byref(v))
return v.value
def is_report_time(self, t: int) -> bool:
return t >= self.report_start and (t - self.report_start) % self.report_step == 0
def simulate(
self,
node_props: tuple[int, ...] = (EN.PRESSURE,),
link_props: tuple[int, ...] = (),
nodes: list[int] | None = None,
links: list[int] | None = None,
max_periods: int = 20000,
) -> SimResult:
"""Run an EPS and record the given properties at reporting periods.
`nodes`/`links` are 1-based indices (default: all). Arrays are
[period, element] in the order of the given index lists.
"""
node_idx = nodes if nodes is not None else list(range(1, self.node_count + 1))
link_idx = links if links is not None else list(range(1, self.link_count + 1))
rows_n: dict[int, list[list[float]]] = {p: [] for p in node_props}
rows_l: dict[int, list[list[float]]] = {p: [] for p in link_props}
times: list[int] = []
warnings: list[tuple[int, int]] = []
t, tstep = ctypes.c_long(), ctypes.c_long(1)
v = ctypes.c_double()
get_n, get_l, ph = self.lib.EN_getnodevalue, self.lib.EN_getlinkvalue, self.ph
self.lib.EN_openH(ph)
try:
self.lib.EN_initH(ph, EN.NOSAVE)
while True:
code = self.lib.EN_runH(ph, ctypes.byref(t))
if 0 < code <= 10:
warnings.append((t.value, code))
if self.is_report_time(t.value):
times.append(t.value)
for p in node_props:
row = []
for i in node_idx:
get_n(ph, i, p, ctypes.byref(v))
row.append(v.value)
rows_n[p].append(row)
for p in link_props:
row = []
for i in link_idx:
get_l(ph, i, p, ctypes.byref(v))
row.append(v.value)
rows_l[p].append(row)
if len(times) > max_periods:
raise BatchError(f"보고 시간대가 {max_periods}개를 넘습니다.")
self.lib.EN_nextH(ph, ctypes.byref(tstep))
if tstep.value <= 0:
break
finally:
self.lib.EN_closeH(ph)
return SimResult(
times=times,
nodes={p: np.asarray(r, dtype=float).reshape(len(times), len(node_idx)) for p, r in rows_n.items()},
links={p: np.asarray(r, dtype=float).reshape(len(times), len(link_idx)) for p, r in rows_l.items()},
warnings=warnings,
)
def hhmm(seconds: int) -> str:
return f"{seconds // 3600}:{(seconds % 3600) // 60:02d}"
def parse_time(value, default: int = 0) -> int:
"""Seconds from a number of hours, 'h:mm', or None."""
if value is None or value == "":
return default
if isinstance(value, (int, float)):
return int(round(float(value) * 3600))
s = str(value).strip()
if ":" in s:
parts = [float(x) for x in s.split(":")]
while len(parts) < 3:
parts.append(0.0)
return int(round(parts[0] * 3600 + parts[1] * 60 + parts[2]))
return int(round(float(s) * 3600))
+11
View File
@@ -0,0 +1,11 @@
"""상수도 블록시스템 자동 분할 도구.
「지방상수도 현대화사업 업무편람」(환경부, 2021) 2편 Ⅰ.8 블록시스템 구축계획 수립의
구축절차 ①~④를 지원한다. 절차 ⑤(블록고립 현장조사)와 최종 경계 확정은 범위 밖이며
사람이 판단한다.
현재 구현 범위(MVP, Step 1~6): 네트워크 로드, 기준 수리해석, 가중 그래프 구성,
블록 분할, 경계밸브/유량계 산정, 분할안 수리적 검증까지.
"""
__version__ = "0.1.0"
+6
View File
@@ -0,0 +1,6 @@
import sys
from .cli import main
if __name__ == "__main__":
sys.exit(main())
+83
View File
@@ -0,0 +1,83 @@
"""Step 7 — 경계 관로 추출 및 밸브/유량계 배정, Step 8 — 모델에 반영."""
from __future__ import annotations
import copy
from dataclasses import dataclass
import networkx as nx
import wntr
from .validate import BaselineResult
@dataclass
class BoundaryLink:
pipe_id: str
from_node: str
to_node: str
block_a: int
block_b: int
action: str # 'close' | 'meter'
baseline_flow: float
diameter: float
def find_boundary_links(G: nx.Graph, labels: dict[str, int]) -> list[BoundaryLink]:
"""경계 엣지 = 양 끝 노드의 블록 라벨이 다른 엣지. 병렬관은 개별 pipe_id로 전개."""
boundary: list[BoundaryLink] = []
for u, v, data in G.edges(data=True):
if u not in labels or v not in labels:
continue
block_a, block_b = labels[u], labels[v]
if block_a == block_b:
continue
for pipe_id in data.get("pipe_ids", []):
boundary.append(
BoundaryLink(
pipe_id=pipe_id,
from_node=u,
to_node=v,
block_a=block_a,
block_b=block_b,
action="close",
baseline_flow=0.0,
diameter=data.get("diameter", 0.0),
)
)
return boundary
def assign_valve_or_meter(
boundary: list[BoundaryLink],
baseline: BaselineResult,
inlets_per_block: int = 1,
) -> list[BoundaryLink]:
"""블록별 유입 경로: baseline 유량 상위 inlets_per_block개는 유량계, 나머지는 차단."""
for link in boundary:
link.baseline_flow = baseline.link_flow_mean_abs.get(link.pipe_id, 0.0)
by_block: dict[int, list[BoundaryLink]] = {}
for link in boundary:
by_block.setdefault(link.block_a, []).append(link)
by_block.setdefault(link.block_b, []).append(link)
meter_pipe_ids: set[str] = set()
for links in by_block.values():
ranked = sorted(links, key=lambda l: l.baseline_flow, reverse=True)
meter_pipe_ids.update(l.pipe_id for l in ranked[:inlets_per_block])
for link in boundary:
link.action = "meter" if link.pipe_id in meter_pipe_ids else "close"
return boundary
def apply_blocks_to_model(
wn: "wntr.network.WaterNetworkModel", boundary: list[BoundaryLink]
) -> "wntr.network.WaterNetworkModel":
wn_blocked = copy.deepcopy(wn)
for link in boundary:
if link.action == "close":
wn_blocked.get_link(link.pipe_id).initial_status = "Closed"
return wn_blocked
+170
View File
@@ -0,0 +1,170 @@
"""CLI: python -m blocksys run --inp network.inp --config config.yaml [--k K] --out ./result
MVP(Step 1~6) 범위: 로드 -> 기준 수리해석 -> 가중 그래프 -> 분할 -> 경계밸브/유량계 ->
분할안 수리적 검증. balance/refine, score.py, report.html, valve_segment_analysis는
아직 구현되지 않았다.
"""
from __future__ import annotations
import argparse
import csv
import logging
import math
import sys
from pathlib import Path
import wntr
from .boundary import BoundaryLink, apply_blocks_to_model, assign_valve_or_meter, find_boundary_links
from .config import Config
from .graph import apply_hard_constraints, build_weighted_graph
from .io import load_network
from .partition import partition
from .validate import ValidationResult, baseline_simulation, hydraulic_validation
logger = logging.getLogger("blocksys")
DISCLAIMER = (
"본 결과는 블록분할 검토 초안이며, 최종 블록 경계는 업무편람 구축절차 ⑤ "
"블록고립 현장조사를 거쳐 확정한다."
)
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="blocksys")
sub = parser.add_subparsers(dest="command", required=True)
run = sub.add_parser("run", help="블록 분할 대안 생성 및 수리적 검증 (MVP: Step 1~6)")
run.add_argument("--inp", required=True, type=Path, help="EPANET .inp 파일 경로")
run.add_argument("--config", type=Path, default=None, help="config.yaml 경로 (생략 시 기본값)")
run.add_argument("--connections", type=Path, default=None, help="connections.csv 경로 (선택)")
run.add_argument("--k", type=int, default=None, help="단일 대안만 생성. 미지정 시 k_min~k_max 스윕")
run.add_argument("--out", type=Path, default=Path("./result"), help="출력 디렉터리")
return parser
def _ensure_utf8_stdio() -> None:
# Windows 콘솔 기본 코드페이지(cp949 등)에서 한글 로그가 깨지는 것을 방지.
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8")
except (AttributeError, ValueError):
pass
def run_command(args: argparse.Namespace) -> int:
_ensure_utf8_stdio()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
cfg = Config.from_yaml(args.config) if args.config else Config()
args.out.mkdir(parents=True, exist_ok=True)
logger.info("네트워크 로드: %s", args.inp)
bundle = load_network(args.inp, args.connections)
logger.info("기준 수리해석(baseline) 실행 중...")
baseline = baseline_simulation(bundle.wn, cfg)
Gw = build_weighted_graph(bundle.graph, baseline, cfg)
Gw = apply_hard_constraints(Gw, cfg)
k_values = [args.k] if args.k is not None else list(range(cfg.partition.k_min, cfg.partition.k_max + 1))
summary_rows = []
for k in k_values:
logger.info("k=%d 대안 생성 중...", k)
try:
labels = partition(Gw, k, cfg)
except ValueError as exc:
logger.warning("k=%d 분할 실패: %s", k, exc)
continue
boundary = find_boundary_links(Gw, labels)
boundary = assign_valve_or_meter(boundary, baseline, inlets_per_block=cfg.boundary.inlets_per_block)
wn_blocked = apply_blocks_to_model(bundle.wn, boundary)
validation = hydraulic_validation(wn_blocked, baseline, cfg)
_write_boundary_csv(args.out / f"boundary_valves_k{k}.csv", boundary)
wntr.network.io.write_inpfile(wn_blocked, str(args.out / f"alt_{k}.inp"))
summary_rows.append(_summarize(k, Gw, boundary, validation))
logger.info(
"k=%d: valve=%d meter=%d status=%s",
k,
sum(1 for b in boundary if b.action == "close"),
sum(1 for b in boundary if b.action == "meter"),
validation.status,
)
_write_summary_csv(args.out / "validation.csv", summary_rows)
logger.info("완료. 결과 디렉터리: %s", args.out)
logger.info(DISCLAIMER)
return 0
def _summarize(k: int, Gw, boundary: list[BoundaryLink], validation: ValidationResult) -> dict:
boundary_length = sum(
Gw[b.from_node][b.to_node].get("length", 0.0) for b in boundary if Gw.has_edge(b.from_node, b.to_node)
)
return {
"k": k,
"n_valves": sum(1 for b in boundary if b.action == "close"),
"n_meters": sum(1 for b in boundary if b.action == "meter"),
"boundary_pipe_length_m": round(boundary_length, 1),
"min_pressure_m": _fmt(validation.min_pressure_m),
"pressure_violations": len(validation.pressure_low_violations) + len(validation.pressure_high_violations),
"isolated_nodes": len(validation.isolated_nodes),
"max_water_age_h": _fmt(validation.max_water_age_h),
"status": validation.status,
"rejection_reason": validation.rejection_reason or "",
}
def _fmt(value: float) -> str:
return "" if value is None or math.isnan(value) else round(value, 2)
def _write_boundary_csv(path: Path, boundary: list[BoundaryLink]) -> None:
fieldnames = ["pipe_id", "from_node", "to_node", "block_a", "block_b", "action", "baseline_flow", "diameter"]
with path.open("w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for b in boundary:
writer.writerow(
{
"pipe_id": b.pipe_id,
"from_node": b.from_node,
"to_node": b.to_node,
"block_a": b.block_a,
"block_b": b.block_b,
"action": b.action,
"baseline_flow": b.baseline_flow,
"diameter": b.diameter,
}
)
def _write_summary_csv(path: Path, rows: list[dict]) -> None:
if not rows:
logger.warning("유효한 대안이 없어 validation.csv를 생성하지 않았습니다")
return
with path.open("w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
def main(argv: list[str] | None = None) -> int:
parser = build_arg_parser()
args = parser.parse_args(argv)
if args.command == "run":
return run_command(args)
parser.print_help()
return 1
if __name__ == "__main__":
sys.exit(main())
+43
View File
@@ -0,0 +1,43 @@
crs: EPSG:5186
partition:
level: small # large | medium | small
k_min: 4 # 블록 수 탐색 하한
k_max: 20 # 상한
method: spectral # spectral | louvain
flow_weight_alpha: 0.7 # 엣지 가중치에서 유량 비중 (나머지는 관경)
constraints:
connections_per_block:
min: 500 # TODO: 편람에 수치 규정 없음. 사내 관행값 확정 필요
max: 3000
max_pipe_length_per_block: 30000 # m, TODO
demand_per_block:
max_cmd: null # m3/day, null이면 미적용
elevation_band_m: 30 # 표고차 이 값 초과 시 경계 선호
large_consumer_demand_lps: 5.0 # 이상이면 대수용가로 태깅, TODO
large_consumer_policy: isolate # isolate | keep_together
sources_in_separate_blocks: true
hydraulics:
duration_hours: 24
timestep_minutes: 60
min_pressure_m: 15.0 # TODO: 수도법·상수도설계기준 확인 후 확정
max_pressure_m: 71.0 # TODO
max_water_age_hours: 48 # TODO
allow_isolated_nodes: false
refine:
max_iterations: 20
moves_per_iteration: 50
boundary:
inlets_per_block: 1 # TODO: 통상 1개, 예비 포함 2개
scoring:
weights:
n_valves: 0.25
n_meters: 0.15
pressure_violations: 0.30
max_water_age: 0.15
boundary_pipe_length: 0.15
+114
View File
@@ -0,0 +1,114 @@
"""설정파일(config.yaml) 스키마와 로더.
사내 관행값·발주처 기준이 필요한 TODO 항목은 §9 표를 참고. 전부 이 모듈의
기본값으로 노출되어 있으며, config.yaml로 덮어쓴다.
"""
from __future__ import annotations
import dataclasses
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
import yaml
@dataclass
class ConnectionsPerBlock:
min: int = 500 # TODO: 편람에 수치 규정 없음. 사내 관행값 확정 필요
max: int = 3000
@dataclass
class DemandPerBlock:
max_cmd: Optional[float] = None # m3/day, null이면 미적용
@dataclass
class PartitionConfig:
level: str = "small" # large | medium | small
k_min: int = 4
k_max: int = 20
method: str = "spectral" # spectral | louvain
flow_weight_alpha: float = 0.7
@dataclass
class ConstraintsConfig:
connections_per_block: ConnectionsPerBlock = field(default_factory=ConnectionsPerBlock)
max_pipe_length_per_block: float = 30000.0 # m, TODO
demand_per_block: DemandPerBlock = field(default_factory=DemandPerBlock)
elevation_band_m: float = 30.0
large_consumer_demand_lps: float = 5.0 # TODO
large_consumer_policy: str = "isolate" # isolate | keep_together
sources_in_separate_blocks: bool = True
@dataclass
class HydraulicsConfig:
duration_hours: int = 24
timestep_minutes: int = 60
min_pressure_m: float = 15.0 # TODO: 수도법 시행규칙 / 상수도설계기준 확인 후 확정
max_pressure_m: float = 71.0 # TODO
max_water_age_hours: float = 48.0 # TODO
allow_isolated_nodes: bool = False
@dataclass
class RefineConfig:
max_iterations: int = 20
moves_per_iteration: int = 50
@dataclass
class ScoringWeights:
n_valves: float = 0.25
n_meters: float = 0.15
pressure_violations: float = 0.30
max_water_age: float = 0.15
boundary_pipe_length: float = 0.15
@dataclass
class ScoringConfig:
weights: ScoringWeights = field(default_factory=ScoringWeights)
@dataclass
class BoundaryConfig:
# 블록당 유입지점(유량계) 목표 개수. 편람에 수치 없음, 통상 1개 예비 포함 2개. TODO
inlets_per_block: int = 1
@dataclass
class Config:
crs: str = "EPSG:5186"
partition: PartitionConfig = field(default_factory=PartitionConfig)
constraints: ConstraintsConfig = field(default_factory=ConstraintsConfig)
hydraulics: HydraulicsConfig = field(default_factory=HydraulicsConfig)
refine: RefineConfig = field(default_factory=RefineConfig)
scoring: ScoringConfig = field(default_factory=ScoringConfig)
boundary: BoundaryConfig = field(default_factory=BoundaryConfig)
@classmethod
def from_yaml(cls, path: str | Path) -> "Config":
raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
return cls.from_dict(raw)
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "Config":
cfg = cls()
_apply(cfg, raw)
return cfg
def _apply(instance: Any, data: dict[str, Any]) -> None:
for key, value in data.items():
if not hasattr(instance, key):
continue
current = getattr(instance, key)
if dataclasses.is_dataclass(current) and isinstance(value, dict):
_apply(current, value)
else:
setattr(instance, key, value)
+70
View File
@@ -0,0 +1,70 @@
"""Step 3 — 가중 그래프 구성, Step 4 — 하드 제약(강한 선호)을 가중치에 반영."""
from __future__ import annotations
import networkx as nx
from .config import Config
from .validate import BaselineResult
def build_weighted_graph(G: nx.Graph, baseline: BaselineResult, cfg: Config) -> nx.Graph:
"""엣지 가중치 = '이 관로를 자르기 싫은 정도'. 클수록 절단 회피."""
Gw = G.copy()
alpha = cfg.partition.flow_weight_alpha
flows: dict[tuple, float] = {}
for u, v, data in Gw.edges(data=True):
pipe_ids = data.get("pipe_ids", [])
flows[(u, v)] = max((baseline.link_flow_mean_abs.get(pid, 0.0) for pid in pipe_ids), default=0.0)
max_flow = max(flows.values(), default=0.0)
max_diameter = max((data.get("diameter", 0.0) for _, _, data in Gw.edges(data=True)), default=0.0)
for u, v, data in Gw.edges(data=True):
q_norm = (flows[(u, v)] / max_flow) if max_flow > 0 else 0.0
d_norm = (data.get("diameter", 0.0) / max_diameter) if max_diameter > 0 else 0.0
w = alpha * q_norm + (1 - alpha) * d_norm
data["w"] = float(min(max(w, 1e-6), 1.0))
return Gw
def apply_hard_constraints(Gw: nx.Graph, cfg: Config) -> nx.Graph:
c = cfg.constraints
# 1. 표고대 분리
for u, v, data in Gw.edges(data=True):
eu = Gw.nodes[u].get("elevation", 0.0)
ev = Gw.nodes[v].get("elevation", 0.0)
if abs(eu - ev) > c.elevation_band_m:
data["w"] *= 0.1
# 2. 대수용가 (base_demand는 SI[m3/s], 기준값은 L/s로 주어짐)
threshold_m3s = c.large_consumer_demand_lps / 1000.0
large_consumers = {
n for n, ndata in Gw.nodes(data=True) if ndata.get("base_demand", 0.0) >= threshold_m3s
}
for n in Gw.nodes:
Gw.nodes[n]["is_large_consumer"] = n in large_consumers
factor = 0.1 if c.large_consumer_policy == "isolate" else 5.0
for n in large_consumers:
for _, _, data in Gw.edges(n, data=True):
data["w"] *= factor
# 3. 수원 분리: Reservoir/Tank 노드쌍 간 최단경로(연장 기준) 상 엣지
if c.sources_in_separate_blocks:
sources = [n for n, ndata in Gw.nodes(data=True) if ndata.get("node_type") in ("Reservoir", "Tank")]
for i, s1 in enumerate(sources):
for s2 in sources[i + 1 :]:
if not nx.has_path(Gw, s1, s2):
continue
path = nx.shortest_path(Gw, s1, s2, weight="length")
for a, b in zip(path[:-1], path[1:]):
Gw[a][b]["w"] *= 0.5
for _, _, data in Gw.edges(data=True):
data["w"] = float(min(max(data["w"], 1e-6), 1.0))
return Gw
+122
View File
@@ -0,0 +1,122 @@
"""Step 1 — EPANET 모델 로드 및 단순 그래프 변환."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import networkx as nx
import pandas as pd
import wntr
logger = logging.getLogger(__name__)
@dataclass
class NetworkBundle:
wn: "wntr.network.WaterNetworkModel"
graph: nx.Graph
isolated_nodes: list[str]
connectivity_warning: Optional[str] = None
def load_network(inp_path: str | Path, connections_csv: Optional[str | Path] = None) -> NetworkBundle:
wn = wntr.network.WaterNetworkModel(str(inp_path))
G = _build_simple_graph(wn)
_inject_node_attributes(wn, G)
_load_connections(G, connections_csv)
isolated = [n for n in G.nodes if G.degree(n) == 0]
if isolated:
logger.warning("%d개 노드가 어떤 관로에도 연결되어 있지 않습니다: %s", len(isolated), isolated[:10])
connectivity_warning = None
if G.number_of_nodes() > 0 and not nx.is_connected(G):
n_components = nx.number_connected_components(G)
connectivity_warning = f"네트워크가 {n_components}개의 연결되지 않은 구성요소로 나뉘어 있습니다"
logger.warning(connectivity_warning)
return NetworkBundle(wn=wn, graph=G, isolated_nodes=isolated, connectivity_warning=connectivity_warning)
def _build_simple_graph(wn: "wntr.network.WaterNetworkModel") -> nx.Graph:
multi = wn.to_graph() # MultiDiGraph, 노드 좌표 등 기본 속성 포함
G = nx.Graph()
G.add_nodes_from(multi.nodes)
grouped: dict[tuple[str, str], list[str]] = {}
for u, v, key in multi.edges(keys=True):
pair = (u, v) if u <= v else (v, u)
grouped.setdefault(pair, []).append(key)
for (u, v), link_names in grouped.items():
lengths, diameters, roughness = [], [], []
for name in link_names:
link = wn.get_link(name)
lengths.append(getattr(link, "length", 0.0) or 0.0)
diameters.append(getattr(link, "diameter", 0.0) or 0.0)
roughness.append(getattr(link, "roughness", 0.0) or 0.0)
if len(link_names) == 1:
length, diameter, rough = lengths[0], diameters[0], roughness[0]
else:
# 병렬관 등가화: 단면적(D^2) 합산 기준 등가관경, 연장은 최단 경로 기준 최소값.
# 실제 수리해석은 원본 wn(EPANET 엔진)으로 수행하므로 여기서는 분할 가중치용 근사치.
length = min(lengths) if lengths else 0.0
diameter = (sum(d**2 for d in diameters)) ** 0.5 if diameters else 0.0
rough = max(roughness) if roughness else 0.0
G.add_edge(u, v, length=length, diameter=diameter, roughness=rough, pipe_ids=list(link_names))
return G
def _inject_node_attributes(wn: "wntr.network.WaterNetworkModel", G: nx.Graph) -> None:
for node_name in G.nodes:
node = wn.get_node(node_name)
elevation = getattr(node, "elevation", None)
if elevation is None:
elevation = getattr(node, "base_head", 0.0) # Reservoir
base_demand = 0.0
for demand in getattr(node, "demand_timeseries_list", []) or []:
base_demand += demand.base_value
G.nodes[node_name]["elevation"] = float(elevation or 0.0)
G.nodes[node_name]["base_demand"] = float(base_demand)
G.nodes[node_name]["coordinates"] = node.coordinates
G.nodes[node_name]["node_type"] = type(node).__name__
G.nodes[node_name]["connections"] = None
G.nodes[node_name]["is_large_consumer_csv"] = False
def _load_connections(G: nx.Graph, connections_csv: Optional[str | Path]) -> None:
if not connections_csv:
logger.warning(
"connections.csv가 제공되지 않았습니다. 급수전수 기반 제약"
"(constraints.connections_per_block)은 이번 실행에서 평가되지 않습니다."
)
return
df = pd.read_csv(connections_csv, dtype={"node_id": str})
missing = {"node_id", "connections"} - set(df.columns)
if missing:
raise ValueError(f"connections.csv에 필수 컬럼이 없습니다: {sorted(missing)}")
unknown = 0
for _, row in df.iterrows():
node_id = str(row["node_id"])
if node_id not in G.nodes:
unknown += 1
continue
G.nodes[node_id]["connections"] = float(row["connections"])
if "is_large_consumer" in df.columns and not pd.isna(row["is_large_consumer"]):
G.nodes[node_id]["is_large_consumer_csv"] = bool(row["is_large_consumer"])
if unknown:
logger.warning("connections.csv의 %d개 행이 네트워크 노드와 일치하지 않아 무시되었습니다", unknown)
+64
View File
@@ -0,0 +1,64 @@
"""Step 5 — 블록 분할 (MVP: partition만. balance_check/refine은 범위 밖)."""
from __future__ import annotations
import networkx as nx
import numpy as np
from sklearn.cluster import SpectralClustering
from .config import Config
def partition(Gw: nx.Graph, k: int, cfg: Config) -> dict[str, int]:
if k < 1:
raise ValueError(f"k는 1 이상이어야 합니다: {k}")
if Gw.number_of_nodes() < k:
raise ValueError(f"노드 수({Gw.number_of_nodes()})가 k({k})보다 적습니다")
method = cfg.partition.method
if method == "spectral":
return _partition_spectral(Gw, k)
if method == "louvain":
return _partition_louvain(Gw, k)
raise ValueError(f"알 수 없는 분할 방법: {method}")
def _partition_spectral(Gw: nx.Graph, k: int) -> dict[str, int]:
nodes = list(Gw.nodes)
A = nx.to_numpy_array(Gw, nodelist=nodes, weight="w")
np.fill_diagonal(A, 0.0)
model = SpectralClustering(
n_clusters=k,
affinity="precomputed",
assign_labels="kmeans",
random_state=0,
)
labels = model.fit_predict(A)
return {n: int(label) for n, label in zip(nodes, labels)}
def _partition_louvain(Gw: nx.Graph, k: int, max_steps: int = 30) -> dict[str, int]:
lo, hi = 0.01, 10.0
best_communities: list[set] | None = None
best_diff = None
for _ in range(max_steps):
r = (lo + hi) / 2
communities = nx.community.louvain_communities(Gw, weight="w", resolution=r, seed=0)
n_found = len(communities)
diff = abs(n_found - k)
if best_diff is None or diff < best_diff:
best_diff, best_communities = diff, communities
if n_found == k:
break
if n_found < k:
lo = r
else:
hi = r
labels: dict[str, int] = {}
for i, community in enumerate(best_communities or []):
for n in community:
labels[n] = i
return labels
View File
Whitespace-only changes.
+37
View File
@@ -0,0 +1,37 @@
"""공통 픽스처. wntr 동봉 Net3.inp를 개발용 데이터로 사용 (§7)."""
from __future__ import annotations
import pytest
import wntr
from blocksys.config import Config
from blocksys.graph import apply_hard_constraints, build_weighted_graph
from blocksys.io import load_network
from blocksys.validate import baseline_simulation
NET3_PATH = wntr.library.model_library.get_filepath("Net3")
@pytest.fixture(scope="session")
def cfg() -> Config:
c = Config()
c.hydraulics.duration_hours = 6 # 테스트 속도를 위해 24 -> 6시간으로 단축
c.hydraulics.timestep_minutes = 60
return c
@pytest.fixture(scope="session")
def bundle():
return load_network(NET3_PATH)
@pytest.fixture(scope="session")
def baseline(bundle, cfg):
return baseline_simulation(bundle.wn, cfg)
@pytest.fixture(scope="session")
def weighted_graph(bundle, baseline, cfg):
Gw = build_weighted_graph(bundle.graph, baseline, cfg)
return apply_hard_constraints(Gw, cfg)
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
from blocksys.boundary import apply_blocks_to_model, assign_valve_or_meter, find_boundary_links
from blocksys.partition import partition
def test_boundary_matches_differing_labels(weighted_graph, cfg):
labels = partition(weighted_graph, 6, cfg)
boundary = find_boundary_links(weighted_graph, labels)
expected_pairs = {
(u, v)
for u, v in weighted_graph.edges
if u in labels and v in labels and labels[u] != labels[v]
}
actual_pairs = {(b.from_node, b.to_node) for b in boundary}
assert actual_pairs == expected_pairs
def test_each_boundary_link_has_a_real_pipe_id(weighted_graph, bundle, cfg):
labels = partition(weighted_graph, 6, cfg)
boundary = find_boundary_links(weighted_graph, labels)
for b in boundary:
assert b.pipe_id in bundle.wn.link_name_list
def test_assign_valve_or_meter_gives_every_block_an_inlet(weighted_graph, baseline, cfg):
labels = partition(weighted_graph, 6, cfg)
boundary = find_boundary_links(weighted_graph, labels)
boundary = assign_valve_or_meter(boundary, baseline, inlets_per_block=cfg.boundary.inlets_per_block)
assert {b.action for b in boundary} <= {"close", "meter"}
blocks_touched = {b.block_a for b in boundary} | {b.block_b for b in boundary}
for block in blocks_touched:
links = [b for b in boundary if b.block_a == block or b.block_b == block]
assert any(b.action == "meter" for b in links)
def test_apply_blocks_to_model_only_closes_marked_links(weighted_graph, bundle, baseline, cfg):
labels = partition(weighted_graph, 6, cfg)
boundary = find_boundary_links(weighted_graph, labels)
boundary = assign_valve_or_meter(boundary, baseline, inlets_per_block=cfg.boundary.inlets_per_block)
already_closed = {
name for name, link in bundle.wn.links() if str(link.initial_status) == "Closed"
}
wn_blocked = apply_blocks_to_model(bundle.wn, boundary)
newly_closed_expected = {b.pipe_id for b in boundary if b.action == "close"}
closed_actual = {
name for name, link in wn_blocked.links() if str(link.initial_status) == "Closed"
}
assert closed_actual == already_closed | newly_closed_expected
# 원본 모델은 변경되지 않아야 한다
assert all(
str(bundle.wn.get_link(pid).initial_status) != "Closed"
for pid in newly_closed_expected - already_closed
)
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
def test_node_preservation(bundle, weighted_graph):
assert set(weighted_graph.nodes) == set(bundle.graph.nodes)
def test_edge_preservation(bundle, weighted_graph):
assert weighted_graph.number_of_edges() == bundle.graph.number_of_edges()
def test_weight_range(weighted_graph):
weights = [d["w"] for _, _, d in weighted_graph.edges(data=True)]
assert weights, "가중치가 부여된 엣지가 없습니다"
assert all(0.0 < w <= 1.0 for w in weights)
def test_large_consumer_edges_suppressed(bundle, weighted_graph, baseline, cfg):
from blocksys.graph import build_weighted_graph
large_nodes = [n for n, d in weighted_graph.nodes(data=True) if d.get("is_large_consumer")]
if not large_nodes:
return # Net3 기본 설정(5 L/s)에는 대수용가가 없을 수 있음
plain = build_weighted_graph(bundle.graph, baseline, cfg)
# 대수용가 인접 엣지는 제약 반영 전보다 가중치가 낮아야 한다(isolate 기본 정책)
for n in large_nodes:
for u, v, d in weighted_graph.edges(n, data=True):
assert d["w"] <= plain[u][v]["w"]
def test_reproducibility(bundle, baseline, cfg):
from blocksys.graph import apply_hard_constraints, build_weighted_graph
g1 = apply_hard_constraints(build_weighted_graph(bundle.graph, baseline, cfg), cfg)
g2 = apply_hard_constraints(build_weighted_graph(bundle.graph, baseline, cfg), cfg)
for u, v, d in g1.edges(data=True):
assert g2[u][v]["w"] == d["w"]
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import networkx as nx
import pytest
from blocksys.partition import partition
K_VALUES = [4, 6, 8]
@pytest.mark.parametrize("k", K_VALUES)
def test_node_preservation(weighted_graph, cfg, k):
labels = partition(weighted_graph, k, cfg)
assert set(labels.keys()) == set(weighted_graph.nodes)
@pytest.mark.parametrize("k", K_VALUES)
def test_block_connectivity(weighted_graph, cfg, k):
labels = partition(weighted_graph, k, cfg)
blocks: dict[int, list] = {}
for n, label in labels.items():
blocks.setdefault(label, []).append(n)
for label, nodes in blocks.items():
sub = weighted_graph.subgraph(nodes)
assert nx.is_connected(sub), f"block {label} is not a single connected component"
def test_reproducibility(weighted_graph, cfg):
labels1 = partition(weighted_graph, 6, cfg)
labels2 = partition(weighted_graph, 6, cfg)
assert labels1 == labels2
def test_k_too_large_raises(weighted_graph, cfg):
with pytest.raises(ValueError):
partition(weighted_graph, weighted_graph.number_of_nodes() + 1, cfg)
def test_louvain_method(weighted_graph, cfg):
cfg.partition.method = "louvain"
try:
labels = partition(weighted_graph, 6, cfg)
assert set(labels.keys()) == set(weighted_graph.nodes)
finally:
cfg.partition.method = "spectral"
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
import math
from blocksys.boundary import BoundaryLink, apply_blocks_to_model
from blocksys.validate import hydraulic_validation
def test_baseline_simulation_has_all_nodes(bundle, baseline):
assert set(baseline.node_pressure_mean.keys()) == set(bundle.wn.node_name_list)
assert set(baseline.link_flow_mean_abs.keys()) == set(bundle.wn.link_name_list)
def test_baseline_pressures_are_finite(baseline):
assert all(math.isfinite(v) for v in baseline.node_pressure_mean.values())
def test_hydraulic_validation_runs_on_unmodified_network(bundle, baseline, cfg):
result = hydraulic_validation(bundle.wn, baseline, cfg)
assert result.status in ("ok", "violated")
assert not result.isolated_nodes
def test_isolation_is_detected_and_rejected(bundle, baseline, cfg):
graph = bundle.graph
source_names = set(bundle.wn.reservoir_name_list) | set(bundle.wn.tank_name_list)
leaf = next(n for n in graph.nodes if graph.degree(n) == 1 and n not in source_names)
_, neighbor, edge_data = next(iter(graph.edges(leaf, data=True)))
pipe_id = edge_data["pipe_ids"][0]
boundary = [
BoundaryLink(
pipe_id=pipe_id,
from_node=leaf,
to_node=neighbor,
block_a=0,
block_b=1,
action="close",
baseline_flow=0.0,
diameter=0.0,
)
]
wn_blocked = apply_blocks_to_model(bundle.wn, boundary)
result = hydraulic_validation(wn_blocked, baseline, cfg)
assert leaf in result.isolated_nodes
assert result.status == "rejected"
assert result.rejection_reason
def test_allow_isolated_nodes_still_simulates(bundle, baseline, cfg):
graph = bundle.graph
source_names = set(bundle.wn.reservoir_name_list) | set(bundle.wn.tank_name_list)
leaf = next(n for n in graph.nodes if graph.degree(n) == 1 and n not in source_names)
_, neighbor, edge_data = next(iter(graph.edges(leaf, data=True)))
pipe_id = edge_data["pipe_ids"][0]
boundary = [
BoundaryLink(
pipe_id=pipe_id, from_node=leaf, to_node=neighbor, block_a=0, block_b=1,
action="close", baseline_flow=0.0, diameter=0.0,
)
]
wn_blocked = apply_blocks_to_model(bundle.wn, boundary)
cfg.hydraulics.allow_isolated_nodes = True
try:
result = hydraulic_validation(wn_blocked, baseline, cfg)
assert leaf in result.isolated_nodes
assert result.status == "violated"
finally:
cfg.hydraulics.allow_isolated_nodes = False
+171
View File
@@ -0,0 +1,171 @@
"""Step 2 — 기준 수리해석, Step 9 — 분할안 수리적 검증."""
from __future__ import annotations
import copy
import logging
import math
from dataclasses import dataclass, field
from typing import Optional
import networkx as nx
import wntr
from .config import Config
def _run_epanet(wn):
"""Run wntr's EPANET simulator in a private temp dir.
EpanetSimulator writes temp.inp/temp.rpt/temp.bin into the *current
directory* by default, which left stray files in backend/ on every run.
"""
import os
import tempfile
with tempfile.TemporaryDirectory(prefix="spipe_blocksys_") as tmp:
return wntr.sim.EpanetSimulator(wn).run_sim(file_prefix=os.path.join(tmp, "sim"))
logger = logging.getLogger(__name__)
@dataclass
class BaselineResult:
node_pressure_mean: dict[str, float]
node_pressure_min: dict[str, float]
link_flow_mean_abs: dict[str, float]
link_flow_max_abs: dict[str, float]
node_demand_mean: dict[str, float]
node_age_max: dict[str, float] # hours
@dataclass
class ValidationResult:
pressure_low_violations: list[str] = field(default_factory=list)
pressure_high_violations: list[str] = field(default_factory=list)
isolated_nodes: list[str] = field(default_factory=list)
water_age_violations: list[str] = field(default_factory=list)
min_pressure_m: float = math.nan
max_water_age_h: float = math.nan
pressure_drop: dict[str, float] = field(default_factory=dict)
status: str = "ok" # ok | violated | rejected
rejection_reason: Optional[str] = None
@property
def is_ok(self) -> bool:
return self.status == "ok"
def _configure_time(wn: "wntr.network.WaterNetworkModel", cfg: Config) -> None:
duration = cfg.hydraulics.duration_hours * 3600
step = cfg.hydraulics.timestep_minutes * 60
wn.options.time.duration = duration
wn.options.time.hydraulic_timestep = step
wn.options.time.report_timestep = step
wn.options.time.quality_timestep = min(wn.options.time.quality_timestep, step)
def baseline_simulation(wn: "wntr.network.WaterNetworkModel", cfg: Config) -> BaselineResult:
wn_sim = copy.deepcopy(wn)
_configure_time(wn_sim, cfg)
results = _run_epanet(wn_sim)
pressure = results.node["pressure"]
flow = results.link["flowrate"].abs()
demand = results.node["demand"]
return BaselineResult(
node_pressure_mean=pressure.mean().to_dict(),
node_pressure_min=pressure.min().to_dict(),
link_flow_mean_abs=flow.mean().to_dict(),
link_flow_max_abs=flow.max().to_dict(),
node_demand_mean=demand.mean().to_dict(),
node_age_max=_run_water_age(wn, cfg),
)
def _run_water_age(wn: "wntr.network.WaterNetworkModel", cfg: Config) -> dict[str, float]:
wn_age = copy.deepcopy(wn)
_configure_time(wn_age, cfg)
wn_age.options.quality.parameter = "AGE"
results = _run_epanet(wn_age)
age_hours = results.node["quality"] / 3600.0 # EPANET AGE는 초 단위(SI)로 보고됨
return age_hours.max().to_dict()
def _open_link_graph(wn: "wntr.network.WaterNetworkModel") -> nx.Graph:
G = nx.Graph()
G.add_nodes_from(wn.node_name_list)
for _, link in wn.links():
if link.initial_status == wntr.network.LinkStatus.Closed:
continue
G.add_edge(link.start_node_name, link.end_node_name)
return G
def _find_isolated_nodes(G: nx.Graph, source_names: list[str]) -> list[str]:
sources = [s for s in source_names if s in G]
if not sources:
return []
reachable: set[str] = set()
for source in sources:
reachable |= nx.node_connected_component(G, source)
return sorted(n for n in G.nodes if n not in reachable)
def hydraulic_validation(
wn_blocked: "wntr.network.WaterNetworkModel",
baseline: BaselineResult,
cfg: Config,
) -> ValidationResult:
source_names = list(wn_blocked.reservoir_name_list) + list(wn_blocked.tank_name_list)
isolated = _find_isolated_nodes(_open_link_graph(wn_blocked), source_names)
if isolated and not cfg.hydraulics.allow_isolated_nodes:
return ValidationResult(
isolated_nodes=isolated,
status="rejected",
rejection_reason=f"{len(isolated)}개 노드가 수원으로부터 고립되었습니다 (구축절차 ④ 위반)",
)
try:
wn_sim = copy.deepcopy(wn_blocked)
_configure_time(wn_sim, cfg)
results = _run_epanet(wn_sim)
pressure = results.node["pressure"]
age_max = _run_water_age(wn_blocked, cfg)
except Exception as exc: # EPANET 엔진 수렴 실패 등
logger.warning("수리해석 실패: %s", exc)
return ValidationResult(
isolated_nodes=isolated,
status="rejected",
rejection_reason=f"수리해석 실패: {exc}",
)
pressure_min = pressure.min()
pressure_max = pressure.max()
pressure_low = sorted(pressure_min[pressure_min < cfg.hydraulics.min_pressure_m].index)
pressure_high = sorted(pressure_max[pressure_max > cfg.hydraulics.max_pressure_m].index)
age_violations = sorted(n for n, v in age_max.items() if v > cfg.hydraulics.max_water_age_hours)
pressure_min_dict = pressure_min.to_dict()
pressure_drop = {
n: baseline.node_pressure_min[n] - pressure_min_dict[n]
for n in pressure_min_dict
if n in baseline.node_pressure_min
}
violated = bool(pressure_low or pressure_high or age_violations or (isolated and cfg.hydraulics.allow_isolated_nodes))
return ValidationResult(
pressure_low_violations=pressure_low,
pressure_high_violations=pressure_high,
isolated_nodes=isolated,
water_age_violations=age_violations,
min_pressure_m=float(pressure_min.min()) if len(pressure_min) else math.nan,
max_water_age_h=max(age_max.values()) if age_max else math.nan,
pressure_drop=pressure_drop,
status="violated" if violated else "ok",
)
+72
View File
@@ -0,0 +1,72 @@
"""Compact wire format for /api/analyze results (``?format=columnar``).
The object format repeats every key per node/link and writes each float as
~18 characters of JSON (9.5 MB for a 2,000-node, 25-step network). Here the
time series are packed per variable into one little-endian float64 array per
element kind, base64-encoded (~10.7 bytes per value), so full double precision
is kept -- the Native-vs-WASM comparison tolerances (rel 1e-8) still apply.
Layout of each packed series: element-major, i.e. element ``i``'s values are
``data[i * steps:(i + 1) * steps]`` in the order of ``ids``.
src/api.ts (decodeColumnarResult) turns it back into the object format.
"""
import base64
import sys
from array import array
FORMAT = "spipe-columnar-1"
NODE_SERIES = ("demand", "head", "pressure", "quality")
LINK_SERIES = ("flow", "velocity", "headloss", "quality")
PUMP_FIELDS = ("energy", "efficiency", "totalKwh", "peakKw", "avgKw", "utilization")
def _pack(items: dict, ids: list, key: str, steps: int):
"""Packed base64 series, or None when any element lacks a full series
(e.g. no water-quality run) -- the decoder then leaves it empty."""
flat = array("d")
for element_id in ids:
series = items[element_id]["timeSeries"].get(key) or []
if len(series) != steps:
return None
flat.extend(series)
if sys.byteorder != "little":
flat.byteswap()
return base64.b64encode(flat.tobytes()).decode("ascii")
def to_columnar(result: dict) -> dict:
steps = len(result.get("timeSteps") or [])
nodes, links = result["nodes"], result["links"]
node_ids, link_ids = list(nodes), list(links)
# Pump series and the pump's scalar fields (initial energy/efficiency, kWh
# totals) use distinct keys -- they share names in the object format.
pumps = {
lid: {
"energySeries": links[lid]["timeSeries"].get("energy") or [],
"efficiencySeries": links[lid]["timeSeries"].get("efficiency") or [],
**{f: links[lid].get(f) for f in PUMP_FIELDS if f in links[lid]},
}
for lid in link_ids
if "totalKwh" in links[lid]
}
return {
"format": FORMAT,
**{k: v for k, v in result.items() if k not in ("nodes", "links")},
"nodes": {
"ids": node_ids,
"types": [nodes[i]["type"] for i in node_ids],
"coordinates": [nodes[i]["coordinates"] for i in node_ids],
"series": {k: _pack(nodes, node_ids, k, steps) for k in NODE_SERIES},
},
"links": {
"ids": link_ids,
"types": [links[i]["type"] for i in link_ids],
"fromNodes": [links[i]["fromNode"] for i in link_ids],
"toNodes": [links[i]["toNode"] for i in link_ids],
"vertices": [links[i]["vertices"] for i in link_ids],
"series": {k: _pack(links, link_ids, k, steps) for k in LINK_SERIES},
"pumps": pumps,
},
}
+432
View File
@@ -0,0 +1,432 @@
"""DEM (digital elevation model) store: upload once, preview, sample, build from points.
Rasters are kept under SPIPE_DEM_DIR (default: <repo>/data/dem) as GeoTIFF,
keyed by a random id; only the newest SPIPE_DEM_KEEP files are retained.
Elevations are returned in the raster's own units (normally metres).
"""
from __future__ import annotations
import json
import os
import shutil
import uuid
import warnings
import zipfile
from pathlib import Path
import numpy as np
import rasterio
import shapely
import shapely.geometry
from rasterio.enums import Resampling
from rasterio.io import MemoryFile
from rasterio.transform import from_origin
from pyproj import CRS, Transformer
from .batch import BatchError
DEM_DIR = Path(os.environ.get("SPIPE_DEM_DIR", Path(__file__).resolve().parents[1] / "data" / "dem"))
DEM_KEEP = int(os.environ.get("SPIPE_DEM_KEEP", "8"))
RASTER_EXTENSIONS = (".tif", ".tiff", ".asc", ".img", ".dem", ".bil", ".flt", ".hgt", ".vrt")
MAX_CELLS_FROM_POINTS = 25_000_000
_ID_OK = set("0123456789abcdef")
def _path(dem_id: str) -> Path:
if len(dem_id) != 32 or not set(dem_id) <= _ID_OK:
raise BatchError("DEM id가 올바르지 않습니다.")
p = DEM_DIR / f"{dem_id}.tif"
if not p.exists():
raise BatchError("DEM을 찾을 수 없습니다. 다시 불러오세요 (서버가 오래된 DEM을 정리했을 수 있습니다).")
return p
def _trim() -> None:
files = sorted(DEM_DIR.glob("*.tif"), key=lambda p: p.stat().st_mtime, reverse=True)
for old in files[DEM_KEEP:]:
old.unlink(missing_ok=True)
old.with_suffix(".json").unlink(missing_ok=True)
def _crs_of(user: str | None) -> CRS:
if not user:
raise BatchError("좌표계가 필요합니다 (로컬 좌표계에서는 DEM을 쓸 수 없습니다).")
try:
return CRS.from_user_input(user)
except Exception as e: # pyproj raises several types
raise BatchError(f"좌표계를 읽을 수 없습니다: {user[:60]}") from e
def _info(dem_id: str, src, name: str) -> dict:
if src.crs is None:
raise BatchError("DEM에 좌표계 정보가 없습니다 (.prj 또는 GeoTIFF 좌표계를 넣어 주세요).")
band = src.read(1, masked=True, out_shape=(min(src.height, 1024), min(src.width, 1024)), resampling=Resampling.nearest)
to_wgs = Transformer.from_crs(src.crs, "EPSG:4326", always_xy=True)
l, b, r, t = src.bounds
corners = [list(to_wgs.transform(x, y)) for x, y in ((l, t), (r, t), (r, b), (l, b))]
return {
"id": dem_id,
"name": name,
"crs": src.crs.to_string(),
"width": src.width,
"height": src.height,
"resolution": [abs(src.res[0]), abs(src.res[1])],
"bounds": [l, b, r, t],
"corners": corners,
"min": float(band.min()) if band.count() else None,
"max": float(band.max()) if band.count() else None,
"nodata": src.nodata,
}
def _store(src_path: str, name: str) -> dict:
DEM_DIR.mkdir(parents=True, exist_ok=True)
dem_id = uuid.uuid4().hex
out = DEM_DIR / f"{dem_id}.tif"
try:
with rasterio.open(src_path) as src:
if src.count < 1:
raise BatchError("래스터 밴드가 없습니다.")
profile = src.profile.copy()
profile.update(driver="GTiff", count=1, compress="deflate", tiled=True, blockxsize=256, blockysize=256, BIGTIFF="IF_SAFER")
if src.width < 256 or src.height < 256:
profile.update(tiled=False)
profile.pop("blockxsize", None)
profile.pop("blockysize", None)
with rasterio.open(out, "w", **profile) as dst:
for _, window in src.block_windows(1):
dst.write(src.read(1, window=window), 1, window=window)
with rasterio.open(out) as src:
info = _info(dem_id, src, name)
except rasterio.errors.RasterioIOError as e:
out.unlink(missing_ok=True)
raise BatchError(f"래스터를 읽을 수 없습니다: {e}") from e
except BatchError:
out.unlink(missing_ok=True)
raise
out.with_suffix(".json").write_text(json.dumps(info, ensure_ascii=False), encoding="utf-8")
_trim()
return info
def import_dem(upload_path: str, filename: str) -> dict:
"""Store an uploaded raster (or a zip holding one, e.g. ASC + PRJ) as a DEM."""
ext = os.path.splitext(filename.lower())[1]
if ext == ".zip":
try:
with zipfile.ZipFile(upload_path) as z:
names = [n for n in z.namelist() if os.path.splitext(n.lower())[1] in RASTER_EXTENSIONS and not n.startswith("__MACOSX")]
except zipfile.BadZipFile as e:
raise BatchError("zip 파일이 손상되었습니다.") from e
if not names:
raise BatchError(f"zip 안에 래스터({', '.join(RASTER_EXTENSIONS)})가 없습니다.")
return _store("/vsizip/" + upload_path.replace("\\", "/") + "/" + names[0], filename)
if ext not in RASTER_EXTENSIONS:
raise BatchError(f"지원하지 않는 형식입니다: {ext} (GeoTIFF, ASC, IMG 또는 이를 담은 zip)")
# GDAL picks the driver from the extension; keep it on the temp copy.
named = upload_path + ext
shutil.copyfile(upload_path, named)
try:
return _store(named, filename)
finally:
os.remove(named)
def dem_info(dem_id: str) -> dict:
p = _path(dem_id)
return json.loads(p.with_suffix(".json").read_text(encoding="utf-8"))
def preview_png(dem_id: str, max_size: int = 1600) -> bytes:
"""Hillshaded colour relief, transparent where there is no data."""
with rasterio.open(_path(dem_id)) as src:
k = max(1, int(np.ceil(max(src.width, src.height) / max_size)))
h, w = max(1, src.height // k), max(1, src.width // k)
z = src.read(1, masked=True, out_shape=(h, w), resampling=Resampling.bilinear).astype("float64")
cell = abs(src.res[0]) * src.width / w
geographic = src.crs and src.crs.is_geographic
if geographic:
cell *= 111_000
mask = np.ma.getmaskarray(z)
zf = z.filled(np.nan)
lo, hi = np.nanpercentile(zf, [2, 98]) if np.isfinite(zf).any() else (0.0, 1.0)
t = np.clip((zf - lo) / max(hi - lo, 1e-9), 0, 1)
# Green -> yellow -> brown -> white elevation ramp.
stops = np.array([[0.0, 64, 140, 80], [0.35, 190, 200, 110], [0.7, 160, 110, 60], [1.0, 245, 245, 240]])
rgb = np.stack([np.interp(np.nan_to_num(t), stops[:, 0], stops[:, c]) for c in (1, 2, 3)])
gy, gx = np.gradient(np.nan_to_num(zf, nan=float(np.nanmean(zf)) if np.isfinite(zf).any() else 0.0), cell)
slope = np.arctan(np.hypot(gx, gy))
aspect = np.arctan2(-gx, gy)
az, alt = np.radians(315), np.radians(45)
shade = np.clip(np.sin(alt) * np.cos(slope) + np.cos(alt) * np.sin(slope) * np.cos(az - aspect), 0, 1)
rgb = rgb * (0.55 + 0.45 * shade)
alpha = np.where(mask | ~np.isfinite(zf), 0, 255)
img = np.concatenate([rgb, alpha[None]]).clip(0, 255).astype("uint8")
with warnings.catch_warnings():
warnings.simplefilter("ignore", rasterio.errors.NotGeoreferencedWarning) # a plain image
with MemoryFile() as mem:
with mem.open(driver="PNG", width=w, height=h, count=4, dtype="uint8") as dst:
dst.write(img)
return mem.read()
def sample(dem_id: str, points: list, points_crs: str | None) -> list:
"""Bilinear DEM values at [x, y] points given in `points_crs` (None outside / no data)."""
if not points:
return []
pts = np.asarray(points, dtype="float64").reshape(-1, 2)
with rasterio.open(_path(dem_id)) as src:
tr = Transformer.from_crs(_crs_of(points_crs), src.crs, always_xy=True)
xs, ys = tr.transform(pts[:, 0], pts[:, 1])
inv = ~src.transform
xs, ys = np.asarray(xs), np.asarray(ys)
cols, rows = inv.a * xs + inv.b * ys + inv.c, inv.d * xs + inv.e * ys + inv.f
# pixel centres sit at +0.5
cols, rows = np.asarray(cols) - 0.5, np.asarray(rows) - 0.5
ok = np.isfinite(cols) & np.isfinite(rows) & (cols >= -0.5) & (rows >= -0.5) & (cols <= src.width - 0.5) & (rows <= src.height - 0.5)
out: list = [None] * len(pts)
if not ok.any():
return out
c0 = int(max(0, np.floor(cols[ok].min()))); c1 = int(min(src.width - 1, np.floor(cols[ok].max()) + 1))
r0 = int(max(0, np.floor(rows[ok].min()))); r1 = int(min(src.height - 1, np.floor(rows[ok].max()) + 1))
win = rasterio.windows.Window(c0, r0, c1 - c0 + 1, r1 - r0 + 1)
if win.width * win.height > 400_000_000:
raise BatchError("지점들이 너무 넓게 퍼져 있습니다. 나눠서 실행하세요.")
z = src.read(1, window=win, masked=True).astype("float64").filled(np.nan)
for k in np.where(ok)[0]:
c, r = cols[k] - c0, rows[k] - r0
ci, ri = int(np.clip(np.floor(c), 0, z.shape[1] - 1)), int(np.clip(np.floor(r), 0, z.shape[0] - 1))
cj, rj = min(ci + 1, z.shape[1] - 1), min(ri + 1, z.shape[0] - 1)
fc, fr = float(np.clip(c - ci, 0, 1)), float(np.clip(r - ri, 0, 1))
q = np.array([z[ri, ci], z[ri, cj], z[rj, ci], z[rj, cj]])
wts = np.array([(1 - fc) * (1 - fr), fc * (1 - fr), (1 - fc) * fr, fc * fr])
good = np.isfinite(q) & (wts > 0)
if good.any():
out[k] = round(float(np.sum(q[good] * wts[good]) / np.sum(wts[good])), 3)
elif np.isfinite(q[0]):
out[k] = round(float(q[0]), 3)
return out
def from_points(points: list, points_crs: str | None, cell: float, name: str = "점 표고 DEM", target_crs: str | None = None) -> dict:
"""TIN-style DEM: linear interpolation over the Delaunay triangulation of [x, y, z] points."""
from scipy.interpolate import LinearNDInterpolator
pts = np.asarray(points, dtype="float64").reshape(-1, 3)
pts = pts[np.all(np.isfinite(pts), axis=1)]
if len(pts) < 3:
raise BatchError("표고 점이 3개 이상 필요합니다.")
crs = _crs_of(points_crs)
if target_crs:
# e.g. GIS points arrive in WGS84; grid them in the model's plane CRS.
dst = _crs_of(target_crs)
x, y = Transformer.from_crs(crs, dst, always_xy=True).transform(pts[:, 0], pts[:, 1])
pts = np.column_stack([x, y, pts[:, 2]])
crs = dst
if crs.is_geographic:
raise BatchError("경위도 좌표계 점으로는 만들 수 없습니다. 평면 좌표계를 쓰세요.")
if not (cell > 0):
raise BatchError("격자 크기는 0보다 커야 합니다.")
x0, y0 = pts[:, 0].min(), pts[:, 1].min()
x1, y1 = pts[:, 0].max(), pts[:, 1].max()
w, h = int(np.ceil((x1 - x0) / cell)) + 1, int(np.ceil((y1 - y0) / cell)) + 1
if w * h > MAX_CELLS_FROM_POINTS:
raise BatchError(f"격자가 너무 큽니다 ({w}x{h}). 격자 크기를 키우세요.")
try:
interp = LinearNDInterpolator(pts[:, :2], pts[:, 2])
except Exception as e: # Qhull errors on collinear input
raise BatchError("점들이 한 줄 위에 있어 삼각망을 만들 수 없습니다.") from e
gx = x0 + (np.arange(w) + 0.5) * cell
gy = y1 - (np.arange(h) + 0.5) * cell
xx, yy = np.meshgrid(gx, gy)
z = interp(xx, yy).astype("float32")
nodata = -9999.0
z[~np.isfinite(z)] = nodata
DEM_DIR.mkdir(parents=True, exist_ok=True)
tmp = DEM_DIR / f"tmp_{uuid.uuid4().hex}.tif"
with rasterio.open(tmp, "w", driver="GTiff", width=w, height=h, count=1, dtype="float32", crs=crs.to_wkt(),
transform=from_origin(x0, y1, cell, cell), nodata=nodata, compress="deflate") as dst:
dst.write(z, 1)
try:
return _store(str(tmp), name)
finally:
tmp.unlink(missing_ok=True)
def terrain_tile(dem_id: str, z: int, x: int, y: int) -> bytes:
"""Web-mercator XYZ tile of the DEM in Mapbox/MapLibre 'terrarium' encoding
(elevation = R*256 + G + B/256 - 32768)."""
from rasterio.warp import reproject
if not (0 <= z <= 18 and 0 <= x < 2 ** z and 0 <= y < 2 ** z):
raise BatchError("타일 번호가 올바르지 않습니다.")
size = 256
world = 20037508.342789244
span = 2 * world / 2 ** z
x0, y1 = -world + x * span, world - y * span
dst = np.full((size, size), np.nan, dtype="float32")
with rasterio.open(_path(dem_id)) as src:
reproject(
source=rasterio.band(src, 1), destination=dst,
dst_transform=from_origin(x0, y1, span / size, span / size), dst_crs="EPSG:3857",
dst_nodata=np.nan, resampling=Resampling.bilinear,
)
# Outside the DEM: its lowest value, so the terrain does not drop to sea level in a cliff.
fill = float(np.nanmin(dst)) if np.isfinite(dst).any() else float(dem_info(dem_id).get("min") or 0.0)
v = np.nan_to_num(dst, nan=fill).astype("float64") + 32768.0
r = np.floor(v / 256)
g = np.floor(v - r * 256)
b = np.floor((v - r * 256 - g) * 256)
img = np.stack([r, g, b]).clip(0, 255).astype("uint8")
with warnings.catch_warnings():
warnings.simplefilter("ignore", rasterio.errors.NotGeoreferencedWarning)
with MemoryFile() as mem:
with mem.open(driver="PNG", width=size, height=size, count=3, dtype="uint8") as d:
d.write(img)
return mem.read()
# ---------------------------------------------------------------- hydrology (유역·하천)
MAX_HYDRO_CELLS = 1_000_000
_D8 = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
def _hydro_grid(dem_id: str):
"""DEM (downsampled to <= MAX_HYDRO_CELLS) -> D8 receivers and flow accumulation
on a priority-flood filled surface (pits and flats drain)."""
import heapq
with rasterio.open(_path(dem_id)) as src:
k = max(1, int(np.ceil(np.sqrt(src.width * src.height / MAX_HYDRO_CELLS))))
h, w = max(2, src.height // k), max(2, src.width // k)
z = src.read(1, masked=True, out_shape=(h, w), resampling=Resampling.average).astype("float64")
transform = src.transform @ src.transform.scale(src.width / w, src.height / h)
crs = src.crs
if crs.is_geographic:
raise BatchError("유역 분석에는 평면 좌표계 DEM이 필요합니다.")
valid = ~np.ma.getmaskarray(z)
filled = np.where(valid, z.filled(np.nan), np.inf)
done = ~valid.copy()
heap: list = []
edge = np.zeros_like(valid)
edge[0, :] = edge[-1, :] = edge[:, 0] = edge[:, -1] = True
pad = np.pad(~valid, 1, constant_values=True)
near_nodata = np.zeros_like(valid)
for dr, dc in _D8:
near_nodata |= pad[1 + dr:1 + dr + h, 1 + dc:1 + dc + w]
for r, c in zip(*np.where(valid & (edge | near_nodata))):
heapq.heappush(heap, (filled[r, c], int(r), int(c)))
done[r, c] = True
eps = 1e-4
order = []
while heap:
e, r, c = heapq.heappop(heap)
order.append(r * w + c)
for dr, dc in _D8:
rr, cc = r + dr, c + dc
if 0 <= rr < h and 0 <= cc < w and not done[rr, cc]:
done[rr, cc] = True
filled[rr, cc] = max(filled[rr, cc], e + eps)
heapq.heappush(heap, (filled[rr, cc], rr, cc))
n = h * w
flat = filled.ravel()
recv = np.full(n, -1, dtype=np.int64)
best = np.zeros(n)
cell = abs(transform.a)
rows, cols = np.divmod(np.arange(n), w)
for dr, dc in _D8:
rr, cc = rows + dr, cols + dc
ok = (rr >= 0) & (rr < h) & (cc >= 0) & (cc < w)
nb = np.where(ok, rr * w + cc, 0)
with np.errstate(invalid="ignore"):
drop = np.where(ok, (flat - flat[nb]) / (cell * np.hypot(dr, dc)), -np.inf)
drop[~np.isfinite(drop)] = -np.inf
take = drop > best
best[take] = drop[take]
recv[take] = nb[take]
acc = valid.ravel().astype("float64")
for i in reversed(order): # from the highest cell down
j = recv[i]
if j >= 0:
acc[j] += acc[i]
return {"h": h, "w": w, "recv": recv, "acc": acc, "transform": transform, "crs": crs, "cell_area": abs(transform.a * transform.e)}
def streams(dem_id: str, min_area_km2: float) -> dict:
"""Stream lines (WGS84 GeoJSON): cells draining at least `min_area_km2`."""
g = _hydro_grid(dem_id)
thr = max(2.0, min_area_km2 * 1e6 / g["cell_area"])
w, tr = g["w"], g["transform"]
idx = np.where((g["acc"] >= thr) & (g["recv"] >= 0))[0]
if len(idx) > 300_000:
raise BatchError("하천 셀이 너무 많습니다. 최소 유역 면적을 키우세요.")
if not len(idx):
return {"type": "FeatureCollection", "features": []}
def xy(i):
r, c = divmod(int(i), w)
return tr @ (c + 0.5, r + 0.5)
segs = np.array([shapely.LineString([xy(i), xy(g["recv"][i])]) for i in idx], dtype=object)
merged = shapely.get_parts(shapely.line_merge(shapely.union_all(segs)))
to_wgs = Transformer.from_crs(g["crs"], "EPSG:4326", always_xy=True)
pts = shapely.points([xy(i) for i in idx])
tree = shapely.STRtree(pts)
acc_at = g["acc"][idx]
feats = []
for line in merged:
near = tree.query(line, predicate="dwithin", distance=abs(tr.a) * 0.6)
area = float(acc_at[near].max() * g["cell_area"] / 1e6) if len(near) else 0.0
coords = np.asarray(line.coords)
x, y = to_wgs.transform(coords[:, 0], coords[:, 1])
feats.append({"type": "Feature", "properties": {"area_km2": round(area, 3), "length_m": round(float(line.length), 1)},
"geometry": {"type": "LineString", "coordinates": [[float(a), float(b)] for a, b in zip(x, y)]}})
return {"type": "FeatureCollection", "features": feats}
def watershed(dem_id: str, points: list, points_crs: str | None, snap_m: float = 50.0) -> dict:
"""Catchment polygon upstream of each pour point (snapped to the largest flow within snap_m)."""
from rasterio import features as rfeatures
g = _hydro_grid(dem_id)
h, w, tr = g["h"], g["w"], g["transform"]
to_dem = Transformer.from_crs(_crs_of(points_crs), g["crs"], always_xy=True)
to_wgs = Transformer.from_crs(g["crs"], "EPSG:4326", always_xy=True)
order = np.argsort(g["recv"], kind="stable")
recv_sorted = g["recv"][order]
inv = ~tr
rad = max(1, int(round(snap_m / abs(tr.a))))
acc2 = g["acc"].reshape(h, w)
feats = []
for k, p in enumerate(points):
x, y = to_dem.transform(float(p[0]), float(p[1]))
c = inv.a * x + inv.b * y + inv.c
r = inv.d * x + inv.e * y + inv.f
r, c = int(r), int(c)
if not (0 <= r < h and 0 <= c < w):
continue
r0, r1, c0, c1 = max(0, r - rad), min(h, r + rad + 1), max(0, c - rad), min(w, c + rad + 1)
win = acc2[r0:r1, c0:c1]
rr, cc = np.unravel_index(int(np.argmax(win)), win.shape)
start = (r0 + rr) * w + (c0 + cc)
mask = np.zeros(h * w, dtype=bool)
mask[start] = True
stack = [start]
while stack:
j = stack.pop()
lo = np.searchsorted(recv_sorted, j, "left")
hi = np.searchsorted(recv_sorted, j, "right")
for i in order[lo:hi]:
if not mask[i]:
mask[i] = True
stack.append(int(i))
m2 = mask.reshape(h, w)
polys = [shapely.geometry.shape(geom) for geom, val in rfeatures.shapes(m2.astype("uint8"), mask=m2, transform=tr) if val == 1]
if not polys:
continue
poly = shapely.union_all(np.array(polys, dtype=object))
poly = shapely.transform(poly, lambda a: np.column_stack(to_wgs.transform(a[:, 0], a[:, 1])))
feats.append({"type": "Feature", "properties": {"point": k, "area_km2": round(float(mask.sum() * g["cell_area"] / 1e6), 4)},
"geometry": shapely.geometry.mapping(poly)})
return {"type": "FeatureCollection", "features": feats}
+266
View File
@@ -0,0 +1,266 @@
import ctypes
import os
class EN:
# EN_CountType
NODECOUNT = 0
LINKCOUNT = 2
# EN_NodeProperty
ELEVATION = 0
BASEDEMAND = 1
EMITTER = 3
TANKLEVEL = 8
DEMAND = 9
HEAD = 10
PRESSURE = 11
QUALITY = 12
# EN_LinkProperty
DIAMETER = 0
LENGTH = 1
ROUGHNESS = 2
INITSTATUS = 4
INITSETTING = 5
KWALL = 7
STATUS = 11
SETTING = 12
FLOW = 8
VELOCITY = 9
HEADLOSS = 10
ENERGY = 13
LINKQUAL = 14
PUMP_EFFIC = 17
# EN_InitHydOption
NOSAVE = 0
SAVE = 1
# EN_Option
PRESS_UNITS = 25
# EN_TimeParameter
DURATION = 0
HYDSTEP = 1
REPORTSTEP = 5
REPORTSTART = 6
# EN_TimeParameter (cont.)
PATTERNSTEP = 3
PATTERNSTART = 4
# EN_ControlType
TIMER = 2
# EN_QualityType / quality time step
QUAL_TRACE = 3
QUALSTEP = 2
# EN_Option (energy)
GLOBALEFFIC = 8
GLOBALPRICE = 9
GLOBALPATTERN = 10
DEMANDCHARGE = 11
# EN_LinkProperty (pump energy)
PUMP_ECOST = 21
PUMP_EPAT = 22
# EN_NodeType / EN_LinkType
JUNCTION = 0
RESERVOIR = 1
TANK = 2
CVPIPE = 0
PIPE = 1
PUMP = 2
FLOW_UNITS = {
0: "CFS", 1: "GPM", 2: "MGD", 3: "IMGD", 4: "AFD",
5: "LPS", 6: "LPM", 7: "MLD", 8: "CMH", 9: "CMD", 10: "CMS"
}
PRESS_UNITS = {
0: "psi", 1: "kPa", 2: "m", 3: "bar", 4: "ft"
}
QUAL_TYPES = {
0: "NONE", 1: "CHEMICAL", 2: "AGE", 3: "TRACE"
}
class EpanetException(Exception):
pass
class EpanetAPI:
def __init__(self):
library_name = "epanet2.dll" if os.name == "nt" else "libepanet2.so"
dll_path = os.environ.get("EPANET_LIBRARY_PATH") or os.path.join(os.path.dirname(os.path.dirname(__file__)), "64bit", library_name)
if not os.path.exists(dll_path):
raise RuntimeError(f"DLL not found: {dll_path}")
self.lib = ctypes.CDLL(dll_path)
self.EN_Project = ctypes.c_void_p
self._setup()
def _setup(self):
def _errcheck(result, func, args):
if result > 10:
err_msg = ctypes.create_string_buffer(256)
self.lib.EN_geterror(result, err_msg, 256)
raise EpanetException(f"EPANET Error {result}: {err_msg.value.decode('utf-8').strip()}")
return result
self.lib.EN_geterror.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int]
self.lib.EN_geterror.restype = ctypes.c_int
self.lib.EN_getversion.argtypes = [ctypes.POINTER(ctypes.c_int)]
self.lib.EN_getversion.restype = ctypes.c_int
self.lib.EN_getversion.errcheck = _errcheck
self.lib.EN_createproject.argtypes = [ctypes.POINTER(self.EN_Project)]
self.lib.EN_createproject.restype = ctypes.c_int
self.lib.EN_createproject.errcheck = _errcheck
self.lib.EN_deleteproject.argtypes = [self.EN_Project]
self.lib.EN_deleteproject.restype = ctypes.c_int
self.lib.EN_deleteproject.errcheck = _errcheck
self.lib.EN_open.argtypes = [self.EN_Project, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p]
self.lib.EN_open.restype = ctypes.c_int
self.lib.EN_open.errcheck = _errcheck
self.lib.EN_close.argtypes = [self.EN_Project]
self.lib.EN_close.restype = ctypes.c_int
self.lib.EN_close.errcheck = _errcheck
self.lib.EN_getcount.argtypes = [self.EN_Project, ctypes.c_int, ctypes.POINTER(ctypes.c_int)]
self.lib.EN_getcount.restype = ctypes.c_int
self.lib.EN_getcount.errcheck = _errcheck
self.lib.EN_getnodeid.argtypes = [self.EN_Project, ctypes.c_int, ctypes.c_char_p]
self.lib.EN_getnodeid.restype = ctypes.c_int
self.lib.EN_getnodeid.errcheck = _errcheck
self.lib.EN_getnodetype.argtypes = [self.EN_Project, ctypes.c_int, ctypes.POINTER(ctypes.c_int)]
self.lib.EN_getnodetype.restype = ctypes.c_int
self.lib.EN_getnodetype.errcheck = _errcheck
self.lib.EN_getnodevalue.argtypes = [self.EN_Project, ctypes.c_int, ctypes.c_int, ctypes.POINTER(ctypes.c_double)]
self.lib.EN_getnodevalue.restype = ctypes.c_int
self.lib.EN_getnodevalue.errcheck = _errcheck
self.lib.EN_getcoord.argtypes = [self.EN_Project, ctypes.c_int, ctypes.POINTER(ctypes.c_double), ctypes.POINTER(ctypes.c_double)]
self.lib.EN_getcoord.restype = ctypes.c_int
self.lib.EN_getcoord.errcheck = _errcheck
self.lib.EN_getlinkid.argtypes = [self.EN_Project, ctypes.c_int, ctypes.c_char_p]
self.lib.EN_getlinkid.restype = ctypes.c_int
self.lib.EN_getlinkid.errcheck = _errcheck
self.lib.EN_getlinktype.argtypes = [self.EN_Project, ctypes.c_int, ctypes.POINTER(ctypes.c_int)]
self.lib.EN_getlinktype.restype = ctypes.c_int
self.lib.EN_getlinktype.errcheck = _errcheck
self.lib.EN_getlinknodes.argtypes = [self.EN_Project, ctypes.c_int, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int)]
self.lib.EN_getlinknodes.restype = ctypes.c_int
self.lib.EN_getlinknodes.errcheck = _errcheck
self.lib.EN_getlinkvalue.argtypes = [self.EN_Project, ctypes.c_int, ctypes.c_int, ctypes.POINTER(ctypes.c_double)]
self.lib.EN_getlinkvalue.restype = ctypes.c_int
self.lib.EN_getlinkvalue.errcheck = _errcheck
self.lib.EN_getvertexcount.argtypes = [self.EN_Project, ctypes.c_int, ctypes.POINTER(ctypes.c_int)]
self.lib.EN_getvertexcount.restype = ctypes.c_int
self.lib.EN_getvertexcount.errcheck = _errcheck
self.lib.EN_getvertex.argtypes = [self.EN_Project, ctypes.c_int, ctypes.c_int, ctypes.POINTER(ctypes.c_double), ctypes.POINTER(ctypes.c_double)]
self.lib.EN_getvertex.restype = ctypes.c_int
self.lib.EN_getvertex.errcheck = _errcheck
self.lib.EN_getflowunits.argtypes = [self.EN_Project, ctypes.POINTER(ctypes.c_int)]
self.lib.EN_getflowunits.restype = ctypes.c_int
self.lib.EN_getflowunits.errcheck = _errcheck
self.lib.EN_getoption.argtypes = [self.EN_Project, ctypes.c_int, ctypes.POINTER(ctypes.c_double)]
self.lib.EN_getoption.restype = ctypes.c_int
self.lib.EN_getoption.errcheck = _errcheck
self.lib.EN_gettimeparam.argtypes = [self.EN_Project, ctypes.c_int, ctypes.POINTER(ctypes.c_long)]
self.lib.EN_gettimeparam.restype = ctypes.c_int
self.lib.EN_gettimeparam.errcheck = _errcheck
self.lib.EN_openH.argtypes = [self.EN_Project]
self.lib.EN_openH.restype = ctypes.c_int
self.lib.EN_openH.errcheck = _errcheck
self.lib.EN_initH.argtypes = [self.EN_Project, ctypes.c_int]
self.lib.EN_initH.restype = ctypes.c_int
self.lib.EN_initH.errcheck = _errcheck
self.lib.EN_runH.argtypes = [self.EN_Project, ctypes.POINTER(ctypes.c_long)]
self.lib.EN_runH.restype = ctypes.c_int
self.lib.EN_runH.errcheck = _errcheck
self.lib.EN_nextH.argtypes = [self.EN_Project, ctypes.POINTER(ctypes.c_long)]
self.lib.EN_nextH.restype = ctypes.c_int
self.lib.EN_nextH.errcheck = _errcheck
self.lib.EN_closeH.argtypes = [self.EN_Project]
self.lib.EN_closeH.restype = ctypes.c_int
self.lib.EN_closeH.errcheck = _errcheck
# Water Quality Analysis Bindings
self.lib.EN_openQ.argtypes = [self.EN_Project]
self.lib.EN_openQ.restype = ctypes.c_int
self.lib.EN_openQ.errcheck = _errcheck
self.lib.EN_initQ.argtypes = [self.EN_Project, ctypes.c_int]
self.lib.EN_initQ.restype = ctypes.c_int
self.lib.EN_initQ.errcheck = _errcheck
self.lib.EN_runQ.argtypes = [self.EN_Project, ctypes.POINTER(ctypes.c_long)]
self.lib.EN_runQ.restype = ctypes.c_int
self.lib.EN_runQ.errcheck = _errcheck
self.lib.EN_nextQ.argtypes = [self.EN_Project, ctypes.POINTER(ctypes.c_long)]
self.lib.EN_nextQ.restype = ctypes.c_int
self.lib.EN_nextQ.errcheck = _errcheck
self.lib.EN_closeQ.argtypes = [self.EN_Project]
self.lib.EN_closeQ.restype = ctypes.c_int
self.lib.EN_closeQ.errcheck = _errcheck
self.lib.EN_getqualinfo.argtypes = [
self.EN_Project,
ctypes.POINTER(ctypes.c_int),
ctypes.c_char_p,
ctypes.c_char_p,
ctypes.POINTER(ctypes.c_int),
]
self.lib.EN_getqualinfo.restype = ctypes.c_int
self.lib.EN_getqualinfo.errcheck = _errcheck
# ---- Bindings used by batch analyses (backend/batch.py) ----
P, I, D, L, S = self.EN_Project, ctypes.c_int, ctypes.c_double, ctypes.c_long, ctypes.c_char_p
def bind(name, argtypes):
fn = getattr(self.lib, name)
fn.argtypes = argtypes
fn.restype = ctypes.c_int
fn.errcheck = _errcheck
bind("EN_getnodeindex", [P, S, ctypes.POINTER(I)])
bind("EN_getlinkindex", [P, S, ctypes.POINTER(I)])
bind("EN_setnodevalue", [P, I, I, D])
bind("EN_setlinkvalue", [P, I, I, D])
bind("EN_settimeparam", [P, I, L])
bind("EN_addpattern", [P, S])
bind("EN_getpatternindex", [P, S, ctypes.POINTER(I)])
bind("EN_setpattern", [P, I, ctypes.POINTER(D), I])
bind("EN_adddemand", [P, I, D, S, S])
bind("EN_getnumdemands", [P, I, ctypes.POINTER(I)])
bind("EN_deletedemand", [P, I, I])
bind("EN_addcontrol", [P, I, I, D, I, D, ctypes.POINTER(I)])
bind("EN_deletecontrol", [P, I])
bind("EN_solveH", [P])
bind("EN_setqualtype", [P, I, S, S, S])
bind("EN_getpatternlen", [P, I, ctypes.POINTER(I)])
bind("EN_getpatternvalue", [P, I, I, ctypes.POINTER(D)])
bind("EN_getcontrol", [P, I, ctypes.POINTER(I), ctypes.POINTER(I), ctypes.POINTER(D), ctypes.POINTER(I), ctypes.POINTER(D)])
+119
View File
@@ -0,0 +1,119 @@
"""Vector GIS input via GDAL (pyogrio): any supported format -> GeoJSON in WGS84.
Shapefiles must arrive zipped (they are several files); everything else is a
single file. Files are read straight from a temp copy (zip through GDAL's
/vsizip/, never extracted), so archive paths cannot escape the temp dir.
"""
from __future__ import annotations
import os
import tempfile
import numpy as np
import pyogrio
import shapely
from pyogrio import raw as ogr_raw
from pyproj import CRS, Transformer
from shapely.geometry import mapping
from .batch import BatchError
VECTOR_EXTENSIONS = {
".zip": "ESRI Shapefile (zip)",
".geojson": "GeoJSON",
".json": "GeoJSON",
".gpkg": "GeoPackage",
".dxf": "DXF",
".dgn": "MicroStation DGN",
".mif": "MapInfo MIF",
".tab": "MapInfo TAB",
".gml": "GML",
".kml": "KML",
".e00": "ArcInfo E00",
".csv": "CSV (WKT/X,Y)",
}
MAX_FEATURES = 200_000
def _gdal_path(path: str, ext: str) -> str:
if ext == ".zip":
return "/vsizip/" + path.replace("\\", "/")
return path
def _clean(v):
if isinstance(v, (np.floating, float)):
f = float(v)
return None if f != f or f in (float("inf"), float("-inf")) else f
if isinstance(v, np.integer):
return int(v)
if isinstance(v, bytes):
try:
return v.decode("utf-8")
except UnicodeDecodeError:
return v.decode("cp949", errors="replace")
if hasattr(v, "isoformat"):
return v.isoformat()
return v
def read_vector(contents: bytes, filename: str, layer: str | None = None, src_epsg: int | None = None, default_crs: str | None = None) -> dict:
"""`default_crs` applies only when the file itself carries no CRS (DXF, bare CSV ...)."""
ext = os.path.splitext(filename.lower())[1]
if ext not in VECTOR_EXTENSIONS:
raise BatchError(f"지원하지 않는 형식입니다: {ext or filename}. 지원: {', '.join(sorted(VECTOR_EXTENSIONS))}")
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "input" + ext)
with open(path, "wb") as f:
f.write(contents)
src = _gdal_path(path, ext)
try:
layers = [str(row[0]) for row in pyogrio.list_layers(src)]
except Exception as e: # GDAL raises its own error types
raise BatchError(f"파일을 읽을 수 없습니다: {e}") from e
if not layers:
raise BatchError("파일에 레이어가 없습니다.")
name = layer if layer in layers else layers[0]
try:
info = pyogrio.read_info(src, layer=name, encoding=None)
meta, _fids, geometry, field_data = ogr_raw.read(src, layer=name, max_features=MAX_FEATURES + 1)
except Exception as e:
raise BatchError(f"레이어 {name}을(를) 읽을 수 없습니다: {e}") from e
count = len(geometry)
if count > MAX_FEATURES:
raise BatchError(f"도형이 {MAX_FEATURES:,}개를 넘습니다.")
crs_text = meta.get("crs") or info.get("crs")
assumed = False
src_crs = CRS.from_epsg(src_epsg) if src_epsg else (CRS.from_user_input(crs_text) if crs_text else None)
if src_crs is None and default_crs:
try:
src_crs = CRS.from_user_input(default_crs)
assumed = True
except Exception as e:
raise BatchError(f"기본 좌표계를 읽을 수 없습니다: {default_crs[:60]}") from e
src_code = src_crs.to_epsg() if src_crs else None
geoms = shapely.from_wkb(np.array(geometry, dtype=object))
if src_crs is not None and not src_crs.equals(CRS.from_epsg(4326)):
tr = Transformer.from_crs(src_crs, CRS.from_epsg(4326), always_xy=True)
geoms = shapely.transform(geoms, lambda xy: np.column_stack(tr.transform(xy[:, 0], xy[:, 1])))
fields = list(meta.get("fields", []))
features = []
for i in range(count):
g = geoms[i]
props = {fields[k]: _clean(field_data[k][i]) for k in range(len(fields))}
features.append({"type": "Feature", "geometry": mapping(g) if g is not None else None, "properties": props})
return {
"layers": layers,
"layer": name,
"crs": f"EPSG:{src_code}" if src_code else (src_crs.to_string() if src_crs else None),
"reprojected": src_crs is not None,
"crsAssumed": assumed,
"geometryType": meta.get("geometry_type"),
"count": count,
"fields": fields,
"featureCollection": {"type": "FeatureCollection", "features": features},
}
+233
View File
@@ -0,0 +1,233 @@
"""GIS layer operations: export in any CRS/format, geometry ops, Voronoi, grids.
Layers travel as GeoJSON FeatureCollections in WGS84 (what the map holds).
Metric operations run in a planar CRS given by the caller (the model's CRS),
and results come back in WGS84.
"""
from __future__ import annotations
import io
import os
import tempfile
import zipfile
import numpy as np
import shapely
from pyogrio import raw as ogr_raw
from pyproj import CRS, Transformer
from shapely.geometry import mapping, shape
from .batch import BatchError
EXPORT_FORMATS = {
"shp": ("ESRI Shapefile", ".shp"),
"gpkg": ("GPKG", ".gpkg"),
"dxf": ("DXF", ".dxf"),
"geojson": ("GeoJSON", ".geojson"),
"kml": ("KML", ".kml"),
}
WGS84 = CRS.from_epsg(4326)
MAX_FEATURES = 200_000
MAX_GRID_CELLS = 200_000
def _crs(user: str | None, what: str = "좌표계") -> CRS:
if not user:
raise BatchError(f"{what}가 필요합니다.")
try:
return CRS.from_user_input(user)
except Exception as e:
raise BatchError(f"{what}를 읽을 수 없습니다: {str(user)[:60]}") from e
def _planar(user: str | None) -> CRS:
c = _crs(user, "평면 좌표계")
if c.is_geographic:
raise BatchError("거리 계산에는 평면 좌표계(예: EPSG:5186)가 필요합니다.")
return c
def _project(geoms: np.ndarray, src: CRS, dst: CRS) -> np.ndarray:
if src.equals(dst):
return geoms
tr = Transformer.from_crs(src, dst, always_xy=True)
return shapely.transform(geoms, lambda xy: np.column_stack(tr.transform(xy[:, 0], xy[:, 1])))
def _read_fc(fc: dict) -> tuple[np.ndarray, list[dict]]:
feats = (fc or {}).get("features") or []
if len(feats) > MAX_FEATURES:
raise BatchError(f"도형이 {MAX_FEATURES:,}개를 넘습니다.")
geoms, props = [], []
for f in feats:
g = f.get("geometry")
try:
geoms.append(shape(g) if g else None)
except Exception:
geoms.append(None)
props.append(dict(f.get("properties") or {}))
return np.array(geoms, dtype=object), props
def _to_fc(geoms, props: list[dict]) -> dict:
out = []
for g, p in zip(geoms, props):
if g is None or g.is_empty:
continue
out.append({"type": "Feature", "geometry": mapping(g), "properties": p})
return {"type": "FeatureCollection", "features": out}
# ---------------------------------------------------------------- export
def _field_arrays(props: list[dict], fmt: str):
names: list[str] = []
for p in props:
for k in p:
if k not in names:
names.append(k)
if fmt == "shp":
# dBASE: 10-char unique names
seen, short = set(), []
for n in names:
base = str(n)[:10] or "F"
s, k = base, 1
while s in seen:
k += 1
s = f"{base[:10 - len(str(k))]}{k}"
seen.add(s)
short.append(s)
else:
short = [str(n) for n in names]
arrays = []
for n in names:
vals = [p.get(n) for p in props]
nums = [v for v in vals if v is not None and v != ""]
if nums and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in nums):
arrays.append(np.array([float(v) if v is not None and v != "" else np.nan for v in vals], dtype="float64"))
else:
arrays.append(np.array(["" if v is None else str(v) for v in vals], dtype=object))
return short, arrays
def write_vector(fc: dict, target_crs: str | None, fmt: str, name: str) -> tuple[bytes, str, str]:
"""FeatureCollection (WGS84) -> file bytes in `fmt` and `target_crs` (shapefile zipped)."""
if fmt not in EXPORT_FORMATS:
raise BatchError(f"지원하지 않는 형식입니다: {fmt}")
driver, ext = EXPORT_FORMATS[fmt]
geoms, props = _read_fc(fc)
keep = [i for i, g in enumerate(geoms) if g is not None and not g.is_empty]
if not keep:
raise BatchError("내보낼 도형이 없습니다.")
geoms, props = geoms[keep], [props[i] for i in keep]
dst = _crs(target_crs) if target_crs else WGS84
if fmt == "kml":
dst = WGS84
geoms = _project(geoms, WGS84, dst)
safe = "".join(ch for ch in (name or "layer") if ch.isalnum() or ch in "-_ ").strip() or "layer"
kinds = {g.geom_type.replace("Multi", "") for g in geoms}
if fmt == "shp" and len(kinds) > 1:
raise BatchError("Shapefile은 한 가지 도형(점/선/면)만 담을 수 있습니다. GeoPackage로 내보내세요.")
geometry_type = "Unknown" if len(kinds) > 1 else ("Multi" + kinds.pop() if any(g.geom_type.startswith("Multi") for g in geoms) else geoms[0].geom_type)
# Promote singles to multi when the layer mixes them (pyogrio needs one type).
if geometry_type.startswith("Multi"):
geoms = np.array([g if g.geom_type.startswith("Multi") else getattr(shapely, geometry_type)([g]) for g in geoms], dtype=object)
fields, arrays = _field_arrays(props, fmt) if fmt != "dxf" else ([], [])
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, safe + ext)
kwargs = {"encoding": "UTF-8"} if fmt == "shp" else {}
ogr_raw.write(path, shapely.to_wkb(geoms), arrays, fields, driver=driver, geometry_type=geometry_type,
crs=dst.to_wkt() if fmt != "dxf" else None, **kwargs)
if fmt == "shp":
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
for fn in sorted(os.listdir(tmp)):
z.write(os.path.join(tmp, fn), fn)
return buf.getvalue(), safe + ".zip", "application/zip"
with open(path, "rb") as f:
data = f.read()
media = {"gpkg": "application/geopackage+sqlite3", "dxf": "application/dxf", "geojson": "application/geo+json", "kml": "application/vnd.google-earth.kml+xml"}[fmt]
return data, safe + ext, media
# ---------------------------------------------------------------- operations
def reproject_fc(fc: dict, src: str, dst: str) -> dict:
"""Coordinates that were read as `src` but are really `dst`: fix the layer (WGS84 in/out)."""
a, b = _crs(src, "원래 좌표계"), _crs(dst, "새 좌표계")
geoms, props = _read_fc(fc)
raw = _project(geoms, WGS84, a) # back to the numbers in the file
return _to_fc(_project(raw, b, WGS84), props)
def vector_op(op: str, params: dict) -> dict:
planar = _planar(params.get("crs"))
if op == "voronoi":
pts = np.asarray(params.get("points") or [], dtype="float64").reshape(-1, 2) # in planar CRS
ids = [str(i) for i in params.get("ids") or []]
if len(pts) < 3 or len(ids) != len(pts):
raise BatchError("점이 3개 이상 필요합니다.")
margin = float(params.get("margin") or 0)
hull = shapely.MultiPoint(pts).convex_hull
clip = hull.buffer(margin if margin > 0 else max(1.0, 0.05 * np.sqrt(max(hull.area, 1.0))))
cells = shapely.get_parts(shapely.voronoi_polygons(shapely.MultiPoint(pts), extend_to=clip.envelope))
tree = shapely.STRtree(shapely.points(pts))
out_g, out_p = [], []
for c in cells:
hit = tree.query(c, predicate="contains")
if len(hit) == 0:
continue
g = c.intersection(clip)
if g.is_empty:
continue
k = int(hit[0])
out_g.append(g)
out_p.append({"node": ids[k], "area_m2": round(float(g.area), 2)})
return _to_fc(_project(np.array(out_g, dtype=object), planar, WGS84), out_p)
if op == "grid":
b = params.get("bounds") # planar [minx, miny, maxx, maxy]
cell = float(params.get("cell") or 0)
if not b or cell <= 0:
raise BatchError("범위와 격자 크기가 필요합니다.")
x0, y0, x1, y1 = map(float, b)
nx, ny = int(np.ceil((x1 - x0) / cell)), int(np.ceil((y1 - y0) / cell))
if nx * ny > MAX_GRID_CELLS:
raise BatchError(f"격자가 {nx * ny:,}칸입니다. 격자 크기를 키우세요 (최대 {MAX_GRID_CELLS:,}).")
gs, ps = [], []
for j in range(ny):
for i in range(nx):
gs.append(shapely.box(x0 + i * cell, y0 + j * cell, x0 + (i + 1) * cell, y0 + (j + 1) * cell))
ps.append({"col": i, "row": j, "cell": f"{i}_{j}"})
return _to_fc(_project(np.array(gs, dtype=object), planar, WGS84), ps)
geoms, props = _read_fc(params.get("layer") or {})
if not len(geoms):
raise BatchError("대상 레이어가 비어 있습니다.")
g = _project(geoms, WGS84, planar)
if op == "buffer":
d = float(params.get("distance") or 0)
if d == 0:
raise BatchError("버퍼 거리를 입력하세요.")
res = shapely.buffer(g, d)
return _to_fc(_project(res, planar, WGS84), props)
if op == "dissolve":
field = params.get("field")
groups: dict = {}
for geom, p in zip(g, props):
if geom is None:
continue
groups.setdefault(p.get(field) if field else None, []).append(geom)
out_g = [shapely.union_all(v) for v in groups.values()]
out_p = [{field: k, "count": len(v)} if field else {"count": len(v)} for k, v in groups.items()]
return _to_fc(_project(np.array(out_g, dtype=object), planar, WGS84), out_p)
if op in ("intersection", "difference", "clip"):
other_g, _ = _read_fc(params.get("other") or {})
if not len(other_g):
raise BatchError("두 번째 레이어가 비어 있습니다.")
mask = shapely.union_all(_project(other_g, WGS84, planar))
res = shapely.difference(g, mask) if op == "difference" else shapely.intersection(g, mask)
return _to_fc(_project(res, planar, WGS84), props)
raise BatchError(f"알 수 없는 연산입니다: {op}")
+724
View File
@@ -0,0 +1,724 @@
"""Batch analysis jobs exposed as POST /api/jobs/{name}.
Each job takes the INP bytes and a params dict and returns a JSON-able dict.
Jobs run in the API process pool; raise BatchError for input problems
(reported as HTTP 400). Register new jobs in JOBS at the bottom.
"""
from __future__ import annotations
import numpy as np
import ctypes
from .batch import BatchError, Network, hhmm, parse_time
from .epanet import EN, EpanetException
def job_summary(inp: bytes, params: dict) -> dict:
"""Framework smoke job: min/max pressure per reporting period."""
with Network(inp) as net:
junctions = net.junction_indices()
res = net.simulate(node_props=(EN.PRESSURE,), nodes=junctions)
p = res.nodes[EN.PRESSURE]
return {
"times": res.times,
"labels": [hhmm(t) for t in res.times],
"minPressure": p.min(axis=1).round(4).tolist() if p.size else [],
"maxPressure": p.max(axis=1).round(4).tolist() if p.size else [],
"junctions": len(junctions),
"warnings": [{"time": t, "code": c} for t, c in res.warnings],
}
def _window_rows(times: list[int], start: int, end: int) -> np.ndarray:
"""Reporting periods inside [start, end); falls back to the one nearest start."""
t = np.asarray(times)
rows = np.where((t >= start) & (t < end))[0]
if rows.size == 0:
rows = np.array([int(np.argmin(np.abs(t - start)))])
return rows
def job_fireflow(inp: bytes, params: dict) -> dict:
"""소화전 모의: extra demand at hydrant nodes for a time window.
params: nodes [ids], flow (model flow units, per hydrant), start/end (h or h:mm),
residual (model pressure units, optional: also find the available flow),
threshold (optional: count junctions whose window-min pressure drops below it).
"""
ids = [str(x) for x in params.get("nodes") or []]
if not ids:
raise BatchError("소화전 절점을 하나 이상 지정하세요.")
flow = float(params.get("flow") or 0)
if flow <= 0:
raise BatchError("소화 유량은 0보다 커야 합니다.")
with Network(inp) as net:
start = parse_time(params.get("start"), 0)
end = parse_time(params.get("end"), max(net.duration, net.report_step))
if end <= start:
end = start + net.report_step
hyd = [net.node_index(i) for i in ids]
junctions = net.junction_indices()
base = net.simulate(nodes=junctions)
rows = _window_rows(base.times, start, end)
base_min = base.nodes[EN.PRESSURE][rows].min(axis=0)
net.window_pattern("SPIPE_FIRE", start, end)
cats = [net.add_demand(h, flow, "SPIPE_FIRE", "fire") for h in hyd]
fire = net.simulate(nodes=junctions)
fire_min = fire.nodes[EN.PRESSURE][rows].min(axis=0)
for h, c in zip(hyd, cats):
net.delete_demand(h, c)
col = {j: k for k, j in enumerate(junctions)}
# Compare the two runs period by period: tank/pump controls can shift
# *when* the daily minimum happens, so "min before vs min after" may
# even show a rise. Report each node at its largest drop instead.
bw = base.nodes[EN.PRESSURE][rows]
fw = fire.nodes[EN.PRESSURE][rows]
drop = fw - bw
worst = np.argmin(drop, axis=0)
cols = np.arange(len(junctions))
diff = drop[worst, cols]
order = np.argsort(diff)
threshold = params.get("threshold")
out_nodes = [
{
"id": net.node_ids[junctions[k] - 1],
"basePressure": round(float(bw[worst[k], k]), 3),
"firePressure": round(float(fw[worst[k], k]), 3),
"diff": round(float(diff[k]), 3),
"time": hhmm(base.times[rows[worst[k]]]),
}
for k in order
if abs(diff[k]) > 1e-4
]
residual = params.get("residual")
hydrants = []
for nid, h in zip(ids, hyd):
entry = {"id": nid}
if h in col:
k = col[h]
# Hydrant: window minimum before/after (what the residual criterion is about).
entry.update(basePressure=round(float(base_min[k]), 3), firePressure=round(float(fire_min[k]), 3))
if residual not in (None, "") and h in col:
entry["available"] = _available_fire_flow(net, h, col[h], junctions, rows, start, end, float(residual), flow)
hydrants.append(entry)
return {
"window": [start, end],
"windowLabel": f"{hhmm(start)}~{hhmm(end)}",
"flow": flow,
"hydrants": hydrants,
"nodes": out_nodes,
"belowThreshold": int(np.sum(fire_min < float(threshold))) if threshold not in (None, "") else None,
"belowThresholdBefore": int(np.sum(base_min < float(threshold))) if threshold not in (None, "") else None,
"warnings": [{"time": t, "code": c} for t, c in fire.warnings],
}
def _available_fire_flow(net, h, col, junctions, rows, start, end, residual, hint) -> float:
"""Largest extra flow at node h keeping its window-min pressure >= residual (bisection)."""
def min_p(q: float) -> float:
net.window_pattern("SPIPE_AFF", start, end)
c = net.add_demand(h, q, "SPIPE_AFF", "aff")
try:
r = net.simulate(nodes=[h])
finally:
net.delete_demand(h, c)
return float(r.nodes[EN.PRESSURE][rows, 0].min())
if min_p(0.0) < residual:
return 0.0
lo, hi = 0.0, max(hint, 1e-3)
for _ in range(30): # grow until the residual is violated
if min_p(hi) < residual:
break
lo, hi = hi, hi * 2
else:
return round(hi, 3)
for _ in range(25):
mid = (lo + hi) / 2
if min_p(mid) >= residual:
lo = mid
else:
hi = mid
if hi - lo <= max(1e-3, 1e-3 * hi):
break
return round(lo, 3)
def _window(net: Network, params: dict) -> tuple[int, int]:
start = parse_time(params.get("start"), 0)
end = parse_time(params.get("end"), max(net.duration, net.report_step))
return start, (end if end > start else start + net.report_step)
MAX_CRITICALITY_LINKS = 4000
def job_criticality(inp: bytes, params: dict) -> dict:
"""관로 중요도: close each pipe in turn and measure who loses pressure.
params: links [ids] (default all pipes), threshold (model pressure units),
start/end (window, h). A junction is "affected" when its window-min pressure
falls below the threshold while it was at/above it in the base run.
"""
threshold = float(params.get("threshold") or 0)
with Network(inp) as net:
start, end = _window(net, params)
ids = [str(x) for x in params.get("links") or []]
if ids:
links = [net.link_index(i) for i in ids]
else:
links = [k + 1 for k, t in enumerate(net.link_types) if t in (EN.CVPIPE, EN.PIPE)]
if not links:
raise BatchError("분석할 관로가 없습니다.")
if len(links) > MAX_CRITICALITY_LINKS:
raise BatchError(f"관로가 {len(links)}개입니다. {MAX_CRITICALITY_LINKS}개 이하로 선택해 나눠 실행하세요.")
junctions = net.junction_indices()
base = net.simulate(node_props=(EN.PRESSURE, EN.DEMAND), nodes=junctions)
rows = _window_rows(base.times, start, end)
bmin = base.nodes[EN.PRESSURE][rows].min(axis=0)
bdem = base.nodes[EN.DEMAND][rows].mean(axis=0)
ok_before = bmin >= threshold
out = []
for li in links:
orig = net.get_link(li, EN.INITSTATUS)
net.set_link(li, EN.INITSTATUS, 0)
entry = {"id": net.link_ids[li - 1]}
try:
r = net.simulate(nodes=junctions)
pmin = r.nodes[EN.PRESSURE][_window_rows(r.times, start, end)].min(axis=0)
affected = np.where((pmin < threshold) & ok_before)[0]
drop = bmin - pmin
order = affected[np.argsort(-drop[affected])]
entry.update(
affectedNodes=int(affected.size),
affectedDemand=round(float(np.sum(np.maximum(bdem[affected], 0))), 4),
minPressure=round(float(pmin.min()), 3),
maxDrop=round(float(drop.max()), 3),
nodes=[net.node_ids[junctions[k] - 1] for k in order[:200]],
warning=any(c in (3, 6) for _, c in r.warnings),
disconnected=bool(pmin.min() < -1000),
)
except (EpanetException, BatchError) as e:
entry.update(error=str(e), affectedNodes=0, affectedDemand=0.0, minPressure=None, maxDrop=None, nodes=[])
finally:
net.set_link(li, EN.INITSTATUS, orig)
out.append(entry)
out.sort(key=lambda e: (-e["affectedDemand"], -e["affectedNodes"], -(e["maxDrop"] or 0)))
return {
"window": [start, end],
"windowLabel": f"{hhmm(start)}~{hhmm(end)}",
"threshold": threshold,
"belowBefore": int(np.sum(~ok_before)),
"totalDemand": round(float(np.sum(np.maximum(bdem, 0))), 4),
"links": out,
}
def job_servicearea(inp: bytes, params: dict) -> dict:
"""급수 구역: source tracing (EPANET TRACE) from each reservoir (tanks optional).
Returns each node's average share (%) of water from each source over the
analysis window (default: the last 24 h of the run; a run shorter than 24 h
is extended to 72 h so traced water can reach the far ends).
"""
with Network(inp) as net:
notes = []
if net.duration < 72 * 3600:
# Traced water starts at 0 everywhere; give it time to reach the far ends.
net.set_time_param(EN.DURATION, 72 * 3600)
notes.append(f"해석 기간({net.duration // 3600}시간)을 72시간으로 늘려 추적하고 마지막 24시간을 평균했습니다.")
net.duration = 72 * 3600
# Default: reservoirs only. A traced tank counts water that merely passed
# through it, so tank shares overlap the reservoir shares (sum > 100%).
kinds = (EN.RESERVOIR, EN.TANK) if params.get("includeTanks") else (EN.RESERVOIR,)
sources = [str(x) for x in params.get("sources") or []] or [
nid for nid, t in zip(net.node_ids, net.node_types) if t in kinds
] or [nid for nid, t in zip(net.node_ids, net.node_types) if t == EN.TANK]
if not sources:
raise BatchError("배수지·탱크가 없습니다.")
if len(sources) > 30:
raise BatchError("수원이 30개를 넘습니다. 추적할 수원을 골라 주세요.")
for sid in sources:
net.node_index(sid)
start = parse_time(params.get("start"), max(0, net.duration - 24 * 3600))
end = parse_time(params.get("end"), net.duration + 1)
net.lib.EN_solveH(net.ph)
shares: dict[str, np.ndarray] = {}
for sid in sources:
net.lib.EN_setqualtype(net.ph, EN.QUAL_TRACE, b"", b"", sid.encode("utf-8"))
times, q = net.run_quality()
rows = _window_rows(times, start, end)
shares[sid] = np.clip(q[rows].mean(axis=0), 0, 100)
mat = np.vstack([shares[s] for s in sources]) # [source, node]
dom = np.argmax(mat, axis=0)
nodes = {}
for k, nid in enumerate(net.node_ids):
col = mat[:, k]
if col.max() < 0.5:
nodes[nid] = {"source": None, "share": 0.0, "shares": {}}
continue
nodes[nid] = {
"source": sources[dom[k]],
"share": round(float(col[dom[k]]), 1),
"shares": {s: round(float(v), 1) for s, v in zip(sources, col) if v >= 0.5},
}
summary = []
for sid in sources:
mine = [nid for nid in net.node_ids if nodes[nid]["source"] == sid]
summary.append({"source": sid, "nodes": len(mine), "mixed": sum(1 for nid in mine if nodes[nid]["share"] < 80)})
return {
"sources": sources,
"window": [start, end],
"windowLabel": f"{hhmm(start)}~{hhmm(end)}",
"nodes": nodes,
"summary": summary,
"unreached": sum(1 for v in nodes.values() if v["source"] is None),
"notes": notes,
}
def job_vsp(inp: bytes, params: dict) -> dict:
"""VSP: pump speed giving a target pressure at a control node (bisection).
Each requested hour is solved as a snapshot (DURATION 0, patterns shifted to
that hour, tanks at their initial levels). params: pump, node, target
(model pressure units), hours [list] (default 0..23), minSpeed, maxSpeed.
"""
pump_id, node_id = str(params.get("pump") or ""), str(params.get("node") or "")
target = float(params.get("target") or 0)
lo_s, hi_s = float(params.get("minSpeed") or 0.5), float(params.get("maxSpeed") or 1.2)
if not (0 < lo_s < hi_s):
raise BatchError("속도 범위가 올바르지 않습니다 (0 < 최소 < 최대).")
hours = [float(h) for h in (params.get("hours") or list(range(24)))][:168]
with Network(inp) as net:
pi = net.link_index(pump_id)
if net.link_types[pi - 1] != EN.PUMP:
raise BatchError(f"{pump_id}는 펌프가 아닙니다.")
ni = net.node_index(node_id)
ps0 = net.time_param(EN.PATTERNSTART)
net.set_time_param(EN.DURATION, 0)
def solve(speed: float) -> tuple[float, float, float]:
net.set_link(pi, EN.INITSTATUS, 1)
net.set_link(pi, EN.INITSETTING, speed)
net.lib.EN_solveH(net.ph)
return net.get_node(ni, EN.PRESSURE), net.get_link(pi, EN.FLOW), net.get_link(pi, EN.ENERGY)
out = []
for h in hours:
net.set_time_param(EN.PATTERNSTART, ps0 + int(h * 3600))
try:
p_hi = solve(hi_s)
p_lo = solve(lo_s)
if p_hi[0] < target:
speed, res, status = hi_s, p_hi, "max"
elif p_lo[0] >= target:
speed, res, status = lo_s, p_lo, "min"
else:
a, b = lo_s, hi_s
for _ in range(30):
m = (a + b) / 2
if solve(m)[0] >= target:
b = m
else:
a = m
if b - a < 1e-4:
break
speed, res, status = b, solve(b), "ok"
out.append({"hour": h, "speed": round(speed, 4), "pressure": round(res[0], 3),
"flow": round(res[1], 4), "power": round(res[2], 3), "status": status})
except EpanetException as e:
out.append({"hour": h, "error": str(e)})
return {"pump": pump_id, "node": node_id, "target": target, "rows": out,
"note": "시간별 정상상태 해석입니다 (탱크는 초기 수위, 그 시각의 시간 제어만 반영)."}
# m3/s per model flow unit, by EN_getflowunits code
_M3S = {0: 0.0283168, 1: 6.30902e-5, 2: 0.0438126, 3: 0.0526168, 4: 0.0142764, 5: 1e-3,
6: 1e-3 / 60, 7: 1e3 / 86400, 8: 1 / 3600, 9: 1 / 86400, 10: 1.0}
def job_energy(inp: bytes, params: dict) -> dict:
"""에너지 보고서: per-pump utilisation, efficiency, kWh, kWh/m3, peak kW and cost,
integrated over every hydraulic step (prices from [ENERGY] incl. patterns)."""
with Network(inp) as net:
pumps = [k + 1 for k, t in enumerate(net.link_types) if t == EN.PUMP]
if not pumps:
raise BatchError("펌프가 없습니다.")
fu = ctypes.c_int()
net.lib.EN_getflowunits(net.ph, ctypes.byref(fu))
to_m3s = _M3S.get(fu.value, 1.0)
g_price = net.option(EN.GLOBALPRICE)
g_pat = int(net.option(EN.GLOBALPATTERN))
d_charge = net.option(EN.DEMANDCHARGE)
g_eff = net.option(EN.GLOBALEFFIC)
ps0 = net.time_param(EN.PATTERNSTART)
price = {p: (net.get_link(p, EN.PUMP_ECOST) or g_price) for p in pumps}
pat = {p: (int(net.get_link(p, EN.PUMP_EPAT)) or g_pat) for p in pumps}
acc = {p: {"on": 0.0, "kwh": 0.0, "m3": 0.0, "effw": 0.0, "peak": 0.0, "cost": 0.0} for p in pumps}
peak_total = 0.0
total_s = 0.0
t, tstep = ctypes.c_long(), ctypes.c_long(1)
steady = net.duration == 0
net.lib.EN_openH(net.ph)
try:
net.lib.EN_initH(net.ph, EN.NOSAVE)
while True:
net.lib.EN_runH(net.ph, ctypes.byref(t))
now = t.value
# Values at `now` hold until the next hydraulic time.
vals = [(p, abs(net.get_link(p, EN.FLOW)), max(0.0, net.get_link(p, EN.ENERGY)), net.get_link(p, EN.PUMP_EFFIC)) for p in pumps]
net.lib.EN_nextH(net.ph, ctypes.byref(tstep))
dt = 3600.0 if steady else float(tstep.value)
kw_total = 0.0
for p, q, kw, eff in vals:
a = acc[p]
if q > 1e-9 and kw > 0:
a["on"] += dt
a["effw"] += eff * dt
a["kwh"] += kw * dt / 3600
a["m3"] += q * to_m3s * dt
a["peak"] = max(a["peak"], kw)
a["cost"] += kw * dt / 3600 * price[p] * net.pattern_value(pat[p], now, ps0)
kw_total += kw
peak_total = max(peak_total, kw_total)
total_s += dt
if tstep.value <= 0:
break
finally:
net.lib.EN_closeH(net.ph)
total_s = max(total_s, 1.0)
rows = []
for p in pumps:
a = acc[p]
rows.append({
"id": net.link_ids[p - 1],
"utilization": round(100 * a["on"] / total_s, 2),
"efficiency": round(100 * a["effw"] / a["on"], 2) if a["on"] else None, # DLL gives a fraction
"kwh": round(a["kwh"], 3),
"kwhPerM3": round(a["kwh"] / a["m3"], 5) if a["m3"] > 0 else None,
"volume": round(a["m3"], 3),
"avgKw": round(a["kwh"] * 3600 / a["on"], 3) if a["on"] else 0.0,
"peakKw": round(a["peak"], 3),
"cost": round(a["cost"], 3),
"costPerDay": round(a["cost"] * 86400 / total_s, 3),
})
return {
"pumps": rows,
"hours": round(total_s / 3600, 3),
"globalPrice": g_price,
"globalEfficiency": g_eff,
"demandCharge": d_charge,
"peakKw": round(peak_total, 3),
"demandCost": round(peak_total * d_charge, 3),
"totalCost": round(sum(r["cost"] for r in rows) + peak_total * d_charge, 3),
"steady": steady,
}
PARAM_PROPS = {"pressure": ("node", EN.PRESSURE), "head": ("node", EN.HEAD), "demand": ("node", EN.DEMAND),
"quality": ("node", EN.QUALITY), "flow": ("link", EN.FLOW), "velocity": ("link", EN.VELOCITY)}
def job_calibrate(inp: bytes, params: dict) -> dict:
"""자동 보정: per-group pipe roughness multipliers ('roughness') or wall
coefficients ('wall') fitted to observations by bounded least squares.
params: mode, param, observations [{location, time (s), value (model units)}],
groups {name: [pipe ids]}, bounds [lo, hi], maxEvaluations.
"""
from scipy.optimize import least_squares
mode = params.get("mode") or "roughness"
param = params.get("param") or "pressure"
if param not in PARAM_PROPS:
raise BatchError(f"보정할 수 없는 항목입니다: {param}")
kind, prop = PARAM_PROPS[param]
if mode == "wall" and param != "quality":
raise BatchError("관벽 반응계수 보정에는 수질(잔류염소) 실측값이 필요합니다.")
if mode == "roughness" and param == "quality":
raise BatchError("조도 보정에는 수압·수두·유량 실측값을 쓰세요.")
obs = [o for o in params.get("observations") or [] if o.get("location") is not None and o.get("value") is not None]
if len(obs) < 2:
raise BatchError("실측값이 2개 이상 필요합니다.")
groups = {str(k): [str(i) for i in v] for k, v in (params.get("groups") or {}).items() if v}
if not groups:
raise BatchError("보정할 관로 그룹이 없습니다.")
if len(groups) > 30:
raise BatchError("그룹이 30개를 넘습니다. 묶음을 줄이세요.")
lo, hi = (float(b) for b in (params.get("bounds") or ((0.5, 1.5) if mode == "roughness" else (-5.0, 0.0))))
if not lo < hi:
raise BatchError("범위가 올바르지 않습니다.")
max_eval = int(params.get("maxEvaluations") or 200)
with Network(inp) as net:
locs = sorted({str(o["location"]) for o in obs})
index = {}
for loc in locs:
index[loc] = net.node_index(loc) if kind == "node" else net.link_index(loc)
cols = {loc: k for k, loc in enumerate(locs)}
o_col = np.array([cols[str(o["location"])] for o in obs])
o_t = np.array([float(o["time"]) for o in obs])
o_v = np.array([float(o["value"]) for o in obs])
g_names = list(groups)
g_links = [[net.link_index(i) for i in groups[g]] for g in g_names]
if mode == "wall":
qt = ctypes.c_int(); tr = ctypes.c_int()
net.lib.EN_getqualinfo(net.ph, ctypes.byref(qt), ctypes.create_string_buffer(64), ctypes.create_string_buffer(64), ctypes.byref(tr))
if qt.value != 1:
raise BatchError("모델의 수질 해석이 화학물질(CHEMICAL)이 아닙니다. 모델 설정에서 수질 모드를 바꾸세요.")
base = [np.array([net.get_link(li, EN.KWALL) for li in links]) for links in g_links]
x0 = np.array([float(np.clip(np.mean(b) if len(b) else (lo + hi) / 2, lo, hi)) for b in base])
net.lib.EN_solveH(net.ph) # wall reactions do not change hydraulics
else:
base = [np.array([net.get_link(li, EN.ROUGHNESS) for li in links]) for links in g_links]
x0 = np.clip(np.ones(len(g_names)), lo, hi)
def apply(x):
for links, b, v in zip(g_links, base, x):
for li, bv in zip(links, b):
net.set_link(li, EN.KWALL if mode == "wall" else EN.ROUGHNESS, v if mode == "wall" else bv * v)
def simulate_values() -> np.ndarray:
if mode == "wall":
times, q = net.run_quality(nodes=[index[l] for l in locs])
times = np.asarray(times, dtype=float)
mat = q
else:
r = net.simulate(node_props=(prop,) if kind == "node" else (), link_props=(prop,) if kind == "link" else (),
nodes=[index[l] for l in locs] if kind == "node" else [], links=[index[l] for l in locs] if kind == "link" else [])
times = np.asarray(r.times, dtype=float)
mat = (r.nodes if kind == "node" else r.links)[prop]
if param == "flow":
mat = np.abs(mat)
out = np.empty(len(obs))
for c in range(len(locs)):
sel = o_col == c
out[sel] = np.interp(o_t[sel], times, mat[:, c]) if len(times) > 1 else mat[0, c]
return out
evals = {"n": 0}
def residuals(x):
evals["n"] += 1
apply(x)
try:
return simulate_values() - o_v
except (EpanetException, BatchError):
return np.full(len(obs), 1e3)
r0 = residuals(x0)
fit = least_squares(residuals, x0, bounds=(np.full(len(x0), lo), np.full(len(x0), hi)),
diff_step=0.02 if mode == "roughness" else 0.05, max_nfev=max_eval, xtol=1e-4, ftol=1e-6)
r1 = residuals(fit.x)
apply(x0) # leave the network as it was (not strictly needed)
rmse = lambda r: float(np.sqrt(np.mean(r ** 2)))
out_groups = []
for name, links, b, v in zip(g_names, g_links, base, fit.x):
out_groups.append({
"name": name, "pipes": len(links),
"value": round(float(v), 4),
"meanBefore": round(float(np.mean(b)), 4) if len(b) else None,
"meanAfter": round(float(np.mean(b * v)) if mode == "roughness" else float(v), 4) if len(b) else None,
"atBound": bool(abs(v - lo) < 1e-6 or abs(v - hi) < 1e-6),
})
per_loc = []
for c, loc in enumerate(locs):
sel = o_col == c
per_loc.append({"location": loc, "n": int(sel.sum()), "before": round(rmse(r0[sel]), 4), "after": round(rmse(r1[sel]), 4)})
return {
"mode": mode, "param": param, "groups": out_groups, "locations": per_loc,
"rmseBefore": round(rmse(r0), 4), "rmseAfter": round(rmse(r1), 4),
"evaluations": evals["n"], "converged": bool(fit.success), "message": str(fit.message),
"observations": len(obs),
}
def _pump_controls(net: Network, pump_idx: set) -> list[int]:
"""1-based indices of simple controls acting on the given pumps."""
n = ctypes.c_int()
net.lib.EN_getcount(net.ph, 5, ctypes.byref(n))
out = []
t, li, ni = ctypes.c_int(), ctypes.c_int(), ctypes.c_int()
st, lv = ctypes.c_double(), ctypes.c_double()
for k in range(1, n.value + 1):
net.lib.EN_getcontrol(net.ph, k, ctypes.byref(t), ctypes.byref(li), ctypes.byref(st), ctypes.byref(ni), ctypes.byref(lv))
if li.value in pump_idx:
out.append(k)
return out
def job_pumpschedule(inp: bytes, params: dict) -> dict:
"""펌프 운전 최적화: hourly (pattern-step) on/off schedules that cut energy cost
while keeping junction pressure >= minPressure and tanks back to their start
level at the end. Existing simple controls on those pumps are ignored.
params: pumps [ids] (default all), minPressure (model units), maxEvaluations,
tankTolerance (fraction of tank range allowed below the start level at the end).
"""
min_p = float(params.get("minPressure") or 0)
budget = int(params.get("maxEvaluations") or 400)
tank_tol = float(params.get("tankTolerance") if params.get("tankTolerance") is not None else 0.02)
with Network(inp) as net:
all_pumps = [k + 1 for k, t in enumerate(net.link_types) if t == EN.PUMP]
pumps = [net.link_index(str(p)) for p in params.get("pumps") or []] or all_pumps
if not pumps:
raise BatchError("펌프가 없습니다.")
if net.duration < net.pattern_step:
raise BatchError("연장 기간 해석(EPS) 모델이어야 합니다.")
rules = ctypes.c_int()
net.lib.EN_getcount(net.ph, 6, ctypes.byref(rules))
controls = _pump_controls(net, set(pumps))
slot = net.pattern_step
n_slots = int(np.ceil(net.duration / slot))
if n_slots > 168:
raise BatchError("시간 구간이 168개를 넘습니다 (패턴 간격을 늘리세요).")
junctions = net.junction_indices()
tanks = [k + 1 for k, t in enumerate(net.node_types) if t == EN.TANK]
t_min = np.array([net.get_node(t, 20) for t in tanks])
t_max = np.array([net.get_node(t, 21) for t in tanks])
t_init = np.array([net.get_node(t, EN.TANKLEVEL) for t in tanks]) # initial level
t_elev = np.array([net.get_node(t, EN.ELEVATION) for t in tanks])
ps0 = net.time_param(EN.PATTERNSTART)
g_price = net.option(EN.GLOBALPRICE)
g_pat = int(net.option(EN.GLOBALPATTERN))
price = {p: (net.get_link(p, EN.PUMP_ECOST) or g_price or 1.0) for p in pumps}
ppat = {p: (int(net.get_link(p, EN.PUMP_EPAT)) or g_pat) for p in pumps}
priced = bool(g_price or any(net.get_link(p, EN.PUMP_ECOST) for p in pumps))
def evaluate(sched: dict | None) -> dict:
t, tstep = ctypes.c_long(), ctypes.c_long(1)
cost = kwh = 0.0
pmin = np.inf
level_end = t_init.copy()
net.lib.EN_openH(net.ph)
try:
net.lib.EN_initH(net.ph, EN.NOSAVE)
while True:
net.lib.EN_runH(net.ph, ctypes.byref(t))
now = t.value
kw = [max(0.0, net.get_link(p, EN.ENERGY)) for p in pumps]
if net.is_report_time(now) and junctions:
pmin = min(pmin, min(net.get_node(j, EN.PRESSURE) for j in junctions))
if tanks:
# EN_TANKLEVEL reads back the *initial* level; the current one is head - bottom.
level_end = np.array([net.get_node(tk, EN.HEAD) for tk in tanks]) - t_elev
net.lib.EN_nextH(net.ph, ctypes.byref(tstep))
dt = float(tstep.value)
for p, k in zip(pumps, kw):
kwh += k * dt / 3600
cost += k * dt / 3600 * price[p] * net.pattern_value(ppat[p], now, ps0)
if tstep.value <= 0:
break
finally:
net.lib.EN_closeH(net.ph)
short = np.maximum(0.0, (t_init - tank_tol * (t_max - t_min)) - level_end) if tanks else np.zeros(0)
violation = max(0.0, min_p - pmin) + float(np.sum(short))
return {"cost": cost, "kwh": kwh, "minPressure": float(pmin), "violation": violation, "tankEnd": level_end.tolist()}
base = evaluate(None)
# Baseline schedule = what the existing controls did (pump on in each slot?).
status = {p: np.zeros(n_slots) for p in pumps}
r = net.simulate(node_props=(), link_props=(EN.FLOW,), nodes=[], links=pumps)
for c, p in enumerate(pumps):
for k, tt in enumerate(r.times):
s = min(n_slots - 1, int(tt // slot))
if abs(r.links[EN.FLOW][k, c]) > 1e-9:
status[p][s] = 1
# Take control of the pumps: drop their simple controls, drive them by a pattern.
for k in sorted(controls, reverse=True):
net.delete_control(k)
for p in pumps:
pid = f"SPSCH{p}"
net.add_pattern(pid, [1.0] * n_slots)
net.set_link(p, 15, float(_pattern_index(net, pid)))
net.set_link(p, EN.INITSTATUS, 1)
evals = {"n": 0}
def run(sched):
evals["n"] += 1
for p in pumps:
net.add_pattern(f"SPSCH{p}", [float(v) for v in sched[p]])
return evaluate(sched)
cur = {p: status[p].copy() for p in pumps}
cur_res = run(cur)
if cur_res["violation"] > 1e-6:
# Pattern replay of the control schedule is not feasible (e.g. sub-slot switching): start from all on.
cur = {p: np.ones(n_slots) for p in pumps}
cur_res = run(cur)
start_res = cur_res
slot_price = {p: np.array([price[p] * net.pattern_value(ppat[p], k * slot, ps0) for k in range(n_slots)]) for p in pumps}
improved = True
while improved and evals["n"] < budget:
improved = False
# 1) switch off the most expensive running slots
cand = sorted(((slot_price[p][k], p, k) for p in pumps for k in range(n_slots) if cur[p][k] > 0), reverse=True)
for _, p, k in cand:
if evals["n"] >= budget:
break
trial = {q: cur[q].copy() for q in pumps}
trial[p][k] = 0
res = run(trial)
if res["violation"] <= 1e-6 and res["cost"] < cur_res["cost"] - 1e-9:
cur, cur_res, improved = trial, res, True
# 2) move running time from expensive to cheap slots
for p in pumps:
on = [k for k in range(n_slots) if cur[p][k] > 0]
off = [k for k in range(n_slots) if cur[p][k] == 0]
for k_on in sorted(on, key=lambda k: -slot_price[p][k]):
for k_off in sorted(off, key=lambda k: slot_price[p][k]):
if evals["n"] >= budget or slot_price[p][k_off] >= slot_price[p][k_on]:
break
trial = {q: cur[q].copy() for q in pumps}
trial[p][k_on], trial[p][k_off] = 0, 1
res = run(trial)
if res["violation"] <= 1e-6 and res["cost"] < cur_res["cost"] - 1e-9:
cur, cur_res, improved = trial, res, True
off.remove(k_off)
off.append(k_on)
break
return {
"pumps": [net.link_ids[p - 1] for p in pumps],
"slotSeconds": slot,
"schedules": {net.link_ids[p - 1]: [int(v) for v in cur[p]] for p in pumps},
"before": {k: round(v, 3) if isinstance(v, float) else v for k, v in base.items()},
"start": {k: round(v, 3) if isinstance(v, float) else v for k, v in start_res.items()},
"after": {k: round(v, 3) if isinstance(v, float) else v for k, v in cur_res.items()},
"priced": priced,
"evaluations": evals["n"],
"removedControls": len(controls),
"rules": rules.value,
"feasible": cur_res["violation"] <= 1e-6,
}
def _pattern_index(net: Network, pid: str) -> int:
idx = ctypes.c_int()
net.lib.EN_getpatternindex(net.ph, pid.encode(), ctypes.byref(idx))
return idx.value
JOBS = {
"summary": job_summary,
"fireflow": job_fireflow,
"criticality": job_criticality,
"servicearea": job_servicearea,
"vsp": job_vsp,
"energy": job_energy,
"calibrate": job_calibrate,
"pumpschedule": job_pumpschedule,
}
def get_job(name: str):
fn = JOBS.get(name)
if fn is None:
raise BatchError(f"알 수 없는 분석입니다: {name}")
return fn
+743
View File
@@ -0,0 +1,743 @@
import asyncio
import ctypes
import json
import logging
import os
import re
import tempfile
from concurrent.futures import ProcessPoolExecutor
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response
from .batch import BatchError
from .columnar import to_columnar
from .gisio import read_vector
from . import dem as dem_store
from . import gisops
from urllib.parse import quote
from .jobs import get_job
from .epanet import EN, EpanetAPI, EpanetException, FLOW_UNITS, PRESS_UNITS, QUAL_TYPES
logger = logging.getLogger("spipe")
# Limits (overridable per deployment). The upload limit must match nginx's
# client_max_body_size; the period/cell caps bound CPU time and response size
# for inputs like DURATION 10000 hrs + HYDRAULIC TIMESTEP 1 sec.
MAX_UPLOAD_BYTES = int(float(os.environ.get("SPIPE_MAX_UPLOAD_MB", "20")) * 1024 * 1024)
# DEM rasters are much larger than INP/GIS files; streamed to disk, never held in memory.
MAX_DEM_BYTES = int(float(os.environ.get("SPIPE_MAX_DEM_MB", "300")) * 1024 * 1024)
ANALYZE_TIMEOUT_S = float(os.environ.get("SPIPE_ANALYZE_TIMEOUT_S", "120"))
MAX_PERIODS = int(os.environ.get("SPIPE_MAX_PERIODS", "10000"))
MAX_RESULT_CELLS = int(os.environ.get("SPIPE_MAX_RESULT_CELLS", "50000000"))
# Batch jobs (criticality, optimisation ...) solve the network many times.
JOB_TIMEOUT_S = float(os.environ.get("SPIPE_JOB_TIMEOUT_S", "900"))
WORKERS = int(os.environ.get("SPIPE_WORKERS", str(max(1, min(4, (os.cpu_count() or 2) - 1)))))
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_methods=["*"],
allow_headers=["*"],
)
api = EpanetAPI()
# Analyses run in separate processes: the ctypes loop is CPU-bound and
# synchronous, so running it on the event loop froze every other request
# (health checks waited up to 16 s behind one large network).
_pool = None
def _get_pool() -> ProcessPoolExecutor:
global _pool
if _pool is None:
_pool = ProcessPoolExecutor(max_workers=WORKERS)
return _pool
@app.middleware("http")
async def reject_oversized_body(request: Request, call_next):
# Starlette spools the whole multipart body before the handler runs, so
# check the declared size first instead of after receiving it all.
# scope["path"] is the routed path; request.url is rebuilt from the Host
# header, so a malformed Host could make the path check miss (GHSA-86qp).
length = request.headers.get("content-length")
path = request.scope.get("path", "")
if (path == "/api/analyze" or path.startswith(("/api/jobs/", "/api/gis/"))) and length and length.isdigit() and int(length) > MAX_UPLOAD_BYTES + 64 * 1024:
return JSONResponse(status_code=413, content={"detail": f"File size exceeds {MAX_UPLOAD_BYTES // (1024 * 1024)} MiB limit"})
return await call_next(request)
def run_job_bytes(name: str, contents: bytes, params: dict) -> dict:
"""Process-pool entry point for /api/jobs/{name}; same envelope as analyze_bytes."""
try:
result = get_job(name)(contents, params)
return {"json": json.dumps(result, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8")}
except BatchError as e:
return {"error": (400, str(e))}
except EpanetException as e:
return {"error": (422, str(e))}
except ValueError as e:
return {"error": (500, f"Result contains non-finite values: {e}")}
@app.post("/api/jobs/{name}")
async def run_job(name: str, file: UploadFile = File(...), params: str = Form("{}")):
"""Batch analysis over one INP. `params` is a JSON object (see backend/jobs.py)."""
if not (file.filename or "").lower().endswith(".inp"):
raise HTTPException(status_code=400, detail="File must be an .inp file")
try:
parsed = json.loads(params or "{}")
if not isinstance(parsed, dict):
raise ValueError
except ValueError:
raise HTTPException(status_code=400, detail="params must be a JSON object")
contents = await file.read(MAX_UPLOAD_BYTES + 1)
if len(contents) > MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail=f"File size exceeds {MAX_UPLOAD_BYTES // (1024 * 1024)} MiB limit")
loop = asyncio.get_running_loop()
try:
outcome = await asyncio.wait_for(
loop.run_in_executor(_get_pool(), run_job_bytes, name, contents, parsed),
timeout=JOB_TIMEOUT_S,
)
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail=f"Analysis exceeded {JOB_TIMEOUT_S:.0f} s time limit")
if "error" in outcome:
status, detail = outcome["error"]
raise HTTPException(status_code=status, detail=detail)
return Response(content=outcome["json"], media_type="application/json")
def read_vector_bytes(contents: bytes, filename: str, layer: str | None, src_epsg: int | None, default_crs: str | None = None) -> dict:
try:
result = read_vector(contents, filename, layer, src_epsg, default_crs)
return {"json": json.dumps(result, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8")}
except BatchError as e:
return {"error": (400, str(e))}
@app.post("/api/gis/vector")
async def gis_vector(file: UploadFile = File(...), layer: str = Form(""), srcEpsg: str = Form(""), defaultCrs: str = Form("")):
"""Any GDAL vector format (zipped SHP, DXF, DGN, MIF, GML, KML, GPKG, E00 ...) -> GeoJSON (WGS84)."""
src_epsg = None
if srcEpsg.strip():
if not srcEpsg.strip().isdigit():
raise HTTPException(status_code=400, detail="srcEpsg must be an EPSG number")
src_epsg = int(srcEpsg)
contents = await file.read(MAX_UPLOAD_BYTES + 1)
if len(contents) > MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail=f"File size exceeds {MAX_UPLOAD_BYTES // (1024 * 1024)} MiB limit")
loop = asyncio.get_running_loop()
try:
outcome = await asyncio.wait_for(
loop.run_in_executor(_get_pool(), read_vector_bytes, contents, file.filename or "", layer or None, src_epsg, defaultCrs or None),
timeout=ANALYZE_TIMEOUT_S,
)
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="GIS conversion timed out")
if "error" in outcome:
status, detail = outcome["error"]
raise HTTPException(status_code=status, detail=detail)
return Response(content=outcome["json"], media_type="application/json")
def _dem_call(fn, *args):
"""Run a DEM operation; BatchError -> 400 (pickle-safe tuple for the pool)."""
try:
return {"ok": fn(*args)}
except dem_store.BatchError as e:
return {"error": (400, str(e))}
async def _dem_run(fn, *args, timeout: float = 300):
loop = asyncio.get_running_loop()
try:
outcome = await asyncio.wait_for(loop.run_in_executor(None, _dem_call, fn, *args), timeout=timeout)
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="DEM processing timed out")
if "error" in outcome:
status, detail = outcome["error"]
raise HTTPException(status_code=status, detail=detail)
return outcome["ok"]
@app.post("/api/dem")
async def dem_upload(file: UploadFile = File(...)):
"""Store a DEM raster (GeoTIFF / ASC / IMG, or a zip holding one) and return its metadata."""
fd, tmp = tempfile.mkstemp(prefix="spipe_dem_")
size = 0
try:
with os.fdopen(fd, "wb") as f:
while chunk := await file.read(1024 * 1024):
size += len(chunk)
if size > MAX_DEM_BYTES:
raise HTTPException(status_code=413, detail=f"DEM exceeds {MAX_DEM_BYTES // (1024 * 1024)} MiB limit")
f.write(chunk)
return await _dem_run(dem_store.import_dem, tmp, file.filename or "dem.tif")
finally:
try:
os.remove(tmp)
except OSError:
pass
@app.get("/api/dem/{dem_id}")
async def dem_get(dem_id: str):
return await _dem_run(dem_store.dem_info, dem_id)
@app.get("/api/dem/{dem_id}/preview.png")
async def dem_preview(dem_id: str):
png = await _dem_run(dem_store.preview_png, dem_id)
return Response(content=png, media_type="image/png", headers={"Cache-Control": "private, max-age=86400"})
@app.get("/api/dem/{dem_id}/terrain/{z}/{x}/{y}.png")
async def dem_terrain(dem_id: str, z: int, x: int, y: int):
"""Terrarium-encoded terrain tiles for the 3D view."""
png = await _dem_run(dem_store.terrain_tile, dem_id, z, x, y, timeout=60)
return Response(content=png, media_type="image/png", headers={"Cache-Control": "private, max-age=86400"})
@app.post("/api/dem/{dem_id}/sample")
async def dem_sample(dem_id: str, request: Request):
"""Body: {"points": [[x, y], ...], "crs": "<EPSG or proj4 of the points>"}."""
body = await request.json()
points = body.get("points") or []
if len(points) > 500_000:
raise HTTPException(status_code=400, detail="too many points")
return {"values": await _dem_run(dem_store.sample, dem_id, points, body.get("crs"))}
@app.post("/api/dem/{dem_id}/hydro")
async def dem_hydro(dem_id: str, request: Request):
"""Body: {"op": "streams", "minAreaKm2": 0.1} or {"op": "watershed", "points": [[x, y]], "crs": "...", "snap": m}."""
body = await request.json()
if body.get("op") == "streams":
return await _dem_run(dem_store.streams, dem_id, float(body.get("minAreaKm2") or 0.1), timeout=600)
if body.get("op") == "watershed":
return await _dem_run(dem_store.watershed, dem_id, body.get("points") or [], body.get("crs"), float(body.get("snap") or 50), timeout=600)
raise HTTPException(status_code=400, detail="op must be streams or watershed")
@app.post("/api/dem/from-points")
async def dem_from_points(request: Request):
"""Body: {"points": [[x, y, z], ...], "crs": "...", "cell": metres, "name": "..."} -> new DEM."""
body = await request.json()
points = body.get("points") or []
if len(points) > 2_000_000:
raise HTTPException(status_code=400, detail="too many points")
return await _dem_run(dem_store.from_points, points, body.get("crs"), float(body.get("cell") or 0), str(body.get("name") or "점 표고 DEM"), body.get("targetCrs"))
@app.post("/api/gis/export")
async def gis_export(request: Request):
"""Body: {"featureCollection": {...WGS84}, "crs": "<target>", "format": "shp|gpkg|dxf|geojson|kml", "name": "..."}."""
body = await request.json()
data, filename, media = await _dem_run(gisops.write_vector, body.get("featureCollection"), body.get("crs"), str(body.get("format") or "geojson"), str(body.get("name") or "layer"))
return Response(content=data, media_type=media, headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"})
@app.post("/api/gis/op")
async def gis_op(request: Request):
"""Body: {"op": "buffer|dissolve|intersection|clip|difference|voronoi|grid", "crs": "<planar>", ...}."""
body = await request.json()
return await _dem_run(gisops.vector_op, str(body.get("op") or ""), body)
@app.post("/api/gis/reproject")
async def gis_reproject(request: Request):
"""Body: {"featureCollection": {...}, "from": "<CRS it was read as>", "to": "<actual CRS>"}."""
body = await request.json()
return await _dem_run(gisops.reproject_fc, body.get("featureCollection"), body.get("from"), body.get("to"))
@app.get("/api/health")
def health():
return {"status": "ok"}
def analyze_bytes(contents: bytes, columnar: bool = False) -> dict:
"""Process-pool entry point. Returns {"json": bytes} or {"error": (status, detail)}
because HTTPException does not survive pickling back to the parent."""
try:
with tempfile.TemporaryDirectory() as tmpdir:
inp_path = os.path.join(tmpdir, "network.inp")
rpt_path = os.path.join(tmpdir, "network.rpt")
out_path = os.path.join(tmpdir, "network.out")
with open(inp_path, "wb") as f:
f.write(contents)
result = run_epanet(inp_path, rpt_path, out_path)
if columnar:
result = to_columnar(result)
# Serialise here, in the worker: encoding a 10 MB result on the event
# loop stalled other requests by ~0.5 s per response.
body = json.dumps(result, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8")
return {"json": body}
except HTTPException as e:
return {"error": (e.status_code, e.detail)}
except ValueError as e: # NaN/Inf in results
return {"error": (500, f"Result contains non-finite values: {e}")}
@app.post("/api/analyze")
async def analyze(file: UploadFile = File(...), format: str = "object"):
"""Full EPS analysis. ``format=columnar`` returns the compact packed time
series (backend/columnar.py); the default keeps the per-element objects."""
if not (file.filename or "").lower().endswith(".inp"):
raise HTTPException(status_code=400, detail="File must be an .inp file")
contents = await file.read(MAX_UPLOAD_BYTES + 1)
if len(contents) > MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail=f"File size exceeds {MAX_UPLOAD_BYTES // (1024 * 1024)} MiB limit")
loop = asyncio.get_running_loop()
try:
outcome = await asyncio.wait_for(
loop.run_in_executor(_get_pool(), analyze_bytes, contents, format == "columnar"),
timeout=ANALYZE_TIMEOUT_S,
)
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail=f"Analysis exceeded {ANALYZE_TIMEOUT_S:.0f} s time limit")
if "error" in outcome:
status, detail = outcome["error"]
raise HTTPException(status_code=status, detail=detail)
return Response(content=outcome["json"], media_type="application/json")
def decode_id(raw_bytes: bytes) -> str:
try:
return raw_bytes.decode('utf-8')
except UnicodeDecodeError:
try:
return raw_bytes.decode('cp949')
except UnicodeDecodeError:
return raw_bytes.decode('latin1', errors='replace')
def read_report_errors(rpt_path: str, limit: int = 10) -> list:
"""Return the 'Error NNN: ...' lines EPANET wrote to the report file."""
if not os.path.exists(rpt_path):
return []
try:
with open(rpt_path, "r", encoding="utf-8", errors="replace") as rf:
lines = [ln.strip() for ln in rf if re.match(r"\s*Error \d+", ln)]
except OSError:
return []
return lines[:limit]
def run_epanet(inp_path: str, rpt_path: str, out_path: str):
ph = api.EN_Project()
# 1. Version check
version = ctypes.c_int()
api.lib.EN_getversion(ctypes.byref(version))
if version.value != 20305:
raise HTTPException(status_code=500, detail=f"Unsupported EPANET version: {version.value}, expected 20305")
ph_created = False
ph_opened = False
hydraulics_opened = False
try:
api.lib.EN_createproject(ctypes.byref(ph))
ph_created = True
# open -- a failed EN_open still leaves the input/report files open, so
# EN_close must run in the finally block either way (EN_runproject does
# the same); otherwise Windows can't delete the temp dir afterwards.
ph_opened = True
api.lib.EN_open(ph, inp_path.encode('utf-8'), rpt_path.encode('utf-8'), out_path.encode('utf-8'))
# get units
flow_unit = ctypes.c_int()
api.lib.EN_getflowunits(ph, ctypes.byref(flow_unit))
f_unit_str = FLOW_UNITS.get(flow_unit.value, "Unknown")
press_unit = ctypes.c_double()
api.lib.EN_getoption(ph, EN.PRESS_UNITS, ctypes.byref(press_unit))
p_unit_str = PRESS_UNITS.get(int(press_unit.value), "Unknown")
# get counts
node_count = ctypes.c_int()
api.lib.EN_getcount(ph, EN.NODECOUNT, ctypes.byref(node_count))
link_count = ctypes.c_int()
api.lib.EN_getcount(ph, EN.LINKCOUNT, ctypes.byref(link_count))
# Reject resource-exhausting inputs before solving (a few-KB INP with
# DURATION 10000 hrs / HYDRAULIC TIMESTEP 1 sec asks for 36M periods).
duration, hstep = ctypes.c_long(), ctypes.c_long()
api.lib.EN_gettimeparam(ph, EN.DURATION, ctypes.byref(duration))
api.lib.EN_gettimeparam(ph, EN.HYDSTEP, ctypes.byref(hstep))
report_step, report_start = ctypes.c_long(), ctypes.c_long()
api.lib.EN_gettimeparam(ph, EN.REPORTSTEP, ctypes.byref(report_step))
api.lib.EN_gettimeparam(ph, EN.REPORTSTART, ctypes.byref(report_start))
planned_periods = duration.value // max(1, hstep.value) + 1
planned_cells = planned_periods * (node_count.value + link_count.value)
if planned_periods > MAX_PERIODS or planned_cells > MAX_RESULT_CELLS:
raise HTTPException(
status_code=413,
detail=(
f"Simulation too large: {planned_periods} hydraulic periods x "
f"{node_count.value + link_count.value} elements "
f"(limits {MAX_PERIODS} periods, {MAX_RESULT_CELLS} result values). "
"Increase HYDRAULIC TIMESTEP or shorten DURATION."
),
)
# extract node metadata
nodes = {}
node_id_map = {}
for i in range(1, node_count.value + 1):
node_id = ctypes.create_string_buffer(256)
api.lib.EN_getnodeid(ph, i, node_id)
nid = decode_id(node_id.value)
node_id_map[i] = nid
node_type = ctypes.c_int()
api.lib.EN_getnodetype(ph, i, ctypes.byref(node_type))
ntype = node_type.value
x, y = ctypes.c_double(), ctypes.c_double()
coords = None
try:
api.lib.EN_getcoord(ph, i, ctypes.byref(x), ctypes.byref(y))
coords = {"x": x.value, "y": y.value}
except EpanetException:
pass
nodes[nid] = {
"id": nid,
"type": ntype,
"coordinates": coords,
}
# extract link metadata
links = {}
link_id_map = {}
for i in range(1, link_count.value + 1):
link_id = ctypes.create_string_buffer(256)
api.lib.EN_getlinkid(ph, i, link_id)
lid = decode_id(link_id.value)
link_id_map[i] = lid
link_type = ctypes.c_int()
api.lib.EN_getlinktype(ph, i, ctypes.byref(link_type))
ltype = link_type.value
n1, n2 = ctypes.c_int(), ctypes.c_int()
api.lib.EN_getlinknodes(ph, i, ctypes.byref(n1), ctypes.byref(n2))
n1_id = ctypes.create_string_buffer(256)
api.lib.EN_getnodeid(ph, n1.value, n1_id)
n2_id = ctypes.create_string_buffer(256)
api.lib.EN_getnodeid(ph, n2.value, n2_id)
v_count = ctypes.c_int()
api.lib.EN_getvertexcount(ph, i, ctypes.byref(v_count))
vertices = []
for j in range(1, v_count.value + 1):
vx, vy = ctypes.c_double(), ctypes.c_double()
api.lib.EN_getvertex(ph, i, j, ctypes.byref(vx), ctypes.byref(vy))
vertices.append({"x": vx.value, "y": vy.value})
links[lid] = {
"id": lid,
"type": ltype,
"fromNode": decode_id(n1_id.value),
"toNode": decode_id(n2_id.value),
"vertices": vertices,
}
# 3. Open hydraulics with EN_SAVE=1 (Save to hyd file so water quality can use it)
api.lib.EN_openH(ph)
hydraulics_opened = True
api.lib.EN_initH(ph, EN.SAVE)
time_steps = []
node_series = {nid: {"demand": [], "head": [], "pressure": [], "quality": []} for nid in nodes}
link_series = {lid: {"flow": [], "velocity": [], "headloss": [], "quality": [], "energy": [], "efficiency": []} for lid in links}
t = ctypes.c_long()
tstep = ctypes.c_long(1)
engine_warnings = []
while True:
# Warning codes 1-6 (e.g. 1 unbalanced, 6 negative pressures) are
# returned here, not raised; they only reach the report file when
# [REPORT] MESSAGES is on, so collect them directly.
code = api.lib.EN_runH(ph, ctypes.byref(t))
if 0 < code <= 10:
engine_warnings.append({"time": t.value, "code": code})
time_steps.append(t.value)
# Control/tank events add steps beyond DURATION/HYDSTEP; keep the cap hard.
if len(time_steps) > MAX_PERIODS * 2:
raise HTTPException(status_code=413, detail=f"Simulation produced more than {MAX_PERIODS * 2} hydraulic periods")
for i in range(1, node_count.value + 1):
nid = node_id_map[i]
d, h, p = ctypes.c_double(), ctypes.c_double(), ctypes.c_double()
api.lib.EN_getnodevalue(ph, i, EN.DEMAND, ctypes.byref(d))
api.lib.EN_getnodevalue(ph, i, EN.HEAD, ctypes.byref(h))
api.lib.EN_getnodevalue(ph, i, EN.PRESSURE, ctypes.byref(p))
node_series[nid]["demand"].append(d.value)
node_series[nid]["head"].append(h.value)
node_series[nid]["pressure"].append(p.value)
for i in range(1, link_count.value + 1):
lid = link_id_map[i]
f, v, hl = ctypes.c_double(), ctypes.c_double(), ctypes.c_double()
api.lib.EN_getlinkvalue(ph, i, EN.FLOW, ctypes.byref(f))
api.lib.EN_getlinkvalue(ph, i, EN.VELOCITY, ctypes.byref(v))
api.lib.EN_getlinkvalue(ph, i, EN.HEADLOSS, ctypes.byref(hl))
link_series[lid]["flow"].append(f.value)
link_series[lid]["velocity"].append(v.value)
link_series[lid]["headloss"].append(hl.value)
if links[lid]["type"] == EN.PUMP:
e_val, ef_val = ctypes.c_double(), ctypes.c_double()
api.lib.EN_getlinkvalue(ph, i, EN.ENERGY, ctypes.byref(e_val))
api.lib.EN_getlinkvalue(ph, i, EN.PUMP_EFFIC, ctypes.byref(ef_val))
link_series[lid]["energy"].append(round(e_val.value, 2))
# EN_PUMP_EFFIC is a decimal ratio (0.75 for 75%), convert to percent
link_series[lid]["efficiency"].append(round(ef_val.value * 100, 1))
api.lib.EN_nextH(ph, ctypes.byref(tstep))
if tstep.value <= 0:
break
# Close hydraulics before running quality
api.lib.EN_closeH(ph)
hydraulics_opened = False
# 4. Check and run Water Quality Simulation (Chlorine/Fluoride decay, Water Age, Chemical Tracer)
qual_type = ctypes.c_int(0)
trace_node = ctypes.c_int(0)
chem_name = ctypes.create_string_buffer(256)
chem_units = ctypes.create_string_buffer(256)
quality_info = None
quality_failed = False
quality_error_message = None
try:
api.lib.EN_getqualinfo(ph, ctypes.byref(qual_type), chem_name, chem_units, ctypes.byref(trace_node))
q_val = qual_type.value
if q_val > 0:
quality_info = {
"qualType": q_val,
"typeName": QUAL_TYPES.get(q_val, "CHEMICAL"),
"chemName": chem_name.value.decode("utf-8", errors="ignore").strip() or ("Age" if q_val == 2 else "Quality"),
"chemUnits": chem_units.value.decode("utf-8", errors="ignore").strip() or ("hours" if q_val == 2 else "mg/L"),
"traceNode": node_id_map.get(trace_node.value, "") if trace_node.value > 0 else "",
"status": "success",
}
except Exception as e:
quality_failed = True
quality_error_message = str(e)
if quality_info and quality_info["qualType"] > 0:
quality_opened = False
try:
api.lib.EN_openQ(ph)
quality_opened = True
api.lib.EN_initQ(ph, 0)
tQ = ctypes.c_long()
tstepQ = ctypes.c_long(1)
while True:
api.lib.EN_runQ(ph, ctypes.byref(tQ))
for i in range(1, node_count.value + 1):
nid = node_id_map[i]
qv = ctypes.c_double()
api.lib.EN_getnodevalue(ph, i, EN.QUALITY, ctypes.byref(qv))
node_series[nid]["quality"].append(round(qv.value, 4))
for i in range(1, link_count.value + 1):
lid = link_id_map[i]
lqv = ctypes.c_double()
api.lib.EN_getlinkvalue(ph, i, EN.LINKQUAL, ctypes.byref(lqv))
link_series[lid]["quality"].append(round(lqv.value, 4))
api.lib.EN_nextQ(ph, ctypes.byref(tstepQ))
if tstepQ.value <= 0:
break
except Exception as e:
quality_failed = True
quality_error_message = str(e)
quality_info["status"] = "failed"
quality_info["errorMessage"] = quality_error_message
finally:
if quality_opened:
try:
api.lib.EN_closeQ(ph)
except Exception:
pass
# Assign initial values and full timeSeries
num_steps = len(time_steps)
has_valid_quality = (
quality_info is not None
and quality_info.get("status") == "success"
and not quality_failed
)
for nid in nodes:
if not has_valid_quality or len(node_series[nid]["quality"]) != num_steps:
node_series[nid]["quality"] = []
nodes[nid]["demand"] = node_series[nid]["demand"][0] if node_series[nid]["demand"] else 0.0
nodes[nid]["head"] = node_series[nid]["head"][0] if node_series[nid]["head"] else 0.0
nodes[nid]["pressure"] = node_series[nid]["pressure"][0] if node_series[nid]["pressure"] else 0.0
nodes[nid]["quality"] = node_series[nid]["quality"][0] if has_valid_quality and node_series[nid]["quality"] else None
nodes[nid]["timeSeries"] = node_series[nid]
for lid in links:
if not has_valid_quality or len(link_series[lid]["quality"]) != num_steps:
link_series[lid]["quality"] = []
links[lid]["flow"] = link_series[lid]["flow"][0] if link_series[lid]["flow"] else 0.0
links[lid]["velocity"] = link_series[lid]["velocity"][0] if link_series[lid]["velocity"] else 0.0
links[lid]["headloss"] = link_series[lid]["headloss"][0] if link_series[lid]["headloss"] else 0.0
links[lid]["quality"] = link_series[lid]["quality"][0] if has_valid_quality and link_series[lid]["quality"] else None
links[lid]["timeSeries"] = link_series[lid]
# 5. Compute Pump Energy Metrics (kWh, peak kW, average efficiency, utilization %)
pump_lids = [lid for lid, link in links.items() if link["type"] == EN.PUMP]
pump_energy_map = {}
total_duration_sec = (time_steps[-1] - time_steps[0]) if len(time_steps) > 1 else 0
for lid in pump_lids:
link = links[lid]
energies = link_series[lid].get("energy", [])
effics = link_series[lid].get("efficiency", [])
active_effics = [ef for ef in effics if ef > 0]
# dt-weighted kWh integration (excluding terminal reading which has no following duration)
pump_kwh = 0.0
pump_active_sec = 0.0
if len(time_steps) > 1:
for i in range(len(time_steps) - 1):
dt_hours = max(0.0, float(time_steps[i + 1] - time_steps[i])) / 3600.0
kw = energies[i] if i < len(energies) else 0.0
pump_kwh += kw * dt_hours
if kw > 0:
pump_active_sec += max(0.0, float(time_steps[i + 1] - time_steps[i]))
elif energies:
pump_kwh = float(energies[0])
if energies[0] > 0:
pump_active_sec = 3600.0
total_kwh = round(pump_kwh, 2)
peak_kw = round(max(energies, default=0.0), 2)
# Time-weighted (hydraulic steps are not uniform: control/tank events
# insert short steps), i.e. kWh over hours actually running.
avg_kw = round(pump_kwh / (pump_active_sec / 3600.0), 2) if pump_active_sec > 0 else 0.0
avg_effic = round(sum(active_effics) / len(active_effics), 1) if active_effics else 0.0
utilization = round((pump_active_sec / total_duration_sec) * 100.0, 1) if total_duration_sec > 0 else (100.0 if energies and energies[0] > 0 else 0.0)
link["energy"] = energies[0] if energies else 0.0
link["efficiency"] = effics[0] if effics else 0.0
link["totalKwh"] = total_kwh
link["peakKw"] = peak_kw
link["avgKw"] = avg_kw
link["utilization"] = utilization
pump_energy_map[lid] = {
"id": lid,
"fromNode": link["fromNode"],
"toNode": link["toNode"],
"totalKwh": total_kwh,
"peakKw": peak_kw,
"avgKw": avg_kw,
"avgEfficiency": avg_effic,
"utilization": utilization,
}
# System peak kW is the maximum over all time steps of the simultaneous sum of pump kWs
system_peak_kw = 0.0
if pump_lids and time_steps:
for i in range(len(time_steps)):
step_sum = sum(link_series[lid]["energy"][i] for lid in pump_lids if i < len(link_series[lid]["energy"]))
if step_sum > system_peak_kw:
system_peak_kw = step_sum
energy_summary = {
"pumps": pump_energy_map,
"totalKwh": round(sum(p["totalKwh"] for p in pump_energy_map.values()), 2),
"peakKw": round(system_peak_kw, 2),
} if pump_energy_map else None
if ph_opened:
try:
api.lib.EN_close(ph)
ph_opened = False
except Exception:
pass
report_text = ""
if os.path.exists(rpt_path):
try:
with open(rpt_path, "r", encoding="utf-8", errors="ignore") as rf:
report_text = rf.read()
except Exception:
pass
return {
"engine": {
"runtime": "native",
"version": "2.3.5"
},
"timeSeconds": time_steps[0] if time_steps else 0,
"timeSteps": time_steps,
"units": {
"flow": f_unit_str,
"pressure": p_unit_str
},
"nodes": nodes,
"links": links,
"reportText": report_text,
"qualityInfo": quality_info,
"energySummary": energy_summary,
"engineWarnings": engine_warnings,
"reportStart": report_start.value,
"reportStep": report_step.value,
}
except EpanetException as e:
# The report file usually names the root cause (e.g. "Error 227: invalid
# head curve") that the API code alone (e.g. 110) hides. Close first so
# the report is flushed and readable.
if hydraulics_opened:
try:
api.lib.EN_closeH(ph)
except Exception:
pass
hydraulics_opened = False
if ph_opened:
try:
api.lib.EN_close(ph)
except Exception:
pass
ph_opened = False
detail = str(e)
causes = read_report_errors(rpt_path)
if causes:
detail += " | " + " / ".join(causes)
raise HTTPException(status_code=500, detail=detail)
finally:
if hydraulics_opened:
try:
api.lib.EN_closeH(ph)
except Exception:
pass
if ph_opened:
try:
api.lib.EN_close(ph)
except Exception:
pass
if ph_created:
try:
api.lib.EN_deleteproject(ph)
except Exception:
pass
+12
View File
@@ -0,0 +1,12 @@
# API server (also used by deploy/Dockerfile). Keep pins in this one file.
fastapi==0.143.1
starlette==1.8.0
python-multipart==0.0.32
uvicorn==0.54.0
numpy==2.5.3
# GIS / raster (GDAL is bundled in these wheels)
pyogrio==0.13.0
rasterio==1.5.2
pyproj==3.8.0
shapely==2.2.0
scipy==1.18.1
+8
View File
@@ -0,0 +1,8 @@
-r requirements-api.txt
# blocksys (상수도 블록시스템 자동 분할 도구)
wntr==1.5.0
networkx==3.6.1
scikit-learn==1.9.0
pandas==3.0.5
pyyaml==6.0.3
View File
Whitespace-only changes.
+155
View File
@@ -0,0 +1,155 @@
"""HTTP-layer tests for backend/main.py (FastAPI TestClient + real EPANET DLL).
Run from the repo root: .venv/Scripts/python -m pytest backend/tests -q
"""
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from backend import main
from backend.main import app
REPO = Path(__file__).resolve().parents[2]
PUBLIC = REPO / "public"
@pytest.fixture(scope="module")
def client():
with TestClient(app, raise_server_exceptions=False) as c:
yield c
def post(client, name: str, data: bytes):
return client.post("/api/analyze", files={"file": (name, data, "text/plain")})
SIMPLE = b"""[JUNCTIONS]
J1 10 5
J2 12 3
[RESERVOIRS]
R1 50
[PIPES]
P1 R1 J1 1000 200 100
P2 J1 J2 1000 150 100
[OPTIONS]
Units LPS
[END]
"""
def test_health(client):
r = client.get("/api/health")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
def test_net1_runs(client):
r = post(client, "Net1.inp", (PUBLIC / "Net1.inp").read_bytes())
assert r.status_code == 200
body = r.json()
assert len(body["nodes"]) == 11 and len(body["links"]) == 13
assert body["units"] == {"flow": "GPM", "pressure": "psi"}
assert body["reportStep"] > 0
assert body["engineWarnings"] == []
def test_wrong_extension_rejected(client):
assert post(client, "a.txt", SIMPLE).status_code == 400
def test_oversized_upload_rejected(client):
r = post(client, "big.inp", b"x" * (main.MAX_UPLOAD_BYTES + 1))
assert r.status_code == 413
def test_early_size_check_not_bypassed_by_bad_host(client):
# request.url is rebuilt from Host; the early 413 must not depend on it.
big = str(main.MAX_UPLOAD_BYTES + 10 * 1024 * 1024)
for host in ("localhost", "bad host:abc", "evil.example/../x"):
r = client.post("/api/analyze", headers={"content-length": big, "host": host}, content=b"")
assert r.status_code == 413, (host, r.status_code)
def test_invalid_inp_returns_json_with_root_cause(client):
# Previously: EN_open failure leaked the .rpt handle, cleanup raised
# PermissionError on Windows, and the client got plain-text 500.
r = post(client, "bad.inp", b"[JUNCTIONS]\n J1 abc\n[END]\n")
assert r.status_code == 500
assert r.headers["content-type"].startswith("application/json")
detail = r.json()["detail"]
assert "Error 200" in detail
assert "Error 202" in detail # root cause pulled from the report file
def test_warning_codes_collected_even_with_messages_no(client):
# J1 sits above the reservoir head -> negative pressure (warning 6).
inp = SIMPLE.replace(b" J1 10 5", b" J1 100 5").replace(
b"[OPTIONS]", b"[REPORT]\n Messages No\n[OPTIONS]"
)
r = post(client, "neg.inp", inp)
assert r.status_code == 200
body = r.json()
assert "WARNING" not in body["reportText"]
assert any(w["code"] == 6 for w in body["engineWarnings"])
def test_step_amplification_rejected_fast(client):
inp = SIMPLE.replace(
b"[OPTIONS]", b"[TIMES]\n Duration 10000:00\n Hydraulic Timestep 0:00:01\n[OPTIONS]"
)
r = post(client, "dos.inp", inp)
assert r.status_code == 413
assert "Simulation too large" in r.json()["detail"]
def test_columnar_format_carries_identical_values(client):
import base64
from array import array
data = (PUBLIC / "Net1.inp").read_bytes() # pump + chlorine: every series kind
obj = client.post("/api/analyze", files={"file": ("Net1.inp", data, "text/plain")}).json()
col = client.post("/api/analyze?format=columnar", files={"file": ("Net1.inp", data, "text/plain")}).json()
assert col["format"] == "spipe-columnar-1"
steps = len(obj["timeSteps"])
for side, keys in (("nodes", ("demand", "head", "pressure", "quality")), ("links", ("flow", "velocity", "headloss", "quality"))):
ids = col[side]["ids"]
assert ids == list(obj[side])
for key in keys:
flat = array("d", base64.b64decode(col[side]["series"][key]))
for i, element_id in enumerate(ids):
assert list(flat[i * steps:(i + 1) * steps]) == obj[side][element_id]["timeSeries"][key]
pump = col["links"]["pumps"]["9"]
assert pump["energySeries"] == obj["links"]["9"]["timeSeries"]["energy"]
assert pump["totalKwh"] == obj["links"]["9"]["totalKwh"]
def test_overlong_utf8_id_is_engine_error(client):
# 12 Korean chars = 36 UTF-8 bytes > EPANET MAXID (31 bytes) -> Error 252.
long_id = "상리배수지유출관로제일번".encode("utf-8")
inp = SIMPLE.replace(b" J2 12 3", b" " + long_id + b" 12 3").replace(b"J1 J2", b"J1 " + long_id)
r = post(client, "longid.inp", inp)
assert r.status_code == 500
assert "252" in r.json()["detail"]
NET1 = (PUBLIC / "Net1.inp").read_bytes()
def post_job(client, name, inp=SIMPLE, params="{}"):
return client.post(f"/api/jobs/{name}", files={"file": ("n.inp", inp, "text/plain")}, data={"params": params})
def test_job_summary_runs(client):
r = post_job(client, "summary", NET1)
assert r.status_code == 200, r.text
body = r.json()
assert body["junctions"] == 9
assert len(body["times"]) == len(body["minPressure"]) == 25
assert all(lo <= hi for lo, hi in zip(body["minPressure"], body["maxPressure"]))
def test_job_unknown_and_bad_params(client):
assert post_job(client, "nope").status_code == 400
assert post_job(client, "summary", params="[1]").status_code == 400
assert post_job(client, "summary", params="not json").status_code == 400
+87
View File
@@ -0,0 +1,87 @@
"""backend/batch.py against the real EPANET DLL."""
from pathlib import Path
import numpy as np
from backend.batch import Network, parse_time
from backend.epanet import EN
NET1 = (Path(__file__).resolve().parents[2] / "public" / "Net1.inp").read_bytes()
def test_open_lookup_and_baseline():
with Network(NET1) as net:
assert net.node_count == 11 and net.link_count == 13
assert net.node_ids[net.node_index("22") - 1] == "22"
res = net.simulate(node_props=(EN.PRESSURE,), link_props=(EN.FLOW,))
assert len(res.times) == 25 and res.times[0] == 0 and res.times[-1] == 24 * 3600
assert res.nodes[EN.PRESSURE].shape == (25, 11)
assert res.links[EN.FLOW].shape == (25, 13)
def test_windowed_extra_demand_only_acts_in_window():
with Network(NET1) as net:
j = net.node_index("22")
base = net.simulate(nodes=[j]).nodes[EN.PRESSURE][:, 0]
net.window_pattern("FIREWIN", 6 * 3600, 9 * 3600)
cat = net.add_demand(j, 500.0, "FIREWIN", "fire")
fire = net.simulate(nodes=[j]).nodes[EN.PRESSURE][:, 0]
hours = np.arange(25)
inside = (hours >= 6) & (hours < 9)
assert np.all(fire[inside] < base[inside] - 1.0)
# Tank levels carry the draw-down a little past the window; before it, nothing changes.
assert np.allclose(fire[hours < 6], base[hours < 6], atol=1e-6)
net.delete_demand(j, cat)
again = net.simulate(nodes=[j]).nodes[EN.PRESSURE][:, 0]
assert np.allclose(again, base, atol=1e-6)
def test_timer_control_closes_pipe_in_window():
with Network(NET1) as net:
p = net.link_index("12")
c1 = net.add_timer_control(p, 0, 3 * 3600)
c2 = net.add_timer_control(p, 1, 5 * 3600)
flow = net.simulate(node_props=(), link_props=(EN.FLOW,), links=[p]).links[EN.FLOW][:, 0]
assert abs(flow[3]) < 1e-6 and abs(flow[4]) < 1e-6
assert abs(flow[2]) > 1e-3 and abs(flow[6]) > 1e-3
net.delete_control(c2)
net.delete_control(c1)
def test_parse_time():
assert parse_time("6:30") == 6 * 3600 + 1800
assert parse_time(2.5) == 9000
assert parse_time(None, 7) == 7
def test_fireflow_job_and_available_flow():
from backend.jobs import job_fireflow
r = job_fireflow(NET1, {"nodes": ["22"], "flow": 500, "start": 6, "end": 9, "residual": 20, "threshold": 50})
assert r["windowLabel"] == "6:00~9:00"
h = r["hydrants"][0]
assert h["firePressure"] < h["basePressure"] - 1
assert r["nodes"][0]["diff"] <= r["nodes"][-1]["diff"] # most affected first
# available flow keeps the hydrant at (just above) the residual pressure
q = h["available"]
assert q > 0
with Network(NET1) as net:
j = net.node_index("22")
net.window_pattern("W", 6 * 3600, 9 * 3600)
c = net.add_demand(j, q, "W", "x")
p = net.simulate(nodes=[j]).nodes[EN.PRESSURE][6:9, 0].min()
assert p >= 20 - 1e-6
net.delete_demand(j, c)
net.add_demand(j, q * 1.02, "W", "x")
assert net.simulate(nodes=[j]).nodes[EN.PRESSURE][6:9, 0].min() < 20 + 0.5
def test_fireflow_rejects_bad_input():
import pytest
from backend.batch import BatchError
from backend.jobs import job_fireflow
with pytest.raises(BatchError):
job_fireflow(NET1, {"nodes": [], "flow": 10})
with pytest.raises(BatchError):
job_fireflow(NET1, {"nodes": ["NOPE"], "flow": 10})
+70
View File
@@ -0,0 +1,70 @@
"""Automatic calibration (roughness / wall coefficient) and pump scheduling jobs, on Net1 with synthetic truth."""
import re
from pathlib import Path
import pytest
from backend.batch import BatchError, Network
from backend.epanet import EN
from backend.jobs import job_calibrate, job_pumpschedule
NET1 = (Path(__file__).resolve().parents[2] / "public" / "Net1.inp").read_text()
PIPES = [l.split()[0] for l in re.search(r"\[PIPES\](.*?)\[", NET1, re.S).group(1).splitlines() if l.strip() and not l.strip().startswith(";")]
def synthetic(factor=None, wall=None, prop=EN.PRESSURE, locs=("22", "31", "12", "23"), groups=None):
with Network(NET1.encode()) as n:
idx = [n.node_index(l) for l in locs]
for pid in PIPES:
li = n.link_index(pid)
f = factor(pid) if callable(factor) else factor
if f:
n.set_link(li, EN.ROUGHNESS, n.get_link(li, EN.ROUGHNESS) * f)
if wall is not None:
n.set_link(li, EN.KWALL, wall)
if prop == EN.QUALITY:
n.lib.EN_solveH(n.ph)
times, mat = n.run_quality(nodes=idx)
else:
r = n.simulate(node_props=(prop,), nodes=idx)
times, mat = r.times, r.nodes[prop]
return [{"location": l, "time": t, "value": float(mat[k, c])} for k, t in enumerate(times) if k % 2 == 0 for c, l in enumerate(locs)]
def test_roughness_recovers_two_group_truth():
big = {"10", "11", "12", "111", "110"}
obs = synthetic(factor=lambda p: 0.75 if p in big else 1.1, locs=("10", "11", "12", "13", "21", "22", "23", "31", "32"))
groups = {"main": sorted(big), "rest": [p for p in PIPES if p not in big]}
r = job_calibrate(NET1.encode(), {"mode": "roughness", "param": "pressure", "observations": obs, "groups": groups, "bounds": [0.5, 1.5]})
got = {g["name"]: g["value"] for g in r["groups"]}
assert got["main"] == pytest.approx(0.75, abs=0.02) and got["rest"] == pytest.approx(1.1, abs=0.05)
assert r["rmseAfter"] < 0.05 * r["rmseBefore"]
def test_wall_coefficient_from_chlorine():
obs = synthetic(wall=-0.4, prop=EN.QUALITY)
r = job_calibrate(NET1.encode(), {"mode": "wall", "param": "quality", "observations": obs, "groups": {"all": PIPES}, "bounds": [-3, 0]})
assert r["groups"][0]["value"] == pytest.approx(-0.4, abs=0.01) and r["groups"][0]["meanBefore"] == -1.0
def test_calibrate_input_errors():
with pytest.raises(BatchError):
job_calibrate(NET1.encode(), {"mode": "wall", "param": "pressure", "observations": synthetic(), "groups": {"a": PIPES}})
with pytest.raises(BatchError):
job_calibrate(NET1.encode(), {"observations": [], "groups": {"a": PIPES}})
with pytest.raises(BatchError):
job_calibrate(NET1.encode(), {"observations": synthetic(), "groups": {}})
def test_pump_schedule_keeps_constraints_and_reports_controls():
priced = NET1.replace("[PATTERNS]", "[PATTERNS]\n PRICE 0.5 0.5 0.5 1.5 1.5 1.5 1.5 1.5 1.5 1.5 1.5 0.5")
priced = re.sub(r"Global Price\s+0\.0", "Global Price 0.1\n Global Pattern PRICE", priced)
r = job_pumpschedule(priced.encode(), {"minPressure": 20})
assert r["pumps"] == ["9"] and r["removedControls"] == 2 and r["slotSeconds"] == 7200
assert len(r["schedules"]["9"]) == 12 and r["feasible"]
assert r["after"]["violation"] == 0 and r["after"]["minPressure"] >= 20
assert r["after"]["tankEnd"][0] >= 120 - 0.02 * 50 - 1e-6
# never worse than the feasible starting schedule
assert r["after"]["cost"] <= r["start"]["cost"] + 1e-9
# Net1's own controls leave the tank ~4.6 ft lower after a day: reported, not hidden.
assert r["before"]["violation"] > 0
+131
View File
@@ -0,0 +1,131 @@
"""DEM store: import (GeoTIFF, zipped ASC), bilinear sampling across CRSs, preview, TIN from points, HTTP."""
import io
import zipfile
import numpy as np
import pytest
import rasterio
from fastapi.testclient import TestClient
from pyproj import CRS, Transformer
from rasterio.transform import from_origin
from backend import dem as dem_store
from backend.batch import BatchError
from backend.main import app
EPSG = "EPSG:5186"
X0, Y0, CELL, W, H = 200_000.0, 550_000.0, 10.0, 60, 40 # top-left corner
def plane(x, y):
return 100 + 0.01 * (x - X0) - 0.02 * (Y0 - y)
@pytest.fixture(autouse=True)
def store(tmp_path, monkeypatch):
monkeypatch.setattr(dem_store, "DEM_DIR", tmp_path / "dem")
def make_tif(path):
xs = X0 + (np.arange(W) + 0.5) * CELL
ys = Y0 - (np.arange(H) + 0.5) * CELL
z = plane(*np.meshgrid(xs, ys)).astype("float32")
z[0, 0] = -9999
with rasterio.open(path, "w", driver="GTiff", width=W, height=H, count=1, dtype="float32", crs=EPSG,
transform=from_origin(X0, Y0, CELL, CELL), nodata=-9999) as dst:
dst.write(z, 1)
def test_import_sample_is_bilinear_and_handles_outside(tmp_path):
src = tmp_path / "up.bin"
make_tif(tmp_path / "a.tif")
src.write_bytes((tmp_path / "a.tif").read_bytes())
info = dem_store.import_dem(str(src), "a.tif")
assert info["width"] == W and info["resolution"] == [CELL, CELL] and len(info["corners"]) == 4
pts = [[X0 + 123.4, Y0 - 77.7], [X0 + 300, Y0 - 200], [X0 - 50, Y0 - 50]]
vals = dem_store.sample(info["id"], pts, EPSG)
assert vals[0] == pytest.approx(plane(*pts[0]), abs=1e-3)
assert vals[1] == pytest.approx(plane(*pts[1]), abs=1e-3)
assert vals[2] is None
# Same point given in WGS84 lands on the same value.
lon, lat = Transformer.from_crs(EPSG, "EPSG:4326", always_xy=True).transform(*pts[0])
assert dem_store.sample(info["id"], [[lon, lat]], "EPSG:4326")[0] == pytest.approx(vals[0], abs=1e-3)
assert dem_store.preview_png(info["id"])[:8] == b"\x89PNG\r\n\x1a\n"
assert dem_store.dem_info(info["id"])["id"] == info["id"]
def test_zipped_asc_with_prj(tmp_path):
xs = X0 + (np.arange(W) + 0.5) * CELL
ys = Y0 - (np.arange(H) + 0.5) * CELL
z = plane(*np.meshgrid(xs, ys))
asc = f"ncols {W}\nnrows {H}\nxllcorner {X0}\nyllcorner {Y0 - H * CELL}\ncellsize {CELL}\nNODATA_value -9999\n"
asc += "\n".join(" ".join(f"{v:.4f}" for v in row) for row in z)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("dem/ground.asc", asc)
zf.writestr("dem/ground.prj", CRS.from_user_input(EPSG).to_wkt("WKT1_ESRI"))
up = tmp_path / "up.zip"
up.write_bytes(buf.getvalue())
info = dem_store.import_dem(str(up), "ground.zip")
p = [X0 + 250, Y0 - 150]
assert dem_store.sample(info["id"], [p], EPSG)[0] == pytest.approx(plane(*p), abs=1e-3)
def test_errors(tmp_path):
with pytest.raises(BatchError):
dem_store.dem_info("0" * 32)
with pytest.raises(BatchError):
dem_store.dem_info("../../etc")
bad = tmp_path / "x.bin"
bad.write_bytes(b"not a raster")
with pytest.raises(BatchError):
dem_store.import_dem(str(bad), "x.tif")
with pytest.raises(BatchError):
dem_store.import_dem(str(bad), "x.docx")
def test_tin_from_points_reproduces_a_plane():
rng = np.random.default_rng(1)
xy = np.column_stack([X0 + rng.uniform(0, 500, 300), Y0 - rng.uniform(0, 300, 300)])
pts = [[x, y, plane(x, y)] for x, y in xy]
pts += [[X0, Y0, plane(X0, Y0)], [X0 + 500, Y0, plane(X0 + 500, Y0)], [X0, Y0 - 300, plane(X0, Y0 - 300)], [X0 + 500, Y0 - 300, plane(X0 + 500, Y0 - 300)]]
info = dem_store.from_points(pts, EPSG, 5.0)
q = [X0 + 211, Y0 - 133]
assert dem_store.sample(info["id"], [q], EPSG)[0] == pytest.approx(plane(*q), abs=0.05)
with pytest.raises(BatchError):
dem_store.from_points([[0, 0, 1], [1, 1, 2], [2, 2, 3]], EPSG, 1.0) # collinear
def test_http_roundtrip(tmp_path):
make_tif(tmp_path / "a.tif")
with TestClient(app) as c:
r = c.post("/api/dem", files={"file": ("a.tif", (tmp_path / "a.tif").read_bytes(), "image/tiff")})
assert r.status_code == 200, r.text
dem_id = r.json()["id"]
assert c.get(f"/api/dem/{dem_id}/preview.png").headers["content-type"] == "image/png"
s = c.post(f"/api/dem/{dem_id}/sample", json={"points": [[X0 + 100, Y0 - 100]], "crs": EPSG})
assert s.json()["values"][0] == pytest.approx(plane(X0 + 100, Y0 - 100), abs=1e-3)
assert c.get("/api/dem/" + "f" * 32).status_code == 400
assert c.post("/api/dem", files={"file": ("a.txt", b"x", "text/plain")}).status_code == 400
def test_terrain_tile_terrarium(tmp_path):
make_tif(tmp_path / "a.tif")
up = tmp_path / "up.bin"
up.write_bytes((tmp_path / "a.tif").read_bytes())
info = dem_store.import_dem(str(up), "a.tif")
import math
lon, lat = Transformer.from_crs(EPSG, "EPSG:4326", always_xy=True).transform(X0 + 300, Y0 - 200)
z = 15
n = 2 ** z
fx = (lon + 180) / 360 * n
fy = (1 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2 * n
xt, yt = int(fx), int(fy)
png = dem_store.terrain_tile(info["id"], z, xt, yt)
with rasterio.MemoryFile(png) as mem, mem.open() as ds:
rgb = ds.read().astype(float)
elev = rgb[0] * 256 + rgb[1] + rgb[2] / 256 - 32768
px, py = int((fx - xt) * 256), int((fy - yt) * 256)
assert elev[py, px] == pytest.approx(plane(X0 + 300, Y0 - 200), abs=0.2)
with pytest.raises(BatchError):
dem_store.terrain_tile(info["id"], 3, 99, 0)
+69
View File
@@ -0,0 +1,69 @@
"""Vector GIS import (/api/gis/vector) with real GDAL files written on the fly."""
import io
import os
import tempfile
import zipfile
import numpy as np
import pytest
import shapely
from fastapi.testclient import TestClient
from pyogrio import raw as ogr_raw
from backend.main import app
@pytest.fixture(scope="module")
def client():
with TestClient(app, raise_server_exceptions=False) as c:
yield c
def _write(path, driver, crs="EPSG:5187"):
geoms = [shapely.LineString([(200000, 300000), (200100, 300050)]), shapely.LineString([(200100, 300050), (200200, 300000)])]
with_fields = driver != "DXF" # DXF has a fixed schema (Layer, EntityHandle ...)
ogr_raw.write(
path,
geometry=np.array(shapely.to_wkb(geoms), dtype=object),
field_data=[np.array([150, 200], dtype=np.int32), np.array(["DCIP", "PE"], dtype=object)] if with_fields else [],
fields=["DIA", "MAT"] if with_fields else [],
geometry_type="LineString",
crs=crs,
driver=driver,
)
def test_zipped_shapefile_reprojected_to_wgs84(client):
with tempfile.TemporaryDirectory() as tmp:
shp = os.path.join(tmp, "pipes.shp")
_write(shp, "ESRI Shapefile")
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
for f in os.listdir(tmp):
z.write(os.path.join(tmp, f), f)
r = client.post("/api/gis/vector", files={"file": ("pipes.zip", buf.getvalue(), "application/zip")})
assert r.status_code == 200, r.text
body = r.json()
assert body["crs"] == "EPSG:5187" and body["count"] == 2
feat = body["featureCollection"]["features"][0]
assert feat["properties"] == {"DIA": 150, "MAT": "DCIP"}
lon, lat = feat["geometry"]["coordinates"][0]
assert 127 < lon < 132 and 33 < lat < 39 # East Belt -> somewhere in Korea
def test_dxf_without_crs_needs_src_epsg(client):
with tempfile.TemporaryDirectory() as tmp:
dxf = os.path.join(tmp, "pipes.dxf")
_write(dxf, "DXF", crs=None)
data = open(dxf, "rb").read()
raw = client.post("/api/gis/vector", files={"file": ("pipes.dxf", data, "application/dxf")}).json()
assert raw["reprojected"] is False and raw["count"] >= 2
fixed = client.post("/api/gis/vector", files={"file": ("pipes.dxf", data, "application/dxf")}, data={"srcEpsg": "5187"}).json()
lon, lat = fixed["featureCollection"]["features"][0]["geometry"]["coordinates"][0]
assert 127 < lon < 132 and 33 < lat < 39
def test_rejects_unknown_extension_and_bad_epsg(client):
assert client.post("/api/gis/vector", files={"file": ("a.exe", b"MZ", "application/octet-stream")}).status_code == 400
assert client.post("/api/gis/vector", files={"file": ("a.dxf", b"0", "x")}, data={"srcEpsg": "abc"}).status_code == 400
assert client.post("/api/gis/vector", files={"file": ("broken.dxf", b"not a dxf", "x")}).status_code == 400
+111
View File
@@ -0,0 +1,111 @@
"""GIS layer export / reprojection / geometry operations (/api/gis/export, /op, /reproject)."""
import io
import os
import tempfile
import zipfile
import numpy as np
import pyogrio
import pytest
import shapely
from fastapi.testclient import TestClient
from pyproj import Transformer
from backend import gisops
from backend.batch import BatchError
from backend.main import app
EPSG = "EPSG:5186"
TO_WGS = Transformer.from_crs(EPSG, "EPSG:4326", always_xy=True)
def wgs(x, y):
return list(TO_WGS.transform(x, y))
def fc(features):
return {"type": "FeatureCollection", "features": features}
SQUARE = fc([{"type": "Feature", "properties": {"name": "블록A", "dia": 150}, "geometry": {"type": "Polygon", "coordinates": [[wgs(200000, 500000), wgs(200100, 500000), wgs(200100, 500100), wgs(200000, 500100), wgs(200000, 500000)]]}}])
LINES = fc([
{"type": "Feature", "properties": {"id": "P1", "dia": 150}, "geometry": {"type": "LineString", "coordinates": [wgs(200000, 500000), wgs(200200, 500000)]}},
{"type": "Feature", "properties": {"id": "P2", "dia": None}, "geometry": {"type": "LineString", "coordinates": [wgs(200200, 500000), wgs(200200, 500200)]}},
])
@pytest.fixture(scope="module")
def client():
with TestClient(app, raise_server_exceptions=False) as c:
yield c
def _area_m2(feature_collection):
from shapely.geometry import shape
g = gisops._project(np.array([shape(f["geometry"]) for f in feature_collection["features"]], dtype=object), gisops.WGS84, gisops.CRS.from_user_input(EPSG))
return float(sum(x.area for x in g))
def test_export_shapefile_in_target_crs_with_korean_fields():
data, name, media = gisops.write_vector(LINES, EPSG, "shp", "관로")
assert name == "관로.zip" and media == "application/zip"
with tempfile.TemporaryDirectory() as tmp:
zipfile.ZipFile(io.BytesIO(data)).extractall(tmp)
shp = [f for f in os.listdir(tmp) if f.endswith(".shp")][0]
df_info = pyogrio.read_info(os.path.join(tmp, shp))
assert "5186" in df_info["crs"] and df_info["features"] == 2
meta, _, geom, fields = pyogrio.raw.read(os.path.join(tmp, shp))
x0 = shapely.from_wkb(geom[0]).coords[0][0]
assert x0 == pytest.approx(200000, abs=0.01)
assert list(meta["fields"]) == ["id", "dia"]
def test_export_gpkg_dxf_and_mixed_shapefile_refused():
gpkg, name, _ = gisops.write_vector(SQUARE, EPSG, "gpkg", "blocks")
assert name == "blocks.gpkg" and gpkg[:15] == b"SQLite format 3"
dxf, _, _ = gisops.write_vector(LINES, EPSG, "dxf", "lines")
assert b"SECTION" in dxf
mixed = fc(SQUARE["features"] + LINES["features"])
with pytest.raises(BatchError):
gisops.write_vector(mixed, EPSG, "shp", "x")
def test_reproject_fixes_a_layer_read_in_the_wrong_crs():
# Numbers really in EPSG:5186 but read as EPSG:5187: reassign -> lands on the 5186 position.
wrong = Transformer.from_crs("EPSG:5187", "EPSG:4326", always_xy=True)
bad = fc([{"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": list(wrong.transform(200000, 500000))}}])
fixed = gisops.reproject_fc(bad, "EPSG:5187", EPSG)
assert fixed["features"][0]["geometry"]["coordinates"] == pytest.approx(wgs(200000, 500000), abs=1e-8)
def test_buffer_clip_difference_dissolve():
buf = gisops.vector_op("buffer", {"crs": EPSG, "layer": LINES, "distance": 10})
assert len(buf["features"]) == 2 and buf["features"][0]["properties"]["id"] == "P1"
# P1 buffered: 200 x 20 rectangle + two half-discs of r=10
from shapely.geometry import shape
a = gisops._project(np.array([shape(buf["features"][0]["geometry"])], dtype=object), gisops.WGS84, gisops.CRS.from_user_input(EPSG))[0].area
assert a == pytest.approx(200 * 20 + np.pi * 100, rel=0.01)
clipped = gisops.vector_op("clip", {"crs": EPSG, "layer": LINES, "other": SQUARE})
assert len(clipped["features"]) == 1 # only P1 crosses the square
diff = gisops.vector_op("difference", {"crs": EPSG, "layer": SQUARE, "other": buf})
assert _area_m2(diff) < 100 * 100
dis = gisops.vector_op("dissolve", {"crs": EPSG, "layer": buf, "field": None})
assert len(dis["features"]) == 1 and dis["features"][0]["properties"]["count"] == 2
def test_voronoi_and_grid():
pts = [[200000, 500000], [200100, 500000], [200000, 500100], [200100, 500100], [200050, 500050]]
v = gisops.vector_op("voronoi", {"crs": EPSG, "points": pts, "ids": ["A", "B", "C", "D", "E"]})
assert sorted(f["properties"]["node"] for f in v["features"]) == ["A", "B", "C", "D", "E"]
g = gisops.vector_op("grid", {"crs": EPSG, "bounds": [200000, 500000, 200100, 500050], "cell": 25})
assert len(g["features"]) == 8
with pytest.raises(BatchError):
gisops.vector_op("grid", {"crs": "EPSG:4326", "bounds": [0, 0, 1, 1], "cell": 1})
def test_http(client):
r = client.post("/api/gis/export", json={"featureCollection": LINES, "crs": EPSG, "format": "geojson", "name": "관로"})
assert r.status_code == 200 and "attachment" in r.headers["content-disposition"]
r = client.post("/api/gis/op", json={"op": "buffer", "crs": EPSG, "layer": LINES, "distance": 5})
assert r.status_code == 200 and len(r.json()["features"]) == 2
assert client.post("/api/gis/op", json={"op": "nope", "crs": EPSG, "layer": LINES}).status_code == 400
+59
View File
@@ -0,0 +1,59 @@
"""DEM hydrology: streams follow a synthetic valley; the outlet's catchment is the whole valley."""
import numpy as np
import pytest
import rasterio
from pyproj import Transformer
from rasterio.transform import from_origin
from shapely.geometry import shape
from backend import dem as dem_store
EPSG = "EPSG:5186"
X0, Y1, CELL, W, H = 200_000.0, 550_000.0, 10.0, 81, 120 # valley axis at column 40
@pytest.fixture(autouse=True)
def store(tmp_path, monkeypatch):
monkeypatch.setattr(dem_store, "DEM_DIR", tmp_path / "dem")
def valley(tmp_path):
xs = X0 + (np.arange(W) + 0.5) * CELL
ys = Y1 - (np.arange(H) + 0.5) * CELL
xx, yy = np.meshgrid(xs, ys)
axis = X0 + 40.5 * CELL
z = 100 + 0.05 * np.abs(xx - axis) + 0.01 * (yy - (Y1 - H * CELL)) # V valley draining south
z[30, 40] -= 3 # a pit on the axis must not stop the flow
p = tmp_path / "v.tif"
with rasterio.open(p, "w", driver="GTiff", width=W, height=H, count=1, dtype="float32", crs=EPSG,
transform=from_origin(X0, Y1, CELL, CELL)) as d:
d.write(z.astype("float32"), 1)
up = tmp_path / "up.bin"
up.write_bytes(p.read_bytes())
return dem_store.import_dem(str(up), "v.tif")
def test_streams_follow_the_axis_through_a_pit(tmp_path):
info = valley(tmp_path)
fc = dem_store.streams(info["id"], 0.05) # 0.05 km2 = 500 cells
assert fc["features"]
to_xy = Transformer.from_crs("EPSG:4326", EPSG, always_xy=True)
axis = X0 + 40.5 * CELL
longest = max(fc["features"], key=lambda f: f["properties"]["length_m"])
xs = [to_xy.transform(*c)[0] for c in longest["geometry"]["coordinates"]]
assert max(abs(x - axis) for x in xs) < CELL * 1.5
assert longest["properties"]["length_m"] > 0.5 * H * CELL
assert longest["properties"]["area_km2"] == pytest.approx(W * H * CELL * CELL / 1e6, rel=0.1)
def test_watershed_of_the_outlet_is_the_whole_valley(tmp_path):
info = valley(tmp_path)
outlet = [X0 + 40.5 * CELL, Y1 - (H - 1) * CELL]
fc = dem_store.watershed(info["id"], [outlet], EPSG, snap_m=30)
(f,) = fc["features"]
assert f["properties"]["area_km2"] == pytest.approx(W * H * CELL * CELL / 1e6, rel=0.05)
assert shape(f["geometry"]).is_valid
# A point halfway up drains only the upper part.
mid = [X0 + 40.5 * CELL, Y1 - (H / 2) * CELL]
half = dem_store.watershed(info["id"], [mid], EPSG, snap_m=30)["features"][0]["properties"]["area_km2"]
assert 0.3 * f["properties"]["area_km2"] < half < 0.7 * f["properties"]["area_km2"]
+60
View File
@@ -0,0 +1,60 @@
"""Criticality, service area (trace), VSP and energy jobs against the real DLL."""
import re
from pathlib import Path
import pytest
from backend.batch import BatchError
from backend.jobs import job_criticality, job_energy, job_servicearea, job_vsp
PUBLIC = Path(__file__).resolve().parents[2] / "public"
NET1 = (PUBLIC / "Net1.inp").read_bytes()
NET3 = (PUBLIC / "Net3.inp").read_bytes()
def test_criticality_ranks_the_supply_main_first():
r = job_criticality(NET1, {"threshold": 40})
assert len(r["links"]) == 12 # pipes only, the pump is skipped
top = r["links"][0]
# Pipe 10 is the only path from the source: closing it cuts off every junction.
assert top["id"] == "10" and top["affectedNodes"] == 8 and top["disconnected"]
loops = [e for e in r["links"] if e["id"] in ("111", "112", "121", "122")]
assert all(e["affectedNodes"] == 0 for e in loops)
def test_criticality_subset_and_limits():
r = job_criticality(NET1, {"threshold": 40, "links": ["111"]})
assert [e["id"] for e in r["links"]] == ["111"]
with pytest.raises(BatchError):
job_criticality(NET1, {"links": ["nope"]})
def test_service_area_shares_sum_to_at_most_100():
r = job_servicearea(NET3, {})
assert r["sources"] == ["River", "Lake"]
assert r["unreached"] == 0 and r["notes"] # 24 h run extended to 72 h
assert max(sum(v["shares"].values()) for v in r["nodes"].values()) <= 100.5
assert r["nodes"]["River"]["source"] == "River" and r["nodes"]["Lake"]["source"] == "Lake"
def test_vsp_hits_the_target_or_reports_the_bound():
r = job_vsp(NET1, {"pump": "9", "node": "31", "target": 125, "hours": [0, 18], "maxSpeed": 1.5})
by_hour = {row["hour"]: row for row in r["rows"]}
assert by_hour[0]["status"] == "max" and by_hour[0]["pressure"] < 125
assert by_hour[18]["status"] == "ok" and abs(by_hour[18]["pressure"] - 125) < 0.05
assert 1.0 < by_hour[18]["speed"] < 1.5
with pytest.raises(BatchError):
job_vsp(NET1, {"pump": "10", "node": "31", "target": 100}) # a pipe
def test_energy_report_matches_epanet_energy_section():
r = job_energy(NET1, {})
(p,) = r["pumps"]
# EPANET's own Net1 energy report: 57.71 % utilisation, 75 % efficiency, ~96 kW average.
assert p["utilization"] == pytest.approx(57.71, abs=0.05)
assert p["efficiency"] == pytest.approx(75.0)
assert p["avgKw"] == pytest.approx(96.25, abs=0.5)
assert p["kwhPerM3"] > 0 and r["totalCost"] == 0.0 # Net1 has no price
priced = re.sub(rb"Global Price\s+0\.0", b"Global Price 0.1", NET1)
assert priced != NET1
assert job_energy(priced, {})["pumps"][0]["cost"] == pytest.approx(p["kwh"] * 0.1, rel=1e-3)
+24
View File
@@ -0,0 +1,24 @@
name: spipe
services:
api:
build:
context: .
dockerfile: deploy/Dockerfile
target: api
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=5)"]
interval: 30s
timeout: 10s
retries: 3
web:
build:
context: .
dockerfile: deploy/Dockerfile
target: web
ports:
- "${SPIPE_PORT:-80}:80"
restart: unless-stopped
depends_on:
api:
condition: service_healthy
+30
View File
@@ -0,0 +1,30 @@
FROM node:24-bookworm-slim AS web-build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json vite.config.ts index.html ./
COPY src ./src
COPY public ./public
RUN npm run build
FROM python:3.12-slim-bookworm AS engine-build
RUN apt-get update && apt-get install -y --no-install-recommends git cmake build-essential ca-certificates && rm -rf /var/lib/apt/lists/*
RUN git clone --depth 1 --branch v2.3.5 https://github.com/OpenWaterAnalytics/EPANET.git /engine
RUN cmake -S /engine -B /engine/build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTS=OFF && cmake --build /engine/build --target epanet2 -j 2
FROM python:3.12-slim-bookworm AS api
WORKDIR /app
COPY backend/requirements-api.txt /tmp/requirements-api.txt
RUN pip install --no-cache-dir -r /tmp/requirements-api.txt
COPY --from=engine-build /engine/build/lib/libepanet2.so /app/64bit/libepanet2.so
COPY backend/__init__.py backend/main.py backend/epanet.py backend/columnar.py backend/batch.py backend/jobs.py backend/gisio.py backend/gisops.py backend/dem.py ./backend/
COPY public/Net1.inp /app/smoke.inp
RUN python -c "from backend.main import run_epanet; import tempfile; from pathlib import Path; t=tempfile.TemporaryDirectory(); p=Path(t.name); r=run_epanet('/app/smoke.inp',str(p/'test.rpt'),str(p/'test.bin')); assert r['nodes'] and r['links']"
RUN useradd --system --uid 10001 spipe && mkdir -p /app/data/dem && chown -R spipe /app/data
ENV SPIPE_DEM_DIR=/app/data/dem
USER spipe
CMD ["python", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
FROM nginx:stable-bookworm AS web
COPY --from=web-build /app/dist /usr/share/nginx/html
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
+26
View File
@@ -0,0 +1,26 @@
# Ubuntu deployment
The repository is private. Authenticate on the server using `gh auth login`
(GitHub.com, HTTPS, web browser), then run:
```sh
gh repo clone tpstn23-afk/SPipe
cd SPipe
COMPOSE_PARALLEL_LIMIT=1 docker compose build
docker compose up -d
curl --fail http://127.0.0.1/api/health
```
Docker Engine and Compose must already be installed. Check free disk space
before building; the target server originally had about 4 GB free.
Set `SPIPE_PORT=8080` when port 80 is already occupied.
The API builds the official EPANET v2.3.5 Linux library and runs a Net1
analysis while building. Source: https://github.com/OpenWaterAnalytics/EPANET/releases/tag/v2.3.5
Only the web/API dependencies are installed; blocksys is a separate CLI.
The web and API share one origin through nginx. Only the web port is published.
The configuration serves HTTP; domain/TLS setup is a separate server step.
Map service allowed domains may need to include the deployed address.
Validation: local Windows regression suite; Docker build and live server
checks still required (Docker is unavailable on the development PC).
+30
View File
@@ -0,0 +1,30 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Keep in sync with SPIPE_MAX_UPLOAD_MB (backend); nginx rejects larger
# bodies before they reach the API.
client_max_body_size 21m;
gzip on;
gzip_types application/json text/plain application/javascript text/css;
gzip_min_length 1024;
# DEM rasters (SPIPE_MAX_DEM_MB, default 300).
location /api/dem {
client_max_body_size 301m;
proxy_request_buffering off;
proxy_pass http://api:8000;
proxy_read_timeout 600s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /api/ {
proxy_pass http://api:8000;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+104
View File
@@ -0,0 +1,104 @@
================================================================
SPipe 패치 내역 - 데모 v2 (2026-10-30)
이전 배포: 데모 v1 (2026-10-15)
================================================================
※ 새 기능은 대부분 오른쪽 [분석] 탭에 있습니다.
※ 표시가 (시험)인 기능은 결과를 기존 방식과 비교해서 써 주세요.
[1] 해석 · 분석
----------------------------------------------------------------
+ 소화전 모의: 지정 시간대에 소화 유량을 더해 다시 해석하고,
잔류압력을 지키며 낼 수 있는 최대 소화 유량까지 계산
+ 관로 중요도: 관로를 하나씩 닫아 보고 영향 받는 절점·수요량 순위
(단절 관로 표시, CSV 저장)
+ 급수 구역: 배수지별 공급 비율 추적 (탱크 포함 선택)
+ 흐름 방향: 하루 중 흐름이 바뀌는 관로, 흐름 없는 관로 찾기
+ VSP: 제어 절점 목표 압력에 맞는 시간별 펌프 회전수 계산, 패턴으로 적용
+ 등치선: 압력·동수두·표고 등치선을 지도 레이어 또는 GeoJSON으로
+ 에너지 비용: 펌프별 가동률·효율·전력량·원단위(kWh/m³)·전력비
+ 관로 통계: 관경·재질·설치 연대·소블록별 개수, 연장, 최대 유속, 경과년수
+ 관망 그룹, 소블록 고립 확인
+ 자동 보정 (시험): 실측 파일로 관로 그룹별 C값 배율 또는
관벽 반응계수를 자동으로 맞춤
+ 펌프 운전 최적화 (시험): 최저압력과 탱크 수위를 지키면서
전력비가 낮은 운전 일정 탐색 (최적해 보장 안 함, 참고용)
[2] 모델 입력 · 편집
----------------------------------------------------------------
+ 속성창 "추가 속성": 다중 수요(범주), 이미터(누수), 초기 수질·수원,
탱크 혼합·반응, 펌프 효율곡선·전력 단가, 초기 상태([STATUS])
+ 수요량 테이블: 전체 절점 수요 한눈에, 엑셀 붙여넣기/CSV 일괄 입력,
총량 배분(수요 비례·균등), 누수량 이미터 배분
+ 모델 설정: 고급 해석 옵션, 에너지·수질 반응 옵션
+ 곡선 편집: 펌프·효율·체적·손실 곡선
+ 범례 편집: 구간·색 직접 지정, 값에 따라 크기 표시
+ 지도 라벨 추가·편집 (INP [LABELS]로 저장)
+ 편집 도구: 이동·회전·축척, 2점 좌표 보정, N등분·간격 분할,
접속 분할, 절점 제거·병합, 관로 점검·자동 정리,
상·하류 추적, 두 절점 최단 경로
+ 지도에서 꺾임점 편집: 관로 선택 후 주황 점 끌기(이동),
흰 점 끌기(추가), 더블클릭(삭제)
+ 관로 길이 자동 계산, 새 절점 표고 = 가장 가까운 절점 표고 (편집 옵션)
+ 자산 속성(관재질·설치년도·매설깊이·소블록 등) 보관
+ 프로젝트 파일(.spipe): 관망·시나리오·레이어·스타일·보정자료를 한 파일로
+ 자동 백업: 수정 중 10분마다 최근 30개 보관
(프로젝트 메뉴 > 백업 기록에서 복원, Ctrl+Z로 되돌리기 가능)
[3] 지형 (DEM)
----------------------------------------------------------------
+ DEM 파일 불러오기 (GeoTIFF·ASC·IMG, zip 가능, 최대 300MB)
→ 국토지리정보원 5m 자료를 받으면 여기에 넣으면 됨
+ 절점 표고 입력: 지반고 또는 관저고(지반고 - 매설깊이)
+ 표고 검증: 모델 표고와 DEM 차이가 큰 절점 목록
+ 인터넷 표고 (90m 근사) 일괄 입력: DEM 파일이 없을 때
+ 점 표고로 DEM 만들기 (TIN)
+ 유역·하천: 하천선 추출, 선택 절점의 상류 유역
[4] GIS 도구
----------------------------------------------------------------
+ 불러오기 형식 추가: DXF, DGN, MIF/TAB, GML, KML, GPKG, E00
(좌표계 없는 파일은 관망 좌표계로 읽음)
+ 레이어 좌표계 다시 지정, 원하는 좌표계로 내보내기
(SHP, GeoPackage, DXF, GeoJSON, KML)
+ 도형 연산: 버퍼, 병합, 잘라내기, 빼기
+ 레이어 만들기: 절점 보로노이, 격자, OSM 건물, 엑셀·CSV 좌표 → 점
+ 관망 반영: 점 레이어로 밸브 삽입, 계량기 사용량 배정,
면 레이어로 소블록 매핑, 자산 속성 엑셀 붙여넣기
[5] 화면
----------------------------------------------------------------
+ 3D 보기: 지도 기울이기, DEM 지형, 절점 압력·동수두 기둥
+ 이미지 겹치기: 항공사진·도면(PNG/JPG + 월드 파일)
+ 결과 그래프: 여러 요소 시계열 비교, 누적 빈도
+ 종단: 평균선, 구간표, 방향 뒤집기
+ 실측 보정 값을 지도에 표시
[6] 버그 수정
----------------------------------------------------------------
* 관로 이름 변경 시 꺾임점이 사라지던 문제
* 관로 분할 시 꺾임점이 한쪽에만 남던 문제
* 관로 방향 뒤집기 후 선이 지그재그로 그려지던 문제
* 삭제한 관로의 꺾임점이 남아 같은 ID의 새 관로에 붙던 문제
* 예제를 열어도 상태줄 파일명이 "Net1.inp"로 남던 문제
* 로컬 좌표계에서 관로 길이가 잘못 재계산되던 문제 (확인 후 실행)
* 업로드 크기 검사 강화, 서버 라이브러리 보안 업데이트
[7] 서버 설치 · 운영 참고
----------------------------------------------------------------
- 업데이트 후 반드시 실행:
.venv\Scripts\python.exe -m pip install -r backend\requirements.txt
(scipy, rasterio 등 새 라이브러리 필요)
- 화면 다시 빌드: start-lan.ps1 -Rebuild
- 올린 DEM은 서버의 data\dem 폴더에 최근 8개만 보관
- 인터넷 표고(90m), OSM 건물은 사용자 PC에서 인터넷 접속이 필요
(사내망에서 막히면 이 두 기능만 안 됨)
- 미반영: 탁도 분석(정의 협의 중), .net·DWG 형식(DWG는 DXF로 변환해 사용)
================================================================
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0c1729" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22%3E%3Crect width=%2232%22 height=%2232%22 rx=%226%22 fill=%22%230f4c81%22/%3E%3Cpath d=%22M6 22h20M10 22V12h12v10%22 stroke=%22white%22 stroke-width=%222.5%22 fill=%22none%22/%3E%3C/svg%3E" />
<title>spipe</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2403
View File
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "spipe",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"test": "node --experimental-strip-types --test tests/regression.test.ts tests/fixes.test.ts",
"test:api": ".\\.venv\\Scripts\\python.exe -m pytest -q",
"typecheck": "tsc --noEmit",
"preview": "vite preview",
"serve:lan": "vite preview",
"api": ".\\.venv\\Scripts\\python.exe -m uvicorn backend.main:app --host 127.0.0.1 --port 8000"
},
"license": "MIT",
"dependencies": {
"@types/proj4": "^2.5.6",
"epanet-js": "^0.9.0",
"lucide-react": "^1.42.0",
"maplibre-gl": "^4.7.1",
"proj4": "^2.22.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/react": "^18.3.24",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.8.3",
"vite": "^6.4.3"
}
}
+33
View File
@@ -0,0 +1,33 @@
# 예제 관망 출처 및 라이선스 (Example Network Sources & License)
이 폴더의 `Net1.inp`, `Net2.inp`, `Net3.inp`는 GISPipe가 자체 제작한 데이터가 아니라,
**Open Water Analytics(OWA)가 관리하는 공식 EPANET 오픈소스 저장소**에 포함된 표준 예제
관망 파일을 그대로 가져온 것이다. 상용 GISPipe 또는 그 외 상용 관망해석 소프트웨어의
샘플 데이터는 어떤 형태로도 사용하지 않았다.
## 출처 (Source)
- 저장소: `github.com/OpenWaterAnalytics/EPANET` (dev 브랜치, `example-networks/` 폴더)
- Net1.inp: EPANET Example Network 1 - 염소 소독(bulk/wall reaction) 예제, 절점 9개, 관로 13개, 탱크 1개.
- Net2.inp: EPANET Example Network 2 - 55시간 불소(fluoride) 추적 예제, 절점 36개, 관로 40개, 탱크 1개.
- Net3.inp: EPANET Example Network 3 - 하천(River)·호수(Lake) 이중 수원 대규모 예제, 절점 92개,
관로 117개, 펌프 2개, 탱크 3개.
## 라이선스 (License)
Open Water Analytics의 EPANET 저장소는 원본 USEPA(미국 환경보호청)의 EPANET 소프트웨어를
계승한 오픈소스 프로젝트로, 저장소 전체가 MIT 라이선스로 배포되며 미국 정부 저작물(공유
저작물, public domain) 성격의 예제 데이터를 포함한다. 이 예제망들은 EPANET 배포판과 사용자
매뉴얼에도 동일하게 포함되어 실무·교육용으로 자유롭게 재배포·활용이 허용된다.
- 프로젝트 라이선스: MIT (`github.com/OpenWaterAnalytics/EPANET/blob/dev/LICENSE`)
- 저작권 관련 불확실성이 있는 데이터, 상용 소프트웨어의 비공개 샘플 데이터는 사용하지 않았다.
## 용도
GISPipe의 Native(OWA-EPANET 2.3.5 C DLL)/WASM(epanet-js) 듀얼 엔진 해석 및 INP
파싱·재생성(round-trip) 신뢰성 회귀 테스트(`tests/regression.test.ts`)와 앱 UI의
"예제 로드" 선택 기능(Net1/Net2/Net3)에 사용된다. Net2/Net3는 각각 확장 EPS 지속시간,
다중 수요 패턴, 펌프 곡선(`[CURVES]`), 규칙 기반이 아닌 시간/수위 조건 제어
(`[CONTROLS]`)를 포함하고 있어 Net1보다 더 폭넓은 EPANET 구문 커버리지를 회귀
테스트에 제공한다.
+174
View File
@@ -0,0 +1,174 @@
[TITLE]
EPANET Example Network 1
A simple example of modeling chlorine decay. Both bulk and
wall reactions are included.
[JUNCTIONS]
;ID Elev Demand Pattern
10 710 0 ;
11 710 150 ;
12 700 150 ;
13 695 100 ;
21 700 150 ;
22 695 200 ;
23 690 150 ;
31 700 100 ;
32 710 100 ;
[RESERVOIRS]
;ID Head Pattern
9 800 ;
[TANKS]
;ID Elevation InitLevel MinLevel MaxLevel Diameter MinVol VolCurve
2 850 120 100 150 50.5 0 ;
[PIPES]
;ID Node1 Node2 Length Diameter Roughness MinorLoss Status
10 10 11 10530 18 100 0 Open ;
11 11 12 5280 14 100 0 Open ;
12 12 13 5280 10 100 0 Open ;
21 21 22 5280 10 100 0 Open ;
22 22 23 5280 12 100 0 Open ;
31 31 32 5280 6 100 0 Open ;
110 2 12 200 18 100 0 Open ;
111 11 21 5280 10 100 0 Open ;
112 12 22 5280 12 100 0 Open ;
113 13 23 5280 8 100 0 Open ;
121 21 31 5280 8 100 0 Open ;
122 22 32 5280 6 100 0 Open ;
[PUMPS]
;ID Node1 Node2 Parameters
9 9 10 HEAD 1 ;
[VALVES]
;ID Node1 Node2 Diameter Type Setting MinorLoss
[TAGS]
[DEMANDS]
;Junction Demand Pattern Category
[STATUS]
;ID Status/Setting
[PATTERNS]
;ID Multipliers
;Demand Pattern
1 1.0 1.2 1.4 1.6 1.4 1.2
1 1.0 0.8 0.6 0.4 0.6 0.8
[CURVES]
;ID X-Value Y-Value
;PUMP: Pump Curve for Pump 9
1 1500 250
[CONTROLS]
LINK 9 OPEN IF NODE 2 BELOW 110
LINK 9 CLOSED IF NODE 2 ABOVE 140
[RULES]
[ENERGY]
Global Efficiency 75
Global Price 0.0
Demand Charge 0.0
[EMITTERS]
;Junction Coefficient
[QUALITY]
;Node InitQual
10 0.5
11 0.5
12 0.5
13 0.5
21 0.5
22 0.5
23 0.5
31 0.5
32 0.5
9 1.0
2 1.0
[SOURCES]
;Node Type Quality Pattern
[REACTIONS]
;Type Pipe/Tank Coefficient
Order Bulk 1
Order Tank 1
Order Wall 1
Global Bulk -.5
Global Wall -1
Limiting Potential 0.0
Roughness Correlation 0.0
[MIXING]
;Tank Model
[TIMES]
Duration 24:00
Hydraulic Timestep 1:00
Quality Timestep 0:05
Pattern Timestep 2:00
Pattern Start 0:00
Report Timestep 1:00
Report Start 0:00
Start ClockTime 12 am
Statistic None
[REPORT]
Status Yes
Summary No
Page 0
[OPTIONS]
Units GPM
Headloss H-W
Specific Gravity 1.0
Viscosity 1.0
Trials 40
Accuracy 0.001
CHECKFREQ 2
MAXCHECK 10
DAMPLIMIT 0
Unbalanced Continue 10
Pattern 1
Demand Multiplier 1.0
Emitter Exponent 0.5
Quality Chlorine mg/L
Diffusivity 1.0
Tolerance 0.01
[COORDINATES]
;Node X-Coord Y-Coord
10 20.00 70.00
11 30.00 70.00
12 50.00 70.00
13 70.00 70.00
21 30.00 40.00
22 50.00 40.00
23 70.00 40.00
31 30.00 10.00
32 50.00 10.00
9 10.00 70.00
2 50.00 90.00
[VERTICES]
;Link X-Coord Y-Coord
[LABELS]
;X-Coord Y-Coord Label & Anchor Node
6.99 73.63 "Source"
13.48 68.13 "Pump"
43.85 91.21 "Tank"
[BACKDROP]
DIMENSIONS 7.00 6.00 73.00 94.00
UNITS None
FILE
OFFSET 0.00 0.00
[END]
+308
View File
@@ -0,0 +1,308 @@
[TITLE]
EPANET Example Network 2
Example of modeling a 55-hour fluoride tracer study.
Measured fluoride data is contained in the file Net2-FL.dat
and should be registered with the project to produce a
Calibration Report (select Calibration Data from the Project
menu).
[JUNCTIONS]
;ID Elev Demand Pattern
1 50 -694.4 2 ;
2 100 8 ;
3 60 14 ;
4 60 8 ;
5 100 8 ;
6 125 5 ;
7 160 4 ;
8 110 9 ;
9 180 14 ;
10 130 5 ;
11 185 34.78 ;
12 210 16 ;
13 210 2 ;
14 200 2 ;
15 190 2 ;
16 150 20 ;
17 180 20 ;
18 100 20 ;
19 150 5 ;
20 170 19 ;
21 150 16 ;
22 200 10 ;
23 230 8 ;
24 190 11 ;
25 230 6 ;
27 130 8 ;
28 110 0 ;
29 110 7 ;
30 130 3 ;
31 190 17 ;
32 110 17 ;
33 180 1.5 ;
34 190 1.5 ;
35 110 0 ;
36 110 1 ;
[RESERVOIRS]
;ID Head Pattern
[TANKS]
;ID Elevation InitLevel MinLevel MaxLevel Diameter MinVol VolCurve
26 235 56.7 50 70 50 0 ;
[PIPES]
;ID Node1 Node2 Length Diameter Roughness MinorLoss Status
1 1 2 2400 12 100 0 Open ;
2 2 5 800 12 100 0 Open ;
3 2 3 1300 8 100 0 Open ;
4 3 4 1200 8 100 0 Open ;
5 4 5 1000 12 100 0 Open ;
6 5 6 1200 12 100 0 Open ;
7 6 7 2700 12 100 0 Open ;
8 7 8 1200 12 140 0 Open ;
9 7 9 400 12 100 0 Open ;
10 8 10 1000 8 140 0 Open ;
11 9 11 700 12 100 0 Open ;
12 11 12 1900 12 100 0 Open ;
13 12 13 600 12 100 0 Open ;
14 13 14 400 12 100 0 Open ;
15 14 15 300 12 100 0 Open ;
16 13 16 1500 8 100 0 Open ;
17 15 17 1500 8 100 0 Open ;
18 16 17 600 8 100 0 Open ;
19 17 18 700 12 100 0 Open ;
20 18 32 350 12 100 0 Open ;
21 16 19 1400 8 100 0 Open ;
22 14 20 1100 12 100 0 Open ;
23 20 21 1300 8 100 0 Open ;
24 21 22 1300 8 100 0 Open ;
25 20 22 1300 8 100 0 Open ;
26 24 23 600 12 100 0 Open ;
27 15 24 250 12 100 0 Open ;
28 23 25 300 12 100 0 Open ;
29 25 26 200 12 100 0 Open ;
30 25 31 600 12 100 0 Open ;
31 31 27 400 8 100 0 Open ;
32 27 29 400 8 100 0 Open ;
34 29 28 700 8 100 0 Open ;
35 22 33 1000 8 100 0 Open ;
36 33 34 400 8 100 0 Open ;
37 32 19 500 8 100 0 Open ;
38 29 35 500 8 100 0 Open ;
39 35 30 1000 8 100 0 Open ;
40 28 35 700 8 100 0 Open ;
41 28 36 300 8 100 0 Open ;
[PUMPS]
;ID Node1 Node2 Parameters
[VALVES]
;ID Node1 Node2 Diameter Type Setting MinorLoss
[TAGS]
[DEMANDS]
;Junction Demand Pattern Category
[STATUS]
;ID Status/Setting
[PATTERNS]
;ID Multipliers
;Demand Pattern
1 1.26 1.04 .97 .97 .89 1.19
1 1.28 .67 .67 1.34 2.46 .97
1 .92 .68 1.43 .61 .31 .78
1 .37 .67 1.26 1.56 1.19 1.26
1 .6 1.1 1.03 .73 .88 1.06
1 .99 1.72 1.12 1.34 1.12 .97
1 1.04 1.15 .91 .61 .68 .46
1 .51 .74 1.12 1.34 1.26 .97
1 .82 1.37 1.03 .81 .88 .81
1 .81
;Pump Station Outflow Pattern
2 .96 .96 .96 .96 .96 .96
2 .62 0 0 0 0 0
2 .8 1 1 1 1 .15
2 0 0 0 0 0 0
2 .55 .92 .92 .92 .92 .9
2 .9 .45 0 0 0 0
2 0 .7 1 1 1 1
2 .2 0 0 0 0 0
2 0 .74 .92 .92 .92 .92
2 .92
;Pump Station Fluoride Pattern
3 .98 1.02 1.05 .99 .64 .46
3 .35 .35 .35 .35 .35 .35
3 .17 .17 .13 .13 .13 .15
3 .15 .15 .15 .15 .15 .15
3 .15 .12 .1 .08 .11 .09
3 .09 .08 .08 .08 .08 .08
3 .08 .09 .07 .07 .09 .09
3 .09 .09 .09 .09 .09 .09
3 .09 .08 .35 .72 .82 .92
3 1
[CURVES]
;ID X-Value Y-Value
[CONTROLS]
[RULES]
[ENERGY]
Global Efficiency 75
Global Price 0.0
Demand Charge 0.0
[EMITTERS]
;Junction Coefficient
[QUALITY]
;Node InitQual
1 1.0
2 1.0
3 1.0
4 1.0
5 1.0
6 1.0
7 1.0
8 1.0
9 1.0
10 1.0
11 1.0
12 1.0
13 1.0
14 1.0
15 1.0
16 1.0
17 1.0
18 1.0
19 1.0
20 1.0
21 1.0
22 1.0
23 1.0
24 1.0
25 1.0
27 1.0
28 1.0
29 1.0
30 1.0
31 1.0
32 1.0
33 1.0
34 1.0
35 1.0
36 1.0
26 1.0
[SOURCES]
;Node Type Quality Pattern
1 CONCEN 1.0 3
[REACTIONS]
;Type Pipe/Tank Coefficient
[REACTIONS]
Order Bulk 1
Order Tank 1
Order Wall 1
Global Bulk 0.0
Global Wall 0.0
Limiting Potential 0.0
Roughness Correlation 0.0
[MIXING]
;Tank Model
[TIMES]
Duration 55:00
Hydraulic Timestep 1:00
Quality Timestep 0:05
Pattern Timestep 1:00
Pattern Start 0:00
Report Timestep 1:00
Report Start 0:00
Start ClockTime 8 am
Statistic None
[REPORT]
Status No
Summary No
Page 0
[OPTIONS]
Units GPM
Headloss H-W
Specific Gravity 1.0
Viscosity 1.0
Trials 40
Accuracy 0.001
CHECKFREQ 2
MAXCHECK 10
DAMPLIMIT 0
Unbalanced Continue 10
Pattern 1
Demand Multiplier 1.0
Emitter Exponent 0.5
Quality Fluoride mg/L
Diffusivity 1.0
Tolerance 0.01
[COORDINATES]
;Node X-Coord Y-Coord
1 21.00 4.00
2 19.00 20.00
3 11.00 21.00
4 14.00 28.00
5 19.00 25.00
6 28.00 23.00
7 36.00 39.00
8 38.00 30.00
9 36.00 42.00
10 37.00 23.00
11 37.00 49.00
12 39.00 60.00
13 38.00 64.00
14 38.00 66.00
15 37.00 69.00
16 27.00 65.00
17 27.00 69.00
18 23.00 68.00
19 21.00 59.00
20 45.00 68.00
21 51.00 62.00
22 54.00 69.00
23 35.00 74.00
24 37.00 71.00
25 35.00 76.00
27 39.00 87.00
28 49.00 85.00
29 42.00 86.00
30 47.00 80.00
31 37.00 80.00
32 23.00 64.00
33 56.00 73.00
34 56.00 77.00
35 43.00 81.00
36 53.00 87.00
26 33.00 76.00
[VERTICES]
;Link X-Coord Y-Coord
[LABELS]
;X-Coord Y-Coord Label & Anchor Node
24.00 7.00 "Pump"
24.00 4.00 "Station"
26.76 77.42 "Tank"
[BACKDROP]
DIMENSIONS 8.75 -0.15 58.25 91.15
UNITS None
FILE
OFFSET 0.00 0.00
[END]
+479
View File
@@ -0,0 +1,479 @@
[TITLE]
EPANET Example Network 3
Example showing how the percent of Lake water in a dual-source
system changes over time.
[JUNCTIONS]
;ID Elev Demand Pattern
10 147 0 ;
15 32 1 3 ;
20 129 0 ;
35 12.5 1 4 ;
40 131.9 0 ;
50 116.5 0 ;
60 0 0 ;
601 0 0 ;
61 0 0 ;
101 42 189.95 ;
103 43 133.2 ;
105 28.5 135.37 ;
107 22 54.64 ;
109 20.3 231.4 ;
111 10 141.94 ;
113 2 20.01 ;
115 14 52.1 ;
117 13.6 117.71 ;
119 2 176.13 ;
120 0 0 ;
121 -2 41.63 ;
123 11 1 2 ;
125 11 45.6 ;
127 56 17.66 ;
129 51 0 ;
131 6 42.75 ;
139 31 5.89 ;
141 4 9.85 ;
143 -4.5 6.2 ;
145 1 27.63 ;
147 18.5 8.55 ;
149 16 27.07 ;
151 33.5 144.48 ;
153 66.2 44.17 ;
157 13.1 51.79 ;
159 6 41.32 ;
161 4 15.8 ;
163 5 9.42 ;
164 5 0 ;
166 -2 2.6 ;
167 -5 14.56 ;
169 -5 0 ;
171 -4 39.34 ;
173 -4 0 ;
177 8 58.17 ;
179 8 0 ;
181 8 0 ;
183 11 0 ;
184 16 0 ;
185 16 25.65 ;
187 12.5 0 ;
189 4 107.92 ;
191 25 81.9 ;
193 18 71.31 ;
195 15.5 0 ;
197 23 17.04 ;
199 -2 119.32 ;
201 0.1 44.61 ;
203 2 1 5 ;
204 21 0 ;
205 21 65.36 ;
206 1 0 ;
207 9 69.39 ;
208 16 0 ;
209 -2 0.87 ;
211 7 8.67 ;
213 7 13.94 ;
215 7 92.19 ;
217 6 24.22 ;
219 4 41.32 ;
225 8 22.8 ;
229 10.5 64.18 ;
231 5 16.48 ;
237 14 15.61 ;
239 13 44.61 ;
241 13 0 ;
243 14 4.34 ;
247 18 70.38 ;
249 18 0 ;
251 30 24.16 ;
253 36 54.52 ;
255 27 40.39 ;
257 17 0 ;
259 25 0 ;
261 0 0 ;
263 0 0 ;
265 0 0 ;
267 21 0 ;
269 0 0 ;
271 6 0 ;
273 8 0 ;
275 10 0 ;
[RESERVOIRS]
;ID Head Pattern
River 220.0 ;
Lake 167.0 ;
[TANKS]
;ID Elevation InitLevel MinLevel MaxLevel Diameter MinVol VolCurve
1 131.9 13.1 .1 32.1 85 0 ;
2 116.5 23.5 6.5 40.3 50 0 ;
3 129.0 29.0 4.0 35.5 164 0 ;
[PIPES]
;ID Node1 Node2 Length Diameter Roughness MinorLoss Status
20 3 20 99 99 199 0 Open ;
40 1 40 99 99 199 0 Open ;
50 2 50 99 99 199 0 Open ;
60 River 60 1231 24 140 0 Open ;
101 10 101 14200 18 110 0 Open ;
103 101 103 1350 16 130 0 Open ;
105 101 105 2540 12 130 0 Open ;
107 105 107 1470 12 130 0 Open ;
109 103 109 3940 16 130 0 Open ;
111 109 111 2000 12 130 0 Open ;
112 115 111 1160 12 130 0 Open ;
113 111 113 1680 12 130 0 Open ;
114 115 113 2000 8 130 0 Open ;
115 107 115 1950 8 130 0 Open ;
116 113 193 1660 12 130 0 Open ;
117 263 105 2725 12 130 0 Open ;
119 115 117 2180 12 130 0 Open ;
120 119 120 730 12 130 0 Open ;
121 120 117 1870 12 130 0 Open ;
122 121 120 2050 8 130 0 Open ;
123 121 119 2000 30 141 0 Open ;
125 123 121 1500 30 141 0 Open ;
129 121 125 930 24 130 0 Open ;
131 125 127 3240 24 130 0 Open ;
133 20 127 785 20 130 0 Open ;
135 127 129 900 24 130 0 Open ;
137 129 131 6480 16 130 0 Open ;
145 129 139 2750 8 130 0 Open ;
147 139 141 2050 8 130 0 Open ;
149 143 141 1400 8 130 0 Open ;
151 15 143 1650 8 130 0 Open ;
153 145 141 3510 12 130 0 Open ;
155 147 145 2200 12 130 0 Open ;
159 147 149 880 12 130 0 Open ;
161 149 151 1020 8 130 0 Open ;
163 151 153 1170 12 130 0 Open ;
169 125 153 4560 8 130 0 Open ;
171 119 151 3460 12 130 0 Open ;
173 119 157 2080 30 141 0 Open ;
175 157 159 2910 30 141 0 Open ;
177 159 161 2000 30 141 0 Open ;
179 161 163 430 30 141 0 Open ;
180 163 164 150 14 130 0 Open ;
181 164 166 490 14 130 0 Open ;
183 265 169 590 30 141 0 Open ;
185 167 169 60 8 130 0 Open ;
186 187 204 99.9 8 130 0 Open ;
187 169 171 1270 30 141 0 Open ;
189 171 173 50 30 141 0 Open ;
191 271 171 760 24 130 0 Open ;
193 35 181 30 24 130 0 Open ;
195 181 177 30 12 130 0 Open ;
197 177 179 30 12 130 0 Open ;
199 179 183 210 12 130 0 Open ;
201 40 179 1190 12 130 0 Open ;
202 185 184 99.9 8 130 0 Open ;
203 183 185 510 8 130 0 Open ;
204 184 205 4530. 12 130 0 Open ;
205 204 185 1325. 12 130 0 Open ;
207 189 183 1350 12 130 0 Open ;
209 189 187 500 8 130 0 Open ;
211 169 269 646 12 130 0 Open ;
213 191 187 2560 12 130 0 Open ;
215 267 189 1230 12 130 0 Open ;
217 191 193 520 12 130 0 Open ;
219 193 195 360 12 130 0 Open ;
221 161 195 2300 8 130 0 Open ;
223 197 191 1150 12 130 0 Open ;
225 111 197 2790 12 130 0 Open ;
229 173 199 4000 24 141 0 Open ;
231 199 201 630 24 141 0 Open ;
233 201 203 120 24 130 0 Open ;
235 199 273 725 12 130 0 Open ;
237 205 207 1200 12 130 0 Open ;
238 207 206 450 12 130 0 Open ;
239 275 207 1430 12 130 0 Open ;
240 206 208 510 12 130 0 Open ;
241 208 209 885 12 130 0 Open ;
243 209 211 1210 16 130 0 Open ;
245 211 213 990 16 130 0 Open ;
247 213 215 4285 16 130 0 Open ;
249 215 217 1660 16 130 0 Open ;
251 217 219 2050 14 130 0 Open ;
257 217 225 1560 12 130 0 Open ;
261 213 229 2200 8 130 0 Open ;
263 229 231 1960 12 130 0 Open ;
269 211 237 2080 12 130 0 Open ;
271 237 229 790 8 130 0 Open ;
273 237 239 510 12 130 0 Open ;
275 239 241 35 12 130 0 Open ;
277 241 243 2200 12 130 0 Open ;
281 241 247 445 10 130 0 Open ;
283 239 249 430 12 130 0 Open ;
285 247 249 10 12 130 0 Open ;
287 247 255 1390 10 130 0 Open ;
289 50 255 925 10 130 0 Open ;
291 255 253 1100 10 130 0 Open ;
293 255 251 1100 8 130 0 Open ;
295 249 251 1450 12 130 0 Open ;
297 120 257 645 8 130 0 Open ;
299 257 259 350 8 130 0 Open ;
301 259 263 1400 8 130 0 Open ;
303 257 261 1400 8 130 0 Open ;
305 117 261 645 12 130 0 Open ;
307 261 263 350 12 130 0 Open ;
309 265 267 1580 8 130 0 Open ;
311 193 267 1170 12 130 0 Open ;
313 269 189 646 12 130 0 Open ;
315 181 271 260 24 130 0 Open ;
317 273 275 2230 8 130 0 Open ;
319 273 205 645 12 130 0 Open ;
321 163 265 1200 30 141 0 Open ;
323 201 275 300 12 130 0 Open ;
325 269 271 1290 8 130 0 Open ;
329 61 123 45500 30 140 0 Open ;
330 60 601 1 30 140 0 Closed ;
333 601 61 1 30 140 0 Open ;
[PUMPS]
;ID Node1 Node2 Parameters
10 Lake 10 HEAD 1 ;
335 60 61 HEAD 2 ;
[VALVES]
;ID Node1 Node2 Diameter Type Setting MinorLoss
[TAGS]
[DEMANDS]
;Junction Demand Pattern Category
[STATUS]
;ID Status/Setting
10 Closed
[PATTERNS]
;ID Multipliers
;General Default Demand Pattern
1 1.34 1.94 1.46 1.44 .76 .92
1 .85 1.07 .96 1.1 1.08 1.19
1 1.16 1.08 .96 .83 .79 .74
1 .64 .64 .85 .96 1.24 1.67
;Demand Pattern for Node 123
2 0 0 0 0 0 1219
2 0 0 0 1866 1836 1818
2 1818 1822 1822 1817 1824 1816
2 1833 1817 1830 1814 1840 1859
;Demand Pattern for Node 15
3 620 620 620 620 620 360
3 360 0 0 0 0 360
3 360 360 360 360 0 0
3 0 0 0 0 360 360
;Demand Pattern for Node 35
4 1637 1706 1719 1719 1791 1819
4 1777 1842 1815 1825 1856 1801
4 1819 1733 1664 1620 1613 1620
4 1616 1647 1627 1627 1671 1668
;Demand Pattern for Node 203
5 4439 4531 4511 4582 4531 4582
5 4572 4613 4643 4643 4592 4613
5 4531 4521 4449 4439 4449 4460
5 4439 4419 4368 4399 4470 4480
[CURVES]
;ID X-Value Y-Value
;PUMP: Pump Curve for Pump 10 (Lake Source)
1 0 104.
1 2000. 92.
1 4000. 63.
;PUMP: Pump Curve for Pump 335 (River Source)
2 0 200.
2 8000. 138.
2 14000. 86.
[CONTROLS]
;Lake source operates only part of the day
Link 10 OPEN AT TIME 1
Link 10 CLOSED AT TIME 15
;Pump 335 controlled by level in Tank 1
;When pump is closed, bypass pipe is opened
Link 335 OPEN IF Node 1 BELOW 17.1
Link 335 CLOSED IF Node 1 ABOVE 19.1
Link 330 CLOSED IF Node 1 BELOW 17.1
Link 330 OPEN IF Node 1 ABOVE 19.1
[RULES]
[ENERGY]
Global Efficiency 75
Global Price 0.0
Demand Charge 0.0
[EMITTERS]
;Junction Coefficient
[QUALITY]
;Node InitQual
[SOURCES]
;Node Type Quality Pattern
[REACTIONS]
;Type Pipe/Tank Coefficient
[REACTIONS]
Order Bulk 1
Order Tank 1
Order Wall 1
Global Bulk 0.0
Global Wall 0.0
Limiting Potential 0.0
Roughness Correlation 0.0
[MIXING]
;Tank Model
[TIMES]
Duration 24:00
Hydraulic Timestep 1:00
Quality Timestep 0:05
Pattern Timestep 1:00
Pattern Start 0:00
Report Timestep 1:00
Report Start 0:00
Start ClockTime 12 am
Statistic None
[REPORT]
Status Yes
Summary No
Page 0
[OPTIONS]
Units GPM
Headloss H-W
Specific Gravity 1.0
Viscosity 1.0
Trials 40
Accuracy 0.001
CHECKFREQ 2
MAXCHECK 10
DAMPLIMIT 0
Unbalanced Continue 10
Pattern 1
Demand Multiplier 1.0
Emitter Exponent 0.5
Quality Trace Lake
Diffusivity 1.0
Tolerance 0.01
[COORDINATES]
;Node X-Coord Y-Coord
10 9.00 27.85
15 38.68 23.76
20 29.44 26.91
35 25.46 10.52
40 27.02 9.81
50 33.01 3.01
60 23.90 29.94
601 23.00 29.49
61 23.71 29.03
101 13.81 22.94
103 12.96 21.31
105 16.97 21.28
107 18.45 20.46
109 17.64 18.92
111 20.21 17.53
113 22.04 16.61
115 20.98 19.18
117 21.69 21.28
119 23.70 22.76
120 22.08 23.10
121 23.54 25.50
123 23.37 27.31
125 24.59 25.64
127 29.29 26.40
129 30.32 26.39
131 37.89 29.55
139 33.28 24.54
141 35.68 23.08
143 37.47 21.97
145 33.02 19.29
147 30.24 20.38
149 29.62 20.74
151 28.29 21.39
153 28.13 22.63
157 24.85 20.16
159 23.12 17.50
161 25.10 15.28
163 25.39 14.98
164 25.98 15.14
166 26.48 15.13
167 25.88 12.98
169 25.68 12.74
171 26.65 11.80
173 26.87 11.59
177 25.92 10.59
179 25.71 10.40
181 25.72 10.74
183 25.45 10.18
184 25.15 9.52
185 25.01 9.67
187 23.64 11.04
189 24.15 11.37
191 22.10 14.07
193 22.88 14.35
195 23.18 14.72
197 20.97 15.18
199 29.42 8.44
201 30.89 8.57
203 31.14 8.89
204 23.80 10.90
205 29.20 6.46
206 31.66 6.64
207 31.00 6.61
208 32.54 6.81
209 33.76 6.59
211 34.20 5.54
213 35.26 6.16
215 39.95 8.73
217 42.11 8.67
219 44.86 9.32
225 43.53 7.38
229 36.16 3.49
231 38.38 2.54
237 35.37 3.08
239 35.76 2.31
241 35.87 2.11
243 37.04 0.00
247 35.02 2.05
249 35.02 1.81
251 34.15 1.10
253 32.17 1.88
255 33.51 2.45
257 21.17 23.32
259 20.80 23.40
261 20.79 21.45
263 20.32 21.57
265 25.39 13.60
267 23.38 12.95
269 25.03 12.14
271 25.97 11.00
273 29.16 7.38
275 31.07 8.29
River 24.15 31.06
Lake 8.00 27.53
1 27.46 9.84
2 32.99 3.45
3 29.41 27.27
[VERTICES]
;Link X-Coord Y-Coord
[LABELS]
;X-Coord Y-Coord Label & Anchor Node
8.00 29.42 "LAKE"
25.00 31.10 "RIVER"
[BACKDROP]
DIMENSIONS 6.16 -1.55 46.70 32.61
UNITS None
FILE
OFFSET 0.00 0.00
[END]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+53
View File
@@ -0,0 +1,53 @@
[TITLE]
한국어 지자체 급수구역 수리해석 모델 (CP949 인코딩 표준 검증본)
배수본관 및 블록 유량계 가압 관망
[JUNCTIONS]
;ID Elev Demand Pattern
정수장유출_J01 25.0 10.0 1 ; 제1가압장 흡수라인
사거리분기_J02 28.5 35.0 1 ; 중앙로 교차점 분기
주거단지유입_J03 32.0 50.0 1 ; 아파트 단지 인입관
고지대절점_J04 45.0 20.0 1 ; 배수구역 말단 가압절점
[RESERVOIRS]
;ID Head Pattern
중앙정수지_RES 65.0 ; 주 수원지 수두 65m
[PIPES]
;ID Node1 Node2 Length Diameter Roughness MinorLoss Status
송수간선_P01 중앙정수지_RES 정수장유출_J01 500 350 130 0 Open ; 닥타일주철관
배수본관_P02 정수장유출_J01 사거리분기_J02 420 300 130 0 Open ; 신설 본관
주거인입관_P03 사거리분기_J02 주거단지유입_J03 280 200 140 0 Open ; PE관
고지대가압관_P04 사거리분기_J02 고지대절점_J04 650 150 110 0 Open ; 주철관
[PATTERNS]
;ID Multipliers
1 0.70 0.80 1.20 1.60 1.40 1.10
1 1.00 0.90 1.30 1.50 1.00 0.70
[TIMES]
Duration 24:00
Hydraulic Timestep 1:00
Quality Timestep 0:05
Pattern Timestep 2:00
Pattern Start 0:00
Report Timestep 1:00
Report Start 0:00
Start ClockTime 12 am
Statistic None
[OPTIONS]
Units LPS
Headloss H-W
Specific Gravity 1.0
Accuracy 0.001
[COORDINATES]
;Node X-Coord Y-Coord
중앙정수지_RES 954200.00 1945100.00
정수장유출_J01 954700.00 1945100.00
사거리분기_J02 955120.00 1945100.00
주거단지유입_J03 955120.00 1945380.00
고지대절점_J04 955770.00 1945100.00
[END]
+118
View File
@@ -0,0 +1,118 @@
{
"type": "FeatureCollection",
"name": "상수관로_GIS_샘플",
"crs": {
"type": "name",
"properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" }
},
"features": [
{
"type": "Feature",
"properties": {
"PIP_ID": "PIPE_101",
"PIP_DIA": 300,
"PIP_LEN": 450,
"PIP_MAT": "DIP",
"ROUGHNESS": 110,
"LAYER": "상수원관"
},
"geometry": {
"type": "LineString",
"coordinates": [
[126.9750, 37.5650],
[126.9780, 37.5650]
]
}
},
{
"type": "Feature",
"properties": {
"PIP_ID": "PIPE_102",
"PIP_DIA": 200,
"PIP_LEN": 350,
"PIP_MAT": "PE",
"ROUGHNESS": 130,
"LAYER": "배수관"
},
"geometry": {
"type": "LineString",
"coordinates": [
[126.9780, 37.5650],
[126.9780, 37.5610]
]
}
},
{
"type": "Feature",
"properties": {
"PIP_ID": "PIPE_103",
"PIP_DIA": 200,
"PIP_LEN": 380,
"PIP_MAT": "PE",
"ROUGHNESS": 130,
"LAYER": "배수관"
},
"geometry": {
"type": "LineString",
"coordinates": [
[126.9780, 37.5610],
[126.9740, 37.5610]
]
}
},
{
"type": "Feature",
"properties": {
"PIP_ID": "PIPE_104",
"PIP_DIA": 150,
"PIP_LEN": 420,
"PIP_MAT": "PVC",
"ROUGHNESS": 140,
"LAYER": "급수관"
},
"geometry": {
"type": "LineString",
"coordinates": [
[126.9740, 37.5610],
[126.9750, 37.5650]
]
}
},
{
"type": "Feature",
"properties": {
"PIP_ID": "PIPE_105",
"PIP_DIA": 150,
"PIP_LEN": 280,
"PIP_MAT": "DIP",
"ROUGHNESS": 120,
"LAYER": "급수지선"
},
"geometry": {
"type": "LineString",
"coordinates": [
[126.9780, 37.5650],
[126.9820, 37.5630]
]
}
},
{
"type": "Feature",
"properties": {
"PIP_ID": "PIPE_106",
"PIP_DIA": 150,
"PIP_LEN": 310,
"PIP_MAT": "PE",
"ROUGHNESS": 130,
"LAYER": "급수지선"
},
"geometry": {
"type": "LineString",
"coordinates": [
[126.9820, 37.5630],
[126.9780, 37.5610]
]
}
}
]
}
+139
View File
@@ -0,0 +1,139 @@
[TITLE]
Anonymous District Metered Area Water Network (EPSG:5179)
spipe Verification Benchmark Model
[JUNCTIONS]
;ID Elev Demand Pattern
N01 35.0 15.0 1 ;
N02 36.5 20.0 1 ;
N03 34.0 25.0 1 ;
N04 38.0 18.0 1 ;
N05 33.5 30.0 1 ;
N06 35.0 22.0 1 ;
N07 37.0 16.0 1 ;
N08 39.0 12.0 1 ;
[RESERVOIRS]
;ID Head Pattern
RES01 85.0 ;
[TANKS]
;ID Elevation InitLevel MinLevel MaxLevel Diameter MinVol VolCurve
TANK01 50.0 8.0 2.0 12.0 25.0 0 ;
[PIPES]
;ID Node1 Node2 Length Diameter Roughness MinorLoss Status
PIPE01 RES01 N01 450 300 110 0 Open ;
PIPE02 N01 N02 320 250 120 0 Open ;
PIPE03 N02 N03 280 200 130 0 Open ;
PIPE04 N03 N04 360 200 130 0 Open ;
PIPE05 N04 N05 410 200 130 0 Open ;
PIPE06 N05 N06 290 150 130 0 Open ;
PIPE07 N06 N07 350 150 130 0 Open ;
PIPE08 N07 N08 310 150 130 0 Open ;
PIPE09 N08 TANK01 250 200 120 0 Open ;
PIPE10 N02 N06 520 200 130 0 Open ;
PIPE11 N03 N07 480 150 130 0 Open ;
[PUMPS]
;ID Node1 Node2 Parameters
[VALVES]
;ID Node1 Node2 Diameter Type Setting MinorLoss
[TAGS]
[DEMANDS]
;Junction Demand Pattern Category
[STATUS]
;ID Status/Setting
[PATTERNS]
;ID Multipliers
;Demand Pattern (Residential 2-Peak)
1 0.60 0.70 0.90 1.40 1.60 1.30
1 1.10 1.00 1.20 1.50 1.10 0.80
[CURVES]
;ID X-Value Y-Value
[CONTROLS]
[RULES]
[ENERGY]
Global Efficiency 75
Global Price 0.0
Demand Charge 0.0
[EMITTERS]
;Junction Coefficient
[QUALITY]
;Node InitQual
[SOURCES]
;Node Type Quality Pattern
[REACTIONS]
;Type Pipe/Tank Coefficient
[MIXING]
;Tank Model
[TIMES]
Duration 24:00
Hydraulic Timestep 1:00
Quality Timestep 0:05
Pattern Timestep 2:00
Pattern Start 0:00
Report Timestep 1:00
Report Start 0:00
Start ClockTime 12 am
Statistic None
[REPORT]
Status Yes
Summary No
Page 0
[OPTIONS]
Units LPS
Headloss H-W
Specific Gravity 1.0
Viscosity 1.0
Trials 40
Accuracy 0.001
CHECKFREQ 2
MAXCHECK 10
DAMPLIMIT 0
Unbalanced Continue 10
Pattern 1
Demand Multiplier 1.0
Emitter Exponent 0.5
Quality None
Diffusivity 1.0
Tolerance 0.01
[COORDINATES]
;Node X-Coord Y-Coord
RES01 986615.92 1894617.19
N01 987015.92 1894417.19
N02 987315.92 1894217.19
N03 987615.92 1894117.19
N04 987915.92 1894317.19
N05 988315.92 1894217.19
N06 987415.92 1893717.19
N07 987715.92 1893617.19
N08 988015.92 1893517.19
TANK01 988265.92 1893417.19
[VERTICES]
;Link X-Coord Y-Coord
[LABELS]
[BACKDROP]
[END]
File diff suppressed because it is too large. Load diff
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
[pytest]
# Run from the repo root: backend.* (API tests) and blocksys.* (its own tests
# import it as a top-level package) must both be importable.
pythonpath = . backend
testpaths = backend/tests backend/blocksys/tests
addopts = -p no:cacheprovider
+402
View File
@@ -0,0 +1,402 @@
import React, { useState } from 'react';
import { X, Plus } from 'lucide-react';
import type { NetworkModel } from './inpModel';
import {
addNode,
addPipe,
addPump,
addValve,
addCurve,
addRule,
addControl,
updatePattern,
getNextNodeId,
getNextPipeId,
getNextPumpId,
getNextValveId,
getNextCurveId,
} from './inpModel';
import type { TableCategory } from './FullAttributeTableModal';
interface AddElementModalProps {
category: TableCategory;
model: NetworkModel;
onAdd: (newModel: NetworkModel) => void;
onClose: () => void;
}
const LABELS: Partial<Record<TableCategory, string>> = {
junctions: '절점 (Junction)',
pipes: '관로 (Pipe)',
pumps: '펌프 (Pump)',
valves: '밸브 (Valve)',
reservoirs: '저수지 (Reservoir)',
tanks: '탱크 (Tank)',
patterns: '패턴 (Pattern)',
controls: '컨트롤 (Control)',
rules: '규칙 (Rule)',
curves: '펌프 곡선 (Curve)',
};
function averageExistingCoords(model: NetworkModel): { x: number; y: number } {
const nodes = Object.values(model.nodes);
if (nodes.length === 0) return { x: 0, y: 0 };
const sum = nodes.reduce((acc, n) => ({ x: acc.x + n.x, y: acc.y + n.y }), { x: 0, y: 0 });
return { x: Math.round((sum.x / nodes.length) * 100) / 100, y: Math.round((sum.y / nodes.length) * 100) / 100 };
}
export const AddElementModal: React.FC<AddElementModalProps> = ({ category, model, onAdd, onClose }) => {
const [error, setError] = useState<string | null>(null);
const nodeIds = Object.keys(model.nodes);
const linkIds = [...Object.keys(model.pipes), ...Object.keys(model.pumps), ...Object.keys(model.valves || {})];
const defaultCoords = averageExistingCoords(model);
// Shared field state (only relevant fields are rendered/used per category)
const [id, setId] = useState('');
const [node1, setNode1] = useState(nodeIds[0] || '');
const [node2, setNode2] = useState(nodeIds[1] || nodeIds[0] || '');
const [elevation, setElevation] = useState('0');
const [demand, setDemand] = useState('0');
const [pattern, setPattern] = useState('');
const [head, setHead] = useState('0');
const [initLevel, setInitLevel] = useState('5');
const [minLevel, setMinLevel] = useState('0');
const [maxLevel, setMaxLevel] = useState('10');
const [diameter, setDiameter] = useState('10');
const [minVol, setMinVol] = useState('0');
const [length, setLength] = useState('100');
const [pipeDiameter, setPipeDiameter] = useState('200');
const [roughness, setRoughness] = useState('100');
const [minorLoss, setMinorLoss] = useState('0');
const [status, setStatus] = useState('Open');
const [parameters, setParameters] = useState('');
const [valveType, setValveType] = useState('PRV');
const [valveSetting, setValveSetting] = useState('0');
const [multipliers, setMultipliers] = useState('1.0 1.0 1.0 1.0 1.0 1.0');
const [controlLinkId, setControlLinkId] = useState(linkIds[0] || '');
const [controlStatus, setControlStatus] = useState('Open');
const [ruleText, setRuleText] = useState('RULE 1\nIF NODE J1 PRESSURE BELOW 10\nTHEN PIPE P1 STATUS = OPEN\nPRIORITY 1');
const [curvePoints, setCurvePoints] = useState('0 50\n50 40\n100 20');
const title = LABELS[category] || category;
const handleSubmit = () => {
setError(null);
try {
if (category === 'junctions') {
const trimmedId = id.trim();
if (!trimmedId) throw new Error('절점 ID를 입력하세요.');
if (model.nodes[trimmedId]) throw new Error(`이미 존재하는 절점 ID입니다: ${trimmedId}`);
const next = addNode(model, {
id: trimmedId,
type: 'JUNCTION',
x: defaultCoords.x,
y: defaultCoords.y,
elevation: parseFloat(elevation) || 0,
demand: parseFloat(demand) || 0,
pattern: pattern.trim() || undefined,
});
onAdd(next);
} else if (category === 'reservoirs') {
const trimmedId = id.trim();
if (!trimmedId) throw new Error('저수지 ID를 입력하세요.');
if (model.nodes[trimmedId]) throw new Error(`이미 존재하는 절점 ID입니다: ${trimmedId}`);
const next = addNode(model, {
id: trimmedId,
type: 'RESERVOIR',
x: defaultCoords.x,
y: defaultCoords.y,
elevation: parseFloat(head) || 0,
head: parseFloat(head) || 0,
pattern: pattern.trim() || undefined,
});
onAdd(next);
} else if (category === 'tanks') {
const trimmedId = id.trim();
if (!trimmedId) throw new Error('탱크 ID를 입력하세요.');
if (model.nodes[trimmedId]) throw new Error(`이미 존재하는 절점 ID입니다: ${trimmedId}`);
const next = addNode(model, {
id: trimmedId,
type: 'TANK',
x: defaultCoords.x,
y: defaultCoords.y,
elevation: parseFloat(elevation) || 0,
initLevel: parseFloat(initLevel) || 0,
minLevel: parseFloat(minLevel) || 0,
maxLevel: parseFloat(maxLevel) || 0,
diameter: parseFloat(diameter) || 0,
minVol: parseFloat(minVol) || 0,
});
onAdd(next);
} else if (category === 'pipes') {
if (!node1 || !node2) throw new Error('시작/끝 절점을 선택하세요.');
if (node1 === node2) throw new Error('시작과 끝 절점은 달라야 합니다.');
const trimmedId = id.trim() || getNextPipeId(model);
if (model.pipes[trimmedId] || model.pumps[trimmedId] || model.valves?.[trimmedId]) {
throw new Error(`이미 존재하는 링크 ID입니다: ${trimmedId}`);
}
const next = addPipe(model, {
id: trimmedId,
node1,
node2,
length: parseFloat(length) || 0,
diameter: parseFloat(pipeDiameter) || 0,
roughness: parseFloat(roughness) || 100,
minorLoss: parseFloat(minorLoss) || 0,
status,
});
onAdd(next);
} else if (category === 'pumps') {
if (!node1 || !node2) throw new Error('상류/하류 절점을 선택하세요.');
if (node1 === node2) throw new Error('상류와 하류 절점은 달라야 합니다.');
const trimmedId = id.trim() || getNextPumpId(model);
if (model.pipes[trimmedId] || model.pumps[trimmedId] || model.valves?.[trimmedId]) {
throw new Error(`이미 존재하는 링크 ID입니다: ${trimmedId}`);
}
if (!parameters.trim()) throw new Error('펌프 특성 (예: HEAD Curve1 또는 POWER 10)을 입력하세요.');
const next = addPump(model, { id: trimmedId, node1, node2, parameters: parameters.trim() });
onAdd(next);
} else if (category === 'valves') {
if (!node1 || !node2) throw new Error('상류/하류 절점을 선택하세요.');
if (node1 === node2) throw new Error('상류와 하류 절점은 달라야 합니다.');
const trimmedId = id.trim() || getNextValveId(model);
if (model.pipes[trimmedId] || model.pumps[trimmedId] || model.valves?.[trimmedId]) {
throw new Error(`이미 존재하는 링크 ID입니다: ${trimmedId}`);
}
const settingNum = parseFloat(valveSetting);
const next = addValve(model, {
id: trimmedId,
node1,
node2,
diameter: parseFloat(diameter) || 0,
valveType,
setting: isNaN(settingNum) ? valveSetting : settingNum,
minorLoss: parseFloat(minorLoss) || 0,
});
onAdd(next);
} else if (category === 'patterns') {
const trimmedId = id.trim();
if (!trimmedId) throw new Error('패턴 ID를 입력하세요.');
if (model.patterns[trimmedId]) throw new Error(`이미 존재하는 패턴 ID입니다: ${trimmedId}`);
const values = multipliers
.split(/[\s,]+/)
.map((v) => v.trim())
.filter(Boolean)
.map(Number);
if (values.length === 0 || values.some((v) => isNaN(v))) {
throw new Error('시간별 배율은 공백/쉼표로 구분된 숫자여야 합니다.');
}
const next = updatePattern(model, trimmedId, values);
onAdd(next);
} else if (category === 'controls') {
if (!controlLinkId) throw new Error('대상 링크를 선택하세요.');
const next = addControl(model, { linkId: controlLinkId, status: controlStatus });
onAdd(next);
} else if (category === 'rules') {
const next = addRule(model, ruleText);
onAdd(next);
} else if (category === 'curves') {
const trimmedId = id.trim();
if (!trimmedId) throw new Error('곡선 ID를 입력하세요.');
if (model.curves[trimmedId]) throw new Error(`이미 존재하는 곡선 ID입니다: ${trimmedId}`);
const points = curvePoints
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [xStr, yStr] = line.split(/[\s,]+/);
return { x: parseFloat(xStr), y: parseFloat(yStr) };
});
if (points.length === 0 || points.some((p) => isNaN(p.x) || isNaN(p.y))) {
throw new Error('곡선 점은 한 줄당 "X Y" 형식의 숫자 쌍이어야 합니다.');
}
const next = addCurve(model, { id: trimmedId, points });
onAdd(next);
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
}
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '5px 8px',
fontSize: '12px',
border: '1px solid var(--border-strong)',
borderRadius: '4px',
};
const labelStyle: React.CSSProperties = { fontSize: '11px', color: 'var(--text-secondary)', display: 'block', marginBottom: '3px' };
const fieldWrap: React.CSSProperties = { marginBottom: '10px' };
const nodeSelect = (value: string, onChange: (v: string) => void, label: string) => (
<div style={fieldWrap}>
<label style={labelStyle}>{label}</label>
<select value={value} onChange={(e) => onChange(e.target.value)} style={inputStyle}>
{nodeIds.length === 0 && <option value="">(절점 없음)</option>}
{nodeIds.map((nid) => (
<option key={nid} value={nid}>{nid}</option>
))}
</select>
</div>
);
return (
<div className="modal-overlay" style={{ zIndex: 1000 }}>
<div className="modal-content" style={{ width: 'min(420px, calc(100vw - 32px))', padding: 'var(--s-6)' }} role="dialog" aria-modal="true">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--s-4)' }}>
<h3 style={{ margin: 0, fontSize: 'var(--fs-lg)', color: 'var(--text)' }}>{title} 추가</h3>
<button onClick={onClose} className="tool-btn" aria-label="닫기" title="닫기">
<X size={16} />
</button>
</div>
{error && (
<div style={{ padding: '8px 10px', marginBottom: '10px', background: 'var(--crit-wash)', color: 'var(--crit)', borderRadius: '4px', fontSize: '11px' }}>
{error}
</div>
)}
{(category === 'junctions' || category === 'reservoirs' || category === 'tanks' || category === 'pipes' || category === 'pumps' || category === 'valves' || category === 'patterns' || category === 'curves') && (
<div style={fieldWrap}>
<label style={labelStyle}>{category === 'pipes' || category === 'pumps' || category === 'valves' ? 'ID (비우면 자동 생성)' : 'ID'}</label>
<input type="text" value={id} onChange={(e) => setId(e.target.value)} style={inputStyle} autoFocus />
</div>
)}
{category === 'junctions' && (
<>
<div style={fieldWrap}><label style={labelStyle}>지반고 (Elevation)</label><input type="number" value={elevation} onChange={(e) => setElevation(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>기본수요량 (Demand)</label><input type="number" value={demand} onChange={(e) => setDemand(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>수요 패턴 ID (선택)</label><input type="text" value={pattern} onChange={(e) => setPattern(e.target.value)} style={inputStyle} /></div>
</>
)}
{category === 'reservoirs' && (
<>
<div style={fieldWrap}><label style={labelStyle}>총수두 (Head)</label><input type="number" value={head} onChange={(e) => setHead(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>Head Pattern ID (선택)</label><input type="text" value={pattern} onChange={(e) => setPattern(e.target.value)} style={inputStyle} /></div>
</>
)}
{category === 'tanks' && (
<>
<div style={fieldWrap}><label style={labelStyle}>바닥 지반고 (Elevation)</label><input type="number" value={elevation} onChange={(e) => setElevation(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>초기 수위 (Initial Level)</label><input type="number" value={initLevel} onChange={(e) => setInitLevel(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>최저 수위 (Minimum Level)</label><input type="number" value={minLevel} onChange={(e) => setMinLevel(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>최고 수위 (Maximum Level)</label><input type="number" value={maxLevel} onChange={(e) => setMaxLevel(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>직경 (Diameter)</label><input type="number" value={diameter} onChange={(e) => setDiameter(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>최저 저수용량 (Min Volume)</label><input type="number" value={minVol} onChange={(e) => setMinVol(e.target.value)} style={inputStyle} /></div>
</>
)}
{category === 'pipes' && (
<>
{nodeSelect(node1, setNode1, '시작절점 (Node1)')}
{nodeSelect(node2, setNode2, '끝절점 (Node2)')}
<div style={fieldWrap}><label style={labelStyle}>관로길이 (Length)</label><input type="number" value={length} onChange={(e) => setLength(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>관경 (Diameter)</label><input type="number" value={pipeDiameter} onChange={(e) => setPipeDiameter(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>조도계수 (Roughness)</label><input type="number" value={roughness} onChange={(e) => setRoughness(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>미소손실계수 (Minor Loss)</label><input type="number" value={minorLoss} onChange={(e) => setMinorLoss(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}>
<label style={labelStyle}>상태 (Status)</label>
<select value={status} onChange={(e) => setStatus(e.target.value)} style={inputStyle}>
<option value="Open">Open</option>
<option value="Closed">Closed</option>
<option value="CV">CV</option>
</select>
</div>
</>
)}
{category === 'pumps' && (
<>
{nodeSelect(node1, setNode1, '상류절점 (Node1)')}
{nodeSelect(node2, setNode2, '하류절점 (Node2)')}
<div style={fieldWrap}>
<label style={labelStyle}>펌프 특성 (예: HEAD Curve1, POWER 10)</label>
<input type="text" value={parameters} onChange={(e) => setParameters(e.target.value)} style={inputStyle} placeholder="HEAD Curve1" />
</div>
</>
)}
{category === 'valves' && (
<>
{nodeSelect(node1, setNode1, '상류절점 (Node1)')}
{nodeSelect(node2, setNode2, '하류절점 (Node2)')}
<div style={fieldWrap}>
<label style={labelStyle}>밸브 유형 (Type)</label>
<select value={valveType} onChange={(e) => setValveType(e.target.value)} style={inputStyle}>
{['PRV', 'PSV', 'PBV', 'FCV', 'TCV', 'GPV', 'PCV'].map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div style={fieldWrap}><label style={labelStyle}>구경 (Diameter)</label><input type="number" value={diameter} onChange={(e) => setDiameter(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>설정값 (Setting)</label><input type="text" value={valveSetting} onChange={(e) => setValveSetting(e.target.value)} style={inputStyle} /></div>
<div style={fieldWrap}><label style={labelStyle}>미소손실계수 (Minor Loss)</label><input type="number" value={minorLoss} onChange={(e) => setMinorLoss(e.target.value)} style={inputStyle} /></div>
</>
)}
{category === 'patterns' && (
<div style={fieldWrap}>
<label style={labelStyle}>시간별 배율 (공백 또는 쉼표 구분)</label>
<textarea value={multipliers} onChange={(e) => setMultipliers(e.target.value)} style={{ ...inputStyle, minHeight: '60px', fontFamily: 'var(--font-mono)' }} />
</div>
)}
{category === 'controls' && (
<>
<div style={fieldWrap}>
<label style={labelStyle}>대상 링크</label>
<select value={controlLinkId} onChange={(e) => setControlLinkId(e.target.value)} style={inputStyle}>
{linkIds.length === 0 && <option value="">(링크 없음)</option>}
{linkIds.map((lid) => <option key={lid} value={lid}>{lid}</option>)}
</select>
</div>
<div style={fieldWrap}>
<label style={labelStyle}>상태 (Open / Closed)</label>
<select value={controlStatus} onChange={(e) => setControlStatus(e.target.value)} style={inputStyle}>
<option value="Open">Open</option>
<option value="Closed">Closed</option>
</select>
</div>
<div style={{ fontSize: '10px', color: 'var(--text-muted)', marginBottom: '10px' }}>
단순 컨트롤(LINK 상태)만 생성합니다. 조건부(IF NODE...)나 시간 기반 컨트롤은 검증 탭의 컨트롤 편집기를 이용하세요.
</div>
</>
)}
{category === 'rules' && (
<div style={fieldWrap}>
<label style={labelStyle}>규칙 정의 (EPANET RULE 구문 원문)</label>
<textarea
value={ruleText}
onChange={(e) => setRuleText(e.target.value)}
style={{ ...inputStyle, minHeight: '140px', fontFamily: 'var(--font-mono)', whiteSpace: 'pre' }}
/>
<div style={{ fontSize: '10px', color: 'var(--text-muted)', marginTop: '4px' }}>
RULE 이름으로 시작해 IF/THEN/PRIORITY 절을 그대로 입력합니다. 문법은 검증 없이 그대로 저장됩니다.
</div>
</div>
)}
{category === 'curves' && (
<div style={fieldWrap}>
<label style={labelStyle}>곡선 점 (한 줄당 "X Y")</label>
<textarea
value={curvePoints}
onChange={(e) => setCurvePoints(e.target.value)}
style={{ ...inputStyle, minHeight: '90px', fontFamily: 'var(--font-mono)', whiteSpace: 'pre' }}
/>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--s-3)', marginTop: 'var(--s-4)' }}>
<button onClick={onClose} className="btn btn-secondary">취소</button>
<button onClick={handleSubmit} className="btn btn-primary">
<Plus size={13} />
추가
</button>
</div>
</div>
</div>
);
};
+145
View File
@@ -0,0 +1,145 @@
import React, { useState } from 'react';
import { Search } from 'lucide-react';
import {
searchKakaoAddress,
type KakaoAddressResult,
formatMapServiceError,
} from './mapServices';
interface AddressSearchProps {
onSelect: (result: KakaoAddressResult) => void;
}
export function AddressSearch({ onSelect }: AddressSearchProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<KakaoAddressResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasSearched, setHasSearched] = useState(false);
const handleSearch = async (event: React.FormEvent) => {
event.preventDefault();
const trimmed = query.trim();
if (!trimmed || isLoading) return;
setIsLoading(true);
setError(null);
setHasSearched(true);
setResults([]);
try {
setResults(await searchKakaoAddress(trimmed));
} catch (caught) {
setError(formatMapServiceError(caught));
} finally {
setIsLoading(false);
}
};
const handleSelect = (result: KakaoAddressResult) => {
onSelect(result);
setResults([]);
setHasSearched(false);
setQuery(result.roadAddressName || result.parcelAddressName || result.addressName);
};
const showPanel = Boolean(error) || (!isLoading && hasSearched && results.length === 0) || results.length > 0;
return (
<div style={{ width: '340px', position: 'relative', flexShrink: 0 }}>
<form onSubmit={handleSearch} style={{ display: 'flex', gap: '4px' }}>
<div style={{ position: 'relative', flex: 1 }}>
<Search
size={12}
aria-hidden="true"
style={{ position: 'absolute', left: '7px', top: '8px', color: 'var(--text-muted)' }}
/>
<input
type="text"
value={query}
onChange={(event) => {
setQuery(event.target.value);
setError(null);
setHasSearched(false);
}}
placeholder="도로명·지번 주소 검색"
aria-label="카카오 주소 검색"
style={{ width: '100%', height: '28px', paddingLeft: '24px', fontSize: '11px' }}
/>
</div>
<button
type="submit"
className="btn btn-secondary"
disabled={isLoading || !query.trim()}
aria-label="주소 검색 실행"
style={{ height: '28px', padding: '0 9px' }}
>
{isLoading ? '검색 중' : '주소 검색'}
</button>
</form>
{showPanel && (
<div
style={{
position: 'absolute',
top: '31px',
right: 0,
zIndex: 1000,
width: '100%',
maxHeight: '280px',
overflowY: 'auto',
background: 'var(--bg-panel)',
border: '1px solid var(--border-strong)',
borderRadius: 'var(--r-sm)',
boxShadow: 'var(--shadow-pop)',
}}
role="listbox"
aria-label="카카오 주소 검색 결과"
>
{error && (
<div role="alert" style={{ padding: '8px', color: 'var(--crit)', fontSize: '11px', lineHeight: 1.4 }}>
{error}
</div>
)}
{!error && !isLoading && hasSearched && results.length === 0 && (
<div style={{ padding: '8px', color: 'var(--text-secondary)', fontSize: '11px' }}>
검색 결과가 없습니다.
</div>
)}
{results.map((result) => (
<button
type="button"
key={result.id}
onClick={() => handleSelect(result)}
role="option"
aria-selected="false"
style={{
display: 'block',
width: '100%',
padding: '7px 9px',
border: 0,
borderBottom: '1px solid var(--border)',
background: 'transparent',
color: 'var(--text)',
cursor: 'pointer',
textAlign: 'left',
}}
>
<strong style={{ display: 'block', fontSize: '11px' }}>
{result.placeName || result.roadAddressName || result.parcelAddressName || result.addressName}
</strong>
{result.roadAddressName && (
<span style={{ display: 'block', marginTop: '2px', fontSize: '10px', color: 'var(--text-secondary)' }}>
도로명: {result.roadAddressName}
</span>
)}
{result.parcelAddressName && (
<span style={{ display: 'block', fontSize: '10px', color: 'var(--text-muted)' }}>
지번: {result.parcelAddressName}
</span>
)}
</button>
))}
</div>
)}
</div>
);
}
+500
View File
@@ -0,0 +1,500 @@
// "분석" tab: network analyses that go beyond a single EPS run.
// Server-side ones (fire flow, criticality, ...) call /api/jobs/{name} with the
// current model as INP; graph-only ones run in the browser.
import React, { useMemo, useState } from 'react';
import { Flame, Network, Boxes, Play, Crosshair, X, Wrench, ShieldAlert, Droplets, ArrowLeftRight, Gauge, Waves, Zap, ClipboardList, PenTool, Mountain, Map as MapIcon, Target, Timer } from 'lucide-react';
import { EditToolsSection } from './EditToolsSection';
import { DemSection, type DemSectionProps } from './DemSection';
import { GisToolsSection, type GisToolsProps } from './GisToolsSection';
import { AutoCalibrationSection, PumpScheduleSection, type OptimizeProps } from './OptimizeSections';
import { CriticalitySection, ServiceAreaSection, FlowDirectionSection, VspSection, ContourSection, EnergySection, PipeReportSection, type AnalysisSectionsExtraProps } from './AnalysisSections';
import type { NetworkModel } from './inpModel';
import { generateInp } from './inpModel';
import type { SimulationResult, UserGisLayer } from './types';
import type { CRS } from './crs';
import { modelToLngLat } from './crs';
import { createDisplayConverter, getDisplayUnits } from './unitSystem';
import { runJob } from './api';
import { findNetworkGroups } from './networkGroups';
import { DEFAULT_EDIT_OPTIONS, fillZeroElevations, loadEditOptions, recalcPipeLengths, saveEditOptions, type EditOptions } from './networkTools';
import { setLabels } from './extrasActions';
import { BLOCK_STATUS_LABEL, blocksFromAssets, blocksFromPolygons, checkBlockIsolation, type BlockReport } from './blockIsolation';
type Converter = ReturnType<typeof createDisplayConverter>;
type DisplayUnits = ReturnType<typeof getDisplayUnits>;
export type AnalysisSection = 'fireflow' | 'groups' | 'blocks' | 'criticality' | 'service' | 'flowdir' | 'vsp' | 'contour' | 'energy' | 'pipes' | 'calib' | 'pumpopt' | 'edit' | 'dem' | 'gis' | 'tools';
export interface AnalysisPanelProps {
model: NetworkModel;
result: SimulationResult | null;
crs: CRS;
userLayers: UserGisLayer[];
selectedNodeIds: string[];
selectedLinkIds: string[];
units: Converter;
displayUnits: DisplayUnits;
minPressureThreshold: number;
/** Results survive switching tabs: the panel keeps them in App state. */
cache: Record<string, unknown>;
setCache: (key: string, value: unknown) => void;
onFocus: (type: 'node' | 'link', id: string) => void;
onSelectElements: (nodeIds: string[], linkIds: string[]) => void;
/** Apply a whole-model edit with undo. */
onApplyModel: (next: NetworkModel, message: string) => void;
}
const pill = (active: boolean): React.CSSProperties => ({ height: 'var(--h-btn-sm)', padding: '0 var(--s-4)', fontSize: 'var(--fs-xs)' });
const th: React.CSSProperties = { padding: '4px 5px', textAlign: 'right', fontWeight: 600, background: 'var(--bg-subtle)', position: 'sticky', top: 0 };
const td: React.CSSProperties = { padding: '3px 5px', textAlign: 'right', borderBottom: '1px solid var(--border)', fontFamily: 'var(--font-mono)' };
const fmt = (v: number | undefined, d = 2) => (v === undefined || !Number.isFinite(v) ? '-' : v.toFixed(d));
const inputStyle: React.CSSProperties = { height: 24, fontSize: 'var(--fs-xs)', width: '100%' };
export const AnalysisPanel: React.FC<AnalysisPanelProps & AnalysisSectionsExtraProps & DemSectionProps & GisToolsProps & OptimizeProps> = (props) => {
const [section, setSection] = useState<AnalysisSection>((props.cache.section as AnalysisSection) ?? 'fireflow');
const go = (s: AnalysisSection) => {
setSection(s);
props.setCache('section', s);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-5)', fontSize: 'var(--fs-xs)' }}>
<div style={{ display: 'flex', gap: 'var(--s-2)', flexWrap: 'wrap', borderBottom: '1px solid var(--border)', paddingBottom: 'var(--s-3)' }}>
<button className={`btn btn-secondary ${section === 'fireflow' ? 'btn-primary' : ''}`} style={pill(section === 'fireflow')} onClick={() => go('fireflow')}>
<Flame size={12} /><span>소화전 모의</span>
</button>
<button className={`btn btn-secondary ${section === 'groups' ? 'btn-primary' : ''}`} style={pill(section === 'groups')} onClick={() => go('groups')}>
<Network size={12} /><span>관망 그룹</span>
</button>
<button className={`btn btn-secondary ${section === 'blocks' ? 'btn-primary' : ''}`} style={pill(section === 'blocks')} onClick={() => go('blocks')}>
<Boxes size={12} /><span>소블록 고립확인</span>
</button>
{([
['criticality', '관로 중요도', <ShieldAlert size={12} key="i" />],
['service', '급수 구역', <Droplets size={12} key="i" />],
['flowdir', '흐름 방향', <ArrowLeftRight size={12} key="i" />],
['vsp', 'VSP', <Gauge size={12} key="i" />],
['contour', '등치선', <Waves size={12} key="i" />],
['energy', '에너지 비용', <Zap size={12} key="i" />],
['pipes', '관로 통계', <ClipboardList size={12} key="i" />],
['calib', '자동 보정', <Target size={12} key="i" />],
['pumpopt', '펌프 운전 최적화', <Timer size={12} key="i" />],
['edit', '편집 도구', <PenTool size={12} key="i" />],
['dem', '지형(DEM)', <Mountain size={12} key="i" />],
['gis', 'GIS 도구', <MapIcon size={12} key="i" />],
] as Array<[AnalysisSection, string, React.ReactNode]>).map(([key, label, icon]) => (
<button key={key} className={`btn btn-secondary ${section === key ? 'btn-primary' : ''}`} style={pill(section === key)} onClick={() => go(key)}>
{icon}<span>{label}</span>
</button>
))}
<button className={`btn btn-secondary ${section === 'tools' ? 'btn-primary' : ''}`} style={pill(section === 'tools')} onClick={() => go('tools')}>
<Wrench size={12} /><span>관망 도구</span>
</button>
</div>
{section === 'fireflow' && <FireFlowSection {...props} />}
{section === 'groups' && <GroupsSection {...props} />}
{section === 'blocks' && <BlocksSection {...props} />}
{section === 'criticality' && <CriticalitySection {...props} />}
{section === 'service' && <ServiceAreaSection {...props} />}
{section === 'flowdir' && <FlowDirectionSection {...props} />}
{section === 'vsp' && <VspSection {...props} />}
{section === 'contour' && <ContourSection {...props} />}
{section === 'energy' && <EnergySection {...props} />}
{section === 'pipes' && <PipeReportSection {...props} />}
{section === 'calib' && <AutoCalibrationSection {...props} />}
{section === 'pumpopt' && <PumpScheduleSection {...props} />}
{section === 'edit' && <EditToolsSection {...props} />}
{section === 'dem' && <DemSection {...props} />}
{section === 'gis' && <GisToolsSection {...props} />}
{section === 'tools' && <ToolsSection {...props} />}
</div>
);
};
// ---------------------------------------------------------------- 소화전 모의
interface FireFlowResult {
windowLabel: string;
flow: number;
hydrants: Array<{ id: string; basePressure?: number; firePressure?: number; available?: number }>;
nodes: Array<{ id: string; basePressure: number; firePressure: number; diff: number; time: string }>;
belowThreshold: number | null;
belowThresholdBefore: number | null;
warnings: Array<{ time: number; code: number }>;
}
const FireFlowSection: React.FC<AnalysisPanelProps> = ({ model, units, displayUnits, selectedNodeIds, minPressureThreshold, cache, setCache, onFocus, onSelectElements }) => {
const durationH = useMemo(() => {
const d = model.options.timeEntries.find((e) => e.key.toUpperCase() === 'DURATION')?.value ?? '24';
const [h, m] = d.split(':').map(Number);
return (h || 0) + (m || 0) / 60 || 24;
}, [model]);
const [hydrants, setHydrants] = useState<string[]>((cache.ffHydrants as string[]) ?? []);
const [idInput, setIdInput] = useState('');
const [flow, setFlow] = useState<string>((cache.ffFlow as string) ?? String(Math.round(units.flow(units.toFlow(1000)))));
const [start, setStart] = useState<string>((cache.ffStart as string) ?? '0');
const [end, setEnd] = useState<string>((cache.ffEnd as string) ?? String(durationH));
const [residual, setResidual] = useState<string>((cache.ffResidual as string) ?? '10');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const result = cache.ffResult as FireFlowResult | undefined;
const addIds = (ids: string[]) => {
const valid = ids.filter((id) => model.nodes[id]?.type === 'JUNCTION');
const next = [...new Set([...hydrants, ...valid])];
setHydrants(next);
setCache('ffHydrants', next);
if (valid.length < ids.length) setError('접합점(JUNCTION)만 소화전으로 지정할 수 있습니다.');
};
const run = async () => {
setError(null);
const q = Number(flow);
if (!hydrants.length) return setError('소화전 절점을 추가하세요.');
if (!(q > 0)) return setError('소화 유량을 입력하세요.');
setBusy(true);
setCache('ffFlow', flow);
setCache('ffStart', start);
setCache('ffEnd', end);
setCache('ffResidual', residual);
try {
const r = await runJob<FireFlowResult>('fireflow', generateInp(model), {
nodes: hydrants,
flow: units.toFlow(q),
start: Number(start),
end: Number(end),
residual: residual.trim() ? units.toPressure(Number(residual)) : null,
threshold: units.toPressure(minPressureThreshold),
});
setCache('ffResult', r);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const p = units.pressure;
const affected = result ? result.nodes.filter((n) => p(n.diff) <= -0.1) : [];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.5 }}>
소화전 절점에 소화 유량을 더해 지정 시간대를 다시 해석하고, 시간대 최저 압력을 소화 전후로 비교합니다.
잔류압력을 입력하면 그 압력을 지키며 낼 수 있는 최대 소화 유량도 계산합니다.
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>소화전 절점</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 6 }}>
{hydrants.map((id) => (
<span key={id} style={{ display: 'inline-flex', alignItems: 'center', gap: 2, padding: '1px 6px', background: 'var(--bg-subtle)', border: '1px solid var(--border)', borderRadius: 10 }}>
<a onClick={() => onFocus('node', id)} style={{ cursor: 'pointer' }}>{id}</a>
<X size={11} style={{ cursor: 'pointer' }} onClick={() => { const n = hydrants.filter((h) => h !== id); setHydrants(n); setCache('ffHydrants', n); }} aria-label={`${id} 제거`} />
</span>
))}
{!hydrants.length && <span style={{ color: 'var(--text-muted)' }}>없음</span>}
</div>
<div style={{ display: 'flex', gap: 4 }}>
<button className="btn btn-secondary" disabled={!selectedNodeIds.length} onClick={() => addIds(selectedNodeIds)} style={{ height: 24, fontSize: 'var(--fs-2xs)' }} title="지도에서 선택한 절점을 추가">
<Crosshair size={11} />선택 절점 추가 ({selectedNodeIds.length})
</button>
<input value={idInput} onChange={(e) => setIdInput(e.target.value)} placeholder="절점 ID" style={{ ...inputStyle, width: 90 }}
onKeyDown={(e) => { if (e.key === 'Enter' && idInput.trim()) { addIds([idInput.trim()]); setIdInput(''); } }} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
<label>소화 유량 ({displayUnits.flow}, 1곳당)<input type="number" value={flow} onChange={(e) => setFlow(e.target.value)} style={inputStyle} /></label>
<label>잔류압력 기준 ({displayUnits.pressure})<input type="number" value={residual} onChange={(e) => setResidual(e.target.value)} placeholder="생략 가능" style={inputStyle} /></label>
<label>시작 (시)<input type="number" step="0.5" value={start} onChange={(e) => setStart(e.target.value)} style={inputStyle} /></label>
<label>종료 (시)<input type="number" step="0.5" value={end} onChange={(e) => setEnd(e.target.value)} style={inputStyle} /></label>
</div>
<button className="btn btn-primary" onClick={() => void run()} disabled={busy} style={{ height: 28 }}>
<Play size={12} />{busy ? '해석 중...' : '소화전 모의 실행'}
</button>
{error && <div style={{ color: 'var(--crit)' }}>{error}</div>}
{result && (
<>
<div style={{ color: 'var(--text-secondary)' }}>
시간대 {result.windowLabel} · 소화 유량 {fmt(units.flow(result.flow), 1)} {displayUnits.flow} ·
최저압력 기준({minPressureThreshold} {displayUnits.pressure}) 미달 절점 {result.belowThresholdBefore ?? '-'} → <b>{result.belowThreshold ?? '-'}</b>개
{result.warnings.length > 0 && <span style={{ color: 'var(--warn, #b45309)' }}> · 엔진 경고 {result.warnings.length}건</span>}
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>
<th style={{ ...th, textAlign: 'left' }}>소화전</th><th style={th}>소화 시 최저</th><th style={th}>최대 하락</th><th style={th}>확보 가능 유량</th>
</tr></thead>
<tbody>
{result.hydrants.map((h) => (
<tr key={h.id} onClick={() => onFocus('node', h.id)} style={{ cursor: 'pointer' }}>
<td style={{ ...td, textAlign: 'left' }}>{h.id}</td>
<td style={{ ...td, color: residual.trim() && h.firePressure !== undefined && p(h.firePressure) < Number(residual) ? 'var(--crit)' : undefined }}>{fmt(h.firePressure !== undefined ? p(h.firePressure) : undefined)}</td>
<td style={td}>{(() => { const n = result.nodes.find((x) => x.id === h.id); return n ? `${fmt(p(n.diff))} (${n.time})` : '-'; })()}</td>
<td style={td}>{h.available !== undefined ? `${fmt(units.flow(h.available), 0)} ${displayUnits.flow}` : '-'}</td>
</tr>
))}
</tbody>
</table>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<b>압력 변화 절점 ({affected.length}곳이 0.1 {displayUnits.pressure} 이상 하락)</b>
<button className="btn btn-secondary" disabled={!affected.length} onClick={() => onSelectElements(affected.map((n) => n.id), [])} style={{ height: 22, fontSize: 'var(--fs-2xs)' }}>지도에서 선택</button>
</div>
<div style={{ maxHeight: 260, overflowY: 'auto', border: '1px solid var(--border)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>
<th style={{ ...th, textAlign: 'left' }}>절점</th><th style={th}>시각</th><th style={th}>이전 압력</th><th style={th}>이후 압력</th><th style={th}>차이</th>
</tr></thead>
<tbody>
{result.nodes.slice(0, 300).map((n) => (
<tr key={n.id} onClick={() => onFocus('node', n.id)} style={{ cursor: 'pointer' }}>
<td style={{ ...td, textAlign: 'left' }}>{n.id}</td>
<td style={td}>{n.time}</td>
<td style={td}>{fmt(p(n.basePressure))}</td>
<td style={{ ...td, color: p(n.firePressure) < minPressureThreshold ? 'var(--crit)' : undefined }}>{fmt(p(n.firePressure))}</td>
<td style={td}>{fmt(p(n.diff))}</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ color: 'var(--text-muted)' }}>
소화전 표는 시간대 최저 압력, 절점 표는 같은 시각끼리 비교해 가장 크게 떨어진 시각의 압력입니다(탱크·펌프 제어로 최저 시각이 바뀔 수 있어서).
행을 누르면 지도에서 해당 절점으로 이동합니다.
</div>
</>
)}
</div>
);
};
// ---------------------------------------------------------------- 관망 그룹
const GroupsSection: React.FC<AnalysisPanelProps> = ({ model, units, displayUnits, onSelectElements, onFocus }) => {
const [excludeClosed, setExcludeClosed] = useState(true);
const groups = useMemo(() => findNetworkGroups(model, excludeClosed), [model, excludeClosed]);
const noSource = groups.filter((g) => g.sources === 0);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.5 }}>
서로 연결된 관망 묶음을 찾습니다. 수원(배수지·탱크)이 없는 그룹은 물을 공급받을 수 없는 구간입니다.
</div>
<label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="checkbox" checked={excludeClosed} onChange={(e) => setExcludeClosed(e.target.checked)} />
닫힌 관로·밸브는 연결로 보지 않음
</label>
<div>그룹 <b>{groups.length}</b>개 {noSource.length > 0 && <span style={{ color: 'var(--crit)' }}>· 수원 없는 그룹 {noSource.length}개</span>}</div>
<div style={{ maxHeight: 360, overflowY: 'auto', border: '1px solid var(--border)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>
<th style={{ ...th, textAlign: 'left' }}>그룹</th><th style={th}>절점</th><th style={th}>수원</th>
<th style={th}>관 연장 ({displayUnits.length})</th><th style={th}>기본수요 ({displayUnits.flow})</th>
</tr></thead>
<tbody>
{groups.map((g) => (
<tr key={g.index} style={{ cursor: 'pointer', color: g.sources === 0 ? 'var(--crit)' : undefined }}
onClick={() => { onSelectElements(g.nodeIds, g.linkIds); onFocus('node', g.nodeIds[0]); }}
title="클릭하면 그룹 전체를 지도에서 선택합니다">
<td style={{ ...td, textAlign: 'left' }}>{g.index}</td>
<td style={td}>{g.nodeIds.length}</td>
<td style={td}>{g.sources}</td>
<td style={td}>{fmt(units.length(g.pipeLength), 0)}</td>
<td style={td}>{fmt(units.flow(g.baseDemand), 1)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
// ---------------------------------------------------------------- 소블록 고립확인
function modelExtents(model: NetworkModel) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of Object.values(model.nodes)) {
if (n.x < minX) minX = n.x;
if (n.y < minY) minY = n.y;
if (n.x > maxX) maxX = n.x;
if (n.y > maxY) maxY = n.y;
}
return { minX, minY, maxX, maxY };
}
const BlocksSection: React.FC<AnalysisPanelProps> = ({ model, result, crs, userLayers, units, displayUnits, onFocus, onSelectElements }) => {
const polygonLayers = userLayers.filter((l) => l.geomTypes.some((g) => g.includes('Polygon')));
const [source, setSource] = useState<string>('assets');
const layer = polygonLayers.find((l) => l.id === source);
const fields = layer ? Object.keys(layer.featureCollection.features[0]?.properties ?? {}) : [];
const [nameField, setNameField] = useState('');
const [open, setOpen] = useState<string | null>(null);
const { reports, assigned } = useMemo(() => {
let blockOf: Record<string, string> = {};
if (source === 'assets') blockOf = blocksFromAssets(model);
else if (layer) {
const ext = modelExtents(model);
blockOf = blocksFromPolygons(model, layer.featureCollection, (x, y) => modelToLngLat(x, y, crs, ext), nameField || undefined);
}
return { reports: checkBlockIsolation(model, blockOf, result), assigned: Object.keys(blockOf).length };
}, [model, result, source, layer, nameField, crs]);
const statusColor = (r: BlockReport) => (r.status === 'single-inlet' ? 'var(--ok, #15803d)' : r.status === 'multi-open' ? 'var(--crit)' : 'var(--warn, #b45309)');
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.5 }}>
소블록마다 경계를 지나는 관로·밸브를 찾고, 열린 연결이 하나(단일 유입)인지 확인합니다.
해석 결과가 있으면 실제로 물이 드나드는 지점도 표시합니다.
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: 6, alignItems: 'center' }}>
<span>소블록 정보</span>
<select value={source} onChange={(e) => setSource(e.target.value)} style={inputStyle}>
<option value="assets">절점 속성 '소블록'</option>
{polygonLayers.map((l) => <option key={l.id} value={l.id}>폴리곤 레이어: {l.name}</option>)}
</select>
{layer && (
<>
<span>블록 이름 필드</span>
<select value={nameField} onChange={(e) => setNameField(e.target.value)} style={inputStyle}>
<option value="">(자동)</option>
{fields.map((f) => <option key={f} value={f}>{f}</option>)}
</select>
</>
)}
</div>
{assigned === 0 ? (
<div style={{ color: 'var(--text-muted)' }}>
소블록이 지정된 절점이 없습니다. 소블록 폴리곤 레이어를 추가하거나(프로젝트 &gt; GIS 레이어 추가), 절점 속성에 소블록을 입력하세요.
</div>
) : (
<>
<div>블록 {reports.length}개 · 블록 지정 절점 {assigned}/{Object.keys(model.nodes).length}{!result && ' · 해석 결과가 없어 흐름 방향은 표시하지 않습니다'}</div>
<div style={{ maxHeight: 420, overflowY: 'auto', border: '1px solid var(--border)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>
<th style={{ ...th, textAlign: 'left' }}>블록</th><th style={th}>절점</th><th style={th}>열린 경계</th><th style={th}>실제 흐름</th><th style={{ ...th, textAlign: 'left' }}>판정</th>
</tr></thead>
<tbody>
{reports.map((r) => (
<React.Fragment key={r.block}>
<tr style={{ cursor: 'pointer' }} onClick={() => setOpen(open === r.block ? null : r.block)}>
<td style={{ ...td, textAlign: 'left' }}>{open === r.block ? '▾' : '▸'} {r.block}</td>
<td style={td}>{r.nodeCount}</td>
<td style={td}>{r.openCount}/{r.boundary.length}</td>
<td style={td}>{result ? r.activeFlows : '-'}</td>
<td style={{ ...td, textAlign: 'left', fontFamily: 'inherit', color: statusColor(r) }}>{BLOCK_STATUS_LABEL[r.status]}</td>
</tr>
{open === r.block && (
<tr><td colSpan={5} style={{ padding: '4px 8px', background: 'var(--bg-subtle)' }}>
<button className="btn btn-secondary" style={{ height: 20, fontSize: 'var(--fs-2xs)', marginBottom: 4 }}
onClick={() => onSelectElements([], r.boundary.map((b) => b.id))}>경계 링크 지도에서 선택</button>
{r.boundary.map((b) => (
<div key={b.id} style={{ display: 'flex', gap: 6, cursor: 'pointer' }} onClick={() => onFocus('link', b.id)}>
<span style={{ minWidth: 70 }}>{b.kind === 'pipe' ? '관로' : b.kind === 'valve' ? '밸브' : '펌프'} {b.id}</span>
<span>↔ {b.otherBlock || '(블록 없음)'}</span>
<span style={{ color: b.closed ? 'var(--text-muted)' : 'var(--crit)' }}>{b.closed ? '닫힘' : '열림'}</span>
{b.direction && <span>{b.direction === 'in' ? '유입' : b.direction === 'out' ? '유출' : b.direction === 'both' ? '양방향' : '흐름 없음'}{b.maxFlow !== undefined && b.direction !== 'none' ? ` 최대 ${fmt(units.flow(b.maxFlow), 1)} ${displayUnits.flow}` : ''}</span>}
</div>
))}
</td></tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
</>
)}
</div>
);
};
// ---------------------------------------------------------------- 관망 도구
const ToolsSection: React.FC<AnalysisPanelProps> = ({ model, crs, selectedNodeIds, selectedLinkIds, units, displayUnits, onApplyModel }) => {
const [opts, setOpts] = useState<EditOptions>(() => loadEditOptions());
const setOpt = (patch: Partial<EditOptions>) => {
const next = { ...opts, ...patch };
setOpts(next);
saveEditOptions(next);
};
const selectedPipes = selectedLinkIds.filter((id) => model.pipes[id]);
const recalc = (ids?: string[]) => {
if (crs === 'LOCAL' && !window.confirm('좌표계가 "로컬 평면"이라 좌표 거리가 실제 길이가 아닙니다. 그래도 좌표 거리로 관로 연장을 바꿀까요? (실행 취소로 되돌릴 수 있습니다)')) return;
const { model: next, changes, isPhysical } = recalcPipeLengths(model, crs, ids);
const total = changes.reduce((a, c) => a + Math.abs(c.after - c.before), 0);
onApplyModel(
next,
changes.length
? `관로 ${changes.length}개 연장을 좌표로 다시 계산했습니다 (변화 합계 ${units.length(total).toFixed(1)} ${displayUnits.length})${isPhysical ? '' : ' - 로컬 좌표 기준'}.`
: '좌표와 다른 관로 연장이 없습니다.'
);
};
const fillElev = () => {
const { model: next, filled } = fillZeroElevations(model);
onApplyModel(next, filled.length ? `표고가 0인 절점 ${filled.length}개에 가장 가까운 절점의 표고를 넣었습니다.` : '표고가 0인 절점이 없습니다.');
};
const box: React.CSSProperties = { border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: 8, display: 'flex', flexDirection: 'column', gap: 6 };
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
<div style={box}>
<b>편집 옵션</b>
<label style={{ display: 'flex', gap: 6 }}>
<input type="checkbox" checked={opts.autoLength} onChange={(e) => setOpt({ autoLength: e.target.checked })} />
관로길이 자동계산 (절점을 옮기면 연결 관로 연장을 다시 계산, 로컬 좌표계에서는 적용 안 함)
</label>
<label style={{ display: 'flex', gap: 6 }}>
<input type="checkbox" checked={opts.nearestElevation} onChange={(e) => setOpt({ nearestElevation: e.target.checked })} />
가장 가까운 절점의 표고로 입력 (새 절점)
</label>
{(opts.autoLength !== DEFAULT_EDIT_OPTIONS.autoLength || opts.nearestElevation !== DEFAULT_EDIT_OPTIONS.nearestElevation) && (
<span style={{ color: 'var(--text-muted)' }}>기본값과 다르게 설정되어 있습니다.</span>
)}
</div>
<div style={box}>
<b>관로길이 재계산</b>
<span style={{ color: 'var(--text-secondary)' }}>절점 좌표와 꺾임점([VERTICES])을 따라 연장을 다시 잽니다. 좌표계가 맞아야 실제 거리가 됩니다.</span>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-secondary" onClick={() => recalc()} style={{ height: 26 }}>전체 관로</button>
<button className="btn btn-secondary" disabled={!selectedPipes.length} onClick={() => recalc(selectedPipes)} style={{ height: 26 }}>선택 관로 ({selectedPipes.length})</button>
</div>
</div>
<div style={box}>
<b>표고 보완</b>
<span style={{ color: 'var(--text-secondary)' }}>표고가 0인 절점·탱크에 가장 가까운 절점의 표고를 넣습니다. DEM을 불러왔다면 DEM 표고 입력이 더 정확합니다.</span>
<button className="btn btn-secondary" onClick={fillElev} style={{ height: 26, alignSelf: 'flex-start' }}>표고 0인 절점 채우기</button>
</div>
<LabelsBox model={model} selectedNodeIds={selectedNodeIds} onApplyModel={onApplyModel} box={box} />
<div style={{ color: 'var(--text-muted)' }}>모든 도구는 실행 취소(Ctrl+Z)할 수 있습니다.</div>
</div>
);
};
/** 지도 라벨 ([LABELS]): text objects saved in the INP and drawn on the map. */
const LabelsBox: React.FC<{ model: NetworkModel; selectedNodeIds: string[]; onApplyModel: AnalysisPanelProps['onApplyModel']; box: React.CSSProperties }> = ({ model, selectedNodeIds, onApplyModel, box }) => {
const labels = model.extras?.labels ?? [];
const [text, setText] = useState('');
const anchorNode = selectedNodeIds.map((id) => model.nodes[id]).find(Boolean);
const add = () => {
if (!anchorNode || !text.trim()) return;
onApplyModel(setLabels(model, [...labels, { x: anchorNode.x, y: anchorNode.y, text: text.trim() }]), `지도 라벨 "${text.trim()}"을(를) 절점 ${anchorNode.id} 위치에 추가했습니다.`);
setText('');
};
return (
<div style={box}>
<b>지도 라벨 ({labels.length})</b>
<span style={{ color: 'var(--text-secondary)' }}>INP의 [LABELS]로 저장되어 EPANET에서도 보이는 글자입니다. 절점을 하나 선택하고 그 위치에 추가합니다.</span>
<div style={{ display: 'flex', gap: 6 }}>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder={anchorNode ? `${anchorNode.id} 위치에 넣을 글자` : '먼저 지도에서 절점 선택'} style={{ flex: 1, height: 26 }} onKeyDown={(e) => { if (e.key === 'Enter') add(); }} />
<button className="btn btn-secondary" disabled={!anchorNode || !text.trim()} onClick={add} style={{ height: 26 }}>추가</button>
</div>
{labels.length > 0 && (
<div style={{ maxHeight: 160, overflow: 'auto', display: 'flex', flexDirection: 'column', gap: 2 }}>
{labels.map((l, i) => (
<div key={i} style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input key={`${i}:${l.text}`} defaultValue={l.text} style={{ flex: 1, height: 22, fontSize: 11 }} aria-label={`라벨 ${i + 1}`}
onBlur={(e) => { const v = e.target.value.trim(); if (v && v !== l.text) onApplyModel(setLabels(model, labels.map((x, k) => (k === i ? { ...x, text: v } : x))), `지도 라벨을 "${v}"(으)로 바꿨습니다.`); }} />
<button className="btn btn-ghost" style={{ padding: 2 }} aria-label={`라벨 ${i + 1} 삭제`} onClick={() => onApplyModel(setLabels(model, labels.filter((_, k) => k !== i)), `지도 라벨 "${l.text}"을(를) 지웠습니다.`)}><X size={11} /></button>
</div>
))}
</div>
)}
</div>
);
};
+531
View File
@@ -0,0 +1,531 @@
// 분석 tab, part 2: 관로 중요도, 급수 구역(수원 추적), 흐름 방향, VSP, 등압선,
// 에너지 비용, 관로 통계. Server jobs go through runJob; results are cached in
// App state like the other analysis sections.
import React, { useEffect, useMemo, useState } from 'react';
import { Play, Crosshair, Download } from 'lucide-react';
import type { NetworkModel } from './inpModel';
import { generateInp } from './inpModel';
import { runJob } from './api';
import { modelToLngLat } from './crs';
import type { Feature, FeatureCollection } from 'geojson';
import type { MapAnnotation, UserGisLayer } from './types';
import type { AnalysisPanelProps } from './AnalysisPanel';
import {
analyzeFlowDirection,
contourLevels,
idwGrid,
isolineSegments,
joinSegments,
niceInterval,
pipeGroupKey,
pipeReport,
type PipeGroupKey,
} from './analysisTools';
export interface AnalysisSectionsExtraProps {
onAddUserLayer?: (layer: UserGisLayer) => void;
onAnnotations?: (annotations: MapAnnotation[] | null) => void;
}
type Props = AnalysisPanelProps & AnalysisSectionsExtraProps;
const th: React.CSSProperties = { padding: '4px 5px', textAlign: 'right', fontWeight: 600, background: 'var(--bg-subtle)', position: 'sticky', top: 0 };
const td: React.CSSProperties = { padding: '3px 5px', textAlign: 'right', borderBottom: '1px solid var(--border)', fontFamily: 'var(--font-mono)' };
const tdl: React.CSSProperties = { ...td, textAlign: 'left' };
const inputStyle: React.CSSProperties = { height: 24, fontSize: 'var(--fs-xs)', width: '100%' };
const col: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' };
const note: React.CSSProperties = { color: 'var(--text-secondary)', lineHeight: 1.5 };
const fmt = (v: number | null | undefined, d = 2) => (v === null || v === undefined || !Number.isFinite(v) ? '-' : v.toFixed(d));
const PALETTE = ['#2563eb', '#dc2626', '#16a34a', '#9333ea', '#ea580c', '#0891b2', '#ca8a04', '#db2777'];
function hoursOf(model: NetworkModel, key: string, fallback: number): number {
const raw = model.options.timeEntries.find((e) => e.key.toUpperCase() === key)?.value?.trim();
if (!raw) return fallback;
const [num, unit = ''] = raw.split(/\s+/);
if (num.includes(':')) {
const [h, m = '0', s = '0'] = num.split(':');
return Number(h) + Number(m) / 60 + Number(s) / 3600;
}
const v = Number(num);
if (!Number.isFinite(v)) return fallback;
const u = unit.toUpperCase();
return u.startsWith('MIN') ? v / 60 : u.startsWith('SEC') ? v / 3600 : u.startsWith('DAY') ? v * 24 : v;
}
function downloadText(name: string, text: string, type = 'text/csv;charset=utf-8') {
const blob = new Blob([type.startsWith('text/csv') ? '' + text : text], { type });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name;
a.click();
URL.revokeObjectURL(a.href);
}
function useJob<T>(cache: Props['cache'], setCache: Props['setCache'], key: string) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const run = async (name: string, model: NetworkModel, params: Record<string, unknown>) => {
setError(null);
setBusy(true);
try {
setCache(key, await runJob<T>(name, generateInp(model), params));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
return { busy, error, setError, run, result: cache[key] as T | undefined };
}
// ---------------------------------------------------------------- 관로 중요도
interface CriticalityResult {
windowLabel: string;
threshold: number;
belowBefore: number;
totalDemand: number;
links: Array<{ id: string; affectedNodes: number; affectedDemand: number; minPressure: number | null; maxDrop: number | null; nodes: string[]; warning?: boolean; disconnected?: boolean; error?: string }>;
}
export const CriticalitySection: React.FC<Props> = ({ model, units, displayUnits, selectedLinkIds, minPressureThreshold, cache, setCache, onFocus, onSelectElements }) => {
const job = useJob<CriticalityResult>(cache, setCache, 'critResult');
const selectedPipes = selectedLinkIds.filter((id) => model.pipes[id]);
const [scope, setScope] = useState<'all' | 'selected'>(selectedPipes.length ? 'selected' : 'all');
const [threshold, setThreshold] = useState(String(minPressureThreshold));
const [start, setStart] = useState('0');
const [end, setEnd] = useState(String(hoursOf(model, 'DURATION', 24) || 24));
const r = job.result;
const p = units.pressure;
const flow = units.flow;
const count = scope === 'selected' ? selectedPipes.length : Object.keys(model.pipes).length;
const exportCsv = () => {
if (!r) return;
const lines = ['관로,영향 절점 수,영향 수요,최저 압력,최대 하락,단절,영향 절점'];
for (const l of r.links) lines.push([l.id, l.affectedNodes, flow(l.affectedDemand), l.disconnected ? '' : fmt(l.minPressure === null ? null : p(l.minPressure)), fmt(l.maxDrop === null ? null : p(l.maxDrop)), l.disconnected ? 'Y' : '', `"${l.nodes.join(' ')}"`].join(','));
downloadText('관로중요도.csv', lines.join('\r\n'));
};
return (
<div style={col}>
<div style={note}>
관로를 하나씩 닫고 다시 해석해, 최저압력 기준 아래로 떨어지는 절점과 그 수요량으로 관로의 중요도를 매깁니다. 관로 수만큼 해석하므로 큰 관망은 수십 초 걸립니다.
</div>
<div style={{ display: 'flex', gap: 10 }}>
<label><input type="radio" checked={scope === 'all'} onChange={() => setScope('all')} /> 전체 관로 ({Object.keys(model.pipes).length})</label>
<label><input type="radio" checked={scope === 'selected'} disabled={!selectedPipes.length} onChange={() => setScope('selected')} /> 선택 관로 ({selectedPipes.length})</label>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 6 }}>
<label>최저압력 기준 ({displayUnits.pressure})<input type="number" value={threshold} onChange={(e) => setThreshold(e.target.value)} style={inputStyle} /></label>
<label>시작 (시)<input type="number" value={start} onChange={(e) => setStart(e.target.value)} style={inputStyle} /></label>
<label>종료 (시)<input type="number" value={end} onChange={(e) => setEnd(e.target.value)} style={inputStyle} /></label>
</div>
<button className="btn btn-primary" style={{ height: 28 }} disabled={job.busy || !count}
onClick={() => void job.run('criticality', model, { links: scope === 'selected' ? selectedPipes : [], threshold: units.toPressure(Number(threshold)), start: Number(start), end: Number(end) })}>
<Play size={12} />{job.busy ? `해석 중... (관로 ${count}개)` : `관로 중요도 분석 (${count}개)`}
</button>
{job.error && <div style={{ color: 'var(--crit)' }}>{job.error}</div>}
{r && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ ...note, flex: 1 }}>
시간대 {r.windowLabel} · 기준 {fmt(p(r.threshold), 1)} {displayUnits.pressure} · 평상시 미달 {r.belowBefore}곳 · 영향 있는 관로 <b>{r.links.filter((l) => l.affectedNodes > 0).length}</b>/{r.links.length}
</span>
<button className="btn btn-secondary" style={{ height: 22 }} onClick={exportCsv}><Download size={11} />CSV</button>
</div>
<div style={{ maxHeight: 360, overflow: 'auto', border: '1px solid var(--border)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr><th style={{ ...th, textAlign: 'left' }}>관로</th><th style={th}>영향 절점</th><th style={th}>영향 수요 ({displayUnits.flow})</th><th style={th}>최저 압력</th><th style={th}>최대 하락</th></tr></thead>
<tbody>
{r.links.slice(0, 300).map((l) => (
<tr key={l.id} style={{ cursor: 'pointer' }} title="누르면 이 관로와 영향 절점을 선택합니다"
onClick={() => { onFocus('link', l.id); onSelectElements(l.nodes, [l.id]); }}>
<td style={tdl}>{l.id}</td>
<td style={td}>{l.affectedNodes}</td>
<td style={td}>{fmt(flow(l.affectedDemand), 1)}</td>
<td style={{ ...td, color: l.disconnected ? 'var(--crit)' : undefined }}>{l.error ? '오류' : l.disconnected ? '단절' : fmt(l.minPressure === null ? null : p(l.minPressure), 1)}</td>
<td style={td}>{l.disconnected ? '-' : fmt(l.maxDrop === null ? null : p(l.maxDrop), 1)}</td>
</tr>
))}
</tbody>
</table>
</div>
<span style={{ color: 'var(--text-muted)' }}>"단절"은 관로를 닫으면 물이 가지 못하는 절점이 생긴다는 뜻입니다. 행을 누르면 영향 절점이 지도에서 선택됩니다.</span>
</>
)}
</div>
);
};
// ---------------------------------------------------------------- 급수 구역
interface ServiceAreaResult {
sources: string[];
windowLabel: string;
nodes: Record<string, { source: string | null; share: number; shares: Record<string, number> }>;
summary: Array<{ source: string; nodes: number; mixed: number }>;
unreached: number;
notes: string[];
}
export const ServiceAreaSection: React.FC<Props> = ({ model, cache, setCache, onSelectElements, onAnnotations }) => {
const job = useJob<ServiceAreaResult>(cache, setCache, 'saResult');
const [includeTanks, setIncludeTanks] = useState(false);
const [showMap, setShowMap] = useState(false);
const r = job.result;
const color = (s: string | null) => (s && r ? PALETTE[r.sources.indexOf(s) % PALETTE.length] : '#64748b');
const toggleMap = (on: boolean) => {
setShowMap(on);
if (!onAnnotations) return;
if (!on || !r) return onAnnotations(null);
onAnnotations(Object.entries(r.nodes).filter(([id, v]) => v.source && model.nodes[id]?.type === 'JUNCTION').map(([id, v]) => ({ type: 'node' as const, id, text: `${v.source} ${v.share.toFixed(0)}%`, color: color(v.source) })));
};
const nodesOf = (s: string | null) => (r ? Object.entries(r.nodes).filter(([, v]) => v.source === s).map(([id]) => id) : []);
// Map labels belong to this view: clear them when leaving it.
useEffect(() => () => onAnnotations?.(null), [onAnnotations]);
return (
<div style={col}>
<div style={note}>
EPANET 수원 추적(TRACE)으로 각 절점의 물이 어느 배수지에서 몇 % 오는지 계산해 급수 구역을 나눕니다. 추적은 72시간 이상 돌려 마지막 24시간을 평균합니다.
</div>
<label><input type="checkbox" checked={includeTanks} onChange={(e) => setIncludeTanks(e.target.checked)} /> 탱크도 수원으로 추적 (탱크를 거친 물이 배수지 몫과 겹쳐 합계가 100%를 넘을 수 있음)</label>
<button className="btn btn-primary" style={{ height: 28 }} disabled={job.busy} onClick={() => { toggleMap(false); void job.run('servicearea', model, { includeTanks }); }}>
<Play size={12} />{job.busy ? '추적 중...' : '급수 구역 분석'}
</button>
{job.error && <div style={{ color: 'var(--crit)' }}>{job.error}</div>}
{r && (
<>
{r.notes.map((n) => <div key={n} style={{ color: 'var(--text-muted)' }}>{n}</div>)}
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr><th style={{ ...th, textAlign: 'left' }}>수원</th><th style={th}>주 급수 절점</th><th style={th}>혼합(80% 미만)</th><th style={th} /></tr></thead>
<tbody>
{r.summary.map((s) => (
<tr key={s.source}>
<td style={tdl}><span style={{ color: color(s.source) }}>●</span> {s.source}</td>
<td style={td}>{s.nodes}</td>
<td style={td}>{s.mixed}</td>
<td style={td}><button className="btn btn-ghost" style={{ height: 20, fontSize: 11 }} onClick={() => onSelectElements(nodesOf(s.source), [])}><Crosshair size={10} />선택</button></td>
</tr>
))}
{r.unreached > 0 && (
<tr>
<td style={tdl}>도달 안 됨</td><td style={td}>{r.unreached}</td><td style={td} />
<td style={td}><button className="btn btn-ghost" style={{ height: 20, fontSize: 11 }} onClick={() => onSelectElements(nodesOf(null), [])}><Crosshair size={10} />선택</button></td>
</tr>
)}
</tbody>
</table>
{onAnnotations && <label><input type="checkbox" checked={showMap} onChange={(e) => toggleMap(e.target.checked)} /> 지도에 절점별 주 수원·비율 표시</label>}
{r.unreached > 0 && <span style={{ color: 'var(--text-muted)' }}>"도달 안 됨"은 평균 0.5% 미만으로, 탱크에서만 공급받거나 물이 거의 흐르지 않는 절점입니다. 탱크 포함으로 다시 확인하세요.</span>}
</>
)}
</div>
);
};
// ---------------------------------------------------------------- 흐름 방향
export const FlowDirectionSection: React.FC<Props> = ({ result, units, displayUnits, onFocus, onSelectElements }) => {
const rows = useMemo(() => (result ? analyzeFlowDirection(result) : []), [result]);
if (!result) return <div style={note}>수리해석을 먼저 실행하세요.</div>;
const reversing = rows.filter((r) => r.reversals > 0);
const noFlow = rows.filter((r) => r.dominant === 0);
return (
<div style={col}>
<div style={note}>해석 기간 동안 흐름 방향이 바뀌는 관로(역류)와 흐름이 전혀 없는 관로를 찾습니다. 탁수·체류 관리와 밸브 조작 검토에 씁니다.</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<span style={{ flex: 1 }}>방향이 바뀌는 관로 <b>{reversing.length}</b>개 · 흐름 없음 {noFlow.length}개</span>
<button className="btn btn-secondary" style={{ height: 22 }} disabled={!reversing.length} onClick={() => onSelectElements([], reversing.map((r) => r.id))}><Crosshair size={11} />역류 관로 선택</button>
<button className="btn btn-secondary" style={{ height: 22 }} disabled={!noFlow.length} onClick={() => onSelectElements([], noFlow.map((r) => r.id))}><Crosshair size={11} />무흐름 관로 선택</button>
</div>
{reversing.length > 0 && (
<div style={{ maxHeight: 360, overflow: 'auto', border: '1px solid var(--border)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr><th style={{ ...th, textAlign: 'left' }}>관로</th><th style={th}>방향 전환 횟수</th><th style={th}>역방향 시간 비율</th><th style={th}>최대 유량 ({displayUnits.flow})</th></tr></thead>
<tbody>
{reversing.slice(0, 500).map((r) => (
<tr key={r.id} style={{ cursor: 'pointer' }} onClick={() => onFocus('link', r.id)}>
<td style={tdl}>{r.id}</td><td style={td}>{r.reversals}</td><td style={td}>{fmt(r.reversedShare, 0)}%</td><td style={td}>{fmt(units.flow(r.maxAbsFlow), 2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
};
// ---------------------------------------------------------------- VSP
interface VspResult {
pump: string;
node: string;
target: number;
rows: Array<{ hour: number; speed?: number; pressure?: number; flow?: number; power?: number; status?: 'ok' | 'max' | 'min'; error?: string }>;
note: string;
}
export const VspSection: React.FC<Props> = ({ model, units, displayUnits, selectedNodeIds, cache, setCache, onApplyModel }) => {
const job = useJob<VspResult>(cache, setCache, 'vspResult');
const pumps = Object.keys(model.pumps);
const [pump, setPump] = useState(pumps[0] ?? '');
const [node, setNode] = useState('');
const [target, setTarget] = useState('20');
const [minS, setMinS] = useState('0.5');
const [maxS, setMaxS] = useState('1.2');
const r = job.result;
const patternStepH = hoursOf(model, 'PATTERN TIMESTEP', 1);
const durationH = hoursOf(model, 'DURATION', 24);
if (!pumps.length) return <div style={note}>모델에 펌프가 없습니다.</div>;
const apply = () => {
if (!r) return;
const speeds = r.rows.map((row) => row.speed ?? 1);
const pid = `VSP_${r.pump}`.slice(0, 31);
const pumpRec = model.pumps[r.pump];
if (!pumpRec) return;
const params = pumpRec.parameters.replace(/\s+PATTERN\s+\S+/i, '').trim() + ` PATTERN ${pid}`;
onApplyModel(
{ ...model, patterns: { ...model.patterns, [pid]: { id: pid, description: `VSP ${r.node} 목표압력 ${r.target}`, multipliers: speeds } }, pumps: { ...model.pumps, [r.pump]: { ...pumpRec, parameters: params } } },
`펌프 ${r.pump}에 시간별 속도 패턴 ${pid}(${speeds.length}개)를 적용했습니다.`
);
};
return (
<div style={col}>
<div style={note}>제어 절점이 목표 압력이 되도록 시간별 펌프 회전수(상대 속도)를 이분법으로 찾습니다. 시간마다 정상상태로 풀며 탱크는 초기 수위입니다.</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
<label>펌프<select value={pump} onChange={(e) => setPump(e.target.value)} style={inputStyle}>{pumps.map((p) => <option key={p} value={p}>{p}</option>)}</select></label>
<label>제어 절점
<div style={{ display: 'flex', gap: 4 }}>
<input value={node} onChange={(e) => setNode(e.target.value)} placeholder="절점 ID" style={inputStyle} />
<button className="btn btn-ghost" style={{ height: 24, padding: '0 4px' }} disabled={!selectedNodeIds.length} onClick={() => setNode(selectedNodeIds[0])} title="선택 절점"><Crosshair size={11} /></button>
</div>
</label>
<label>목표 압력 ({displayUnits.pressure})<input type="number" value={target} onChange={(e) => setTarget(e.target.value)} style={inputStyle} /></label>
<label>속도 범위 (최소~최대)
<div style={{ display: 'flex', gap: 4 }}>
<input type="number" step="0.05" value={minS} onChange={(e) => setMinS(e.target.value)} style={inputStyle} />
<input type="number" step="0.05" value={maxS} onChange={(e) => setMaxS(e.target.value)} style={inputStyle} />
</div>
</label>
</div>
<button className="btn btn-primary" style={{ height: 28 }} disabled={job.busy || !node.trim() || !model.nodes[node.trim()]}
onClick={() => void job.run('vsp', model, {
pump, node: node.trim(), target: units.toPressure(Number(target)), minSpeed: Number(minS), maxSpeed: Number(maxS),
hours: Array.from({ length: Math.max(1, Math.round((durationH || 24) / patternStepH)) }, (_, k) => k * patternStepH),
})}>
<Play size={12} />{job.busy ? '계산 중...' : 'VSP 속도 계산'}
</button>
{job.error && <div style={{ color: 'var(--crit)' }}>{job.error}</div>}
{r && (
<>
<div style={{ maxHeight: 320, overflow: 'auto', border: '1px solid var(--border)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr><th style={{ ...th, textAlign: 'left' }}>시각</th><th style={th}>속도</th><th style={th}>압력</th><th style={th}>유량</th><th style={th}>전력 (kW)</th><th style={th}>상태</th></tr></thead>
<tbody>
{r.rows.map((row) => (
<tr key={row.hour}>
<td style={tdl}>{Math.floor(row.hour)}:{String(Math.round((row.hour % 1) * 60)).padStart(2, '0')}</td>
<td style={td}>{fmt(row.speed, 3)}</td>
<td style={td}>{row.pressure !== undefined ? fmt(units.pressure(row.pressure), 1) : '-'}</td>
<td style={td}>{row.flow !== undefined ? fmt(units.flow(row.flow), 1) : '-'}</td>
<td style={td}>{fmt(row.power, 1)}</td>
<td style={{ ...td, color: row.status === 'ok' ? undefined : 'var(--warn, #b45309)' }}>{row.error ? '오류' : row.status === 'max' ? '최대에도 부족' : row.status === 'min' ? '최소에도 초과' : '목표 달성'}</td>
</tr>
))}
</tbody>
</table>
</div>
<span style={{ color: 'var(--text-muted)' }}>{r.note}</span>
<button className="btn btn-secondary" style={{ height: 26, alignSelf: 'flex-start' }} onClick={apply} title="속도들을 패턴으로 만들어 펌프의 PATTERN으로 지정합니다 (실행 취소 가능)">
속도 패턴으로 펌프에 적용
</button>
</>
)}
</div>
);
};
// ---------------------------------------------------------------- 등압선
type ContourVar = 'pressure' | 'head' | 'elevation';
type ContourStat = 'min' | 'mean' | 'max' | 'now';
export const ContourSection: React.FC<Props> = ({ model, result, crs, units, displayUnits, onAddUserLayer }) => {
const [variable, setVariable] = useState<ContourVar>('pressure');
const [stat, setStat] = useState<ContourStat>('min');
const [interval, setStep] = useState('');
const [made, setMade] = useState<{ fc: FeatureCollection; levels: number; name: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const conv = variable === 'pressure' ? units.pressure : units.head;
const unit = variable === 'pressure' ? displayUnits.pressure : displayUnits.head;
const points = useMemo(() => {
const out: Array<{ x: number; y: number; v: number }> = [];
for (const n of Object.values(model.nodes)) {
if (n.type !== 'JUNCTION') continue;
let v: number | undefined;
if (variable === 'elevation') v = n.elevation;
else {
const nr = result?.nodes[n.id];
if (!nr) continue;
const s = nr.timeSeries?.[variable];
if (stat === 'now' || !s?.length) v = nr[variable];
else v = stat === 'min' ? Math.min(...s) : stat === 'max' ? Math.max(...s) : s.reduce((a, b) => a + b, 0) / s.length;
}
if (v !== undefined && Number.isFinite(v)) out.push({ x: n.x, y: n.y, v: conv(v) });
}
return out;
}, [model, result, variable, stat, conv]);
const range = useMemo(() => {
let lo = Infinity, hi = -Infinity;
for (const p of points) { lo = Math.min(lo, p.v); hi = Math.max(hi, p.v); }
return [lo, hi] as const;
}, [points]);
const make = () => {
setError(null);
try {
const step = Number(interval) > 0 ? Number(interval) : niceInterval(range[0], range[1]);
const grid = idwGrid(points, 140);
const ext = (() => {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of Object.values(model.nodes)) { minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); minY = Math.min(minY, n.y); maxY = Math.max(maxY, n.y); }
return { minX, minY, maxX, maxY };
})();
const features: Feature[] = [];
const levels = contourLevels(range[0], range[1], step);
for (const level of levels) {
for (const line of joinSegments(isolineSegments(grid, level))) {
if (line.length < 2) continue;
features.push({ type: 'Feature', properties: { level, label: `${level} ${unit}` }, geometry: { type: 'LineString', coordinates: line.map(([x, y]) => modelToLngLat(x, y, crs, ext)) } });
}
}
if (!features.length) throw new Error('등치선이 만들어지지 않았습니다. 간격을 줄여 보세요.');
const name = `등치선 ${variable === 'pressure' ? '압력' : variable === 'head' ? '동수두' : '표고'}${variable === 'elevation' ? '' : ` (${{ min: '최소', mean: '평균', max: '최대', now: '현재' }[stat]})`} ${step}${unit}`;
setMade({ fc: { type: 'FeatureCollection', features }, levels: levels.length, name });
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
return (
<div style={col}>
<div style={note}>절점 값을 역거리 가중(IDW)으로 격자 보간해 등치선을 만들고, GIS 레이어로 지도에 올리거나 GeoJSON으로 저장합니다. 관망에서 먼 빈 곳은 그리지 않습니다.</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 6 }}>
<label>항목<select value={variable} onChange={(e) => { setVariable(e.target.value as ContourVar); setMade(null); }} style={inputStyle}>
<option value="pressure">압력</option><option value="head">동수두</option><option value="elevation">표고</option>
</select></label>
<label>시간 통계<select value={stat} disabled={variable === 'elevation'} onChange={(e) => { setStat(e.target.value as ContourStat); setMade(null); }} style={inputStyle}>
<option value="min">최소</option><option value="mean">평균</option><option value="max">최대</option><option value="now">기본 결과값</option>
</select></label>
<label>간격 ({unit})<input type="number" value={interval} onChange={(e) => setStep(e.target.value)} placeholder={Number.isFinite(range[0]) ? String(niceInterval(range[0], range[1])) : '자동'} style={inputStyle} /></label>
</div>
<div style={{ color: 'var(--text-muted)' }}>값 {points.length}개 · 범위 {fmt(range[0], 1)} ~ {fmt(range[1], 1)} {unit}{variable !== 'elevation' && !result ? ' · 수리해석 결과가 필요합니다' : ''}</div>
<button className="btn btn-primary" style={{ height: 28 }} disabled={points.length < 3} onClick={make}><Play size={12} />등치선 만들기</button>
{error && <div style={{ color: 'var(--crit)' }}>{error}</div>}
{made && (
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ flex: 1 }}>{made.name}: 선 {made.fc.features.length}개 · 등급 {made.levels}개</span>
{onAddUserLayer && (
<button className="btn btn-secondary" style={{ height: 24 }} onClick={() => onAddUserLayer({
id: `contour-${Date.now()}`, name: made.name, fileName: `${made.name}.geojson`, featureCollection: made.fc, featureCount: made.fc.features.length,
geomTypes: ['LineString'], estimatedCrs: 'EPSG:4326', visible: true, opacity: 0.9, color: '#7c3aed', createdAt: new Date().toISOString(),
})}>지도 레이어로 추가</button>
)}
<button className="btn btn-secondary" style={{ height: 24 }} onClick={() => downloadText(`${made.name}.geojson`, JSON.stringify(made.fc), 'application/geo+json')}><Download size={11} />GeoJSON</button>
</div>
)}
</div>
);
};
// ---------------------------------------------------------------- 에너지 비용
interface EnergyResult {
pumps: Array<{ id: string; utilization: number; efficiency: number | null; kwh: number; kwhPerM3: number | null; volume: number; avgKw: number; peakKw: number; cost: number; costPerDay: number }>;
hours: number;
globalPrice: number;
demandCharge: number;
peakKw: number;
demandCost: number;
totalCost: number;
steady: boolean;
}
export const EnergySection: React.FC<Props> = ({ model, cache, setCache }) => {
const job = useJob<EnergyResult>(cache, setCache, 'energyResult');
const r = job.result;
if (!Object.keys(model.pumps).length) return <div style={note}>모델에 펌프가 없습니다.</div>;
const exportCsv = () => {
if (!r) return;
const lines = ['펌프,가동률(%),평균효율(%),전력량(kWh),원단위(kWh/m3),양수량(m3),평균kW,최대kW,전력비,일평균 전력비'];
for (const p of r.pumps) lines.push([p.id, p.utilization, p.efficiency ?? '', p.kwh, p.kwhPerM3 ?? '', p.volume, p.avgKw, p.peakKw, p.cost, p.costPerDay].join(','));
lines.push(`최대 수요 요금,,,,,,,${r.peakKw},${r.demandCost},`, `합계,,,,,,,,${r.totalCost},`);
downloadText('에너지보고서.csv', lines.join('\r\n'));
};
return (
<div style={col}>
<div style={note}>펌프별 가동률·효율·전력량과 원단위(kWh/m³), [ENERGY]의 단가·단가 패턴·최대 수요 요금을 반영한 전력비를 해석의 모든 시간 간격에 걸쳐 적분합니다. 단가는 모델 설정의 "에너지·수질 반응 옵션"과 펌프의 "추가 속성"에서 넣습니다.</div>
<button className="btn btn-primary" style={{ height: 28 }} disabled={job.busy} onClick={() => void job.run('energy', model, {})}><Play size={12} />{job.busy ? '계산 중...' : '에너지 보고서 계산'}</button>
{job.error && <div style={{ color: 'var(--crit)' }}>{job.error}</div>}
{r && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ ...note, flex: 1 }}>기간 {fmt(r.hours, 1)}시간{r.steady ? ' (정상상태: 1시간 기준)' : ''} · 전역 단가 {r.globalPrice} · 최대 동시 전력 {fmt(r.peakKw, 1)} kW · 총 전력비 <b>{fmt(r.totalCost, 2)}</b>{r.demandCharge ? ` (수요 요금 ${fmt(r.demandCost, 2)} 포함)` : ''}</span>
<button className="btn btn-secondary" style={{ height: 22 }} onClick={exportCsv}><Download size={11} />CSV</button>
</div>
<div style={{ overflow: 'auto', border: '1px solid var(--border)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>{['펌프', '가동률 %', '효율 %', 'kWh', 'kWh/m³', '평균 kW', '최대 kW', '전력비', '일 전력비'].map((h, i) => <th key={h} style={i ? th : { ...th, textAlign: 'left' }}>{h}</th>)}</tr></thead>
<tbody>
{r.pumps.map((p) => (
<tr key={p.id}>
<td style={tdl}>{p.id}</td><td style={td}>{fmt(p.utilization, 1)}</td><td style={td}>{fmt(p.efficiency, 1)}</td><td style={td}>{fmt(p.kwh, 1)}</td>
<td style={td}>{fmt(p.kwhPerM3, 3)}</td><td style={td}>{fmt(p.avgKw, 1)}</td><td style={td}>{fmt(p.peakKw, 1)}</td><td style={td}>{fmt(p.cost, 2)}</td><td style={td}>{fmt(p.costPerDay, 2)}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
);
};
// ---------------------------------------------------------------- 관로 통계
const GROUP_LABEL: Record<PipeGroupKey, string> = { diameter: '관경', material: '관재질', installDecade: '설치 연대', block: '소블록', pipeKind: '관로 구분', roughness: '조도계수' };
export const PipeReportSection: React.FC<Props> = ({ model, result, units, displayUnits, onSelectElements }) => {
const [by, setBy] = useState<PipeGroupKey>('diameter');
const rep = useMemo(() => pipeReport(model, by, result), [model, by, result]);
const keyLabel = (k: string) => (by === 'diameter' && k !== '(미입력)' ? `${fmt(units.diameter(Number(k)), 0)} ${displayUnits.diameter}` : k);
const idsOf = (k: string) => Object.keys(model.pipes).filter((id) => pipeGroupKey(model, id, by) === k);
const exportCsv = () => {
const lines = [`${GROUP_LABEL[by]},관로 수,연장(${displayUnits.length}),비율(%),최대 유속(${displayUnits.velocity}),평균 경과년수`];
for (const r of rep.rows) lines.push([keyLabel(r.key), r.count, fmt(units.length(r.length), 1), fmt(r.share, 1), r.maxVelocity !== undefined ? fmt(units.velocity(r.maxVelocity), 2) : '', r.meanAge !== undefined ? fmt(r.meanAge, 1) : ''].join(','));
downloadText(`관로통계_${GROUP_LABEL[by]}.csv`, lines.join('\r\n'));
};
return (
<div style={col}>
<div style={note}>관로를 관경·재질·설치 연대·소블록별로 묶어 개수·연장·비율과 최대 유속, 평균 경과년수를 봅니다. 재질·설치년도·소블록은 자산 속성(속성 테이블)에서 옵니다.</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<label style={{ flex: 1 }}>구분<select value={by} onChange={(e) => setBy(e.target.value as PipeGroupKey)} style={inputStyle}>
{(Object.keys(GROUP_LABEL) as PipeGroupKey[]).map((k) => <option key={k} value={k}>{GROUP_LABEL[k]}</option>)}
</select></label>
<button className="btn btn-secondary" style={{ height: 24, alignSelf: 'flex-end' }} onClick={exportCsv}><Download size={11} />CSV</button>
</div>
<div style={{ color: 'var(--text-secondary)' }}>관로 {rep.count}개 · 총 연장 {fmt(units.length(rep.totalLength), 1)} {displayUnits.length}</div>
<div style={{ maxHeight: 380, overflow: 'auto', border: '1px solid var(--border)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr><th style={{ ...th, textAlign: 'left' }}>{GROUP_LABEL[by]}</th><th style={th}>개수</th><th style={th}>연장 ({displayUnits.length})</th><th style={th}>비율</th><th style={th}>최대 유속</th><th style={th}>경과년수</th></tr></thead>
<tbody>
{rep.rows.map((r) => (
<tr key={r.key} style={{ cursor: 'pointer' }} title="누르면 이 그룹의 관로를 선택합니다" onClick={() => onSelectElements([], idsOf(r.key))}>
<td style={tdl}>{keyLabel(r.key)}</td><td style={td}>{r.count}</td><td style={td}>{fmt(units.length(r.length), 1)}</td><td style={td}>{fmt(r.share, 1)}%</td>
<td style={td}>{r.maxVelocity !== undefined ? fmt(units.velocity(r.maxVelocity), 2) : '-'}</td><td style={td}>{r.meanAge !== undefined ? fmt(r.meanAge, 1) : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
+2194
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+65
View File
@@ -0,0 +1,65 @@
// 백업 기록: list the automatic/manual backups, restore one (undoable) or download it.
import React, { useEffect, useState } from 'react';
import { X } from 'lucide-react';
import { deleteBackup, listBackups, MAX_BACKUPS, type BackupRecord } from './backupStore';
interface Props {
onRestore: (rec: BackupRecord) => void;
onBackupNow: () => Promise<void>;
onClose: () => void;
}
export const BackupModal: React.FC<Props> = ({ onRestore, onBackupNow, onClose }) => {
const [items, setItems] = useState<BackupRecord[] | null>(null);
const [error, setError] = useState<string | null>(null);
const reload = () => listBackups().then(setItems).catch((e) => setError(e instanceof Error ? e.message : String(e)));
useEffect(() => { void reload(); }, []);
const download = (b: BackupRecord) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([b.inp], { type: 'text/plain;charset=utf-8' }));
a.download = `${b.fileName.replace(/\.[^.]+$/, '')}_백업_${b.savedAt.slice(0, 16).replace(/[-:T]/g, '')}.inp`;
a.click();
URL.revokeObjectURL(a.href);
};
return (
<div role="dialog" aria-modal="true" aria-label="백업 기록" style={{ position: 'fixed', inset: 0, background: 'rgba(15,23,42,.35)', zIndex: 3000, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={onClose}>
<div onClick={(e) => e.stopPropagation()} style={{ width: 'min(640px, 96vw)', maxHeight: '86vh', background: 'var(--bg-panel)', borderRadius: 8, boxShadow: '0 12px 40px rgba(0,0,0,.25)', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', alignItems: 'center', padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
<b style={{ flex: 1 }}>백업 기록</b>
<button className="btn btn-ghost" onClick={onClose} aria-label="닫기"><X size={14} /></button>
</div>
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 10, fontSize: 12, overflow: 'auto' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ flex: 1, color: 'var(--text-secondary)' }}>
모델이 수정된 상태면 10분마다 이 브라우저에 자동으로 백업합니다 (최근 {MAX_BACKUPS}개). 복원은 실행 취소(Ctrl+Z)로 되돌릴 수 있습니다.
</span>
<button className="btn btn-secondary" style={{ height: 26 }} onClick={() => void onBackupNow().then(reload)}>지금 백업</button>
</div>
{error && <div style={{ color: 'var(--crit)' }}>{error}</div>}
{items && !items.length && <div style={{ color: 'var(--text-muted)' }}>백업이 아직 없습니다.</div>}
{items && items.length > 0 && (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>{['시각', '파일', '규모', '', ''].map((h, i) => <th key={i} style={{ textAlign: 'left', padding: '4px 6px', background: 'var(--bg-subtle)' }}>{h}</th>)}</tr></thead>
<tbody>
{items.map((b) => (
<tr key={b.id} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '4px 6px' }}>{new Date(b.savedAt).toLocaleString('ko-KR')}{b.reason === 'manual' ? ' (수동)' : ''}</td>
<td style={{ padding: '4px 6px' }}>{b.fileName}</td>
<td style={{ padding: '4px 6px' }}>절점 {b.nodes} · 링크 {b.links}</td>
<td style={{ padding: '4px 6px' }}>
<button className="btn btn-secondary" style={{ height: 22, fontSize: 11 }} onClick={() => { onRestore(b); onClose(); }}>복원</button>{' '}
<button className="btn btn-ghost" style={{ height: 22, fontSize: 11 }} onClick={() => download(b)}>.inp</button>
</td>
<td style={{ padding: '4px 6px' }}>
<button className="btn btn-ghost" style={{ height: 22, fontSize: 11 }} aria-label="백업 삭제" onClick={() => void deleteBackup(b.id).then(reload)}><X size={11} /></button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
);
};
+734
View File
@@ -0,0 +1,734 @@
import React, { useState } from 'react';
import {
Trash2,
Copy,
X,
Check,
Move,
Layers,
ArrowRight,
Sliders,
Maximize2,
} from 'lucide-react';
import type { NetworkModel, InpNode, InpPipe } from './inpModel';
import type { MultiSelectionState, SelectionTarget } from './types';
import {
m3DayToFlow,
metersToLength,
millimetersToDiameter,
type DisplayUnitMode,
} from './unitSystem';
export interface BatchPropertyEditorProps {
model: NetworkModel;
multiSelection: MultiSelectionState;
selectedTarget: SelectionTarget;
displayUnitMode?: DisplayUnitMode;
onUpdatePipes: (pipeIds: string[], updates: Partial<InpPipe>) => void;
onUpdateNodes: (nodeIds: string[], updater: (node: InpNode) => Partial<InpNode>) => void;
onMoveNodes: (nodeIds: string[], dx: number, dy: number) => void;
onDeleteSelected: () => void;
onClearSelection: () => void;
onSelectTarget: (target: SelectionTarget) => void;
onCopyClipboard?: () => void;
}
export const BatchPropertyEditor: React.FC<BatchPropertyEditorProps> = ({
model,
multiSelection,
selectedTarget,
displayUnitMode = 'korean',
onUpdatePipes,
onUpdateNodes,
onMoveNodes,
onDeleteSelected,
onClearSelection,
onSelectTarget,
onCopyClipboard,
}) => {
// Extract all currently selected node IDs and pipe IDs
const selectedNodeIds = Array.from(
new Set([
...multiSelection.nodes,
...(selectedTarget?.type === 'node' ? [selectedTarget.id] : []),
])
);
const selectedPipeIds = Array.from(
new Set([
...multiSelection.links,
...(selectedTarget?.type === 'link' ? [selectedTarget.id] : []),
])
);
// Pipe batch state
const [batchDiameter, setBatchDiameter] = useState<string>('');
const [batchRoughness, setBatchRoughness] = useState<string>('');
const [batchStatus, setBatchStatus] = useState<string>('OPEN');
const [batchMinorLoss, setBatchMinorLoss] = useState<string>('');
const [batchTag, setBatchTag] = useState<string>('');
const [batchDesc, setBatchDesc] = useState<string>('');
// Node batch state
const [batchElevMode, setBatchElevMode] = useState<'set' | 'offset'>('offset');
const [batchElevValue, setBatchElevValue] = useState<string>('');
const [batchDemandMode, setBatchDemandMode] = useState<'set' | 'mult'>('mult');
const [batchDemandValue, setBatchDemandValue] = useState<string>('1.0');
const [batchPattern, setBatchPattern] = useState<string>('');
// Move offset state
const [moveDx, setMoveDx] = useState<string>('0');
const [moveDy, setMoveDy] = useState<string>('0');
// Success flash messages
const [flashMsg, setFlashMsg] = useState<string | null>(null);
const notify = (msg: string) => {
setFlashMsg(msg);
setTimeout(() => setFlashMsg(null), 2500);
};
// 1. Pipe actions
// The batch editor's inputs are always entered in the fixed Korean/metric
// display units shown in its labels (mm / m / m³/day), regardless of the
// app-wide displayUnitMode toggle, so they always go through the display ->
// model conversion (never a raw pass-through).
const applyDiameter = (diamMm: number) => {
if (!Number.isFinite(diamMm) || diamMm <= 0) return;
const sourceUnits = model.options.units || 'LPS';
const internalDiam = millimetersToDiameter(diamMm, sourceUnits);
onUpdatePipes(selectedPipeIds, { diameter: internalDiam });
notify(`관경 D${diamMm} 일괄 적용 (${selectedPipeIds.length}개 관로)`);
};
const applyRoughness = (roughness: number) => {
if (!Number.isFinite(roughness) || roughness <= 0) return;
onUpdatePipes(selectedPipeIds, { roughness });
notify(`조도계수 C=${roughness} 일괄 적용 (${selectedPipeIds.length}개 관로)`);
};
const applyStatus = (status: 'OPEN' | 'CLOSED' | 'CV') => {
onUpdatePipes(selectedPipeIds, { status });
notify(`관로 상태 '${status}' 일괄 적용 (${selectedPipeIds.length}개 관로)`);
};
const applyMinorLoss = () => {
const val = parseFloat(batchMinorLoss);
if (!Number.isFinite(val) || val < 0) return;
onUpdatePipes(selectedPipeIds, { minorLoss: val });
notify(`미소손실계수 ${val} 일괄 적용 (${selectedPipeIds.length}개 관로)`);
};
const applyTagDesc = () => {
const updates: Partial<InpPipe> = {};
if (batchTag.trim()) updates.tag = batchTag.trim();
if (batchDesc.trim()) updates.description = batchDesc.trim();
if (Object.keys(updates).length === 0) return;
onUpdatePipes(selectedPipeIds, updates);
notify(`태그/설명 일괄 적용 (${selectedPipeIds.length}개 관로)`);
};
// 2. Node actions
const applyElevation = () => {
const val = parseFloat(batchElevValue);
if (!Number.isFinite(val)) return;
const sourceUnits = model.options.units || 'LPS';
// batchElevValue is always entered in meters (display); convert the
// set-value / offset-delta into the model's native length unit (m or ft).
const modelVal = metersToLength(val, sourceUnits);
onUpdateNodes(selectedNodeIds, (node) => {
if (batchElevMode === 'set') {
return { elevation: modelVal };
} else {
return { elevation: Math.round((node.elevation + modelVal) * 100) / 100 };
}
});
notify(
batchElevMode === 'set'
? `지반고 ${val}m 일괄 설정 (${selectedNodeIds.length}개 절점)`
: `지반고 ${val >= 0 ? `+${val}` : val}m 일괄 증감 (${selectedNodeIds.length}개 절점)`
);
};
const applyDemand = () => {
const val = parseFloat(batchDemandValue);
if (!Number.isFinite(val)) return;
const sourceUnits = model.options.units || 'LPS';
onUpdateNodes(selectedNodeIds, (node) => {
if (batchDemandMode === 'set') {
// batchDemandValue (set mode) is entered in m³/day (display);
// convert into the model's native flow unit before storing.
return { demand: m3DayToFlow(val, sourceUnits) };
} else {
// Multiplier is dimensionless; applies directly to the stored value.
const curDemand = node.demand ?? 0;
return { demand: Math.round(curDemand * val * 1000) / 1000 };
}
});
notify(
batchDemandMode === 'set'
? `기본수요량 ${val} m³/day 일괄 설정 (${selectedNodeIds.length}개 절점)`
: `기본수요량 ×${val}배 일괄 곱셈 (${selectedNodeIds.length}개 절점)`
);
};
const applyPattern = () => {
if (!batchPattern) return;
onUpdateNodes(selectedNodeIds, () => ({ pattern: batchPattern }));
notify(`수요 패턴 '${batchPattern}' 일괄 지정 (${selectedNodeIds.length}개 절점)`);
};
const applyMoveOffset = () => {
const dx = parseFloat(moveDx) || 0;
const dy = parseFloat(moveDy) || 0;
if (dx === 0 && dy === 0) return;
onMoveNodes(selectedNodeIds, dx, dy);
notify(`절점 ${selectedNodeIds.length}개 일괄 이동 완료 (ΔX: ${dx}, ΔY: ${dy})`);
};
const patterns = Object.keys(model.patterns || {});
return (
<div
style={{
background: 'var(--bg-panel)',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border-strong)',
overflow: 'hidden',
fontSize: '12px',
boxShadow: 'var(--shadow-sm)',
}}
>
{/* Header with Element Counts & Global Multi Actions */}
<div
style={{
padding: '10px 12px',
background: 'var(--bg-subtle)',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: '6px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<Layers size={15} style={{ color: 'var(--accent)' }} />
<strong style={{ color: 'var(--text)', fontSize: '13px' }}>
다중 선택 일괄 편집기
</strong>
<span
style={{
fontSize: '11px',
padding: '1px 6px',
borderRadius: '10px',
background: 'var(--accent-wash)',
color: 'var(--accent)',
fontWeight: 600,
}}
>
총 {selectedNodeIds.length + selectedPipeIds.length}개
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
{onCopyClipboard && (
<button
type="button"
className="btn btn-secondary"
onClick={onCopyClipboard}
title="선택된 요소 복사 (Ctrl+C)"
style={{ height: '24px', padding: '0 6px', fontSize: '11px', display: 'inline-flex', alignItems: 'center', gap: '3px' }}
>
<Copy size={11} />
복사
</button>
)}
<button
type="button"
onClick={onDeleteSelected}
className="btn btn-danger"
title="선택된 요소 일괄 삭제"
style={{
height: '24px',
padding: '0 6px',
fontSize: '11px',
display: 'inline-flex',
alignItems: 'center',
gap: '3px',
background: 'var(--crit-wash)',
color: 'var(--crit)',
border: '1px solid var(--crit)',
}}
>
<Trash2 size={11} />
삭제
</button>
<button
type="button"
className="btn btn-secondary"
onClick={onClearSelection}
title="전체 선택 해제 (ESC)"
style={{ height: '24px', padding: '0 6px', fontSize: '11px' }}
>
<X size={11} />
</button>
</div>
</div>
{/* Toast Flash Notice */}
{flashMsg && (
<div
style={{
padding: '6px 12px',
background: 'var(--good-wash)',
color: 'var(--good)',
borderBottom: '1px solid var(--good)',
fontSize: '11px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<Check size={13} />
{flashMsg}
</div>
)}
{/* Selected Items ID Chips Preview */}
<div
style={{
padding: '6px 12px',
background: 'var(--bg-app)',
borderBottom: '1px solid var(--border)',
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
maxHeight: '75px',
overflowY: 'auto',
}}
>
{selectedNodeIds.map((id) => (
<span
key={`node-chip-${id}`}
onClick={() => onSelectTarget({ type: 'node', id })}
style={{
cursor: 'pointer',
fontSize: '10px',
padding: '1px 5px',
borderRadius: '3px',
background: selectedTarget?.type === 'node' && selectedTarget.id === id ? 'var(--accent)' : 'var(--accent-wash)',
color: selectedTarget?.type === 'node' && selectedTarget.id === id ? '#fff' : 'var(--accent)',
fontFamily: 'var(--font-mono)',
}}
title="클릭 시 대표 요소로 포커스"
>
절점 {id}
</span>
))}
{selectedPipeIds.map((id) => (
<span
key={`pipe-chip-${id}`}
onClick={() => onSelectTarget({ type: 'link', id })}
style={{
cursor: 'pointer',
fontSize: '10px',
padding: '1px 5px',
borderRadius: '3px',
background: selectedTarget?.type === 'link' && selectedTarget.id === id ? 'var(--good)' : 'var(--good-wash)',
color: selectedTarget?.type === 'link' && selectedTarget.id === id ? '#fff' : 'var(--good)',
fontFamily: 'var(--font-mono)',
}}
title="클릭 시 대표 요소로 포커스"
>
관로 {id}
</span>
))}
</div>
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* ======================================================== */}
{/* 1. PIPE BATCH EDIT CARD */}
{/* ======================================================== */}
{selectedPipeIds.length > 0 && (
<div
style={{
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-sm)',
padding: '10px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, color: 'var(--text)' }}>
관로 일괄 속성 변경 ({selectedPipeIds.length}개 관로)
</span>
</div>
{/* Pipe Diameter */}
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '3px' }}>
관경 (Diameter mm)
</label>
<div style={{ display: 'flex', gap: '4px', marginBottom: '4px' }}>
<input
type="number"
step="10"
value={batchDiameter}
onChange={(e) => setBatchDiameter(e.target.value)}
placeholder="예: 150"
style={{ width: '100%', padding: '3px 6px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
<button
type="button"
className="btn btn-secondary"
disabled={!batchDiameter}
onClick={() => applyDiameter(parseFloat(batchDiameter))}
style={{ whiteSpace: 'nowrap', padding: '0 8px', fontSize: '11px' }}
>
적용
</button>
</div>
<div style={{ display: 'flex', gap: '3px', flexWrap: 'wrap' }}>
{[80, 100, 150, 200, 250, 300, 400, 500].map((d) => (
<button
key={`diam-${d}`}
type="button"
onClick={() => {
setBatchDiameter(String(d));
applyDiameter(d);
}}
style={{
padding: '2px 5px',
fontSize: '10px',
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: '3px',
cursor: 'pointer',
fontFamily: 'var(--font-mono)',
}}
>
D{d}
</button>
))}
</div>
</div>
{/* Roughness Presets */}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '6px' }}>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '3px' }}>
Hazen-Williams 조도계수 (Roughness C)
</label>
<div style={{ display: 'flex', gap: '4px', marginBottom: '4px' }}>
<input
type="number"
step="5"
value={batchRoughness}
onChange={(e) => setBatchRoughness(e.target.value)}
placeholder="예: 130"
style={{ width: '100%', padding: '3px 6px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
<button
type="button"
className="btn btn-secondary"
disabled={!batchRoughness}
onClick={() => applyRoughness(parseFloat(batchRoughness))}
style={{ whiteSpace: 'nowrap', padding: '0 8px', fontSize: '11px' }}
>
적용
</button>
</div>
<div style={{ display: 'flex', gap: '3px', flexWrap: 'wrap' }}>
{[
{ name: '주철관 (100)', val: 100 },
{ name: '강관 (110)', val: 110 },
{ name: '닥타일주철 (130)', val: 130 },
{ name: 'PVC/PE (140)', val: 140 },
].map((preset) => (
<button
key={`rough-${preset.val}`}
type="button"
onClick={() => {
setBatchRoughness(String(preset.val));
applyRoughness(preset.val);
}}
style={{
padding: '2px 5px',
fontSize: '10px',
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: '3px',
cursor: 'pointer',
}}
>
{preset.name}
</button>
))}
</div>
</div>
{/* Pipe Status */}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '6px' }}>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '3px' }}>
관로 개폐 상태 (Status)
</label>
<div style={{ display: 'flex', gap: '4px' }}>
<button
type="button"
onClick={() => applyStatus('OPEN')}
className="btn btn-secondary"
style={{ flex: 1, padding: '4px', fontSize: '11px', color: 'var(--good)' }}
>
전체 열림 (OPEN)
</button>
<button
type="button"
onClick={() => applyStatus('CLOSED')}
className="btn btn-secondary"
style={{ flex: 1, padding: '4px', fontSize: '11px', color: 'var(--crit)' }}
>
전체 닫힘 (CLOSED)
</button>
<button
type="button"
onClick={() => applyStatus('CV')}
className="btn btn-secondary"
style={{ flex: 1, padding: '4px', fontSize: '11px', color: 'var(--accent)' }}
>
체크밸브 (CV)
</button>
</div>
</div>
{/* Minor Loss & Tag */}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '6px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }}>
<div>
<label style={{ display: 'block', fontSize: '10px', color: 'var(--text-muted)' }}>미소손실 K</label>
<div style={{ display: 'flex', gap: '2px' }}>
<input
type="number"
step="0.1"
value={batchMinorLoss}
onChange={(e) => setBatchMinorLoss(e.target.value)}
placeholder="0.0"
style={{ width: '100%', padding: '2px 4px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '3px' }}
/>
<button type="button" className="btn btn-secondary" onClick={applyMinorLoss} style={{ padding: '0 4px', fontSize: '10px' }}>
적용
</button>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '10px', color: 'var(--text-muted)' }}>설명/태그</label>
<div style={{ display: 'flex', gap: '2px' }}>
<input
type="text"
value={batchTag}
onChange={(e) => setBatchTag(e.target.value)}
placeholder="배수관로"
style={{ width: '100%', padding: '2px 4px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '3px' }}
/>
<button type="button" className="btn btn-secondary" onClick={applyTagDesc} style={{ padding: '0 4px', fontSize: '10px' }}>
적용
</button>
</div>
</div>
</div>
</div>
)}
{/* ======================================================== */}
{/* 2. NODE BATCH EDIT CARD */}
{/* ======================================================== */}
{selectedNodeIds.length > 0 && (
<div
style={{
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-sm)',
padding: '10px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, color: 'var(--text)' }}>
절점 일괄 속성 변경 ({selectedNodeIds.length}개 절점)
</span>
</div>
{/* Node Elevation */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '3px' }}>
<label style={{ fontSize: '11px', color: 'var(--text-secondary)' }}>
지반고 (Elevation m)
</label>
<div style={{ display: 'flex', gap: '6px', fontSize: '10px' }}>
<label style={{ cursor: 'pointer' }}>
<input
type="radio"
name="batchElevMode"
checked={batchElevMode === 'offset'}
onChange={() => setBatchElevMode('offset')}
/>{' '}
증감 가감 (±)
</label>
<label style={{ cursor: 'pointer' }}>
<input
type="radio"
name="batchElevMode"
checked={batchElevMode === 'set'}
onChange={() => setBatchElevMode('set')}
/>{' '}
값 고정
</label>
</div>
</div>
<div style={{ display: 'flex', gap: '4px' }}>
<input
type="number"
step="0.5"
value={batchElevValue}
onChange={(e) => setBatchElevValue(e.target.value)}
placeholder={batchElevMode === 'offset' ? '+1.5 또는 -0.5' : '30.0'}
style={{ width: '100%', padding: '3px 6px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
<button
type="button"
className="btn btn-secondary"
disabled={!batchElevValue}
onClick={applyElevation}
style={{ whiteSpace: 'nowrap', padding: '0 8px', fontSize: '11px' }}
>
적용
</button>
</div>
</div>
{/* Node Demand */}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '6px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '3px' }}>
<label style={{ fontSize: '11px', color: 'var(--text-secondary)' }}>
기본수요량 (Base Demand)
</label>
<div style={{ display: 'flex', gap: '6px', fontSize: '10px' }}>
<label style={{ cursor: 'pointer' }}>
<input
type="radio"
name="batchDemandMode"
checked={batchDemandMode === 'mult'}
onChange={() => setBatchDemandMode('mult')}
/>{' '}
배율 곱셈 (×)
</label>
<label style={{ cursor: 'pointer' }}>
<input
type="radio"
name="batchDemandMode"
checked={batchDemandMode === 'set'}
onChange={() => setBatchDemandMode('set')}
/>{' '}
값 고정
</label>
</div>
</div>
<div style={{ display: 'flex', gap: '4px' }}>
<input
type="number"
step="0.1"
value={batchDemandValue}
onChange={(e) => setBatchDemandValue(e.target.value)}
placeholder={batchDemandMode === 'mult' ? '1.2 (20% 증가)' : '100.0'}
style={{ width: '100%', padding: '3px 6px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
<button
type="button"
className="btn btn-secondary"
disabled={!batchDemandValue}
onClick={applyDemand}
style={{ whiteSpace: 'nowrap', padding: '0 8px', fontSize: '11px' }}
>
적용
</button>
</div>
</div>
{/* Demand Pattern */}
{patterns.length > 0 && (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '6px' }}>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '3px' }}>
수요 패턴 (Pattern ID)
</label>
<div style={{ display: 'flex', gap: '4px' }}>
<select
value={batchPattern}
onChange={(e) => setBatchPattern(e.target.value)}
style={{ width: '100%', padding: '3px 6px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="">패턴 선택...</option>
{patterns.map((patId) => (
<option key={patId} value={patId}>
{patId} {model.patterns[patId]?.description ? `(${model.patterns[patId].description})` : ''}
</option>
))}
</select>
<button
type="button"
className="btn btn-secondary"
disabled={!batchPattern}
onClick={applyPattern}
style={{ whiteSpace: 'nowrap', padding: '0 8px', fontSize: '11px' }}
>
적용
</button>
</div>
</div>
)}
{/* CAD Move Offset */}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '6px' }}>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '3px' }}>
절점 위치 일괄 평행이동 (CAD Move Offset)
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: '4px' }}>
<div>
<input
type="number"
step="1"
value={moveDx}
onChange={(e) => setMoveDx(e.target.value)}
placeholder="ΔX (m)"
style={{ width: '100%', padding: '3px 6px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
</div>
<div>
<input
type="number"
step="1"
value={moveDy}
onChange={(e) => setMoveDy(e.target.value)}
placeholder="ΔY (m)"
style={{ width: '100%', padding: '3px 6px', fontSize: '11px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
</div>
<button
type="button"
className="btn btn-secondary"
onClick={applyMoveOffset}
style={{ whiteSpace: 'nowrap', padding: '0 8px', fontSize: '11px', display: 'inline-flex', alignItems: 'center', gap: '3px' }}
>
<Move size={11} />
이동
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
};
+172
View File
@@ -0,0 +1,172 @@
import React, { useEffect, useRef } from 'react';
import { Activity, Table, X } from 'lucide-react';
export type BottomPanelTab = 'table' | 'eps';
interface BottomPanelProps {
activeTab: BottomPanelTab | null;
height: number;
epsAvailable: boolean;
onToggleTab: (tab: BottomPanelTab) => void;
onHeightChange: (height: number) => void;
tableContent: React.ReactNode;
epsContent: React.ReactNode;
}
export const BottomPanel: React.FC<BottomPanelProps> = ({
activeTab,
height,
epsAvailable,
onToggleTab,
onHeightChange,
tableContent,
epsContent,
}) => {
const dragRef = useRef<{ startY: number; startHeight: number } | null>(null);
useEffect(() => {
const handleMove = (event: PointerEvent) => {
const drag = dragRef.current;
if (!drag) return;
const next = Math.min(
Math.max(drag.startHeight + drag.startY - event.clientY, 160),
Math.floor(window.innerHeight * 0.7)
);
onHeightChange(next);
};
const handleUp = () => {
dragRef.current = null;
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleUp);
return () => {
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
};
}, [onHeightChange]);
if (!activeTab) return null;
return (
<section
aria-label="하단 패널"
style={{
position: 'absolute',
left: 44,
right: 0,
bottom: 0,
height,
minHeight: 160,
maxHeight: '70vh',
zIndex: 300,
display: 'flex',
flexDirection: 'column',
background: 'var(--bg-panel)',
borderTop: '1px solid var(--border-strong)',
boxShadow: '0 -4px 16px rgba(15, 23, 42, 0.12)',
}}
>
<div
role="separator"
aria-orientation="horizontal"
aria-label="하단 패널 높이 조절"
onPointerDown={(event) => {
dragRef.current = { startY: event.clientY, startHeight: height };
document.body.style.cursor = 'ns-resize';
document.body.style.userSelect = 'none';
event.currentTarget.setPointerCapture(event.pointerId);
}}
style={{
position: 'absolute',
top: -4,
left: 0,
right: 0,
height: 8,
cursor: 'ns-resize',
zIndex: 2,
}}
/>
<header
style={{
flex: '0 0 34px',
display: 'flex',
alignItems: 'stretch',
justifyContent: 'space-between',
background: 'var(--bg-subtle)',
borderBottom: '1px solid var(--border)',
}}
>
<div style={{ display: 'flex', alignItems: 'stretch' }}>
<button
onClick={() => onToggleTab('table')}
style={{
minWidth: 116,
border: 'none',
borderBottom: activeTab === 'table' ? '2px solid var(--accent)' : '2px solid transparent',
background: 'transparent',
color: activeTab === 'table' ? 'var(--text)' : 'var(--text-secondary)',
fontSize: 'var(--fs-xs)',
fontWeight: activeTab === 'table' ? 600 : 500,
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--s-2)',
}}
title="요소 테이블 열기/닫기 (Ctrl+T)"
>
<Table size={13} />
요소 테이블
</button>
{epsAvailable && (
<button
onClick={() => onToggleTab('eps')}
style={{
minWidth: 116,
border: 'none',
borderBottom: activeTab === 'eps' ? '2px solid var(--accent)' : '2px solid transparent',
background: 'transparent',
color: activeTab === 'eps' ? 'var(--text)' : 'var(--text-secondary)',
fontSize: 'var(--fs-xs)',
fontWeight: activeTab === 'eps' ? 600 : 500,
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--s-2)',
}}
title="EPS 시간 컨트롤러 열기/닫기"
>
<Activity size={13} />
EPS 시간
</button>
)}
</div>
<button
onClick={() => onToggleTab(activeTab)}
style={{
width: 34,
border: 'none',
background: 'transparent',
color: 'var(--text-muted)',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
title="하단 패널 닫기"
aria-label="하단 패널 닫기"
>
<X size={14} />
</button>
</header>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', padding: activeTab === 'table' ? 0 : 'var(--s-4)' }}>
{activeTab === 'table' ? tableContent : epsContent}
</div>
</section>
);
};
+295
View File
@@ -0,0 +1,295 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Upload, FileText, Download } from 'lucide-react';
import type { SimulationResult } from './types';
import {
CALIBRATION_PARAMS,
buildCalibrationReport,
formatCalibrationReport,
parseCalibrationFile,
type CalibrationParam,
type Observation,
} from './calibration';
export interface CalibrationData {
fileName: string;
param: CalibrationParam;
observations: Observation[];
skippedLines: number[];
}
interface CalibrationPanelProps {
result: SimulationResult;
data: CalibrationData | null;
onDataChange: (data: CalibrationData | null) => void;
/** Raw engine value -> display units, per parameter. */
toDisplay: Record<CalibrationParam, (v: number) => number>;
units: Record<CalibrationParam, string>;
onFocusLocation: (type: 'node' | 'link', id: string) => void;
/** 지도에 값 보기: labels for the map (null = hide). */
onAnnotations?: (annotations: import('./types').MapAnnotation[] | null) => void;
}
const W = 320;
const fmt = (v: number) => (Math.abs(v) >= 100 ? v.toFixed(1) : v.toFixed(2));
const hhmm = (sec: number) => `${Math.floor(sec / 3600)}:${String(Math.floor((sec % 3600) / 60)).padStart(2, '0')}`;
function niceRange(values: number[]): [number, number] {
let lo = Math.min(...values);
let hi = Math.max(...values);
if (!Number.isFinite(lo)) return [0, 1];
if (hi === lo) { lo -= 1; hi += 1; }
const pad = (hi - lo) * 0.08;
return [lo - pad, hi + pad];
}
export const CalibrationPanel: React.FC<CalibrationPanelProps> = ({ result, data, onDataChange, toDisplay, units, onFocusLocation, onAnnotations }) => {
const fileRef = useRef<HTMLInputElement>(null);
const [selectedLoc, setSelectedLoc] = useState<string | null>(null);
const [showReport, setShowReport] = useState(false);
const [pickedParam, setPickedParam] = useState<CalibrationParam>(data?.param ?? 'pressure');
const param = data?.param ?? pickedParam;
const paramMeta = CALIBRATION_PARAMS.find((p) => p.id === param)!;
const unit = units[param];
const report = useMemo(
() => (data ? buildCalibrationReport(data.observations, data.param, result, toDisplay[data.param]) : null),
[data, result, toDisplay]
);
const activeLoc = selectedLoc && report?.locations.some((l) => l.location === selectedLoc)
? selectedLoc
: report?.locations[0]?.location ?? null;
const handleFile = async (file: File) => {
const parsed = parseCalibrationFile(await file.text());
onDataChange({ fileName: file.name, param, observations: parsed.observations, skippedLines: parsed.skippedLines });
setSelectedLoc(null);
};
const reportText = report ? formatCalibrationReport(report, paramMeta.label, unit) : '';
const [showOnMap, setShowOnMap] = useState(false);
useEffect(() => {
if (!onAnnotations) return;
if (!showOnMap || !report) {
onAnnotations(null);
return;
}
onAnnotations(
report.locations.map((l) => ({
type: paramMeta.element,
id: l.location,
text: `실측 ${fmt(l.observedMean)} / 계산 ${fmt(l.computedMean)}\n오차 ${fmt(l.computedMean - l.observedMean)} ${unit}`,
color: Math.abs(l.computedMean - l.observedMean) > Math.max(1e-9, 0.1 * Math.abs(l.observedMean)) ? '#b91c1c' : '#0f172a',
}))
);
}, [showOnMap, report, paramMeta.element, unit, onAnnotations]);
useEffect(() => () => onAnnotations?.(null), [onAnnotations]);
const downloadReport = () => {
const blob = new Blob(['' + reportText], { type: 'text/plain;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `calibration_${param}.txt`;
a.click();
URL.revokeObjectURL(a.href);
};
// ---- time-series chart for the selected location ----
const series = useMemo(() => {
if (!report || !activeLoc) return null;
const ts = result.timeSteps ?? [];
const raw = paramMeta.element === 'node'
? result.nodes[activeLoc]?.timeSeries?.[param as 'pressure' | 'head' | 'demand' | 'quality']
: result.links[activeLoc]?.timeSeries?.[param as 'flow' | 'velocity'];
const sim = (raw ?? []).map((v) => toDisplay[param](paramMeta.element === 'link' ? Math.abs(v) : v));
const obs = report.points.filter((p) => p.location === activeLoc);
return { ts, sim, obs };
}, [report, activeLoc, result, param, paramMeta.element, toDisplay]);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-5)', fontSize: 'var(--fs-xs)' }}>
<div style={{ display: 'flex', gap: 'var(--s-2)', alignItems: 'center', flexWrap: 'wrap' }}>
<select
value={param}
onChange={(e) => {
const next = e.target.value as CalibrationParam;
setPickedParam(next);
if (data) onDataChange({ ...data, param: next });
}}
style={{ height: 'var(--h-btn-sm)', fontSize: 'var(--fs-xs)' }}
aria-label="보정 항목"
>
{CALIBRATION_PARAMS.map((p) => <option key={p.id} value={p.id}>{p.label} ({units[p.id]})</option>)}
</select>
<button className="btn btn-secondary" onClick={() => fileRef.current?.click()} style={{ height: 'var(--h-btn-sm)', fontSize: 'var(--fs-xs)' }}>
<Upload size={12} /><span>실측 데이터 불러오기</span>
</button>
<input
ref={fileRef}
type="file"
accept=".dat,.csv,.txt"
style={{ display: 'none' }}
onChange={(e) => { const f = e.target.files?.[0]; if (f) void handleFile(f); e.target.value = ''; }}
/>
</div>
{!data ? (
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.6 }}>
실측값 파일(.dat / .csv / .txt)을 불러오면 해석값과 비교합니다. 한 줄에 <b>위치 ID, 시각, 값</b>을 적고,
시각은 시간(6, 6.5) 또는 시:분(6:30)으로 씁니다. 값은 화면에 표시된 단위로 입력합니다.
<pre style={{ margin: '6px 0 0', padding: '6px 8px', background: 'var(--bg-subtle)', borderRadius: 'var(--r-sm)', fontSize: '11px' }}>
{`;위치 시각 값
1265 0 52.3
6 48.1
1270 6:30 44.0`}
</pre>
</div>
) : !report || report.points.length === 0 ? (
<div style={{ color: 'var(--crit)' }}>
{data.fileName}: 비교할 수 있는 관측값이 없습니다.
{report?.unknownLocations.length ? ` 모델에 없는 위치: ${report.unknownLocations.slice(0, 8).join(', ')}` : ''}
{' '}항목({paramMeta.label})이 맞는지 확인하세요.
</div>
) : (
<>
<div style={{ color: 'var(--text-secondary)' }}>
{data.fileName} · 관측값 {report.network.count}개 · 위치 {report.locations.length}곳
{report.unknownLocations.length > 0 && ` · 모델에 없는 위치 ${report.unknownLocations.length}곳 제외`}
{report.outOfRange > 0 && ` · 해석 기간 밖 ${report.outOfRange}개 제외`}
{data.skippedLines.length > 0 && ` · 읽지 못한 줄 ${data.skippedLines.length}개`}
</div>
{/* Report table (EPANET calibration report columns) */}
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-mono)', fontSize: '11px' }}>
<thead>
<tr style={{ background: 'var(--bg-subtle)', textAlign: 'right' }}>
{['위치', '수', '관측 평균', '계산 평균', '평균 오차', 'RMS 오차'].map((h, i) => (
<th key={h} style={{ padding: '4px 5px', textAlign: i === 0 ? 'left' : 'right', fontWeight: 600, fontFamily: 'var(--font-sans)' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{report.locations.map((l) => (
<tr
key={l.location}
onClick={() => { setSelectedLoc(l.location); onFocusLocation(paramMeta.element, l.location); }}
style={{ cursor: 'pointer', background: l.location === activeLoc ? 'var(--accent-soft, #e0ecff)' : undefined, borderBottom: '1px solid var(--border)' }}
title="클릭하면 그래프와 지도 위치를 보여줍니다"
>
<td style={{ padding: '3px 5px' }}>{l.location}</td>
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{l.count}</td>
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(l.observedMean)}</td>
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(l.computedMean)}</td>
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(l.meanError)}</td>
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(l.rmsError)}</td>
</tr>
))}
<tr style={{ fontWeight: 700, background: 'var(--bg-subtle)' }}>
<td style={{ padding: '3px 5px' }}>전체</td>
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{report.network.count}</td>
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(report.network.observedMean)}</td>
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(report.network.computedMean)}</td>
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(report.network.meanError)}</td>
<td style={{ padding: '3px 5px', textAlign: 'right' }}>{fmt(report.network.rmsError)}</td>
</tr>
</tbody>
</table>
<div style={{ marginTop: 4, color: 'var(--text-secondary)' }}>
평균값 상관계수 R = {report.correlationOfMeans === null ? '-' : report.correlationOfMeans.toFixed(3)} ·
전체 상관계수 R = {report.correlationAll === null ? '-' : report.correlationAll.toFixed(3)} · 단위 {unit}
</div>
</div>
<label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="checkbox" checked={showOnMap} onChange={(e) => setShowOnMap(e.target.checked)} />
지도에 값 보기 (위치별 평균 실측·계산·오차, 오차 10% 초과는 빨간색)
</label>
<div style={{ display: 'flex', gap: 'var(--s-2)' }}>
<button className="btn btn-secondary" onClick={() => setShowReport((v) => !v)} style={{ height: 'var(--h-btn-sm)', fontSize: 'var(--fs-xs)' }}>
<FileText size={12} /><span>{showReport ? '리포트 닫기' : '리포트 보기'}</span>
</button>
<button className="btn btn-secondary" onClick={downloadReport} style={{ height: 'var(--h-btn-sm)', fontSize: 'var(--fs-xs)' }}>
<Download size={12} /><span>리포트 저장 (.txt)</span>
</button>
</div>
{showReport && (
<pre style={{ margin: 0, padding: 8, background: 'var(--bg-subtle)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', fontSize: '10.5px', overflowX: 'auto', whiteSpace: 'pre' }}>
{reportText}
</pre>
)}
<ScatterChart points={report.points} unit={unit} highlight={activeLoc} />
{series && activeLoc && (
<SeriesChart location={activeLoc} unit={unit} ts={series.ts} sim={series.sim} obs={series.obs} />
)}
</>
)}
</div>
);
};
/** Observed (x) vs computed (y) with the 1:1 line -- EPANET's correlation plot. */
const ScatterChart: React.FC<{ points: { location: string; observed: number; computed: number }[]; unit: string; highlight: string | null }> = ({ points, unit, highlight }) => {
const H = 240, pl = 40, pr = 10, pt = 18, pb = 30;
const [lo, hi] = niceRange(points.flatMap((p) => [p.observed, p.computed]));
const sx = (v: number) => pl + ((v - lo) / (hi - lo)) * (W - pl - pr);
const sy = (v: number) => H - pb - ((v - lo) / (hi - lo)) * (H - pt - pb);
const ticks = [0, 0.25, 0.5, 0.75, 1].map((f) => lo + f * (hi - lo));
return (
<svg width={W} height={H} role="img" aria-label="실측값과 계산값 상관도" style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)' }}>
<text x={pl} y={12} fontSize={11} fontWeight={600} fill="currentColor">실측 vs 계산 ({unit})</text>
{ticks.map((t) => (
<g key={t} fontSize={9} fill="#64748b">
<line x1={pl} x2={W - pr} y1={sy(t)} y2={sy(t)} stroke="#e2e8f0" />
<text x={pl - 4} y={sy(t) + 3} textAnchor="end">{fmt(t)}</text>
<text x={sx(t)} y={H - pb + 12} textAnchor="middle">{fmt(t)}</text>
</g>
))}
<line x1={sx(lo)} y1={sy(lo)} x2={sx(hi)} y2={sy(hi)} stroke="#94a3b8" strokeDasharray="4 3" />
{points.map((p, i) => (
<circle key={i} cx={sx(p.observed)} cy={sy(p.computed)} r={p.location === highlight ? 4 : 3}
fill={p.location === highlight ? '#dc2626' : '#2563eb'} fillOpacity={0.8} />
))}
<text x={(W + pl) / 2} y={H - 4} fontSize={9} textAnchor="middle" fill="#64748b">실측값</text>
<text x={10} y={(H - pb + pt) / 2} fontSize={9} textAnchor="middle" fill="#64748b" transform={`rotate(-90 10 ${(H - pb + pt) / 2})`}>계산값</text>
</svg>
);
};
/** Simulated series (line) with the measurements (dots) for one location. */
const SeriesChart: React.FC<{ location: string; unit: string; ts: number[]; sim: number[]; obs: { timeSec: number; observed: number }[] }> = ({ location, unit, ts, sim, obs }) => {
const H = 200, pl = 40, pr = 10, pt = 18, pb = 26;
const tMax = Math.max(ts[ts.length - 1] ?? 0, ...obs.map((o) => o.timeSec), 1);
const [lo, hi] = niceRange([...sim, ...obs.map((o) => o.observed)]);
const sx = (t: number) => pl + (t / tMax) * (W - pl - pr);
const sy = (v: number) => H - pb - ((v - lo) / (hi - lo)) * (H - pt - pb);
const path = sim.map((v, i) => `${i ? 'L' : 'M'}${sx(ts[i] ?? 0).toFixed(1)},${sy(v).toFixed(1)}`).join('');
const hourStep = tMax > 48 * 3600 ? 12 : tMax > 24 * 3600 ? 6 : 3;
const hourTicks: number[] = [];
for (let h = 0; h * 3600 <= tMax; h += hourStep) hourTicks.push(h * 3600);
return (
<svg width={W} height={H} role="img" aria-label={`${location} 시간별 실측값과 계산값`} style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)' }}>
<text x={pl} y={12} fontSize={11} fontWeight={600} fill="currentColor">{location} 시간별 ({unit})</text>
<g fontSize={9} fill="#64748b">
<line x1={W - 118} x2={W - 104} y1={9} y2={9} stroke="#2563eb" strokeWidth={2} />
<text x={W - 100} y={12}>계산</text>
<circle cx={W - 62} cy={9} r={3} fill="#dc2626" />
<text x={W - 55} y={12}>실측</text>
</g>
{[0, 0.5, 1].map((f) => {
const v = lo + f * (hi - lo);
return (
<g key={f} fontSize={9} fill="#64748b">
<line x1={pl} x2={W - pr} y1={sy(v)} y2={sy(v)} stroke="#e2e8f0" />
<text x={pl - 4} y={sy(v) + 3} textAnchor="end">{fmt(v)}</text>
</g>
);
})}
{hourTicks.map((t) => (
<text key={t} x={sx(t)} y={H - pb + 12} fontSize={9} textAnchor="middle" fill="#64748b">{hhmm(t)}</text>
))}
<path d={path} fill="none" stroke="#2563eb" strokeWidth={1.8} />
{obs.map((o, i) => <circle key={i} cx={sx(o.timeSec)} cy={sy(o.observed)} r={3.2} fill="#dc2626" />)}
</svg>
);
};
+505
View File
@@ -0,0 +1,505 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Copy,
Plus,
Maximize2,
Move,
Pencil,
Activity,
GitCommit,
Link,
Trash2,
Ruler,
FileCode,
ArrowRight,
ArrowLeftRight,
MapPin,
Mountain,
Scissors,
} from 'lucide-react';
import type { MapContextMenuEvent, ContextMenuTarget } from './types';
import { lngLatToModel, type CRS } from './crs';
import {
reverseGeocodeKakao,
lookupApproxElevation,
formatMapServiceError,
type ElevationResult,
type KakaoAddressResult,
} from './mapServices';
interface ContextMenuProps {
crs: CRS;
event: MapContextMenuEvent;
onClose: () => void;
onCopyText: (text: string, label: string) => void;
onAddNodeAtCoords: (x: number, y: number) => void;
onFitBounds: () => void;
onPanTo: (lngLat: [number, number]) => void;
onInspectTarget: (target: { type: 'node' | 'link'; id: string } | { type: 'gis-feature'; layerId: string; featureId: string | number }) => void;
onViewResults: (target: { type: 'node' | 'link'; id: string }) => void;
onStartPipeFromNode: (nodeId: string) => void;
onSelectConnectedLinks?: (nodeId: string) => void;
onSelectConnectedNodes?: (pipeId: string) => void;
onCalculatePipeLength?: (pipeId: string) => void;
onReversePipe?: (pipeId: string) => void;
onSplitPipe?: (pipeId: string) => void;
onDeleteNode?: (nodeId: string) => void;
onDeletePipe?: (pipeId: string) => void;
onZoomToLayer?: (layerId: string) => void;
canRunResults?: boolean;
}
interface MenuItem {
id: string;
label: string | React.ReactNode;
sublabel?: string | React.ReactNode;
icon: React.ReactNode;
disabled?: boolean;
disabledReason?: string;
danger?: boolean;
closeOnClick?: boolean; // Defaults to true
action: () => void | Promise<void>;
}
export const ContextMenu: React.FC<ContextMenuProps> = ({
crs,
event,
onClose,
onCopyText,
onAddNodeAtCoords,
onFitBounds,
onPanTo,
onInspectTarget,
onViewResults,
onStartPipeFromNode,
onSelectConnectedLinks,
onSelectConnectedNodes,
onSplitPipe,
onCalculatePipeLength,
onReversePipe,
onDeleteNode,
onDeletePipe,
onZoomToLayer,
canRunResults = false,
}) => {
const menuRef = useRef<HTMLDivElement>(null);
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
const [pos, setPos] = useState<{ x: number; y: number }>({ x: event.x, y: event.y });
// Async action states
const [addressState, setAddressState] = useState<{ loading: boolean; result?: KakaoAddressResult | null; error?: string }>({ loading: false });
const [elevationState, setElevationState] = useState<{ loading: boolean; result?: ElevationResult | null; error?: string }>({ loading: false });
const { target } = event;
// Build menu items based on target
const items: MenuItem[] = [];
let coordsHeader = null;
if (target.type === 'canvas') {
const [modelX, modelY] = target.modelCoords;
const [lng, lat] = target.lngLat;
const [epsg5179X, epsg5179Y] = lngLatToModel(lng, lat, 'EPSG:5179', { minX: 0, minY: 0, maxX: 0, maxY: 0 });
const allCoordsText = `지도 CRS (${crs}): ${modelX.toFixed(2)}, ${modelY.toFixed(2)}
WGS84: ${lng.toFixed(6)}, ${lat.toFixed(6)}
EPSG:5179: ${epsg5179X.toFixed(2)}, ${epsg5179Y.toFixed(2)}`;
coordsHeader = (
<div style={{
padding: '6px 8px',
borderBottom: '1px solid var(--border)',
marginBottom: '4px',
fontSize: '11px',
color: 'var(--text-secondary)',
display: 'flex',
flexDirection: 'column',
gap: '4px',
fontFamily: 'var(--font-mono)'
}}>
<strong style={{ color: 'var(--text)', fontFamily: 'inherit' }}>좌표 표시</strong>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '16px' }}>
<span style={{ color: 'var(--text-muted)' }}>지도 CRS ({crs})</span>
<span>{modelX.toFixed(2)}, {modelY.toFixed(2)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '16px' }}>
<span style={{ color: 'var(--text-muted)' }}>WGS84</span>
<span>{lng.toFixed(6)}, {lat.toFixed(6)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '16px' }}>
<span style={{ color: 'var(--text-muted)' }}>EPSG:5179</span>
<span>{epsg5179X.toFixed(2)}, {epsg5179Y.toFixed(2)}</span>
</div>
</div>
);
items.push(
{
id: 'copy-coords',
label: '좌표 모두 복사',
icon: <Copy size={14} aria-hidden="true" />,
action: () => onCopyText(allCoordsText, '좌표'),
},
{
id: 'lookup-address',
label: addressState.loading ? '주소 조회 중...' : '주소 조회',
sublabel: addressState.error ? (
<span style={{ color: 'var(--crit)' }}>{addressState.error}</span>
) : addressState.result ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', marginTop: '2px' }}>
{addressState.result.roadAddressName && <span style={{ color: 'var(--text)' }}>도로명: {addressState.result.roadAddressName}</span>}
<span style={{ color: 'var(--text)' }}>지번: {addressState.result.parcelAddressName || addressState.result.addressName}</span>
</div>
) : undefined,
icon: <MapPin size={14} aria-hidden="true" />,
closeOnClick: false,
action: async () => {
if (addressState.loading) return;
setAddressState({ loading: true });
try {
const result = await reverseGeocodeKakao(lng, lat);
setAddressState({
loading: false,
result,
error: result ? undefined : '해당 좌표의 주소 결과가 없습니다.',
});
} catch (caught) {
setAddressState({ loading: false, error: formatMapServiceError(caught) });
}
},
},
{
id: 'lookup-elevation',
label: elevationState.loading ? '표고 조회 중...' : '표고 조회 (근사)',
sublabel: elevationState.error ? (
<span style={{ color: 'var(--crit)' }}>{elevationState.error}</span>
) : elevationState.result ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', marginTop: '2px' }}>
<span style={{ color: 'var(--text)', fontWeight: 600 }}>{elevationState.result.elevationMeters.toFixed(1)} m</span>
<span style={{ color: 'var(--text-muted)', fontSize: '9px', lineHeight: '1.2' }}>출처: {elevationState.result.source}<br/>해상도: {elevationState.result.resolution} · 공식 한국 DEM 아님</span>
</div>
) : undefined,
icon: <Mountain size={14} aria-hidden="true" />,
closeOnClick: false,
action: async () => {
if (elevationState.loading) return;
setElevationState({ loading: true });
try {
const result = await lookupApproxElevation(lng, lat);
setElevationState({ loading: false, result });
} catch (caught) {
setElevationState({ loading: false, error: formatMapServiceError(caught) });
}
},
},
{
id: 'add-node-here',
label: '현재 위치에 절점 추가',
icon: <Plus size={14} aria-hidden="true" />,
action: () => onAddNodeAtCoords(modelX, modelY),
},
{
id: 'fit-bounds',
label: '전체 관망 보기 (Fit)',
icon: <Maximize2 size={14} aria-hidden="true" />,
action: onFitBounds,
},
{
id: 'pan-to-here',
label: '지도 중심으로 이동',
icon: <Move size={14} aria-hidden="true" />,
action: () => onPanTo(target.lngLat),
}
);
} else if (target.type === 'node') {
const nodeId = target.id;
items.push(
{
id: 'node-inspect',
label: `절점 [${nodeId}] 속성 보기`,
icon: <Pencil size={14} aria-hidden="true" />,
action: () => onInspectTarget({ type: 'node', id: nodeId }),
},
{
id: 'node-results',
label: '절점 수리결과 보기',
icon: <Activity size={14} aria-hidden="true" />,
disabled: !canRunResults,
disabledReason: '해석 미실행 상태입니다',
action: () => onViewResults({ type: 'node', id: nodeId }),
},
{
id: 'node-copy-coords',
label: '절점 좌표 복사',
icon: <Copy size={14} aria-hidden="true" />,
action: () => {
const coords = target.modelCoords
? `${target.modelCoords[0].toFixed(2)}, ${target.modelCoords[1].toFixed(2)}`
: `${target.lngLat[0].toFixed(5)}, ${target.lngLat[1].toFixed(5)}`;
onCopyText(coords, '절점 좌표');
},
},
{
id: 'node-start-pipe',
label: '이 절점에서 관로 시작',
icon: <ArrowRight size={14} aria-hidden="true" />,
action: () => onStartPipeFromNode(nodeId),
},
{
id: 'node-select-connected',
label: '연결 관로 선택',
icon: <Link size={14} aria-hidden="true" />,
action: () => onSelectConnectedLinks?.(nodeId),
},
{
id: 'node-delete',
label: '절점 삭제',
icon: <Trash2 size={14} aria-hidden="true" />,
danger: true,
action: () => onDeleteNode?.(nodeId),
}
);
} else if (target.type === 'link') {
const pipeId = target.id;
items.push(
{
id: 'link-inspect',
label: `관로 [${pipeId}] 속성 보기`,
icon: <Pencil size={14} aria-hidden="true" />,
action: () => onInspectTarget({ type: 'link', id: pipeId }),
},
{
id: 'link-results',
label: '관로 수리결과 보기',
icon: <Activity size={14} aria-hidden="true" />,
disabled: !canRunResults,
disabledReason: '해석 미실행 상태입니다',
action: () => onViewResults({ type: 'link', id: pipeId }),
},
{
id: 'link-copy-id',
label: '관로 ID 복사',
icon: <Copy size={14} aria-hidden="true" />,
action: () => onCopyText(pipeId, '관로 ID'),
},
{
id: 'link-select-connected-nodes',
label: '연결 절점 선택',
icon: <GitCommit size={14} aria-hidden="true" />,
action: () => onSelectConnectedNodes?.(pipeId),
},
{
id: 'link-calc-length',
label: '좌표 거리로 Length 재계산',
icon: <Ruler size={14} aria-hidden="true" />,
action: () => onCalculatePipeLength?.(pipeId),
},
{
id: 'link-reverse-direction',
label: '관로 방향 변경 (Node1 / Node2 교환)',
icon: <ArrowLeftRight size={14} aria-hidden="true" />,
action: () => onReversePipe?.(pipeId),
},
{
id: 'link-split',
label: '관로 분할 (절점 삽입 및 2개 관로 분리)',
icon: <Scissors size={14} aria-hidden="true" />,
action: () => onSplitPipe?.(pipeId),
},
{
id: 'link-delete',
label: '관로 삭제',
icon: <Trash2 size={14} aria-hidden="true" />,
danger: true,
action: () => onDeletePipe?.(pipeId),
}
);
} else if (target.type === 'gis-feature') {
const { layerId, featureId, properties } = target;
items.push(
{
id: 'gis-inspect',
label: `GIS 객체 [${featureId}] 속성 보기`,
icon: <Pencil size={14} aria-hidden="true" />,
action: () => onInspectTarget({ type: 'gis-feature', layerId, featureId }),
},
{
id: 'gis-copy-props',
label: '속성 데이터 (JSON) 복사',
icon: <Copy size={14} aria-hidden="true" />,
action: () => onCopyText(JSON.stringify(properties, null, 2), 'GIS 속성'),
},
{
id: 'gis-zoom-layer',
label: '해당 레이어로 확대',
icon: <Maximize2 size={14} aria-hidden="true" />,
action: () => onZoomToLayer?.(layerId),
}
);
}
// Adjust coordinates so the menu never overflows viewport
useEffect(() => {
if (!menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
const pad = 8;
const x = Math.max(pad, Math.min(event.x, window.innerWidth - rect.width - pad));
const y = Math.max(pad, Math.min(event.y, window.innerHeight - rect.height - pad));
setPos({ x, y });
}, [event.x, event.y]);
// Handle ESC and click outside
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => {
const next = (prev + 1) % items.length;
return items[next].disabled ? (next + 1) % items.length : next;
});
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => {
const next = (prev - 1 + items.length) % items.length;
return items[next].disabled ? (next - 1 + items.length) % items.length : next;
});
} else if (e.key === 'Enter' && selectedIndex >= 0 && selectedIndex < items.length) {
e.preventDefault();
const item = items[selectedIndex];
if (!item.disabled) {
item.action();
if (item.closeOnClick !== false) {
onClose();
}
}
}
};
const handleMouseDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
const timer = window.setTimeout(() => {
window.addEventListener('mousedown', handleMouseDown);
}, 50);
return () => {
window.clearTimeout(timer);
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('mousedown', handleMouseDown);
};
}, [items, onClose, selectedIndex]);
return (
<div
ref={menuRef}
style={{
position: 'fixed',
left: pos.x,
top: pos.y,
zIndex: 1000,
width: target.type === 'canvas' ? '330px' : '230px',
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-sm)',
boxShadow: 'var(--shadow-pop)',
padding: '4px',
display: 'flex',
flexDirection: 'column',
gap: '1px',
fontSize: '12px',
fontFamily: 'inherit',
}}
role="menu"
aria-label="지도 컨텍스트 메뉴"
>
<div
style={{
padding: '4px 8px 3px',
fontSize: '10px',
fontWeight: 600,
color: 'var(--text-muted)',
borderBottom: '1px solid var(--border)',
marginBottom: '2px',
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
{target.type === 'canvas'
? '지도 작업'
: target.type === 'node'
? `절점: ${target.id}`
: target.type === 'link'
? `관로: ${target.id}`
: `GIS 피처: ${target.featureId}`}
</div>
{coordsHeader}
{items.map((item, idx) => {
const isSelected = selectedIndex === idx;
return (
<button
key={item.id}
onClick={() => {
if (item.disabled) return;
item.action();
if (item.closeOnClick !== false) {
onClose();
}
}}
disabled={item.disabled}
title={item.disabled ? item.disabledReason : undefined}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '5px 8px',
border: 'none',
background: isSelected
? 'var(--accent-wash)'
: 'transparent',
color: item.disabled
? 'var(--text-disabled)'
: item.danger
? 'var(--crit)'
: 'var(--text)',
borderRadius: '2px',
cursor: item.disabled ? 'not-allowed' : 'pointer',
textAlign: 'left',
fontSize: '12px',
transition: 'background var(--dur) var(--ease)',
}}
onMouseEnter={() => !item.disabled && setSelectedIndex(idx)}
role="menuitem"
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
color: item.disabled
? 'var(--text-disabled)'
: item.danger
? 'var(--crit)'
: 'var(--text-secondary)',
}}
>
{item.icon}
</span>
<span>{item.label}</span>
</div>
{item.sublabel && (
<div style={{ maxWidth: '205px', fontSize: '10px', color: 'var(--text-muted)', fontFamily: 'var(--font-mono)', textAlign: 'right' }}>
{item.sublabel}
</div>
)}
</button>
);
})}
</div>
);
};
+283
View File
@@ -0,0 +1,283 @@
import React, { useState } from 'react';
import { Trash2, Plus, Clock } from 'lucide-react';
import type { InpControl } from './inpModel';
interface ControlEditorProps {
controls: InpControl[];
linkIds: string[];
nodeIds: string[];
onAddControl: (ctrl: Omit<InpControl, 'id' | 'rawText'>) => void;
onUpdateControl: (id: string, updates: Partial<InpControl>) => void;
onDeleteControl: (id: string) => void;
}
export const ControlEditor: React.FC<ControlEditorProps> = ({
controls,
linkIds,
nodeIds,
onAddControl,
onUpdateControl,
onDeleteControl,
}) => {
// New rule form state
const [newLinkId, setNewLinkId] = useState<string>(linkIds[0] || '9');
const [newStatus, setNewStatus] = useState<'OPEN' | 'CLOSED'>('OPEN');
const [newNodeId, setNewNodeId] = useState<string>(nodeIds[0] || '2');
const [newCondition, setNewCondition] = useState<'BELOW' | 'ABOVE'>('BELOW');
const [newValue, setNewValue] = useState<number>(110);
const handleAddNew = (e: React.FormEvent) => {
e.preventDefault();
onAddControl({
linkId: newLinkId,
status: newStatus,
nodeId: newNodeId,
condition: newCondition,
value: newValue,
});
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px', fontSize: '12px' }}>
<div style={{ color: 'var(--text-secondary)', lineHeight: 1.5, background: 'var(--bg-subtle)', padding: '10px', borderRadius: '6px', border: '1px solid #e2e8f0' }}>
<strong>운영 제어(Controls) 규칙 안내:</strong>
<div style={{ marginTop: '4px', fontSize: '11px', color: 'var(--text-secondary)' }}>
배수지(탱크) 수위나 특정 절점의 압력 변화에 따라 <strong>펌프를 가동(OPEN)하거나 정지(CLOSED)</strong>하는 EPANET 핵심 운영 규칙입니다.
</div>
</div>
{/* Control Rules List */}
<div>
<div style={{ fontWeight: 600, color: 'var(--text)', marginBottom: '8px' }}>
등록된 운영 제어 규칙 ({controls.length}건):
</div>
{controls.length === 0 ? (
<div style={{ padding: '16px', textAlign: 'center', color: 'var(--text-muted)', border: '1px dashed #cbd5e1', borderRadius: '6px' }}>
등록된 제어 규칙이 없습니다. 아래 폼에서 새 규칙을 추가하세요.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{controls.map((ctrl) => (
<div
key={ctrl.id}
style={{
background: 'var(--bg-panel)',
border: '1px solid #cbd5e1',
borderRadius: '6px',
padding: '10px',
boxShadow: '0 1px 2px rgba(0,0,0,0.02)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ fontWeight: 700, color: 'var(--accent)' }}>규칙 {ctrl.id}</span>
<span
style={{
fontSize: '11px',
padding: '1px 6px',
borderRadius: '4px',
background: ctrl.status === 'OPEN' ? 'var(--good-wash)' : 'var(--crit-wash)',
color: ctrl.status === 'OPEN' ? 'var(--good)' : 'var(--crit)',
fontWeight: 600,
}}
>
{ctrl.status === 'OPEN' ? '가동 (OPEN)' : '정지 (CLOSED)'}
</span>
</div>
<button
onClick={() => onDeleteControl(ctrl.id)}
style={{
border: 'none',
background: 'none',
color: 'var(--crit)',
cursor: 'pointer',
fontSize: '11px',
padding: '2px 4px',
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--s-1)',
}}
title="규칙 삭제"
>
<Trash2 size={12} aria-hidden="true" />
삭제
</button>
</div>
{/* Friendly description */}
<div style={{ fontSize: '12px', color: 'var(--text)', marginBottom: '8px' }}>
대상 링크 <strong>{ctrl.linkId}</strong>을(를){' '}
<strong style={{ color: ctrl.status === 'OPEN' ? 'var(--good)' : 'var(--crit)' }}>
{ctrl.status === 'OPEN' ? '가동(OPEN)' : '정지(CLOSED)'}
</strong>
{ctrl.nodeId && ctrl.condition && ctrl.value !== undefined && (
<span>
{' '}(조건: 절점 <strong>{ctrl.nodeId}</strong> 수위가{' '}
<strong>{ctrl.value}</strong>{' '}
{ctrl.condition === 'BELOW' ? '이하일 때' : '이상일 때'})
</span>
)}
{ctrl.atClause && (
<span>
{' '}(조건: <strong>{ctrl.atClause}</strong>)
</span>
)}
</div>
{ctrl.atClause && (
<div style={{ fontSize: '11px', color: 'var(--warn)', background: 'var(--warn-wash)', border: '1px solid #fde68a', borderRadius: '4px', padding: '4px 8px', marginBottom: '8px', display: 'flex', alignItems: 'flex-start', gap: 'var(--s-2)' }}>
<Clock size={13} aria-hidden="true" style={{ flexShrink: 0, marginTop: '1px' }} />
<span>시간 기반 제어 - 아래에서 동작(가동/정지)만 편집 가능하며, <strong>{ctrl.atClause}</strong> 조건은 그대로 유지됩니다.</span>
</div>
)}
{/* Edit inline fields */}
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', background: 'var(--bg-subtle)', padding: '6px 8px', borderRadius: '4px' }}>
<span style={{ fontSize: '11px', color: 'var(--text-secondary)' }}>동작:</span>
<select
value={ctrl.status}
onChange={(e) => onUpdateControl(ctrl.id, { status: e.target.value as 'OPEN' | 'CLOSED' })}
style={{ fontSize: '11px', padding: '2px 4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="OPEN">가동 (OPEN)</option>
<option value="CLOSED">정지 (CLOSED)</option>
</select>
{ctrl.nodeId && (
<>
<span style={{ fontSize: '11px', color: 'var(--text-secondary)', marginLeft: '4px' }}>조건:</span>
<select
value={ctrl.condition || 'BELOW'}
onChange={(e) => onUpdateControl(ctrl.id, { condition: e.target.value as 'BELOW' | 'ABOVE' })}
style={{ fontSize: '11px', padding: '2px 4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="BELOW">이하 (BELOW)</option>
<option value="ABOVE">이상 (ABOVE)</option>
</select>
<input
type="number"
step="1"
value={ctrl.value ?? 100}
onChange={(e) => onUpdateControl(ctrl.id, { value: parseFloat(e.target.value) || 0 })}
style={{ width: '60px', fontSize: '11px', padding: '2px 4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
</>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Add New Rule Form */}
<form
onSubmit={handleAddNew}
style={{
background: 'var(--bg-subtle)',
border: '1px solid #e2e8f0',
borderRadius: '6px',
padding: '12px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<div style={{ fontWeight: 600, color: 'var(--text)', display: 'flex', alignItems: 'center', gap: 'var(--s-2)' }}>
<Plus size={14} aria-hidden="true" />
새 운영 제어 규칙 추가:
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
제어 대상 링크 (펌프/관로)
</label>
<select
value={newLinkId}
onChange={(e) => setNewLinkId(e.target.value)}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
{linkIds.map((id) => (
<option key={id} value={id}>
링크 {id}
</option>
))}
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
동작 상태
</label>
<select
value={newStatus}
onChange={(e) => setNewStatus(e.target.value as 'OPEN' | 'CLOSED')}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="OPEN">가동 (OPEN)</option>
<option value="CLOSED">정지 (CLOSED)</option>
</select>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '8px' }}>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
조건 절점 (탱크/노드)
</label>
<select
value={newNodeId}
onChange={(e) => setNewNodeId(e.target.value)}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
{nodeIds.map((id) => (
<option key={id} value={id}>
절점 {id}
</option>
))}
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
조건 연산자
</label>
<select
value={newCondition}
onChange={(e) => setNewCondition(e.target.value as 'BELOW' | 'ABOVE')}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="BELOW">이하 (BELOW)</option>
<option value="ABOVE">이상 (ABOVE)</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
수위/압력 임계값
</label>
<input
type="number"
step="1"
value={newValue}
onChange={(e) => setNewValue(parseFloat(e.target.value) || 0)}
style={{ width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
</div>
</div>
<button
type="submit"
className="btn btn-primary"
style={{ marginTop: '4px', padding: '6px 12px', fontSize: '12px' }}
>
규칙 추가하기
</button>
</form>
</div>
);
};
+167
View File
@@ -0,0 +1,167 @@
// 곡선 편집: pump / efficiency / volume / headloss curves with a point table
// and a chart (GISpipe 커브 편집 parity). Edits are applied in one undoable step.
import React, { useMemo, useState } from 'react';
import { X, Plus, Trash2 } from 'lucide-react';
import { addCurve, deleteCurve, getNextCurveId, type InpCurve, type NetworkModel } from './inpModel';
import { CURVE_TYPES, withCurveType, type CurveType } from './inpExtras';
import { curveReferences, curveWarnings, inferCurveType } from './curveTools';
import { getUnitSystem } from './unitSystem';
const TYPE_LABEL: Record<CurveType, string> = { PUMP: '펌프', EFFICIENCY: '효율', VOLUME: '체적', HEADLOSS: '손실수두' };
function axisLabels(type: CurveType, flowUnit: string, lengthUnit: string): [string, string] {
const vol = lengthUnit === 'm' ? 'm³' : 'ft³';
switch (type) {
case 'PUMP': return [`유량 (${flowUnit})`, `양정 (${lengthUnit})`];
case 'EFFICIENCY': return [`유량 (${flowUnit})`, '효율 (%)'];
case 'VOLUME': return [`수위 (${lengthUnit})`, `체적 (${vol})`];
case 'HEADLOSS': return [`유량 (${flowUnit})`, `손실수두 (${lengthUnit})`];
}
}
interface Row { x: string; y: string }
export const CurveEditorModal: React.FC<{
model: NetworkModel;
onApply: (next: NetworkModel, message: string) => void;
onClose: () => void;
}> = ({ model, onApply, onClose }) => {
const types = useMemo(() => {
const t: Record<string, CurveType> = {};
for (const id of Object.keys(model.curves)) t[id] = inferCurveType(model, id);
return t;
}, [model]);
const [tab, setTab] = useState<CurveType>('PUMP');
const ids = Object.keys(model.curves).filter((id) => types[id] === tab);
const [selected, setSelected] = useState<string | null>(ids[0] ?? null);
const current = selected ? model.curves[selected] : null;
const [rows, setRows] = useState<Row[]>(current ? current.points.map((p) => ({ x: String(p.x), y: String(p.y) })) : []);
const [description, setDescription] = useState((current?.description ?? '').replace(/^(PUMP|EFFICIENCY|VOLUME|HEADLOSS)\s*:\s*/i, ''));
const [error, setError] = useState<string | null>(null);
const flowUnit = model.options.units;
const lengthUnit = getUnitSystem(flowUnit) === 'US' ? 'ft' : 'm';
const [xLabel, yLabel] = axisLabels(tab, flowUnit, lengthUnit);
const pick = (id: string | null) => {
setSelected(id);
const c = id ? model.curves[id] : null;
setRows(c ? c.points.map((p) => ({ x: String(p.x), y: String(p.y) })) : []);
setDescription((c?.description ?? '').replace(/^(PUMP|EFFICIENCY|VOLUME|HEADLOSS)\s*:\s*/i, ''));
setError(null);
};
const switchTab = (t: CurveType) => {
setTab(t);
const first = Object.keys(model.curves).find((id) => types[id] === t) ?? null;
pick(first);
};
const points = rows.map((r) => ({ x: Number(r.x), y: Number(r.y) }));
const valid = points.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
const warnings = valid ? curveWarnings(tab, points) : ['숫자가 아닌 값이 있습니다.'];
const save = () => {
if (!selected) return;
if (!valid || points.length === 0) return setError('점을 하나 이상, 숫자로 입력하세요.');
const curve: InpCurve = { id: selected, description: withCurveType(description, tab), points };
onApply(addCurve(model, curve), `곡선 ${selected}(${TYPE_LABEL[tab]})을 저장했습니다.`);
};
const create = () => {
const id = getNextCurveId(model, tab === 'PUMP' ? 'PC' : tab === 'EFFICIENCY' ? 'EC' : tab === 'VOLUME' ? 'VC' : 'HC');
const seed = tab === 'PUMP' ? [{ x: 0, y: 60 }, { x: 50, y: 50 }, { x: 100, y: 30 }] : tab === 'EFFICIENCY' ? [{ x: 0, y: 40 }, { x: 50, y: 75 }, { x: 100, y: 60 }] : [{ x: 0, y: 0 }, { x: 10, y: 100 }];
onApply(addCurve(model, { id, description: withCurveType('', tab), points: seed }), `새 ${TYPE_LABEL[tab]} 곡선 ${id}을(를) 만들었습니다.`);
setSelected(id);
setRows(seed.map((p) => ({ x: String(p.x), y: String(p.y) })));
setDescription('');
};
const remove = () => {
if (!selected) return;
const refs = curveReferences(model, selected);
if (refs.length) return setError(`사용 중인 곡선이라 지울 수 없습니다: ${refs.join(', ')}`);
if (!window.confirm(`곡선 ${selected}을(를) 삭제할까요?`)) return;
onApply(deleteCurve(model, selected), `곡선 ${selected}을(를) 삭제했습니다.`);
pick(null);
};
// chart
const W = 300, H = 190, pl = 42, pr = 10, pt = 10, pb = 30;
const xs = points.map((p) => p.x).filter(Number.isFinite);
const ys = points.map((p) => p.y).filter(Number.isFinite);
const x0 = Math.min(0, ...xs), x1 = Math.max(1e-9, ...xs), y0 = Math.min(0, ...ys), y1 = Math.max(1e-9, ...ys);
const sx = (v: number) => pl + ((v - x0) / (x1 - x0 || 1)) * (W - pl - pr);
const sy = (v: number) => H - pb - ((v - y0) / (y1 - y0 || 1)) * (H - pt - pb);
const path = valid ? points.map((p, i) => `${i ? 'L' : 'M'}${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`).join('') : '';
const cell: React.CSSProperties = { width: 80, height: 22, fontSize: 11, fontFamily: 'var(--font-mono)' };
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 1050, background: 'rgba(15,23,42,0.35)', display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={onClose}>
<div role="dialog" aria-label="곡선 편집" onClick={(e) => e.stopPropagation()}
style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 'var(--r-md)', boxShadow: 'var(--shadow-modal)', width: 720, maxWidth: '96vw', fontSize: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 14px', borderBottom: '1px solid var(--border)', background: 'var(--bg-subtle)', fontWeight: 600 }}>
<span>곡선 편집</span>
<button className="btn btn-ghost" style={{ padding: 3 }} onClick={onClose} aria-label="닫기"><X size={14} /></button>
</div>
<div style={{ display: 'flex', gap: 6, padding: '8px 14px', borderBottom: '1px solid var(--border)' }}>
{CURVE_TYPES.map((t) => (
<button key={t} className={`btn btn-secondary ${tab === t ? 'btn-primary' : ''}`} style={{ height: 24, fontSize: 11 }} onClick={() => switchTab(t)}>
{TYPE_LABEL[t]} ({Object.values(types).filter((x) => x === t).length})
</button>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '150px 1fr 300px', gap: 12, padding: 14, minHeight: 260 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{ids.map((id) => (
<button key={id} className={`btn ${selected === id ? 'btn-primary' : 'btn-ghost'}`} style={{ height: 24, justifyContent: 'flex-start', fontSize: 11 }} onClick={() => pick(id)}>
{id} <span style={{ opacity: 0.6, marginLeft: 4 }}>({model.curves[id].points.length}점)</span>
</button>
))}
{!ids.length && <span style={{ color: 'var(--text-muted)' }}>곡선 없음</span>}
<button className="btn btn-secondary" style={{ height: 24, fontSize: 11, marginTop: 6 }} onClick={create}><Plus size={11} />새 곡선</button>
</div>
<div>
{current ? (
<>
<label style={{ display: 'grid', gap: 2, marginBottom: 6 }}>설명<input value={description} onChange={(e) => setDescription(e.target.value)} style={{ height: 22, fontSize: 11 }} /></label>
<div style={{ color: 'var(--text-muted)', marginBottom: 4 }}>{curveReferences(model, current.id).join(', ') || '사용처 없음'}</div>
<table style={{ borderCollapse: 'collapse' }}>
<thead><tr><th style={{ textAlign: 'left', fontWeight: 600 }}>{xLabel}</th><th style={{ textAlign: 'left', fontWeight: 600 }}>{yLabel}</th><th /></tr></thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
<td><input value={r.x} onChange={(e) => setRows(rows.map((x, k) => (k === i ? { ...x, x: e.target.value } : x)))} style={cell} aria-label={`${i + 1}행 X`} /></td>
<td><input value={r.y} onChange={(e) => setRows(rows.map((x, k) => (k === i ? { ...x, y: e.target.value } : x)))} style={cell} aria-label={`${i + 1}행 Y`} /></td>
<td><button className="btn btn-ghost" style={{ padding: 2 }} onClick={() => setRows(rows.filter((_, k) => k !== i))} aria-label={`${i + 1}행 삭제`}><Trash2 size={11} /></button></td>
</tr>
))}
</tbody>
</table>
<button className="btn btn-secondary" style={{ height: 22, fontSize: 11, marginTop: 4 }} onClick={() => setRows([...rows, { x: '', y: '' }])}><Plus size={11} />점 추가</button>
</>
) : (
<span style={{ color: 'var(--text-muted)' }}>왼쪽에서 곡선을 고르거나 새로 만드세요.</span>
)}
</div>
<div>
<svg width={W} height={H} style={{ border: '1px solid var(--border)', borderRadius: 4, background: 'var(--bg-panel)' }} role="img" aria-label="곡선 그래프">
<line x1={pl} y1={H - pb} x2={W - pr} y2={H - pb} stroke="#94a3b8" />
<line x1={pl} y1={pt} x2={pl} y2={H - pb} stroke="#94a3b8" />
<text x={pl} y={H - 8} fontSize={9} fill="#64748b">{xLabel}</text>
<text x={4} y={pt + 8} fontSize={9} fill="#64748b">{y1.toPrecision(3)}</text>
<text x={W - pr} y={H - pb + 12} fontSize={9} fill="#64748b" textAnchor="end">{x1.toPrecision(3)}</text>
{path && <path d={path} fill="none" stroke="#2563eb" strokeWidth={2} />}
{valid && points.map((p, i) => <circle key={i} cx={sx(p.x)} cy={sy(p.y)} r={3} fill="#2563eb" />)}
</svg>
{current && warnings.map((w) => <div key={w} style={{ color: 'var(--warn, #b45309)', marginTop: 4 }}>⚠ {w}</div>)}
{error && <div style={{ color: 'var(--crit)', marginTop: 4 }}>{error}</div>}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '10px 14px', borderTop: '1px solid var(--border)', background: 'var(--bg-subtle)' }}>
<button className="btn btn-secondary" style={{ fontSize: 11 }} disabled={!current} onClick={remove}><Trash2 size={11} />곡선 삭제</button>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-secondary" style={{ fontSize: 11 }} onClick={onClose}>닫기</button>
<button className="btn btn-primary" style={{ fontSize: 11 }} disabled={!current} onClick={save}>저장</button>
</div>
</div>
</div>
</div>
);
};
+273
View File
@@ -0,0 +1,273 @@
// 분석 tab > 지형(DEM): load a DEM (or build one from points), show it on the
// map, fill node elevations as ground or pipe-invert level, check elevations
// against it, or fetch coarse internet elevations when there is no DEM file.
import React, { useMemo, useState } from 'react';
import { Crosshair, Upload } from 'lucide-react';
import type { AnalysisPanelProps } from './AnalysisPanel';
import type { RasterOverlay, UserGisLayer, View3D } from './types';
import { demFromPoints, demHydro, demPreviewUrl, sampleDem, uploadDem, type DemInfo } from './api';
import { applyGroundElevations, checkElevations, crsProj4, type ElevationMode, type ElevationOptions } from './demTools';
import { lookupApproxElevations } from './mapServices';
import { modelToLngLat } from './crs';
import { getUnitSystem } from './unitSystem';
export interface DemSectionProps {
onRasterOverlays?: (update: (prev: RasterOverlay[]) => RasterOverlay[]) => void;
view3d?: View3D;
onView3d?: (v: View3D) => void;
onAddUserLayer?: (layer: UserGisLayer) => void;
}
const box: React.CSSProperties = { border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: 8, display: 'flex', flexDirection: 'column', gap: 6 };
const inputStyle: React.CSSProperties = { height: 24, fontSize: 'var(--fs-xs)', width: '100%' };
const btn: React.CSSProperties = { height: 24, fontSize: 'var(--fs-2xs)' };
const muted: React.CSSProperties = { color: 'var(--text-muted)' };
const th: React.CSSProperties = { padding: '3px 5px', textAlign: 'right', background: 'var(--bg-subtle)', position: 'sticky', top: 0 };
const td: React.CSSProperties = { padding: '2px 5px', textAlign: 'right', borderBottom: '1px solid var(--border)', fontFamily: 'var(--font-mono)' };
export const DemSection: React.FC<AnalysisPanelProps & DemSectionProps> = ({ model, crs, userLayers, selectedNodeIds, cache, setCache, onApplyModel, onFocus, onSelectElements, onRasterOverlays, view3d, onView3d, onAddUserLayer }) => {
const [minArea, setMinArea] = useState('0.1');
const addLayer = (name: string, fc: import('geojson').FeatureCollection, color: string) => onAddUserLayer?.({
id: `layer-${Date.now()}`, name, fileName: `${name}.geojson`, featureCollection: fc, featureCount: fc.features.length,
geomTypes: [...new Set(fc.features.map((f) => f.geometry?.type).filter((t): t is NonNullable<typeof t> => !!t))],
estimatedCrs: 'EPSG:4326', visible: true, opacity: 0.85, color, createdAt: new Date().toISOString(),
});
const v3: View3D = view3d ?? { enabled: false, exaggeration: 1.5, columns: 'none', columnScale: 1 };
const set3d = (patch: Partial<View3D>) => onView3d?.({ ...v3, ...patch });
const dem = cache.demInfo as DemInfo | undefined;
const [busy, setBusy] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [mode, setMode] = useState<ElevationMode>('ground');
const [depth, setDepth] = useState('1.2');
const [useAssetDepth, setUseAssetDepth] = useState(true);
const [target, setTarget] = useState<'all' | 'selected' | 'zero'>('all');
const [tol, setTol] = useState('1');
const [opacity, setOpacity] = useState(0.7);
const [shown, setShown] = useState(true);
const [ptSource, setPtSource] = useState('network');
const [ptField, setPtField] = useState('');
const [cell, setCell] = useState('5');
const check = cache.demCheck as ReturnType<typeof checkElevations> | undefined;
const proj = crsProj4(crs);
const us = getUnitSystem(model.options.units) === 'US';
const lenUnit = us ? 'ft' : 'm';
const opts: ElevationOptions = { mode, defaultDepth: Number(depth) || 0, useAssetDepth };
const pointLayers = userLayers.filter((l) => l.geomTypes.some((g) => g.includes('Point')));
const ptLayer = pointLayers.find((l) => l.id === ptSource);
const numericFields = useMemo(() => {
const props = ptLayer?.featureCollection.features[0]?.properties ?? {};
return Object.keys(props).filter((k) => ptLayer!.featureCollection.features.slice(0, 50).some((f) => Number.isFinite(Number(f.properties?.[k]))));
}, [ptLayer]);
const targetIds = () => {
const ids = target === 'selected' ? selectedNodeIds.filter((id) => model.nodes[id]) : Object.keys(model.nodes);
return target === 'zero' ? ids.filter((id) => model.nodes[id].elevation === 0) : ids;
};
const task = async (label: string, fn: () => Promise<void>) => {
setErr(null);
setBusy(label);
try {
await fn();
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy(null);
}
};
const showDem = (info: DemInfo, visible = shown, op = opacity) => {
onRasterOverlays?.((prev) => [...prev.filter((r) => r.id !== 'dem'), { id: 'dem', name: `DEM ${info.name}`, url: `${demPreviewUrl(info.id)}?v=${info.id}`, corners: info.corners, opacity: op, visible }]);
};
const setDem = (info: DemInfo) => {
setCache('demInfo', info);
setCache('demCheck', undefined);
showDem(info, true);
setShown(true);
};
const sampleGround = async (ids: string[]) => {
if (!dem || !proj) throw new Error('DEM과 좌표계가 필요합니다.');
const vals = await sampleDem(dem.id, ids.map((id) => [model.nodes[id].x, model.nodes[id].y]), proj);
return Object.fromEntries(ids.map((id, k) => [id, vals[k]]));
};
const applyGround = (ground: Record<string, number | null>, source: string) => {
const r = applyGroundElevations(model, ground, opts, target === 'zero');
onApplyModel(r.model, `${source} ${mode === 'invert' ? '관저고' : '지반고'}로 절점 ${r.changed.length}개 표고를 넣었습니다${r.missing.length ? ` (범위 밖·자료 없음 ${r.missing.length}개 제외)` : ''}.`);
};
if (!proj) {
return <div style={{ color: 'var(--text-secondary)', lineHeight: 1.5 }}>좌표계가 "로컬 평면"이라 DEM 위치를 맞출 수 없습니다. 상단에서 관망의 실제 좌표계(예: EPSG:5186)를 지정하세요.</div>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
{err && <div style={{ color: 'var(--crit)' }}>{err}</div>}
{busy && <div style={{ color: 'var(--text-secondary)' }}>{busy}...</div>}
<div style={box}>
<b>DEM</b>
<label className="btn btn-secondary" style={{ ...btn, alignSelf: 'flex-start', display: 'inline-flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<Upload size={11} />DEM 파일 불러오기 (GeoTIFF·ASC·IMG, zip)
<input type="file" accept=".tif,.tiff,.asc,.img,.zip,.dem,.bil,.flt,.hgt" style={{ display: 'none' }} onChange={(e) => {
const f = e.target.files?.[0];
e.target.value = '';
if (f) void task(`DEM 올리는 중 (${(f.size / 1048576).toFixed(1)} MB)`, async () => setDem(await uploadDem(f)));
}} />
</label>
{dem ? (
<>
<span>{dem.name} · {dem.width}×{dem.height} · 격자 {dem.resolution[0].toFixed(2)} · 표고 {dem.min?.toFixed(1)}~{dem.max?.toFixed(1)}</span>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<label><input type="checkbox" checked={shown} onChange={(e) => { setShown(e.target.checked); showDem(dem, e.target.checked); }} /> 지도에 표시</label>
<input type="range" min={0.1} max={1} step={0.05} value={opacity} aria-label="DEM 투명도" onChange={(e) => { const v = Number(e.target.value); setOpacity(v); showDem(dem, shown, v); }} />
</div>
</>
) : (
<span style={muted}>국토지리정보원 수치표고모델 등 DEM 파일을 넣으세요. 좌표계 정보(.prj 또는 GeoTIFF)가 있어야 합니다. 서버에 보관되어 다른 화면을 오가도 유지됩니다.</span>
)}
</div>
{onView3d && (
<div style={box}>
<b>3D 보기</b>
<label><input type="checkbox" checked={v3.enabled} onChange={(e) => set3d({ enabled: e.target.checked, demId: dem?.id })} /> 지도를 기울여 3D로 보기{dem ? ' (DEM 지형 포함)' : ''}</label>
{v3.enabled && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 6 }}>
<label>지형 과장<input type="number" step="0.5" min={0.5} value={v3.exaggeration} disabled={!dem} onChange={(e) => set3d({ exaggeration: Number(e.target.value) || 1, demId: dem?.id })} style={inputStyle} /></label>
<label>절점 기둥
<select value={v3.columns} onChange={(e) => set3d({ columns: e.target.value as View3D['columns'] })} style={inputStyle}>
<option value="none">없음</option><option value="pressure">압력</option><option value="head">동수두</option><option value="elevation">표고</option>
</select>
</label>
<label>기둥 배율<input type="number" step="0.5" min={0.1} value={v3.columnScale} onChange={(e) => set3d({ columnScale: Number(e.target.value) || 1 })} style={inputStyle} /></label>
</div>
)}
<span style={muted}>마우스 오른쪽 버튼(또는 Ctrl+드래그)으로 시점을 돌릴 수 있습니다. 기둥 색은 압력 기준(빨강 낮음 → 파랑 높음)이며 수치 판단은 2D 화면과 종단을 쓰세요.</span>
</div>
)}
<div style={box}>
<b>절점 표고 입력</b>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
<label>표고 기준
<select value={mode} onChange={(e) => setMode(e.target.value as ElevationMode)} style={inputStyle}>
<option value="ground">지반고 (DEM 그대로)</option>
<option value="invert">관저고 (지반고 − 매설깊이)</option>
</select>
</label>
<label>대상
<select value={target} onChange={(e) => setTarget(e.target.value as typeof target)} style={inputStyle}>
<option value="all">전체 절점</option>
<option value="selected">선택 절점 ({selectedNodeIds.length})</option>
<option value="zero">표고가 0인 절점만</option>
</select>
</label>
{mode === 'invert' && <label>기본 매설깊이 (m)<input type="number" step="0.1" value={depth} onChange={(e) => setDepth(e.target.value)} style={inputStyle} /></label>}
{mode === 'invert' && <label style={{ alignSelf: 'end' }}><input type="checkbox" checked={useAssetDepth} onChange={(e) => setUseAssetDepth(e.target.checked)} /> 자산 속성 "매설깊이"가 있으면 그 값</label>}
</div>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
<button className="btn btn-primary" style={btn} disabled={!dem || !!busy} onClick={() => void task('DEM에서 표고 읽는 중', async () => applyGround(await sampleGround(targetIds()), 'DEM'))}>DEM 표고 입력</button>
<button className="btn btn-secondary" style={btn} disabled={!!busy} title="Copernicus 90m 근사값 (설계용으로는 DEM 파일 권장)"
onClick={() => void task('인터넷 표고 조회 중', async () => {
const ids = targetIds();
if (ids.length > 20000) throw new Error('절점이 2만 개를 넘습니다. 선택해서 나눠 실행하세요.');
const ext = { minX: 0, minY: 0, maxX: 1, maxY: 1 };
const vals = await lookupApproxElevations(ids.map((id) => modelToLngLat(model.nodes[id].x, model.nodes[id].y, crs, ext)), (done) => setBusy(`인터넷 표고 조회 중 ${done}/${ids.length}`));
applyGround(Object.fromEntries(ids.map((id, k) => [id, vals[k]])), '인터넷(90m)');
})}>인터넷 표고 (90m 근사)</button>
</div>
<span style={muted}>배수지는 수위(동수두)이므로 바꾸지 않습니다. 실행 취소(Ctrl+Z)할 수 있습니다.{us ? ' DEM 미터 값을 ft로 바꿔 넣습니다.' : ''}</span>
</div>
<div style={box}>
<b>표고 검증</b>
<div style={{ display: 'flex', gap: 6, alignItems: 'end' }}>
<label style={{ width: 120 }}>허용 차이 ({lenUnit})<input type="number" step="0.1" value={tol} onChange={(e) => setTol(e.target.value)} style={inputStyle} /></label>
<button className="btn btn-secondary" style={btn} disabled={!dem || !!busy} onClick={() => void task('표고 비교 중', async () => {
const ground = await sampleGround(Object.keys(model.nodes));
setCache('demCheck', checkElevations(model, ground, opts, Number(tol) || 0));
})}>위 기준({mode === 'invert' ? '관저고' : '지반고'})으로 검증</button>
</div>
{check && (
<>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span style={{ flex: 1 }}>검사 {check.checked}개 · 차이 큰 절점 <b>{check.rows.length}</b>개{check.missing ? ` · DEM 범위 밖 ${check.missing}개` : ''}</span>
{check.rows.length > 0 && <button className="btn btn-ghost" style={{ height: 20, fontSize: 11 }} onClick={() => onSelectElements(check.rows.map((r) => r.id), [])}><Crosshair size={10} />모두 선택</button>}
</div>
{check.rows.length > 0 && (
<div style={{ maxHeight: 240, overflow: 'auto', border: '1px solid var(--border)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr><th style={{ ...th, textAlign: 'left' }}>절점</th><th style={th}>모델 표고</th><th style={th}>DEM 기준</th><th style={th}>차이</th></tr></thead>
<tbody>
{check.rows.slice(0, 500).map((r) => (
<tr key={r.id} style={{ cursor: 'pointer' }} onClick={() => onFocus('node', r.id)}>
<td style={{ ...td, textAlign: 'left' }}>{r.id}</td><td style={td}>{r.model.toFixed(2)}</td><td style={td}>{r.expected.toFixed(2)}</td><td style={td}>{r.diff > 0 ? '+' : ''}{r.diff.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
{dem && onAddUserLayer && (
<div style={box}>
<b>유역 · 하천</b>
<span style={muted}>DEM의 웅덩이를 메운 뒤 D8 흐름 방향과 누적 유량으로 하천선을 뽑고, 선택 절점(취수원·배수지 등)을 출구로 하는 상류 유역을 구합니다. 큰 DEM은 100만 칸 이하로 줄여 계산합니다.</span>
<div style={{ display: 'flex', gap: 6, alignItems: 'end', flexWrap: 'wrap' }}>
<label style={{ width: 130 }}>최소 유역 (km²)<input type="number" step="0.05" value={minArea} onChange={(e) => setMinArea(e.target.value)} style={inputStyle} /></label>
<button className="btn btn-secondary" style={btn} disabled={!!busy} onClick={() => void task('하천 추출 중', async () => {
const fc = await demHydro(dem.id, { op: 'streams', minAreaKm2: Number(minArea) || 0.1 });
if (!fc.features.length) throw new Error('조건에 맞는 하천이 없습니다. 최소 유역을 줄이세요.');
addLayer(`하천 (≥${minArea} km²)`, fc, '#0284c7');
})}>하천선 추출</button>
<button className="btn btn-secondary" style={btn} disabled={!!busy || !selectedNodeIds.length} title="선택한 절점마다 상류 유역" onClick={() => void task('유역 계산 중', async () => {
const ids = selectedNodeIds.filter((id) => model.nodes[id]);
const fc = await demHydro(dem.id, { op: 'watershed', points: ids.map((id) => [model.nodes[id].x, model.nodes[id].y]), crs: proj, snap: 50 });
fc.features.forEach((f) => { if (f.properties) f.properties.node = ids[Number(f.properties.point)]; });
if (!fc.features.length) throw new Error('선택 절점이 DEM 범위 밖입니다.');
addLayer(`유역 (${ids.slice(0, 3).join(', ')}${ids.length > 3 ? ' …' : ''})`, fc, '#0d9488');
})}>선택 절점 유역 ({selectedNodeIds.length})</button>
</div>
</div>
)}
<div style={box}>
<b>점 표고로 DEM 만들기 (TIN)</b>
<span style={muted}>표고 점들을 삼각망으로 이어 격자 DEM을 만듭니다. 측량 표고점 레이어나 관망 절점 표고를 쓸 수 있습니다.</span>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 80px', gap: 6 }}>
<label>점 자료
<select value={ptSource} onChange={(e) => { setPtSource(e.target.value); setPtField(''); }} style={inputStyle}>
<option value="network">관망 절점 표고</option>
{pointLayers.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
<label>표고 필드
<select value={ptField} disabled={!ptLayer} onChange={(e) => setPtField(e.target.value)} style={inputStyle}>
<option value="">{ptLayer ? '선택' : '-'}</option>
{numericFields.map((f) => <option key={f} value={f}>{f}</option>)}
</select>
</label>
<label>격자 (m)<input type="number" value={cell} onChange={(e) => setCell(e.target.value)} style={inputStyle} /></label>
</div>
<button className="btn btn-secondary" style={{ ...btn, alignSelf: 'flex-start' }} disabled={!!busy || (!!ptLayer && !ptField)} onClick={() => void task('DEM 만드는 중', async () => {
if (!ptLayer) {
const k = us ? 1 / 3.280839895 : 1;
const pts = Object.values(model.nodes).filter((n) => n.type !== 'RESERVOIR' && n.elevation !== 0).map((n) => [n.x, n.y, n.elevation * k] as [number, number, number]);
setDem(await demFromPoints(pts, proj, Number(cell), '관망 절점 표고 DEM'));
} else {
const pts: [number, number, number][] = [];
for (const f of ptLayer.featureCollection.features) {
const z = Number(f.properties?.[ptField]);
const g = f.geometry;
if (!Number.isFinite(z) || !g) continue;
if (g.type === 'Point') pts.push([g.coordinates[0], g.coordinates[1], z]);
else if (g.type === 'MultiPoint') for (const c of g.coordinates) pts.push([c[0], c[1], z]);
}
setDem(await demFromPoints(pts, 'EPSG:4326', Number(cell), `${ptLayer.name} ${ptField} DEM`, proj));
}
})}>DEM 만들기</button>
</div>
</div>
);
};
+219
View File
@@ -0,0 +1,219 @@
// 수요량 테이블: every junction's demand categories at a glance, Excel paste /
// CSV bulk input, 수요 배분 and 누수(이미터) 배분 over a node set.
import React, { useMemo, useState } from 'react';
import { X } from 'lucide-react';
import type { NetworkModel } from './inpModel';
import { getUnitSystem, createDisplayConverter, getDisplayUnits } from './unitSystem';
import {
applyDemandRows,
distributeDemand,
distributeEmitters,
getDemandCategories,
parseDemandRows,
totalBaseDemand,
} from './extrasActions';
interface Props {
model: NetworkModel;
units: ReturnType<typeof createDisplayConverter>;
displayUnits: ReturnType<typeof getDisplayUnits>;
selectedNodeIds: string[];
onApply: (next: NetworkModel, message: string) => void;
onFocusNode: (id: string) => void;
onClose: () => void;
}
type Tab = 'table' | 'paste' | 'distribute' | 'leak';
const fmt = (v: number) => (Math.abs(v) >= 100 ? v.toFixed(1) : Math.abs(v) >= 1 ? v.toFixed(3) : v.toPrecision(3));
const field: React.CSSProperties = { display: 'grid', gap: 2, fontSize: 11, color: 'var(--text-secondary)' };
const inp: React.CSSProperties = { padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: 4, fontSize: 12 };
export const DemandTableModal: React.FC<Props> = ({ model, units, displayUnits, selectedNodeIds, onApply, onFocusNode, onClose }) => {
const [tab, setTab] = useState<Tab>('table');
const [filter, setFilter] = useState('');
const junctions = useMemo(() => Object.values(model.nodes).filter((n) => n.type === 'JUNCTION').map((n) => n.id), [model]);
const selJunctions = selectedNodeIds.filter((id) => model.nodes[id]?.type === 'JUNCTION');
const [scope, setScope] = useState<'selected' | 'all'>(selJunctions.length ? 'selected' : 'all');
const targetIds = scope === 'selected' ? selJunctions : junctions;
const rows = useMemo(() => {
const f = filter.trim().toLowerCase();
return junctions
.filter((id) => !f || id.toLowerCase().includes(f))
.map((id) => {
const cats = getDemandCategories(model, id);
return { id, total: totalBaseDemand(model, id), cats, emitter: model.extras?.emitters[id] };
});
}, [model, junctions, filter]);
const grandTotal = useMemo(() => junctions.reduce((a, id) => a + totalBaseDemand(model, id), 0), [model, junctions]);
const exportCsv = () => {
const lines = ['ID,수요량,패턴,범주'];
for (const id of junctions) for (const c of getDemandCategories(model, id)) lines.push([id, units.flow(c.demand), c.pattern ?? '', c.category ?? ''].join(','));
const blob = new Blob(['' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = '수요량.csv';
a.click();
URL.revokeObjectURL(a.href);
};
return (
<div role="dialog" aria-modal="true" aria-label="수요량 테이블" style={{ position: 'fixed', inset: 0, background: 'rgba(15,23,42,.35)', zIndex: 3000, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={onClose}>
<div onClick={(e) => e.stopPropagation()} style={{ width: 'min(760px, 96vw)', maxHeight: '88vh', background: 'var(--bg-panel)', borderRadius: 8, boxShadow: '0 12px 40px rgba(0,0,0,.25)', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', alignItems: 'center', padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
<b style={{ flex: 1 }}>수요량 테이블 · 일괄 입력 · 배분</b>
<button className="btn btn-ghost" onClick={onClose} aria-label="닫기"><X size={14} /></button>
</div>
<div style={{ display: 'flex', gap: 4, padding: '8px 14px 0' }}>
{([['table', '절점 수요량'], ['paste', '붙여넣기 / CSV'], ['distribute', '수요 배분'], ['leak', '누수(이미터) 배분']] as [Tab, string][]).map(([k, l]) => (
<button key={k} className={tab === k ? 'btn btn-primary' : 'btn btn-secondary'} style={{ height: 26, fontSize: 12 }} onClick={() => setTab(k)}>{l}</button>
))}
</div>
<div style={{ padding: 14, overflow: 'auto', display: 'flex', flexDirection: 'column', gap: 10, fontSize: 12 }}>
{tab === 'table' && (
<>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="ID 검색" style={{ ...inp, width: 160 }} />
<span style={{ color: 'var(--text-muted)', flex: 1 }}>접합점 {junctions.length}개 · 기본 수요 합계 {fmt(units.flow(grandTotal))} {displayUnits.flow}</span>
<button className="btn btn-secondary" style={{ height: 26, fontSize: 12 }} onClick={exportCsv}>CSV 내보내기</button>
</div>
<div style={{ border: '1px solid var(--border)', maxHeight: 420, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11.5 }}>
<thead>
<tr>{['ID', `합계 (${displayUnits.flow})`, '범주', '패턴', '이미터 계수'].map((h) => <th key={h} style={{ position: 'sticky', top: 0, background: 'var(--bg-subtle)', textAlign: 'left', padding: '4px 6px' }}>{h}</th>)}</tr>
</thead>
<tbody>
{rows.slice(0, 2000).map((r) => (
<tr key={r.id} onClick={() => onFocusNode(r.id)} style={{ cursor: 'pointer', borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '3px 6px' }}>{r.id}</td>
<td style={{ padding: '3px 6px', fontFamily: 'var(--font-mono)' }}>{fmt(units.flow(r.total))}</td>
<td style={{ padding: '3px 6px' }}>{r.cats.length > 1 || r.cats[0]?.category ? r.cats.map((c) => `${c.category ?? '-'} ${fmt(units.flow(c.demand))}`).join(' · ') : '-'}</td>
<td style={{ padding: '3px 6px' }}>{[...new Set(r.cats.map((c) => c.pattern || '(기본)'))].join(', ')}</td>
<td style={{ padding: '3px 6px' }}>{r.emitter ?? ''}</td>
</tr>
))}
</tbody>
</table>
{rows.length > 2000 && <div style={{ padding: 6, color: 'var(--text-muted)' }}>처음 2,000개만 표시합니다. ID 검색으로 좁히세요.</div>}
</div>
<span style={{ color: 'var(--text-muted)' }}>행을 누르면 지도가 그 절점으로 이동합니다. 범주별 편집은 절점을 선택한 뒤 속성창의 "추가 속성"에서 합니다.</span>
</>
)}
{tab === 'paste' && <PasteTab model={model} units={units} displayUnits={displayUnits} onApply={onApply} />}
{(tab === 'distribute' || tab === 'leak') && (
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<span>대상</span>
<label><input type="radio" checked={scope === 'selected'} disabled={!selJunctions.length} onChange={() => setScope('selected')} /> 선택한 접합점 ({selJunctions.length})</label>
<label><input type="radio" checked={scope === 'all'} onChange={() => setScope('all')} /> 전체 접합점 ({junctions.length})</label>
</div>
)}
{tab === 'distribute' && <DistributeTab model={model} units={units} displayUnits={displayUnits} ids={targetIds} onApply={onApply} />}
{tab === 'leak' && <LeakTab model={model} units={units} displayUnits={displayUnits} ids={targetIds} onApply={onApply} />}
</div>
</div>
</div>
);
};
type SubProps = Pick<Props, 'model' | 'units' | 'displayUnits' | 'onApply'>;
const PasteTab: React.FC<SubProps> = ({ model, units, displayUnits, onApply }) => {
const [text, setText] = useState('');
const [mode, setMode] = useState<'replace' | 'add'>('replace');
const parsed = useMemo(() => parseDemandRows(text), [text]);
const apply = () => {
const r = applyDemandRows(model, parsed.rows, mode, units.toFlow);
if (!r.updated.length) {
alert(`적용할 접합점이 없습니다.${r.unknown.length ? ` 모델에 없는 ID: ${r.unknown.slice(0, 10).join(', ')}` : ''}`);
return;
}
onApply(r.model, `수요량 일괄 입력: 접합점 ${r.updated.length}개${r.unknown.length ? `, 없는 ID ${r.unknown.length}개 무시` : ''}`);
setText('');
};
return (
<>
<span style={{ color: 'var(--text-secondary)' }}>
엑셀에서 <b>ID · 수요량 · [패턴] · [범주]</b> 열을 복사해 붙여넣거나 CSV 파일을 여세요. 수요량 단위는 {displayUnits.flow}입니다. 같은 ID가 여러 줄이면 범주가 여러 개가 됩니다.
</span>
<input type="file" accept=".csv,.txt,.tsv" onChange={async (e) => { const f = e.target.files?.[0]; if (f) setText(await f.text()); e.target.value = ''; }} />
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={10} placeholder={'J-1\t12.5\tP1\t가정용\nJ-1\t3\tP2\t상업용\nJ-2\t8'} style={{ ...inp, fontFamily: 'var(--font-mono)' }} />
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<label><input type="radio" checked={mode === 'replace'} onChange={() => setMode('replace')} /> 바꾸기 (목록의 절점 수요를 이 값으로)</label>
<label><input type="radio" checked={mode === 'add'} onChange={() => setMode('add')} /> 범주 추가 (기존 수요 유지)</label>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ flex: 1, color: parsed.bad.length ? '#b45309' : 'var(--text-muted)' }}>
읽은 행 {parsed.rows.length}개{parsed.bad.length ? ` · 읽지 못한 줄: ${parsed.bad.slice(0, 8).join(', ')}${parsed.bad.length > 8 ? ' …' : ''}` : ''}
</span>
<button className="btn btn-primary" disabled={!parsed.rows.length} onClick={apply}>적용</button>
</div>
</>
);
};
const DistributeTab: React.FC<SubProps & { ids: string[] }> = ({ model, units, displayUnits, ids, onApply }) => {
const [total, setTotal] = useState('');
const [by, setBy] = useState<'demand' | 'equal'>('demand');
const [category, setCategory] = useState('배분');
const [pattern, setPattern] = useState('');
const apply = () => {
try {
const next = distributeDemand(model, ids, units.toFlow(Number(total)), by, category.trim() || '배분', pattern || undefined);
onApply(next, `수요 ${total} ${displayUnits.flow}를 접합점 ${ids.length}개에 ${by === 'demand' ? '수요 비례' : '균등'} 배분했습니다 (범주 "${category}").`);
} catch (e) {
alert(e instanceof Error ? e.message : String(e));
}
};
return (
<>
<span style={{ color: 'var(--text-secondary)' }}>총량을 대상 접합점에 새 수요 범주로 나눠 더합니다 (기존 수요는 그대로). 대규모 수용가·신규 개발 수요 반영에 씁니다.</span>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0,1fr))', gap: 10 }}>
<label style={field}>배분할 총량 ({displayUnits.flow})<input type="number" value={total} onChange={(e) => setTotal(e.target.value)} style={inp} /></label>
<label style={field}>배분 방식
<select value={by} onChange={(e) => setBy(e.target.value as 'demand' | 'equal')} style={inp}>
<option value="demand">기존 수요 비례</option>
<option value="equal">균등</option>
</select>
</label>
<label style={field}>범주 이름<input value={category} onChange={(e) => setCategory(e.target.value)} style={inp} /></label>
<label style={field}>패턴
<select value={pattern} onChange={(e) => setPattern(e.target.value)} style={inp}>
<option value="">(기본)</option>
{Object.keys(model.patterns).map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</label>
</div>
<button className="btn btn-primary" style={{ alignSelf: 'flex-start' }} disabled={!ids.length || !(Number(total) > 0)} onClick={apply}>배분 적용 ({ids.length}개)</button>
</>
);
};
const LeakTab: React.FC<SubProps & { ids: string[] }> = ({ model, units, displayUnits, ids, onApply }) => {
const si = getUnitSystem(model.options.units) === 'SI';
const [total, setTotal] = useState('');
const [pressure, setPressure] = useState(si ? '30' : '40');
const [exp, setExp] = useState(String(model.options.emitterExponent ?? 0.5));
const apply = () => {
try {
const r = distributeEmitters(model, ids, units.toFlow(Number(total)), Number(pressure), Number(exp));
onApply(r.model, `누수량 ${total} ${displayUnits.flow}를 접합점 ${Object.keys(r.coefficients).length}개의 이미터 계수로 배분했습니다 (관로 연장 비례).`);
} catch (e) {
alert(e instanceof Error ? e.message : String(e));
}
};
return (
<>
<span style={{ color: 'var(--text-secondary)' }}>
총 누수량을 각 접합점에 연결된 관로 연장(반씩) 비율로 나누고, 기준 압력에서 그 유량이 나오도록 이미터 계수 C = Q / P<sup>지수</sup>를 넣습니다. 기존 이미터 계수는 덮어씁니다.
</span>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0,1fr))', gap: 10 }}>
<label style={field}>총 누수량 ({displayUnits.flow})<input type="number" value={total} onChange={(e) => setTotal(e.target.value)} style={inp} /></label>
<label style={field}>기준 압력 ({si ? 'm' : 'psi'})<input type="number" value={pressure} onChange={(e) => setPressure(e.target.value)} style={inp} /></label>
<label style={field}>압력 지수<input type="number" step="0.05" value={exp} onChange={(e) => setExp(e.target.value)} style={inp} /></label>
</div>
{Number(exp) !== (model.options.emitterExponent ?? 0.5) && <span style={{ color: '#b45309' }}>해석은 모델 설정의 이미터 지수({model.options.emitterExponent ?? 0.5})를 씁니다. 다르게 하려면 모델 설정도 바꾸세요.</span>}
<button className="btn btn-primary" style={{ alignSelf: 'flex-start' }} disabled={!ids.length || !(Number(total) > 0)} onClick={apply}>이미터 배분 적용 ({ids.length}개)</button>
</>
);
};
+190
View File
@@ -0,0 +1,190 @@
// 분석 tab > 편집 도구: transform, splits, node dissolve/merge, pipe check,
// flow tracing and shortest path. Every change goes through onApplyModel (undo).
import React, { useMemo, useState } from 'react';
import { Crosshair } from 'lucide-react';
import type { AnalysisPanelProps } from './AnalysisPanel';
import {
autoFixPipes,
centroid,
connectNodeToPipe,
dissolveNode,
findPipeIssues,
mergeNodes,
similarityFrom2Points,
splitPipeByLength,
splitPipeEqual,
traceFlow,
transformFn,
transformNetwork,
} from './editTools';
import { findProfilePath } from './profilePath';
const box: React.CSSProperties = { border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: 8, display: 'flex', flexDirection: 'column', gap: 6 };
const inputStyle: React.CSSProperties = { height: 24, fontSize: 'var(--fs-xs)', width: '100%' };
const btn: React.CSSProperties = { height: 24, fontSize: 'var(--fs-2xs)' };
const muted: React.CSSProperties = { color: 'var(--text-muted)' };
export const EditToolsSection: React.FC<AnalysisPanelProps> = ({ model, result, selectedNodeIds, selectedLinkIds, units, displayUnits, onApplyModel, onSelectElements }) => {
const [err, setErr] = useState<string | null>(null);
const run = (fn: () => void) => {
setErr(null);
try {
fn();
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
}
};
const selPipes = selectedLinkIds.filter((id) => model.pipes[id]);
const selNodes = selectedNodeIds.filter((id) => model.nodes[id]);
// transform
const [dx, setDx] = useState('0');
const [dy, setDy] = useState('0');
const [rot, setRot] = useState('0');
const [scale, setScale] = useState('1');
const [onlySel, setOnlySel] = useState(false);
// 2-point fix
const [refA, setRefA] = useState('');
const [refB, setRefB] = useState('');
const [toA, setToA] = useState('');
const [toB, setToB] = useState('');
// split
const [parts, setParts] = useState('2');
const [interval, setStep] = useState('');
// merge
const [keep, setKeep] = useState('');
const issues = useMemo(() => findPipeIssues(model), [model]);
const xy = (s: string) => {
const [x, y] = s.split(/[\s,]+/).map(Number);
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error(`좌표 "${s}"를 읽을 수 없습니다 (예: 203512.4, 451230.8).`);
return { x, y };
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
{err && <div style={{ color: 'var(--crit)' }}>{err}</div>}
<div style={box}>
<b>이동 · 회전 · 축척</b>
<span style={muted}>모델 좌표 단위로 옮기고, 대상 절점들의 중심을 기준으로 회전·확대합니다. 양 끝이 함께 움직이는 관로의 꺾임점도 따라갑니다. 연장은 그대로이니 필요하면 관망 도구의 관로길이 재계산을 쓰세요.</span>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 4 }}>
<label>X 이동<input value={dx} onChange={(e) => setDx(e.target.value)} style={inputStyle} /></label>
<label>Y 이동<input value={dy} onChange={(e) => setDy(e.target.value)} style={inputStyle} /></label>
<label>회전(°)<input value={rot} onChange={(e) => setRot(e.target.value)} style={inputStyle} /></label>
<label>축척<input value={scale} onChange={(e) => setScale(e.target.value)} style={inputStyle} /></label>
</div>
<label><input type="checkbox" checked={onlySel} disabled={!selNodes.length} onChange={(e) => setOnlySel(e.target.checked)} /> 선택 절점만 ({selNodes.length})</label>
<button className="btn btn-secondary" style={{ ...btn, alignSelf: 'flex-start' }} onClick={() => run(() => {
const ids = onlySel ? selNodes : undefined;
const t = { dx: Number(dx) || 0, dy: Number(dy) || 0, rotate: Number(rot) || 0, scale: Number(scale) || 1, origin: centroid(model, ids) };
const r = transformNetwork(model, transformFn(t), ids);
onApplyModel(r.model, `절점 ${r.moved}개를 변환했습니다 (이동 ${t.dx}, ${t.dy} · 회전 ${t.rotate}° · 축척 ${t.scale}).`);
})}>적용</button>
</div>
<div style={box}>
<b>2점 좌표 보정</b>
<span style={muted}>기준 절점 두 개의 올바른 좌표를 넣으면 관망 전체를 그에 맞게 이동·회전·축척합니다 (좌표계가 어긋난 도면 보정).</span>
<div style={{ display: 'grid', gridTemplateColumns: '80px 1fr', gap: 4, alignItems: 'center' }}>
<input value={refA} onChange={(e) => setRefA(e.target.value)} placeholder="기준 절점 1" style={inputStyle} />
<input value={toA} onChange={(e) => setToA(e.target.value)} placeholder="올바른 X, Y" style={inputStyle} />
<input value={refB} onChange={(e) => setRefB(e.target.value)} placeholder="기준 절점 2" style={inputStyle} />
<input value={toB} onChange={(e) => setToB(e.target.value)} placeholder="올바른 X, Y" style={inputStyle} />
</div>
<button className="btn btn-secondary" style={{ ...btn, alignSelf: 'flex-start' }} onClick={() => run(() => {
const a = model.nodes[refA.trim()], b = model.nodes[refB.trim()];
if (!a || !b) throw new Error('기준 절점 ID를 확인하세요.');
const r = transformNetwork(model, similarityFrom2Points(a, b, xy(toA), xy(toB)));
onApplyModel(r.model, `2점 보정으로 절점 ${r.moved}개 좌표를 바꿨습니다.`);
})}>보정 적용</button>
</div>
<div style={box}>
<b>관로 분할 (선택 관로 {selPipes.length}개)</b>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr auto', gap: 4, alignItems: 'end' }}>
<label>등분 수<input type="number" min={2} value={parts} onChange={(e) => setParts(e.target.value)} style={inputStyle} /></label>
<button className="btn btn-secondary" style={btn} disabled={!selPipes.length} onClick={() => run(() => {
let m = model; let made = 0;
for (const id of selPipes) { const r = splitPipeEqual(m, id, Number(parts)); m = r.model; made += r.junctions.length; }
onApplyModel(m, `관로 ${selPipes.length}개를 ${parts}등분했습니다 (절점 ${made}개 추가).`);
})}>N등분</button>
<label>최대 간격 ({displayUnits.length})<input type="number" value={interval} onChange={(e) => setStep(e.target.value)} style={inputStyle} /></label>
<button className="btn btn-secondary" style={btn} disabled={!selPipes.length || !(Number(interval) > 0)} onClick={() => run(() => {
let m = model; let made = 0;
for (const id of selPipes) { const r = splitPipeByLength(m, id, units.toLength(Number(interval))); m = r.model; made += r.junctions.length; }
onApplyModel(m, made ? `관로를 ${interval} ${displayUnits.length} 이하로 나눴습니다 (절점 ${made}개 추가).` : '간격보다 긴 관로가 없습니다.');
})}>간격 분할</button>
</div>
<button className="btn btn-secondary" style={{ ...btn, alignSelf: 'flex-start' }} disabled={selPipes.length !== 1 || selNodes.length !== 1}
title="절점 하나와 관로 하나를 선택하세요" onClick={() => run(() => {
const r = connectNodeToPipe(model, selNodes[0], selPipes[0]);
onApplyModel(r.model, `절점 ${selNodes[0]}를 관로 ${selPipes[0]}에 접속했습니다 (분할 관로 ${r.newPipeId}).`);
})}>접속 분할: 선택 절점을 선택 관로에 연결</button>
</div>
<div style={box}>
<b>절점 제거 · 병합 (선택 절점 {selNodes.length}개)</b>
<span style={muted}>제거: 관경·조도가 같은 관로 2개 사이의 수요 없는 접합점을 없애고 관로를 하나로 합칩니다 (절점 자리는 꺾임점). 병합: 다른 절점들의 관로·수요를 남길 절점으로 옮깁니다.</span>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'center' }}>
<button className="btn btn-secondary" style={btn} disabled={!selNodes.length} onClick={() => run(() => {
let m = model;
for (const id of selNodes) m = dissolveNode(m, id);
onApplyModel(m, `절점 ${selNodes.length}개를 제거하고 관로를 합쳤습니다.`);
})}>선택 절점 제거</button>
<select value={keep} onChange={(e) => setKeep(e.target.value)} style={{ ...inputStyle, width: 110 }} aria-label="남길 절점">
<option value="">남길 절점</option>
{selNodes.map((id) => <option key={id} value={id}>{id}</option>)}
</select>
<button className="btn btn-secondary" style={btn} disabled={selNodes.length < 2 || !selNodes.includes(keep)} onClick={() => run(() => {
let m = model; const dropped: string[] = [];
for (const id of selNodes) if (id !== keep) { const r = mergeNodes(m, keep, id); m = r.model; dropped.push(...r.removedLinks); }
onApplyModel(m, `절점 ${selNodes.length - 1}개를 ${keep}에 병합했습니다${dropped.length ? ` (사이 관로 ${dropped.length}개 삭제)` : ''}.`);
})}>병합</button>
</div>
</div>
<div style={box}>
<b>관로 점검</b>
{([
['자기 자신으로 돌아오는 관로', issues.selfLoops, 'link'],
['끝 절점이 없는 관로', issues.dangling, 'link'],
['연장 0 이하', issues.nonPositiveLength, 'link'],
['관경 0', issues.zeroDiameter, 'link'],
['같은 두 절점을 잇는 평행 관로', issues.parallel.flat(), 'link'],
['연결 관로 없는 접합점', issues.orphanNodes, 'node'],
] as Array<[string, string[], 'node' | 'link']>).map(([label, ids, kind]) => (
<div key={label} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span style={{ flex: 1, color: ids.length ? undefined : 'var(--text-muted)' }}>{label}: <b>{ids.length}</b></span>
{ids.length > 0 && <button className="btn btn-ghost" style={{ height: 20, fontSize: 11 }} onClick={() => (kind === 'node' ? onSelectElements(ids, []) : onSelectElements([], ids))}><Crosshair size={10} />선택</button>}
</div>
))}
<button className="btn btn-secondary" style={{ ...btn, alignSelf: 'flex-start' }} disabled={!issues.selfLoops.length && !issues.dangling.length && !issues.orphanNodes.length} onClick={() => run(() => {
const r = autoFixPipes(model);
onApplyModel(r.model, `자동 정리: 관로 ${r.removedPipes.length}개, 고립 접합점 ${r.removedNodes.length}개를 지웠습니다.`);
})}>자동 정리 (자기 루프·끝 없는 관로·수요 없는 고립 절점 삭제)</button>
<span style={muted}>평행 관로와 연장·관경 0은 의도일 수 있어 자동으로 고치지 않습니다. 선택해 확인하세요.</span>
</div>
<div style={box}>
<b>추적 · 경로</b>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
<button className="btn btn-secondary" style={btn} disabled={!result || selNodes.length !== 1} onClick={() => run(() => {
const r = traceFlow(model, result!, selNodes[0], 'up');
onSelectElements(r.nodes, r.links);
})}>상류 추적</button>
<button className="btn btn-secondary" style={btn} disabled={!result || selNodes.length !== 1} onClick={() => run(() => {
const r = traceFlow(model, result!, selNodes[0], 'down');
onSelectElements(r.nodes, r.links);
})}>하류 추적</button>
<button className="btn btn-secondary" style={btn} disabled={selNodes.length !== 2} onClick={() => run(() => {
const p = findProfilePath(model, selNodes[0], selNodes[1]);
if (!p) throw new Error('두 절점을 잇는 경로가 없습니다.');
onSelectElements(p.nodeIds, p.linkIds);
})}>두 절점 최단 경로</button>
</div>
<span style={muted}>상·하류 추적은 해석 결과의 평균 흐름 방향을 따라갑니다 (절점 1개 선택). 최단 경로는 관로 연장 기준입니다 (절점 2개 선택).</span>
</div>
</div>
);
};
+269
View File
@@ -0,0 +1,269 @@
// "추가 속성 (INP 고급)" under the property inspector: demand categories,
// emitter, initial/source quality, tank mixing & reaction, pump energy and
// [STATUS] override -- the inputs that used to be preserved raw only.
import React, { useEffect, useState } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import type { NetworkModel } from './inpModel';
import { MIXING_MODELS, SOURCE_TYPES, type MixingModel, type SourceType } from './inpExtras';
import {
getDemandCategories,
setDemandCategories,
setEmitter,
setInitialQuality,
setMixing,
setPumpEnergy,
setSource,
setStatusOverride,
setTankReaction,
} from './extrasActions';
import { createDisplayConverter, getDisplayUnits } from './unitSystem';
import { inferCurveType } from './curveTools';
type Converter = ReturnType<typeof createDisplayConverter>;
type DisplayUnits = ReturnType<typeof getDisplayUnits>;
interface Props {
model: NetworkModel;
target: { type: 'node' | 'link'; id: string };
units: Converter;
displayUnits: DisplayUnits;
onApply: (next: NetworkModel, message: string) => void;
}
const box: React.CSSProperties = { border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: 8, display: 'flex', flexDirection: 'column', gap: 6, background: 'var(--bg-panel)' };
const inp: React.CSSProperties = { height: 22, fontSize: 11, width: '100%' };
const row: React.CSSProperties = { display: 'grid', gridTemplateColumns: '110px 1fr', gap: 6, alignItems: 'center' };
const SOURCE_LABEL: Record<SourceType, string> = { CONCEN: '농도', MASS: '질량 부스터', SETPOINT: '설정점 부스터', FLOWPACED: '유량 비례 부스터' };
const MIX_LABEL: Record<MixingModel, string> = { MIXED: '완전 혼합', '2COMP': '2구획 혼합', FIFO: 'FIFO 플러그', LIFO: 'LIFO 플러그' };
export const ElementExtrasEditor: React.FC<Props> = (props) => {
const [open, setOpen] = useState(true);
const { model, target } = props;
const node = target.type === 'node' ? model.nodes[target.id] : undefined;
const pump = target.type === 'link' ? model.pumps[target.id] : undefined;
const pipe = target.type === 'link' ? model.pipes[target.id] : undefined;
const valve = target.type === 'link' ? model.valves?.[target.id] : undefined;
if (!node && !pump && !pipe && !valve) return null;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 'var(--fs-xs)' }}>
<button type="button" onClick={() => setOpen(!open)} style={{ border: 'none', background: 'none', padding: 0, textAlign: 'left', fontWeight: 600, cursor: 'pointer', color: 'inherit' }}>
{open ? '▾' : '▸'} 추가 속성 (다중 수요·이미터·수질·{node?.type === 'TANK' ? '혼합·' : ''}{pump ? '에너지·' : ''}초기 상태)
</button>
{open && (
<>
{node?.type === 'JUNCTION' && <DemandCategories {...props} />}
{node?.type === 'JUNCTION' && <EmitterBox {...props} />}
{node?.type === 'TANK' && <TankBox {...props} />}
{node && <QualityBox {...props} />}
{pump && <PumpEnergyBox {...props} />}
{(pipe || valve || pump) && <StatusBox {...props} kind={pump ? 'pump' : valve ? 'valve' : 'pipe'} />}
</>
)}
</div>
);
};
const DemandCategories: React.FC<Props> = ({ model, target, units, displayUnits, onApply }) => {
const load = () => getDemandCategories(model, target.id).map((c) => ({ demand: String(Math.round(units.flow(c.demand) * 1e4) / 1e4), pattern: c.pattern ?? '', category: c.category ?? '' }));
const [rows, setRows] = useState(load);
useEffect(() => setRows(load()), [model, target.id]); // eslint-disable-line react-hooks/exhaustive-deps
const patterns = Object.keys(model.patterns);
const total = rows.reduce((a, r) => a + (Number(r.demand) || 0), 0);
const save = () => {
try {
const cats = rows.map((r) => ({ demand: units.toFlow(Number(r.demand) || 0), pattern: r.pattern || undefined, category: r.category || undefined }));
onApply(setDemandCategories(model, target.id, cats), `절점 ${target.id} 수요량 범주 ${cats.length}개를 저장했습니다.`);
} catch (e) {
alert(e instanceof Error ? e.message : String(e));
}
};
return (
<div style={box}>
<b>수요량 범주 ({displayUnits.flow})</b>
<span style={{ color: 'var(--text-muted)' }}>범주가 둘 이상이면 [DEMANDS]로 저장되고, 첫 범주가 기본 수요량이 됩니다. 합계 {Math.round(total * 1000) / 1000}</span>
<table style={{ borderCollapse: 'collapse' }}>
<thead><tr><th style={{ textAlign: 'left' }}>수요량</th><th style={{ textAlign: 'left' }}>패턴</th><th style={{ textAlign: 'left' }}>범주</th><th /></tr></thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
<td><input value={r.demand} onChange={(e) => setRows(rows.map((x, k) => (k === i ? { ...x, demand: e.target.value } : x)))} style={{ ...inp, width: 70 }} aria-label={`${i + 1}번째 수요량`} /></td>
<td>
<select value={r.pattern} onChange={(e) => setRows(rows.map((x, k) => (k === i ? { ...x, pattern: e.target.value } : x)))} style={{ ...inp, width: 80 }} aria-label={`${i + 1}번째 패턴`}>
<option value="">(기본)</option>
{patterns.map((p) => <option key={p} value={p}>{p}</option>)}
{r.pattern && !patterns.includes(r.pattern) && <option value={r.pattern}>{r.pattern} (없음)</option>}
</select>
</td>
<td><input value={r.category} onChange={(e) => setRows(rows.map((x, k) => (k === i ? { ...x, category: e.target.value } : x)))} style={{ ...inp, width: 80 }} placeholder="예: 가정용" aria-label={`${i + 1}번째 범주`} /></td>
<td><button className="btn btn-ghost" style={{ padding: 2 }} disabled={rows.length <= 1} onClick={() => setRows(rows.filter((_, k) => k !== i))} aria-label={`${i + 1}번째 범주 삭제`}><Trash2 size={11} /></button></td>
</tr>
))}
</tbody>
</table>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-secondary" style={{ height: 22, fontSize: 11 }} onClick={() => setRows([...rows, { demand: '0', pattern: '', category: '' }])}><Plus size={11} />범주 추가</button>
<button className="btn btn-primary" style={{ height: 22, fontSize: 11 }} onClick={save}>저장</button>
</div>
</div>
);
};
const EmitterBox: React.FC<Props> = ({ model, target, onApply }) => {
const cur = model.extras?.emitters[target.id];
const [v, setV] = useState(cur !== undefined ? String(cur) : '');
useEffect(() => setV(cur !== undefined ? String(cur) : ''), [cur, target.id]);
return (
<div style={box}>
<b>이미터 (누수·스프링클러)</b>
<div style={row}>
<span title="압력 1단위에서의 유출량 (원본 유량 단위 / 압력^지수)">유출 계수</span>
<div style={{ display: 'flex', gap: 4 }}>
<input value={v} onChange={(e) => setV(e.target.value)} placeholder="없음" style={inp} />
<button className="btn btn-secondary" style={{ height: 22, fontSize: 11 }} onClick={() => onApply(setEmitter(model, target.id, v.trim() ? Number(v) : null), v.trim() ? `절점 ${target.id} 이미터 계수 ${v}` : `절점 ${target.id} 이미터 제거`)}>적용</button>
</div>
</div>
<span style={{ color: 'var(--text-muted)' }}>원본 모델 단위 ({model.options.units}, 지수 {model.options.emitterExponent ?? 0.5}). 여러 절점에 누수량을 나눌 때는 수요량 테이블의 "누수(이미터) 배분"을 쓰세요.</span>
</div>
);
};
const QualityBox: React.FC<Props> = ({ model, target, onApply }) => {
const q = model.extras?.quality[target.id];
const src = model.extras?.sources[target.id];
const [init, setInit] = useState(q !== undefined ? String(q) : '');
const [type, setType] = useState<SourceType | ''>(src?.type ?? '');
const [strength, setStrength] = useState(src ? String(src.strength) : '');
const [pattern, setPattern] = useState(src?.pattern ?? '');
useEffect(() => {
setInit(q !== undefined ? String(q) : '');
setType(src?.type ?? '');
setStrength(src ? String(src.strength) : '');
setPattern(src?.pattern ?? '');
}, [q, src, target.id]);
const apply = () => {
let m = setInitialQuality(model, target.id, init.trim() ? Number(init) : null);
m = setSource(m, target.id, type && strength.trim() ? { type, strength: Number(strength), pattern: pattern || undefined } : null);
onApply(m, `절점 ${target.id} 수질 입력을 저장했습니다.`);
};
return (
<div style={box}>
<b>수질</b>
<div style={row}><span>초기 수질</span><input value={init} onChange={(e) => setInit(e.target.value)} placeholder="0" style={inp} /></div>
<div style={row}>
<span>수원 수질 형식</span>
<select value={type} onChange={(e) => setType(e.target.value as SourceType | '')} style={inp}>
<option value="">없음</option>
{SOURCE_TYPES.map((t) => <option key={t} value={t}>{SOURCE_LABEL[t]} ({t})</option>)}
</select>
</div>
{type && (
<>
<div style={row}><span>수원 농도/질량</span><input value={strength} onChange={(e) => setStrength(e.target.value)} style={inp} /></div>
<div style={row}>
<span>수원 패턴</span>
<select value={pattern} onChange={(e) => setPattern(e.target.value)} style={inp}>
<option value="">(일정)</option>
{Object.keys(model.patterns).map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</div>
</>
)}
<button className="btn btn-secondary" style={{ height: 22, fontSize: 11, alignSelf: 'flex-start' }} onClick={apply}>수질 저장</button>
<span style={{ color: 'var(--text-muted)' }}>모델 설정의 수질 모드가 "화학물질"일 때 해석에 쓰입니다. 질량 부스터는 질량 유량(mg/min)입니다.</span>
</div>
);
};
const TankBox: React.FC<Props> = ({ model, target, onApply }) => {
const mix = model.extras?.mixing[target.id];
const k = model.extras?.tankReactions[target.id];
const [mode, setMode] = useState<MixingModel>(mix?.model ?? 'MIXED');
const [frac, setFrac] = useState(mix?.fraction !== undefined ? String(mix.fraction) : '0.5');
const [react, setReact] = useState(k !== undefined ? String(k) : '');
useEffect(() => {
setMode(mix?.model ?? 'MIXED');
setFrac(mix?.fraction !== undefined ? String(mix.fraction) : '0.5');
setReact(k !== undefined ? String(k) : '');
}, [mix, k, target.id]);
const apply = () => {
let m = setMixing(model, target.id, { model: mode, fraction: mode === '2COMP' ? Number(frac) : undefined });
m = setTankReaction(m, target.id, react.trim() ? Number(react) : null);
onApply(m, `탱크 ${target.id} 혼합·반응 설정을 저장했습니다.`);
};
return (
<div style={box}>
<b>탱크 혼합·반응</b>
<div style={row}>
<span>혼합 모델</span>
<select value={mode} onChange={(e) => setMode(e.target.value as MixingModel)} style={inp}>
{MIXING_MODELS.map((m) => <option key={m} value={m}>{MIX_LABEL[m]} ({m})</option>)}
</select>
</div>
{mode === '2COMP' && <div style={row}><span>혼합 구획 비율</span><input value={frac} onChange={(e) => setFrac(e.target.value)} style={inp} /></div>}
<div style={row}><span title="1/day, 성장은 양수·감쇠는 음수">반응 계수</span><input value={react} onChange={(e) => setReact(e.target.value)} placeholder="전역값 사용" style={inp} /></div>
<button className="btn btn-secondary" style={{ height: 22, fontSize: 11, alignSelf: 'flex-start' }} onClick={apply}>저장</button>
</div>
);
};
const PumpEnergyBox: React.FC<Props> = ({ model, target, onApply }) => {
const pe = model.extras?.energy.pumps[target.id];
const [eff, setEff] = useState(pe?.efficiency ?? '');
const [price, setPrice] = useState(pe?.price !== undefined ? String(pe.price) : '');
const [pattern, setPattern] = useState(pe?.pattern ?? '');
useEffect(() => {
setEff(pe?.efficiency ?? '');
setPrice(pe?.price !== undefined ? String(pe.price) : '');
setPattern(pe?.pattern ?? '');
}, [pe, target.id]);
const effCurves = Object.keys(model.curves).filter((id) => inferCurveType(model, id) === 'EFFICIENCY' || id === eff);
return (
<div style={box}>
<b>펌프 에너지</b>
<div style={row}>
<span>효율 곡선</span>
<select value={eff} onChange={(e) => setEff(e.target.value)} style={inp}>
<option value="">(전역 효율)</option>
{effCurves.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div style={row}><span>전력 단가 (/kWh)</span><input value={price} onChange={(e) => setPrice(e.target.value)} placeholder="전역 단가" style={inp} /></div>
<div style={row}>
<span>단가 패턴</span>
<select value={pattern} onChange={(e) => setPattern(e.target.value)} style={inp}>
<option value="">(전역 패턴)</option>
{Object.keys(model.patterns).map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</div>
<button className="btn btn-secondary" style={{ height: 22, fontSize: 11, alignSelf: 'flex-start' }}
onClick={() => onApply(setPumpEnergy(model, target.id, { efficiency: eff || undefined, price: price.trim() ? Number(price) : undefined, pattern: pattern || undefined }), `펌프 ${target.id} 에너지 설정을 저장했습니다.`)}>저장</button>
<span style={{ color: 'var(--text-muted)' }}>효율 곡선은 "곡선 편집"의 효율 탭에서 만듭니다.</span>
</div>
);
};
const StatusBox: React.FC<Props & { kind: 'pipe' | 'valve' | 'pump' }> = ({ model, target, onApply, kind }) => {
const cur = model.extras?.statusOverrides[target.id] ?? '';
const [v, setV] = useState(cur);
useEffect(() => setV(cur), [cur, target.id]);
const options = kind === 'valve' ? ['OPEN', 'CLOSED', 'ACTIVE'] : ['OPEN', 'CLOSED'];
const isNumeric = v !== '' && !options.includes(v.toUpperCase());
return (
<div style={box}>
<b>초기 상태 재정의 [STATUS]</b>
<div style={row}>
<span>상태</span>
<div style={{ display: 'flex', gap: 4 }}>
<select value={isNumeric ? '#' : v.toUpperCase()} onChange={(e) => setV(e.target.value === '#' ? '1' : e.target.value)} style={inp}>
<option value="">재정의 없음</option>
{options.map((o) => <option key={o} value={o}>{o === 'OPEN' ? '열림' : o === 'CLOSED' ? '닫힘' : '활성'} ({o})</option>)}
{kind !== 'pipe' && <option value="#">{kind === 'pump' ? '속도 설정' : '설정값'}</option>}
</select>
{isNumeric && <input value={v} onChange={(e) => setV(e.target.value)} style={{ ...inp, width: 60 }} />}
<button className="btn btn-secondary" style={{ height: 22, fontSize: 11 }} onClick={() => onApply(setStatusOverride(model, target.id, v || null), v ? `${target.id} 초기 상태를 ${v}(으)로 재정의했습니다.` : `${target.id} 초기 상태 재정의를 지웠습니다.`)}>적용</button>
</div>
</div>
<span style={{ color: 'var(--text-muted)' }}>[STATUS] 값은 관로·밸브·펌프 자체의 상태보다 우선합니다.</span>
</div>
);
};
+85
View File
@@ -0,0 +1,85 @@
// 에너지 옵션 ([ENERGY] globals) and 전역 반응 옵션 ([REACTIONS] ORDER/GLOBAL/
// LIMITING/ROUGHNESS) for the model settings page.
import React, { useState } from 'react';
import type { NetworkModel } from './inpModel';
import { getKeyValue, setEnergyGlobal, setReactionGlobal } from './extrasActions';
import { inferCurveType } from './curveTools';
interface Props {
model: NetworkModel;
onApply: (next: NetworkModel, message: string) => void;
}
const label: React.CSSProperties = { display: 'grid', gap: '2px', color: 'var(--text-secondary)', fontSize: '11px' };
const input: React.CSSProperties = { width: '100%', padding: '4px 6px', border: '1px solid #cbd5e1', borderRadius: '4px' };
export const EnergyReactionOptions: React.FC<Props> = ({ model, onApply }) => {
const [open, setOpen] = useState(false);
const energy = model.extras?.energy.global;
const react = model.extras?.reactionsGlobal;
const setE = (key: string, value: string) => onApply(setEnergyGlobal(model, key, value.trim() || null), `에너지 옵션 ${key} = ${value || '(기본)'}`);
const setR = (key: string, value: string) => onApply(setReactionGlobal(model, key, value.trim() || null), `반응 옵션 ${key} = ${value || '(기본)'}`);
// Blur-commit text fields so typing does not push one undo step per key.
const text = (kind: 'E' | 'R', key: string, title: string, help: string, placeholder: string) => {
const cur = getKeyValue(kind === 'E' ? energy : react, key) ?? '';
return (
<label style={label} title={help}>
{title}
<input key={`${key}:${cur}`} defaultValue={cur} placeholder={placeholder} style={input}
onBlur={(e) => { if (e.target.value.trim() !== cur) (kind === 'E' ? setE : setR)(key, e.target.value); }} />
</label>
);
};
const order = (key: string, title: string, values: string[]) => (
<label style={label}>
{title}
<select value={getKeyValue(react, key) ?? ''} onChange={(e) => setR(key, e.target.value)} style={input}>
<option value="">기본 (1차)</option>
{values.map((v) => <option key={v} value={v}>{v}차</option>)}
</select>
</label>
);
const patterns = Object.keys(model.patterns);
const effCurves = Object.keys(model.curves).filter((id) => inferCurveType(model, id) === 'EFFICIENCY');
const globalEff = getKeyValue(energy, 'Global Efficiency') ?? '';
return (
<div style={{ background: 'var(--bg-subtle)', border: '1px solid #e2e8f0', borderRadius: '6px', padding: '12px' }}>
<button type="button" onClick={() => setOpen(!open)} style={{ border: 'none', background: 'none', padding: 0, fontWeight: 600, cursor: 'pointer', color: 'inherit' }}>
{open ? '▾' : '▸'} 에너지·수질 반응 옵션 ([ENERGY]·[REACTIONS])
</button>
{open && (
<div style={{ display: 'grid', gap: 10, marginTop: 8 }}>
<b style={{ fontSize: 11 }}>에너지</b>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '10px' }}>
<label style={label} title="효율 곡선이 없는 펌프에 적용되는 효율(%) 또는 효율 곡선 ID">
전역 펌프 효율 (%)
<input key={`ge:${globalEff}`} defaultValue={globalEff} placeholder="75" list="spipe-eff-curves" style={input}
onBlur={(e) => { if (e.target.value.trim() !== globalEff) setE('Global Efficiency', e.target.value); }} />
<datalist id="spipe-eff-curves">{effCurves.map((c) => <option key={c} value={c} />)}</datalist>
</label>
{text('E', 'Global Price', '전역 전력 단가 (/kWh)', '펌프별 단가가 없을 때 쓰는 kWh당 비용', '0')}
<label style={label} title="시간대별 전력 단가 배율 패턴">
전역 단가 패턴
<select value={getKeyValue(energy, 'Global Pattern') ?? ''} onChange={(e) => setE('Global Pattern', e.target.value)} style={input}>
<option value="">(일정)</option>
{patterns.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</label>
{text('E', 'Demand Charge', '최대 수요 요금 (/kW)', '해석 기간 최대 전력(kW)당 추가 요금', '0')}
</div>
<b style={{ fontSize: 11 }}>수질 반응</b>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '10px' }}>
{order('Order Bulk', '수체 반응 차수', ['0', '1', '2'])}
{order('Order Wall', '관벽 반응 차수', ['0', '1'])}
{order('Order Tank', '탱크 반응 차수', ['0', '1', '2'])}
{text('R', 'Global Bulk', '전역 수체 반응계수 (1/day)', '관로별 값이 없을 때. 감쇠는 음수 (예: 염소 -0.5)', '0')}
{text('R', 'Global Wall', '전역 관벽 반응계수', '1차: m/day(ft/day), 0차: mg/m²/day. 감쇠는 음수', '0')}
{text('R', 'Limiting Potential', '한계 농도', '반응이 이 농도에 비례하도록 하는 한계값 (0 = 미사용)', '0')}
{text('R', 'Roughness Correlation', '조도-관벽 상관계수', '관벽 계수 = 상관계수 / C값 (H-W). 0 = 미사용', '0')}
</div>
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}>빈칸은 EPANET 기본값을 씁니다. 펌프별 효율·단가는 펌프를 선택해 "추가 속성"에서, 탱크 반응계수는 탱크의 "추가 속성"에서 지정합니다.</span>
</div>
)}
</div>
);
};
+105
View File
@@ -0,0 +1,105 @@
import React from 'react';
import { CheckCircle2, AlertTriangle } from 'lucide-react';
import { type ComparisonResult, type SimulationResult } from './types';
export interface EngineComparisonPanelProps {
comparison: ComparisonResult | null;
serverResult: SimulationResult | null;
wasmResult: SimulationResult | null;
}
/** Native DLL vs WASM cross-validation summary (verification tab). */
export const EngineComparisonPanel: React.FC<EngineComparisonPanelProps> = ({
comparison,
serverResult,
wasmResult,
}) => (
<div>
{!comparison || !serverResult || !wasmResult ? (
<div style={{ padding: 'var(--s-8)', textAlign: 'center', color: 'var(--text-secondary)', fontSize: 'var(--fs-sm)' }}>
{wasmResult && !serverResult
? '서버(Native) 엔진 결과가 없어 교차검증을 할 수 없습니다. WASM 엔진 결과는 지도·결과 탭에서 확인할 수 있습니다.'
: serverResult && !wasmResult
? 'WASM 엔진 결과가 없어 교차검증을 할 수 없습니다. 서버(Native) 엔진 결과는 지도·결과 탭에서 확인할 수 있습니다.'
: "비교할 수리해석 결과가 없습니다. 상단 '해석 실행' 버튼을 클릭하세요."}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-5)' }}>
<div className="diff-grid">
<div className="result-card card-server">
<strong>서버 (Native DLL)</strong>
<div>버전: {serverResult.engine.version}</div>
<div>시간: {serverResult.timeSeconds}초</div>
<div style={{ color: 'var(--text-muted)' }}>
노드: {Object.keys(serverResult.nodes).length} / 링크: {Object.keys(serverResult.links).length}
</div>
</div>
<div className="result-card card-wasm">
<strong>클라이언트 (WASM)</strong>
<div>버전: {wasmResult.engine.version}</div>
<div>시간: {wasmResult.timeSeconds}초</div>
<div style={{ color: 'var(--text-muted)' }}>
노드: {Object.keys(wasmResult.nodes).length} / 링크: {Object.keys(wasmResult.links).length}
</div>
</div>
</div>
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-secondary)', background: 'var(--bg-subtle)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: 'var(--s-4) var(--s-5)', lineHeight: 'var(--lh-prose)' }}>
<strong>수치 정합성 판정 기준</strong>: 각 물리량별 허용오차 <code>absTol + relTol × max(|Native|, |WASM|)</code> 기준으로 정합성을 판정합니다.
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--s-2)' }}>
<h4 style={{ margin: 0, fontSize: 'var(--fs-md)', color: 'var(--text)' }}>최대 절대 오차</h4>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>기준: Head/Press 1e-4, Demand/Flow 1e-3, Vel/HL 1e-5</span>
</div>
<div className="diff-grid">
<div className="diff-row">
<span>Demand (수요량)</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{comparison.maxDiffs.demand.toExponential(2)}</span>
</div>
<div className="diff-row">
<span>Head (수두)</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{comparison.maxDiffs.head.toExponential(2)}</span>
</div>
<div className="diff-row">
<span>Pressure (수압)</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{comparison.maxDiffs.pressure.toExponential(2)}</span>
</div>
<div className="diff-row">
<span>Flow (유량)</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{comparison.maxDiffs.flow.toExponential(2)}</span>
</div>
<div className="diff-row">
<span>Velocity (유속)</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{comparison.maxDiffs.velocity.toExponential(2)}</span>
</div>
<div className="diff-row">
<span>Headloss (손실)</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{comparison.maxDiffs.headloss.toExponential(2)}</span>
</div>
</div>
</div>
{comparison.passed ? (
<div style={{ padding: 'var(--s-4)', background: 'var(--good-wash)', color: 'var(--good)', border: '1px solid var(--good)', borderRadius: 'var(--r-sm)', fontSize: 'var(--fs-sm)', display: 'flex', alignItems: 'center', gap: 'var(--s-3)' }}>
<CheckCircle2 size={16} />
<span><strong>정합성 통과</strong>: Native C DLL과 WebAssembly 해석 결과가 허용오차 범위 내에서 일치합니다.</span>
</div>
) : (
<div style={{ padding: 'var(--s-4)', background: 'var(--crit-wash)', color: 'var(--crit)', border: '1px solid var(--crit)', borderRadius: 'var(--r-sm)', fontSize: 'var(--fs-sm)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-3)', fontWeight: 'var(--fw-semi)' }}>
<AlertTriangle size={16} />
<span>허용오차 초과 또는 데이터 불일치 발생</span>
</div>
{comparison.missingIds.length > 0 && (
<div style={{ marginTop: 'var(--s-2)', fontSize: 'var(--fs-xs)' }}>
누락/단위 불일치: {comparison.missingIds.join(', ')}
</div>
)}
</div>
)}
</div>
)}
</div>
);
+107
View File
@@ -0,0 +1,107 @@
import React from 'react';
import { Camera, ChevronDown, Download, FileText, Layers, Table } from 'lucide-react';
interface ExportMenuProps {
open: boolean;
onToggle: () => void;
onClose: () => void;
disabled: boolean;
hasResult: boolean;
onExportInp: () => void;
onExportNet: () => void;
onExportXlsx: () => void;
onExportDxf: () => void;
onExportCsv: () => void;
onCaptureMap: () => void;
onExportShapefile: () => void;
onShowInpText: () => void;
onOpenAttributeTable: () => void;
}
/** Header "내보내기" dropdown. Result-based exports are disabled until a run exists. */
export const ExportMenu: React.FC<ExportMenuProps> = ({
open,
onToggle,
onClose,
disabled,
hasResult,
onExportInp,
onExportNet,
onExportXlsx,
onExportDxf,
onExportCsv,
onCaptureMap,
onExportShapefile,
onShowInpText,
onOpenAttributeTable,
}) => {
const act = (fn: () => void) => () => {
fn();
onClose();
};
return (
<div className="dropdown-container">
<button
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
disabled={disabled}
className="btn btn-secondary"
title="INP, DXF, XLSX, CSV 등 내보내기"
>
<Download size={14} />
<span>내보내기</span>
<ChevronDown size={12} style={{ opacity: 0.6 }} />
</button>
{open && (
<div className="dropdown-menu align-right" style={{ width: '220px' }}>
<div className="dropdown-header">EPANET 모델 파일</div>
<button className="dropdown-item" onClick={act(onExportInp)}>
<FileText size={14} />
<span>.inp 모델 파일 다운로드</span>
</button>
<button className="dropdown-item" onClick={act(onExportNet)}>
<FileText size={14} />
<span>.net 네트워크 바이너리</span>
</button>
<div className="dropdown-divider" />
<div className="dropdown-header">엔지니어링 산출물</div>
<button className="dropdown-item" disabled={!hasResult} onClick={act(onExportXlsx)}>
<Table size={14} />
<span>검토용 수리계산서 (.xlsx)</span>
</button>
<button className="dropdown-item" onClick={act(onExportDxf)}>
<Layers size={14} />
<span>AutoCAD 표준 도면 (.dxf)</span>
</button>
<button className="dropdown-item" disabled={!hasResult} onClick={act(onExportCsv)}>
<FileText size={14} />
<span>24h 시계열 수치 (.csv)</span>
</button>
<div className="dropdown-divider" />
<button className="dropdown-item" onClick={act(onCaptureMap)}>
<Camera size={14} />
<span>지도 화면 캡처 (.png)</span>
</button>
<button className="dropdown-item" onClick={act(onExportShapefile)}>
<Layers size={14} />
<span>GIS Shapefile (.zip)</span>
</button>
<button className="dropdown-item" onClick={act(onShowInpText)}>
<FileText size={14} />
<span>INP 텍스트 전문 보기</span>
</button>
<button className="dropdown-item" onClick={act(onOpenAttributeTable)}>
<Table size={14} />
<span>속성 테이블 열기</span>
</button>
</div>
)}
</div>
);
};
File diff suppressed because it is too large. Load diff
+74
View File
@@ -0,0 +1,74 @@
import React from 'react';
import { Layers } from 'lucide-react';
import { type SelectionTarget, type UserGisLayer } from './types';
export interface GisFeatureInspectorProps {
selectedTarget: Extract<SelectionTarget, { type: 'gis-feature' }>;
userLayers: UserGisLayer[];
handleConvertGisLayerToNetwork: (layer: UserGisLayer) => void;
}
/** Attributes of a selected GIS reference feature, with convert-to-network. */
export const GisFeatureInspector: React.FC<GisFeatureInspectorProps> = ({
selectedTarget,
userLayers,
handleConvertGisLayerToNetwork,
}) => (
<div
style={{
background: 'var(--bg-subtle)',
padding: '12px',
borderRadius: '6px',
border: '1px solid var(--border)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<h3 style={{ margin: 0, color: 'var(--text)', fontSize: '14px' }}>
GIS 객체: <span style={{ color: 'var(--accent)' }}>#{selectedTarget.featureId}</span>
</h3>
<span
style={{
fontSize: '10px',
padding: '2px 6px',
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: '4px',
color: 'var(--text-secondary)',
}}
>
{selectedTarget.geometryType || 'Feature'}
</span>
</div>
<div style={{ maxHeight: '280px', overflowY: 'auto', border: '1px solid var(--border)', borderRadius: '4px', background: 'var(--bg-panel)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '11px' }}>
<thead>
<tr style={{ background: 'var(--bg-subtle)', borderBottom: '1px solid var(--border)', textAlign: 'left' }}>
<th style={{ padding: '4px 6px', width: '40%' }}>속성 (Field)</th>
<th style={{ padding: '4px 6px' }}>값 (Value)</th>
</tr>
</thead>
<tbody>
{Object.entries(selectedTarget.properties).map(([k, v]) => (
<tr key={k} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '3px 6px', color: 'var(--text-secondary)', fontFamily: 'var(--font-mono)' }}>{k}</td>
<td style={{ padding: '3px 6px', color: 'var(--text)', wordBreak: 'break-word' }}>{String(v ?? '-')}</td>
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={() => {
const layer = userLayers.find((l) => l.id === selectedTarget.layerId);
if (layer) handleConvertGisLayerToNetwork(layer);
}}
className="btn btn-secondary"
style={{ width: '100%', marginTop: '8px', fontSize: '11px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '4px' }}
>
<Layers size={13} aria-hidden="true" />
<span>이 레이어를 관망으로 변환</span>
</button>
</div>
);
+342
View File
@@ -0,0 +1,342 @@
// 분석 tab > GIS 도구: layer CRS reassignment and export, geometry operations,
// Voronoi / grid generation, Excel/CSV points, OSM buildings, and layer -> network
// tools (valves, meters, small blocks, asset tables).
import React, { useMemo, useState } from 'react';
import proj4 from 'proj4';
import * as XLSX from 'xlsx';
import type { Feature, FeatureCollection } from 'geojson';
import type { AnalysisPanelProps } from './AnalysisPanel';
import type { RasterOverlay, UserGisLayer } from './types';
import { exportLayerFile, gisOp, reprojectLayer } from './api';
import { crsProj4 } from './demTools';
import { lngLatToModel, modelToLngLat, SUPPORTED_CRS_DEFINITIONS } from './crs';
import { blocksFromPolygons } from './blockIsolation';
import { applyAssetTable, assignMeters, extentCorners, insertValves, worldFileCorners, writeBlocks } from './gisTools';
import { m3DayToFlow } from './unitSystem';
export interface GisToolsProps {
onAddUserLayer?: (layer: UserGisLayer) => void;
onSetUserLayers?: (update: (prev: UserGisLayer[]) => UserGisLayer[]) => void;
rasterOverlays?: RasterOverlay[];
onRasterOverlays?: (update: (prev: RasterOverlay[]) => RasterOverlay[]) => void;
}
const box: React.CSSProperties = { border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: 8, display: 'flex', flexDirection: 'column', gap: 6 };
const inputStyle: React.CSSProperties = { height: 24, fontSize: 'var(--fs-xs)', width: '100%' };
const btn: React.CSSProperties = { height: 24, fontSize: 'var(--fs-2xs)' };
const muted: React.CSSProperties = { color: 'var(--text-muted)' };
const grid = (cols: string): React.CSSProperties => ({ display: 'grid', gridTemplateColumns: cols, gap: 6, alignItems: 'end' });
const PROJECTED = SUPPORTED_CRS_DEFINITIONS.filter((d) => d.code !== 'LOCAL' && d.proj4Def);
const COLORS = ['#7c3aed', '#0891b2', '#ca8a04', '#db2777', '#16a34a', '#ea580c'];
function newLayer(name: string, fc: FeatureCollection, sourceCrs?: string): UserGisLayer {
const geomTypes = [...new Set(fc.features.map((f) => f.geometry?.type).filter((t): t is NonNullable<typeof t> => !!t))];
return {
id: `layer-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
name, fileName: `${name}.geojson`, featureCollection: fc, featureCount: fc.features.length, geomTypes,
estimatedCrs: 'EPSG:4326', visible: true, opacity: 0.8, color: COLORS[Math.floor(Math.random() * COLORS.length)],
createdAt: new Date().toISOString(), sourceCrs,
};
}
function pointsOf(layer: UserGisLayer): Array<{ lon: number; lat: number; props: Record<string, unknown> }> {
const out: Array<{ lon: number; lat: number; props: Record<string, unknown> }> = [];
for (const f of layer.featureCollection.features) {
const g = f.geometry;
const props = (f.properties ?? {}) as Record<string, unknown>;
if (g?.type === 'Point') out.push({ lon: g.coordinates[0], lat: g.coordinates[1], props });
else if (g?.type === 'MultiPoint') for (const c of g.coordinates) out.push({ lon: c[0], lat: c[1], props });
}
return out;
}
export const GisToolsSection: React.FC<AnalysisPanelProps & GisToolsProps> = ({ model, crs, userLayers, onApplyModel, onAddUserLayer, onSetUserLayers, rasterOverlays = [], onRasterOverlays }) => {
const [layerId, setLayerId] = useState(userLayers[0]?.id ?? '');
const layer = userLayers.find((l) => l.id === layerId) ?? userLayers[0];
const fields = useMemo(() => Object.keys(layer?.featureCollection.features[0]?.properties ?? {}), [layer]);
const isPoint = !!layer?.geomTypes.some((g) => g.includes('Point'));
const isPolygon = !!layer?.geomTypes.some((g) => g.includes('Polygon'));
const proj = crsProj4(crs);
const ext = useMemo(() => {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of Object.values(model.nodes)) { minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); minY = Math.min(minY, n.y); maxY = Math.max(maxY, n.y); }
return { minX, minY, maxX, maxY };
}, [model]);
const toModel = (lon: number, lat: number) => { const [x, y] = lngLatToModel(lon, lat, crs, ext); return { x, y }; };
const [busy, setBusy] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const task = async (label: string, fn: () => Promise<void> | void) => {
setErr(null); setMsg(null); setBusy(label);
try { await fn(); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } finally { setBusy(null); }
};
const add = (l: UserGisLayer) => { onAddUserLayer?.(l); setMsg(`"${l.name}" 레이어를 추가했습니다 (${l.featureCount}개).`); };
// state for the forms
const [assignCrs, setAssignCrs] = useState<string>(crs !== 'LOCAL' ? crs : 'EPSG:5186');
const [expFmt, setExpFmt] = useState('shp');
const [expCrs, setExpCrs] = useState<string>(crs !== 'LOCAL' ? crs : 'EPSG:4326');
const [op, setOp] = useState('buffer');
const [dist, setDist] = useState('10');
const [otherId, setOtherId] = useState('');
const [dissolveField, setDissolveField] = useState('');
const [cell, setCell] = useState('100');
const [sheet, setSheet] = useState<{ name: string; rows: Record<string, unknown>[] } | null>(null);
const [xCol, setXCol] = useState('');
const [yCol, setYCol] = useState('');
const [ptCrs, setPtCrs] = useState<string>(crs !== 'LOCAL' ? crs : 'EPSG:4326');
const [tolM, setTolM] = useState('5');
const [idField, setIdField] = useState('');
const [statusField, setStatusField] = useState('');
const [closedValue, setClosedValue] = useState('폐');
const [flowField, setFlowField] = useState('');
const [flowUnit, setFlowUnit] = useState<'m3month' | 'm3day' | 'lps'>('m3month');
const [nameField, setNameField] = useState('');
const [table, setTable] = useState('');
const [tableKind, setTableKind] = useState<'link' | 'node'>('link');
const [img, setImg] = useState<{ file: File; world?: string; w: number; h: number } | null>(null);
const [imgExt, setImgExt] = useState('');
const images = rasterOverlays.filter((r) => r.id.startsWith('img-'));
const pickImage = async (files: FileList | null) => {
const list = [...(files ?? [])];
const file = list.find((f) => /\.(png|jpe?g|webp|gif)$/i.test(f.name));
if (!file) throw new Error('PNG/JPG 이미지를 고르세요 (월드 파일 .pgw/.jgw를 함께 고르면 위치가 자동으로 맞습니다).');
const worldFile = list.find((f) => /\.(pgw|jgw|pngw|jpgw|wld|tfw)$/i.test(f.name));
const url = URL.createObjectURL(file);
const dims = await new Promise<{ w: number; h: number }>((resolve, reject) => {
const im = new Image();
im.onload = () => resolve({ w: im.naturalWidth, h: im.naturalHeight });
im.onerror = () => reject(new Error('이미지를 읽을 수 없습니다.'));
im.src = url;
});
URL.revokeObjectURL(url);
setImg({ file, world: worldFile ? await worldFile.text() : undefined, ...dims });
setImgExt(`${ext.minX.toFixed(1)}, ${ext.minY.toFixed(1)}, ${ext.maxX.toFixed(1)}, ${ext.maxY.toFixed(1)}`);
};
const crsOptions = (value: string, onChange: (v: string) => void, withWgs = true) => (
<select value={value} onChange={(e) => onChange(e.target.value)} style={inputStyle}>
{PROJECTED.filter((d) => withWgs || d.code !== 'EPSG:4326').map((d) => <option key={d.code} value={d.code}>{d.name} ({d.code})</option>)}
</select>
);
const proj4Of = (code: string) => PROJECTED.find((d) => d.code === code)?.proj4Def ?? code;
if (!proj) return <div style={{ color: 'var(--text-secondary)', lineHeight: 1.5 }}>좌표계가 "로컬 평면"이면 GIS 레이어와 관망을 맞출 수 없습니다. 상단에서 관망 좌표계를 지정하세요.</div>;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-4)' }}>
{busy && <div style={{ color: 'var(--text-secondary)' }}>{busy}...</div>}
{err && <div style={{ color: 'var(--crit)' }}>{err}</div>}
{msg && <div style={{ color: 'var(--ok, #15803d)' }}>{msg}</div>}
<div style={box}>
<b>대상 레이어</b>
{userLayers.length ? (
<>
<select value={layer?.id ?? ''} onChange={(e) => setLayerId(e.target.value)} style={inputStyle} aria-label="대상 레이어">
{userLayers.map((l) => <option key={l.id} value={l.id}>{l.name} ({l.featureCount}, {l.geomTypes.join('/')})</option>)}
</select>
<span style={muted}>원본 좌표계: {layer?.sourceCrs ? (PROJECTED.find((d) => d.proj4Def === layer.sourceCrs)?.code ?? layer.sourceCrs.slice(0, 40)) : layer?.estimatedCrs}</span>
</>
) : <span style={muted}>GIS 레이어가 없습니다. 프로젝트 메뉴의 GIS 레이어 추가(SHP·DXF·DGN·MIF·GML·KML·GPKG·GeoJSON)로 넣거나, 아래 생성 기능을 쓰세요.</span>}
</div>
{layer && (
<div style={box}>
<b>좌표계 다시 지정 · 내보내기</b>
<div style={grid('1fr auto')}>
<label>실제 좌표계{crsOptions(assignCrs, setAssignCrs)}</label>
<button className="btn btn-secondary" style={btn} disabled={!!busy} onClick={() => void task('좌표 다시 계산', async () => {
const to = proj4Of(assignCrs);
const fc = await reprojectLayer(layer.featureCollection, layer.sourceCrs ?? 'EPSG:4326', to);
onSetUserLayers?.((prev) => prev.map((l) => (l.id === layer.id ? { ...l, featureCollection: fc, sourceCrs: to, estimatedCrs: assignCrs } : l)));
setMsg(`레이어 좌표를 ${assignCrs} 기준으로 다시 놓았습니다.`);
})}>다시 지정</button>
</div>
<span style={muted}>파일 좌표가 원래 이 좌표계였는데 다르게 읽혀 엉뚱한 곳에 놓였을 때 씁니다.</span>
<div style={grid('90px 1fr auto')}>
<label>형식<select value={expFmt} onChange={(e) => setExpFmt(e.target.value)} style={inputStyle}>
<option value="shp">SHP (zip)</option><option value="gpkg">GeoPackage</option><option value="dxf">DXF</option><option value="geojson">GeoJSON</option><option value="kml">KML</option>
</select></label>
<label>좌표계{crsOptions(expCrs, setExpCrs)}</label>
<button className="btn btn-secondary" style={btn} disabled={!!busy} onClick={() => void task('내보내는 중', () => exportLayerFile(layer.featureCollection, proj4Of(expCrs), expFmt, layer.name))}>내보내기</button>
</div>
</div>
)}
{layer && (
<div style={box}>
<b>도형 연산</b>
<div style={grid('110px 1fr auto')}>
<label>연산<select value={op} onChange={(e) => setOp(e.target.value)} style={inputStyle}>
<option value="buffer">버퍼</option><option value="dissolve">병합(디졸브)</option><option value="clip">잘라내기(교차)</option><option value="difference">빼기(차이)</option>
</select></label>
{op === 'buffer' && <label>거리 (m)<input type="number" value={dist} onChange={(e) => setDist(e.target.value)} style={inputStyle} /></label>}
{op === 'dissolve' && <label>기준 필드<select value={dissolveField} onChange={(e) => setDissolveField(e.target.value)} style={inputStyle}><option value="">(전체 하나로)</option>{fields.map((f) => <option key={f} value={f}>{f}</option>)}</select></label>}
{(op === 'clip' || op === 'difference') && <label>기준 레이어<select value={otherId} onChange={(e) => setOtherId(e.target.value)} style={inputStyle}><option value="">선택</option>{userLayers.filter((l) => l.id !== layer.id).map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}</select></label>}
<button className="btn btn-secondary" style={btn} disabled={!!busy || ((op === 'clip' || op === 'difference') && !otherId)} onClick={() => void task('계산 중', async () => {
const other = userLayers.find((l) => l.id === otherId)?.featureCollection;
const fc = await gisOp(op, { crs: proj, layer: layer.featureCollection, distance: Number(dist), field: dissolveField || null, other });
add(newLayer(`${layer.name} ${{ buffer: `버퍼 ${dist}m`, dissolve: '병합', clip: '잘라내기', difference: '빼기' }[op]}`, fc));
})}>실행</button>
</div>
</div>
)}
<div style={box}>
<b>레이어 만들기</b>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'end' }}>
<button className="btn btn-secondary" style={btn} disabled={!!busy} title="각 접합점이 가장 가까운 영역 (급수 면적 배분 등)" onClick={() => void task('보로노이 계산', async () => {
const js = Object.values(model.nodes).filter((n) => n.type === 'JUNCTION');
const fc = await gisOp('voronoi', { crs: proj, points: js.map((n) => [n.x, n.y]), ids: js.map((n) => n.id) });
add(newLayer('절점 보로노이', fc));
})}>절점 보로노이</button>
<label style={{ width: 90 }}>격자 (m)<input type="number" value={cell} onChange={(e) => setCell(e.target.value)} style={inputStyle} /></label>
<button className="btn btn-secondary" style={btn} disabled={!!busy} onClick={() => void task('격자 만들기', async () => {
const fc = await gisOp('grid', { crs: proj, bounds: [ext.minX, ext.minY, ext.maxX, ext.maxY], cell: Number(cell) });
add(newLayer(`격자 ${cell}m`, fc));
})}>관망 범위 격자</button>
<button className="btn btn-secondary" style={btn} disabled={!!busy} title="OpenStreetMap 건물 외곽선 (인터넷 필요)" onClick={() => void task('OSM 건물 불러오는 중', async () => {
const [w, s] = modelToLngLat(ext.minX, ext.minY, crs, ext);
const [e, n] = modelToLngLat(ext.maxX, ext.maxY, crs, ext);
const pad = 0.002;
const bbox = [Math.min(s, n) - pad, Math.min(w, e) - pad, Math.max(s, n) + pad, Math.max(w, e) + pad];
if ((bbox[2] - bbox[0]) * (bbox[3] - bbox[1]) > 0.0025) throw new Error('관망 범위가 너무 넓습니다 (약 25 km² 이하). 일부를 잘라 쓰세요.');
const q = `[out:json][timeout:60];(way["building"](${bbox.join(',')}););out geom;`;
const res = await fetch('https://overpass-api.de/api/interpreter', { method: 'POST', body: new URLSearchParams({ data: q }) });
if (!res.ok) throw new Error(`OSM 서버 오류 ${res.status} (잠시 뒤 다시 시도)`);
const data = await res.json() as { elements: Array<{ id: number; tags?: Record<string, string>; geometry?: Array<{ lat: number; lon: number }> }> };
const features: Feature[] = data.elements.filter((el) => (el.geometry?.length ?? 0) >= 4).map((el) => ({
type: 'Feature', properties: { osm_id: el.id, building: el.tags?.building ?? '', name: el.tags?.name ?? '', levels: el.tags?.['building:levels'] ?? '' },
geometry: { type: 'Polygon', coordinates: [el.geometry!.map((p) => [p.lon, p.lat])] },
}));
if (!features.length) throw new Error('관망 범위에 OSM 건물 자료가 없습니다.');
add(newLayer('OSM 건물', { type: 'FeatureCollection', features }));
})}>OSM 건물</button>
</div>
<div style={grid('1fr')}>
<label>엑셀·CSV 좌표 → 점 레이어
<input type="file" accept=".xlsx,.xls,.csv" onChange={async (e) => {
const f = e.target.files?.[0];
e.target.value = '';
if (!f) return;
await task('표 읽는 중', async () => {
const wb = XLSX.read(await f.arrayBuffer(), { type: 'array' });
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(wb.Sheets[wb.SheetNames[0]], { defval: '' });
if (!rows.length) throw new Error('행이 없습니다.');
const cols = Object.keys(rows[0]);
setSheet({ name: f.name.replace(/\.[^.]+$/, ''), rows });
setXCol(cols.find((c) => /^(x|경도|lon|lng|x좌표)/i.test(c)) ?? cols[0]);
setYCol(cols.find((c) => /^(y|위도|lat|y좌표)/i.test(c)) ?? cols[1] ?? cols[0]);
});
}} />
</label>
</div>
{sheet && (
<div style={grid('1fr 1fr 1.4fr auto')}>
<label>X 열<select value={xCol} onChange={(e) => setXCol(e.target.value)} style={inputStyle}>{Object.keys(sheet.rows[0]).map((c) => <option key={c} value={c}>{c}</option>)}</select></label>
<label>Y 열<select value={yCol} onChange={(e) => setYCol(e.target.value)} style={inputStyle}>{Object.keys(sheet.rows[0]).map((c) => <option key={c} value={c}>{c}</option>)}</select></label>
<label>좌표계{crsOptions(ptCrs, setPtCrs)}</label>
<button className="btn btn-secondary" style={btn} onClick={() => void task('점 만들기', () => {
const def = proj4Of(ptCrs);
const features: Feature[] = [];
let bad = 0;
for (const r of sheet.rows) {
const x = Number(r[xCol]), y = Number(r[yCol]);
if (!Number.isFinite(x) || !Number.isFinite(y)) { bad++; continue; }
const [lon, lat] = ptCrs === 'EPSG:4326' ? [x, y] : proj4(def, 'EPSG:4326', [x, y]);
features.push({ type: 'Feature', properties: r as Record<string, never>, geometry: { type: 'Point', coordinates: [lon, lat] } });
}
add(newLayer(sheet.name, { type: 'FeatureCollection', features }, ptCrs === 'EPSG:4326' ? undefined : def));
if (bad) setMsg(`점 ${features.length}개를 만들었습니다 (좌표 없는 행 ${bad}개 제외).`);
})}>점 레이어 추가</button>
</div>
)}
</div>
{layer && (
<div style={box}>
<b>관망에 반영</b>
{isPoint && (
<>
<div style={grid('1fr 1fr 1fr 70px')}>
<label>밸브 ID 필드<select value={idField} onChange={(e) => setIdField(e.target.value)} style={inputStyle}><option value="">(자동 번호)</option>{fields.map((f) => <option key={f} value={f}>{f}</option>)}</select></label>
<label>개폐 필드<select value={statusField} onChange={(e) => setStatusField(e.target.value)} style={inputStyle}><option value="">(모두 열림)</option>{fields.map((f) => <option key={f} value={f}>{f}</option>)}</select></label>
<label>닫힘 값<input value={closedValue} onChange={(e) => setClosedValue(e.target.value)} disabled={!statusField} style={inputStyle} /></label>
<label>허용 (m)<input type="number" value={tolM} onChange={(e) => setTolM(e.target.value)} style={inputStyle} /></label>
</div>
<button className="btn btn-secondary" style={{ ...btn, alignSelf: 'flex-start' }} onClick={() => void task('밸브 넣는 중', () => {
const specs = pointsOf(layer).map((p) => ({ ...toModel(p.lon, p.lat), id: idField ? String(p.props[idField] ?? '') : undefined, closed: !!statusField && String(p.props[statusField] ?? '').trim() === closedValue.trim() }));
const r = insertValves(model, specs, Number(tolM) || 5);
onApplyModel(r.model, `밸브 ${r.added.length}개를 관로에 넣었습니다${r.skipped ? ` (관로에서 ${tolM}m 넘게 떨어졌거나 끝점 위인 ${r.skipped}개 제외)` : ''}.`);
})}>점을 밸브로 추가 (가장 가까운 관로를 나눠 TCV 삽입)</button>
<div style={grid('1fr 110px 70px')}>
<label>사용량 필드<select value={flowField} onChange={(e) => setFlowField(e.target.value)} style={inputStyle}><option value="">선택</option>{fields.map((f) => <option key={f} value={f}>{f}</option>)}</select></label>
<label>단위<select value={flowUnit} onChange={(e) => setFlowUnit(e.target.value as typeof flowUnit)} style={inputStyle}><option value="m3month">m³/월</option><option value="m3day">m³/일</option><option value="lps">L/s</option></select></label>
<label>허용 (m)<input type="number" value={tolM} onChange={(e) => setTolM(e.target.value)} style={inputStyle} /></label>
</div>
<button className="btn btn-secondary" style={{ ...btn, alignSelf: 'flex-start' }} disabled={!flowField} onClick={() => void task('계량기 배정 중', () => {
const toM3Day = (v: number) => (flowUnit === 'm3month' ? v / 30 : flowUnit === 'lps' ? v * 86.4 : v);
const meters = pointsOf(layer).map((p) => ({ ...toModel(p.lon, p.lat), flow: m3DayToFlow(toM3Day(Number(p.props[flowField])), model.options.units) }));
const r = assignMeters(model, meters, Number(tolM) || 5);
onApplyModel(r.model, `계량기 ${meters.length - r.unassigned}개를 접합점 ${Object.keys(r.nodes).length}곳의 "계량기" 수요로 배정했습니다${r.unassigned ? ` (${r.unassigned}개는 허용 거리 밖·값 없음)` : ''}.`);
})}>계량기 배정 (가장 가까운 접합점의 "계량기" 수요 범주)</button>
</>
)}
{isPolygon && (
<div style={grid('1fr auto')}>
<label>블록 이름 필드<select value={nameField} onChange={(e) => setNameField(e.target.value)} style={inputStyle}><option value="">(도형 번호)</option>{fields.map((f) => <option key={f} value={f}>{f}</option>)}</select></label>
<button className="btn btn-secondary" style={btn} onClick={() => void task('소블록 매핑', () => {
const map = blocksFromPolygons(model, layer.featureCollection, (x, y) => modelToLngLat(x, y, crs, ext), nameField || undefined);
const r = writeBlocks(model, map);
onApplyModel(r.model, `소블록: 절점 ${r.nodes}개, 관로 ${r.links}개의 자산 속성 "소블록"을 채웠습니다.`);
})}>소블록 매핑</button>
</div>
)}
{!isPoint && !isPolygon && <span style={muted}>점 레이어는 밸브·계량기, 면 레이어는 소블록 매핑에 씁니다. 선 레이어는 프로젝트 메뉴의 "GIS로 관망 만들기"를 쓰세요.</span>}
</div>
)}
<div style={box}>
<b>이미지 겹치기 (항공사진·도면)</b>
<span style={muted}>PNG/JPG와 월드 파일(.pgw/.jgw)을 함께 고르면 관망 좌표계 기준으로 제자리에 놓입니다. 월드 파일이 없으면 범위(최소X, 최소Y, 최대X, 최대Y)를 입력하세요.</span>
<input type="file" multiple accept=".png,.jpg,.jpeg,.webp,.gif,.pgw,.jgw,.pngw,.jpgw,.wld,.tfw" onChange={(e) => { const fl = e.target.files; void task('이미지 읽는 중', () => pickImage(fl)); e.target.value = ''; }} />
{img && (
<>
<span>{img.file.name} · {img.w}×{img.h}px · {img.world ? '월드 파일 있음' : '범위 직접 입력'}</span>
{!img.world && <input value={imgExt} onChange={(e) => setImgExt(e.target.value)} placeholder="최소X, 최소Y, 최대X, 최대Y (관망 좌표계)" style={inputStyle} aria-label="이미지 범위" />}
<button className="btn btn-secondary" style={{ ...btn, alignSelf: 'flex-start' }} onClick={() => void task('이미지 놓는 중', () => {
const corners = img.world ? worldFileCorners(img.world, img.w, img.h) : extentCorners(...(imgExt.split(/[\s,]+/).filter(Boolean).map(Number) as [number, number, number, number]));
const id = `img-${Date.now()}`;
onRasterOverlays?.((prev) => [...prev, { id, name: img.file.name, url: URL.createObjectURL(img.file), corners: corners.map((c) => modelToLngLat(c.x, c.y, crs, ext)), opacity: 0.7, visible: true }]);
setImg(null);
setMsg(`이미지 "${img.file.name}"를 지도에 겹쳤습니다.`);
})}>지도에 겹치기</button>
</>
)}
{images.map((r) => (
<div key={r.id} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<label style={{ flex: 1 }}><input type="checkbox" checked={r.visible} onChange={(e) => onRasterOverlays?.((prev) => prev.map((x) => (x.id === r.id ? { ...x, visible: e.target.checked } : x)))} /> {r.name}</label>
<input type="range" min={0.1} max={1} step={0.05} value={r.opacity} aria-label={`${r.name} 투명도`} onChange={(e) => onRasterOverlays?.((prev) => prev.map((x) => (x.id === r.id ? { ...x, opacity: Number(e.target.value) } : x)))} />
<button className="btn btn-ghost" style={{ height: 20, fontSize: 11 }} onClick={() => { URL.revokeObjectURL(r.url); onRasterOverlays?.((prev) => prev.filter((x) => x.id !== r.id)); }}>삭제</button>
</div>
))}
</div>
<div style={box}>
<b>자산 속성 일괄 입력</b>
<span style={muted}>엑셀에서 첫 행 머리글(ID, 관재질, 설치년도, 매설깊이(m), 소블록 ...)과 함께 복사해 붙여넣으세요.</span>
<textarea rows={5} value={table} onChange={(e) => setTable(e.target.value)} placeholder={'ID\t관재질\t설치년도\nP-1\tDCIP\t1998'} style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }} />
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<label><input type="radio" checked={tableKind === 'link'} onChange={() => setTableKind('link')} /> 관로·링크</label>
<label><input type="radio" checked={tableKind === 'node'} onChange={() => setTableKind('node')} /> 절점</label>
<button className="btn btn-secondary" style={btn} disabled={!table.trim()} onClick={() => void task('속성 입력', () => {
const r = applyAssetTable(model, table, tableKind);
onApplyModel(r.model, `${tableKind === 'link' ? '링크' : '절점'} ${r.updated}개의 속성(${r.fields.join(', ')})을 넣었습니다${r.unknown.length ? ` (없는 ID ${r.unknown.length}개)` : ''}.`);
})}>적용</button>
</div>
</div>
</div>
);
};
+127
View File
@@ -0,0 +1,127 @@
import React from 'react';
import { Compass, Pencil, Activity, Search, Undo2, Redo2 } from 'lucide-react';
import { type SimulationResult, type SelectionTarget } from './types';
import { type NetworkModel } from './inpModel';
export interface HeaderCenterProps {
appMode: 'explore' | 'edit' | 'result';
setAppMode: React.Dispatch<React.SetStateAction<'explore' | 'edit' | 'result'>>;
wasmResult: SimulationResult | null;
networkModel: NetworkModel | null;
undoStack: NetworkModel[];
redoStack: NetworkModel[];
handleUndo: () => void;
handleRedo: () => void;
searchInputRef: React.RefObject<HTMLInputElement>;
searchQuery: string;
setSearchQuery: React.Dispatch<React.SetStateAction<string>>;
setSelectedTarget: React.Dispatch<React.SetStateAction<SelectionTarget>>;
setActiveTab: React.Dispatch<React.SetStateAction<import('./types').RightTab | null>>;
}
/** Header center: mode segment (탐색/편집/결과), undo/redo, quick search (Ctrl+K). */
export const HeaderCenter: React.FC<HeaderCenterProps> = ({
appMode,
setAppMode,
wasmResult,
networkModel,
undoStack,
redoStack,
handleUndo,
handleRedo,
searchInputRef,
searchQuery,
setSearchQuery,
setSelectedTarget,
setActiveTab,
}) => (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{/* Mode Segment: 탐색(1) | 편집(2) | 결과(3) */}
<div className="mode-segment">
<button
onClick={() => setAppMode('explore')}
className={`mode-btn ${appMode === 'explore' ? 'active' : ''}`}
title="탐색 모드 (단축키 1) - 요소 정보 조회 및 이동"
>
<Compass size={13} />
<span>탐색</span>
</button>
<button
onClick={() => setAppMode('edit')}
className={`mode-btn ${appMode === 'edit' ? 'active' : ''}`}
title="편집 모드 (단축키 2) - 요소 작도, 수정, 연결"
>
<Pencil size={13} />
<span>편집</span>
</button>
<button
onClick={() => {
if (wasmResult) setAppMode('result');
}}
disabled={!wasmResult}
className={`mode-btn ${appMode === 'result' ? 'active' : ''}`}
title={wasmResult ? '결과 모드 (단축키 3) - 시계열 및 수리 결과' : '먼저 해석을 실행하세요'}
>
<Activity size={13} />
<span>결과</span>
</button>
</div>
{/* Undo / Redo Buttons */}
<div style={{ display: 'flex', gap: '2px' }}>
<button
type="button"
className="btn btn-secondary"
onClick={handleUndo}
disabled={undoStack.length === 0 || !networkModel}
title="실행 취소 (Ctrl+Z)"
style={{ padding: '0 7px', height: '28px' }}
>
<Undo2 size={13} />
</button>
<button
type="button"
className="btn btn-secondary"
onClick={handleRedo}
disabled={redoStack.length === 0 || !networkModel}
title="다시 실행 (Ctrl+Y / Ctrl+Shift+Z)"
style={{ padding: '0 7px', height: '28px' }}
>
<Redo2 size={13} />
</button>
</div>
{/* Quick Search (Ctrl+K) */}
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<Search
size={13}
style={{ position: 'absolute', left: '8px', color: 'var(--text-muted)', pointerEvents: 'none' }}
/>
<input
ref={searchInputRef}
type="text"
placeholder="검색 (Ctrl+K)"
value={searchQuery}
onChange={(e) => {
const q = e.target.value;
setSearchQuery(q);
if (!networkModel || !q.trim()) return;
const trimmed = q.trim();
if (networkModel.nodes[trimmed]) {
setSelectedTarget({ type: 'node', id: trimmed });
setActiveTab('inspector');
} else if (networkModel.pipes[trimmed] || networkModel.pumps[trimmed] || networkModel.valves?.[trimmed]) {
setSelectedTarget({ type: 'link', id: trimmed });
setActiveTab('inspector');
}
}}
style={{
width: '140px',
height: '28px',
paddingLeft: '26px',
fontSize: '11px',
}}
/>
</div>
</div>
);
+133
View File
@@ -0,0 +1,133 @@
import React from 'react';
import { Ruler } from 'lucide-react';
import type { InpNode, InpPipe } from './inpModel';
interface HglProfileViewerProps {
pipe: InpPipe;
node1: InpNode;
node2: InpNode;
node1Head: number;
node2Head: number;
node1Pressure: number;
node2Pressure: number;
unit: string;
pressureUnit?: string;
}
export const HglProfileViewer: React.FC<HglProfileViewerProps> = ({
pipe,
node1,
node2,
node1Head,
node2Head,
node1Pressure,
node2Pressure,
unit,
pressureUnit = unit,
}) => {
const elev1 = node1.elevation;
const elev2 = node2.elevation;
const hgl1 = node1Head || elev1 + node1Pressure;
const hgl2 = node2Head || elev2 + node2Pressure;
// Chart dimensions
const width = 320;
const height = 110;
const padLeft = 32;
const padRight = 16;
const padTop = 14;
const padBottom = 22;
const plotWidth = width - padLeft - padRight;
const plotHeight = height - padTop - padBottom;
const minVal = Math.min(elev1, elev2, hgl1, hgl2, 0);
const maxVal = Math.max(elev1, elev2, hgl1, hgl2, 10);
const range = maxVal - minVal || 1;
const getY = (val: number) => {
return padTop + plotHeight - ((val - minVal) / range) * plotHeight;
};
const x1 = padLeft;
const x2 = padLeft + plotWidth;
const yElev1 = getY(elev1);
const yElev2 = getY(elev2);
const yHgl1 = getY(hgl1);
const yHgl2 = getY(hgl2);
return (
<div
style={{
background: 'var(--bg-panel)',
border: '1px solid #e2e8f0',
borderRadius: '6px',
padding: '10px',
marginTop: '10px',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
<span style={{ fontWeight: 600, fontSize: '12px', color: 'var(--text)', display: 'inline-flex', alignItems: 'center', gap: 'var(--s-2)' }}>
<Ruler size={13} aria-hidden="true" />
관로 종단 수리동수두선 (HGL Profile)
</span>
<span style={{ fontSize: '10px', color: 'var(--text-secondary)' }}>연장: {pipe.length}</span>
</div>
<svg viewBox={`0 0 ${width} ${height}`} style={{ width: '100%', height: '110px' }}>
{/* Ground fill polygon */}
<polygon
points={`${x1},${yElev1} ${x2},${yElev2} ${x2},${padTop + plotHeight} ${x1},${padTop + plotHeight}`}
fill="#f1f5f9"
stroke="none"
/>
{/* Pressure water column fill between Ground and HGL */}
<polygon
points={`${x1},${yHgl1} ${x2},${yHgl2} ${x2},${yElev2} ${x1},${yElev1}`}
fill="rgba(59, 130, 246, 0.15)"
stroke="none"
/>
{/* Ground Elevation Line (Brown/Slate) */}
<line x1={x1} y1={yElev1} x2={x2} y2={yElev2} stroke="#78716c" strokeWidth="2.5" />
{/* HGL Hydraulic Grade Line (Blue) */}
<line x1={x1} y1={yHgl1} x2={x2} y2={yHgl2} stroke="#2563eb" strokeWidth="2" strokeDasharray="4,2" />
{/* Points & Labels at Node 1 */}
<circle cx={x1} cy={yHgl1} r="3" fill="#2563eb" />
<circle cx={x1} cy={yElev1} r="3" fill="#78716c" />
<text x={x1} y={yHgl1 - 4} fontSize="8" fontWeight="bold" fill="#2563eb" textAnchor="start">
HGL {hgl1.toFixed(1)}
</text>
<text x={x1} y={height - 4} fontSize="8" fill="#475569" textAnchor="start">
{pipe.node1} (지반 {elev1.toFixed(1)})
</text>
{/* Points & Labels at Node 2 */}
<circle cx={x2} cy={yHgl2} r="3" fill="#2563eb" />
<circle cx={x2} cy={yElev2} r="3" fill="#78716c" />
<text x={x2} y={yHgl2 - 4} fontSize="8" fontWeight="bold" fill="#2563eb" textAnchor="end">
HGL {hgl2.toFixed(1)}
</text>
<text x={x2} y={height - 4} fontSize="8" fill="#475569" textAnchor="end">
{pipe.node2} (지반 {elev2.toFixed(1)})
</text>
</svg>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', color: 'var(--text-secondary)', marginTop: '4px', borderTop: '1px solid #f8fafc', paddingTop: '4px' }}>
<span>
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>■ 동수압</span>: {node1Pressure.toFixed(1)} $\to$ {node2Pressure.toFixed(1)} {pressureUnit}
</span>
<span>
<span style={{ color: '#78716c', fontWeight: 600 }}>■ 지반고차</span>: {(elev2 - elev1).toFixed(1)}
</span>
<span>
<span style={{ color: 'var(--crit)', fontWeight: 600 }}>■ 마찰손실</span>: {Math.abs(hgl1 - hgl2).toFixed(2)}
</span>
</div>
</div>
);
};
+544
View File
@@ -0,0 +1,544 @@
import React, { useState } from 'react';
import { Filter, Plus, Trash2, Bookmark } from 'lucide-react';
import type { NetworkModel } from './inpModel';
import type { MultiSelectionState, SelectionTarget, SimulationResult } from './types';
import {
computeUnitHeadloss,
diameterToMillimeters,
flowToM3Day,
getDisplayUnits,
headToMeters,
lengthToMeters,
pressureToMeters,
resolvePressureUnit,
velocityToMetersPerSecond,
type DisplayUnitMode,
createDisplayConverter,
} from './unitSystem';
import { rowMatchesAllConditions, type FilterCondition, type FilterOperator } from './resultSync';
const NODE_FILTER_FIELDS: Array<{ key: string; label: string }> = [
{ key: 'pressure', label: '동수압' },
{ key: 'demand', label: '수요량' },
{ key: 'head', label: '수리동수두' },
{ key: 'elev', label: '지반고' },
];
const LINK_FILTER_FIELDS: Array<{ key: string; label: string }> = [
{ key: 'flow', label: '유량' },
{ key: 'velocity', label: '유속' },
{ key: 'headloss', label: '손실수두(총)' },
{ key: 'gradient', label: '동수경사' },
{ key: 'diameter', label: '관경' },
{ key: 'length', label: '연장' },
];
const FILTER_OPERATORS: FilterOperator[] = ['<', '<=', '>', '>=', '=='];
interface SavedSelectionSet {
name: string;
subTab: 'nodes' | 'links';
ids: string[];
}
interface HydraulicTableViewerProps {
model: NetworkModel;
simulationResult: SimulationResult | null;
displayUnitMode?: DisplayUnitMode;
timeStepIndex: number;
selectedTarget: SelectionTarget;
multiSelection?: MultiSelectionState;
onMultiSelectionChange?: React.Dispatch<React.SetStateAction<MultiSelectionState>>;
onSelectTarget: (t: SelectionTarget) => void;
/**
* Project-wide judgement criteria (same setting the map's violation
* highlighting uses, user-adjustable in App.tsx) -- kept in sync here
* instead of this table defining its own separate minimum-pressure/
* maximum-velocity standard that silently disagreed with the map's.
*/
minPressureThreshold?: number;
maxVelocityThreshold?: number;
}
export const HydraulicTableViewer: React.FC<HydraulicTableViewerProps> = ({
model,
simulationResult,
displayUnitMode = 'korean',
minPressureThreshold = 15,
maxVelocityThreshold = 2.5,
timeStepIndex,
selectedTarget,
multiSelection,
onMultiSelectionChange,
onSelectTarget,
}) => {
const [subTab, setSubTab] = useState<'nodes' | 'links'>('nodes');
const [onlyWarnings, setOnlyWarnings] = useState(false);
// Compound numeric filter (P2-1), e.g. "pressure < 15 AND velocity > 2" --
// kept separately per table since node/link fields don't overlap.
const [nodeConditions, setNodeConditions] = useState<FilterCondition[]>([]);
const [linkConditions, setLinkConditions] = useState<FilterCondition[]>([]);
const [savedSets, setSavedSets] = useState<SavedSelectionSet[]>([]);
const activeConditions = subTab === 'nodes' ? nodeConditions : linkConditions;
const setActiveConditions = subTab === 'nodes' ? setNodeConditions : setLinkConditions;
const activeFilterFields = subTab === 'nodes' ? NODE_FILTER_FIELDS : LINK_FILTER_FIELDS;
const sourceUnits = model.options.units || simulationResult?.units.flow || 'LPS';
// The engine's own reported pressure unit (psi/kPa/m/bar/ft) can differ
// from what the flow unit's US/SI system implies (EPANET 2.2+ EN_PRESSUNITS),
// so it -- not a guess from the flow unit -- drives both the displayed
// value and the low-pressure warning threshold below.
const resultPressureUnit = resolvePressureUnit(simulationResult?.units?.pressure, sourceUnits);
const displayUnits = getDisplayUnits(sourceUnits, displayUnitMode, resultPressureUnit);
const units = createDisplayConverter(displayUnitMode, sourceUnits, resultPressureUnit, model.options.specificGravity);
const displayHead = units.head;
const displayFlow = units.flow;
const displayPressure = units.pressure;
const displayLength = units.length;
const displayDiameter = units.diameter;
const displayVelocity = units.velocity;
const formatSecs = (secs: number) => {
const hh = String(Math.floor(secs / 3600)).padStart(2, '0');
return `${hh}:00`;
};
const currentTimeStr = simulationResult?.timeSteps
? formatSecs(simulationResult.timeSteps[timeStepIndex] ?? 0)
: '00:00';
// 1. Process Nodes data
const nodeRows = Object.values(model.nodes).map((node, idx) => {
const res = simulationResult?.nodes[node.id];
const elev = displayHead(node.elevation);
const rawDemand = res?.timeSeries
? (res.timeSeries.demand[timeStepIndex] ?? res.demand)
: (res?.demand ?? (node.demand ?? 0));
const rawHead = res?.timeSeries
? (res.timeSeries.head[timeStepIndex] ?? res.head)
: (res?.head ?? node.elevation);
const rawPressure = res?.timeSeries
? (res.timeSeries.pressure[timeStepIndex] ?? res.pressure)
: (res?.pressure ?? 0);
const demand = displayFlow(rawDemand);
const head = displayHead(rawHead);
const pressure = displayPressure(rawPressure);
const pressureMeters = pressureToMeters(rawPressure, resultPressureUnit, model.options.specificGravity);
let statusType: 'ok' | 'warn' | 'info' = 'ok';
let statusText = '적정';
if (node.type === 'JUNCTION') {
const minThreshold = minPressureThreshold;
const maxThreshold = 70;
if (pressureMeters < minThreshold) {
statusType = 'warn';
statusText = '수압부족';
} else if (pressureMeters > maxThreshold) {
statusType = 'warn';
statusText = '과압';
}
} else {
statusType = 'info';
statusText = node.type === 'RESERVOIR' ? '상수원' : '배수지';
}
return {
idx: idx + 1,
id: node.id,
type: node.type,
elev,
demand: Math.round(demand * 100) / 100,
head: Math.round(head * 100) / 100,
pressure: Math.round(pressure * 100) / 100,
statusType,
statusText,
};
});
// 2. Process Links data
const linkRows = Object.values(model.pipes).map((pipe, idx) => {
const res = simulationResult?.links[pipe.id];
const length = displayLength(pipe.length);
const rawFlow = res?.timeSeries
? (res.timeSeries.flow[timeStepIndex] ?? res.flow)
: (res?.flow ?? 0);
const rawVelocity = res?.timeSeries
? (res.timeSeries.velocity[timeStepIndex] ?? res.velocity)
: (res?.velocity ?? 0);
const rawHeadloss = res?.timeSeries
? (res.timeSeries.headloss[timeStepIndex] ?? res.headloss)
: (res?.headloss ?? 0);
const flow = displayFlow(rawFlow);
const velocity = displayVelocity(rawVelocity);
const velocityMeters = velocityToMetersPerSecond(rawVelocity, sourceUnits);
// EN_HEADLOSS is the TOTAL head difference across the whole pipe, in the
// model's head unit (feet for a US-unit model) -- convert it for display
// just like every other head-length quantity, and keep it clearly
// distinct from the length-normalized gradient below (a longer pipe
// naturally accumulates more total headloss without being "worse").
const headloss = displayHead(rawHeadloss);
// 동수경사 (hydraulic gradient, per-mille): headloss normalized by pipe
// length, in the SAME display unit for both -- so it stays a valid
// per-pipe comparison metric regardless of unit system or pipe length.
const gradient = Math.round(computeUnitHeadloss(Math.abs(headloss), length) * 100) / 100;
let statusType: 'ok' | 'warn' | 'info' = 'ok';
let statusText = '적정';
const minVel = 0.3;
const maxVel = maxVelocityThreshold;
if (Math.abs(rawFlow) < 1e-9) {
statusType = 'info';
statusText = '정체/무유량';
} else if (velocityMeters < minVel) {
statusType = 'warn';
statusText = '유속미달';
} else if (velocityMeters > maxVel) {
statusType = 'warn';
statusText = '유속과다';
}
return {
idx: idx + 1,
id: pipe.id,
node1: pipe.node1,
node2: pipe.node2,
diameter: displayDiameter(pipe.diameter),
length,
roughness: pipe.roughness,
flow: Math.round(flow * 100) / 100,
velocity: Math.round(velocity * 100) / 100,
headloss: Math.round(headloss * 100) / 100,
gradient,
statusType,
statusText,
pipeStatus: pipe.status,
};
});
const filteredNodeRows = nodeRows
.filter((r) => !onlyWarnings || r.statusType === 'warn')
.filter((r) => rowMatchesAllConditions(r, nodeConditions));
const filteredLinkRows = linkRows
.filter((r) => !onlyWarnings || r.statusType === 'warn')
.filter((r) => rowMatchesAllConditions(r, linkConditions));
const addCondition = () => {
setActiveConditions([...activeConditions, { field: activeFilterFields[0].key, operator: '<', value: 0 }]);
};
const updateCondition = (idx: number, patch: Partial<FilterCondition>) => {
setActiveConditions(activeConditions.map((c, i) => (i === idx ? { ...c, ...patch } : c)));
};
const removeCondition = (idx: number) => {
setActiveConditions(activeConditions.filter((_, i) => i !== idx));
};
const activeFilteredRows = subTab === 'nodes' ? filteredNodeRows : filteredLinkRows;
const saveCurrentFilterAsSet = () => {
if (activeFilteredRows.length === 0) {
return;
}
const name = window.prompt(
`현재 필터 결과(${activeFilteredRows.length}개)를 선택 집합으로 저장합니다. 이름을 입력하세요:`,
`${subTab === 'nodes' ? '절점' : '관로'} 필터 ${savedSets.length + 1}`
);
if (!name || !name.trim()) return;
setSavedSets([
...savedSets,
{ name: name.trim(), subTab, ids: activeFilteredRows.map((r) => r.id) },
]);
};
const applySavedSet = (set: SavedSelectionSet) => {
setSubTab(set.subTab);
if (onMultiSelectionChange) {
onMultiSelectionChange({
nodes: new Set(set.subTab === 'nodes' ? set.ids : []),
links: new Set(set.subTab === 'links' ? set.ids : []),
gisFeatures: [],
});
}
};
const removeSavedSet = (idx: number) => {
setSavedSets(savedSets.filter((_, i) => i !== idx));
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-5)', fontSize: 'var(--fs-sm)' }}>
{/* Sub tabs and filters */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid var(--border)', paddingBottom: 'var(--s-3)' }}>
<div style={{ display: 'flex', gap: 'var(--s-2)' }}>
<button
onClick={() => setSubTab('nodes')}
className={`btn btn-secondary ${subTab === 'nodes' ? 'btn-primary' : ''}`}
style={{ height: 'var(--h-btn-sm)', padding: '0 var(--s-5)', fontSize: 'var(--fs-sm)' }}
>
절점 수리계산서 ({nodeRows.length})
</button>
<button
onClick={() => setSubTab('links')}
className={`btn btn-secondary ${subTab === 'links' ? 'btn-primary' : ''}`}
style={{ height: 'var(--h-btn-sm)', padding: '0 var(--s-5)', fontSize: 'var(--fs-sm)' }}
>
관로 수리계산서 ({linkRows.length})
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-4)', fontSize: 'var(--fs-xs)', color: 'var(--text-secondary)' }}>
<span style={{ fontWeight: 'var(--fw-semi)', color: 'var(--text)' }}>{currentTimeStr} 시점</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-2)', cursor: 'pointer' }}>
<input
type="checkbox"
checked={onlyWarnings}
onChange={(e) => setOnlyWarnings(e.target.checked)}
/>
<span>기준 미달만 보기</span>
</label>
</div>
</div>
{/* Compound numeric condition filter (P2-1): e.g. "pressure < 15 AND
velocity > 2" -- every condition must hold (AND) for a row to show.
The currently filtered set can be saved and re-selected later. */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-2)', padding: 'var(--s-3)', background: 'var(--bg-subtle)', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-2)', fontWeight: 'var(--fw-semi)', fontSize: 'var(--fs-xs)' }}>
<Filter size={12} aria-hidden="true" />
조건 필터 (AND) -- {activeFilteredRows.length}개 일치
</span>
<div style={{ display: 'flex', gap: 'var(--s-2)' }}>
<button type="button" className="btn btn-secondary" onClick={addCondition} style={{ height: '22px', padding: '0 8px', fontSize: 'var(--fs-2xs)', display: 'inline-flex', alignItems: 'center', gap: '3px' }}>
<Plus size={11} aria-hidden="true" />
조건 추가
</button>
<button
type="button"
className="btn btn-secondary"
onClick={saveCurrentFilterAsSet}
disabled={activeFilteredRows.length === 0}
title="현재 필터에 일치하는 절점/관로를 이름 붙여 저장하고 나중에 다시 선택할 수 있습니다."
style={{ height: '22px', padding: '0 8px', fontSize: 'var(--fs-2xs)', display: 'inline-flex', alignItems: 'center', gap: '3px' }}
>
<Bookmark size={11} aria-hidden="true" />
선택 집합으로 저장
</button>
</div>
</div>
{activeConditions.map((cond, idx) => (
<div key={idx} style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-2)', fontSize: 'var(--fs-xs)' }}>
{idx > 0 && <span style={{ color: 'var(--text-muted)', fontWeight: 'var(--fw-semi)' }}>AND</span>}
<select
value={cond.field}
onChange={(e) => updateCondition(idx, { field: e.target.value })}
style={{ padding: '2px 4px', fontSize: 'var(--fs-xs)' }}
>
{activeFilterFields.map((f) => (
<option key={f.key} value={f.key}>{f.label}</option>
))}
</select>
<select
value={cond.operator}
onChange={(e) => updateCondition(idx, { operator: e.target.value as FilterOperator })}
style={{ padding: '2px 4px', fontSize: 'var(--fs-xs)', fontFamily: 'var(--font-mono)' }}
>
{FILTER_OPERATORS.map((op) => (
<option key={op} value={op}>{op}</option>
))}
</select>
<input
type="number"
value={cond.value}
onChange={(e) => updateCondition(idx, { value: Number(e.target.value) || 0 })}
style={{ width: '90px', padding: '2px 4px', fontSize: 'var(--fs-xs)', fontFamily: 'var(--font-mono)' }}
/>
<button
type="button"
onClick={() => removeCondition(idx)}
aria-label="조건 삭제"
title="조건 삭제"
style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--crit)', display: 'inline-flex', alignItems: 'center' }}
>
<Trash2 size={12} aria-hidden="true" />
</button>
</div>
))}
{savedSets.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-1)', paddingTop: 'var(--s-2)', borderTop: '1px solid var(--border)' }}>
<span style={{ fontSize: 'var(--fs-2xs)', color: 'var(--text-secondary)' }}>저장된 선택 집합:</span>
{savedSets.map((set, idx) => (
<div key={idx} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 'var(--fs-xs)' }}>
<span>
{set.name} ({set.subTab === 'nodes' ? '절점' : '관로'} {set.ids.length}개)
</span>
<div style={{ display: 'flex', gap: 'var(--s-2)' }}>
<button type="button" className="btn btn-secondary" onClick={() => applySavedSet(set)} style={{ height: '20px', padding: '0 6px', fontSize: 'var(--fs-2xs)' }}>
선택
</button>
<button type="button" onClick={() => removeSavedSet(idx)} aria-label="저장된 집합 삭제" style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--crit)', display: 'inline-flex', alignItems: 'center' }}>
<Trash2 size={12} aria-hidden="true" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Nodes Table */}
{subTab === 'nodes' && (
<div style={{ maxHeight: '380px', overflowY: 'auto', border: '1px solid var(--border-strong)', borderRadius: 'var(--r-sm)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-xs)', whiteSpace: 'nowrap' }}>
<thead>
<tr style={{ background: 'var(--bg-subtle)', position: 'sticky', top: 0, zIndex: 2, borderBottom: '1px solid var(--border-strong)' }}>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'center' }}>No</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'left' }}>절점 ID</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'left' }}>유형</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>지반고 ({displayUnits.head})</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>수요량 ({displayUnits.demand})</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>수리동수두 ({displayUnits.head})</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>동수압 ({displayUnits.pressure})</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'center' }}>수압 판정</th>
</tr>
</thead>
<tbody>
{filteredNodeRows.map((r) => {
const isSelected =
(selectedTarget?.type === 'node' && selectedTarget.id === r.id) ||
Boolean(multiSelection?.nodes.has(r.id));
return (
<tr
key={r.id}
onClick={(e) => {
if (e.shiftKey || e.ctrlKey || e.metaKey) {
if (onMultiSelectionChange) {
const next: MultiSelectionState = {
nodes: new Set(multiSelection?.nodes ?? []),
links: new Set(multiSelection?.links ?? []),
gisFeatures: [...(multiSelection?.gisFeatures ?? [])],
};
if (selectedTarget?.type === 'node') next.nodes.add(selectedTarget.id);
if (next.nodes.has(r.id)) next.nodes.delete(r.id);
else next.nodes.add(r.id);
onMultiSelectionChange(next);
}
} else {
onSelectTarget({ type: 'node', id: r.id });
}
}}
style={{
cursor: 'pointer',
borderBottom: '1px solid var(--border)',
background: isSelected ? 'var(--accent-wash)' : 'transparent',
}}
>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'center', color: 'var(--text-disabled)' }}>{r.idx}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', fontWeight: 'var(--fw-semi)', color: 'var(--accent)' }}>{r.id}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', color: 'var(--text-secondary)' }}>{r.type}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{r.elev.toFixed(1)}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{r.demand.toFixed(1)}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{r.head.toFixed(2)}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)', fontWeight: 'var(--fw-semi)' }}>{r.pressure.toFixed(2)}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'center' }}>
<span
className={
r.statusType === 'warn' ? 'badge-crit' : r.statusType === 'info' ? 'badge-neutral' : 'badge-good'
}
style={{ fontSize: 'var(--fs-2xs)' }}
>
{r.statusText}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Links Table */}
{subTab === 'links' && (
<div style={{ maxHeight: '380px', overflowY: 'auto', border: '1px solid var(--border-strong)', borderRadius: 'var(--r-sm)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-xs)', whiteSpace: 'nowrap' }}>
<thead>
<tr style={{ background: 'var(--bg-subtle)', position: 'sticky', top: 0, zIndex: 2, borderBottom: '1px solid var(--border-strong)' }}>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'center' }}>No</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'left' }}>관로 ID</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'center' }}>시작절점 → 끝절점</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>관경 ({displayUnits.diameter})</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>연장 ({displayUnits.length})</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>조도계수</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>유량 ({displayUnits.flow})</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>유속 ({displayUnits.velocity})</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>손실수두 (총, m)</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'right' }}>동수경사(‰)</th>
<th style={{ padding: 'var(--s-3) var(--s-4)', textAlign: 'center' }}>유속 판정</th>
</tr>
</thead>
<tbody>
{filteredLinkRows.map((r) => {
const isSelected =
(selectedTarget?.type === 'link' && selectedTarget.id === r.id) ||
Boolean(multiSelection?.links.has(r.id));
return (
<tr
key={r.id}
onClick={(e) => {
if (e.shiftKey || e.ctrlKey || e.metaKey) {
if (onMultiSelectionChange) {
const next: MultiSelectionState = {
nodes: new Set(multiSelection?.nodes ?? []),
links: new Set(multiSelection?.links ?? []),
gisFeatures: [...(multiSelection?.gisFeatures ?? [])],
};
if (selectedTarget?.type === 'link') next.links.add(selectedTarget.id);
if (next.links.has(r.id)) next.links.delete(r.id);
else next.links.add(r.id);
onMultiSelectionChange(next);
}
} else {
onSelectTarget({ type: 'link', id: r.id });
}
}}
style={{
cursor: 'pointer',
borderBottom: '1px solid var(--border)',
background: isSelected ? 'var(--accent-wash)' : 'transparent',
}}
>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'center', color: 'var(--text-disabled)' }}>{r.idx}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', fontWeight: 'var(--fw-semi)', color: 'var(--accent)' }}>{r.id}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'center', fontFamily: 'var(--font-mono)' }}>
{r.node1} → {r.node2}
</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{r.diameter}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{r.length.toFixed(1)}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{r.roughness}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)', fontWeight: 'var(--fw-semi)' }}>{r.flow.toFixed(2)}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{r.velocity.toFixed(2)}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{r.headloss.toFixed(2)}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{r.gradient.toFixed(2)}</td>
<td style={{ padding: 'var(--s-2) var(--s-4)', textAlign: 'center' }}>
<span
className={
r.statusType === 'warn' ? 'badge-crit' : r.statusType === 'info' ? 'badge-neutral' : 'badge-good'
}
style={{ fontSize: 'var(--fs-2xs)' }}
>
{r.statusText}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-secondary)' }}>
* 표의 행을 클릭하면 지도가 해당 객체 위치로 자동 포커스 이동합니다.
</div>
</div>
);
};
+485
View File
@@ -0,0 +1,485 @@
import React, { useState } from 'react';
import type { FeatureCollection, Geometry, GeoJsonProperties } from 'geojson';
import { Map, X, FolderOpen, MapPin, Info, Droplet, Check, Play } from 'lucide-react';
import {
autoDetectFields,
buildNetworkFromGeoJSON,
DetectedFields,
FieldMappingConfig,
} from './gisImport';
import { generateInp, NetworkModel } from './inpModel';
interface ImportWizardModalProps {
onClose: () => void;
onImportComplete: (model: NetworkModel, inpStr: string) => void;
initialGeoJson?: FeatureCollection<Geometry, GeoJsonProperties>;
initialFileName?: string;
}
export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
onClose,
onImportComplete,
initialGeoJson,
initialFileName,
}) => {
const [step, setStep] = useState<1 | 2 | 3>(1);
const [geojsonData, setGeojsonData] = useState<FeatureCollection<Geometry, GeoJsonProperties> | null>(null);
const [fileName, setFileName] = useState<string>('');
const [detected, setDetected] = useState<DetectedFields | null>(null);
// Field mapping state
const [pipeIdField, setPipeIdField] = useState<string>('');
const [diameterField, setDiameterField] = useState<string>('');
const [lengthField, setLengthField] = useState<string>('');
const [roughnessField, setRoughnessField] = useState<string>('');
const [materialField, setMaterialField] = useState<string>('');
const [defaultDiameter, setDefaultDiameter] = useState<number>(200);
const [defaultRoughness, setDefaultRoughness] = useState<number>(120);
const [sourceHead, setSourceHead] = useState<number>(60);
// Auto-load if initialGeoJson is passed
React.useEffect(() => {
if (initialGeoJson) {
processGeoJSON(initialGeoJson, initialFileName || 'uploaded_layer.geojson');
}
}, [initialGeoJson, initialFileName]);
// 1. Handle File upload
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setFileName(file.name);
const reader = new FileReader();
reader.onload = (evt) => {
try {
const parsed = JSON.parse(String(evt.target?.result)) as FeatureCollection<Geometry, GeoJsonProperties>;
processGeoJSON(parsed, file.name);
} catch (err: unknown) {
alert('유효하지 않은 GeoJSON 파일입니다.');
}
};
reader.readAsText(file);
};
// Load Seoul Sample
const handleLoadSample = async () => {
try {
const res = await fetch('/sample_gis_pipes.geojson');
if (!res.ok) throw new Error('샘플 로드 실패');
const parsed = (await res.json()) as FeatureCollection<Geometry, GeoJsonProperties>;
processGeoJSON(parsed, 'sample_gis_pipes.geojson');
} catch (err: unknown) {
alert('샘플 GeoJSON 로드 실패');
}
};
const processGeoJSON = (data: FeatureCollection<Geometry, GeoJsonProperties>, name: string) => {
setGeojsonData(data);
setFileName(name);
// Sample first feature properties for field detection
const firstProps = data.features[0]?.properties || {};
const det = autoDetectFields(firstProps);
setDetected(det);
setPipeIdField(det.pipeIdField || '');
setDiameterField(det.diameterField || '');
setLengthField(det.lengthField || '');
setRoughnessField(det.roughnessField || '');
setMaterialField(det.materialField || '');
setStep(2);
};
const buildConfig = (): FieldMappingConfig => ({
pipeIdField: pipeIdField || undefined,
diameterField: diameterField || undefined,
lengthField: lengthField || undefined,
roughnessField: roughnessField || undefined,
materialField: materialField || undefined,
defaultDiameter,
defaultRoughness,
sourceHead,
});
// Live conversion preview for the step-3 summary screen -- computed from
// the same function handleFinish uses, so the "estimated value" counts
// shown to the user always match what will actually be generated.
const preview = React.useMemo(() => {
if (!geojsonData || step !== 3) return null;
try {
return buildNetworkFromGeoJSON(geojsonData, buildConfig());
} catch {
return null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [geojsonData, step, pipeIdField, diameterField, lengthField, roughnessField, materialField, defaultDiameter, defaultRoughness, sourceHead]);
// Complete import
const handleFinish = () => {
if (!geojsonData) return;
const { model } = buildNetworkFromGeoJSON(geojsonData, buildConfig());
const inpStr = generateInp(model);
onImportComplete(model, inpStr);
onClose();
};
return (
<div className="modal-overlay" style={{ zIndex: 'var(--z-modal)' }}>
<div
className="modal-content"
style={{
width: '90%',
maxWidth: '620px',
maxHeight: '85vh',
padding: 'var(--s-6)',
display: 'flex',
flexDirection: 'column',
fontSize: 'var(--fs-sm)',
}}
>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--s-6)' }}>
<div>
<h3 style={{ margin: 0, fontSize: 'var(--fs-md)', color: 'var(--text)', fontWeight: 'var(--fw-semi)', display: 'flex', alignItems: 'center', gap: 'var(--s-2)' }}>
<Map size={16} aria-hidden="true" />
지자체 상수도 GIS 데이터 가져오기 마법사
</h3>
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-secondary)', marginTop: 'var(--s-1)' }}>
GeoJSON 관로 데이터를 EPANET 수리해석 모델로 자동 위상 정합 변환합니다.
</div>
</div>
<button
onClick={onClose}
className="tool-btn"
style={{ fontSize: 'var(--fs-lg)' }}
aria-label="닫기"
title="닫기"
>
<X size={16} aria-hidden="true" />
</button>
</div>
{/* Step Indicator */}
<div style={{ display: 'flex', gap: 'var(--s-4)', marginBottom: 'var(--s-6)' }}>
<div
style={{
flex: 1,
padding: 'var(--s-3)',
textAlign: 'center',
borderRadius: 'var(--r-sm)',
background: step === 1 ? 'var(--accent)' : 'var(--bg-subtle)',
color: step === 1 ? 'var(--text-on-accent)' : 'var(--text-secondary)',
fontWeight: 'var(--fw-semi)',
}}
>
1. 파일 선택
</div>
<div
style={{
flex: 1,
padding: 'var(--s-3)',
textAlign: 'center',
borderRadius: 'var(--r-sm)',
background: step === 2 ? 'var(--accent)' : 'var(--bg-subtle)',
color: step === 2 ? 'var(--text-on-accent)' : 'var(--text-secondary)',
fontWeight: 'var(--fw-semi)',
}}
>
2. 필드 매핑
</div>
<div
style={{
flex: 1,
padding: 'var(--s-3)',
textAlign: 'center',
borderRadius: 'var(--r-sm)',
background: step === 3 ? 'var(--accent)' : 'var(--bg-subtle)',
color: step === 3 ? 'var(--text-on-accent)' : 'var(--text-secondary)',
fontWeight: 'var(--fw-semi)',
}}
>
3. 수리조건 및 생성
</div>
</div>
{/* STEP 1: File Selection */}
{step === 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', padding: '16px 0' }}>
<div
style={{
border: '2px dashed #cbd5e1',
borderRadius: '8px',
padding: '32px',
textAlign: 'center',
background: 'var(--bg-subtle)',
}}
>
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: '8px', color: 'var(--text-secondary)' }}><FolderOpen size={28} aria-hidden="true" /></div>
<div style={{ fontWeight: 600, fontSize: '14px', marginBottom: '4px' }}>
상수관로 GeoJSON 파일 업로드
</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '11px', marginBottom: '16px' }}>
LineString 형상의 지자체 상수관망 GeoJSON 파일을 선택하세요.
</div>
<label className="btn btn-primary" style={{ display: 'inline-block', cursor: 'pointer' }}>
내 컴퓨터에서 파일 찾기
<input type="file" accept=".geojson,.json" style={{ display: 'none' }} onChange={handleFileUpload} />
</label>
</div>
<div style={{ textAlign: 'center' }}>
<span style={{ color: 'var(--text-muted)', fontSize: '11px' }}>또는</span>
</div>
<div style={{ textAlign: 'center' }}>
<button
onClick={handleLoadSample}
className="btn btn-secondary"
style={{ background: 'var(--good-wash)', borderColor: '#86efac', color: 'var(--good)', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 'var(--s-2)' }}
>
<MapPin size={14} aria-hidden="true" />
서울시 상수관망 GIS 샘플 데이터셋으로 시작하기
</button>
</div>
</div>
)}
{/* STEP 2: Field Mapping */}
{step === 2 && detected && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ background: 'var(--bg-subtle)', padding: '8px 12px', borderRadius: '4px', color: 'var(--text)' }}>
선택 파일: <strong>{fileName}</strong> (총 {geojsonData?.features.length}개 관로 피처)
<div style={{ fontSize: '11px', color: '#0369a1', marginTop: '4px', display: 'flex', alignItems: 'flex-start', gap: 'var(--s-2)' }}>
<Info size={13} aria-hidden="true" style={{ flexShrink: 0, marginTop: '1px' }} />
<span>MultiLineString 복합 선형 분해 및 좌표계 적응형 스냅핑(미터 투영 0.5m / 경위도 ~5m)이 자동 적용되어 단절 없는 일체형 관망을 구축합니다.</span>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
관로 ID 컬럼
</label>
<select
value={pipeIdField}
onChange={(e) => setPipeIdField(e.target.value)}
style={{ width: '100%', padding: '4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="">(자동 일련번호 생성)</option>
{detected.availableFields.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
관경(Diameter) 컬럼
</label>
<select
value={diameterField}
onChange={(e) => setDiameterField(e.target.value)}
style={{ width: '100%', padding: '4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="">(기본값 사용)</option>
{detected.availableFields.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
관로 연장(Length) 컬럼
</label>
<select
value={lengthField}
onChange={(e) => setLengthField(e.target.value)}
style={{ width: '100%', padding: '4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="">(도형 지오메트리 거리 자동 계산)</option>
{detected.availableFields.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
조도계수(Roughness) 컬럼
</label>
<select
value={roughnessField}
onChange={(e) => setRoughnessField(e.target.value)}
style={{ width: '100%', padding: '4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="">(기본값 사용)</option>
{detected.availableFields.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
</div>
<div style={{ marginTop: '4px' }}>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
관종/재질(Material) 컬럼 (DIP C=110, PE C=130, PVC C=140 자동 추정)
</label>
<select
value={materialField}
onChange={(e) => setMaterialField(e.target.value)}
style={{ width: '100%', padding: '4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
>
<option value="">(재질 미지정 - 기본 조도계수 적용)</option>
{detected.availableFields.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px', marginTop: '4px' }}>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
기본 관경 (mm)
</label>
<input
type="number"
value={defaultDiameter}
onChange={(e) => setDefaultDiameter(parseFloat(e.target.value) || 150)}
style={{ width: '100%', padding: '4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
기본 조도계수 (C-value)
</label>
<input
type="number"
value={defaultRoughness}
onChange={(e) => setDefaultRoughness(parseFloat(e.target.value) || 120)}
style={{ width: '100%', padding: '4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
</div>
</div>
</div>
)}
{/* STEP 3: Summary & Water Source */}
{step === 3 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ background: 'var(--bg-subtle)', padding: '12px', borderRadius: '6px', border: '1px solid #e2e8f0' }}>
<div style={{ fontWeight: 600, marginBottom: '6px', color: 'var(--text)', display: 'flex', alignItems: 'center', gap: 'var(--s-2)' }}>
<Droplet size={14} aria-hidden="true" />
상수도 공급원(Reservoir) 설정:
</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '11px', marginBottom: '10px' }}>
EPANET 수리해석을 위해 관망 시작 절점에 배수지/저수조(수원)를 자동 배치합니다.
</div>
<div>
<label style={{ display: 'block', fontSize: '11px', color: 'var(--text-secondary)', marginBottom: '2px' }}>
배수지 공급 수두 (Head, m)
</label>
<input
type="number"
value={sourceHead}
onChange={(e) => setSourceHead(parseFloat(e.target.value) || 60)}
style={{ width: '100%', padding: '4px', border: '1px solid #cbd5e1', borderRadius: '4px' }}
/>
</div>
</div>
<div style={{ padding: '10px', background: 'var(--good-wash)', borderRadius: '6px', border: '1px solid #bbf7d0', color: 'var(--good)', display: 'flex', flexDirection: 'column', gap: 'var(--s-2)' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--s-2)' }}>
<Check size={13} aria-hidden="true" style={{ flexShrink: 0, marginTop: '1px' }} />
<span>관로 끝점 좌표를 분석하여 인접 절점을 자동 스냅 결합(위상 정합)합니다.</span>
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--s-2)' }}>
<Check size={13} aria-hidden="true" style={{ flexShrink: 0, marginTop: '1px' }} />
<span>모델 생성 즉시 Native DLL과 브라우저 WASM 듀얼 엔진으로 수리해석이 실행됩니다.</span>
</div>
</div>
{/* Auto-generated / estimated value disclosure: without this, a
placeholder like "10m elevation" or a proximity-snapped
endpoint looks indistinguishable from real surveyed data. */}
{preview && (
<div style={{ padding: '10px', background: 'var(--warn-wash)', borderRadius: '6px', border: '1px solid var(--warn)', color: 'var(--warn)', display: 'flex', flexDirection: 'column', gap: 'var(--s-2)' }}>
<div style={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 'var(--s-2)' }}>
<Info size={13} aria-hidden="true" />
검토 필요: 자동 생성/추정된 값
</div>
<ul style={{ margin: 0, paddingLeft: '18px', fontSize: '11px', lineHeight: 1.6 }}>
<li>
절점 지반고: {preview.summary.placeholderElevationCount}개 전체가 임시값(10m)입니다.
GIS 데이터에 표고 필드가 없어 실측값이 아닙니다.
</li>
<li>
절점 기본수요량: {preview.summary.placeholderDemandCount}개 전체가 임시값(10 LPS)입니다.
</li>
<li>
관경: 전체 {preview.summary.totalPipeCount}개 중{' '}
<strong>{preview.summary.defaultDiameterPipeCount}개</strong>가 GIS 필드값이 아닌
기본값({defaultDiameter}mm)으로 채워졌습니다.
</li>
<li>
조도계수: 전체 {preview.summary.totalPipeCount}개 중{' '}
<strong>{preview.summary.defaultRoughnessPipeCount}개</strong>가 실측값이 아닌
기본값/재질 추정값입니다.
</li>
{preview.summary.autoSnappedEndpointCount > 0 && (
<li>
끝점 자동 결합: <strong>{preview.summary.autoSnappedEndpointCount}건</strong>이 정확히
일치하지 않는 좌표를 근접 거리로 병합한 것입니다. 실제로 연결된 지점인지
확인이 필요합니다.
</li>
)}
</ul>
</div>
)}
</div>
)}
{/* Footer Navigation */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '16px', borderTop: '1px solid #e2e8f0', paddingTop: '12px' }}>
{step > 1 && (
<button onClick={() => setStep((s) => (s - 1) as 1 | 2)} className="btn btn-secondary">
이전
</button>
)}
{step < 3 ? (
<button
onClick={() => {
if (!geojsonData) return alert('파일을 먼저 선택하세요.');
setStep((s) => (s + 1) as 2 | 3);
}}
className="btn btn-primary"
>
다음
</button>
) : (
<button onClick={handleFinish} className="btn btn-primary" style={{ background: 'var(--good)', display: 'inline-flex', alignItems: 'center', gap: 'var(--s-2)' }}>
<Play size={14} aria-hidden="true" />
관망 모델 생성 및 수리해석
</button>
)}
<button onClick={onClose} className="btn btn-secondary">
취소
</button>
</div>
</div>
</div>
);
};
+97
View File
@@ -0,0 +1,97 @@
import React from 'react';
import { generateInp, type NetworkModel } from './inpModel';
import { X } from 'lucide-react';
export interface InpTextModalProps {
networkModel: NetworkModel;
showInpModal: boolean;
setShowInpModal: React.Dispatch<React.SetStateAction<boolean>>;
}
/** Shows the regenerated INP text with copy/download. */
export const InpTextModal: React.FC<InpTextModalProps> = ({
networkModel,
showInpModal,
setShowInpModal,
}) => (
<div className="modal-overlay">
<div
className="modal-content"
style={{
width: '80%',
maxWidth: '800px',
maxHeight: '85vh',
padding: 'var(--s-6)',
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 'var(--s-5)',
}}
>
<h3 style={{ margin: 0, fontSize: 'var(--fs-lg)', color: 'var(--text)', fontWeight: 'var(--fw-semi)' }}>재생성된 EPANET INP 파일</h3>
<button
onClick={() => setShowInpModal(false)}
className="tool-btn"
style={{ fontSize: 'var(--fs-lg)' }}
aria-label="닫기"
title="닫기"
>
<X size={16} aria-hidden="true" />
</button>
</div>
<textarea
readOnly
value={generateInp(networkModel)}
style={{
flex: 1,
minHeight: '350px',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--fs-sm)',
padding: 'var(--s-4)',
border: '1px solid var(--border-strong)',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-subtle)',
color: 'var(--text)',
resize: 'none',
}}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--s-4)', marginTop: 'var(--s-5)' }}>
<button
onClick={() => {
navigator.clipboard.writeText(generateInp(networkModel));
alert('클립보드에 복사되었습니다.');
}}
className="btn btn-secondary"
>
클립보드 복사
</button>
<button
onClick={() => {
const content = generateInp(networkModel);
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'modified_network.inp';
a.click();
URL.revokeObjectURL(url);
}}
className="btn btn-primary"
>
.inp 파일 다운로드
</button>
<button onClick={() => setShowInpModal(false)} className="btn btn-secondary">
닫기
</button>
</div>
</div>
</div>
);
+318
View File
@@ -0,0 +1,318 @@
import React, { useState } from 'react';
import { Type, X, RotateCcw, Check } from 'lucide-react';
export interface LayerLabelConfig {
showLabel: boolean;
field: string;
fontSize: number; // 9 ~ 16px, default 10px
fontWeight: number; // 400, 500, 600
color: string;
halo: boolean;
haloColor: string;
}
interface LayerLabelModalProps {
layerKey: string;
layerTitle: string;
availableFields: Array<{ id: string; label: string }>;
initialConfig?: Partial<LayerLabelConfig>;
onClose: () => void;
onApply: (config: LayerLabelConfig) => void;
}
const LABEL_COLOR_SWATCHES = [
'#0f172a', // Dark slate
'#334155', // Slate
'#1e293b', // Deep slate
'#0369a1', // Sky
'#15803d', // Green
'#b45309', // Amber
'#b91c1c', // Red
'#6d28d9', // Purple
];
export const LayerLabelModal: React.FC<LayerLabelModalProps> = ({
layerKey,
layerTitle,
availableFields,
initialConfig,
onClose,
onApply,
}) => {
const [showLabel, setShowLabel] = useState<boolean>(
initialConfig?.showLabel ?? true
);
const [field, setField] = useState<string>(
initialConfig?.field || (availableFields[0]?.id ?? 'id')
);
const [fontSize, setFontSize] = useState<number>(
initialConfig?.fontSize || 10
);
const [fontWeight, setFontWeight] = useState<number>(
initialConfig?.fontWeight || 500
);
const [color, setColor] = useState<string>(
initialConfig?.color || '#334155'
);
const [halo, setHalo] = useState<boolean>(
initialConfig?.halo ?? true
);
const [haloColor, setHaloColor] = useState<string>(
initialConfig?.haloColor || '#ffffff'
);
const handleSave = () => {
const config: LayerLabelConfig = {
showLabel,
field,
fontSize,
fontWeight,
color,
halo,
haloColor,
};
localStorage.setItem(`gispipe_layer_label_${layerKey}`, JSON.stringify(config));
onApply(config);
onClose();
};
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 1050,
background: 'rgba(15, 23, 42, 0.4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
}}
onClick={onClose}
>
<div
style={{
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
boxShadow: 'var(--shadow-modal)',
width: '100%',
maxWidth: '400px',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
fontSize: '12px',
}}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="레이어 라벨 설정"
>
{/* Modal Header */}
<div
style={{
padding: '10px 14px',
borderBottom: '1px solid var(--border)',
background: 'var(--bg-subtle)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 600 }}>
<Type size={15} style={{ color: 'var(--accent)' }} aria-hidden="true" />
<span>라벨 설정: {layerTitle}</span>
</div>
<button
onClick={onClose}
className="btn btn-ghost"
style={{ padding: '3px' }}
title="닫기"
aria-label="닫기"
>
<X size={15} aria-hidden="true" />
</button>
</div>
{/* Modal Body */}
<div style={{ padding: '14px', display: 'flex', flexDirection: 'column', gap: '12px', maxHeight: '75vh', overflowY: 'auto' }}>
{/* 1. Show/Hide Label Toggle */}
<div style={{ padding: '8px 10px', background: 'var(--bg-subtle)', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)' }}>
<label style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', fontWeight: 500 }}>
<span>라벨 텍스트 표시</span>
<input
type="checkbox"
checked={showLabel}
onChange={(e) => setShowLabel(e.target.checked)}
/>
</label>
</div>
{showLabel && (
<>
{/* 2. Field Selection */}
<div>
<label style={{ display: 'block', marginBottom: '4px', fontWeight: 500, color: 'var(--text-secondary)' }}>
표시할 속성 필드
</label>
<select
value={field}
onChange={(e) => setField(e.target.value)}
style={{
width: '100%',
padding: '5px 8px',
border: '1px solid var(--border-strong)',
borderRadius: 'var(--r-sm)',
fontSize: '12px',
}}
>
{availableFields.map((f) => (
<option key={f.id} value={f.id}>
{f.label} ({f.id})
</option>
))}
</select>
</div>
{/* 3. Font Size */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px', color: 'var(--text-secondary)' }}>
<span>글자 크기 (Font Size)</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{fontSize}px</span>
</div>
<input
type="range"
min={9}
max={16}
value={fontSize}
onChange={(e) => setFontSize(parseInt(e.target.value, 10))}
style={{ width: '100%', accentColor: 'var(--accent)' }}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', color: 'var(--text-muted)' }}>
<span>작게 (9px)</span>
<span>기본 (10px)</span>
<span>크게 (16px)</span>
</div>
</div>
{/* 4. Font Weight */}
<div>
<label style={{ display: 'block', marginBottom: '4px', color: 'var(--text-secondary)' }}>
글자 굵기 (Weight)
</label>
<div style={{ display: 'flex', gap: '6px' }}>
{[
{ val: 400, label: '기본 (400)' },
{ val: 500, label: '중간 (500)' },
{ val: 600, label: '굵게 (600)' },
].map((w) => (
<button
key={w.val}
type="button"
onClick={() => setFontWeight(w.val)}
className={`btn ${fontWeight === w.val ? 'btn-primary' : 'btn-secondary'}`}
style={{ flex: 1, padding: '4px 6px', fontSize: '11px' }}
>
{w.label}
</button>
))}
</div>
</div>
{/* 5. Text Color */}
<div>
<label style={{ display: 'block', marginBottom: '4px', color: 'var(--text-secondary)' }}>
텍스트 색상
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
style={{ width: '32px', height: '28px', padding: 0, border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', cursor: 'pointer' }}
/>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{LABEL_COLOR_SWATCHES.map((swatch) => (
<div
key={swatch}
onClick={() => setColor(swatch)}
style={{
width: '20px',
height: '20px',
borderRadius: '4px',
background: swatch,
cursor: 'pointer',
border: color === swatch ? '2px solid var(--text)' : '1px solid var(--border)',
}}
/>
))}
</div>
</div>
</div>
{/* 6. Text Halo */}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '10px' }}>
<label style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', marginBottom: '8px' }}>
<span>텍스트 외곽 테두리 (Halo)</span>
<input
type="checkbox"
checked={halo}
onChange={(e) => setHalo(e.target.checked)}
/>
</label>
{halo && (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '11px', color: 'var(--text-secondary)' }}>Halo 색상:</span>
<input
type="color"
value={haloColor}
onChange={(e) => setHaloColor(e.target.value)}
style={{ width: '28px', height: '24px', padding: 0, border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', cursor: 'pointer' }}
/>
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>(흰색 또는 배경색 권장)</span>
</div>
)}
</div>
</>
)}
</div>
{/* Modal Footer */}
<div
style={{
padding: '10px 14px',
borderTop: '1px solid var(--border)',
background: 'var(--bg-subtle)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<button
onClick={() => {
localStorage.removeItem(`gispipe_layer_label_${layerKey}`);
onClose();
}}
className="btn btn-secondary"
style={{ fontSize: '11px', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
>
<RotateCcw size={12} aria-hidden="true" />
<span>기본값</span>
</button>
<div style={{ display: 'flex', gap: '6px' }}>
<button onClick={onClose} className="btn btn-secondary" style={{ fontSize: '11px' }}>
취소
</button>
<button
onClick={handleSave}
className="btn btn-primary"
style={{ fontSize: '11px', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
>
<Check size={12} aria-hidden="true" />
<span>적용</span>
</button>
</div>
</div>
</div>
</div>
);
};
+905
View File
@@ -0,0 +1,905 @@
import React, { useRef, useState, useEffect } from 'react';
import {
Layers,
ChevronLeft,
Circle,
GitCommit,
Activity,
Disc,
Droplet,
Info,
CheckSquare,
Square,
Eye,
EyeOff,
Settings,
Clock,
ChartNoAxesCombined,
TableProperties,
GitBranch,
} from 'lucide-react';
import type {
DisplayMode,
LayerVisibilityState,
SimulationResult,
UserGisLayer,
} from './types';
import type { NetworkModel } from './inpModel';
import type { TableCategory } from './FullAttributeTableModal';
import { getDisplayUnits, type DisplayUnitMode } from './unitSystem';
interface LayerPanelProps {
isOpen: boolean;
onClose: () => void;
width: number;
onWidthChange: (w: number) => void;
networkModel: NetworkModel | null;
wasmResult: SimulationResult | null;
displayMode: DisplayMode;
displayUnitMode?: DisplayUnitMode;
onDisplayModeChange: (mode: DisplayMode) => void;
visibility: LayerVisibilityState;
onVisibilityChange: (updater: (prev: LayerVisibilityState) => LayerVisibilityState) => void;
userLayers: UserGisLayer[];
onAddUserLayer: (file: File) => Promise<void>;
onToggleUserLayerVisibility: (layerId: string) => void;
onUserLayerOpacityChange: (layerId: string, opacity: number) => void;
onDeleteUserLayer: (layerId: string) => void;
onConvertToNetwork: (layer: UserGisLayer) => void;
onOpenAttributeTable: (category: TableCategory) => void;
onOpenCurveEditor?: () => void;
onOpenDemandTable?: () => void;
onOpenStyleSettings: (layerKey: string, isLine: boolean, isUserLayer: boolean) => void;
onOpenLabelSettings: (layerKey: string, isLine: boolean, isUserLayer: boolean) => void;
onZoomToLayer?: (layerKey: string) => void;
onOpenScenarios: () => void;
onOpenModelSection: (section: 'options' | 'patterns' | 'controls') => void;
}
type SelectedInfoItem =
| { type: 'network'; category: 'nodes' | 'pipes' | 'pumps' | 'valves' | 'tanksReservoirs' }
| { type: 'results'; metric: DisplayMode }
| { type: 'user'; layer: UserGisLayer }
| null;
export const LayerPanel: React.FC<LayerPanelProps> = ({
isOpen,
onClose,
width,
onWidthChange,
networkModel,
wasmResult,
displayMode,
displayUnitMode = 'korean',
onDisplayModeChange,
visibility,
onVisibilityChange,
userLayers,
onToggleUserLayerVisibility,
onDeleteUserLayer,
onConvertToNetwork,
onOpenAttributeTable,
onOpenCurveEditor,
onOpenDemandTable,
onOpenStyleSettings,
onOpenLabelSettings,
onOpenScenarios,
onOpenModelSection,
}) => {
const displayUnits = getDisplayUnits(
networkModel?.options.units || wasmResult?.units.flow || 'LPS',
displayUnitMode
);
const [selectedInfo, setSelectedInfo] = useState<SelectedInfoItem>(null);
const [layerContextMenu, setLayerContextMenu] = useState<{
x: number;
y: number;
layerKey: string;
category: TableCategory;
layerTitle: string;
isLine: boolean;
isUserLayer: boolean;
userLayerObj?: UserGisLayer;
} | null>(null);
// Close context menu on ESC or outside click
useEffect(() => {
if (!layerContextMenu) return;
const handleClose = () => setLayerContextMenu(null);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setLayerContextMenu(null);
};
window.addEventListener('mousedown', handleClose);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('mousedown', handleClose);
window.removeEventListener('keydown', handleKeyDown);
};
}, [layerContextMenu]);
// Resize handling
const isResizingRef = useRef(false);
const startXRef = useRef(0);
const startWidthRef = useRef(width);
const handleResizeStart = (e: React.MouseEvent) => {
e.preventDefault();
isResizingRef.current = true;
startXRef.current = e.clientX;
startWidthRef.current = width;
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!isResizingRef.current) return;
const dx = moveEvent.clientX - startXRef.current;
const newWidth = Math.max(220, Math.min(420, startWidthRef.current + dx));
onWidthChange(newWidth);
};
const handleMouseUp = () => {
isResizingRef.current = false;
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
};
const handleResizeDoubleClick = () => {
onWidthChange(264);
};
// Counts from network model
const junctionCount = networkModel
? Object.values(networkModel.nodes).filter((n) => n.type === 'JUNCTION').length
: 0;
const tankReservoirCount = networkModel
? Object.values(networkModel.nodes).filter((n) => n.type === 'TANK' || n.type === 'RESERVOIR').length
: 0;
const pipeCount = networkModel ? Object.keys(networkModel.pipes).length : 0;
const pumpCount = networkModel ? Object.keys(networkModel.pumps).length : 0;
const valveCount = networkModel && networkModel.valves ? Object.keys(networkModel.valves).length : 0;
if (!isOpen) return null;
return (
<aside
className="layer-panel"
style={{
width: `${width}px`,
minWidth: '220px',
maxWidth: '420px',
background: 'var(--bg-panel)',
borderRight: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
position: 'relative',
zIndex: 20,
height: '100%',
userSelect: 'none',
}}
aria-label="레이어 패널"
>
{/* 1. Panel Header */}
<div
style={{
height: '38px',
padding: '0 10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: '1px solid var(--border)',
background: 'var(--bg-subtle)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontWeight: 600, fontSize: '12px' }}>
<Layers size={14} style={{ color: 'var(--accent)' }} aria-hidden="true" />
<span>레이어</span>
<span style={{ fontSize: '10px', color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
(단축키: [)
</span>
</div>
<button
onClick={onClose}
className="btn btn-ghost"
style={{ padding: '3px', borderRadius: '4px', color: 'var(--text-secondary)' }}
title="레이어 패널 닫기 (단축키 [)"
aria-label="레이어 패널 닫기"
>
<ChevronLeft size={15} aria-hidden="true" />
</button>
</div>
{/* 2. Scrollable Layer Tree */}
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
{/* GROUP 1: 관망 모델 (Network Model) */}
<div className="layer-group">
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontSize: '11px',
fontWeight: 600,
color: 'var(--text-secondary)',
marginBottom: '4px',
padding: '2px 4px',
borderBottom: '1px solid var(--border)',
}}
>
<span>관망 모델</span>
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>
{junctionCount + tankReservoirCount + pipeCount + pumpCount + valveCount} 요소
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1px' }}>
{/* 1.1 절점 (Junctions) */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 6px',
borderRadius: 'var(--r-sm)',
fontSize: '12px',
background: selectedInfo?.type === 'network' && selectedInfo.category === 'nodes' ? 'var(--accent-wash)' : 'transparent',
cursor: 'pointer',
}}
onClick={() => setSelectedInfo({ type: 'network', category: 'nodes' })}
onContextMenu={(e) => {
e.preventDefault();
setLayerContextMenu({
x: e.clientX,
y: e.clientY,
layerKey: 'nodes',
category: 'junctions',
layerTitle: '절점 (Junctions)',
isLine: false,
isUserLayer: false,
});
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={visibility.nodes}
onChange={(e) => {
e.stopPropagation();
onVisibilityChange((prev) => ({ ...prev, nodes: e.target.checked }));
}}
title="절점 레이어 표시/숨김"
style={{ cursor: 'pointer' }}
/>
<Circle size={13} style={{ color: '#3b82f6' }} aria-hidden="true" />
<span>절점 (Junctions)</span>
</div>
<span style={{ fontSize: '10px', color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
{junctionCount}
</span>
</div>
{/* 1.2 관로 (Pipes) */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 6px',
borderRadius: 'var(--r-sm)',
fontSize: '12px',
background: selectedInfo?.type === 'network' && selectedInfo.category === 'pipes' ? 'var(--accent-wash)' : 'transparent',
cursor: 'pointer',
}}
onClick={() => setSelectedInfo({ type: 'network', category: 'pipes' })}
onContextMenu={(e) => {
e.preventDefault();
setLayerContextMenu({
x: e.clientX,
y: e.clientY,
layerKey: 'pipes',
category: 'pipes',
layerTitle: '관로 (Pipes)',
isLine: true,
isUserLayer: false,
});
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={visibility.pipes}
onChange={(e) => {
e.stopPropagation();
onVisibilityChange((prev) => ({ ...prev, pipes: e.target.checked }));
}}
title="관로 레이어 표시/숨김"
style={{ cursor: 'pointer' }}
/>
<GitCommit size={13} style={{ color: '#64748b' }} aria-hidden="true" />
<span>관로 (Pipes)</span>
</div>
<span style={{ fontSize: '10px', color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
{pipeCount}
</span>
</div>
{/* 1.3 펌프 (Pumps) */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 6px',
borderRadius: 'var(--r-sm)',
fontSize: '12px',
background: selectedInfo?.type === 'network' && selectedInfo.category === 'pumps' ? 'var(--accent-wash)' : 'transparent',
cursor: 'pointer',
}}
onClick={() => setSelectedInfo({ type: 'network', category: 'pumps' })}
onContextMenu={(e) => {
e.preventDefault();
setLayerContextMenu({
x: e.clientX,
y: e.clientY,
layerKey: 'pumps',
category: 'pumps',
layerTitle: '펌프 (Pumps)',
isLine: true,
isUserLayer: false,
});
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={visibility.pumps}
disabled={pumpCount === 0}
onChange={(e) => {
e.stopPropagation();
onVisibilityChange((prev) => ({ ...prev, pumps: e.target.checked }));
}}
title={pumpCount === 0 ? '펌프 없음' : '펌프 레이어 표시/숨김'}
style={{ cursor: pumpCount === 0 ? 'not-allowed' : 'pointer' }}
/>
<Activity size={13} style={{ color: pumpCount > 0 ? '#16a34a' : 'var(--text-disabled)' }} aria-hidden="true" />
<span style={{ color: pumpCount === 0 ? 'var(--text-disabled)' : 'var(--text)' }}>
펌프 (Pumps)
</span>
</div>
<span style={{ fontSize: '10px', color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
{pumpCount}
</span>
</div>
{/* 1.4 밸브 (Valves) */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 6px',
borderRadius: 'var(--r-sm)',
fontSize: '12px',
background: selectedInfo?.type === 'network' && selectedInfo.category === 'valves' ? 'var(--accent-wash)' : 'transparent',
cursor: 'pointer',
}}
onClick={() => setSelectedInfo({ type: 'network', category: 'valves' })}
onContextMenu={(e) => {
e.preventDefault();
setLayerContextMenu({
x: e.clientX,
y: e.clientY,
layerKey: 'valves',
category: 'valves',
layerTitle: '밸브 (Valves)',
isLine: true,
isUserLayer: false,
});
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={visibility.valves}
disabled={valveCount === 0}
onChange={(e) => {
e.stopPropagation();
onVisibilityChange((prev) => ({ ...prev, valves: e.target.checked }));
}}
title={valveCount === 0 ? '밸브 없음' : '밸브 레이어 표시/숨김'}
style={{ cursor: valveCount === 0 ? 'not-allowed' : 'pointer' }}
/>
<Disc size={13} style={{ color: valveCount > 0 ? '#d97706' : 'var(--text-disabled)' }} aria-hidden="true" />
<span style={{ color: valveCount === 0 ? 'var(--text-disabled)' : 'var(--text)' }}>
밸브 (Valves)
</span>
</div>
<span style={{ fontSize: '10px', color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
{valveCount}
</span>
</div>
{/* 1.5 탱크 / 저수지 (Tanks / Reservoirs) */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 6px',
borderRadius: 'var(--r-sm)',
fontSize: '12px',
background: selectedInfo?.type === 'network' && selectedInfo.category === 'tanksReservoirs' ? 'var(--accent-wash)' : 'transparent',
cursor: 'pointer',
}}
onClick={() => setSelectedInfo({ type: 'network', category: 'tanksReservoirs' })}
onContextMenu={(e) => {
e.preventDefault();
setLayerContextMenu({
x: e.clientX,
y: e.clientY,
layerKey: 'tanksReservoirs',
category: 'reservoirs',
layerTitle: '탱크 / 저수지',
isLine: false,
isUserLayer: false,
});
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={visibility.tanksReservoirs}
disabled={tankReservoirCount === 0}
onChange={(e) => {
e.stopPropagation();
onVisibilityChange((prev) => ({ ...prev, tanksReservoirs: e.target.checked }));
}}
title={tankReservoirCount === 0 ? '탱크/저수지 없음' : '탱크/저수지 레이어 표시/숨김'}
style={{ cursor: tankReservoirCount === 0 ? 'not-allowed' : 'pointer' }}
/>
<Droplet size={13} style={{ color: tankReservoirCount > 0 ? '#0284c7' : 'var(--text-disabled)' }} aria-hidden="true" />
<span style={{ color: tankReservoirCount === 0 ? 'var(--text-disabled)' : 'var(--text)' }}>
탱크 / 저수지
</span>
</div>
<span style={{ fontSize: '10px', color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
{tankReservoirCount}
</span>
</div>
</div>
</div>
{/* GROUP 2: 수리해석 결과 표시 (Results) */}
<div className="layer-group">
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontSize: '11px',
fontWeight: 600,
color: 'var(--text-secondary)',
marginBottom: '4px',
padding: '2px 4px',
borderBottom: '1px solid var(--border)',
}}
>
<span>수리해석 결과 표시</span>
<span style={{ fontSize: '10px', color: wasmResult ? 'var(--good)' : 'var(--text-muted)' }}>
{wasmResult ? '해석 완료' : '미실행'}
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
{[
{ id: 'pressure', label: '수압', unit: displayUnits.pressure },
{ id: 'head', label: '동수두', unit: displayUnits.head },
{ id: 'flow', label: '유량', unit: displayUnits.flow },
{ id: 'velocity', label: '유속', unit: displayUnits.velocity },
{ id: 'headloss', label: '마찰손실수두', unit: 'm/km' },
{ id: 'quality', label: '수질', unit: wasmResult?.qualityInfo?.chemUnits || 'mg/L' },
].map((metric) => {
const isSelected = displayMode === metric.id;
const disabled = !wasmResult;
return (
<div
key={metric.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '3px 6px',
borderRadius: 'var(--r-sm)',
fontSize: '11px',
cursor: disabled ? 'not-allowed' : 'pointer',
background: isSelected ? 'var(--accent-wash)' : 'transparent',
color: disabled ? 'var(--text-disabled)' : 'var(--text)',
}}
onClick={() => {
if (disabled) return;
onDisplayModeChange(metric.id as DisplayMode);
setSelectedInfo({ type: 'results', metric: metric.id as DisplayMode });
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<input
type="radio"
name="result-metric"
checked={isSelected}
disabled={disabled}
onChange={() => onDisplayModeChange(metric.id as DisplayMode)}
style={{ cursor: disabled ? 'not-allowed' : 'pointer' }}
/>
<span>{metric.label}</span>
</div>
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>{metric.unit}</span>
</div>
);
})}
</div>
</div>
{/* GROUP: 사용자 GIS 레이어 (GeoJSON / Shapefile). Right-click for
attribute table, style, label, convert-to-network, delete. */}
{userLayers.length > 0 && (
<div className="layer-group">
<div
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--text-secondary)',
marginBottom: '4px',
padding: '2px 4px',
borderBottom: '1px solid var(--border)',
}}
>
GIS 레이어 ({userLayers.length}) · 우클릭: 관망 변환 등
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1px' }}>
{userLayers.map((layer) => {
const isLine = layer.geomTypes.some((g) => /Line/i.test(g));
const isSelected = selectedInfo?.type === 'user' && selectedInfo.layer.id === layer.id;
return (
<div
key={layer.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 6px',
borderRadius: 'var(--r-sm)',
fontSize: '12px',
background: isSelected ? 'var(--accent-wash)' : 'transparent',
cursor: 'pointer',
}}
onClick={() => setSelectedInfo({ type: 'user', layer })}
onContextMenu={(e) => {
e.preventDefault();
setLayerContextMenu({
x: e.clientX,
y: e.clientY,
layerKey: layer.id,
category: `gis_${layer.id}`,
layerTitle: layer.name,
isLine,
isUserLayer: true,
userLayerObj: layer,
});
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 }}>
<input
type="checkbox"
checked={layer.visible}
onChange={(e) => {
e.stopPropagation();
onToggleUserLayerVisibility(layer.id);
}}
title={`${layer.name} 표시/숨김`}
style={{ cursor: 'pointer' }}
/>
<span style={{ width: 10, height: 10, borderRadius: isLine ? 0 : '50%', background: layer.color, flexShrink: 0 }} aria-hidden="true" />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={layer.fileName}>
{layer.name}
</span>
</div>
<span style={{ fontSize: '10px', color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
{layer.featureCount}
</span>
</div>
);
})}
</div>
</div>
)}
{/* GROUP 3: 모델 구성요소 */}
<div className="layer-group">
<div
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--text-secondary)',
marginBottom: '4px',
padding: '2px 4px',
borderBottom: '1px solid var(--border)',
}}
>
모델 구성요소
</div>
<div style={{ display: 'grid', gap: '2px' }}>
{[
{ id: 'scenarios', label: '시나리오', icon: <GitBranch size={12} />, action: onOpenScenarios },
{ id: 'options', label: '모델 설정', icon: <Settings size={12} />, action: () => onOpenModelSection('options') },
{ id: 'patterns', label: '패턴', icon: <Layers size={12} />, action: () => onOpenModelSection('patterns') },
{ id: 'curves', label: '곡선 편집', icon: <ChartNoAxesCombined size={12} />, action: () => (onOpenCurveEditor ? onOpenCurveEditor() : onOpenAttributeTable('curves')) },
...(onOpenDemandTable ? [{ id: 'demands', label: '수요량 테이블', icon: <TableProperties size={12} />, action: onOpenDemandTable }] : []),
{ id: 'controls', label: '컨트롤', icon: <Clock size={12} />, action: () => onOpenModelSection('controls') },
{ id: 'rules', label: '규칙', icon: <Info size={12} />, action: () => onOpenAttributeTable('rules') },
].map((item) => (
<button
key={item.id}
type="button"
className="btn btn-ghost"
onClick={item.action}
disabled={!networkModel}
style={{
justifyContent: 'flex-start',
padding: '5px 6px',
fontSize: '11px',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
{item.icon}
{item.label}
</button>
))}
</div>
</div>
</div>
{/* 3. Bottom Layer Info Metadata Card */}
<div
style={{
borderTop: '1px solid var(--border)',
background: 'var(--bg-subtle)',
padding: '8px 10px',
fontSize: '11px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontWeight: 600, color: 'var(--text)', marginBottom: '4px' }}>
<Info size={12} aria-hidden="true" />
<span>선택 레이어 정보</span>
</div>
{selectedInfo ? (
selectedInfo.type === 'network' ? (
<div style={{ display: 'grid', gridTemplateColumns: '70px 1fr', gap: '3px', color: 'var(--text-secondary)' }}>
<span>구분:</span>
<strong style={{ color: 'var(--text)' }}>
{selectedInfo.category === 'nodes'
? '절점 (Junctions)'
: selectedInfo.category === 'pipes'
? '관로 (Pipes)'
: selectedInfo.category === 'pumps'
? '펌프 (Pumps)'
: selectedInfo.category === 'valves'
? '밸브 (Valves)'
: '탱크 / 저수지'}
</strong>
<span>객체 수:</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>
{selectedInfo.category === 'nodes'
? `${junctionCount}개`
: selectedInfo.category === 'pipes'
? `${pipeCount}개`
: selectedInfo.category === 'pumps'
? `${pumpCount}개`
: selectedInfo.category === 'valves'
? `${valveCount}개`
: `${tankReservoirCount}개`}
</span>
<span>형상 유형:</span>
<span>
{selectedInfo.category === 'nodes' || selectedInfo.category === 'tanksReservoirs'
? 'Point (점)'
: 'LineString (선)'}
</span>
<span>표시 상태:</span>
<span style={{ color: visibility[selectedInfo.category] ? 'var(--good)' : 'var(--text-muted)' }}>
{visibility[selectedInfo.category] ? '표시 중' : '숨김'}
</span>
</div>
) : selectedInfo.type === 'results' ? (
<div style={{ display: 'grid', gridTemplateColumns: '70px 1fr', gap: '3px', color: 'var(--text-secondary)' }}>
<span>지표:</span>
<strong style={{ color: 'var(--text)' }}>{selectedInfo.metric}</strong>
<span>해석 엔진:</span>
<span>{wasmResult?.engine.runtime.toUpperCase()} 2.3.5</span>
<span>단위:</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>
{selectedInfo.metric === 'pressure'
? displayUnits.pressure
: selectedInfo.metric === 'head'
? displayUnits.head
: selectedInfo.metric === 'flow'
? displayUnits.flow
: selectedInfo.metric === 'velocity'
? displayUnits.velocity
: selectedInfo.metric === 'headloss'
? 'm/km'
: '-'}
</span>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '70px 1fr', gap: '3px', color: 'var(--text-secondary)' }}>
<span>레이어명:</span>
<strong style={{ color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{selectedInfo.layer.name}
</strong>
<span>피처 수:</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{selectedInfo.layer.featureCount}개</span>
<span>형상 유형:</span>
<span>{selectedInfo.layer.geomTypes.join(', ')}</span>
<span>추정 좌표:</span>
<span style={{ color: 'var(--accent)', fontWeight: 500 }}>{selectedInfo.layer.estimatedCrs}</span>
<span>표시 상태:</span>
<span style={{ color: selectedInfo.layer.visible ? 'var(--good)' : 'var(--text-muted)' }}>
{selectedInfo.layer.visible ? `표시 중 (${Math.round(selectedInfo.layer.opacity * 100)}%)` : '숨김'}
</span>
</div>
)
) : (
<div style={{ color: 'var(--text-muted)', fontSize: '10px' }}>
위 목록에서 레이어를 클릭하면 상세 정보가 표시됩니다.
</div>
)}
</div>
{/* 4. Resizer Handle on Right Edge */}
<div
onMouseDown={handleResizeStart}
onDoubleClick={handleResizeDoubleClick}
style={{
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
width: '5px',
cursor: 'col-resize',
zIndex: 30,
}}
title="드래그하여 폭 조절 (더블클릭: 기본폭 264px 초기화)"
/>
{/* Layer Context Menu */}
{layerContextMenu && (
<div
style={{
position: 'fixed',
left: Math.min(layerContextMenu.x, window.innerWidth - 190),
top: Math.min(layerContextMenu.y, window.innerHeight - 200),
zIndex: 1200,
width: '180px',
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-sm)',
boxShadow: 'var(--shadow-pop)',
padding: '4px',
display: 'flex',
flexDirection: 'column',
gap: '1px',
fontSize: '12px',
}}
onMouseDown={(e) => e.stopPropagation()}
role="menu"
aria-label="레이어 컨텍스트 메뉴"
>
<div
style={{
padding: '3px 6px',
fontSize: '10px',
fontWeight: 600,
color: 'var(--text-muted)',
borderBottom: '1px solid var(--border)',
marginBottom: '2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{layerContextMenu.layerTitle}
</div>
<button
className="dropdown-item"
onClick={() => {
onOpenAttributeTable(layerContextMenu.category);
setLayerContextMenu(null);
}}
style={{ width: '100%', padding: '4px 6px', fontSize: '11px', textAlign: 'left', border: 'none', background: 'none', cursor: 'pointer' }}
>
속성 테이블 열기
</button>
<button
className="dropdown-item"
onClick={() => {
if (layerContextMenu.isUserLayer) {
onToggleUserLayerVisibility(layerContextMenu.layerKey);
} else {
const k = layerContextMenu.layerKey as keyof LayerVisibilityState;
if (typeof visibility[k] === 'boolean') {
onVisibilityChange((prev) => ({
...prev,
[k]: !prev[k],
}));
}
}
setLayerContextMenu(null);
}}
style={{ width: '100%', padding: '4px 6px', fontSize: '11px', textAlign: 'left', border: 'none', background: 'none', cursor: 'pointer' }}
>
표시 / 숨기기
</button>
<div style={{ height: '1px', background: 'var(--border)', margin: '2px 0' }} />
<button
className="dropdown-item"
onClick={() => {
onOpenStyleSettings(layerContextMenu.layerKey, layerContextMenu.isLine, layerContextMenu.isUserLayer);
setLayerContextMenu(null);
}}
style={{ width: '100%', padding: '4px 6px', fontSize: '11px', textAlign: 'left', border: 'none', background: 'none', cursor: 'pointer' }}
>
스타일 설정...
</button>
<button
className="dropdown-item"
onClick={() => {
onOpenLabelSettings(layerContextMenu.layerKey, layerContextMenu.isLine, layerContextMenu.isUserLayer);
setLayerContextMenu(null);
}}
style={{ width: '100%', padding: '4px 6px', fontSize: '11px', textAlign: 'left', border: 'none', background: 'none', cursor: 'pointer' }}
>
라벨 설정...
</button>
{layerContextMenu.isUserLayer && (
<>
<div style={{ height: '1px', background: 'var(--border)', margin: '2px 0' }} />
<button
className="dropdown-item"
onClick={() => {
if (layerContextMenu.userLayerObj) onConvertToNetwork(layerContextMenu.userLayerObj);
setLayerContextMenu(null);
}}
style={{ width: '100%', padding: '4px 6px', fontSize: '11px', textAlign: 'left', border: 'none', background: 'none', cursor: 'pointer' }}
>
관망으로 변환
</button>
<button
className="dropdown-item"
onClick={() => {
onDeleteUserLayer(layerContextMenu.layerKey);
setLayerContextMenu(null);
}}
style={{ width: '100%', padding: '4px 6px', fontSize: '11px', textAlign: 'left', border: 'none', background: 'none', cursor: 'pointer', color: 'var(--crit)' }}
>
레이어 삭제
</button>
</>
)}
</div>
)}
</aside>
);
};
+393
View File
@@ -0,0 +1,393 @@
import React, { useState } from 'react';
import { Palette, X, RotateCcw, Check, Sparkles } from 'lucide-react';
import type { DisplayMode, SimulationResult } from './types';
import type { NetworkModel } from './inpModel';
import { computeUnitHeadloss } from './unitSystem';
export interface LayerStyleConfig {
useColormap: boolean;
colormapPreset: 'blue' | 'amber' | 'green' | 'purple' | 'diverging';
minValue?: number;
maxValue?: number;
solidColor: string;
sizeOrWidth: number; // radius for point (3~10px), width for line (0.8~5px)
opacity: number; // 0.0 ~ 1.0
}
interface LayerStyleModalProps {
layerKey: string;
layerTitle: string;
isLine: boolean;
isUserLayer: boolean;
simulationResult: SimulationResult | null;
networkModel?: NetworkModel | null;
displayMode: DisplayMode;
initialConfig?: Partial<LayerStyleConfig>;
onClose: () => void;
onApply: (config: LayerStyleConfig) => void;
}
const COLOR_PRESETS = [
{ id: 'blue', label: '블루 (기본 수압/수두/유량)', colors: ['#dbeafe', '#93c5fd', '#3b82f6', '#1d4ed8', '#172554'] },
{ id: 'amber', label: '앰버 (기본 유속/손실수두)', colors: ['#d9a873', '#c68941', '#b06b00', '#935100', '#713d00'] },
{ id: 'green', label: '그린 (적정/안정 수압)', colors: ['#bbf7d0', '#86efac', '#4ade80', '#22c55e', '#15803d'] },
{ id: 'purple', label: '퍼플 (특수/수질 농도)', colors: ['#e9d5ff', '#d8b4fe', '#c084fc', '#a855f7', '#7e22ce'] },
{ id: 'diverging', label: '다이버지 (차이/편차)', colors: ['#0b5ca3', '#80ade0', '#e5e6e8', '#db948b', '#98342e'] },
] as const;
const SOLID_SWATCHES = [
'#0284c7', // Sky blue
'#2563eb', // Blue
'#16a34a', // Green
'#d97706', // Amber
'#dc2626', // Red
'#7c3aed', // Purple
'#64748b', // Slate
'#0f172a', // Dark
];
export const LayerStyleModal: React.FC<LayerStyleModalProps> = ({
layerKey,
layerTitle,
isLine,
isUserLayer,
simulationResult,
networkModel,
displayMode,
initialConfig,
onClose,
onApply,
}) => {
const [useColormap, setUseColormap] = useState<boolean>(
initialConfig?.useColormap ?? (!isUserLayer && !!simulationResult)
);
const [colormapPreset, setColormapPreset] = useState<'blue' | 'amber' | 'green' | 'purple' | 'diverging'>(
initialConfig?.colormapPreset || (displayMode === 'flow' ? 'blue' : isLine ? 'amber' : 'blue')
);
const [minValue, setMinValue] = useState<string>(
initialConfig?.minValue !== undefined ? String(initialConfig.minValue) : '0'
);
const [maxValue, setMaxValue] = useState<string>(
initialConfig?.maxValue !== undefined ? String(initialConfig.maxValue) : '100'
);
const [solidColor, setSolidColor] = useState<string>(
initialConfig?.solidColor || (isLine ? '#64748b' : '#3b82f6')
);
const [sizeOrWidth, setSizeOrWidth] = useState<number>(
initialConfig?.sizeOrWidth || (isLine ? 1.2 : 5)
);
// Auto Range Calculation from actual simulation results
const handleAutoRange = () => {
if (!simulationResult) return;
let min = Infinity;
let max = -Infinity;
if (!isLine) {
// Node values
Object.values(simulationResult.nodes).forEach((n) => {
let val = 0;
if (displayMode === 'pressure') val = n.pressure;
else if (displayMode === 'head') val = n.head;
else if (displayMode === 'quality') val = n.quality ?? 0;
if (Number.isFinite(val)) {
if (val < min) min = val;
if (val > max) max = val;
}
});
} else {
// Link values
Object.entries(simulationResult.links).forEach(([linkId, l]) => {
let val = 0;
if (displayMode === 'flow') val = l.flow;
else if (displayMode === 'velocity') val = l.velocity;
else if (displayMode === 'headloss') {
// EN_HEADLOSS is a TOTAL across the whole pipe and scales with pipe
// length, so the auto-range must use the length-normalized unit
// headloss (matching the map's own "headloss" property) -- ranging
// on the raw total would bias the scale toward long pipes.
const length = networkModel?.pipes[linkId]?.length ?? 0;
val = computeUnitHeadloss(l.headloss, length);
}
if (Number.isFinite(val)) {
const absVal = Math.abs(val);
if (absVal < min) min = absVal;
if (absVal > max) max = absVal;
}
});
}
if (min !== Infinity && max !== -Infinity) {
setMinValue(String(Math.floor(min)));
setMaxValue(String(Math.ceil(max) || 10));
}
};
const handleSave = () => {
const config: LayerStyleConfig = {
useColormap: isUserLayer ? false : useColormap,
colormapPreset,
minValue: parseFloat(minValue) || 0,
maxValue: parseFloat(maxValue) || 100,
solidColor,
sizeOrWidth,
opacity: initialConfig?.opacity ?? 0.9,
};
localStorage.setItem(`gispipe_layer_style_${layerKey}`, JSON.stringify(config));
onApply(config);
onClose();
};
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 1050,
background: 'rgba(15, 23, 42, 0.4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
}}
onClick={onClose}
>
<div
style={{
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
boxShadow: 'var(--shadow-modal)',
width: '100%',
maxWidth: '440px',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
fontSize: '12px',
}}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="레이어 스타일 설정"
>
{/* Modal Header */}
<div
style={{
padding: '10px 14px',
borderBottom: '1px solid var(--border)',
background: 'var(--bg-subtle)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 600 }}>
<Palette size={15} style={{ color: 'var(--accent)' }} aria-hidden="true" />
<span>스타일 설정: {layerTitle}</span>
</div>
<button
onClick={onClose}
className="btn btn-ghost"
style={{ padding: '3px' }}
title="닫기"
aria-label="닫기"
>
<X size={15} aria-hidden="true" />
</button>
</div>
{/* Modal Body */}
<div style={{ padding: '14px', display: 'flex', flexDirection: 'column', gap: '14px', maxHeight: '75vh', overflowY: 'auto' }}>
{/* Section 1: Colormap vs Single color */}
{!isUserLayer && (
<div style={{ padding: '8px 10px', background: 'var(--bg-subtle)', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)' }}>
<label style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', fontWeight: 500 }}>
<span>수리해석 결과 컬러맵 적용</span>
<input
type="checkbox"
checked={useColormap}
disabled={!simulationResult}
onChange={(e) => setUseColormap(e.target.checked)}
/>
</label>
{!simulationResult && (
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '4px' }}>
해석 결과가 없을 때는 기본 단색 스타일이 적용됩니다.
</div>
)}
</div>
)}
{/* Section 2: Colormap Preset & Range */}
{useColormap && !isUserLayer && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: 500, color: 'var(--text-secondary)' }}>
색상 팔레트 프리셋
</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{COLOR_PRESETS.map((p) => {
const isSelected = colormapPreset === p.id;
return (
<div
key={p.id}
onClick={() => setColormapPreset(p.id)}
style={{
padding: '6px 8px',
borderRadius: 'var(--r-sm)',
border: isSelected ? '1.5px solid var(--accent)' : '1px solid var(--border)',
background: isSelected ? 'var(--accent-wash)' : 'var(--bg-panel)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
}}
>
<span style={{ fontSize: '11px', fontWeight: isSelected ? 600 : 400 }}>{p.label}</span>
<div style={{ display: 'flex', gap: '2px', height: '10px', width: '80px', borderRadius: '2px', overflow: 'hidden' }}>
{p.colors.map((c, i) => (
<div key={i} style={{ flex: 1, background: c }} />
))}
</div>
</div>
);
})}
</div>
</div>
{/* Range settings */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
<label style={{ fontWeight: 500, color: 'var(--text-secondary)' }}>
값 표시 범위 ({displayMode})
</label>
<button
onClick={handleAutoRange}
className="btn btn-secondary"
style={{ padding: '2px 6px', fontSize: '10px', display: 'inline-flex', alignItems: 'center', gap: '3px' }}
title="해석 결과의 최소/최대값 자동 적용"
>
<Sparkles size={11} aria-hidden="true" />
<span>자동 범위</span>
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
<div>
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>최솟값 (0%)</span>
<input
type="number"
value={minValue}
onChange={(e) => setMinValue(e.target.value)}
style={{ width: '100%', padding: '4px 6px', border: '1px solid var(--border-strong)', borderRadius: 'var(--r-sm)', fontSize: '12px' }}
/>
</div>
<div>
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>최댓값 (100%)</span>
<input
type="number"
value={maxValue}
onChange={(e) => setMaxValue(e.target.value)}
style={{ width: '100%', padding: '4px 6px', border: '1px solid var(--border-strong)', borderRadius: 'var(--r-sm)', fontSize: '12px' }}
/>
</div>
</div>
</div>
</div>
)}
{/* Section 3: Solid Color Selection (when colormap is off or for user GIS) */}
{(!useColormap || isUserLayer) && (
<div>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: 500, color: 'var(--text-secondary)' }}>
기본 색상
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="color"
value={solidColor}
onChange={(e) => setSolidColor(e.target.value)}
style={{ width: '32px', height: '28px', padding: 0, border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', cursor: 'pointer' }}
/>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{SOLID_SWATCHES.map((swatch) => (
<div
key={swatch}
onClick={() => setSolidColor(swatch)}
style={{
width: '20px',
height: '20px',
borderRadius: '4px',
background: swatch,
cursor: 'pointer',
border: solidColor === swatch ? '2px solid var(--text)' : '1px solid var(--border)',
}}
/>
))}
</div>
</div>
</div>
)}
{/* Section 4: Stroke Width or Node Size */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px', color: 'var(--text-secondary)' }}>
<span>{isLine ? '선 두께 (Stroke Width)' : '절점 크기 (Node Radius)'}</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{sizeOrWidth}{isLine ? 'px' : 'px'}</span>
</div>
<input
type="range"
min={isLine ? 0.8 : 3}
max={isLine ? 4.0 : 10}
step={isLine ? 0.2 : 0.5}
value={sizeOrWidth}
onChange={(e) => setSizeOrWidth(parseFloat(e.target.value))}
style={{ width: '100%', accentColor: 'var(--accent)' }}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', color: 'var(--text-muted)' }}>
<span>{isLine ? '얇게 (0.8px)' : '작게 (3px)'}</span>
<span>{isLine ? '두껍게 (4.0px)' : '크게 (10px)'}</span>
</div>
</div>
</div>
{/* Modal Footer */}
<div
style={{
padding: '10px 14px',
borderTop: '1px solid var(--border)',
background: 'var(--bg-subtle)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<button
onClick={() => {
localStorage.removeItem(`gispipe_layer_style_${layerKey}`);
onClose();
}}
className="btn btn-secondary"
style={{ fontSize: '11px', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
>
<RotateCcw size={12} aria-hidden="true" />
<span>기본값 초기화</span>
</button>
<div style={{ display: 'flex', gap: '6px' }}>
<button onClick={onClose} className="btn btn-secondary" style={{ fontSize: '11px' }}>
취소
</button>
<button
onClick={handleSave}
className="btn btn-primary"
style={{ fontSize: '11px', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
>
<Check size={12} aria-hidden="true" />
<span>적용</span>
</button>
</div>
</div>
</div>
</div>
);
};
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff. Show more