도면 출력 추가: 수리 종단면도, 관망도(색인도·부분도·수압표), 범례·색상 선택, DXF(AutoCAD 2018)

- 데모 v2 이후 개발 PC의 변경을 옮김 (이 저장소의 이름 변경·README 정리는 유지)
- 서버 기본 DEM, DEM 등고선, 속성 테이블 범위 선택, WNTR 교차 검증 시험 포함
- 새 라이브러리: ezdxf(Python), maplibre-contour(npm)
- 변경 내역: docs/SPipe_패치내역_도면출력_2026-11-06.txt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SPipe EngineeringandClaude Opus 5.5 committed 2026-11-06 15:03:21 +09:00
1 parent 14f07181db
commit e3f386addf
33 files changed
+3454 -482

No files matched your search

+405 -396
View File
@@ -1,396 +1,405 @@
# SPipe
## 프로젝트 개요
웹 브라우저에서 쓰는 상수도 관망 수리해석·관리 플랫폼.
EPANET 2.3.5 엔진으로 관망을 해석하고, GIS·DEM·자산 정보를 한 화면에서 함께 다룬다.
주요 기능:
- EPANET INP 관망 불러오기·편집·해석 (Native DLL과 WASM 두 엔진 교차 검증)
- 지도 기반 관망 편집 (절점·관로·밸브·펌프, 꺾임점, 분할·병합)
- 해석 결과 지도·그래프·종단·보고서, 실측 보정
- 분석 도구: 소화전 모의, 관로 중요도, 급수 구역, VSP, 등치선, 에너지 비용, 자동 보정, 펌프 운전 최적화
- GIS 연동: SHP·DXF·DGN·MIF·GML·KML·GPKG 불러오기·내보내기, 좌표계 변환, 도형 연산
- DEM 기반 절점 표고 입력·검증, 유역·하천 분석, 3D 보기
- 프로젝트 파일(.spipe), 시나리오, 자동 백업
## 웹 기반으로 만드는 이유
### 기존 운영 방식
설치형 관망해석 프로그램(EPANET 등)을 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-spipe.bat` 더블클릭입니다. 해석 API(8000)와 웹 개발 서버(5173)를 함께 띄우고 브라우저를 엽니다. 종료는 `stop-spipe.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 (현재 운영 방식)
현재 배포본은 데모 v2 (2026-10-30, 이전 배포 데모 v1 2026-10-15)입니다. 변경 내역은 `docs/SPipe_패치내역_v2_2026-10-30.txt`를 확인하고, (시험)으로 표시된 기능은 기존 방식과 결과를 비교해 사용합니다.
저장소의 파일(또는 배포용 압축 파일 `SPipe_server_*.zip`)을 서버 PC의 SPipe 폴더에 덮어쓰고 다음 순서로 실행합니다. 기존 `.venv`, `node_modules`, `data`와 `.env.local`은 유지합니다. 최초 설치 절차는 `SERVER_SETUP.md`를 따릅니다.
1. 새 라이브러리 설치 (데모 v2에서 scipy, rasterio 등이 추가되어 반드시 실행)
```powershell
.\.venv\Scripts\python.exe -m pip install -r backend\requirements.txt
```
2. 화면을 다시 빌드하면서 서버 실행 (`-Rebuild` 없이 실행하면 예전 화면이 남음)
```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 보관, 커밋 제외)
```
# SPipe
## 프로젝트 개요
웹 브라우저에서 쓰는 상수도 관망 수리해석·관리 플랫폼.
EPANET 2.3.5 엔진으로 관망을 해석하고, GIS·DEM·자산 정보를 한 화면에서 함께 다룬다.
주요 기능:
- EPANET INP 관망 불러오기·편집·해석 (Native DLL과 WASM 두 엔진 교차 검증)
- 지도 기반 관망 편집 (절점·관로·밸브·펌프, 꺾임점, 분할·병합)
- 해석 결과 지도·그래프·종단·보고서, 실측 보정
- 분석 도구: 소화전 모의, 관로 중요도, 급수 구역, VSP, 등치선, 에너지 비용, 자동 보정, 펌프 운전 최적화
- GIS 연동: SHP·DXF·DGN·MIF·GML·KML·GPKG 불러오기·내보내기, 좌표계 변환, 도형 연산
- DEM 기반 절점 표고 입력·검증, 유역·하천 분석, 3D 보기
- 프로젝트 파일(.spipe), 시나리오, 자동 백업
## 웹 기반으로 만드는 이유
### 기존 운영 방식
설치형 관망해석 프로그램(EPANET 등)을 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-spipe.bat` 더블클릭입니다. 해석 API(8000)와 웹 개발 서버(5173)를 함께 띄우고 브라우저를 엽니다. 종료는 `stop-spipe.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 개수 |
| `SPIPE_BASE_DEM_DIR` | `data/base_dem` | 서버 기본 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 (현재 운영 방식)
현재 배포본은 데모 v2 (2026-10-30, 이전 배포 데모 v1 2026-10-15)입니다. 변경 내역은 `docs/SPipe_패치내역_v2_2026-10-30.txt`를 확인하고, (시험)으로 표시된 기능은 기존 방식과 결과를 비교해 사용합니다.
저장소의 파일(또는 배포용 압축 파일 `SPipe_server_*.zip`)을 서버 PC의 SPipe 폴더에 덮어쓰고 다음 순서로 실행합니다. 기존 `.venv`, `node_modules`, `data`와 `.env.local`은 유지합니다. 최초 설치 절차는 `SERVER_SETUP.md`를 따릅니다.
1. 새 라이브러리 설치 (데모 v2에서 scipy, rasterio 등이 추가되어 반드시 실행)
```powershell
.\.venv\Scripts\python.exe -m pip install -r backend\requirements.txt
```
2. 화면을 다시 빌드하면서 서버 실행 (`-Rebuild` 없이 실행하면 예전 화면이 남음)
```powershell
powershell -ExecutionPolicy Bypass -File .\start-lan.ps1 -Rebuild
```
- 웹 화면: `http://<서버 IP>:8080` (사내망 공개)
- 해석 API: `127.0.0.1:8000` (서버 내부 전용, 웹 서버가 `/api`만 전달)
- 코드 갱신 후에는 반드시 `-Rebuild`로 화면을 다시 빌드합니다.
서버 기본 DEM 등록 (선택, 최초 1회): 국토지리정보원 DEM 등을 서버에 한 번 등록해 두면 사용자가 파일을 올리지 않고 분석 → 지형(DEM)의 "서버 DEM" 버튼으로 바로 씁니다. DEM 파일은 저장소·배포 압축에 들어 있지 않으므로 서버마다 등록합니다.
```powershell
.\.venv\Scripts\python.exe -m backend.dem register "C:\경로\한반도.zip" korea-90m "국토지리정보원 DEM 90m (비영리·변경금지)"
```
이름(두 번째 값)은 영문 소문자·숫자·`-`·`_`만 씁니다. 자료의 이용 조건(국토지리정보원 90m는 CC BY-NC-ND)을 확인하고 등록하세요.
### 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 보관, 커밋 제외)
```
+28 -19
View File
@@ -1,19 +1,28 @@
# 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` 추가
## 2026-10-30 예전 프로그램 이름 표기 정리
- 실행 파일 이름을 `start-spipe.bat`, `stop-spipe.bat`로 변경 (README·실행 안내 문구 함께 수정)
- README, `public/EXAMPLE_NETWORKS.md`, 코드 주석, CSS 머리말, 시험 임시 폴더 접두어의 예전 이름을 SPipe 또는 "상용 관망 프로그램"으로 변경
- 그대로 둔 것: 브라우저 저장 키, 세션 DB 이름, 복사·붙여넣기 표시의 예전 접두어. 바꾸면 사용자의 저장된 설정·세션이 초기화되므로 이전(마이그레이션) 방식을 정한 뒤 변경
# 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` 추가
## 2026-10-30 예전 프로그램 이름 표기 정리
- 실행 파일 이름을 `start-spipe.bat`, `stop-spipe.bat`로 변경 (README·실행 안내 문구 함께 수정)
- README, `public/EXAMPLE_NETWORKS.md`, 코드 주석, CSS 머리말, 시험 임시 폴더 접두어의 예전 이름을 SPipe 또는 "상용 관망 프로그램"으로 변경
- 그대로 둔 것: 브라우저 저장 키, 세션 DB 이름, 복사·붙여넣기 표시의 예전 접두어. 바꾸면 사용자의 저장된 설정·세션이 초기화되므로 이전(마이그레이션) 방식을 정한 뒤 변경
## 2026-11-06 도면 출력 추가
- 변경 내역: `docs/SPipe_패치내역_도면출력_2026-11-06.txt` (이전 배포 데모 v2, 2026-10-30)
- 기준: 데모 v2 이후 개발 PC에서 추가한 변경만 옮김. 이 저장소의 이름 변경(`start-spipe.bat` 등)과 README 정리는 그대로 유지
- 주요 내용: 수리 종단면도·관망도(색인도 + 부분도, 수압표) 도면 출력, 범례 양식·색상 선택, DXF(AutoCAD 2018), 서버 기본 DEM, DEM 등고선, 속성 테이블 범위 선택, WNTR 교차 검증 시험
- 새 라이브러리: Python `ezdxf`(DXF), npm `maplibre-contour`(등고선). 서버 반영 시 `pip install -r backend\requirements.txt`와 `npm install` 후 `start-lan.ps1 -Rebuild`
- 확인: `npm run typecheck`, `npm test`(109개), `npm run test:api`(75개), `npm run build` 통과
- 그대로 둔 것: 브라우저 저장 키의 예전 접두어(`gispipe_`). 새 도면 설정 키는 `spipe_` 접두어 사용
+76 -15
View File
@@ -27,12 +27,24 @@ 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"))
# Server-provided DEMs (e.g. the national 90 m DEM), registered once with
# `python -m backend.dem register <raster|zip> <name> <label>`; never trimmed.
BASE_DIR = Path(os.environ.get("SPIPE_BASE_DEM_DIR", Path(__file__).resolve().parents[1] / "data" / "base_dem"))
_NAME_OK = set("abcdefghijklmnopqrstuvwxyz0123456789_-")
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 dem_id.startswith("base-"):
name = dem_id[5:]
if not name or not set(name) <= _NAME_OK:
raise BatchError("DEM id가 올바르지 않습니다.")
p = BASE_DIR / f"{name}.tif"
if not p.exists():
raise BatchError("서버 기본 DEM이 없습니다.")
return p
if len(dem_id) != 32 or not set(dem_id) <= _ID_OK:
raise BatchError("DEM id가 올바르지 않습니다.")
p = DEM_DIR / f"{dem_id}.tif"
@@ -79,10 +91,12 @@ def _info(dem_id: str, src, name: str) -> dict:
}
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"
def _store(src_path: str, name: str, base: str | None = None) -> dict:
"""Copy a raster into the store as a tiled GeoTIFF (base: a permanent server DEM)."""
folder = BASE_DIR if base else DEM_DIR
folder.mkdir(parents=True, exist_ok=True)
dem_id = f"base-{base}" if base else uuid.uuid4().hex
out = folder / f"{base if base else dem_id}.tif"
try:
with rasterio.open(src_path) as src:
if src.count < 1:
@@ -105,10 +119,36 @@ def _store(src_path: str, name: str) -> dict:
out.unlink(missing_ok=True)
raise
out.with_suffix(".json").write_text(json.dumps(info, ensure_ascii=False), encoding="utf-8")
_trim()
if not base:
_trim()
return info
def register_base(src: str, name: str, label: str) -> dict:
"""Register a raster (or a zip holding one) as a permanent server DEM."""
if not name or not set(name) <= _NAME_OK:
raise BatchError("이름은 영문 소문자·숫자·-·_ 만 쓸 수 있습니다.")
if src.lower().endswith(".zip"):
with zipfile.ZipFile(src) as z:
names = [n for n in z.namelist() if os.path.splitext(n.lower())[1] in RASTER_EXTENSIONS]
if not names:
raise BatchError("zip 안에 래스터가 없습니다.")
src = "/vsizip/" + os.path.abspath(src).replace("\\", "/") + "/" + names[0]
return _store(src, label, base=name)
def list_base() -> list[dict]:
if not BASE_DIR.exists():
return []
out = []
for j in sorted(BASE_DIR.glob("*.json")):
try:
out.append(json.loads(j.read_text(encoding="utf-8")))
except (OSError, ValueError):
continue
return out
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]
@@ -292,16 +332,27 @@ 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):
def _hydro_grid(dem_id: str, bbox: list | None = None, bbox_crs: str | None = None):
"""DEM (downsampled to <= MAX_HYDRO_CELLS) -> D8 receivers and flow accumulation
on a priority-flood filled surface (pits and flats drain)."""
on a priority-flood filled surface (pits and flats drain). `bbox` [x0, y0, x1, y1]
in `bbox_crs` limits the area (needed for country-wide DEMs)."""
import heapq
from rasterio.windows import Window, from_bounds
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)
win = Window(0, 0, src.width, src.height)
if bbox:
tr = Transformer.from_crs(_crs_of(bbox_crs), src.crs, always_xy=True)
xs, ys = tr.transform([bbox[0], bbox[2], bbox[0], bbox[2]], [bbox[1], bbox[1], bbox[3], bbox[3]])
win = from_bounds(min(xs), min(ys), max(xs), max(ys), src.transform).round_offsets().round_lengths()
win = win.intersection(Window(0, 0, src.width, src.height))
elif src.width * src.height > 16 * MAX_HYDRO_CELLS:
raise BatchError("DEM이 너무 넓습니다. 관망 범위로 잘라서 계산하세요.")
k = max(1, int(np.ceil(np.sqrt(win.width * win.height / MAX_HYDRO_CELLS))))
h, w = max(2, int(win.height) // k), max(2, int(win.width) // k)
z = src.read(1, window=win, masked=True, out_shape=(h, w), resampling=Resampling.average).astype("float64")
wt = src.window_transform(win)
transform = wt @ wt.scale(win.width / w, win.height / h)
crs = src.crs
if crs.is_geographic:
raise BatchError("유역 분석에는 평면 좌표계 DEM이 필요합니다.")
@@ -353,9 +404,9 @@ def _hydro_grid(dem_id: str):
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:
def streams(dem_id: str, min_area_km2: float, bbox: list | None = None, bbox_crs: str | None = None) -> dict:
"""Stream lines (WGS84 GeoJSON): cells draining at least `min_area_km2`."""
g = _hydro_grid(dem_id)
g = _hydro_grid(dem_id, bbox, bbox_crs)
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]
@@ -385,11 +436,11 @@ def streams(dem_id: str, min_area_km2: float) -> dict:
return {"type": "FeatureCollection", "features": feats}
def watershed(dem_id: str, points: list, points_crs: str | None, snap_m: float = 50.0) -> dict:
def watershed(dem_id: str, points: list, points_crs: str | None, snap_m: float = 50.0, bbox: list | None = None) -> 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)
g = _hydro_grid(dem_id, bbox, points_crs)
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)
@@ -430,3 +481,13 @@ def watershed(dem_id: str, points: list, points_crs: str | None, snap_m: float =
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}
if __name__ == "__main__":
import sys
if len(sys.argv) == 5 and sys.argv[1] == "register":
info = register_base(sys.argv[2], sys.argv[3], sys.argv[4])
print(f"registered {info['id']}: {info['name']} {info['width']}x{info['height']} res {info['resolution']} z {info['min']}~{info['max']}")
else:
print("usage: python -m backend.dem register <raster|zip> <name> <label>")
+134
View File
@@ -0,0 +1,134 @@
"""Drawing sheets (src/drawing.ts model) -> DXF R2018 via ezdxf.
The browser sends the same item list it renders as SVG: lines, texts, circles
and solid fills in millimetres (origin bottom-left). R2018 stores text as
UTF-8, so Korean needs no code page; widths become lineweights, colours true
colours, fills solid hatches. Each item keeps its layer.
"""
from __future__ import annotations
import io
import re
import ezdxf
from ezdxf import colors
from ezdxf.enums import TextEntityAlignment
from .batch import BatchError
MAX_ITEMS = 400_000
_LINEWEIGHTS = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211]
_FONTS = {"ulleungM": ("HY울릉도M.TTF", "HY울릉도M"), "malgun": ("malgun.ttf", "Malgun Gothic")}
_HEX = re.compile(r"^#([0-9a-fA-F]{6})$")
_ALIGN = {
("start", "bottom"): TextEntityAlignment.LEFT, ("middle", "bottom"): TextEntityAlignment.CENTER, ("end", "bottom"): TextEntityAlignment.RIGHT,
("start", "middle"): TextEntityAlignment.MIDDLE_LEFT, ("middle", "middle"): TextEntityAlignment.MIDDLE_CENTER, ("end", "middle"): TextEntityAlignment.MIDDLE_RIGHT,
("start", "top"): TextEntityAlignment.TOP_LEFT, ("middle", "top"): TextEntityAlignment.TOP_CENTER, ("end", "top"): TextEntityAlignment.TOP_RIGHT,
}
def _rgb(color: str | None) -> int | None:
m = _HEX.match(color or "")
return colors.rgb2int(tuple(int(m.group(1)[i:i + 2], 16) for i in (0, 2, 4))) if m else None
def _lineweight(width_mm: float | None) -> int:
w = (width_mm or 0.25) * 100
return min(_LINEWEIGHTS[1:], key=lambda v: abs(v - w))
def _pts(raw) -> list[tuple[float, float]]:
try:
return [(float(p[0]), float(p[1])) for p in raw]
except (TypeError, ValueError, IndexError) as e:
raise BatchError("도면 좌표가 올바르지 않습니다.") from e
def drawing_to_dxf(drawing: dict) -> bytes:
items = drawing.get("items") or []
if not isinstance(items, list) or not items:
raise BatchError("도면이 비어 있습니다.")
if len(items) > MAX_ITEMS:
raise BatchError("도면 요소가 너무 많습니다.")
doc = ezdxf.new("R2018", setup=True) # standard linetypes incl. DASHED / DOT
doc.units = ezdxf.units.MM
doc.header["$LWDISPLAY"] = 1
# Text style: the sheet's font (HY울릉도M by default) by file and family
# name; CAD substitutes when the font is not installed on that PC.
font_file, family = _FONTS.get(str(drawing.get("font") or ""), _FONTS["malgun"])
style = doc.styles.add("MALGUN", font=font_file)
style.set_extended_font_data(family=family)
msp = doc.modelspace()
layers: set[str] = set()
def attrs(it: dict, color: str | None) -> dict:
layer = str(it.get("layer") or "0")[:255]
if layer not in layers:
doc.layers.add(layer)
layers.add(layer)
a = {"layer": layer}
m = _HEX.match(color or "")
if m and max(int(m.group(1)[i:i + 2], 16) for i in (0, 2, 4)) < 64:
a["color"] = 7 # near-black -> white/black by CAD background (AutoCAD model space is dark)
elif m:
a["true_color"] = _rgb(color)
return a
def is_white(color: str | None) -> bool:
m = _HEX.match(color or "")
return bool(m) and min(int(m.group(1)[i:i + 2], 16) for i in (0, 2, 4)) > 240
for it in items:
kind = it.get("kind")
if kind == "line":
pts = _pts(it.get("pts") or [])
if len(pts) < 2:
continue
a = attrs(it, it.get("color") or "#111111")
a["lineweight"] = _lineweight(it.get("width"))
if it.get("dash") == "dashed":
a["linetype"] = "DASHED"
elif it.get("dash") == "dotted":
a["linetype"] = "DOT"
pl = msp.add_lwpolyline(pts, close=bool(it.get("closed")), dxfattribs=a)
if a.get("linetype"):
pl.dxf.ltscale = max(0.05, (it.get("width") or 0.25) * 1.2)
elif kind == "fill":
pts = _pts(it.get("pts") or [])
# White boxes only mask lines behind labels on paper; in CAD they hide geometry.
if len(pts) < 3 or is_white(it.get("fill")):
continue
a = attrs(it, it.get("fill"))
h = msp.add_hatch(dxfattribs=a)
h.paths.add_polyline_path(pts, is_closed=True)
op = float(it.get("opacity") if it.get("opacity") is not None else 1)
if op < 1:
h.transparency = max(0.0, min(1.0, 1 - op))
elif kind == "circle":
x, y, r = float(it["x"]), float(it["y"]), float(it["r"])
if it.get("fill") and it.get("fill") != "none":
h = msp.add_hatch(dxfattribs=attrs(it, it.get("fill")))
h.paths.add_edge_path().add_arc((x, y), r, 0, 360)
a = attrs(it, it.get("color") or "#111111")
a["lineweight"] = _lineweight(it.get("width") or 0.2)
msp.add_circle((x, y), r, dxfattribs=a)
elif kind == "text":
text = str(it.get("text") or "")
if not text:
continue
a = attrs(it, it.get("color") or "#111111")
a.update(style="MALGUN", height=float(it.get("size") or 2.5) * 0.8, rotation=float(it.get("rotate") or 0))
t = msp.add_text(text, dxfattribs=a)
t.set_placement((float(it["x"]), float(it["y"])), align=_ALIGN.get((it.get("anchor") or "start", it.get("vAnchor") or "bottom"), TextEntityAlignment.LEFT))
# Open zoomed to the sheet (extents + initial model-space view).
w, h = float(drawing.get("width") or 420), float(drawing.get("height") or 297)
doc.set_modelspace_vport(height=h * 1.05, center=(w / 2, h / 2))
buf = io.StringIO()
doc.write(buf)
# R2018 text is UTF-8 and AutoCAD ignores $DWGCODEPAGE, but GDAL-based readers
# (QGIS etc.) decode with it; ezdxf always writes ANSI_1252 on save.
out = buf.getvalue().replace("$DWGCODEPAGE\n 3\nANSI_1252\n", "$DWGCODEPAGE\n 3\nANSI_65001\n", 1)
# ezdxf writes "unknown" extents (+-1e20); give CAD the sheet so zoom-extents works.
out = out.replace("$EXTMIN\n 10\n1e+20\n 20\n1e+20\n 30\n1e+20\n", "$EXTMIN\n 10\n0.0\n 20\n0.0\n 30\n0.0\n", 1)
out = out.replace("$EXTMAX\n 10\n-1e+20\n 20\n-1e+20\n 30\n-1e+20\n", f"$EXTMAX\n 10\n{w}\n 20\n{h}\n 30\n0.0\n", 1)
return out.encode("utf-8")
+48 -2
View File
@@ -16,6 +16,7 @@ from .columnar import to_columnar
from .gisio import read_vector
from . import dem as dem_store
from . import gisops
from .drawing_dxf import drawing_to_dxf
from urllib.parse import quote
from .jobs import get_job
from .epanet import EN, EpanetAPI, EpanetException, FLOW_UNITS, PRESS_UNITS, QUAL_TYPES
@@ -186,6 +187,12 @@ async def dem_upload(file: UploadFile = File(...)):
pass
@app.get("/api/dem-base")
async def dem_base():
"""Server-provided DEMs (registered with `python -m backend.dem register ...`)."""
return await _dem_run(dem_store.list_base)
@app.get("/api/dem/{dem_id}")
async def dem_get(dem_id: str):
return await _dem_run(dem_store.dem_info, dem_id)
@@ -219,9 +226,9 @@ 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)
return await _dem_run(dem_store.streams, dem_id, float(body.get("minAreaKm2") or 0.1), body.get("bbox"), body.get("crs"), 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)
return await _dem_run(dem_store.watershed, dem_id, body.get("points") or [], body.get("crs"), float(body.get("snap") or 50), body.get("bbox"), timeout=600)
raise HTTPException(status_code=400, detail="op must be streams or watershed")
@@ -257,6 +264,45 @@ async def gis_reproject(request: Request):
return await _dem_run(gisops.reproject_fc, body.get("featureCollection"), body.get("from"), body.get("to"))
_UESC = re.compile(r"\\U\+([0-9A-Fa-f]{4})")
_CRLF = "\r\n"
def dxf_to_cp949(text: str) -> bytes:
"""R12 DXF from the browser (non-ASCII written as backslash-U+XXXX) -> Korean
code page (ANSI_949), which Korean CAD reads; characters outside CP949 stay escaped."""
def unesc(m: re.Match) -> str:
ch = chr(int(m.group(1), 16))
try:
ch.encode("cp949")
return ch
except UnicodeEncodeError:
return m.group(0)
out = _UESC.sub(unesc, text)
ver = _CRLF.join(["$ACADVER", "1", "AC1009", ""])
out = out.replace(ver, ver + _CRLF.join(["9", "$DWGCODEPAGE", "3", "ANSI_949", ""]), 1)
return out.encode("cp949", errors="replace")
@app.post("/api/drawing/dxf")
async def drawing_dxf(request: Request):
"""Body: {"drawing": {...items}, "name": ...} -> DXF R2018 (ezdxf, UTF-8 text);
legacy {"dxf": "<R12 DXF text>"} -> CP949 R12."""
body = await request.json()
name = str(body.get("name") or "drawing") + ".dxf"
if isinstance(body.get("drawing"), dict):
data = await _dem_run(drawing_to_dxf, body["drawing"], timeout=120)
return Response(content=data, media_type="application/dxf",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(name)}"})
text = str(body.get("dxf") or "")
if not text.startswith("0" + _CRLF + "SECTION") or len(text) > 50_000_000:
raise HTTPException(status_code=400, detail="DXF가 올바르지 않습니다.")
name = str(body.get("name") or "drawing") + ".dxf"
return Response(content=dxf_to_cp949(text), media_type="application/dxf",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(name)}"})
@app.get("/api/health")
def health():
return {"status": "ok"}
+2
View File
@@ -10,3 +10,5 @@ rasterio==1.5.2
pyproj==3.8.0
shapely==2.2.0
scipy==1.18.1
# Drawing output (DXF R2018: Unicode text, lineweights, hatches)
ezdxf==1.4.5
+35
View File
@@ -0,0 +1,35 @@
"""/api/drawing/dxf: browser R12 DXF (Unicode escapes) -> Korean code page (ANSI_949)."""
import os
import tempfile
import pyogrio
from fastapi.testclient import TestClient
from backend.main import app, dxf_to_cp949
DXF = "\r\n".join([
"0", "SECTION", "2", "HEADER", "9", "$ACADVER", "1", "AC1009", "0", "ENDSEC",
"0", "SECTION", "2", "ENTITIES",
"0", "TEXT", "8", "TITLE", "10", "1", "20", "2", "30", "0", "40", "2.5", "1", r"\U+C218\U+B9AC \U+C885\U+B2E8\U+BA74\U+B3C4 \U+1F600",
"0", "ENDSEC", "0", "EOF", "",
])
def test_conversion_reads_back_as_korean():
data = dxf_to_cp949(DXF)
assert b"$DWGCODEPAGE\r\n3\r\nANSI_949" in data
with tempfile.TemporaryDirectory() as tmp:
p = os.path.join(tmp, "t.dxf")
open(p, "wb").write(data)
meta, _, _, fields = pyogrio.raw.read(p)
text = fields[list(meta["fields"]).index("Text")][0]
assert text.startswith("수리 종단면도")
assert r"\U+1F600" in data.decode("cp949") # outside CP949: kept escaped
def test_endpoint():
with TestClient(app) as c:
r = c.post("/api/drawing/dxf", json={"dxf": DXF, "name": "수리 종단면도"})
assert r.status_code == 200 and "attachment" in r.headers["content-disposition"]
assert "수리".encode("cp949") in r.content
assert c.post("/api/drawing/dxf", json={"dxf": "nope"}).status_code == 400
+56
View File
@@ -0,0 +1,56 @@
"""Drawing sheet -> DXF R2018 (ezdxf): Unicode text, layers, lineweights, hatches."""
import io
import os
import tempfile
import ezdxf
import pyogrio
import pytest
from fastapi.testclient import TestClient
from backend.batch import BatchError
from backend.drawing_dxf import drawing_to_dxf
from backend.main import app
DRAWING = {
"width": 420, "height": 297, "title": "t",
"items": [
{"kind": "line", "layer": "FRAME", "pts": [[0, 0], [420, 0], [420, 297], [0, 297]], "closed": True, "width": 0.7},
{"kind": "line", "layer": "HGL_1", "pts": [[50, 200], [300, 150]], "width": 0.45, "color": "#1d4ed8", "dash": "dashed"},
{"kind": "fill", "layer": "LEGEND", "pts": [[10, 10], [30, 10], [30, 20], [10, 20]], "fill": "#ffffff", "opacity": 0.9},
{"kind": "circle", "layer": "NODE", "x": 100, "y": 100, "r": 1.2, "fill": "#dc2626", "color": "#dc2626"},
{"kind": "text", "layer": "TITLE", "x": 210, "y": 280, "text": "송수관로 수리 종단면도 ○ ‰ m³", "size": 7, "anchor": "middle", "vAnchor": "middle", "bold": True},
{"kind": "text", "layer": "TABLE", "x": 60, "y": 60, "text": "J-101", "size": 2, "rotate": 90, "anchor": "middle", "vAnchor": "middle"},
],
}
def test_r2018_with_unicode_layers_and_styles():
data = drawing_to_dxf(DRAWING)
doc = ezdxf.read(io.StringIO(data.decode("utf-8")))
assert doc.dxfversion == "AC1032" and doc.units == ezdxf.units.MM
msp = doc.modelspace()
texts = [e.dxf.text for e in msp.query("TEXT")]
assert "송수관로 수리 종단면도 ○ ‰ m³" in texts
hgl = msp.query('LWPOLYLINE[layer=="HGL_1"]')[0]
assert hgl.dxf.linetype == "DASHED" and hgl.dxf.lineweight == 40 and hgl.rgb == (29, 78, 216)
assert len(msp.query("HATCH")) == 1 and len(msp.query("CIRCLE")) == 1 # white mask box dropped
assert {"FRAME", "HGL_1", "NODE", "TITLE", "TABLE"} <= {l.dxf.name for l in doc.layers}
rotated = [e for e in msp.query("TEXT") if e.dxf.text == "J-101"][0]
assert rotated.dxf.rotation == 90
# GDAL (what GIS tools use) reads the Korean text back too.
with tempfile.TemporaryDirectory() as tmp:
p = os.path.join(tmp, "d.dxf")
open(p, "wb").write(data)
meta, _, _, fields = pyogrio.raw.read(p)
gtexts = [t for t in fields[list(meta["fields"]).index("Text")] if t]
assert any("송수관로" in t for t in gtexts)
def test_errors_and_endpoint():
with pytest.raises(BatchError):
drawing_to_dxf({"items": []})
with TestClient(app) as c:
r = c.post("/api/drawing/dxf", json={"drawing": DRAWING, "name": "종단면도"})
assert r.status_code == 200 and b"AC1032" in r.content
assert "송수관로".encode("utf-8") in r.content
+78
View File
@@ -0,0 +1,78 @@
"""Cross-check SPipe's server analyses against USEPA WNTR (an independent EPANET
front end). Skipped when wntr is not installed (it is a dev-only dependency).
WNTR works in SI (m, m³/s); Net1/Net3 are US units (psi, GPM), so values are
converted before comparing. WNTR bundles its own EPANET build, so small
numerical differences are allowed.
"""
import os
import tempfile
from pathlib import Path
import numpy as np
import pytest
from backend.batch import Network
from backend.epanet import EN
from backend.jobs import job_criticality, job_fireflow
wntr = pytest.importorskip("wntr")
PUBLIC = Path(__file__).resolve().parents[2] / "public"
PSI_TO_M = 0.3048 / 0.4333 # EPANET's own psi <-> ft of water factor
GPM_TO_M3S = 6.30902e-5
def wntr_pressure(wn) -> "object":
with tempfile.TemporaryDirectory() as tmp:
res = wntr.sim.EpanetSimulator(wn).run_sim(file_prefix=os.path.join(tmp, "w"))
return res.node["pressure"]
def spipe_pressure(inp: bytes) -> dict:
with Network(inp) as n:
js = n.junction_indices()
r = n.simulate(node_props=(EN.PRESSURE,), nodes=js)
return {n.node_ids[j - 1]: r.nodes[EN.PRESSURE][:, k] * PSI_TO_M for k, j in enumerate(js)}, r.times
@pytest.mark.parametrize("name", ["Net1.inp", "Net3.inp"])
def test_baseline_pressures_match(name):
path = PUBLIC / name
ours, times = spipe_pressure(path.read_bytes())
p = wntr_pressure(wntr.network.WaterNetworkModel(str(path)))
diffs = []
for nid, series in ours.items():
w = p[nid].reindex(times).to_numpy()
diffs.append(np.nanmax(np.abs(series - w)))
assert max(diffs) < 0.01, f"max pressure difference {max(diffs):.3f} m"
def test_pipe_criticality_matches_closing_pipes_in_wntr():
path = PUBLIC / "Net1.inp"
ours = {e["id"]: e for e in job_criticality(path.read_bytes(), {"threshold": 40})["links"]}
checked = 0
for pid, e in ours.items():
if e.get("disconnected") or e.get("minPressure") is None:
continue # isolated nodes: both engines report meaningless negative heads
wn = wntr.network.WaterNetworkModel(str(path))
wn.get_link(pid).initial_status = wntr.network.LinkStatus.Closed
p = wntr_pressure(wn)
junctions = [n for n, _ in wn.junctions()]
wmin = float(p[junctions].min().min())
assert abs(e["minPressure"] * PSI_TO_M - wmin) < 0.01, f"pipe {pid}: {e['minPressure'] * PSI_TO_M:.2f} vs {wmin:.2f} m"
checked += 1
assert checked >= 10
def test_fire_flow_pressure_drop_matches_extra_demand_in_wntr():
path = PUBLIC / "Net1.inp"
flow_gpm = 500.0
r = job_fireflow(path.read_bytes(), {"nodes": ["22"], "flow": flow_gpm, "start": 0, "end": 24})
ours = r["hydrants"][0]["firePressure"] * PSI_TO_M
wn = wntr.network.WaterNetworkModel(str(path))
# A demand without a pattern would get Net1's default pattern (up to 1.6x); fire flow is constant.
wn.add_pattern("fire_const", [1.0])
wn.get_node("22").add_demand(flow_gpm * GPM_TO_M3S, "fire_const", "fire")
p = wntr_pressure(wn)
assert abs(ours - float(p["22"].min())) < 0.01
+1 -1
View File
@@ -17,7 +17,7 @@ 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 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/drawing_dxf.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
@@ -0,0 +1,78 @@
================================================================
SPipe 패치 내역 - 도면 출력 추가 (2026-11-06)
이전 배포: 데모 v2 (2026-10-30)
================================================================
※ 도면 출력은 상단 [내보내기] > [도면 출력], 또는 결과 창
[시간별 종단] > [도면 출력]에서 엽니다.
※ 서버 반영 시 새 라이브러리 설치가 필요합니다 (맨 아래 [5] 참고).
[1] 도면 출력 - 수리 종단면도
----------------------------------------------------------------
+ 두 절점 사이 경로의 종단면도를 A3·A2·A1 도면 양식으로 출력
(표제란: 발주처·개정란·설계사·사업명·도면명·설계/제도/검토/승인·
축척·작성일·도면번호)
+ 하단 표: 측점, 절점번호, 거리, 추가거리, 지반고, 관저고,
동수두, 수압(최소 기준 미만은 붉은 글씨), 관경·연장
+ 지반선, 관저고(관정), 동수경사선(선택 시점·최대),
배수지 수위(H.W.L), 경로 위 실제 밸브·펌프 기호와 설정값
(예: 감압밸브 871 (PRV 28.0m))
+ 축척 자동 (V=1:2,000 H=1:40,000 등)
[2] 도면 출력 - 관망도
----------------------------------------------------------------
+ 출력 방식: "색인도 + 부분도 (자동 분할)" 또는 "한 장"
+ 색인도: A3 반듯한 격자로 도면을 나누고(이웃 도면 6% 겹침),
관이 지나는 칸만 남겨 장 수를 최소화. 칸마다 도면 번호 표시
+ 부분도: 축척 자동(절점 간격에 맞춤) 또는 직접 선택(1:500~1:10,000),
우측 하단 위치도, 테두리에 상하좌우·대각선 접속도면 표시
+ 도면 번호: 일련번호(01, 02 …) 또는 격자(A-1, B-2 …)
+ 수압표: 절점마다 원 안에 동수두/지반고, 옆에 수압(kg/cm² 또는 m)과
유출량. 기준을 벗어난 수압은 붉은 글씨. 자리가 없는 수압표는
생략하고 개수를 안내 (축척을 키우면 더 표시)
+ 관로 표기: 관경 / 관경+연장 / 관번호+관경+연장
+ 좌표 격자(X·Y), 방위표, 축척 막대, 배경 GIS 레이어(켜진 것)
[3] 도면 공통
----------------------------------------------------------------
+ 범례 양식 선택: 표형(서울시 배관망도식) / 간단형 / 표시 안 함
+ 범례 제목 띠 색 선택 (회색·하늘·연두·베이지·없음·직접 지정)
+ 구분 색상 선택: 기본 / CAD 기본색 / 색약 배려 / 흑백 인쇄용,
등급별 색 직접 지정
+ 글꼴: HY울릉도M(기본) 또는 맑은 고딕
(HY울릉도M이 없는 PC에서는 맑은 고딕·CAD 대체 글꼴로 표시)
+ 저장 형식
- PDF: 인쇄 창에서 PDF로 저장 (분할 출력은 전 장이 한 파일)
- DXF: AutoCAD 2018 형식, 도면층·선 굵기·색 구분, 한글 정상 표시
(분할 출력은 전 장을 모형 공간에 옆으로 나란히)
- SVG: 보고 있는 장만
[4] 그 밖의 개선
----------------------------------------------------------------
+ 서버 기본 DEM: 서버에 한 번 등록한 DEM을 사용자가 파일 없이 사용
(분석 > 지형(DEM) > "서버 DEM: 이름"), 유역·하천은 관망 범위로 잘라 계산
+ DEM 등고선을 지도에 표시
+ 속성 테이블: Shift+클릭 범위 선택, Ctrl+Shift 범위 추가,
필터된 행 전체 선택·해제
+ 꺾임점 핸들은 편집 모드에서만 표시
+ 오른쪽 속성창·모델 설정 칸이 창 너비에 맞게 줄어듦 (스크롤 밑으로
가려지던 문제)
+ 한글 관로 라벨 때문에 관로가 안 보이던 문제 수정
+ 해석 검증: 미국 환경보호청 WNTR로 기본 수압·관로 중요도·소화전
결과를 교차 확인하는 시험 추가 (1cm 이내 일치)
[5] 서버 반영 순서
----------------------------------------------------------------
1. 저장소 파일을 서버 PC의 SPipe 폴더에 덮어쓰기
(.venv, node_modules, data, .env.local은 유지)
2. 새 라이브러리 설치
.\.venv\Scripts\python.exe -m pip install -r backend\requirements.txt
npm install
(도면 DXF용 ezdxf, 지도 등고선용 maplibre-contour 추가)
3. 화면 다시 빌드하며 실행
powershell -ExecutionPolicy Bypass -File .\start-lan.ps1 -Rebuild
+7
View File
@@ -12,6 +12,7 @@
"@types/proj4": "^2.5.6",
"epanet-js": "^0.9.0",
"lucide-react": "^1.42.0",
"maplibre-contour": "0.1.1",
"maplibre-gl": "^4.7.1",
"proj4": "^2.22.0",
"react": "^18.3.1",
@@ -1830,6 +1831,12 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/maplibre-contour": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/maplibre-contour/-/maplibre-contour-0.1.1.tgz",
"integrity": "sha512-0m267124bx+LJ5zM1mpB23uvO4sCrgOb4dWgwbUbGWGsDsWNA4g93XTXPoRQga6GAD6TDI7tzyowBzdADSPusg==",
"license": "BSD-3-Clause"
},
"node_modules/maplibre-gl": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz",
+1
View File
@@ -18,6 +18,7 @@
"@types/proj4": "^2.5.6",
"epanet-js": "^0.9.0",
"lucide-react": "^1.42.0",
"maplibre-contour": "0.1.1",
"maplibre-gl": "^4.7.1",
"proj4": "^2.22.0",
"react": "^18.3.1",
+36 -2
View File
@@ -81,6 +81,7 @@ import { CurveEditorModal } from './CurveEditorModal';
import { ElementExtrasEditor } from './ElementExtrasEditor';
import { DemandTableModal } from './DemandTableModal';
import { BackupModal } from './BackupModal';
import { DrawingExportModal, type DrawingKind } from './DrawingExportModal';
import { saveBackup } from './backupStore';
import { loadEditOptions, recalcPipeLengths } from './networkTools';
import { FrequencyPanel, MultiSeriesPanel, type SeriesItem } from './ResultCharts';
@@ -247,8 +248,10 @@ export default function App() {
const [showCurveEditor, setShowCurveEditor] = useState(false);
const [showDemandTable, setShowDemandTable] = useState(false);
const [showBackups, setShowBackups] = useState(false);
const [drawingTarget, setDrawingTarget] = useState<{ kind: DrawingKind; from?: string; to?: string } | null>(null);
const [rasterOverlays, setRasterOverlays] = useState<import('./types').RasterOverlay[]>([]);
const [view3d, setView3d] = useState<import('./types').View3D | undefined>(undefined);
const [demContours, setDemContours] = useState<import('./types').DemContours | null>(null);
const [mapAnnotations, setMapAnnotations] = useState<import('./types').MapAnnotation[] | null>(null);
const [verificationSection, setVerificationSection] = useState<'comparison' | 'topology' | 'options' | 'patterns' | 'controls' | 'diff' | 'scenarios'>('comparison');
const [batchRunProgress, setBatchRunProgress] = useState<{ done: number; total: number } | null>(null);
@@ -929,6 +932,7 @@ export default function App() {
onExportShapefile={handleExportShapefile}
onShowInpText={() => setShowInpModal(true)}
onOpenAttributeTable={() => toggleBottomPanel('table')}
onOpenDrawing={networkModel ? () => setDrawingTarget({ kind: 'plan' }) : undefined}
/>
)}
@@ -1079,13 +1083,15 @@ export default function App() {
annotations={mapAnnotations ?? undefined}
rasterOverlays={rasterOverlays}
view3d={view3d}
demContours={demContours}
onLegendOverride={setLegendOverride}
onSelectTarget={(t) => {
setSelectedTarget(t);
if (t) setActiveTab('inspector');
}}
onNodeDragEnd={handleNodeDragEnd}
onVerticesChange={(linkId, verts, message) => {
// 꺾임점 핸들은 [편집] 모드에서만 (탐색 중 선택한 관로가 핸들로 뒤덮이지 않게).
onVerticesChange={appMode !== 'edit' ? undefined : (linkId, verts, message) => {
if (!networkModel) return;
const vertices = { ...(networkModel.vertices ?? {}), [linkId]: verts };
if (!verts.length) delete vertices[linkId];
@@ -1294,7 +1300,7 @@ export default function App() {
{/* TAB 1: Inspector */}
{activeTab === 'inspector' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div className="inspector-pane" style={{ display: 'flex', flexDirection: 'column', gap: '12px', minWidth: 0 }}>
{/* CAD Multi-Selection Batch Property Editor */}
{networkModel && (selectionCounts.nodes + selectionCounts.links > 1) && (
<BatchPropertyEditor
@@ -1547,6 +1553,7 @@ export default function App() {
setSelectedTarget({ type: 'node', id });
requestMapFocus({ target: { type: 'node', id } });
}}
onOpenDrawing={(from, to) => setDrawingTarget({ kind: 'profile', from, to })}
/>
)}
@@ -1661,6 +1668,8 @@ export default function App() {
rasterOverlays={rasterOverlays}
view3d={view3d}
onView3d={setView3d}
demContours={demContours}
onDemContours={setDemContours}
onSetUserLayers={setUserLayers}
calibrationData={calibrationData}
/>
@@ -2109,6 +2118,31 @@ export default function App() {
{showCurveEditor && networkModel && (
<CurveEditorModal model={networkModel} onApply={handleApplyModel} onClose={() => setShowCurveEditor(false)} />
)}
{drawingTarget && networkModel && (
<DrawingExportModal
model={networkModel}
result={wasmResult}
crs={crs}
units={units}
flowUnit={displayUnits.flow}
timeIndex={timeStepIndex}
timeLabel={(() => {
const s = wasmResult?.timeSteps?.[timeStepIndex] ?? 0;
return `${Math.floor(s / 3600)}:${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}`;
})()}
minPressure={minPressureThreshold}
maxVelocity={maxVelocityThreshold}
userLayers={userLayers}
selection={{
nodes: [...new Set([...multiSelection.nodes, ...(selectedTarget?.type === 'node' ? [selectedTarget.id] : [])])],
links: [...new Set([...multiSelection.links, ...(selectedTarget?.type === 'link' ? [selectedTarget.id] : [])])],
}}
initialKind={drawingTarget.kind}
initialFrom={drawingTarget.from}
initialTo={drawingTarget.to}
onClose={() => setDrawingTarget(null)}
/>
)}
{showBackups && (
<BackupModal
onBackupNow={async () => {
+36 -6
View File
@@ -1,11 +1,11 @@
// 분석 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 React, { useEffect, 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 type { DemContours, RasterOverlay, UserGisLayer, View3D } from './types';
import { demFromPoints, demHydro, demPreviewUrl, listBaseDems, sampleDem, uploadDem, type DemInfo } from './api';
import { applyGroundElevations, checkElevations, crsProj4, type ElevationMode, type ElevationOptions } from './demTools';
import { lookupApproxElevations } from './mapServices';
import { modelToLngLat } from './crs';
@@ -16,6 +16,8 @@ export interface DemSectionProps {
view3d?: View3D;
onView3d?: (v: View3D) => void;
onAddUserLayer?: (layer: UserGisLayer) => void;
demContours?: DemContours | null;
onDemContours?: (c: DemContours | null) => void;
}
const box: React.CSSProperties = { border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: 8, display: 'flex', flexDirection: 'column', gap: 6 };
@@ -25,7 +27,7 @@ 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 }) => {
export const DemSection: React.FC<AnalysisPanelProps & DemSectionProps> = ({ model, crs, userLayers, selectedNodeIds, cache, setCache, onApplyModel, onFocus, onSelectElements, onRasterOverlays, view3d, onView3d, onAddUserLayer, demContours, onDemContours }) => {
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,
@@ -78,6 +80,17 @@ export const DemSection: React.FC<AnalysisPanelProps & DemSectionProps> = ({ mod
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 [baseDems, setBaseDems] = useState<DemInfo[]>([]);
const [contourStep, setContourStep] = useState(demContours?.interval ?? 10);
useEffect(() => {
listBaseDems().then(setBaseDems).catch(() => setBaseDems([]));
}, []);
/** Network extent + margin (model units): hydrology on country-wide DEMs runs on this window. */
const networkBox = (margin = 2000): number[] => {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const n of Object.values(model.nodes)) { x0 = Math.min(x0, n.x); y0 = Math.min(y0, n.y); x1 = Math.max(x1, n.x); y1 = Math.max(y1, n.y); }
return [x0 - margin, y0 - margin, x1 + margin, y1 + margin];
};
const setDem = (info: DemInfo) => {
setCache('demInfo', info);
setCache('demCheck', undefined);
@@ -104,6 +117,15 @@ export const DemSection: React.FC<AnalysisPanelProps & DemSectionProps> = ({ mod
<div style={box}>
<b>DEM</b>
{baseDems.length > 0 && (
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{baseDems.map((b) => (
<button key={b.id} className={dem?.id === b.id ? 'btn btn-primary' : 'btn btn-secondary'} style={btn} title="서버에 등록된 DEM (업로드 없이 바로 사용)" onClick={() => setDem(b)}>
서버 DEM: {b.name}
</button>
))}
</div>
)}
<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) => {
@@ -119,6 +141,14 @@ export const DemSection: React.FC<AnalysisPanelProps & DemSectionProps> = ({ mod
<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>
{onDemContours && (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<label><input type="checkbox" checked={demContours?.demId === dem.id} onChange={(e) => onDemContours(e.target.checked ? { demId: dem.id, interval: contourStep, maxzoom: Math.max(8, Math.min(16, Math.round(Math.log2(156543 / Math.max(dem.resolution[0], 1))))) } : null)} /> 지도에 등고선</label>
<select value={contourStep} aria-label="등고선 간격" onChange={(e) => { const v = Number(e.target.value); setContourStep(v); if (demContours?.demId === dem.id) onDemContours({ ...demContours, interval: v }); }} style={{ ...inputStyle, width: 90 }}>
{[1, 2, 5, 10, 20, 50].map((v) => <option key={v} value={v}>{v} m 간격</option>)}
</select>
</div>
)}
</>
) : (
<span style={muted}>국토지리정보원 수치표고모델 등 DEM 파일을 넣으세요. 좌표계 정보(.prj 또는 GeoTIFF)가 있어야 합니다. 서버에 보관되어 다른 화면을 오가도 유지됩니다.</span>
@@ -217,13 +247,13 @@ export const DemSection: React.FC<AnalysisPanelProps & DemSectionProps> = ({ mod
<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 });
const fc = await demHydro(dem.id, { op: 'streams', minAreaKm2: Number(minArea) || 0.1, bbox: networkBox(), crs: proj });
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 });
const fc = await demHydro(dem.id, { op: 'watershed', points: ids.map((id) => [model.nodes[id].x, model.nodes[id].y]), crs: proj, snap: 50, bbox: networkBox(5000) });
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');
+395
View File
@@ -0,0 +1,395 @@
// 도면 출력: 수리 종단면도 / 관망도 sheets with live preview, saved as PDF
// (browser print), DXF (Korean code page via the API) or SVG.
import React, { useEffect, useMemo, useState } from 'react';
import { X, Printer, Download } from 'lucide-react';
import type { NetworkModel } from './inpModel';
import type { SimulationResult, UserGisLayer } from './types';
import { lngLatToModel, type CRS } from './crs';
import { findProfilePath } from './profilePath';
import { DEFAULT_FONT, DRAWING_FONTS, mergeDrawings, SHEETS, toDxf, toSvg, type Drawing, type TitleInfo } from './drawing';
import { buildProfileDrawing } from './profileDrawing';
import { buildNetworkDrawing, planLayout, tileNeighbours } from './networkDrawing';
import { PALETTE_KEYS, PLAN_PALETTES, planInput, profileInput, SERIES_LABEL, type Conv, type PlanColorMode, type PlanPalette, type SeriesKey } from './drawingData';
import type { LegendStyle } from './drawing';
const LEGEND_BANDS = [
{ color: '#c8c8c8', label: '회색 (기본)' },
{ color: '#d6e4f0', label: '하늘색' },
{ color: '#dcebd5', label: '연두색' },
{ color: '#efe6d2', label: '베이지' },
{ color: '#ffffff', label: '없음 (흰색)' },
];
export type DrawingKind = 'profile' | 'plan';
interface Props {
model: NetworkModel;
result: SimulationResult | null;
crs: CRS;
units: Conv;
flowUnit: string;
timeIndex: number;
timeLabel: string;
minPressure: number;
maxVelocity: number;
userLayers: UserGisLayer[];
selection: { nodes: string[]; links: string[] };
initialKind?: DrawingKind;
initialFrom?: string;
initialTo?: string;
onClose: () => void;
}
const INFO_KEY = 'spipe_title_info';
const inp: React.CSSProperties = { height: 24, fontSize: 12, width: '100%' };
const lab: React.CSSProperties = { display: 'grid', gap: 2, fontSize: 11, color: 'var(--text-secondary)' };
function loadInfo(): Omit<TitleInfo, 'scale' | 'title'> {
try {
return { project: '', number: '', client: '', company: '', date: new Date().toISOString().slice(0, 7).replace('-', '.'), ...JSON.parse(localStorage.getItem(INFO_KEY) || '{}') };
} catch {
return { project: '', number: '', client: '', company: '', date: '' };
}
}
function download(blob: Blob, name: string) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
}
export const DrawingExportModal: React.FC<Props> = (p) => {
const { model, result } = p;
const [kind, setKind] = useState<DrawingKind>(p.initialKind ?? 'profile');
const [sheet, setSheet] = useState<keyof typeof SHEETS>('A3');
const [info, setInfo] = useState(loadInfo);
const [title, setTitle] = useState('');
// profile
const [from, setFrom] = useState(p.initialFrom ?? p.selection.nodes[0] ?? '');
const [to, setTo] = useState(p.initialTo ?? p.selection.nodes[1] ?? '');
const [elevationIs, setElevationIs] = useState<'invert' | 'ground'>('invert');
const [depth, setDepth] = useState('1.2');
const [series, setSeries] = useState<SeriesKey[]>(result ? ['now', 'max'] : []);
// plan
const [mode, setMode] = useState<PlanColorMode>(result ? 'hydraulic' : 'diameter');
const [pipeLabels, setPipeLabels] = useState<'none' | 'D' | 'DL' | 'IDDL'>('DL');
const [nodeLabels, setNodeLabels] = useState<'none' | 'sources' | 'all'>('sources');
const [maxPressure, setMaxPressure] = useState('70');
const [onlySelection, setOnlySelection] = useState(false);
const [withBackground, setWithBackground] = useState(true);
const [layout, setLayout] = useState<'single' | 'split'>('split');
const [planScale, setPlanScale] = useState<string>('auto');
const [sheetNaming, setSheetNaming] = useState<'serial' | 'grid'>('serial');
const [pressureTags, setPressureTags] = useState<'none' | 'kgf' | 'm'>(() => {
try { const v = localStorage.getItem('spipe_drawing_tags'); return v === 'm' || v === 'none' ? v : 'kgf'; } catch { return 'kgf'; }
});
const [font, setFont] = useState<string>(() => { try { return localStorage.getItem('spipe_drawing_font') || DEFAULT_FONT; } catch { return DEFAULT_FONT; } });
// Legend look and class colours (remembered between sessions).
const [legend, setLegend] = useState<{ style: LegendStyle; band: string; preset: string; colors: PlanPalette }>(() => {
const def = { style: 'table' as LegendStyle, band: '#c8c8c8', preset: 'standard', colors: PLAN_PALETTES.standard.colors };
try { const v = JSON.parse(localStorage.getItem('spipe_drawing_legend') || 'null'); return v && v.colors ? { ...def, ...v, colors: { ...def.colors, ...v.colors } } : def; } catch { return def; }
});
const [showColors, setShowColors] = useState(false);
const [page, setPage] = useState(0);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
useEffect(() => {
try { localStorage.setItem(INFO_KEY, JSON.stringify(info)); } catch { /* private mode */ }
}, [info]);
useEffect(() => {
try { localStorage.setItem('spipe_drawing_font', font); } catch { /* private mode */ }
}, [font]);
useEffect(() => {
try { localStorage.setItem('spipe_drawing_legend', JSON.stringify(legend)); } catch { /* private mode */ }
}, [legend]);
useEffect(() => {
try { localStorage.setItem('spipe_drawing_tags', pressureTags); } catch { /* private mode */ }
}, [pressureTags]);
const effectiveTitle = title || (kind === 'profile' ? '관로 수리 종단면도' : '관망도');
const nodeIds = useMemo(() => Object.keys(model.nodes), [model]);
const built = useMemo((): { drawings: Drawing[]; pages: string[]; note: string } | { error: string } => {
try {
const fullInfo = { ...info, title: effectiveTitle };
if (kind === 'profile') {
if (!model.nodes[from] || !model.nodes[to]) return { error: '시작·끝 절점 ID를 입력하세요 (지도에서 절점 2개를 선택한 뒤 열면 자동으로 채워집니다).' };
const path = findProfilePath(model, from, to);
if (!path) return { error: '두 절점을 잇는 경로가 없습니다.' };
const d = profileInput(model, result, path, { elevationIs, depth: Number(depth) || 0, series, timeIndex: p.timeIndex }, p.units, p.flowUnit);
const r = buildProfileDrawing({ sheet, info: fullInfo, ...d, minPressure: result ? p.minPressure : undefined, font, legendStyle: legend.style, legendBand: legend.band });
return { drawings: [r.drawing], pages: [effectiveTitle], note: `경로 절점 ${path.nodeIds.length}개 · 연장 ${d.stations[d.stations.length - 1].x.toFixed(1)} m · V=1:${r.vScale.toLocaleString()} H=1:${r.hScale.toLocaleString()}${result ? ` · ${series.map((s) => SERIES_LABEL[s]).join('/')}${series.includes('now') ? ` (${p.timeLabel})` : ''}` : ' · 해석 결과 없음(동수두 생략)'}` };
}
const ids = onlySelection && (p.selection.nodes.length || p.selection.links.length)
? (() => {
const links = new Set(p.selection.links);
const nodes = new Set(p.selection.nodes);
for (const id of links) { const l = model.pipes[id] ?? model.pumps[id] ?? model.valves?.[id]; if (l) { nodes.add(l.node1); nodes.add(l.node2); } }
return { nodes, links };
})()
: undefined;
const d = planInput(model, result, { mode, timeIndex: p.timeIndex, minPressure: p.minPressure, maxPressure: Number(maxPressure) || 70, maxVelocity: p.maxVelocity, ids, palette: legend.colors }, p.units);
const metric = p.crs !== 'LOCAL';
const background: Array<Array<[number, number]>> = [];
if (withBackground && metric) {
const ext = { minX: 0, minY: 0, maxX: 1, maxY: 1 };
let budget = 200_000;
for (const layer of p.userLayers.filter((l) => l.visible)) {
for (const f of layer.featureCollection.features) {
const g = f.geometry;
const lines = !g ? [] : g.type === 'LineString' ? [g.coordinates] : g.type === 'MultiLineString' || g.type === 'Polygon' ? g.coordinates : g.type === 'MultiPolygon' ? g.coordinates.flat() : [];
for (const line of lines) {
if ((budget -= line.length) < 0) break;
background.push(line.map(([lon, lat]) => lngLatToModel(lon, lat, p.crs, ext) as [number, number]));
}
}
}
}
const base = { sheet, info: fullInfo, metric, ...d, background, font, legendStyle: legend.style, legendBand: legend.band };
if (layout === 'single' || !metric) {
const r = buildNetworkDrawing({ ...base, pipeLabels, nodeLabels, pressureTags });
return { drawings: [r.drawing], pages: [effectiveTitle], note: `관로 ${d.links.length}개 · 절점 ${d.nodes.length}개 · ${metric ? `S=1:${r.scale.toLocaleString()}` : '로컬 좌표: 축척 없음 (분할 출력은 좌표계가 있어야 합니다)'}${background.length ? ` · 배경 선 ${background.length}개` : ''}${pressureTags !== 'none' && r.tagsSkipped ? ` · 수압표 ${r.tagsSkipped}개는 자리가 없어 생략` : ''}` };
}
// 색인도 + 부분도: automatic sheet layout at one detail scale.
const lay = planLayout(base, { scale: planScale === 'auto' ? 'auto' : Number(planScale), maxSheets: 40, naming: sheetNaming });
const N = lay.tiles.length;
const pad = (n: number) => String(n).padStart(2, '0');
const nameOf = (n: number) => (n === 0 ? pad(0) : sheetNaming === 'grid' ? lay.tiles[n - 1].name : pad(n));
const numberOf = (n: number) => (info.number ? `${info.number}-${nameOf(n)}` : nameOf(n));
const index = buildNetworkDrawing({ ...base, info: { ...fullInfo, title: `${effectiveTitle} 색인도`, number: numberOf(0) }, pipeLabels: 'D', nodeLabels: 'sources', pressureTags: 'none', indexTiles: lay.tiles });
const outline = d.links.map((l) => l.pts);
let skipped = 0;
const details = lay.tiles.map((t) => {
const r = buildNetworkDrawing({
...base, info: { ...fullInfo, number: numberOf(t.no) }, view: t, scale: lay.scale, sheetLabel: sheetNaming === 'grid' ? `(${t.name})` : `(${t.no}/${N})`,
pipeLabels, nodeLabels, pressureTags, keyMap: { lines: outline, tiles: lay.tiles, current: t.no }, neighbours: tileNeighbours(lay.tiles, t),
});
skipped += r.tagsSkipped;
return r.drawing;
});
return {
drawings: [index.drawing, ...details],
pages: ['색인도', ...lay.tiles.map((t) => (sheetNaming === 'grid' ? `${t.name} (${t.no}/${N})` : `${t.no}/${N}`))],
note: `색인도 1장 + 부분도 ${N}장 · 부분도 S=1:${lay.scale.toLocaleString()} · 색인도 S=1:${index.scale.toLocaleString()} · 관로 ${d.links.length}개 · 절점 ${d.nodes.length}개${pressureTags !== 'none' && skipped ? ` · 수압표 ${skipped}개는 자리가 없어 생략 (축척을 키우면 표시)` : ''}`,
};
} catch (e) {
return { error: e instanceof Error ? e.message : String(e) };
}
}, [kind, sheet, info, effectiveTitle, from, to, elevationIs, depth, series, mode, pipeLabels, nodeLabels, maxPressure, onlySelection, withBackground, model, result, p.timeIndex, p.units, p.flowUnit, p.crs, p.userLayers, p.selection, p.minPressure, p.maxVelocity, p.timeLabel, layout, planScale, sheetNaming, pressureTags, font, legend]);
const pageCount = 'error' in built ? 0 : built.drawings.length;
const cur = Math.min(page, Math.max(0, pageCount - 1));
const current = 'error' in built ? null : built.drawings[cur];
const fileBase = `${effectiveTitle}${info.number ? `_${info.number}` : ''}`.replace(/[\\/:*?"<>|]/g, '_');
const printPdf = () => {
if ('error' in built) return;
const s = SHEETS[sheet];
const w = window.open('', '_blank');
if (!w) { setMsg('팝업이 막혀 인쇄 창을 열 수 없습니다. 이 사이트의 팝업을 허용하세요.'); return; }
w.document.write(`<!doctype html><html><head><meta charset="utf-8"><title>${fileBase}</title><style>@page{size:${s.w}mm ${s.h}mm;margin:0}html,body{margin:0;padding:0}svg{display:block;break-after:page}svg:last-child{break-after:auto}</style></head><body>${built.drawings.map((dr) => toSvg(dr, { forPrint: true })).join('')}</body></html>`);
w.document.close();
w.onload = () => { w.focus(); w.print(); };
setTimeout(() => { try { w.focus(); w.print(); } catch { /* closed */ } }, 600);
setMsg(`인쇄 창에서 대상 "PDF로 저장"을 고르세요. ${built.drawings.length > 1 ? `${built.drawings.length}쪽이 한 PDF로 저장되고, ` : ''}용지는 자동으로 맞춰집니다. HY울릉도M은 이 PC에 설치되어 있어야 하며, 없으면 맑은 고딕으로 나옵니다.`);
};
const saveDxf = async () => {
if ('error' in built) return;
setBusy(true);
// Several sheets go side by side in one model space (one DXF).
const one = mergeDrawings(built.drawings);
const dxf = toDxf(one);
try {
const res = await fetch('/api/drawing/dxf', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ drawing: one, name: fileBase }) });
if (!res.ok) throw new Error(String(res.status));
download(await res.blob(), `${fileBase}.dxf`);
setMsg(`DXF(AutoCAD 2018 형식)를 저장했습니다.${built.drawings.length > 1 ? ` ${built.drawings.length}장이 모형 공간에 옆으로 나란히 들어 있습니다.` : ''} 단위 mm, 도면층·선 굵기·색 구분, 글꼴 ${DRAWING_FONTS[font]?.label ?? '맑은 고딕'} (없는 PC에서는 CAD가 대체 글꼴로 표시).`);
} catch {
download(new Blob([dxf], { type: 'application/dxf' }), `${fileBase}.dxf`);
setMsg('서버에 연결할 수 없어 간이 DXF(R12, 한글은 유니코드 표기)로 저장했습니다. AutoCAD에서는 한글이 정상 표시됩니다.');
} finally {
setBusy(false);
}
};
return (
<div role="dialog" aria-modal="true" aria-label="도면 출력" style={{ position: 'fixed', inset: 0, background: 'rgba(15,23,42,.4)', zIndex: 3000, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={p.onClose}>
<div onClick={(e) => e.stopPropagation()} style={{ width: 'min(1280px, 97vw)', height: '92vh', background: 'var(--bg-panel)', borderRadius: 8, boxShadow: '0 12px 40px rgba(0,0,0,.3)', display: 'flex', flexDirection: 'column', position: 'relative' }}>
<button className="btn btn-ghost" onClick={p.onClose} aria-label="닫기" style={{ position: 'absolute', top: 6, right: 8, padding: 4 }}><X size={18} /></button>
<div style={{ padding: '10px 44px 10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', gap: 8, alignItems: 'center' }}>
<b style={{ marginRight: 8 }}>도면 출력</b>
{(['profile', 'plan'] as DrawingKind[]).map((k) => (
<button key={k} className={kind === k ? 'btn btn-primary' : 'btn btn-secondary'} style={{ height: 26, fontSize: 12 }} onClick={() => setKind(k)}>{k === 'profile' ? '수리 종단면도' : '관망도'}</button>
))}
</div>
<div style={{ flex: 1, display: 'grid', gridTemplateColumns: '300px minmax(0, 1fr)', minHeight: 0 }}>
<div style={{ padding: 12, borderRight: '1px solid var(--border)', overflow: 'auto', display: 'flex', flexDirection: 'column', gap: 10, fontSize: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
<label style={lab}>용지<select value={sheet} onChange={(e) => setSheet(e.target.value as keyof typeof SHEETS)} style={inp}>{Object.entries(SHEETS).map(([k, s]) => <option key={k} value={k}>{s.label}</option>)}</select></label>
<label style={lab}>글꼴<select value={font} onChange={(e) => setFont(e.target.value)} style={inp}>{Object.entries(DRAWING_FONTS).map(([k, f]) => <option key={k} value={k}>{f.label}</option>)}</select></label>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
<label style={lab}>범례 양식
<select value={legend.style} onChange={(e) => setLegend({ ...legend, style: e.target.value as LegendStyle })} style={inp}>
<option value="table">표형 (서울시 배관망도식)</option>
<option value="list">간단형 (목록)</option>
<option value="none">표시 안 함</option>
</select>
</label>
<label style={lab}>범례 제목 띠
<span style={{ display: 'flex', gap: 4 }}>
<select value={LEGEND_BANDS.some((bd) => bd.color === legend.band) ? legend.band : 'custom'} onChange={(e) => e.target.value !== 'custom' && setLegend({ ...legend, band: e.target.value })} style={{ ...inp, flex: 1 }} disabled={legend.style === 'none'}>
{LEGEND_BANDS.map((bd) => <option key={bd.color} value={bd.color}>{bd.label}</option>)}
<option value="custom">직접 지정</option>
</select>
<input type="color" aria-label="범례 제목 띠 색" value={legend.band} onChange={(e) => setLegend({ ...legend, band: e.target.value })} disabled={legend.style === 'none'} style={{ width: 30, height: 26, padding: 0, border: '1px solid #cbd5e1', borderRadius: 4 }} />
</span>
</label>
</div>
{kind === 'plan' && (
<div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 6, alignItems: 'end' }}>
<label style={lab}>색상 구성 (유속·수압·관경 구분색)
<select value={legend.preset} onChange={(e) => { const pr = PLAN_PALETTES[e.target.value]; setLegend({ ...legend, preset: e.target.value, colors: pr ? pr.colors : legend.colors }); }} style={inp}>
{Object.entries(PLAN_PALETTES).map(([key, pr]) => <option key={key} value={key}>{pr.label}</option>)}
{legend.preset === 'custom' && <option value="custom">직접 지정</option>}
</select>
</label>
<button type="button" onClick={() => setShowColors((v) => !v)} style={{ ...inp, cursor: 'pointer', width: 'auto', padding: '4px 8px' }}>{showColors ? '색 숨기기' : '색 직접 지정'}</button>
</div>
<div style={{ display: 'flex', gap: 2, margin: '4px 0' }} aria-label="현재 색상 구성">
{PALETTE_KEYS.filter((pk) => (mode === 'diameter' || !result ? pk.group === 'diameter' : pk.group !== 'diameter')).map((pk) => <span key={pk.key} title={pk.label} style={{ flex: 1, height: 8, borderRadius: 2, background: legend.colors[pk.key] }} />)}
</div>
{showColors && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 10px', padding: 6, border: '1px solid #e2e8f0', borderRadius: 6 }}>
{PALETTE_KEYS.filter((pk) => (mode === 'diameter' || !result ? pk.group === 'diameter' : pk.group !== 'diameter')).map((pk) => (
<label key={pk.key} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
<input type="color" value={legend.colors[pk.key]} onChange={(e) => setLegend({ ...legend, preset: 'custom', colors: { ...legend.colors, [pk.key]: e.target.value } })} style={{ width: 28, height: 22, padding: 0, border: '1px solid #cbd5e1', borderRadius: 4 }} />
{pk.label}
</label>
))}
</div>
)}
</div>
)}
{kind === 'profile' ? (
<>
<datalist id="drawing-node-ids">{nodeIds.slice(0, 5000).map((id) => <option key={id} value={id} />)}</datalist>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
<label style={lab}>시작 절점<input list="drawing-node-ids" value={from} onChange={(e) => setFrom(e.target.value.trim())} style={inp} /></label>
<label style={lab}>끝 절점<input list="drawing-node-ids" value={to} onChange={(e) => setTo(e.target.value.trim())} style={inp} /></label>
</div>
<label style={lab}>절점 표고의 의미
<select value={elevationIs} onChange={(e) => setElevationIs(e.target.value as 'invert' | 'ground')} style={inp}>
<option value="invert">관저고 (지반고 = 표고 + 매설깊이)</option>
<option value="ground">지반고 (관저고 = 표고 − 매설깊이)</option>
</select>
</label>
<label style={lab}>매설깊이 (m, 자산 속성 없을 때)<input type="number" step="0.1" value={depth} onChange={(e) => setDepth(e.target.value)} style={inp} /></label>
<div style={lab}>
동수경사선
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', color: 'var(--text)' }}>
{(Object.keys(SERIES_LABEL) as SeriesKey[]).map((k) => (
<label key={k} style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
<input type="checkbox" disabled={!result} checked={series.includes(k)} onChange={(e) => setSeries(e.target.checked ? [...series, k] : series.filter((s) => s !== k))} />
{SERIES_LABEL[k]}{k === 'now' ? ` (${p.timeLabel})` : ''}
</label>
))}
</div>
{!result && <span>수리해석을 먼저 실행하면 동수두가 그려집니다.</span>}
</div>
</>
) : (
<>
<label style={lab}>색 구분
<select value={mode} onChange={(e) => setMode(e.target.value as PlanColorMode)} style={inp}>
<option value="diameter">관경별</option>
<option value="hydraulic" disabled={!result}>수리 판정 (유속·수압, {p.timeLabel})</option>
</select>
</label>
<label style={lab}>출력 방식
<select value={layout} onChange={(e) => { setLayout(e.target.value as 'single' | 'split'); setPage(0); }} style={inp}>
<option value="split">색인도 + 부분도 (자동 분할)</option>
<option value="single">전체 한 장</option>
</select>
</label>
{layout === 'split' && (
<label style={lab}>도면 번호
<select value={sheetNaming} onChange={(e) => { setSheetNaming(e.target.value as 'serial' | 'grid'); setPage(0); }} style={inp}>
<option value="serial">일련번호 (01, 02, …)</option>
<option value="grid">격자 (A-1, A-2, B-1 …)</option>
</select>
</label>
)}
{layout === 'split' && (
<label style={lab}>부분도 축척
<select value={planScale} onChange={(e) => { setPlanScale(e.target.value); setPage(0); }} style={inp}>
<option value="auto">자동 (절점 간격에 맞춤)</option>
{[500, 1000, 1200, 1500, 2000, 2500, 3000, 5000, 6000, 10000].map((sc) => <option key={sc} value={sc}>1:{sc.toLocaleString()}</option>)}
</select>
</label>
)}
<label style={lab}>수압표 (절점별 동수두·지반고·수압·유출량)
<select value={pressureTags} onChange={(e) => setPressureTags(e.target.value as 'none' | 'kgf' | 'm')} style={inp}>
<option value="kgf">표시 (수압 kg/cm²)</option>
<option value="m">표시 (수압 m)</option>
<option value="none">표시 안 함</option>
</select>
{pressureTags !== 'none' && !result && <span style={{ color: '#b45309', fontSize: 11 }}>해석 결과가 없어 수압표를 그릴 수 없습니다. 상단 「해석 실행」을 누르면 바로 반영됩니다.</span>}
{pressureTags !== 'none' && result && layout === 'split' && page === 0 && <span style={{ color: '#64748b', fontSize: 11 }}>수압표는 색인도가 아닌 부분도(2쪽부터)에 표시됩니다.</span>}
</label>
{mode === 'hydraulic' && <label style={lab}>과압 기준 (m)<input type="number" value={maxPressure} onChange={(e) => setMaxPressure(e.target.value)} style={inp} /></label>}
<label style={lab}>관로 표기<select value={pipeLabels} onChange={(e) => setPipeLabels(e.target.value as 'none' | 'D' | 'DL' | 'IDDL')} style={inp}><option value="DL">관경 + 연장 (D300 L=125m)</option><option value="IDDL">관번호 + 관경 + 연장 (P1-D300 L=125m)</option><option value="D">관경만</option><option value="none">없음</option></select></label>
<label style={lab}>절점 표기<select value={nodeLabels} onChange={(e) => setNodeLabels(e.target.value as 'none' | 'sources' | 'all')} style={inp}><option value="sources">배수지·탱크</option><option value="all">전체 (겹치면 생략)</option><option value="none">없음</option></select></label>
<label style={{ display: 'flex', gap: 4 }}><input type="checkbox" checked={onlySelection} disabled={!p.selection.nodes.length && !p.selection.links.length} onChange={(e) => setOnlySelection(e.target.checked)} /> 선택한 요소만 ({p.selection.nodes.length + p.selection.links.length})</label>
<label style={{ display: 'flex', gap: 4 }}><input type="checkbox" checked={withBackground} disabled={p.crs === 'LOCAL'} onChange={(e) => setWithBackground(e.target.checked)} /> 켜진 GIS 레이어를 배경으로</label>
</>
)}
<hr style={{ border: 0, borderTop: '1px solid var(--border)', margin: '2px 0' }} />
<b style={{ fontSize: 12 }}>표제란</b>
<label style={lab}>도면명<input value={title} placeholder={effectiveTitle} onChange={(e) => setTitle(e.target.value)} style={inp} /></label>
<label style={lab}>사업명<input value={info.project} onChange={(e) => setInfo({ ...info, project: e.target.value })} style={inp} /></label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
<label style={lab}>발주처<input value={info.client} onChange={(e) => setInfo({ ...info, client: e.target.value })} style={inp} /></label>
<label style={lab}>설계사<input value={info.company} onChange={(e) => setInfo({ ...info, company: e.target.value })} style={inp} /></label>
<label style={lab}>도면번호<input value={info.number} onChange={(e) => setInfo({ ...info, number: e.target.value })} style={inp} /></label>
<label style={lab}>작성일<input value={info.date} onChange={(e) => setInfo({ ...info, date: e.target.value })} style={inp} /></label>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6 }}>
{([['design', '설계'], ['draft', '제도'], ['check', '검토'], ['approve', '승인']] as const).map(([key, label]) => (
<label key={key} style={lab}>{label}<input value={info.staff?.[key] ?? ''} onChange={(e) => setInfo({ ...info, staff: { ...info.staff, [key]: e.target.value } })} style={inp} /></label>
))}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 4 }}>
<button className="btn btn-primary" style={{ height: 28 }} disabled={!current} onClick={printPdf}><Printer size={13} />PDF (인쇄)</button>
<button className="btn btn-secondary" style={{ height: 28 }} disabled={!current || busy} onClick={() => void saveDxf()}><Download size={13} />DXF</button>
<button className="btn btn-secondary" style={{ height: 28 }} disabled={!current} title="지금 보고 있는 장을 SVG로 저장" onClick={() => current && download(new Blob([toSvg(current, { forPrint: true })], { type: 'image/svg+xml' }), `${fileBase}${pageCount > 1 ? `_${cur === 0 ? '색인도' : String(cur).padStart(2, '0')}` : ''}.svg`)}>SVG</button>
</div>
{msg && <div style={{ color: 'var(--text-secondary)', lineHeight: 1.5 }}>{msg}</div>}
</div>
<div style={{ padding: 12, overflow: 'auto', background: '#e2e8f0', display: 'flex', flexDirection: 'column', gap: 6 }}>
{'error' in built ? (
<div style={{ color: 'var(--crit)', padding: 20 }}>{built.error}</div>
) : (
<>
<div style={{ fontSize: 12, color: '#334155' }}>{built.note}</div>
{pageCount > 1 && (
<div style={{ display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap', fontSize: 12 }}>
<button className="btn btn-secondary" style={{ height: 24, padding: '0 8px' }} disabled={cur === 0} onClick={() => setPage(cur - 1)}>◀</button>
<select aria-label="도면 장" value={cur} onChange={(e) => setPage(Number(e.target.value))} style={{ height: 24, fontSize: 12 }}>
{built.pages.map((name, i) => <option key={i} value={i}>{i === 0 ? '색인도' : `부분도 ${name}`}</option>)}
</select>
<button className="btn btn-secondary" style={{ height: 24, padding: '0 8px' }} disabled={cur >= pageCount - 1} onClick={() => setPage(cur + 1)}>▶</button>
<span style={{ color: '#475569' }}>PDF·DXF는 전체 {pageCount}장, SVG는 보고 있는 장만 저장</span>
</div>
)}
<div aria-label="도면 미리보기" style={{ background: '#fff', boxShadow: '0 2px 10px rgba(0,0,0,.15)' }} dangerouslySetInnerHTML={{ __html: current ? toSvg(current) : '' }} />
</>
)}
</div>
</div>
</div>
</div>
);
};
+9 -1
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { Camera, ChevronDown, Download, FileText, Layers, Table } from 'lucide-react';
import { Camera, ChevronDown, Download, FileText, Layers, Table, PenTool } from 'lucide-react';
interface ExportMenuProps {
open: boolean;
@@ -16,6 +16,7 @@ interface ExportMenuProps {
onExportShapefile: () => void;
onShowInpText: () => void;
onOpenAttributeTable: () => void;
onOpenDrawing?: () => void;
}
/** Header "내보내기" dropdown. Result-based exports are disabled until a run exists. */
@@ -34,6 +35,7 @@ export const ExportMenu: React.FC<ExportMenuProps> = ({
onExportShapefile,
onShowInpText,
onOpenAttributeTable,
onOpenDrawing,
}) => {
const act = (fn: () => void) => () => {
fn();
@@ -100,6 +102,12 @@ export const ExportMenu: React.FC<ExportMenuProps> = ({
<Table size={14} />
<span>속성 테이블 열기</span>
</button>
{onOpenDrawing && (
<button className="dropdown-item" onClick={act(onOpenDrawing)} title="수리 종단면도·관망도를 도곽·표제란과 함께 PDF/DXF로">
<PenTool size={14} />
<span>도면 출력 (종단면도·관망도)</span>
</button>
)}
</div>
)}
</div>
+73 -14
View File
@@ -442,14 +442,51 @@ export const FullAttributeTableModal: React.FC<FullAttributeTableModalProps> = (
setSelectedRowIds(ids);
}, [category, multiSelection, selectedTarget]);
// Shift+click selects the range from the last plain/Ctrl click (the anchor)
// in the table's current filter/sort order.
const anchorRowIdRef = useRef<string | null>(null);
const rowIdOf = (row: Record<string, unknown>) => String(row.id || row.__id);
/** Select many rows at once; map-linked rows also update the map selection. */
const selectRowIds = (ids: string[], keepExisting: boolean) => {
const byId = new Map(filteredRows.map((r) => [rowIdOf(r), r]));
const targets = ids.map((id) => byId.get(id)).filter((r): r is Record<string, unknown> => !!r).map((r) => getRowTarget(r));
const mapTargets = targets.filter((t): t is { type: 'node' | 'link'; id: string } => !!t && (t.type === 'node' || t.type === 'link'));
if (!mapTargets.length) {
setSelectedRowIds((prev) => new Set([...(keepExisting ? prev : []), ...ids]));
return;
}
const next: MultiSelectionState = keepExisting
? { nodes: new Set(multiSelection?.nodes ?? []), links: new Set(multiSelection?.links ?? []), gisFeatures: [...(multiSelection?.gisFeatures ?? [])] }
: { nodes: new Set<string>(), links: new Set<string>(), gisFeatures: [] };
if (keepExisting && selectedTarget?.type === 'node') next.nodes.add(selectedTarget.id);
if (keepExisting && selectedTarget?.type === 'link') next.links.add(selectedTarget.id);
for (const t of mapTargets) (t.type === 'node' ? next.nodes : next.links).add(t.id);
onMultiSelectionChange?.(next);
setSelectedRowIds(mapTargets[0].type === 'node' ? new Set(next.nodes) : new Set(next.links));
onSelectTarget(mapTargets[mapTargets.length - 1]);
};
const allFilteredSelected = filteredRows.length > 0 && filteredRows.every((r) => selectedRowIds.has(rowIdOf(r)));
// Table selection and map selection share the same node/link ID sets.
const handleRowClick = (row: Record<string, unknown>, e: React.MouseEvent) => {
const rowId = String(row.id || row.__id);
if (e.shiftKey && anchorRowIdRef.current) {
const ids = filteredRows.map(rowIdOf);
const a = ids.indexOf(anchorRowIdRef.current);
const b = ids.indexOf(rowId);
if (a >= 0 && b >= 0) {
selectRowIds(ids.slice(Math.min(a, b), Math.max(a, b) + 1), e.ctrlKey || e.metaKey);
return;
}
}
anchorRowIdRef.current = rowId;
const target = getRowTarget(row);
if (!target) {
// Patterns/Controls/Rules/Curves have no map representation; use plain
// local multi-select for delete/copy purposes only.
const additive = e.shiftKey || e.ctrlKey || e.metaKey;
const additive = e.ctrlKey || e.metaKey;
setSelectedRowIds((prev) => {
if (!additive) return new Set([rowId]);
const next = new Set(prev);
@@ -459,7 +496,7 @@ export const FullAttributeTableModal: React.FC<FullAttributeTableModalProps> = (
});
return;
}
const additive = e.shiftKey || e.ctrlKey || e.metaKey;
const additive = e.ctrlKey || e.metaKey;
if (!additive || target.type === 'gis-feature') {
setSelectedRowIds(new Set([rowId]));
@@ -742,15 +779,26 @@ export const FullAttributeTableModal: React.FC<FullAttributeTableModalProps> = (
flexDirection: 'column',
overflow: 'hidden',
fontSize: '12px',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="속성 테이블"
>
{/* Header: Title, Category Tabs, Search, Actions, Close */}
{/* Close: fixed to the dialog's top-right corner, whatever the toolbar wraps to. */}
<button
onClick={onClose}
className="btn btn-ghost"
style={{ position: 'absolute', top: 6, right: 8, padding: '4px', zIndex: 2 }}
title="닫기 (ESC)"
aria-label="닫기"
>
<X size={18} aria-hidden="true" />
</button>
{/* Header: Title, Category Tabs, Search, Actions */}
<div
style={{
padding: '8px 14px',
padding: '8px 44px 8px 14px',
borderBottom: '1px solid var(--border)',
background: 'var(--bg-subtle)',
display: 'flex',
@@ -864,6 +912,24 @@ export const FullAttributeTableModal: React.FC<FullAttributeTableModalProps> = (
{selectedRowIds.size > 0 ? `${selectedRowIds.size}/` : ''}{filteredRows.length}행
</span>
<button
onClick={() => {
if (allFilteredSelected) {
setSelectedRowIds(new Set());
onMultiSelectionChange?.({ nodes: new Set<string>(), links: new Set<string>(), gisFeatures: [] });
onSelectTarget(null);
} else {
selectRowIds(filteredRows.map(rowIdOf), false);
}
}}
className="btn btn-secondary"
disabled={filteredRows.length === 0}
style={{ padding: '4px 8px', fontSize: '11px' }}
title="검색·필터로 보이는 행을 모두 선택 (Shift+클릭: 범위 선택, Ctrl+클릭: 하나씩 추가)"
>
{allFilteredSelected ? '선택 해제' : '전체 선택'}
</button>
<button
onClick={() => {
if (selectedTarget && onZoomToSelection) onZoomToSelection(selectedTarget);
@@ -911,15 +977,6 @@ export const FullAttributeTableModal: React.FC<FullAttributeTableModalProps> = (
<span>복사 (TSV)</span>
</button>
<button
onClick={onClose}
className="btn btn-ghost"
style={{ padding: '4px' }}
title="닫기 (ESC)"
aria-label="닫기"
>
<X size={16} aria-hidden="true" />
</button>
</div>
</div>
@@ -1028,6 +1085,8 @@ export const FullAttributeTableModal: React.FC<FullAttributeTableModalProps> = (
<tr
key={rowId}
onClick={(e) => handleRowClick(row, e)}
// Shift+click would otherwise also drag-select the cell text.
onMouseDown={(e) => { if (e.shiftKey) e.preventDefault(); }}
onContextMenu={(e) => {
const target = getRowTarget(row);
if (!target) return;
@@ -1166,7 +1225,7 @@ export const FullAttributeTableModal: React.FC<FullAttributeTableModalProps> = (
}}
>
<div>
<span>셀 더블클릭 수정 · 행 우클릭 후 선택 객체 확대 · Shift/Ctrl 클릭 다중 선택 · Ctrl+C 복사</span>
<span>셀 더블클릭 수정 · 행 우클릭 후 선택 객체 확대 · Ctrl 클릭 추가 · Shift 클릭 범위 선택 · Ctrl+C 복사</span>
</div>
{toastText && (
<div style={{ color: 'var(--accent)', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '4px' }}>
+4 -4
View File
@@ -264,8 +264,8 @@ export const HydraulicTableViewer: React.FC<HydraulicTableViewerProps> = ({
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)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', rowGap: 'var(--s-2)', columnGap: 'var(--s-4)', borderBottom: '1px solid var(--border)', paddingBottom: 'var(--s-3)' }}>
<div style={{ display: 'flex', gap: 'var(--s-2)', flexWrap: 'wrap' }}>
<button
onClick={() => setSubTab('nodes')}
className={`btn btn-secondary ${subTab === 'nodes' ? 'btn-primary' : ''}`}
@@ -282,9 +282,9 @@ export const HydraulicTableViewer: React.FC<HydraulicTableViewerProps> = ({
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-4)', fontSize: 'var(--fs-xs)', color: 'var(--text-secondary)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-4)', fontSize: 'var(--fs-xs)', color: 'var(--text-secondary)', whiteSpace: 'nowrap', marginLeft: 'auto' }}>
<span style={{ fontWeight: 'var(--fw-semi)', color: 'var(--text)' }}>{currentTimeStr} 시점</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-2)', cursor: 'pointer' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-2)', cursor: 'pointer', whiteSpace: 'nowrap' }}>
<input
type="checkbox"
checked={onlyWarnings}
+5 -5
View File
@@ -137,7 +137,7 @@ export const LinkInspector: React.FC<LinkInspectorProps> = ({
관로 ID
<input value={selectedPipe.id} readOnly style={{ background: 'var(--bg-panel)' }} />
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: '6px', alignItems: 'end' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr) auto', gap: '6px', alignItems: 'end' }}>
<label style={{ display: 'grid', gap: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}>
변경할 밸브 유형
<select
@@ -171,7 +171,7 @@ export const LinkInspector: React.FC<LinkInspectorProps> = ({
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
gap: '8px',
marginBottom: '12px',
}}
@@ -284,7 +284,7 @@ export const LinkInspector: React.FC<LinkInspectorProps> = ({
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
gap: '8px',
marginBottom: '12px',
}}
@@ -335,7 +335,7 @@ export const LinkInspector: React.FC<LinkInspectorProps> = ({
)}
{selectedValve && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '12px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: '8px', marginBottom: '12px' }}>
<label style={{ display: 'grid', gap: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}>
밸브 유형
<select
@@ -384,7 +384,7 @@ export const LinkInspector: React.FC<LinkInspectorProps> = ({
</div>
)}
{!selectedPump && !selectedValve && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '12px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: '8px', marginBottom: '12px' }}>
<label style={{ display: 'grid', gap: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}>
미소손실계수
<input
+94 -2
View File
@@ -122,6 +122,7 @@ interface MapProps {
userLayers?: UserGisLayer[];
rasterOverlays?: import('./types').RasterOverlay[];
view3d?: import('./types').View3D;
demContours?: import('./types').DemContours | null;
layerVisibility?: LayerVisibilityState;
multiSelection?: MultiSelectionState;
boxSelectionEnabled?: boolean;
@@ -221,6 +222,7 @@ export const MapViewer: React.FC<MapProps> = ({
userLayers = [],
rasterOverlays,
view3d,
demContours,
layerVisibility,
styleSettings,
multiSelection,
@@ -735,6 +737,8 @@ export const MapViewer: React.FC<MapProps> = ({
const map = new maplibregl.Map({
container: mapContainer.current,
// No (i) attribution button; the required credit is a small static text below.
attributionControl: false,
// Hangul (and other CJK) label glyphs are drawn locally with this font
// instead of being fetched as glyph PBFs.
localIdeographFontFamily: "'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif",
@@ -901,6 +905,12 @@ export const MapViewer: React.FC<MapProps> = ({
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
});
// Pipe labels sit on a separate point source (link midpoints): line-placed
// Hangul text (tags) made MapLibre drop the whole 'links' bucket, pipes included.
map.addSource('link-label-points', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
});
map.addSource('flow-arrows', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
@@ -1135,13 +1145,15 @@ export const MapViewer: React.FC<MapProps> = ({
map.addLayer({
id: 'links-label-layer',
type: 'symbol',
source: 'links',
source: 'link-label-points',
layout: {
'symbol-placement': 'line',
'text-field': ['get', 'id'],
'text-font': LABEL_FONT,
'text-size': 10,
'text-offset': [0, -0.8],
'text-rotate': ['get', 'angle'],
'text-rotation-alignment': 'map',
'text-max-width': 30,
visibility: 'none',
},
paint: {
@@ -2095,6 +2107,33 @@ export const MapViewer: React.FC<MapProps> = ({
useLayoutEffect(placeHoverBox, [hovered]);
// Pipe label anchors: midpoint of each drawn line, rotated along its middle leg (kept upright).
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const apply = () => {
const src = map.getSource('link-label-points') as maplibregl.GeoJSONSource | undefined;
if (!src) return;
const features: Array<Feature<Point>> = [];
for (const f of geojson?.links.features ?? []) {
const c = f.geometry.coordinates as [number, number][];
if (c.length < 2) continue;
const k = Math.floor((c.length - 1) / 2);
const [a, b] = [c[k], c[k + 1]];
const mid: [number, number] = c.length % 2 === 1 && c.length > 2 ? c[(c.length - 1) / 2] : [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
const lat = (a[1] * Math.PI) / 180;
let angle = (-Math.atan2(b[1] - a[1], (b[0] - a[0]) * Math.cos(lat)) * 180) / Math.PI;
if (angle > 90) angle -= 180;
if (angle < -90) angle += 180;
features.push({ type: 'Feature', properties: { ...f.properties, angle }, geometry: { type: 'Point', coordinates: mid } });
}
src.setData({ type: 'FeatureCollection', features });
};
if (mapReadyRef.current) apply();
else map.once('load', apply);
return () => { map.off('load', apply); };
}, [geojson]);
// Handles for the selected link: its vertices plus midpoints of every leg.
useEffect(() => {
const map = mapRef.current;
@@ -2249,6 +2288,53 @@ export const MapViewer: React.FC<MapProps> = ({
else map.once('load', applyStyles);
}, [styleSettings, sizeKey]);
// DEM 등고선: contour vector tiles generated in a worker from the terrain tiles.
const demSourcesRef = useRef<Record<string, { url: (o: object) => string }>>({});
useEffect(() => {
const map = mapRef.current;
if (!map) return;
let cancelled = false;
const apply = async () => {
for (const id of ['dem-contour-label', 'dem-contour-line']) if (map.getLayer(id)) map.removeLayer(id);
if (map.getSource('dem-contours')) map.removeSource('dem-contours');
if (!demContours) return;
const key = `${demContours.demId}:${demContours.maxzoom}`;
let src = demSourcesRef.current[key];
if (!src) {
const mlcontour = (await import('maplibre-contour')).default;
if (cancelled) return;
const dem = new mlcontour.DemSource({
url: `${window.location.origin}/api/dem/${demContours.demId}/terrain/{z}/{x}/{y}.png`,
encoding: 'terrarium',
maxzoom: demContours.maxzoom,
worker: true,
id: `spipedem${Object.keys(demSourcesRef.current).length}`,
});
dem.setupMaplibre(maplibregl);
src = { url: (o: object) => dem.contourProtocolUrl(o as Parameters<typeof dem.contourProtocolUrl>[0]) };
demSourcesRef.current[key] = src;
}
if (cancelled || map.getSource('dem-contours')) return;
const i = demContours.interval;
map.addSource('dem-contours', {
type: 'vector',
tiles: [src.url({ thresholds: { 9: [i * 10, i * 50], 11: [i * 4, i * 20], 13: [i * 2, i * 10], 14: [i, i * 5] }, elevationKey: 'ele', levelKey: 'level', contourLayer: 'contours' })],
maxzoom: 16,
});
const before = map.getLayer('links-layer') ? 'links-layer' : undefined;
map.addLayer({ id: 'dem-contour-line', type: 'line', source: 'dem-contours', 'source-layer': 'contours', paint: { 'line-color': '#a16207', 'line-opacity': 0.55, 'line-width': ['match', ['get', 'level'], 1, 1.1, 0.45] } }, before);
map.addLayer({
id: 'dem-contour-label', type: 'symbol', source: 'dem-contours', 'source-layer': 'contours', filter: ['>', ['get', 'level'], 0],
layout: { 'symbol-placement': 'line', 'text-field': ['concat', ['number-format', ['get', 'ele'], {}], 'm'], 'text-font': LABEL_FONT, 'text-size': 10 },
paint: { 'text-color': '#92400e', 'text-halo-color': '#ffffff', 'text-halo-width': 1.2 },
}, before);
};
const run = () => { void apply(); };
if (mapReadyRef.current) run();
else map.once('load', run);
return () => { cancelled = true; map.off('load', run); };
}, [demContours]);
// 3D 보기: pitch, DEM terrain (terrarium tiles from the API) and node columns.
useEffect(() => {
const map = mapRef.current;
@@ -2501,8 +2587,14 @@ export const MapViewer: React.FC<MapProps> = ({
map.once('load', syncUserLayers);
}
}, [userLayers]);
const basemapCredit = basemap === 'osm' ? '© OpenStreetMap contributors' : basemap.startsWith('vworld') ? '© VWorld (국토교통부)' : '';
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
{basemapCredit && (
<div style={{ position: 'absolute', left: 6, bottom: 2, zIndex: 2, fontSize: 9, color: 'rgba(71,85,105,.75)', pointerEvents: 'none', textShadow: '0 0 2px #fff' }}>
{basemapCredit}
</div>
)}
<div className="map-notice">
{editMode === 'select' && (
<>
+2 -2
View File
@@ -85,7 +85,7 @@ export const NodeInspector: React.FC<NodeInspectorProps> = ({
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
gap: '8px',
marginBottom: '12px',
}}
@@ -133,7 +133,7 @@ export const NodeInspector: React.FC<NodeInspectorProps> = ({
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
gap: '8px',
marginBottom: '12px',
}}
+5 -5
View File
@@ -71,13 +71,13 @@ export const PatternEditor: React.FC<PatternEditorProps> = ({
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', fontSize: '12px' }}>
{/* Pattern Selector */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ fontWeight: 600, color: 'var(--text)' }}>패턴 선택:</span>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '6px 8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flex: '1 1 220px', minWidth: 0 }}>
<span style={{ fontWeight: 600, color: 'var(--text)', whiteSpace: 'nowrap' }}>패턴 선택:</span>
<select
value={selectedId}
onChange={(e) => setSelectedId(e.target.value)}
style={{ padding: '3px 8px', border: '1px solid #cbd5e1', borderRadius: '4px', fontSize: '12px' }}
style={{ padding: '3px 8px', border: '1px solid #cbd5e1', borderRadius: '4px', fontSize: '12px', flex: 1, minWidth: 0 }}
>
{patternKeys.map((id) => (
<option key={id} value={id}>
@@ -87,7 +87,7 @@ export const PatternEditor: React.FC<PatternEditorProps> = ({
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', whiteSpace: 'nowrap', marginLeft: 'auto' }}>
<span style={{ color: 'var(--text-secondary)', fontSize: '11px' }}>
{multipliers.length}구간 (24시간)
</span>
+16 -6
View File
@@ -2,7 +2,7 @@
// ground elevation, hydraulic grade line (동수두) at the current time step with
// its min-max envelope over the whole run, and the flow in each link below.
import React, { useEffect, useMemo, useState } from 'react';
import { Pause, Play, Crosshair, ArrowLeftRight } from 'lucide-react';
import { Pause, Play, Crosshair, ArrowLeftRight, PenTool } from 'lucide-react';
import type { NetworkModel } from './inpModel';
import { findProfilePath } from './profilePath';
import type { SimulationResult } from './types';
@@ -17,6 +17,8 @@ interface ProfilePanelProps {
toDisplay: { head: (v: number) => number; length: (v: number) => number; flow: (v: number) => number; diameter?: (v: number) => number };
units: { head: string; flow: string; length: string; diameter?: string };
onFocusNode: (id: string) => void;
/** 도면 출력: open the drawing dialog for this path. */
onOpenDrawing?: (from: string, to: string) => void;
}
const W = 320;
@@ -24,7 +26,7 @@ const fmt = (v: number) => (Math.abs(v) >= 100 ? v.toFixed(0) : Math.abs(v) >= 1
const hhmm = (sec: number) => `${Math.floor(sec / 3600)}:${String(Math.floor((sec % 3600) / 60)).padStart(2, '0')}`;
export const ProfilePanel: React.FC<ProfilePanelProps> = ({
model, result, timeStepIndex, onTimeStepIndexChange, selectedNodeId, toDisplay, units, onFocusNode,
model, result, timeStepIndex, onTimeStepIndexChange, selectedNodeId, toDisplay, units, onFocusNode, onOpenDrawing,
}) => {
const [fromId, setFromId] = useState('');
const [toId, setToId] = useState('');
@@ -112,10 +114,18 @@ export const ProfilePanel: React.FC<ProfilePanelProps> = ({
{pickButton(setToId, '선택 절점')}
</div>
{fromId && toId && (
<button type="button" className="btn btn-secondary" style={{ height: 22, fontSize: 'var(--fs-2xs)', alignSelf: 'flex-start' }}
onClick={() => { setFromId(toId); setToId(fromId); }} title="시작과 끝을 바꿔 종단 방향을 뒤집습니다">
<ArrowLeftRight size={11} />역으로
</button>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button type="button" className="btn btn-secondary" style={{ height: 22, fontSize: 'var(--fs-2xs)' }}
onClick={() => { setFromId(toId); setToId(fromId); }} title="시작과 끝을 바꿔 종단 방향을 뒤집습니다">
<ArrowLeftRight size={11} />역으로
</button>
{onOpenDrawing && path && (
<button type="button" className="btn btn-secondary" style={{ height: 22, fontSize: 'var(--fs-2xs)' }}
onClick={() => onOpenDrawing(fromId.trim(), toId.trim())} title="이 경로로 수리 종단면도(도곽·표제란)를 PDF/DXF로 출력">
<PenTool size={11} />도면 출력
</button>
)}
</div>
)}
{!fromId || !toId ? (
+1 -1
View File
@@ -60,7 +60,7 @@ export const StatusBar: React.FC<StatusBarProps> = ({
justifyContent: 'space-between',
gap: '12px',
fontSize: '11px',
overflowX: 'auto',
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
>
+6 -1
View File
@@ -178,6 +178,11 @@ export async function uploadDem(file: File): Promise<DemInfo> {
return jsonOrThrow<DemInfo>(await demFetch('/api/dem', { method: 'POST', body: form }));
}
/** Server-provided DEMs (e.g. the national 90 m DEM). */
export async function listBaseDems(): Promise<DemInfo[]> {
return jsonOrThrow<DemInfo[]>(await demFetch('/api/dem-base'));
}
export const demPreviewUrl = (id: string) => `/api/dem/${id}/preview.png`;
/** DEM values at [x, y] points given in `crs` (proj4 / EPSG); null outside the DEM. */
@@ -187,7 +192,7 @@ export async function sampleDem(id: string, points: [number, number][], crs: str
}
/** 유역·하천: stream lines, or catchments of pour points (WGS84 GeoJSON). */
export async function demHydro(id: string, body: { op: 'streams'; minAreaKm2: number } | { op: 'watershed'; points: [number, number][]; crs: string; snap?: number }) {
export async function demHydro(id: string, body: { op: 'streams'; minAreaKm2: number; bbox?: number[]; crs?: string } | { op: 'watershed'; points: [number, number][]; crs: string; snap?: number; bbox?: number[] }) {
const res = await demFetch(`/api/dem/${id}/hydro`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
return jsonOrThrow<import('geojson').FeatureCollection>(res);
}
+412
View File
@@ -0,0 +1,412 @@
// Vector drawing model for printable sheets (수리 종단면도, 관망도).
// Units are millimetres on paper, origin bottom-left, y up (CAD convention).
// One model, two writers: SVG (preview / print to PDF) and DXF R12 (CAD).
export type Anchor = 'start' | 'middle' | 'end';
export type VAnchor = 'bottom' | 'middle' | 'top';
export interface DLine { kind: 'line'; layer: string; pts: Array<[number, number]>; width?: number; color?: string; dash?: 'dashed' | 'dotted'; closed?: boolean }
export interface DText { kind: 'text'; layer: string; x: number; y: number; text: string; size: number; anchor?: Anchor; vAnchor?: VAnchor; rotate?: number; color?: string; bold?: boolean }
export interface DCircle { kind: 'circle'; layer: string; x: number; y: number; r: number; fill?: string; color?: string; width?: number }
export interface DFill { kind: 'fill'; layer: string; pts: Array<[number, number]>; fill: string; opacity?: number }
export type DItem = DLine | DText | DCircle | DFill;
export interface Drawing {
width: number;
height: number;
title: string;
items: DItem[];
/** Font family for SVG/PDF and the DXF text style (see DRAWING_FONTS). */
font?: string;
}
/**
* Drawing fonts. HY울릉도M is the usual Korean design-drawing face; it is a
* commercial font that must be installed on the PC (it is not bundled), so
* every entry falls back to 맑은 고딕.
*/
export const DRAWING_FONTS: Record<string, { label: string; css: string; dxfFile: string; dxfFamily: string }> = {
ulleungM: { label: 'HY울릉도M', css: "'HY울릉도M','HYwulM','Malgun Gothic','맑은 고딕',sans-serif", dxfFile: 'HY울릉도M.TTF', dxfFamily: 'HY울릉도M' },
malgun: { label: '맑은 고딕', css: "'Malgun Gothic','맑은 고딕','Apple SD Gothic Neo',sans-serif", dxfFile: 'malgun.ttf', dxfFamily: 'Malgun Gothic' },
};
export const DEFAULT_FONT = 'ulleungM';
export const SHEETS: Record<string, { w: number; h: number; label: string }> = {
A3: { w: 420, h: 297, label: 'A3 가로 (420×297)' },
A2: { w: 594, h: 420, label: 'A2 가로 (594×420)' },
A1: { w: 841, h: 594, label: 'A1 가로 (841×594)' },
};
export interface TitleInfo {
project: string; // 사업명
title: string; // 도면명
number: string; // 도면번호
client: string; // 발주처
company: string; // 회사
scale: string; // 축척 표기
date: string;
/** Sign-off names (설계·제도·검토·승인). */
staff?: { design?: string; draft?: string; check?: string; approve?: string };
}
export class DrawingBuilder {
items: DItem[] = [];
width: number;
height: number;
title: string;
constructor(width: number, height: number, title: string) {
this.width = width;
this.height = height;
this.title = title;
}
line(layer: string, pts: Array<[number, number]>, o: Partial<Omit<DLine, 'kind' | 'layer' | 'pts'>> = {}) {
if (pts.length >= 2) this.items.push({ kind: 'line', layer, pts, ...o });
}
rect(layer: string, x: number, y: number, w: number, h: number, o: Partial<Omit<DLine, 'kind' | 'layer' | 'pts'>> = {}) {
this.line(layer, [[x, y], [x + w, y], [x + w, y + h], [x, y + h]], { ...o, closed: true });
}
text(layer: string, x: number, y: number, text: string, size: number, o: Partial<Omit<DText, 'kind' | 'layer' | 'x' | 'y' | 'text' | 'size'>> = {}) {
if (text !== '') this.items.push({ kind: 'text', layer, x, y, text, size, ...o });
}
circle(layer: string, x: number, y: number, r: number, o: Partial<Omit<DCircle, 'kind' | 'layer' | 'x' | 'y' | 'r'>> = {}) {
this.items.push({ kind: 'circle', layer, x, y, r, ...o });
}
fill(layer: string, pts: Array<[number, number]>, fill: string, opacity = 1) {
if (pts.length >= 3) this.items.push({ kind: 'fill', layer, pts, fill, opacity });
}
build(font: string = DEFAULT_FONT): Drawing {
return { width: this.width, height: this.height, title: this.title, items: this.items, font };
}
}
/**
* Sheet frame in the usual Korean design-drawing layout (cf. 태성 C-1-002):
* binding margin on the left, double border, and a full-width title strip at
* the bottom — 발주처 | 개정 이력 | 설계사 | PROJECT | TITLE | 설계·제도·검토·승인 |
* 축척·작성일·도면번호. Returns the drawable area above the strip.
*/
export function sheetFrame(b: DrawingBuilder, info: TitleInfo): { x0: number; y0: number; x1: number; y1: number } {
const W = b.width, H = b.height;
const k = W / 420;
const L = 25, M = 10;
b.rect('FRAME', 0, 0, W, H, { width: 0.1, color: '#cbd5e1' });
b.rect('FRAME', L, M, W - L - M, H - 2 * M, { width: 0.7 });
const th = 17 * k;
const x0 = L, x1 = W - M, y0 = M, y1 = M + th;
b.line('TITLE', [[x0, y1], [x1, y1]], { width: 0.5 });
// Column widths as fractions of the strip (sum 1).
const fr = [0.155, 0.15, 0.135, 0.17, 0.17, 0.11, 0.11];
const xs = [x0];
for (const f of fr) xs.push(xs[xs.length - 1] + f * (x1 - x0));
for (let i = 1; i < xs.length - 1; i++) b.line('TITLE', [[xs[i], y0], [xs[i], y1]], { width: 0.35 });
const small = 1.35 * k, cap = (x: number, y: number, t: string) => b.text('TITLE', x, y, t, small, { color: '#334155' });
const mid = (a: number, c: number) => (a + c) / 2;
// 1. 발주처 (large)
b.text('TITLE', mid(xs[0], xs[1]), mid(y0, y1), info.client || '발 주 처', 3.6 * k, { anchor: 'middle', vAnchor: 'middle', bold: true, color: info.client ? '#111111' : '#94a3b8' });
// 2. Revision table: NO | DATE | REVISED | CHECKED | APPROVED, header + 3 rows.
{
const rw = [0.12, 0.2, 0.28, 0.2, 0.2];
const rx = [xs[1]];
for (const f of rw) rx.push(rx[rx.length - 1] + f * (xs[2] - xs[1]));
const rh = th / 4;
for (let r = 1; r < 4; r++) b.line('TITLE', [[xs[1], y1 - r * rh], [xs[2], y1 - r * rh]], { width: 0.13 });
for (let c = 1; c < rx.length - 1; c++) b.line('TITLE', [[rx[c], y0], [rx[c], y1]], { width: 0.13 });
['NO.', 'DATE', 'REVISED', 'CHECKED', 'APPROVED'].forEach((t, c) => b.text('TITLE', mid(rx[c], rx[c + 1]), y1 - rh / 2, t, 1.1 * k, { anchor: 'middle', vAnchor: 'middle', bold: true }));
for (let r = 1; r < 4; r++) b.text('TITLE', mid(rx[0], rx[1]), y1 - (r + 0.5) * rh, '△', 1.2 * k, { anchor: 'middle', vAnchor: 'middle' });
}
// 3. 설계사
b.text('TITLE', mid(xs[2], xs[3]), mid(y0, y1), info.company || '설 계 사', 2.9 * k, { anchor: 'middle', vAnchor: 'middle', bold: true, color: info.company ? '#111111' : '#94a3b8' });
// 4. PROJECT / 5. TITLE
cap(xs[3] + 1.2 * k, y1 - 2.6 * k, 'PROJECT');
b.text('TITLE', mid(xs[3], xs[4]), mid(y0, y1) - 1.2 * k, info.project, 2.7 * k, { anchor: 'middle', vAnchor: 'middle', bold: true });
cap(xs[4] + 1.2 * k, y1 - 2.6 * k, 'TITLE');
b.text('TITLE', mid(xs[4], xs[5]), mid(y0, y1) - 1.2 * k, info.title, 2.9 * k, { anchor: 'middle', vAnchor: 'middle', bold: true });
// 6. Sign-off: role | name, 4 rows.
const roles: Array<[string, string]> = [['설 계', info.staff?.design ?? ''], ['제 도', info.staff?.draft ?? ''], ['검 토', info.staff?.check ?? ''], ['승 인', info.staff?.approve ?? '']];
const rh = th / 4, roleW = (xs[6] - xs[5]) * 0.4;
b.line('TITLE', [[xs[5] + roleW, y0], [xs[5] + roleW, y1]], { width: 0.13 });
roles.forEach(([role, name], i) => {
const yb = y1 - (i + 1) * rh;
if (i < 3) b.line('TITLE', [[xs[5], yb], [xs[6], yb]], { width: 0.13 });
b.text('TITLE', xs[5] + roleW / 2, yb + rh / 2, role, 1.5 * k, { anchor: 'middle', vAnchor: 'middle' });
b.text('TITLE', mid(xs[5] + roleW, xs[6]), yb + rh / 2, name, 1.7 * k, { anchor: 'middle', vAnchor: 'middle' });
});
// 7. 축척 / 작성일 / 도면번호
const cells: Array<[string, string]> = [['축 척', info.scale], ['작 성 일', info.date], ['도면번호', info.number]];
const ch = th / 3, keyW = (xs[7] - xs[6]) * 0.38;
b.line('TITLE', [[xs[6] + keyW, y0], [xs[6] + keyW, y1]], { width: 0.13 });
cells.forEach(([key, v], i) => {
const yb = y1 - (i + 1) * ch;
if (i < 2) b.line('TITLE', [[xs[6], yb], [xs[7], yb]], { width: 0.13 });
b.text('TITLE', xs[6] + keyW / 2, yb + ch / 2, key, 1.4 * k, { anchor: 'middle', vAnchor: 'middle' });
b.text('TITLE', mid(xs[6] + keyW, xs[7]), yb + ch / 2, v, (i === 2 ? 2.2 : 1.6) * k, { anchor: 'middle', vAnchor: 'middle', bold: i === 2 });
});
return { x0: L + 5, y0: y1 + 5, x1: W - M - 5, y1: H - M - 5 };
}
/**
* Drawing heading: large centred name over a double rule with the scale
* right-aligned below it. Returns the y just under the heading.
*/
export function sheetHeading(b: DrawingBuilder, area: { x0: number; x1: number; y1: number }, title: string, scale: string): number {
const k = b.width / 420;
const cx = (area.x0 + area.x1) / 2;
const size = 6 * k;
const half = Math.max(45 * k, title.length * size * 0.5);
const yT = area.y1 - 6 * k;
b.text('HEADING', cx, yT, title, size, { anchor: 'middle', vAnchor: 'middle', bold: true });
const yR = yT - size * 0.75;
b.line('HEADING', [[cx - half, yR], [cx + half, yR]], { width: 0.5 });
b.line('HEADING', [[cx - half, yR - 0.9 * k], [cx + half, yR - 0.9 * k]], { width: 0.18 });
if (scale) b.text('HEADING', cx + half, yR - 2 * k, scale, 2.6 * k, { anchor: 'end', vAnchor: 'top', bold: true });
return yR - 6 * k;
}
/**
* Boxed panel with a header tab (범 례, 사업의 개요 …). Returns the inner box
* (x0, y top of the body, width) so the caller can fill rows below `top`.
*/
export function panelHeader(b: DrawingBuilder, layer: string, x: number, top: number, w: number, bodyH: number, title: string): { x: number; top: number; w: number } {
// Plain cartographic panel: one hairline frame, centred title over a thin rule
// (no tab, no tinted header), white backing so map lines do not show through.
const k = b.width / 420;
const hh = 5.2 * k;
b.fill(layer, [[x, top - bodyH], [x + w, top - bodyH], [x + w, top + hh], [x, top + hh]], '#ffffff', 0.94);
b.rect(layer, x, top - bodyH, w, bodyH + hh, { width: 0.3, color: '#1f2937' });
b.line(layer, [[x + 2 * k, top], [x + w - 2 * k, top]], { width: 0.15, color: '#475569' });
b.text(layer, x + w / 2, top + hh / 2, title.replace(/\s+/g, ' ').split('').join(' ').replace(/ {3}/g, ' '), 2.3 * k, { anchor: 'middle', vAnchor: 'middle', bold: true, color: '#111827' });
return { x, top, w };
}
export interface LegendRow { label: string; /** Draw the symbol in a 10 mm (x k) slot starting at x, centred on y. */ draw: (x: number, y: number) => void }
export interface LegendGroup { name: string; rows: LegendRow[] }
/** Legend look: ruled table (서울시 배관망도식), plain list with group headings, or none. */
export type LegendStyle = 'table' | 'list' | 'none';
function roundedBox(x0: number, y0: number, x1: number, y1: number, r: number, corners: 'all' | 'top' = 'all'): Array<[number, number]> {
const pts: Array<[number, number]> = [];
const arc = (cx: number, cy: number, a0: number) => { for (let i = 0; i <= 6; i++) { const a = ((a0 + i * 15) * Math.PI) / 180; pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]); } };
if (corners === 'all') { arc(x0 + r, y0 + r, 180); arc(x1 - r, y0 + r, 270); } else { pts.push([x0, y0], [x1, y0]); }
arc(x1 - r, y1 - r, 0); arc(x0 + r, y1 - r, 90);
return pts;
}
/**
* Legend as practice drawings draw it (서울시 수도정비기본계획 배관망도 범례):
* rounded heavy frame, grey title band "범 례", an optional sample panel
* showing how labels read, then a ruled table: group name written down a
* merged left cell, symbol column, description column.
*/
export function legendTable(
b: DrawingBuilder, layer: string, groups: LegendGroup[],
opts: { w: number; sample?: { h: number; draw: (x0: number, y0: number, w: number, h: number) => void }; rowH?: number; title?: string; style?: LegendStyle; band?: string },
): { h: number; draw: (x: number, y0: number) => void } {
const k = b.width / 420;
const titleH = 6.5 * k, rowH = opts.rowH ?? 4.4 * k, sampleH = opts.sample?.h ?? 0;
const gs = groups.filter((g) => g.rows.length);
const nRows = gs.reduce((t, g) => t + g.rows.length, 0);
if (opts.style === 'none') return { h: 0, draw: () => undefined };
if (opts.style === 'list') return legendList(b, layer, gs, { ...opts, rowH: rowH * 0.9 });
const band = opts.band ?? '#c8c8c8';
const h = titleH + sampleH + nRows * rowH;
const w = opts.w, gw = 8 * k, sw = 15 * k, r = 2 * k;
return {
h,
draw: (x, y0) => {
const top = y0 + h;
b.fill(layer, roundedBox(x, y0, x + w, top, r), '#ffffff', 0.95);
if (band.toLowerCase() !== '#ffffff') b.fill(layer, roundedBox(x, top - titleH, x + w, top, r, 'top'), band);
// Title letters spread out (SVG collapses runs of spaces).
const tc = (opts.title ?? '범례').replace(/\s+/g, '').split('');
const span = tc.length > 1 ? Math.min(w * 0.36, tc.length * 6 * k) : 0;
tc.forEach((c, i) => b.text(layer, x + w / 2 - span / 2 + (tc.length > 1 ? (i * span) / (tc.length - 1) : 0), top - titleH / 2, c, 3 * k, { anchor: 'middle', vAnchor: 'middle', bold: true, color: '#111111' }));
b.line(layer, [[x, top - titleH], [x + w, top - titleH]], { width: 0.3, color: '#111111' });
if (opts.sample) {
opts.sample.draw(x, top - titleH - sampleH, w, sampleH);
b.line(layer, [[x, top - titleH - sampleH], [x + w, top - titleH - sampleH]], { width: 0.3, color: '#111111' });
}
let y = top - titleH - sampleH;
gs.forEach((g, gi) => {
const gh = g.rows.length * rowH;
if (gi) b.line(layer, [[x, y], [x + w, y]], { width: 0.3, color: '#111111' });
b.line(layer, [[x + gw, y], [x + gw, y - gh]], { width: 0.2, color: '#111111' });
b.line(layer, [[x + gw + sw, y], [x + gw + sw, y - gh]], { width: 0.2, color: '#111111' });
const chars = g.name.replace(/\s+/g, '').split('');
const step = Math.min(gh / (chars.length + 0.3), 4.5 * k);
const fs = Math.min(2.6 * k, step * 0.8);
chars.forEach((c, i) => b.text(layer, x + gw / 2, y - gh / 2 + ((chars.length - 1) / 2 - i) * step, c, fs, { anchor: 'middle', vAnchor: 'middle', bold: true, color: '#111111' }));
g.rows.forEach((row, i) => {
const cy = y - (i + 0.5) * rowH;
if (i) b.line(layer, [[x + gw, y - i * rowH], [x + w, y - i * rowH]], { width: 0.15, color: '#111111' });
row.draw(x + gw + (sw - 10 * k) / 2, cy);
b.text(layer, x + gw + sw + 2.2 * k, cy, row.label, 1.9 * k, { vAnchor: 'middle', color: '#111111' });
});
y -= gh;
});
b.line(layer, roundedBox(x, y0, x + w, top, r), { width: 0.5, color: '#111111', closed: true });
},
};
}
/** Plain list legend: hairline frame, centred title over a thin rule, small grey group headings. */
function legendList(
b: DrawingBuilder, layer: string, gs: LegendGroup[],
opts: { w: number; sample?: { h: number; draw: (x0: number, y0: number, w: number, h: number) => void }; rowH: number; title?: string; band?: string },
): { h: number; draw: (x: number, y0: number) => void } {
const k = b.width / 420;
const titleH = 5.5 * k, headH = 3.6 * k, rowH = opts.rowH, sampleH = opts.sample?.h ?? 0, w = opts.w;
const h = titleH + sampleH + gs.reduce((t, g) => t + headH + g.rows.length * rowH, 0) + 1.5 * k;
return {
h,
draw: (x, y0) => {
const top = y0 + h;
b.fill(layer, [[x, y0], [x + w, y0], [x + w, top], [x, top]], '#ffffff', 0.94);
const band = opts.band ?? '#ffffff';
if (band.toLowerCase() !== '#ffffff') b.fill(layer, [[x, top - titleH], [x + w, top - titleH], [x + w, top], [x, top]], band);
b.rect(layer, x, y0, w, h, { width: 0.3, color: '#1f2937' });
b.line(layer, [[x + 2 * k, top - titleH], [x + w - 2 * k, top - titleH]], { width: 0.15, color: '#475569' });
const tc = (opts.title ?? '범례').replace(/\s+/g, '').split('');
const span = tc.length > 1 ? Math.min(w * 0.25, tc.length * 4 * k) : 0;
tc.forEach((c, i) => b.text(layer, x + w / 2 - span / 2 + (tc.length > 1 ? (i * span) / (tc.length - 1) : 0), top - titleH / 2, c, 2.4 * k, { anchor: 'middle', vAnchor: 'middle', bold: true, color: '#111827' }));
let y = top - titleH;
if (opts.sample) { opts.sample.draw(x, y - sampleH, w, sampleH); y -= sampleH; b.line(layer, [[x + 2 * k, y], [x + w - 2 * k, y]], { width: 0.1, color: '#cbd5e1' }); }
gs.forEach((g, gi) => {
if (gi) b.line(layer, [[x + 3 * k, y], [x + w - 3 * k, y]], { width: 0.1, color: '#cbd5e1' });
b.text(layer, x + 3 * k, y - headH / 2, g.name, 1.7 * k, { vAnchor: 'middle', bold: true, color: '#475569' });
y -= headH;
for (const row of g.rows) {
const cy = y - rowH / 2;
row.draw(x + 4 * k, cy);
b.text(layer, x + 17 * k, cy, row.label, 1.8 * k, { vAnchor: 'middle', color: '#1f2937' });
y -= rowH;
}
});
},
};
}
/** Sheets side by side (30 mm apart) in one drawing, for a single DXF. */
export function mergeDrawings(ds: Drawing[], gap = 30): Drawing {
if (ds.length === 1) return ds[0];
const items: DItem[] = [];
let x = 0;
for (const d of ds) {
for (const it of d.items) {
if (it.kind === 'line' || it.kind === 'fill') items.push({ ...it, pts: it.pts.map(([px, py]) => [px + x, py] as [number, number]) });
else items.push({ ...it, x: it.x + x });
}
x += d.width + gap;
}
return { width: x - gap, height: Math.max(...ds.map((d) => d.height)), title: ds[0].title, items, font: ds[0].font };
}
/** Smallest "nice" drawing scale (1:s) at which `extentMm` real metres fit in `paperMm`. */
export function niceScale(extentM: number, paperMm: number, candidates = [50, 100, 200, 250, 300, 400, 500, 600, 700, 800, 1000, 1200, 1500, 2000, 2500, 3000, 4000, 5000, 6000, 7000, 8000, 10000, 12000, 15000, 20000, 25000, 30000, 40000, 50000, 60000, 75000, 80000, 100000]): number {
const need = (extentM * 1000) / Math.max(paperMm, 1);
return candidates.find((s) => s >= need) ?? Math.ceil(need / 10000) * 10000;
}
// ---------------------------------------------------------------- SVG
const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const f = (n: number) => (Math.round(n * 100) / 100).toString();
export function toSvg(d: Drawing, opts: { forPrint?: boolean } = {}): string {
const H = d.height;
const parts: string[] = [];
const byLayer = new Map<string, string[]>();
const push = (layer: string, s: string) => { const a = byLayer.get(layer) ?? []; a.push(s); byLayer.set(layer, a); };
for (const it of d.items) {
if (it.kind === 'line') {
const pts = it.pts.map(([x, y]) => `${f(x)},${f(H - y)}`).join(' ');
const dash = it.dash === 'dashed' ? ` stroke-dasharray="${f((it.width ?? 0.25) * 8)} ${f((it.width ?? 0.25) * 5)}"` : it.dash === 'dotted' ? ` stroke-dasharray="${f((it.width ?? 0.25) * 1.5)} ${f((it.width ?? 0.25) * 3)}"` : '';
push(it.layer, `<${it.closed ? 'polygon' : 'polyline'} points="${pts}" fill="none" stroke="${it.color ?? '#111'}" stroke-width="${f(it.width ?? 0.25)}"${dash} stroke-linejoin="round" stroke-linecap="round"/>`);
} else if (it.kind === 'fill') {
push(it.layer, `<polygon points="${it.pts.map(([x, y]) => `${f(x)},${f(H - y)}`).join(' ')}" fill="${it.fill}" fill-opacity="${it.opacity ?? 1}" stroke="none"/>`);
} else if (it.kind === 'circle') {
push(it.layer, `<circle cx="${f(it.x)}" cy="${f(H - it.y)}" r="${f(it.r)}" fill="${it.fill ?? 'none'}" stroke="${it.color ?? '#111'}" stroke-width="${f(it.width ?? 0.2)}"/>`);
} else {
const anchor = it.anchor ?? 'start';
const base = it.vAnchor === 'middle' ? 'central' : it.vAnchor === 'top' ? 'hanging' : 'alphabetic';
const rot = it.rotate ? ` transform="rotate(${f(-it.rotate)} ${f(it.x)} ${f(H - it.y)})"` : '';
push(it.layer, `<text x="${f(it.x)}" y="${f(H - it.y)}" font-size="${f(it.size)}" text-anchor="${anchor}" dominant-baseline="${base}" fill="${it.color ?? '#111'}"${it.bold ? ' font-weight="700"' : ''}${rot}>${esc(it.text)}</text>`);
}
}
for (const [layer, list] of byLayer) parts.push(`<g data-layer="${esc(layer)}">${list.join('')}</g>`);
const size = opts.forPrint ? `width="${d.width}mm" height="${d.height}mm"` : `width="100%"`;
return `<svg xmlns="http://www.w3.org/2000/svg" ${size} viewBox="0 0 ${d.width} ${d.height}" font-family="${esc((DRAWING_FONTS[d.font ?? ''] ?? DRAWING_FONTS.malgun).css)}"><rect width="${d.width}" height="${d.height}" fill="#fff"/>${parts.join('')}</svg>`;
}
// ---------------------------------------------------------------- DXF (R12, mm)
/** Non-ASCII as \U+XXXX (how AutoCAD/ezdxf store Unicode in R12 text). */
function dxfText(s: string): string {
let out = '';
for (const ch of s) {
const c = ch.codePointAt(0)!;
out += c < 128 ? ch : c <= 0xffff ? `\\U+${c.toString(16).toUpperCase().padStart(4, '0')}` : '?';
}
return out;
}
const ACI: Array<[number, [number, number, number]]> = [
[1, [255, 0, 0]], [2, [255, 255, 0]], [3, [0, 255, 0]], [4, [0, 255, 255]], [5, [0, 0, 255]], [6, [255, 0, 255]], [7, [0, 0, 0]], [8, [128, 128, 128]], [9, [192, 192, 192]],
[30, [255, 127, 0]], [140, [0, 127, 255]], [94, [0, 128, 0]], [12, [165, 0, 0]], [150, [0, 63, 127]],
];
function aci(color?: string): number {
if (!color || !/^#[0-9a-f]{6}$/i.test(color)) return 7;
const r = parseInt(color.slice(1, 3), 16), g = parseInt(color.slice(3, 5), 16), b = parseInt(color.slice(5, 7), 16);
let best = 7, bd = Infinity;
for (const [i, [R, G, B]] of ACI) {
const dd = (R - r) ** 2 + (G - g) ** 2 + (B - b) ** 2;
if (dd < bd) { bd = dd; best = i; }
}
return best;
}
export function toDxf(d: Drawing): string {
const o: string[] = [];
const g = (code: number, v: string | number) => o.push(String(code), typeof v === 'number' ? f(v) : v);
const layers = [...new Set(d.items.map((i) => i.layer))];
g(0, 'SECTION'); g(2, 'HEADER');
g(9, '$ACADVER'); g(1, 'AC1009');
g(9, '$EXTMIN'); g(10, 0); g(20, 0);
g(9, '$EXTMAX'); g(10, d.width); g(20, d.height);
g(0, 'ENDSEC');
g(0, 'SECTION'); g(2, 'TABLES');
g(0, 'TABLE'); g(2, 'LTYPE'); g(70, 3);
for (const [name, desc, pat] of [['CONTINUOUS', 'Solid', [] as number[]], ['DASHED', '__ __', [4, -2.5]], ['DOT', '. . .', [0.5, -1.5]]] as Array<[string, string, number[]]>) {
g(0, 'LTYPE'); g(2, name); g(70, 0); g(3, desc); g(72, 65); g(73, pat.length); g(40, pat.reduce((a, b) => a + Math.abs(b), 0));
for (const p of pat) g(49, p);
}
g(0, 'ENDTAB');
g(0, 'TABLE'); g(2, 'LAYER'); g(70, layers.length);
for (const l of layers) { g(0, 'LAYER'); g(2, l); g(70, 0); g(62, 7); g(6, 'CONTINUOUS'); }
g(0, 'ENDTAB');
g(0, 'TABLE'); g(2, 'STYLE'); g(70, 1);
g(0, 'STYLE'); g(2, 'STANDARD'); g(70, 0); g(40, 0); g(41, 1); g(50, 0); g(71, 0); g(42, 2.5); g(3, 'malgun.ttf'); g(4, '');
g(0, 'ENDTAB');
g(0, 'ENDSEC');
g(0, 'SECTION'); g(2, 'ENTITIES');
for (const it of d.items) {
if (it.kind === 'line' || it.kind === 'fill') {
const pts = it.pts;
g(0, 'POLYLINE'); g(8, it.layer); g(62, aci(it.kind === 'fill' ? it.fill : it.color));
if (it.kind === 'line' && it.dash) g(6, it.dash === 'dashed' ? 'DASHED' : 'DOT');
g(66, 1); g(10, 0); g(20, 0); g(30, 0); g(70, it.kind === 'fill' || (it.kind === 'line' && it.closed) ? 1 : 0);
if (it.kind === 'line' && it.width && it.width >= 0.5) { g(40, it.width); g(41, it.width); }
for (const [x, y] of pts) { g(0, 'VERTEX'); g(8, it.layer); g(10, x); g(20, y); g(30, 0); }
g(0, 'SEQEND'); g(8, it.layer);
} else if (it.kind === 'circle') {
g(0, 'CIRCLE'); g(8, it.layer); g(62, aci(it.color)); g(10, it.x); g(20, it.y); g(30, 0); g(40, it.r);
} else {
const h = it.anchor === 'middle' ? 1 : it.anchor === 'end' ? 2 : 0;
const v = it.vAnchor === 'middle' ? 2 : it.vAnchor === 'top' ? 3 : 0;
g(0, 'TEXT'); g(8, it.layer); g(62, aci(it.color)); g(10, it.x); g(20, it.y); g(30, 0); g(40, it.size * 0.8);
g(1, dxfText(it.text)); if (it.rotate) g(50, it.rotate);
if (h || v) { g(72, h); g(73, v); g(11, it.x); g(21, it.y); g(31, 0); }
}
}
g(0, 'ENDSEC'); g(0, 'EOF');
return o.join('\r\n') + '\r\n';
}
+209
View File
@@ -0,0 +1,209 @@
// Model + result -> drawing inputs (수리 종단면도, 관망도), in display units.
import type { NetworkModel } from './inpModel';
import type { SimulationResult } from './types';
import type { ProfileFitting, ProfileRun, ProfileSeries, ProfileStation } from './profileDrawing.ts';
import type { PlanLegendItem, PlanLink, PlanNode } from './networkDrawing.ts';
export interface Conv {
head: (v: number) => number; // model elevation/head -> display (m)
length: (v: number) => number;
diameter: (v: number) => number;
flow: (v: number) => number;
pressure: (v: number) => number;
velocity: (v: number) => number;
}
export type SeriesKey = 'now' | 'min' | 'max' | 'avg';
export const SERIES_LABEL: Record<SeriesKey, string> = { now: '선택 시점', min: '최소', max: '최대', avg: '평균' };
const SERIES_STYLE: Record<SeriesKey, Omit<ProfileSeries, 'name'>> = {
now: { color: '#1d4ed8', dash: 'dashed' },
min: { color: '#0891b2', dash: 'dotted' },
max: { color: '#dc2626', dash: 'dotted' },
avg: { color: '#7c3aed', dash: 'dashed' },
};
export interface ProfileOptions {
/** What node elevation means in this model. */
elevationIs: 'invert' | 'ground';
/** Burial depth (m) when elevation is the invert and no asset depth exists. */
depth: number;
series: SeriesKey[];
timeIndex: number;
}
function seriesValue(s: number[] | undefined, fallback: number, key: SeriesKey, t: number): number {
if (!s?.length) return fallback;
if (key === 'now') return s[Math.min(t, s.length - 1)] ?? fallback;
if (key === 'min') return Math.min(...s);
if (key === 'max') return Math.max(...s);
return s.reduce((a, b) => a + b, 0) / s.length;
}
export function profileInput(
model: NetworkModel,
result: SimulationResult | null,
path: { nodeIds: string[]; linkIds: string[]; linkLengths: number[] },
o: ProfileOptions,
u: Conv,
flowUnit: string,
): { stations: ProfileStation[]; runs: ProfileRun[]; series: ProfileSeries[]; marks: Array<{ x: number; text: string }>; fittings: ProfileFitting[] } {
const series = (result ? o.series : []).map((k) => ({ name: SERIES_LABEL[k], ...SERIES_STYLE[k] }));
const stations: ProfileStation[] = [];
const runs: ProfileRun[] = [];
const marks: Array<{ x: number; text: string }> = [];
const fittings: ProfileFitting[] = [];
let x = 0;
path.nodeIds.forEach((id, i) => {
if (i > 0) x += u.length(path.linkLengths[i - 1] ?? 0);
const n = model.nodes[id];
const elev = u.head(n?.elevation ?? 0);
const depthAsset = Number(model.assets?.nodes[id]?.depth);
const depth = Number.isFinite(depthAsset) && depthAsset > 0 ? depthAsset : o.depth;
const isNode = n?.type === 'JUNCTION';
const ground = isNode && o.elevationIs === 'invert' ? elev + depth : elev;
const invert = isNode && o.elevationIs === 'invert' ? elev : isNode ? elev - depth : elev;
const nr = result?.nodes[id];
const heads = (result ? o.series : []).map((k) => u.head(seriesValue(nr?.timeSeries?.head, nr?.head ?? NaN, k, o.timeIndex)));
const p = nr ? u.pressure(seriesValue(nr.timeSeries?.pressure, nr.pressure, o.series[0] ?? 'now', o.timeIndex)) : undefined;
const kind = n?.type === 'RESERVOIR' ? 'reservoir' : n?.type === 'TANK' ? 'tank' : 'junction';
// Water levels: reservoir head = its elevation; tank H.W.L/L.W.L from levels.
const hwl = kind === 'reservoir' ? elev : kind === 'tank' ? u.head((n?.elevation ?? 0) + (n?.maxLevel ?? n?.initLevel ?? 0)) : undefined;
const lwl = kind === 'tank' ? u.head((n?.elevation ?? 0) + (n?.minLevel ?? 0)) : undefined;
stations.push({ id, x, ground, invert, heads, pressure: p, kind, hwl, lwl });
if (n && n.type !== 'JUNCTION') marks.push({ x, text: `${n.type === 'RESERVOIR' ? '배수지' : '탱크'} ${id}` });
});
path.linkIds.forEach((lid, i) => {
const pipe = model.pipes[lid];
const valve = model.valves?.[lid];
const pump = model.pumps[lid];
const at = (stations[i].x + stations[i + 1].x) / 2;
if (valve) {
const t = valve.valveType.toUpperCase();
const set = Number(valve.setting);
const setTxt = !Number.isFinite(set) ? '' : ['PRV', 'PSV', 'PBV'].includes(t) ? ` ${u.pressure(set).toFixed(1)}m` : t === 'FCV' ? ` ${u.flow(set).toFixed(1)}${flowUnit}` : '';
fittings.push({ x: at, kind: 'valve', subtype: t, label: `${VALVE_NAME[t] ?? '밸브'} ${lid} (${t}${setTxt})` });
} else if (pump) {
fittings.push({ x: at, kind: 'pump', label: `가압펌프 ${lid}` });
}
if (!pipe) return; // pumps / valves have no run
const lr = result?.links[lid];
const q = lr ? Math.abs(seriesValue(lr.timeSeries?.flow, lr.flow, o.series[0] ?? 'now', o.timeIndex)) : undefined;
const hl = lr ? Math.abs(seriesValue(lr.timeSeries?.headloss, lr.headloss, o.series[0] ?? 'now', o.timeIndex)) : undefined;
const len = u.length(pipe.length);
runs.push({
fromX: stations[i].x,
toX: stations[i + 1].x,
diameter: u.diameter(pipe.diameter),
length: len,
roughness: model.options.headloss?.toUpperCase().startsWith('H') ? pipe.roughness : undefined,
flow: q !== undefined ? u.flow(q) : undefined,
flowUnit,
gradient: hl !== undefined && len > 0 ? (u.head(hl) / len) * 1000 : undefined,
});
});
return { stations, runs, series, marks, fittings };
}
const VALVE_NAME: Record<string, string> = { PRV: '감압밸브', PSV: '지속밸브', PBV: '압력차단밸브', FCV: '유량조절밸브', TCV: '제어밸브', GPV: '범용밸브', PCV: '제어밸브' };
export type PlanColorMode = 'diameter' | 'hydraulic';
/** Class colours of a plan drawing (velocity, pressure, diameter classes). */
export interface PlanPalette {
velHigh: string; velMid: string; velOk: string; velLow: string;
pLow: string; pOk: string; pHigh: string;
d300: string; d150: string; d100: string; d0: string;
}
export const PALETTE_KEYS: Array<{ key: keyof PlanPalette; label: string; group: 'velocity' | 'pressure' | 'diameter' }> = [
{ key: 'velHigh', label: '유속 기준 초과', group: 'velocity' },
{ key: 'velMid', label: '유속 1.0m/s~기준', group: 'velocity' },
{ key: 'velOk', label: '유속 0.3~1.0m/s', group: 'velocity' },
{ key: 'velLow', label: '유속 0.3m/s 미만', group: 'velocity' },
{ key: 'pLow', label: '수압 최소 미만', group: 'pressure' },
{ key: 'pOk', label: '수압 적정', group: 'pressure' },
{ key: 'pHigh', label: '수압 최대 초과', group: 'pressure' },
{ key: 'd300', label: 'D300 이상', group: 'diameter' },
{ key: 'd150', label: 'D150~D250', group: 'diameter' },
{ key: 'd100', label: 'D100~D125', group: 'diameter' },
{ key: 'd0', label: 'D100 미만', group: 'diameter' },
];
export const PLAN_PALETTES: Record<string, { label: string; colors: PlanPalette }> = {
standard: { label: '기본 (컬러)', colors: { velHigh: '#dc2626', velMid: '#f59e0b', velOk: '#16a34a', velLow: '#94a3b8', pLow: '#dc2626', pOk: '#2563eb', pHigh: '#f97316', d300: '#1d4ed8', d150: '#16a34a', d100: '#ca8a04', d0: '#9ca3af' } },
cad: { label: 'CAD 기본색 (빨강·자홍·초록·파랑)', colors: { velHigh: '#ff0000', velMid: '#ff00ff', velOk: '#00a000', velLow: '#0000ff', pLow: '#ff0000', pOk: '#0000ff', pHigh: '#ff00ff', d300: '#0000ff', d150: '#00a000', d100: '#ff00ff', d0: '#808080' } },
colorblind: { label: '색약 배려 (Okabe-Ito)', colors: { velHigh: '#d55e00', velMid: '#e69f00', velOk: '#009e73', velLow: '#999999', pLow: '#d55e00', pOk: '#0072b2', pHigh: '#cc79a7', d300: '#0072b2', d150: '#009e73', d100: '#e69f00', d0: '#999999' } },
mono: { label: '흑백 인쇄용', colors: { velHigh: '#000000', velMid: '#3f3f3f', velOk: '#7a7a7a', velLow: '#b4b4b4', pLow: '#000000', pOk: '#8a8a8a', pHigh: '#3f3f3f', d300: '#000000', d150: '#3f3f3f', d100: '#7a7a7a', d0: '#b4b4b4' } },
};
export function planInput(
model: NetworkModel,
result: SimulationResult | null,
o: { mode: PlanColorMode; timeIndex: number; minPressure: number; maxPressure: number; maxVelocity: number; ids?: { nodes: Set<string>; links: Set<string> }; palette?: PlanPalette },
u: Conv,
): { links: PlanLink[]; nodes: PlanNode[]; legend: PlanLegendItem[]; legendTitle: string } {
const pal = o.palette ?? PLAN_PALETTES.standard.colors;
const D_CLASSES: Array<[number, string, string]> = [[300, pal.d300, 'D300 이상'], [150, pal.d150, 'D150~D250'], [100, pal.d100, 'D100~D125'], [0, pal.d0, 'D100 미만']];
const at = (s: number[] | undefined, v: number) => (s?.length ? s[Math.min(o.timeIndex, s.length - 1)] ?? v : v);
const pipeColor = (id: string, dmm: number): string => {
if (o.mode === 'diameter' || !result) return D_CLASSES.find(([min]) => dmm >= min)![1];
const lr = result.links[id];
if (!lr) return '#9ca3af';
const v = Math.abs(u.velocity(at(lr.timeSeries?.velocity, lr.velocity)));
return v > o.maxVelocity ? pal.velHigh : v >= 1 ? pal.velMid : v >= 0.3 ? pal.velOk : pal.velLow;
};
const nodeClass = (id: string): 'low' | 'ok' | 'high' | null => {
if (o.mode === 'diameter' || !result) return null;
const nr = result.nodes[id];
if (!nr) return null;
const p = u.pressure(at(nr.timeSeries?.pressure, nr.pressure));
return p < o.minPressure ? 'low' : p > o.maxPressure ? 'high' : 'ok';
};
const nodeColor = (id: string): string => {
const c = nodeClass(id);
return c === 'low' ? pal.pLow : c === 'high' ? pal.pHigh : c === 'ok' ? pal.pOk : '#334155';
};
const keepL = (id: string) => !o.ids || o.ids.links.has(id);
const keepN = (id: string) => !o.ids || o.ids.nodes.has(id);
const xy = (id: string): [number, number] => [model.nodes[id].x, model.nodes[id].y];
const links: PlanLink[] = [];
for (const p of Object.values(model.pipes)) {
if (!keepL(p.id) || !model.nodes[p.node1] || !model.nodes[p.node2]) continue;
const d = u.diameter(p.diameter);
links.push({
id: p.id, kind: 'pipe', diameter: d, length: u.length(p.length), color: pipeColor(p.id, d),
closed: (model.extras?.statusOverrides[p.id] ?? p.status ?? '').toUpperCase().startsWith('CLOSED'),
pts: [xy(p.node1), ...(model.vertices?.[p.id] ?? []).map((v) => [v.x, v.y] as [number, number]), xy(p.node2)],
});
}
for (const [kind, rec] of [['pump', model.pumps], ['valve', model.valves ?? {}]] as const) {
for (const l of Object.values(rec)) {
if (!keepL(l.id) || !model.nodes[l.node1] || !model.nodes[l.node2]) continue;
links.push({ id: l.id, kind, subtype: kind === 'valve' ? (l as { valveType?: string }).valveType?.toUpperCase() : undefined, color: '#111111', pts: [xy(l.node1), ...(model.vertices?.[l.id] ?? []).map((v) => [v.x, v.y] as [number, number]), xy(l.node2)] });
}
}
const nodes: PlanNode[] = Object.values(model.nodes).filter((n) => keepN(n.id)).map((n) => ({
id: n.id, type: n.type, x: n.x, y: n.y, color: nodeColor(n.id),
alert: nodeClass(n.id) === 'low' || nodeClass(n.id) === 'high',
elevation: u.head(n.elevation ?? 0),
...(result?.nodes[n.id] ? (() => {
const nr = result.nodes[n.id];
return {
head: u.head(at(nr.timeSeries?.head, nr.head)),
pressure: u.pressure(at(nr.timeSeries?.pressure, nr.pressure)),
demand: u.flow(at(nr.timeSeries?.demand, nr.demand)),
};
})() : {}),
label: n.type === 'RESERVOIR' ? `배수지 ${n.id}` : n.type === 'TANK' ? `탱크 ${n.id}` : undefined,
}));
const legend: PlanLegendItem[] = o.mode === 'diameter' || !result
? D_CLASSES.map(([min, color, label]) => ({ label, color, shape: 'line', diameter: Math.max(min, 80) }))
: [
{ label: `유속 ${o.maxVelocity}m/s 초과`, color: pal.velHigh, shape: 'line' },
{ label: `유속 1.0~${o.maxVelocity}m/s`, color: pal.velMid, shape: 'line' },
{ label: '유속 0.3~1.0m/s', color: pal.velOk, shape: 'line' },
{ label: '유속 0.3m/s 미만', color: pal.velLow, shape: 'line' },
{ label: `수압 ${o.minPressure}m 미만`, color: pal.pLow, shape: 'dot' },
{ label: '수압 적정', color: pal.pOk, shape: 'dot' },
{ label: `수압 ${o.maxPressure}m 초과`, color: pal.pHigh, shape: 'dot' },
];
return { links, nodes, legend, legendTitle: o.mode === 'diameter' || !result ? '범 례 (관경)' : '범 례 (수리 판정)' };
}
+14
View File
@@ -850,3 +850,17 @@ tr:hover td {
background: var(--border);
margin: 2px 0;
}
/* Right dock (속성·결과·검증·분석): fields shrink to their grid/flex cell
instead of pushing the content wider than the dock (inputs and selects
otherwise keep an intrinsic ~150px minimum width). */
.panel input:not([type='checkbox']):not([type='radio']):not([type='range']),
.panel select,
.panel textarea {
min-width: 0;
max-width: 100%;
}
.panel label,
.panel .inspector-pane > * {
min-width: 0;
}
+668
View File
@@ -0,0 +1,668 @@
// 관망도 (network plan sheet): pipes and nodes at a nice scale with D/L labels,
// coordinate grid, legend, north arrow, scale bar and the title strip (cf. 태성
// C-1-002). Coordinates come in model units (metres for a projected CRS);
// background GIS lines are drawn in grey.
import { DrawingBuilder, legendTable, niceScale, panelHeader, sheetFrame, sheetHeading, SHEETS, type Drawing, type LegendStyle, type TitleInfo } from './drawing.ts';
export interface PlanLink {
id: string;
kind: 'pipe' | 'pump' | 'valve';
/** Valve type (PRV, FCV …). */
subtype?: string;
pts: Array<[number, number]>;
diameter?: number; // mm
length?: number; // m
color: string;
closed?: boolean;
}
export interface PlanNode {
id: string;
type: 'JUNCTION' | 'RESERVOIR' | 'TANK';
x: number;
y: number;
color: string;
/** Out of the design range: drawn larger with a ring. */
alert?: boolean;
label?: string;
/** For pressure tags (display units). */
head?: number;
elevation?: number;
pressure?: number;
demand?: number;
}
/** One detail sheet: the model window it shows and its cell in the sheet grid (i = column from west, j = row from south). */
export interface PlanTile { no: number; minX: number; minY: number; maxX: number; maxY: number; i: number; j: number; name: string }
/** Neighbouring sheet: number/name and the grid direction (dx east, dy north, each -1..1). */
export interface PlanNeighbour { no: number; name: string; dx: number; dy: number }
export interface PlanLegendItem {
label: string;
color: string;
shape: 'line' | 'dot';
/** Representative diameter (mm) for line width. */
diameter?: number;
}
export interface PlanSheetInput {
sheet: keyof typeof SHEETS;
info: Omit<TitleInfo, 'scale'>;
/** False for LOCAL (unitless) coordinates: no scale bar, "NONE" scale. */
metric: boolean;
links: PlanLink[];
nodes: PlanNode[];
legend: PlanLegendItem[];
legendTitle: string;
background?: Array<Array<[number, number]>>;
/** Pipe labels: none, D only, D+L, pipe ID + D + L. */
pipeLabels: 'none' | 'D' | 'DL' | 'IDDL';
/** Minimum pipe length on paper (mm) to get a label. */
labelMinMm?: number;
nodeLabels: 'none' | 'sources' | 'all';
/** Detail sheet: model window to show at a fixed scale. */
view?: { minX: number; minY: number; maxX: number; maxY: number };
scale?: number;
/** "(3/12)" after the drawing name. */
sheetLabel?: string;
/** Location inset (위치도) for detail sheets. */
keyMap?: { lines: Array<Array<[number, number]>>; tiles: PlanTile[]; current: number };
/** Adjacent sheets, shown at the frame edge. */
neighbours?: PlanNeighbour[];
/** Index sheet (색인도): tile rectangles with numbers. */
indexTiles?: PlanTile[];
/** Node pressure tags as in practice drawings (수두고/계획고 · 수압/유출량). */
pressureTags?: 'none' | 'kgf' | 'm';
font?: string;
/** Legend look and title band colour. */
legendStyle?: LegendStyle;
legendBand?: string;
}
const fmt = (v: number, d = 1) => v.toLocaleString('en-US', { maximumFractionDigits: d });
const pipeWidth = (d: number, k: number) => Math.min(1.1, 0.2 + d / 700) * k;
function niceStep(range: number, target: number): number {
const raw = range / target;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const n = raw / mag;
return (n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10) * mag;
}
type Rect = { x0: number; y0: number; x1: number; y1: number };
/** Liang–Barsky clip of a polyline to a rectangle; returns the inside pieces. */
export function clipPolyline(pts: Array<[number, number]>, r: Rect): Array<Array<[number, number]>> {
const out: Array<Array<[number, number]>> = [];
let cur: Array<[number, number]> | null = null;
for (let i = 0; i < pts.length - 1; i++) {
const [x0, y0] = pts[i], [x1, y1] = pts[i + 1];
const dx = x1 - x0, dy = y1 - y0;
let t0 = 0, t1 = 1, ok = true;
for (const [pp, qq] of [[-dx, x0 - r.x0], [dx, r.x1 - x0], [-dy, y0 - r.y0], [dy, r.y1 - y0]] as const) {
if (pp === 0) { if (qq < 0) { ok = false; break; } continue; }
const t = qq / pp;
if (pp < 0) { if (t > t1) { ok = false; break; } if (t > t0) t0 = t; } else { if (t < t0) { ok = false; break; } if (t < t1) t1 = t; }
}
if (!ok) { cur = null; continue; }
const a: [number, number] = [x0 + dx * t0, y0 + dy * t0], c: [number, number] = [x0 + dx * t1, y0 + dy * t1];
if (cur && t0 === 0) cur.push(c);
else { cur = [a, c]; out.push(cur); }
if (t1 < 1) cur = null;
}
return out;
}
/**
* Chains of pipes with the same diameter joined through plain degree-2 points,
* so a label reads "D300 L=4,170m" for the whole stretch instead of per pipe.
*/
export function pipeChains(links: PlanLink[], stops: Set<string> = new Set()): Array<{ links: PlanLink[]; length: number; diameter: number }> {
const key = ([x, y]: [number, number]) => `${x.toFixed(3)},${y.toFixed(3)}`;
const pipes = links.filter((l) => l.kind === 'pipe' && l.diameter && l.pts.length >= 2);
const deg = new Map<string, number>();
for (const l of links) for (const p of [l.pts[0], l.pts[l.pts.length - 1]]) deg.set(key(p), (deg.get(key(p)) ?? 0) + 1);
const at = new Map<string, PlanLink[]>();
for (const l of pipes) for (const p of [l.pts[0], l.pts[l.pts.length - 1]]) at.set(key(p), [...(at.get(key(p)) ?? []), l]);
const used = new Set<string>();
const out: Array<{ links: PlanLink[]; length: number; diameter: number }> = [];
const passable = (k: string) => deg.get(k) === 2 && !stops.has(k);
for (const start of pipes) {
if (used.has(start.id)) continue;
used.add(start.id);
const chain = [start];
// grow from both ends
for (const end of [start.pts[0], start.pts[start.pts.length - 1]]) {
let k = key(end);
let cur = start;
while (passable(k)) {
const next = (at.get(k) ?? []).find((l) => l !== cur && !used.has(l.id) && l.diameter === start.diameter && !!l.closed === !!start.closed);
if (!next) break;
used.add(next.id);
chain.push(next);
const a = key(next.pts[0]), b = key(next.pts[next.pts.length - 1]);
k = a === k ? b : a;
cur = next;
}
}
out.push({ links: chain, length: chain.reduce((s, l) => s + (l.length ?? 0), 0), diameter: start.diameter! });
}
return out;
}
export function buildNetworkDrawing(input: PlanSheetInput): { drawing: Drawing; scale: number; tagsSkipped: number } {
const sheet = SHEETS[input.sheet];
const k = sheet.w / 420;
const b = new DrawingBuilder(sheet.w, sheet.h, input.info.title);
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
const grow = (x: number, y: number) => { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; };
if (input.view) { grow(input.view.minX, input.view.minY); grow(input.view.maxX, input.view.maxY); }
else {
for (const n of input.nodes) grow(n.x, n.y);
for (const l of input.links) for (const [x, y] of l.pts) grow(x, y);
}
if (!Number.isFinite(minX)) throw new Error('그릴 관망이 없습니다.');
const tmp = new DrawingBuilder(sheet.w, sheet.h, '');
const area0 = sheetFrame(tmp, { ...input.info, scale: '' });
// Map frame below the heading.
const area = { x0: area0.x0, x1: area0.x1, y0: area0.y0, y1: area0.y1 - 17 * k };
const pad = 8 * k;
const aw = area.x1 - area.x0 - 2 * pad, ah = area.y1 - area.y0 - 2 * pad;
const ext = Math.max((maxX - minX) / aw, (maxY - minY) / ah) * 1000;
const scale = input.metric ? input.scale ?? niceScale(Math.max(ext, 1e-9) / 1000 * aw, aw) : 0;
const unitsPerMm = input.metric ? scale / 1000 : input.view ? Math.max((maxX - minX) / (area.x1 - area.x0), (maxY - minY) / (area.y1 - area.y0)) || 1 : Math.max((maxX - minX) / aw, (maxY - minY) / ah) || 1;
const cx = (area.x0 + area.x1) / 2, cy = (area.y0 + area.y1) / 2;
const mx = (minX + maxX) / 2, my = (minY + maxY) / 2;
const P = (x: number, y: number): [number, number] => [cx + (x - mx) / unitsPerMm, cy + (y - my) / unitsPerMm];
const W = (px: number, py: number): [number, number] => [mx + (px - cx) * unitsPerMm, my + (py - cy) * unitsPerMm];
const scaleTxt = input.metric ? `S = 1:${scale.toLocaleString()}` : 'S = NONE';
sheetFrame(b, { ...input.info, scale: input.metric ? `1:${scale.toLocaleString()}` : 'NONE' });
sheetHeading(b, area0, input.sheetLabel ? `${input.info.title} ${input.sheetLabel}` : input.info.title, scaleTxt);
b.rect('MAPFRAME', area.x0, area.y0, area.x1 - area.x0, area.y1 - area.y0, { width: 0.45 });
const inside = ([x, y]: [number, number]) => x >= area.x0 && x <= area.x1 && y >= area.y0 && y <= area.y1;
for (const line of input.background ?? []) {
for (const piece of clipPolyline(line.map(([x, y]) => P(x, y)), area)) b.line('BACKGROUND', piece, { width: 0.1, color: '#a8b0bc' });
}
// Coordinate grid (Korean survey convention: X = northing, Y = easting).
if (input.metric) {
const [wx0, wy0] = W(area.x0, area.y0), [wx1, wy1] = W(area.x1, area.y1);
const step = niceStep(Math.max(wx1 - wx0, wy1 - wy0), 5);
const fs = 1.8 * k;
for (let gx = Math.ceil(wx0 / step) * step; gx < wx1; gx += step) {
const [px] = P(gx, my);
if (px - area.x0 < 6 * k || area.x1 - px < 6 * k) continue;
b.line('GRID', [[px, area.y0], [px, area.y1]], { width: 0.08, color: '#cbd5e1' });
b.line('GRID', [[px, area.y1], [px, area.y1 - 2 * k]], { width: 0.25 });
b.line('GRID', [[px, area.y0], [px, area.y0 + 2 * k]], { width: 0.25 });
b.text('GRID', px - 0.8 * k, area.y1 - 2.5 * k, `Y=${fmt(gx, 0)}`, fs, { rotate: -90, color: '#475569' });
}
for (let gy = Math.ceil(wy0 / step) * step; gy < wy1; gy += step) {
const [, py] = P(mx, gy);
if (py - area.y0 < 6 * k || area.y1 - py < 6 * k) continue;
b.line('GRID', [[area.x0, py], [area.x1, py]], { width: 0.08, color: '#cbd5e1' });
b.line('GRID', [[area.x0, py], [area.x0 + 2 * k, py]], { width: 0.25 });
b.line('GRID', [[area.x1, py], [area.x1 - 2 * k, py]], { width: 0.25 });
// left labels stay clear of the legend / scale bar block
if (py > area.y0 + (input.legend.length + 6) * 4.4 * k + 22 * k) b.text('GRID', area.x0 + 2.5 * k, py + 0.8 * k, `X=${fmt(gy, 0)}`, fs, { color: '#475569' });
b.text('GRID', area.x1 - 2.5 * k, py + 0.8 * k, `X=${fmt(gy, 0)}`, fs, { anchor: 'end', color: '#475569' });
}
}
// Pipes: width grows with diameter; closed pipes dashed.
for (const l of input.links) {
if (l.kind !== 'pipe') continue;
for (const piece of clipPolyline(l.pts.map(([x, y]) => P(x, y)), area)) b.line('PIPE', piece, { width: pipeWidth(l.diameter ?? 100, k), color: l.color, dash: l.closed ? 'dashed' : undefined });
}
// Valves / pumps oriented along their link (or the neighbouring pipe).
const dirAt = (l: PlanLink): number => {
const [ax, ay] = P(...l.pts[0]), [bx, by] = P(...l.pts[l.pts.length - 1]);
if (Math.hypot(bx - ax, by - ay) > 0.2) return Math.atan2(by - ay, bx - ax);
const near = input.links.find((o) => o.kind === 'pipe' && o.pts.length >= 2 && [o.pts[0], o.pts[o.pts.length - 1]].some((p) => Math.hypot(p[0] - l.pts[0][0], p[1] - l.pts[0][1]) < 1e-6));
if (!near) return 0;
const [cx2, cy2] = P(...near.pts[0]), [dx2, dy2] = P(...near.pts[1]);
return Math.atan2(dy2 - cy2, dx2 - cx2);
};
const usedKinds = new Set<string>();
const valveLabels: Array<{ x: number; y: number; nx: number; ny: number; text: string }> = [];
const symbolBoxes: Array<[number, number, number, number]> = [];
for (const l of input.links) {
if (l.kind === 'pipe') continue;
const pp = l.pts.map(([x, y]) => P(x, y));
const [ax, ay] = pp[0], [bx, by] = pp[pp.length - 1];
const c: [number, number] = [(ax + bx) / 2, (ay + by) / 2];
if (!inside(c)) continue;
b.line(l.kind === 'pump' ? 'PUMP' : 'VALVE', pp, { width: 0.4 * k, color: '#111111' });
const a = dirAt(l), ux = Math.cos(a), uy = Math.sin(a), nx = -uy, ny = ux;
const s = 1.5 * k;
if (l.kind === 'valve') {
const filled = ['PRV', 'PSV', 'PBV'].includes(l.subtype ?? '');
usedKinds.add(filled ? 'valveF' : 'valve');
for (const sg of [-1, 1]) {
const tri: Array<[number, number]> = [[c[0] + sg * ux * s + nx * s * 0.75, c[1] + sg * uy * s + ny * s * 0.75], [c[0] + sg * ux * s - nx * s * 0.75, c[1] + sg * uy * s - ny * s * 0.75], c];
b.fill('VALVE', tri, filled ? '#111111' : '#ffffff');
b.line('VALVE', tri, { width: 0.25, closed: true });
}
if (l.subtype) valveLabels.push({ x: c[0], y: c[1], nx, ny, text: l.subtype });
symbolBoxes.push([c[0] - s, c[1] - s, c[0] + s, c[1] + s]);
} else {
usedKinds.add('pump');
b.circle('PUMP', c[0], c[1], s * 1.15, { fill: '#ffffff', color: '#111111', width: 0.3 });
symbolBoxes.push([c[0] - s * 1.2, c[1] - s * 1.2, c[0] + s * 1.2, c[1] + s * 1.2]);
b.fill('PUMP', [[c[0] - ux * s * 0.5 + nx * s * 0.6, c[1] - uy * s * 0.5 + ny * s * 0.6], [c[0] - ux * s * 0.5 - nx * s * 0.6, c[1] - uy * s * 0.5 - ny * s * 0.6], [c[0] + ux * s * 0.7, c[1] + uy * s * 0.7]], '#111111');
}
}
// Label collision boxes: map furniture first.
const boxes: Array<[number, number, number, number]> = [...symbolBoxes];
const free = (x0: number, y0: number, x1: number, y1: number) => x0 > area.x0 && x1 < area.x1 && y0 > area.y0 && y1 < area.y1 && boxes.every(([a, c, e, g]) => x1 < a || x0 > e || y1 < c || y0 > g);
// Nodes: small junction dots, problem nodes emphasised, sources as symbols.
for (const n of input.nodes) {
const [x, y] = P(n.x, n.y);
if (!inside([x, y])) continue;
if (n.type === 'RESERVOIR') {
b.fill('SOURCE', [[x - 2.4 * k, y - 1.7 * k], [x + 2.4 * k, y - 1.7 * k], [x + 2.4 * k, y + 1.7 * k], [x - 2.4 * k, y + 1.7 * k]], '#0369a1');
b.rect('SOURCE', x - 2.4 * k, y - 1.7 * k, 4.8 * k, 3.4 * k, { width: 0.3 });
boxes.push([x - 2.6 * k, y - 1.9 * k, x + 2.6 * k, y + 1.9 * k]);
} else if (n.type === 'TANK') {
b.circle('SOURCE', x, y, 2.1 * k, { fill: '#0369a1', color: '#111111', width: 0.3 });
boxes.push([x - 2.3 * k, y - 2.3 * k, x + 2.3 * k, y + 2.3 * k]);
} else if (n.alert) {
b.circle('NODE_ALERT', x, y, 1.2 * k, { color: n.color, width: 0.3 });
boxes.push([x - 1.3 * k, y - 1.3 * k, x + 1.3 * k, y + 1.3 * k]);
b.circle('NODE_ALERT', x, y, 0.6 * k, { fill: n.color, color: n.color, width: 0.1 });
} else {
b.circle('NODE', x, y, 0.32 * k, { fill: '#475569', color: '#475569', width: 0.05 });
boxes.push([x - 0.5 * k, y - 0.5 * k, x + 0.5 * k, y + 0.5 * k]);
}
}
// North arrow (compass style, top-right inside the map frame).
{
const nx = area.x1 - 13 * k, ny = area.y1 - 15 * k, r = 6 * k;
b.circle('NORTH', nx, ny, r * 0.55, { width: 0.25 });
b.fill('NORTH', [[nx, ny + r], [nx - r * 0.22, ny], [nx, ny]], '#111111');
b.line('NORTH', [[nx, ny + r], [nx + r * 0.22, ny], [nx, ny]], { width: 0.25, closed: true });
b.fill('NORTH', [[nx, ny - r], [nx + r * 0.22, ny], [nx, ny]], '#111111');
b.line('NORTH', [[nx, ny - r], [nx - r * 0.22, ny], [nx, ny]], { width: 0.25, closed: true });
b.line('NORTH', [[nx - r * 0.8, ny], [nx + r * 0.8, ny]], { width: 0.2 });
b.text('NORTH', nx, ny + r + 2.2 * k, 'N', 3.2 * k, { anchor: 'middle', vAnchor: 'middle', bold: true });
boxes.push([nx - r, ny - r, nx + r, ny + r + 4 * k]);
}
// Legend panel (bottom-left inside the map frame) with the scale bar below.
const rowH = 3.9 * k;
type Item = { label: string; draw: (x: number, y: number) => void; group: string; h?: number };
const pipeHead = /관경/.test(input.legendTitle) ? '관경' : '유속';
const items: Item[] = input.legend.map((it) => ({
label: it.label, group: it.shape === 'line' ? pipeHead : '수압',
draw: (x, y) => (it.shape === 'line'
? b.line('LEGEND', [[x, y], [x + 10 * k, y]], { width: it.diameter ? pipeWidth(it.diameter, k) : 0.7 * k, color: it.color })
: b.circle('LEGEND', x + 5 * k, y, 0.9 * k, { fill: it.color, color: '#ffffff', width: 0.15 })),
}));
const fac = (label: string, draw: Item['draw']) => items.push({ label, draw, group: '시설' });
if (input.nodes.some((n) => n.type === 'RESERVOIR')) fac('배수지·정수지', (x, y) => { b.fill('LEGEND', [[x + 3 * k, y - 1.3 * k], [x + 7 * k, y - 1.3 * k], [x + 7 * k, y + 1.3 * k], [x + 3 * k, y + 1.3 * k]], '#0369a1'); });
if (input.nodes.some((n) => n.type === 'TANK')) fac('탱크', (x, y) => b.circle('LEGEND', x + 5 * k, y, 1.5 * k, { fill: '#0369a1', color: '#111111', width: 0.3 }));
const bow = (x: number, y: number, fill: string) => {
const c = x + 5 * k, s = 1.4 * k;
for (const sg of [-1, 1]) {
const t: Array<[number, number]> = [[c + sg * s, y + s * 0.75], [c + sg * s, y - s * 0.75], [c, y]];
b.fill('LEGEND', t, fill);
b.line('LEGEND', t, { width: 0.25, closed: true });
}
};
if (usedKinds.has('valveF')) fac('감압·지속밸브 (PRV 등)', (x, y) => bow(x, y, '#111111'));
if (usedKinds.has('valve')) fac('조절밸브 (FCV·TCV 등)', (x, y) => bow(x, y, '#ffffff'));
if (usedKinds.has('pump')) fac('가압펌프', (x, y) => { b.circle('LEGEND', x + 5 * k, y, 1.6 * k, { fill: '#ffffff', color: '#111111', width: 0.3 }); b.fill('LEGEND', [[x + 4.3 * k, y - 0.9 * k], [x + 4.3 * k, y + 0.9 * k], [x + 6.1 * k, y]], '#111111'); });
if (input.links.some((l) => l.closed)) items.push({ label: '폐쇄 관로', group: pipeHead, draw: (x, y) => b.line('LEGEND', [[x, y], [x + 10 * k, y]], { width: 0.4 * k, color: '#64748b', dash: 'dashed' }) });
// Sample panel: how a pipe label and a pressure tag read (as on practice drawings).
const withTags = !!input.pressureTags && input.pressureTags !== 'none';
const withPipe = input.pipeLabels !== 'none';
const sample = withTags || withPipe ? {
h: (withPipe ? 9 * k : 0) + (withTags ? 11 * k : 0) + 2 * k,
draw: (x0: number, y0: number, w: number, h: number) => {
let y = y0 + h - 1 * k;
if (withPipe) {
const cyy = y - 5 * k, ax = x0 + 10 * k, bx = x0 + w - 10 * k;
b.line('LEGEND', [[ax, cyy], [bx, cyy]], { width: 0.35, color: input.legend.find((it) => it.shape === 'line')?.color ?? '#1d4ed8' });
b.circle('LEGEND', ax, cyy, 0.9 * k, { fill: '#111111', color: '#111111' });
b.circle('LEGEND', bx, cyy, 0.9 * k, { fill: '#111111', color: '#111111' });
const lab = input.pipeLabels === 'IDDL' ? 'P-1-D200 L=85.30m' : input.pipeLabels === 'DL' ? 'D200 L=85.30m' : 'D200';
b.text('LEGEND', (ax + bx) / 2, cyy + 1.6 * k, lab, 1.9 * k, { anchor: 'middle', color: '#111111' });
const cap = input.pipeLabels === 'IDDL' ? '관로번호 - 관경(mm) 연장(m)' : input.pipeLabels === 'DL' ? '관경(mm) 연장(m)' : '관경(mm)';
b.text('LEGEND', (ax + bx) / 2, cyy - 1.4 * k, cap, 1.5 * k, { anchor: 'middle', vAnchor: 'top', color: '#475569' });
y -= 9 * k;
}
if (withTags) {
const unit = input.pressureTags === 'kgf' ? 'kg/cm²' : 'm';
const R = 3.4 * k, cx0 = x0 + 14 * k, cyy = y - 5.5 * k;
b.circle('LEGEND', cx0, cyy, R, { fill: '#e7e2dc', color: '#22d3ee', width: 0.25 });
b.line('LEGEND', [[cx0 - R, cyy], [cx0 + R, cyy]], { width: 0.15, color: '#22d3ee' });
b.text('LEGEND', cx0, cyy + R * 0.45, '동수두', 1.25 * k, { anchor: 'middle', vAnchor: 'middle' });
b.text('LEGEND', cx0, cyy - R * 0.45, '지반고', 1.25 * k, { anchor: 'middle', vAnchor: 'middle' });
b.line('LEGEND', [[cx0 + R, cyy], [cx0 + R + 18 * k, cyy]], { width: 0.15, color: '#22d3ee' });
b.text('LEGEND', cx0 + R + 1 * k, cyy + 1 * k, `수압 (${unit})`, 1.7 * k, { vAnchor: 'middle', color: '#1d4ed8' });
b.text('LEGEND', cx0 + R + 1 * k, cyy - 1.2 * k, '유출량 (m³/d)', 1.7 * k, { vAnchor: 'middle', color: '#1d4ed8' });
b.text('LEGEND', cx0 - R - 1.2 * k, cyy, '절점', 1.5 * k, { anchor: 'end', vAnchor: 'middle', color: '#475569' });
}
},
} : undefined;
// Stable group order: pipes, nodes, facilities.
const order = [pipeHead, '수압', '시설'];
items.sort((a, c) => order.indexOf(a.group) - order.indexOf(c.group));
const lw = 64 * k;
const lx = area.x0 + 4 * k;
let base = area.y0 + 4 * k;
if (input.metric) {
const target = (40 * k * scale) / 1000;
const mag = Math.pow(10, Math.floor(Math.log10(target)));
const len = [1, 2, 5, 10].map((m) => m * mag).filter((v) => v <= target).pop() ?? mag;
const bw = (len * 1000) / scale;
const x0 = lx + 2 * k, y0 = base + 3 * k;
b.fill('SCALEBAR', [[lx, base], [lx + Math.max(lw, bw + 14 * k), base], [lx + Math.max(lw, bw + 14 * k), base + 11 * k], [lx, base + 11 * k]], '#ffffff', 0.9);
for (let i = 0; i < 4; i++) {
const seg: Array<[number, number]> = [[x0 + (bw / 4) * i, y0], [x0 + (bw / 4) * (i + 1), y0], [x0 + (bw / 4) * (i + 1), y0 + 1.3 * k], [x0 + (bw / 4) * i, y0 + 1.3 * k]];
if (i % 2 === 0) b.fill('SCALEBAR', seg, '#111111');
b.line('SCALEBAR', seg, { width: 0.2, closed: true });
}
b.text('SCALEBAR', x0, y0 + 2.6 * k, '0', 1.7 * k, { anchor: 'middle' });
b.text('SCALEBAR', x0 + bw / 2, y0 + 2.6 * k, fmt(len / 2, 1), 1.7 * k, { anchor: 'middle' });
b.text('SCALEBAR', x0 + bw, y0 + 2.6 * k, fmt(len, 0), 1.7 * k, { anchor: 'middle' });
b.text('SCALEBAR', x0 + bw + 2.5 * k, y0 + 0.65 * k, '(m)', 1.7 * k, { vAnchor: 'middle' });
b.text('SCALEBAR', x0, y0 - 1.6 * k, scaleTxt, 1.8 * k, { vAnchor: 'top', bold: true });
base += 13 * k;
}
const groupsArr = order.map((name) => ({ name, rows: items.filter((it) => it.group === name).map((it) => ({ label: it.label, draw: it.draw })) }));
const leg = legendTable(b, 'LEGEND', groupsArr, { w: lw, sample, style: input.legendStyle ?? 'table', band: input.legendBand });
if (leg.h) {
boxes.push([lx - 1 * k, area.y0, lx + lw + 2 * k, base + leg.h + 3 * k]);
leg.draw(lx, base + 1 * k);
}
// Index sheet: tile rectangles and numbers.
for (const t of input.indexTiles ?? []) {
const [x0, y0] = P(t.minX, t.minY), [x1, y1] = P(t.maxX, t.maxY);
b.fill('INDEX', [[x0, y0], [x1, y0], [x1, y1], [x0, y1]], '#fee2e2', 0.25);
b.rect('INDEX', x0, y0, x1 - x0, y1 - y0, { width: 0.35, color: '#dc2626' });
const size = Math.min(5 * k, Math.max(2.2 * k, (y1 - y0) * 0.3));
const tw = t.name.length * size * 0.65 + 2 * k;
const cx0 = (x0 + x1) / 2, cy0 = (y0 + y1) / 2;
b.fill('INDEX', [[cx0 - tw / 2, cy0 - size * 0.7], [cx0 + tw / 2, cy0 - size * 0.7], [cx0 + tw / 2, cy0 + size * 0.7], [cx0 - tw / 2, cy0 + size * 0.7]], '#ffffff', 0.85);
b.text('INDEX', cx0, cy0, t.name, size, { anchor: 'middle', vAnchor: 'middle', bold: true, color: '#dc2626' });
boxes.push([cx0 - tw / 2, cy0 - size * 0.7, cx0 + tw / 2, cy0 + size * 0.7]);
}
// Location inset (위치도) at the bottom-right of detail sheets.
if (input.keyMap && input.keyMap.tiles.length) {
const km = input.keyMap;
const kw = 56 * k, kh = 38 * k, kx = area.x1 - kw - 3 * k, ky = area.y0 + 3 * k;
panelHeader(b, 'KEYMAP', kx, ky + kh, kw, kh, '위 치 도');
let ax0 = Infinity, ay0 = Infinity, ax1 = -Infinity, ay1 = -Infinity;
for (const t of km.tiles) { ax0 = Math.min(ax0, t.minX); ay0 = Math.min(ay0, t.minY); ax1 = Math.max(ax1, t.maxX); ay1 = Math.max(ay1, t.maxY); }
const sc = Math.max((ax1 - ax0) / (kw - 6 * k), (ay1 - ay0) / (kh - 6 * k)) || 1;
const kcx = kx + kw / 2, kcy = ky + kh / 2, mcx = (ax0 + ax1) / 2, mcy = (ay0 + ay1) / 2;
const Q = (x: number, y: number): [number, number] => [kcx + (x - mcx) / sc, kcy + (y - mcy) / sc];
for (const line of km.lines) b.line('KEYMAP', line.map(([x, y]) => Q(x, y)), { width: 0.12, color: '#64748b' });
for (const t of km.tiles) {
const [x0, y0] = Q(t.minX, t.minY), [x1, y1] = Q(t.maxX, t.maxY);
if (t.no === km.current) b.fill('KEYMAP', [[x0, y0], [x1, y0], [x1, y1], [x0, y1]], '#dc2626', 0.35);
b.rect('KEYMAP', x0, y0, x1 - x0, y1 - y0, { width: t.no === km.current ? 0.35 : 0.1, color: t.no === km.current ? '#dc2626' : '#94a3b8' });
if (km.tiles.length <= 60 && (x1 - x0) > 2.2 * k) b.text('KEYMAP', (x0 + x1) / 2, (y0 + y1) / 2, t.name, Math.min(1.6 * k, (y1 - y0) * 0.6), { anchor: 'middle', vAnchor: 'middle', color: t.no === km.current ? '#dc2626' : '#64748b', bold: t.no === km.current });
}
boxes.push([kx - 1 * k, area.y0, area.x1, ky + kh + 6 * k]);
}
// Adjacent sheet references (접속도면): edge middles and corners, by grid direction.
for (const nb of input.neighbours ?? []) {
const corner = nb.dx !== 0 && nb.dy !== 0;
const arrow = corner ? (nb.dy > 0 ? (nb.dx > 0 ? '↗' : '↖') : nb.dx > 0 ? '↘' : '↙') : nb.dx > 0 ? '▶' : nb.dx < 0 ? '◀' : nb.dy > 0 ? '▲' : '▼';
const text = corner ? `${arrow} ${nb.name}` : `${arrow} 도면 ${nb.name} 연결`;
const size = 2 * k, tw = text.length * size * 0.7 + 2 * k, th = size * 1.6;
const pos = (d: number, lo: number, hi: number, half: number) => (d < 0 ? lo + half + 1.5 * k : d > 0 ? hi - half - 1.5 * k : (lo + hi) / 2);
const bx = pos(nb.dx, area.x0, area.x1, tw / 2), by = pos(nb.dy, area.y0, area.y1, th / 2);
// Edge refs slide along their edge, corner refs inward, until clear of the legend / inset / other refs.
let ex = bx, ey = by;
for (const sh of [0, 1, -1, 2, -2, 3, -3, 4, -4, 6, -6, 8, -8, 10, -10, 12, -12, 14, -14, 16, -16].map((v) => v * 6 * k)) {
if (corner && sh < 0) continue;
const qx = nb.dx === 0 ? Math.min(Math.max(bx + sh, area.x0 + tw / 2 + 2 * k), area.x1 - tw / 2 - 2 * k) : bx;
const qy = nb.dx === 0 ? by : corner ? by - Math.sign(nb.dy) * sh : Math.min(Math.max(by + sh, area.y0 + th), area.y1 - th);
if (boxes.every(([a0, c0, e0, g0]) => qx + tw / 2 < a0 || qx - tw / 2 > e0 || qy + th / 2 < c0 || qy - th / 2 > g0)) { ex = qx; ey = qy; break; }
}
b.fill('MATCH', [[ex - tw / 2, ey - th / 2], [ex + tw / 2, ey - th / 2], [ex + tw / 2, ey + th / 2], [ex - tw / 2, ey + th / 2]], '#ffffff', 0.9);
b.rect('MATCH', ex - tw / 2, ey - th / 2, tw, th, { width: 0.25, color: '#7c3aed' });
b.text('MATCH', ex, ey, text, size, { anchor: 'middle', vAnchor: 'middle', bold: true, color: '#7c3aed' });
boxes.push([ex - tw / 2, ey - th / 2, ex + tw / 2, ey + th / 2]);
}
// Source labels (always win), then pressure tags, pipe labels, junction labels.
for (const n of input.nodes) {
const show = input.nodeLabels === 'all' || (input.nodeLabels === 'sources' && n.type !== 'JUNCTION');
if (!show || n.type === 'JUNCTION') continue;
const [x, y] = P(n.x, n.y);
const t = n.label ?? n.id, size = 2.6 * k;
const tw = t.length * size * 0.75 + 2 * k;
// First corner (NE, NW, SE, SW) where the box does not hit nodes or symbols.
const corners: Array<[number, number]> = [[x + 3.2 * k, y + 2.2 * k], [x - 3.2 * k - tw, y + 2.2 * k], [x + 3.2 * k, y - 2.2 * k], [x - 3.2 * k - tw, y - 2.2 * k], [x - tw / 2, y + 4.5 * k], [x - tw / 2, y - 4.5 * k]];
const [bx0, by] = corners.find(([cx0, cy0]) => free(cx0, cy0 - size * 0.75, cx0 + tw, cy0 + size * 0.75)) ?? corners[0];
b.fill('SOURCE', [[bx0, by - size * 0.75], [bx0 + tw, by - size * 0.75], [bx0 + tw, by + size * 0.75], [bx0, by + size * 0.75]], '#ffffff', 0.9);
b.rect('SOURCE', bx0, by - size * 0.75, tw, size * 1.5, { width: 0.3, color: '#0369a1' });
b.text('SOURCE', bx0 + tw / 2, by, t, size, { anchor: 'middle', vAnchor: 'middle', bold: true, color: '#0c4a6e' });
boxes.push([bx0, by - size * 0.75, bx0 + tw, by + size * 0.75]);
}
let tagsSkipped = 0;
if (input.pressureTags && input.pressureTags !== 'none') {
const R = 3.2 * k, fs = 1.6 * k;
const kgf = input.pressureTags === 'kgf';
const dirs = [45, 135, -45, -135, 90, -90, 0, 180].map((d) => (d * Math.PI) / 180);
for (const n of input.nodes) {
if (n.type !== 'JUNCTION' || n.pressure === undefined || !Number.isFinite(n.pressure)) continue;
const [x, y] = P(n.x, n.y);
if (!inside([x, y])) continue;
const pTxt = `${(kgf ? n.pressure / 10 : n.pressure).toFixed(2)} ${kgf ? 'kg/cm²' : 'm'}`;
const dTxt = n.demand !== undefined && Math.abs(n.demand) > 1e-6 ? n.demand.toFixed(2) : '-';
const tw = Math.max(pTxt.length, dTxt.length) * fs * 0.56 + 1.2 * k;
let spot: [number, number] | null = null;
for (const dist of [R + 1.6 * k, R + 4 * k, R + 7.5 * k]) {
for (const a of dirs) {
const cx0 = x + Math.cos(a) * dist, cy0 = y + Math.sin(a) * dist;
if (free(cx0 - R, cy0 - R, cx0 + R + tw, cy0 + R)) { spot = [cx0, cy0]; break; }
}
if (spot) break;
}
if (!spot) { tagsSkipped++; continue; }
const [tx, ty] = spot;
boxes.push([tx - R, ty - R, tx + R + tw, ty + R]);
const d = Math.hypot(tx - x, ty - y);
b.line('PRESSURE', [[x, y], [tx - ((tx - x) / d) * R, ty - ((ty - y) / d) * R]], { width: 0.12, color: '#0891b2' });
b.circle('PRESSURE', tx, ty, R, { fill: '#e7e2dc', color: '#22d3ee', width: 0.15 });
b.line('PRESSURE', [[tx - R, ty], [tx + R + tw, ty]], { width: 0.12, color: '#22d3ee' });
const blue = '#1d4ed8';
b.text('PRESSURE', tx, ty + R * 0.47, n.head !== undefined ? n.head.toFixed(2) : '-', fs, { anchor: 'middle', vAnchor: 'middle', color: blue });
b.text('PRESSURE', tx, ty - R * 0.47, n.elevation !== undefined ? n.elevation.toFixed(2) : '-', fs, { anchor: 'middle', vAnchor: 'middle', color: blue });
b.text('PRESSURE', tx + R + 0.8 * k, ty + R * 0.47, pTxt, fs, { vAnchor: 'middle', color: n.alert ? '#dc2626' : blue, bold: !!n.alert });
b.text('PRESSURE', tx + R + 0.8 * k, ty - R * 0.47, dTxt, fs, { vAnchor: 'middle', color: blue });
}
}
if (input.pipeLabels !== 'none') {
const minMm = input.labelMinMm ?? 18 * k;
const stops = new Set(input.nodes.filter((n) => n.type !== 'JUNCTION').map((n) => `${n.x.toFixed(3)},${n.y.toFixed(3)}`));
const groups = input.view
? input.links.filter((l) => l.kind === 'pipe' && l.diameter).map((l) => ({ links: [l], length: l.length ?? 0, diameter: l.diameter! }))
: pipeChains(input.links, stops);
const chains = groups.map((c) => {
// Longest single leg on paper across the chain.
let best = 0, seg: [[number, number], [number, number]] | null = null, tot = 0;
for (const l of c.links) {
const pp = l.pts.map(([x, y]) => P(x, y));
for (let i = 0; i < pp.length - 1; i++) {
const d = Math.hypot(pp[i + 1][0] - pp[i][0], pp[i + 1][1] - pp[i][1]);
const mid: [number, number] = [(pp[i][0] + pp[i + 1][0]) / 2, (pp[i][1] + pp[i + 1][1]) / 2];
if (!inside(mid)) continue;
tot += d;
if (d > best) { best = d; seg = [pp[i], pp[i + 1]]; }
}
}
return { ...c, best, seg, tot };
}).filter((c) => c.seg && c.tot >= minMm).sort((a, b2) => b2.diameter - a.diameter || b2.tot - a.tot);
for (const c of chains) {
const [a, e] = c.seg!;
const dl = `D${c.diameter.toFixed(0)} L=${fmt(c.length, input.view ? 2 : 0)}m`;
const text = input.pipeLabels === 'IDDL' && c.length ? `${c.links[0].id}-${dl}` : input.pipeLabels === 'DL' && c.length ? dl : `D${c.diameter.toFixed(0)}`;
const size = (c.diameter >= 300 ? 2 : 1.75) * k;
const half = text.length * size * 0.3;
// Along the longest straight leg when it fits; otherwise (winding pipe)
// horizontal beside it as long as the whole stretch is long enough.
if (half * 2 > c.tot * 0.9) continue;
let ang = half * 2 <= c.best * 1.15 ? (Math.atan2(e[1] - a[1], e[0] - a[0]) * 180) / Math.PI : 0;
if (ang > 90) ang -= 180;
if (ang < -90) ang += 180;
const mxp = (a[0] + e[0]) / 2, myp = (a[1] + e[1]) / 2;
const r = (ang * Math.PI) / 180;
const off = [-Math.sin(r) * (size * 0.9 + 0.6 * k), Math.cos(r) * (size * 0.9 + 0.6 * k)];
// Rotated label extents (axis-aligned box of the rotated rectangle).
const ex = Math.abs(Math.cos(r)) * half + Math.abs(Math.sin(r)) * size * 0.55;
const ey = Math.abs(Math.sin(r)) * half + Math.abs(Math.cos(r)) * size * 0.55;
const spot = [1, -1].map((sg) => [mxp + sg * off[0], myp + sg * off[1]] as const).find(([qx, qy]) => free(qx - ex, qy - ey, qx + ex, qy + ey));
if (!spot) continue;
const [lx, ly] = spot;
boxes.push([lx - ex, ly - ey, lx + ex, ly + ey]);
b.text('PIPE_LABEL', lx, ly, text, size, { anchor: 'middle', vAnchor: 'middle', rotate: ang, color: '#1e293b', bold: c.diameter >= 300 });
}
}
for (const v of valveLabels) {
const size = 1.45 * k, hw = v.text.length * size * 0.33;
const spot = [1, -1].map((sg) => [v.x + sg * v.nx * 2.8 * k, v.y + sg * v.ny * 2.8 * k] as const).find(([qx, qy]) => free(qx - hw, qy - size * 0.6, qx + hw, qy + size * 0.6));
if (!spot) continue;
boxes.push([spot[0] - hw, spot[1] - size * 0.6, spot[0] + hw, spot[1] + size * 0.6]);
b.text('VALVE', spot[0], spot[1], v.text, size, { anchor: 'middle', vAnchor: 'middle', bold: true, color: '#7c2d12' });
}
if (input.nodeLabels === 'all') {
for (const n of input.nodes) {
if (n.type !== 'JUNCTION') continue;
const [x, y] = P(n.x, n.y);
if (!inside([x, y])) continue;
const size = 1.5 * k;
const bx0 = x + 1.2 * k, bx1 = bx0 + n.id.length * size * 0.62, by0 = y - size / 2, by1 = y + size;
if (!free(bx0, by0, bx1, by1)) continue;
boxes.push([bx0, by0, bx1, by1]);
b.text('NODE_LABEL', bx0, y, n.id, size, { vAnchor: 'middle', color: n.alert ? n.color : '#475569' });
}
}
return { drawing: b.build(input.font), scale, tagsSkipped };
}
/** Map window of a sheet (mm on paper) for the given sheet size. */
function mapArea(sheetKey: keyof typeof SHEETS, info: Omit<TitleInfo, 'scale'>) {
const sheet = SHEETS[sheetKey];
const k = sheet.w / 420;
const tmp = new DrawingBuilder(sheet.w, sheet.h, '');
const a = sheetFrame(tmp, { ...info, scale: '' });
return { w: a.x1 - a.x0, h: a.y1 - 17 * k - a.y0, k };
}
const DETAIL_SCALES = [500, 600, 1000, 1200, 1500, 2000, 2500, 3000, 4000, 5000, 6000, 10000, 15000, 20000];
/** Excel-style row letters: 0 -> A, 25 -> Z, 26 -> AA. */
function rowLetters(n: number): string {
let r = '';
for (let m = n + 1; m > 0; m = Math.floor((m - 1) / 26)) r = String.fromCharCode(65 + ((m - 1) % 26)) + r;
return r;
}
/**
* Split the network into detail sheets at one scale on a regular sheet grid
* (map book / atlas practice, as QGIS AtlasGrid, ATLAS Index Pro and ArcGIS
* Grid Index Features do): every sheet is the full map window, neighbouring
* sheets overlap by `overlap` of the window, the grid is shifted over a few
* offsets and the one needing the fewest sheets wins (ties: closest to
* centred on the network), and only cells the network passes through are
* kept. A cell is claimed by what falls in its core (window minus half the
* overlap on each side), so pipes that only touch the overlap strip do not
* add a sheet. Sheets are numbered row by row from the north-west; `name` is
* the serial number or the grid reference (A-1: row letter from the north,
* column number from the west). `auto` picks the scale from the typical
* junction spacing (room for a pressure tag) and coarsens it while the sheet
* count exceeds `maxSheets`.
*/
export function planLayout(
input: Pick<PlanSheetInput, 'sheet' | 'info' | 'links' | 'nodes'>,
opts: { scale: number | 'auto'; maxSheets?: number; overlap?: number; naming?: 'serial' | 'grid' },
): { scale: number; tiles: PlanTile[] } {
const { w, h, k } = mapArea(input.sheet, input.info);
const overlap = opts.overlap ?? 0.06;
const pts: Array<[number, number]> = input.nodes.map((n) => [n.x, n.y]);
for (const l of input.links) for (const p of l.pts) pts.push(p);
if (!pts.length) throw new Error('그릴 관망이 없습니다.');
const segs = input.links.flatMap((l) => l.pts.slice(1).map((p, i) => [l.pts[i], p] as const));
const cover = (scale: number): PlanTile[] => {
const fw = (w * scale) / 1000, fh = (h * scale) / 1000; // sheet window (model units)
const sw = fw * (1 - overlap), sh = fh * (1 - overlap); // grid step = core size
// densify long segments so a pipe crossing a cell claims it
const all = [...pts];
const step = Math.min(sw, sh) / 8;
for (const [a, c] of segs) {
const L = Math.hypot(c[0] - a[0], c[1] - a[1]);
for (let t = step; t < L; t += step) all.push([a[0] + ((c[0] - a[0]) * t) / L, a[1] + ((c[1] - a[1]) * t) / L]);
}
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const [x, y] of all) { x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y); }
// Offset that centres the grid on the network, then shifts around it.
const cx0 = x0 - (Math.max(1, Math.ceil((x1 - x0) / sw)) * sw - (x1 - x0)) / 2;
const cy0 = y0 - (Math.max(1, Math.ceil((y1 - y0) / sh)) * sh - (y1 - y0)) / 2;
let best: { cells: Set<string>; gx: number; gy: number; off: number } | null = null;
const shifts = [0, 0.125, -0.125, 0.25, -0.25, 0.375, -0.375, 0.5];
for (const ox of shifts) for (const oy of shifts) {
const gx = cx0 + ox * sw, gy = cy0 + oy * sh;
const cells = new Set<string>();
for (const [x, y] of all) cells.add(`${Math.floor((x - gx) / sw)},${Math.floor((y - gy) / sh)}`);
const off = Math.abs(ox) + Math.abs(oy);
if (!best || cells.size < best.cells.size || (cells.size === best.cells.size && off < best.off)) best = { cells, gx, gy, off };
}
const { cells, gx, gy } = best!;
const ij = [...cells].map((c) => c.split(',').map(Number) as [number, number]);
const imin = Math.min(...ij.map(([i]) => i)), jmax = Math.max(...ij.map(([, j]) => j));
ij.sort((a, b) => b[1] - a[1] || a[0] - b[0]); // rows from the north, west to east
const mx = (fw - sw) / 2, my = (fh - sh) / 2;
return ij.map(([i, j], n) => ({
no: n + 1, i, j,
name: opts.naming === 'grid' ? `${rowLetters(jmax - j)}-${i - imin + 1}` : String(n + 1),
minX: gx + i * sw - mx, maxX: gx + (i + 1) * sw + mx, minY: gy + j * sh - my, maxY: gy + (j + 1) * sh + my,
}));
};
const max = opts.maxSheets ?? 40;
if (opts.scale !== 'auto') return { scale: opts.scale, tiles: cover(opts.scale) };
// Typical junction spacing (median nearest neighbour, sampled).
const js = input.nodes.filter((n) => n.type === 'JUNCTION');
const sample = js.length > 1500 ? js.filter((_, i) => i % Math.ceil(js.length / 1500) === 0) : js;
const nn: number[] = [];
for (const a of sample) {
let m = Infinity;
for (const c of js) if (c !== a) { const d = Math.hypot(c.x - a.x, c.y - a.y); if (d > 1e-6 && d < m) m = d; }
if (Number.isFinite(m)) nn.push(m);
}
nn.sort((a, b) => a - b);
const med = nn.length ? nn[Math.floor(nn.length / 2)] : 100;
const want = (med * 1000) / (16 * k); // room for one pressure tag between neighbours
let idx = Math.max(0, DETAIL_SCALES.filter((sc) => sc <= want).length - 1);
let tiles = cover(DETAIL_SCALES[idx]);
while (tiles.length > max && idx < DETAIL_SCALES.length - 1) tiles = cover(DETAIL_SCALES[++idx]);
return { scale: DETAIL_SCALES[idx], tiles };
}
/** The (up to 8) grid neighbours of a sheet, with their direction. */
export function tileNeighbours(tiles: PlanTile[], t: PlanTile): PlanNeighbour[] {
return tiles.filter((o) => o !== t && Math.abs(o.i - t.i) <= 1 && Math.abs(o.j - t.j) <= 1)
.map((o) => ({ no: o.no, name: o.name, dx: o.i - t.i, dy: o.j - t.j }));
}
+376
View File
@@ -0,0 +1,376 @@
// 수리 종단면도 (hydraulic profile sheet) in the style of a Korean design drawing
// (e.g. 국사봉배수지 C1-003, 마전배수지 동수두도): ground / pipe / hydraulic grade
// lines over a station table (측점·절점번호·거리·추가거리·지반고·관저고·동수두·수압)
// with pipe runs (D, L) in the bottom row, facility symbols (배수지 ▽H.W.L,
// 감압밸브, 가압펌프) and pressures below the design minimum in red.
import { DrawingBuilder, legendTable, niceScale, panelHeader, sheetFrame, sheetHeading, SHEETS, type Drawing, type LegendStyle, type TitleInfo } from './drawing.ts';
export interface ProfileStation {
id: string;
/** Cumulative distance from the start (m). */
x: number;
ground: number;
invert: number;
/** Hydraulic head per series, same order as `series`. */
heads: number[];
pressure?: number;
kind?: 'reservoir' | 'tank' | 'junction';
/** Water levels for reservoirs / tanks (m). */
hwl?: number;
lwl?: number;
}
export interface ProfileRun {
fromX: number;
toX: number;
diameter: number; // mm
length: number; // m
roughness?: number;
flow?: number;
flowUnit?: string;
/** Hydraulic gradient (‰) along the run. */
gradient?: number;
}
export interface ProfileSeries {
name: string;
color: string;
dash?: 'dashed' | 'dotted';
}
/** Valves and pumps on the path (zero-length links between two stations). */
export interface ProfileFitting {
x: number;
kind: 'valve' | 'pump';
subtype?: string;
label: string;
}
export interface ProfileSheetInput {
sheet: keyof typeof SHEETS;
info: Omit<TitleInfo, 'scale'>;
stations: ProfileStation[];
runs: ProfileRun[];
series: ProfileSeries[];
/** Optional named points above the HGL (e.g. reservoirs). */
marks?: Array<{ x: number; text: string }>;
fittings?: ProfileFitting[];
/** Design minimum pressure (m): lower values are drawn red. */
minPressure?: number;
font?: string;
legendStyle?: LegendStyle;
legendBand?: string;
}
const fx = (v: number, d = 2) => (Number.isFinite(v) ? v.toFixed(d) : '-');
const fxc = (v: number, d = 2) => (Number.isFinite(v) ? v.toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d }) : '-');
const RED = '#dc2626';
function niceStep(range: number, target = 10): number {
const raw = range / target;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const n = raw / mag;
return (n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10) * mag;
}
/** Stations whose labels fit side by side (first, last and run ends win). */
export function labelledStations(xs: number[], priority: boolean[], minGap: number): boolean[] {
const keep = xs.map(() => false);
const taken: number[] = [];
const fits = (x: number) => taken.every((t) => Math.abs(t - x) >= minGap);
const order = xs.map((x, i) => i).sort((a, b) => Number(priority[b]) - Number(priority[a]) || a - b);
for (const i of [0, xs.length - 1, ...order]) {
if (keep[i] || !fits(xs[i])) continue;
keep[i] = true;
taken.push(xs[i]);
}
return keep;
}
/** Linear interpolation of a station value at distance x. */
function at(st: ProfileStation[], x: number, f: (s: ProfileStation) => number): number {
for (let i = 0; i < st.length - 1; i++) {
const a = st[i], c = st[i + 1];
if (x >= a.x && x <= c.x) {
const t = c.x > a.x ? (x - a.x) / (c.x - a.x) : 0;
return f(a) + (f(c) - f(a)) * t;
}
}
return f(st[x <= st[0].x ? 0 : st.length - 1]);
}
export function buildProfileDrawing(input: ProfileSheetInput): { drawing: Drawing; hScale: number; vScale: number } {
const sheet = SHEETS[input.sheet];
const st = input.stations;
if (st.length < 2) throw new Error('종단 경로에 절점이 2개 이상 필요합니다.');
const b = new DrawingBuilder(sheet.w, sheet.h, input.info.title);
const k = sheet.w / 420; // text/row sizes grow with the sheet
const total = st[st.length - 1].x - st[0].x;
const zs = st.flatMap((s) => [s.ground, s.invert, ...s.heads, s.hwl ?? NaN]).filter(Number.isFinite);
const zMin = Math.min(...zs), zMax = Math.max(...zs);
// Frame first so the scales use the real space.
const tmp = new DrawingBuilder(sheet.w, sheet.h, '');
const area = sheetFrame(tmp, { ...input.info, scale: '' });
const headW = 25 * k;
const rows: Array<{ label: string; h: number }> = [
{ label: '측 점', h: 8 },
{ label: '절 점 번 호', h: 11 },
{ label: '거 리 (m)', h: 10 },
{ label: '추가거리 (m)', h: 11 },
{ label: '지 반 고 (m)', h: 10 },
{ label: '관 저 고 (m)', h: 10 },
{ label: '동 수 두 (m)', h: 10 },
{ label: '수 압 (m)', h: 9 },
{ label: '관경 · 연장', h: 7 },
].map((r) => ({ ...r, h: r.h * k }));
const tableH = rows.reduce((a, r) => a + r.h, 0);
const gx0 = area.x0 + headW + 5 * k, gx1 = area.x1 - 14 * k;
const ty0 = area.y0, ty1 = ty0 + tableH;
const headingY = area.y1 - 16 * k;
const gy0 = ty1 + 6 * k, gy1 = headingY - 4 * k;
const hScale = niceScale(Math.max(total, 1), gx1 - gx0);
// Headroom above the highest line for facility labels.
const vScale = niceScale(Math.max(zMax - zMin, 1) * 1.35, gy1 - gy0, [10, 20, 25, 50, 100, 200, 250, 300, 500, 700, 1000, 2000, 2500, 5000, 10000]);
const tick = niceStep((gy1 - gy0) * vScale / 1000, 10);
// Do not drop below 0 m when everything is above sea level (wasted space).
const zBase0 = Math.floor((zMin - 0.05 * (zMax - zMin)) / tick) * tick;
const zBase = zMin >= 0 ? Math.max(0, zBase0) : zBase0;
const X = (x: number) => gx0 + ((x - st[0].x) * 1000) / hScale;
const Y = (z: number) => gy0 + ((z - zBase) * 1000) / vScale;
const zTop = zBase + ((gy1 - gy0) * vScale) / 1000;
const scaleTxt = `V = 1:${vScale.toLocaleString()} H = 1:${hScale.toLocaleString()}`;
sheetFrame(b, { ...input.info, scale: `V=1:${vScale.toLocaleString()} H=1:${hScale.toLocaleString()}` });
sheetHeading(b, area, input.info.title, scaleTxt);
// Elevation axes (both sides) + horizontal grid + datum.
for (const [ax, dir] of [[gx0, -1], [gx1, 1]] as const) {
b.line('AXIS', [[ax, gy0], [ax, gy1]], { width: 0.35 });
for (let z = zBase; z <= zTop + 1e-9; z += tick) {
const y = Y(z);
b.line('AXIS', [[ax, y], [ax + dir * 1.5 * k, y]], { width: 0.25 });
b.text('AXIS', ax + dir * 2.5 * k, y, fx(z, tick < 1 ? 1 : 0), 2 * k, { anchor: dir < 0 ? 'end' : 'start', vAnchor: 'middle' });
}
b.text('AXIS', ax + dir * 2.5 * k, gy1 + 3 * k, 'EL.(m)', 2 * k, { anchor: dir < 0 ? 'end' : 'start' });
}
for (let z = zBase + tick; z < zTop - 1e-9; z += tick) b.line('GRID', [[gx0, Y(z)], [gx1, Y(z)]], { width: 0.08, color: '#cbd5e1' });
b.line('AXIS', [[gx0, gy0], [gx1, gy0]], { width: 0.35 });
b.text('AXIS', gx0 + 1.5 * k, gy0 + 1.2 * k, `D.L = ${fx(zBase, tick < 1 ? 1 : 2)} m`, 2 * k, { bold: true });
// Station table.
// Priority stations: ends, facilities, diameter changes, fittings.
const dChange = (x: number) => {
const into = input.runs.find((r) => Math.abs(r.toX - x) < 1e-6), out = input.runs.find((r) => Math.abs(r.fromX - x) < 1e-6);
return !!into && !!out && into.diameter !== out.diameter;
};
const nearFitting = (x: number) => (input.fittings ?? []).some((f) => Math.abs(f.x - x) < 1e-3 + total * 1e-4);
const isRunEnd = st.map((s, i) => i === 0 || i === st.length - 1 || (s.kind !== undefined && s.kind !== 'junction') || dChange(s.x) || nearFitting(s.x));
const show = labelledStations(st.map((s) => X(s.x)), isRunEnd, 3.2 * k);
const rowTop: number[] = [];
{
let y = ty1;
for (const r of rows) { rowTop.push(y); y -= r.h; }
}
b.rect('TABLE', area.x0, ty0, gx1 - area.x0, tableH, { width: 0.45 });
rows.forEach((r, i) => {
if (i > 0) b.line('TABLE', [[area.x0, rowTop[i]], [gx1, rowTop[i]]], { width: 0.15 });
b.text('TABLE', area.x0 + headW / 2, rowTop[i] - r.h / 2, r.label, 2.2 * k, { anchor: 'middle', vAnchor: 'middle', bold: true });
});
b.line('TABLE', [[area.x0 + headW, ty0], [area.x0 + headW, ty1]], { width: 0.35 });
const minP = input.minPressure;
let prevShown = -1;
st.forEach((s, i) => {
if (!show[i]) return;
const x = X(s.x);
const topZ = Math.max(s.ground, s.invert);
b.line('GUIDE', [[x, rowTop[rows.length - 1]], [x, ty1]], { width: 0.08, color: '#94a3b8' });
b.line('GUIDE', [[x, gy0], [x, Y(Math.min(topZ, zTop))]], { width: 0.08, color: '#94a3b8', dash: 'dotted' });
const prev = prevShown >= 0 ? st[prevShown] : undefined;
prevShown = i;
const low = minP !== undefined && s.pressure !== undefined && s.kind !== 'reservoir' && s.kind !== 'tank' && s.pressure < minP;
const id = s.id.length > 11 ? `${s.id.slice(0, 10)}…` : s.id;
const vals: Array<[string, string?]> = [
[`No.${i}`],
[id],
[fxc(prev ? s.x - prev.x : 0, 1)],
[fxc(s.x - st[0].x, 1)],
[fx(s.ground)],
[fx(s.invert)],
[s.heads.length ? fx(s.heads[0]) : '-'],
[s.pressure === undefined ? '-' : fx(s.pressure), low ? RED : undefined],
];
vals.forEach(([v, color], r) => {
b.text('TABLE', x, rowTop[r] - rows[r].h / 2, v, (r <= 1 ? 1.8 : 1.65) * k, { anchor: 'middle', vAnchor: 'middle', rotate: 90, color, bold: r === 0 || !!color });
});
});
// Bottom row: one bracket per run of equal diameter.
const merged: ProfileRun[] = [];
for (const r of input.runs) {
const last = merged[merged.length - 1];
if (last && last.diameter === r.diameter && Math.abs(last.toX - r.fromX) < 1e-6) merged[merged.length - 1] = { ...last, toX: r.toX, length: last.length + r.length };
else merged.push({ ...r });
}
const rr = rows.length - 1;
const remY = rowTop[rr] - rows[rr].h / 2;
for (const r of merged) {
const a = X(r.fromX), c = X(r.toX);
const h = rows[rr].h;
b.line('RUN', [[a, remY - h * 0.28], [a, remY + h * 0.28]], { width: 0.25 });
b.line('RUN', [[c, remY - h * 0.28], [c, remY + h * 0.28]], { width: 0.25 });
const t = `D${r.diameter.toFixed(0)}mm L=${fxc(r.length, 1)}m`;
const tw = t.length * 0.62 * 1.8 * k;
if (c - a > tw + 4 * k) {
b.line('RUN', [[a, remY], [(a + c) / 2 - tw / 2 - 1 * k, remY]], { width: 0.15 });
b.line('RUN', [[(a + c) / 2 + tw / 2 + 1 * k, remY], [c, remY]], { width: 0.15 });
b.text('RUN', (a + c) / 2, remY, t, 1.8 * k, { anchor: 'middle', vAnchor: 'middle' });
} else {
b.line('RUN', [[a, remY], [c, remY]], { width: 0.15 });
if (c - a > 7 * k) b.text('RUN', (a + c) / 2, remY, `D${r.diameter.toFixed(0)}`, 1.5 * k, { anchor: 'middle', vAnchor: 'middle' });
}
}
// Lines: ground, pipe invert (+ crown when the bore is visible), HGL series.
const P = (f: (s: ProfileStation) => number) => st.map((s) => [X(s.x), Y(f(s))] as [number, number]).filter(([, y]) => Number.isFinite(y));
b.line('GROUND', P((s) => s.ground), { width: 0.4, color: '#7c4a1e' });
// Ground hatch ticks under the ground line (every ~4 mm on paper).
{
const step = 4 * k;
for (let px = gx0 + step / 2; px < X(st[st.length - 1].x); px += step) {
const xm = st[0].x + ((px - gx0) * hScale) / 1000;
const yg = Y(at(st, xm, (s) => s.ground));
if (!Number.isFinite(yg) || yg - 1.6 * k < gy0) continue;
b.line('GROUND', [[px, yg], [px - 1.1 * k, yg - 1.6 * k]], { width: 0.1, color: '#a16207' });
}
}
const showCrown = merged.some((r) => (r.diameter / 1000) * 1000 / vScale >= 0.45);
b.line('PIPE', P((s) => s.invert), { width: 0.5, color: '#111111' });
if (showCrown) {
for (const r of input.runs) {
const d = r.diameter / 1000;
b.line('PIPE', [[X(r.fromX), Y(at(st, r.fromX, (s) => s.invert) + d)], [X(r.toX), Y(at(st, r.toX, (s) => s.invert) + d)]], { width: 0.3, color: '#111111' });
}
}
input.series.forEach((sr, i) => {
b.line(`HGL_${i + 1}`, P((s) => s.heads[i]), { width: 0.45, color: sr.color, dash: sr.dash });
});
// Inline name on the first HGL, along its longest early segment.
if (input.series.length) {
let best = -1, bi = -1;
for (let i = 0; i < st.length - 1; i++) {
const L = X(st[i + 1].x) - X(st[i].x);
if (Number.isFinite(st[i].heads[0]) && Number.isFinite(st[i + 1].heads[0]) && L > best) { best = L; bi = i; }
if (X(st[i + 1].x) > (gx0 + gx1) / 2 && best > 25 * k) break;
}
if (bi >= 0 && best > 22 * k) {
const ax = X(st[bi].x), ay = Y(st[bi].heads[0]), cx = X(st[bi + 1].x), cy = Y(st[bi + 1].heads[0]);
const ang = (Math.atan2(cy - ay, cx - ax) * 180) / Math.PI;
const t = 0.3;
b.text('NOTE', ax + (cx - ax) * t, ay + (cy - ay) * t + 1.2 * k, `동수경사선 (${input.series[0].name})`, 2 * k, { rotate: ang, color: input.series[0].color, bold: true });
}
}
// Run annotations above the first HGL: C, Q, I.
for (const r of merged) {
const a = X(r.fromX), c = X(r.toX);
if (c - a < 26 * k) continue;
const mid = (r.fromX + r.toX) / 2;
const h = at(st, mid, (s) => s.heads[0]);
if (!Number.isFinite(h)) continue;
const y = Math.min(Y(h) + 3.5 * k, gy1 - 2 * k);
const parts = [r.roughness !== undefined ? `C=${r.roughness.toFixed(0)}` : '', r.flow !== undefined ? `Q=${fxc(r.flow, 1)}${r.flowUnit ?? ''}` : '', r.gradient !== undefined ? `I=${r.gradient.toFixed(2)}‰` : ''].filter(Boolean);
if (parts.length) b.text('NOTE', (a + c) / 2 + 6 * k, y, parts.join(' '), 1.8 * k, { anchor: 'middle', color: '#1e3a8a' });
}
// Leader labels (60°) above the highest line at x.
const labelTops: number[] = [];
const leader = (x: number, fromY: number, text: string, color = '#0f172a') => {
const px = X(x);
const topZ = Math.max(at(st, x, (s) => Math.max(s.ground, ...s.heads.filter(Number.isFinite), s.hwl ?? -Infinity)));
const size = 2.1 * k;
const rise = text.length * size * 0.8 * Math.sin(Math.PI / 3);
let base = Math.min(Y(topZ) + 7 * k, gy1 - rise);
base = Math.max(base, fromY + 3 * k);
// nudge up when an earlier label sits right next to this one
for (const t of labelTops) if (Math.abs(t - px) < size * 1.6) base = Math.min(base + size * 1.4, gy1 - 2 * k);
labelTops.push(px);
b.line('NOTE', [[px, fromY], [px, base]], { width: 0.15, color });
b.line('NOTE', [[px, base], [px + size * 0.5, base + size * 0.87]], { width: 0.15, color });
b.text('NOTE', px + size * 0.4, base + size * 0.5, text, size, { rotate: 60, color, bold: true });
};
// Reservoirs / tanks: ▽ at the water level and a label.
const tri = (layer: string, cx: number, y: number, s: number, color: string) => {
b.fill(layer, [[cx - s, y + s * 1.15], [cx + s, y + s * 1.15], [cx, y]], color);
};
for (const s of st) {
if (s.kind !== 'reservoir' && s.kind !== 'tank') continue;
const x = X(s.x);
const hwl = s.hwl ?? s.ground;
const y = Y(hwl);
if (s.kind === 'tank' && s.lwl !== undefined) {
const w = 5 * k;
b.rect('FACILITY', x - w / 2, Y(at(st, s.x, (q) => q.invert)), w, y - Y(at(st, s.x, (q) => q.invert)) + 1.5 * k, { width: 0.35 });
b.line('FACILITY', [[x - w / 2 - 1.5 * k, Y(s.lwl)], [x + w / 2 + 1.5 * k, Y(s.lwl)]], { width: 0.2, color: '#0369a1', dash: 'dashed' });
}
b.line('FACILITY', [[x - 4 * k, y], [x + 4 * k, y]], { width: 0.35, color: '#0369a1' });
b.line('FACILITY', [[x - 2.6 * k, y - 0.8 * k], [x + 2.6 * k, y - 0.8 * k]], { width: 0.2, color: '#0369a1' });
tri('FACILITY', x, y, 1.3 * k, '#0369a1');
b.text('FACILITY', x + 4.5 * k, y - 1.2 * k, `H.W.L ${fx(hwl)}`, 1.9 * k, { vAnchor: 'top', color: '#0369a1', bold: true });
const name = (input.marks ?? []).find((m) => Math.abs(m.x - s.x) < 1e-6)?.text ?? `${s.kind === 'reservoir' ? '배수지' : '탱크'} ${s.id}`;
leader(s.x, y + 2 * k, name);
}
for (const m of input.marks ?? []) {
if (st.some((s) => (s.kind === 'reservoir' || s.kind === 'tank') && Math.abs(s.x - m.x) < 1e-6)) continue;
leader(m.x, Y(at(st, m.x, (s) => Math.max(s.ground, ...s.heads.filter(Number.isFinite)))) + 1 * k, m.text);
}
// Valves (bow-tie on the pipe, PRV filled) and pumps (circle + triangle).
for (const f of input.fittings ?? []) {
const x = X(f.x), y = Y(at(st, f.x, (s) => s.invert));
const s = 1.5 * k;
if (f.kind === 'valve') {
const filled = f.subtype === 'PRV' || f.subtype === 'PSV' || f.subtype === 'PBV';
const L: Array<[number, number]> = [[x - s, y - s * 0.75], [x - s, y + s * 0.75], [x, y]];
const R: Array<[number, number]> = [[x + s, y - s * 0.75], [x + s, y + s * 0.75], [x, y]];
if (filled) { b.fill('VALVE', L, '#111111'); b.fill('VALVE', R, '#111111'); }
b.line('VALVE', L, { width: 0.25, closed: true });
b.line('VALVE', R, { width: 0.25, closed: true });
} else {
b.circle('PUMP', x, y, s * 1.1, { fill: '#ffffff', color: '#111111', width: 0.3 });
b.fill('PUMP', [[x - s * 0.45, y - s * 0.6], [x - s * 0.45, y + s * 0.6], [x + s * 0.65, y]], '#111111');
}
const hgl = at(st, f.x, (q) => q.heads[0]);
leader(f.x, Math.max(y, Number.isFinite(hgl) ? Y(hgl) : y) + 2 * k, f.label, f.kind === 'valve' ? '#7c2d12' : '#14532d');
}
// Legend (top-right of the graph): plain framed panel.
const entries: Array<{ name: string; color: string; dash?: 'dashed' | 'dotted'; w: number; sym?: 'tri' | 'valve' | 'pump' | 'red' }> = [
{ name: '지반선', color: '#7c4a1e', w: 0.4 },
{ name: showCrown ? '관로 (관저·관정)' : '관저고', color: '#111111', w: 0.5 },
...input.series.map((s) => ({ name: `동수경사선 (${s.name})`, color: s.color, dash: s.dash, w: 0.45 })),
...(st.some((s) => s.kind === 'reservoir' || s.kind === 'tank') ? [{ name: '배수지 수위 (H.W.L)', color: '#0369a1', w: 0, sym: 'tri' as const }] : []),
...((input.fittings ?? []).some((f) => f.kind === 'valve') ? [{ name: '밸브 (감압 등: 채움)', color: '#111111', w: 0, sym: 'valve' as const }] : []),
...((input.fittings ?? []).some((f) => f.kind === 'pump') ? [{ name: '가압펌프', color: '#111111', w: 0, sym: 'pump' as const }] : []),
...(minP !== undefined && st.some((s) => s.pressure !== undefined) ? [{ name: `수압 ${minP}m 미만 (붉은 글씨)`, color: RED, w: 0, sym: 'red' as const }] : []),
];
const lw = 62 * k;
type E = (typeof entries)[number];
const sym = (en: E) => (sx: number, y: number) => {
const ex = sx + 10 * k;
if (en.sym === 'tri') { b.line('LEGEND', [[sx + 1 * k, y], [ex - 1 * k, y]], { width: 0.3, color: en.color }); tri('LEGEND', (sx + ex) / 2, y, 1.1 * k, en.color); }
else if (en.sym === 'valve') { const c = (sx + ex) / 2, s2 = 1.3 * k; b.fill('LEGEND', [[c - s2, y - s2 * 0.75], [c - s2, y + s2 * 0.75], [c, y]], '#111111'); b.fill('LEGEND', [[c + s2, y - s2 * 0.75], [c + s2, y + s2 * 0.75], [c, y]], '#111111'); }
else if (en.sym === 'pump') { const c = (sx + ex) / 2; b.circle('LEGEND', c, y, 1.4 * k, { fill: '#ffffff', color: '#111111', width: 0.3 }); b.fill('LEGEND', [[c - 0.6 * k, y - 0.8 * k], [c - 0.6 * k, y + 0.8 * k], [c + 0.9 * k, y]], '#111111'); }
else if (en.sym === 'red') b.text('LEGEND', (sx + ex) / 2, y, '12.34', 1.8 * k, { anchor: 'middle', vAnchor: 'middle', color: RED, bold: true });
else b.line('LEGEND', [[sx, y], [ex, y]], { width: en.w, color: en.color, dash: en.dash });
};
const leg = legendTable(b, 'LEGEND', [
{ name: '선형', rows: entries.filter((en) => !en.sym).map((en) => ({ label: en.name, draw: sym(en) })) },
{ name: '기호', rows: entries.filter((en) => en.sym).map((en) => ({ label: en.name, draw: sym(en) })) },
], { w: lw, rowH: 4.2 * k, style: input.legendStyle ?? 'table', band: input.legendBand });
if (leg.h) leg.draw(gx1 - lw - 3 * k, gy1 - 6 * k - leg.h);
return { drawing: b.build(input.font), hScale, vScale };
}
+9
View File
@@ -145,6 +145,15 @@ export interface MapAnnotation {
color?: string;
}
/** DEM 등고선 on the map (maplibre-contour over the API's terrain tiles). */
export interface DemContours {
demId: string;
/** Minor contour interval (m); every 5th is a labelled major line. */
interval: number;
/** Highest useful tile zoom for this DEM's resolution. */
maxzoom: number;
}
/** 3D 보기: tilted map, optional DEM terrain and node columns. */
export interface View3D {
enabled: boolean;
+135
View File
@@ -845,3 +845,138 @@ test('RASTER-1: world file and extent corners', async () => {
assert.deepEqual(extentCorners(0, 0, 10, 5)[0], { x: 0, y: 5 });
assert.throws(() => extentCorners(5, 0, 1, 1));
});
test('DRAWING-1: profile/plan sheets from a model, nice scales, SVG and DXF writers', async () => {
const { buildProfileDrawing, labelledStations } = await import('../src/profileDrawing.ts');
const { buildNetworkDrawing } = await import('../src/networkDrawing.ts');
const { toSvg, toDxf, niceScale } = await import('../src/drawing.ts');
const { profileInput, planInput } = await import('../src/drawingData.ts');
const m = parseInp(`[JUNCTIONS]
J1 50 1 ;
J2 40 1 ;
[RESERVOIRS]
R1 100 ;
[PIPES]
P1 R1 J1 1000 300 120 0 Open ;
P2 J1 J2 500 200 120 0 Open ;
[COORDINATES]
R1 0 0
J1 1000 0
J2 1500 0
[OPTIONS]
Units LPS
[END]`);
const id = (v: number) => v;
const u = { head: id, length: id, diameter: id, flow: id, pressure: id, velocity: id };
const link = (flow: number, headloss: number) => ({ id: '', type: 1, fromNode: '', toNode: '', vertices: [], flow, velocity: 0.5, headloss, timeSeries: { flow: [flow, flow * 2], velocity: [0.5, 1], headloss: [headloss, headloss * 3], quality: [] } });
const node = (h: number, p: number) => ({ id: '', type: 0, coordinates: null, demand: 0, head: h, pressure: p, timeSeries: { head: [h, h - 5], pressure: [p, p - 5], demand: [], quality: [] } });
const res = { nodes: { R1: node(100, 0), J1: node(98, 48), J2: node(97, 57) }, links: { P1: link(10, 2), P2: link(5, 1) } } as unknown as SimulationResult;
const path = { nodeIds: ['R1', 'J1', 'J2'], linkIds: ['P1', 'P2'], linkLengths: [1000, 500] };
const d = profileInput(m, res, path, { elevationIs: 'invert', depth: 1.2, series: ['now', 'min'], timeIndex: 0 }, u, 'L/s');
assert.deepEqual(d.stations.map((s) => s.x), [0, 1000, 1500]);
assert.deepEqual([d.stations[1].ground, d.stations[1].invert], [51.2, 50]);
assert.equal(d.stations[0].ground, 100); // reservoir: water level, no burial depth
assert.deepEqual(d.stations[2].heads, [97, 92]);
assert.deepEqual(d.runs.map((r) => [r.diameter, r.gradient]), [[300, 2], [200, 2]]);
assert.deepEqual(d.marks, [{ x: 0, text: '배수지 R1' }]);
const info = { project: '시험', title: '수리 종단면도', number: 'W-1', client: '○○시', company: '(주)삼안', date: '2026.11' };
const prof = buildProfileDrawing({ sheet: 'A3', info, ...d });
assert.ok(prof.hScale >= 1500 * 1000 / 400 && prof.vScale > 0);
const svg = toSvg(prof.drawing);
assert.match(svg, /수리 종단면도/);
assert.match(svg, /D300mm\s+L=1,000\.0m/);
const dxf = toDxf(prof.drawing);
assert.ok(dxf.startsWith('0\r\nSECTION') && dxf.trimEnd().endsWith('EOF'));
assert.ok(dxf.includes('\\U+C218\\U+B9AC')); // 수리
assert.match(dxf, /\r\nHGL_2\r\n/);
assert.equal(niceScale(3080, 350), 10000);
// ends always; a priority station (run end) beats a plain one competing for the same space
assert.deepEqual(labelledStations([0, 4, 6, 10], [false, false, true, false], 3), [true, false, true, true]);
const plan = planInput(m, res, { mode: 'hydraulic', timeIndex: 1, minPressure: 50, maxPressure: 56, maxVelocity: 2 }, u);
// t=1: J1 43 m (< 50 red), J2 52 m (ok, blue)
const color = Object.fromEntries(plan.nodes.map((n) => [n.id, n.color]));
assert.deepEqual([color.J1, color.J2], ['#dc2626', '#2563eb']);
// t=1: P1 velocity 1.0 m/s -> 1.0~max band (amber)
assert.equal(plan.links.find((l) => l.id === 'P1')!.color, '#f59e0b');
const sheetPlan = buildNetworkDrawing({ sheet: 'A3', info, metric: true, ...plan, pipeLabels: 'DL', nodeLabels: 'sources' });
assert.ok(sheetPlan.scale >= 1500 * 1000 / 380);
assert.match(toSvg(sheetPlan.drawing), /D300 L=1,000m/);
// Equal-diameter pipes through a plain junction are labelled as one stretch.
const { pipeChains } = await import('../src/networkDrawing.ts');
const L = (id: string, a: [number, number], c: [number, number], d: number) => ({ id, kind: 'pipe' as const, pts: [a, c], diameter: d, length: Math.hypot(c[0] - a[0], c[1] - a[1]), color: '#000' });
const ch = pipeChains([L('a', [0, 0], [100, 0], 300), L('b', [100, 0], [250, 0], 300), L('c', [250, 0], [300, 0], 200)]);
assert.deepEqual(ch.map((c) => [c.links.map((l) => l.id).sort().join(','), c.length]), [['a,b', 250], ['c', 50]]);
// A reservoir/tank point stops the chain.
assert.equal(pipeChains([L('a', [0, 0], [100, 0], 300), L('b', [100, 0], [250, 0], 300)], new Set(['100.000,0.000'])).length, 2);
// Pressures below the design minimum are drawn red; reservoir gets ▽ H.W.L.
const red = buildProfileDrawing({ sheet: 'A3', info, ...d, minPressure: 50 });
const redTexts = red.drawing.items.filter((it) => it.kind === 'text' && it.color === '#dc2626').map((it) => (it as { text: string }).text);
assert.ok(redTexts.includes('48.00') && !redTexts.includes('57.00'));
assert.match(toSvg(red.drawing), /H\.W\.L 100\.00/);
assert.equal(d.stations[0].kind, 'reservoir');
});
test('DRAWING-2: split plan sheets (색인도 + 부분도), clipping, pressure tags, font', async () => {
const { buildNetworkDrawing, planLayout, tileNeighbours, clipPolyline } = await import('../src/networkDrawing.ts');
const { toSvg, mergeDrawings } = await import('../src/drawing.ts');
// clip: a line crossing the box keeps only the inside part
assert.deepEqual(clipPolyline([[-5, 5], [15, 5]], { x0: 0, y0: 0, x1: 10, y1: 10 }), [[[0, 5], [10, 5]]]);
assert.deepEqual(clipPolyline([[-5, -5], [-1, -1]], { x0: 0, y0: 0, x1: 10, y1: 10 }), []);
// A 6 km straight main with junctions every 100 m.
const nodes = Array.from({ length: 61 }, (_, i) => ({ id: `J${i}`, type: 'JUNCTION' as const, x: 200000 + i * 100, y: 500000, color: '#334155', head: 120 - i * 0.1, elevation: 60, pressure: 60 - i * 0.1, demand: i % 2 ? 0.5 : 0 }));
const links = nodes.slice(1).map((n, i) => ({ id: `P${i + 1}`, kind: 'pipe' as const, pts: [[nodes[i].x, nodes[i].y], [n.x, n.y]] as Array<[number, number]>, diameter: 200, length: 100, color: '#1d4ed8' }));
const info = { project: '시험', title: '관망도', number: 'W', client: '', company: '', date: '' };
const lay = planLayout({ sheet: 'A3', info, links, nodes }, { scale: 2000 });
// map window ~ 375 mm wide at 1:2000 = ~750 m: 6 km needs about 8-9 sheets, numbered left to right
assert.ok(lay.tiles.length >= 8 && lay.tiles.length <= 10, String(lay.tiles.length));
assert.deepEqual(lay.tiles.map((t) => t.no), lay.tiles.map((_, i) => i + 1));
assert.ok(lay.tiles.every((t, i) => i === 0 || t.minX > lay.tiles[i - 1].minX));
// every node is inside some sheet
assert.ok(nodes.every((n) => lay.tiles.some((t) => n.x >= t.minX && n.x <= t.maxX && n.y >= t.minY && n.y <= t.maxY)));
assert.deepEqual(tileNeighbours(lay.tiles, lay.tiles[0]).map((n) => [n.no, n.dx, n.dy]), [[2, 1, 0]]);
// regular grid: equal steps, neighbours overlap by the same strip
const stepX = lay.tiles[1].minX - lay.tiles[0].minX;
assert.ok(lay.tiles.every((t, i) => i === 0 || Math.abs(t.minX - lay.tiles[i - 1].minX - stepX) < 1e-6));
assert.ok(lay.tiles[0].maxX > lay.tiles[1].minX);
assert.ok(lay.tiles.every((t) => t.minY === lay.tiles[0].minY));
// grid names: one row -> A-1, A-2, ...
const g = planLayout({ sheet: 'A3', info, links, nodes }, { scale: 2000, naming: 'grid' });
assert.deepEqual(g.tiles.slice(0, 2).map((t) => t.name), ['A-1', 'A-2']);
// an L-shaped network: two rows, numbered from the north-west, corner neighbour reported
const lnodes = [...nodes.slice(0, 20), ...Array.from({ length: 15 }, (_, i) => ({ ...nodes[0], id: `K${i}`, x: 200000, y: 500000 - (i + 1) * 100 }))];
const llinks = [...links.slice(0, 19), ...lnodes.slice(20).map((n, i) => ({ ...links[0], id: `Q${i}`, pts: [[i ? lnodes[19 + i].x : 200000, i ? lnodes[19 + i].y : 500000], [n.x, n.y]] as Array<[number, number]> }))];
const L = planLayout({ sheet: 'A3', info, links: llinks, nodes: lnodes }, { scale: 2000, naming: 'grid' });
assert.equal(L.tiles[0].name, 'A-1');
assert.ok(L.tiles.some((t) => t.name.startsWith('B-')));
const a1 = L.tiles[0];
assert.ok(tileNeighbours(L.tiles, a1).some((n) => n.dy === -1));
const base = { sheet: 'A3' as const, info, metric: true, links, nodes, legend: [], legendTitle: '범 례', pipeLabels: 'DL' as const, nodeLabels: 'none' as const };
const t = lay.tiles[1];
const det = buildNetworkDrawing({ ...base, view: t, scale: lay.scale, sheetLabel: `(2/${lay.tiles.length})`, pressureTags: 'kgf', keyMap: { lines: links.map((l) => l.pts), tiles: lay.tiles, current: 2 }, neighbours: tileNeighbours(lay.tiles, t) });
const svg = toSvg(det.drawing);
assert.match(svg, /관망도 \(2\/\d+\)/);
assert.match(svg, /kg\/cm²/);
assert.match(svg, /위 치 도/);
assert.match(svg, /◀ 도면 1 연결/);
assert.match(svg, /▶ 도면 3 연결/);
assert.match(svg, /HY울릉도M/); // default font
// nothing drawn outside the map frame on the paper sheet
for (const it of det.drawing.items) if (it.kind === 'line' && it.layer === 'PIPE') assert.ok(it.pts.every(([x, y]) => x >= 0 && x <= 420 && y >= 0 && y <= 297));
// pressure 60 m -> 6.00 kg/cm²; head / elevation in the circle
assert.match(svg, /5\.\d\d kg\/cm²/);
// legend styles: table (default) has the ruled group cell, list has headings, none has no legend
const legBase = { ...base, legend: [{ label: '유속 0.3m/s 미만', color: '#123456', shape: 'line' as const }], legendTitle: '범 례 (수리 판정)' };
const tbl = toSvg(buildNetworkDrawing({ ...legBase, legendBand: '#d6e4f0' }).drawing);
assert.match(tbl, /#d6e4f0/);
assert.match(tbl, />유</);
assert.match(tbl, /#123456/);
const lst = toSvg(buildNetworkDrawing({ ...legBase, legendStyle: 'list' }).drawing);
assert.match(lst, />유속</);
const none = toSvg(buildNetworkDrawing({ ...legBase, legendStyle: 'none' }).drawing);
assert.doesNotMatch(none, /유속 0\.3m\/s 미만/);
const both = mergeDrawings([det.drawing, det.drawing]);
assert.equal(both.width, 420 * 2 + 30);
});