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

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

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

No files matched your search

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