도면 출력 추가: 수리 종단면도, 관망도(색인도·부분도·수압표), 범례·색상 선택, 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:
1 parent
14f07181db
commit
e3f386addf
33 files changed
+3454
-482
No files matched your search
@@ -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
@@ -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
@@ -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>")
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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' && (
|
||||
<>
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
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';
|
||||
}
|
||||
@@ -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 ? '범 례 (관경)' : '범 례 (수리 판정)' };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user