From 4aa211ceada9eeec59965b0eb8e6599297cde5f1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 31 Jul 2026 21:47:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(drainage):=20B05=20=ED=8F=89=EA=B7=A0=20?= =?UTF-8?q?=ED=9D=90=EB=A6=84=20=ED=99=94=EC=82=B4=ED=91=9C=20=ED=91=9C?= =?UTF-8?q?=EA=B8=B0=20+=20=EC=9D=91=EB=8B=B5=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts | 2 + .../B04_wf1_Surface_Router_Watershed.py | 28 ++++-- .../B04_wf1_Surface_UI_FlowArrows.ts | 88 +++++++++++++++++++ .../B04_wf1_Surface_UI_Watershed.ts | 69 +++++---------- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 4 + .../B05_wf2_Route_Engine_Drainage_Basin.py | 23 +++++ .../B05_wf2_Route_Router_Drainage.py | 3 + .../B05_wf2_Route_UI_Drainage_Panel.ts | 43 ++++++++- 8 files changed, 204 insertions(+), 56 deletions(-) create mode 100644 B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows.ts diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index f85dc842..bfbfb09e 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -333,6 +333,8 @@ export interface WatershedAnalysis { /** B05용 평균 흐름 화살표 — [lon, lat, 방위(도), 도로도달, 셀 수]. * 세류·도로 셀을 뺀 10m 블록 평균이라 사면 경향만 남는다. */ flow_arrows: Array<[number, number, number, boolean, number]>; + /** 화살표 사이 실제 간격(m). 화면이 화살표를 이보다 짧게 그려 서로 닿지 않게 한다. */ + arrow_spacing_m: number; /** 계산하지 않고 저장분을 그대로 돌려준 응답인지. */ from_cache: boolean; /** 영구저장소에 남긴 검증용 GeoJSON 경로. */ diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py b/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py index 757a0e0d..068418e1 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_Watershed.py @@ -43,7 +43,7 @@ from common_util.common_util_route_geometry import ( ) from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool -from config.config_system import DRAINAGE_RESPONSE_FILENAME +from config.config_system import DRAINAGE_ARROW_SPACING_M, DRAINAGE_RESPONSE_FILENAME logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface Watershed"]) @@ -298,6 +298,8 @@ async def get_primary_region( [*to_lonlat(x, y), round(math.degrees(angle), 1), reaches, cells] for x, y, angle, reaches, cells in preview.flow_arrows ], + # 화살표 간격(m). 화면이 화살표 크기를 정할 때 쓴다 — 서로 닿지 않게 이 값보다 짧게 그린다. + "arrow_spacing_m": DRAINAGE_ARROW_SPACING_M, "from_cache": False, } # 단계 산출물을 영구저장소에 남긴다 — 기능을 붙일 때마다 여기에 단계가 하나씩 늘어난다. @@ -341,6 +343,8 @@ async def get_primary_region( ) _write_stage_arrays(prepared["stored_path"], preview, domain, spec) _write_road_routing(prepared["stored_path"], preview, spec, prepared["route_line"], to_lonlat) + # 응답 자체를 캐시로 남긴다 — 다음 조회는 배열을 재조립하지 않고 이 파일을 그대로 준다. + _save_response(prepared["stored_path"], payload) return payload @@ -390,11 +394,28 @@ def _write_road_routing( ) for pipe in preview.pipes ], + # 평균 흐름 화살표 — B05도 같은 그림을 그려야 하므로 여기 함께 남긴다. + "flow_arrow": [ + ( + Point(x, y), + { + # B05 화면은 사업지 CRS(m)로 그리므로 미터 좌표도 함께 남긴다. + "x": round(x, 2), + "y": round(y, 2), + "azimuth_deg": round(math.degrees(angle), 1), + "reaches_road": reaches, + "cells": cells, + }, + ) + for x, y, angle, reaches, cells in preview.flow_arrows + ], }, { "basin_area_m2": round(preview.basin_area_m2, 1), "pipe_count": len(preview.pipes), "route_length_m": round(route_line.length, 1), + "arrow_count": len(preview.flow_arrows), + "arrow_spacing_m": DRAINAGE_ARROW_SPACING_M, }, to_lonlat, ) @@ -495,11 +516,6 @@ def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]: return [Polygon(ring)] if len(ring) >= 4 else [] -def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]: - """2차 유역 외곽 링을 저장용 폴리곤으로 만든다(정점 3개 미만이면 비운다).""" - return [Polygon(ring)] if len(ring) >= 4 else [] - - def _as_polygons(geometry: Any) -> list[Any]: if geometry is None or geometry.is_empty: return [] diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows.ts new file mode 100644 index 00000000..632c197f --- /dev/null +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * 평균 흐름 화살표 렌더러 (B04·B05 공용) + * + * 백엔드가 10m 블록 평균으로 뽑아 둔 화살표를 그린다. 셀 화살표(1m)는 도면 배율에서 + * 경향이 안 보이므로 B05는 이 화살표만 쓴다. + * + * 화살표 길이는 **간격보다 짧게** 잡아 서로 닿지 않게 한다 — 격자처럼 맞물리면 + * 방향이 아니라 그물망으로 읽힌다. + * + * 좌표계는 페이지마다 다르다(B04는 lon/lat 정규화, B05는 사업지 CRS 미터). 그래서 + * 화면 변환은 호출부가 `project`로 넘긴다 — 이 파일은 좌표계를 모른다. + * ========================================================================== */ + +/** 화살표 1개 — [가로, 세로, 방위(도), 도로 도달, 셀 수]. 앞 두 값의 좌표계는 호출부가 정한다. */ +export type FlowArrow = [number, number, number, boolean, number]; + +/** 화살표 길이를 간격의 몇 배로 할지. 1보다 작아야 서로 닿지 않는다. */ +const LENGTH_RATIO = 0.55; +/** 선 두께를 길이의 몇 배로 할지. */ +const WIDTH_RATIO = 0.07; +/** 이보다 짧으면 방향이 안 읽히므로 그리지 않는다(px). */ +const MIN_LENGTH_PX = 9; +/** 화면을 가득 채우지 않도록 두는 상한(px). */ +const MAX_LENGTH_PX = 40; + +const TO_ROAD_COLOR = "rgba(153, 27, 27, 0.95)"; +const AWAY_COLOR = "rgba(30, 64, 175, 0.95)"; +const HALO_COLOR = "rgba(255, 255, 255, 0.9)"; + +/** 화살표 좌표를 캔버스 픽셀로 옮기는 함수. */ +export type ArrowProjector = (a: number, b: number) => readonly [number, number]; + +/** + * 평균 흐름 화살표를 그린다. + * + * `spacingM`은 화살표 사이 실제 간격(m), `pxPerMeter`는 현재 배율에서 1m가 몇 px인지. + * 둘을 곱해 길이를 정하므로 확대·축소에 따라 화살표도 같이 커지고 작아진다. + */ +export function drawFlowArrows( + context: CanvasRenderingContext2D, + arrows: ReadonlyArray, + spacingM: number, + pxPerMeter: number, + project: ArrowProjector, + canvas: { readonly width: number; readonly height: number }, +): void { + if (arrows.length === 0 || spacingM <= 0 || pxPerMeter <= 0) return; + const length = Math.min(spacingM * pxPerMeter * LENGTH_RATIO, MAX_LENGTH_PX); + if (length < MIN_LENGTH_PX) return; + + const reach = length / 2; + const head = length * 0.26; + const width = Math.max(0.8, length * WIDTH_RATIO); + + context.save(); + context.lineCap = "round"; + context.lineJoin = "round"; + context.setLineDash([]); + arrows.forEach(([a, b, degrees, reaches]) => { + const [x, y] = project(a, b); + if (x < -length || x > canvas.width + length) return; + if (y < -length || y > canvas.height + length) return; + const angle = (degrees * Math.PI) / 180; + const unitX = Math.cos(angle); + const unitY = Math.sin(angle); + const tailX = x - unitX * reach; + const tailY = y - unitY * reach; + const tipX = x + unitX * reach; + const tipY = y + unitY * reach; + // 어두운 배경·채움색 위에서도 읽히도록 흰 테두리를 한 겹 깔고 그 위에 색을 얹는다. + for (const [color, lineWidth] of [ + [HALO_COLOR, width + 1.4] as const, + [reaches ? TO_ROAD_COLOR : AWAY_COLOR, width] as const, + ]) { + context.strokeStyle = color; + context.lineWidth = lineWidth; + context.beginPath(); + context.moveTo(tailX, tailY); + context.lineTo(tipX, tipY); + context.moveTo(tipX, tipY); + context.lineTo(tipX - (unitX + unitY * 0.65) * head, tipY - (unitY - unitX * 0.65) * head); + context.moveTo(tipX, tipY); + context.lineTo(tipX - (unitX - unitY * 0.65) * head, tipY - (unitY + unitX * 0.65) * head); + context.stroke(); + } + }); + context.restore(); +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts index 1561e647..7bceeb65 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts @@ -1,4 +1,5 @@ import { fetchWatershedAnalysis, type WatershedAnalysis } from "./B04_wf1_Surface_Api_Fetch"; +import { drawFlowArrows } from "./B04_wf1_Surface_UI_FlowArrows"; import type { Normalizer, ViewState } from "./B04_wf1_Surface_UI_MapRender"; /* ============================================================================= @@ -32,11 +33,6 @@ const ARROW_SPACING_PX = 22; const BASIN_RING_COLOR = "rgba(146, 64, 14, 0.95)"; /** 기본 관 마커. */ const PIPE_COLOR = "rgba(249, 115, 22, 0.95)"; -/** 평균 흐름 화살표 — 10m 블록 평균. 셀 화살표보다 크게 그려 경향을 읽는다. */ -const MEAN_ARROW_TO_ROAD = "rgba(153, 27, 27, 0.95)"; -const MEAN_ARROW_AWAY = "rgba(30, 64, 175, 0.95)"; -const MEAN_ARROW_PX = 14; -const MEAN_ARROW_MAX_PX = 46; /** 개별로 켜고 끌 수 있는 오버레이 갈래. */ const PARTS = [ @@ -340,58 +336,33 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { context.restore(); } - /** B05에 얹을 평균 흐름 화살표 — 10m 블록 평균이라 축소해도 경향이 읽힌다. */ - function drawFlowArrows( + /** B05에도 같이 쓰는 평균 흐름 화살표. 그리기는 공용 렌더러에 맡긴다. */ + function drawMeanArrows( context: CanvasRenderingContext2D, map: Normalizer, view: ViewState, region: WatershedAnalysis, ): void { - const arrows = region.flow_arrows ?? []; - if (arrows.length === 0) return; + // 격자 bbox의 경도 폭과 실폭(m)으로 1m당 픽셀을 환산한다. + const lons = region.grid.bbox_lonlat.map(([lon]) => lon); + const spanLon = Math.max(...lons) - Math.min(...lons); + if (!(spanLon > 0) || !(region.grid.width_m > 0)) return; const ax = view.mapRect.width * view.scale; + const pxPerMeter = ((spanLon / map.lonRange) * ax) / region.grid.width_m; const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; const ay = view.mapRect.height * view.scale; const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; - // 블록 간격(m)이 화면에서 몇 px인지로 화살표 크기를 정한다 — 확대하면 같이 커진다. - const pxPerLon = ax / map.lonRange; - const spacingPx = - arrows.length > 1 - ? Math.abs(arrows[1][0] - arrows[0][0]) * pxPerLon || MEAN_ARROW_PX - : MEAN_ARROW_PX; - const size = Math.max(MEAN_ARROW_PX, Math.min(spacingPx * 0.8, MEAN_ARROW_MAX_PX)); - - context.save(); - context.lineCap = "round"; - arrows.forEach(([lon, lat, degrees, reaches]) => { - const x = ((lon - map.lonMin) / map.lonRange) * ax + bx; - const y = (1 - (lat - map.latMin) / map.latRange) * ay + by; - if (x < -size || x > view.width + size || y < -size || y > view.height + size) return; - const angle = (degrees * Math.PI) / 180; - const unitX = Math.cos(angle); - const unitY = Math.sin(angle); - const reach = size / 2; - const tipX = x + unitX * reach; - const tipY = y + unitY * reach; - const head = size * 0.32; - // 배경 대비를 위해 흰 테두리를 깔고 그 위에 색을 얹는다. - for (const [color, lineWidth] of [ - ["rgba(255, 255, 255, 0.9)", size * 0.18 + 2] as const, - [reaches ? MEAN_ARROW_TO_ROAD : MEAN_ARROW_AWAY, size * 0.18] as const, - ]) { - context.strokeStyle = color; - context.lineWidth = lineWidth; - context.beginPath(); - context.moveTo(x - unitX * reach, y - unitY * reach); - context.lineTo(tipX, tipY); - context.moveTo(tipX, tipY); - context.lineTo(tipX - (unitX + unitY * 0.7) * head, tipY - (unitY - unitX * 0.7) * head); - context.moveTo(tipX, tipY); - context.lineTo(tipX - (unitX - unitY * 0.7) * head, tipY - (unitY + unitX * 0.7) * head); - context.stroke(); - } - }); - context.restore(); + drawFlowArrows( + context, + region.flow_arrows ?? [], + region.arrow_spacing_m ?? 0, + pxPerMeter, + (lon, lat) => [ + ((lon - map.lonMin) / map.lonRange) * ax + bx, + (1 - (lat - map.latMin) / map.latRange) * ay + by, + ], + view, + ); } /** ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 ⑧ 기본 관 위치. */ @@ -549,7 +520,7 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { // 격자·화살표(유역 방향) → 1차 영역 → 2차 유역·관 순으로 아래에서 위로 쌓는다. if (shownParts.flow) drawGridCells(context, map, view, analysis); if (shownParts.primary) drawPrimaryRegion(context, map, view, analysis); - if (shownParts.arrows) drawFlowArrows(context, map, view, analysis); + if (shownParts.arrows) drawMeanArrows(context, map, view, analysis); if (shownParts.basin) drawBasinAndPipes(context, map, view, analysis); }, }; diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index 3d934025..30a3154a 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -306,6 +306,10 @@ export interface DrainageBasinResponse { main_polygon_lonlat: Array<[number, number]>; /** B04 해석 격자 한 변(m). */ grid_cell_m: number; + /** 평균 흐름 화살표 — [x, y(사업지 CRS m), 방위(도), 도로도달, 셀 수]. */ + flow_arrows: Array<[number, number, number, boolean, number]>; + /** 화살표 사이 실제 간격(m). */ + arrow_spacing_m: number; basins: DrainageBasin[]; } diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py index 51be65f9..50f5b630 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Drainage_Basin.py @@ -21,6 +21,7 @@ import json import logging from dataclasses import dataclass, field from pathlib import Path +from typing import Any import numpy as np @@ -58,6 +59,9 @@ class DrainageDetail: pipes: list[StructureCandidate] = field(default_factory=list) basins: list[WatershedBasin] = field(default_factory=list) grid_cell_m: float = 1.0 + # B04가 계산해 둔 평균 흐름 화살표를 그대로 넘긴다 — B05는 다시 계산하지 않는다. + flow_arrows: list[list[Any]] = field(default_factory=list) + arrow_spacing_m: float = 0.0 def build_drainage_detail( @@ -94,6 +98,8 @@ def build_drainage_detail( basin_lonlat=routing.basin_lonlat, pipes=pipes, grid_cell_m=routing.spec.cell_m, + flow_arrows=routing.flow_arrows, + arrow_spacing_m=routing.arrow_spacing_m, ) if not pipes: return detail @@ -140,6 +146,9 @@ class RoadRouting: route_lonlat: list[list[float]] = field(default_factory=list) basin_lonlat: list[list[float]] = field(default_factory=list) base_pipes: list[StructureCandidate] = field(default_factory=list) + # 평균 흐름 화살표 — [x, y, 방위(도), 도로도달, 셀 수]. B04가 계산해 둔 그대로. + flow_arrows: list[list[Any]] = field(default_factory=list) + arrow_spacing_m: float = 0.0 @property def strength_curve(self) -> np.ndarray: @@ -204,6 +213,9 @@ def _read_geometry(path: Path, routing: RoadRouting) -> None: except (OSError, json.JSONDecodeError): logger.warning("배수유역: B04 기하 산출물을 읽지 못했습니다 (%s).", path) return + routing.arrow_spacing_m = float( + (document.get("properties") or {}).get("arrow_spacing_m") or 0.0 + ) for feature in document.get("features", []): properties = feature.get("properties") or {} geometry = feature.get("geometry") or {} @@ -213,6 +225,17 @@ def _read_geometry(path: Path, routing: RoadRouting) -> None: routing.route_lonlat = coordinates elif kind == "basin_boundary" and geometry.get("type") == "Polygon" and coordinates: routing.basin_lonlat = coordinates[0] + elif kind == "flow_arrow" and geometry.get("type") == "Point": + # 화면이 미터로 그리므로 속성의 x·y를 쓴다(기하는 저장 규약상 lon/lat). + routing.flow_arrows.append( + [ + float(properties.get("x") or 0.0), + float(properties.get("y") or 0.0), + float(properties.get("azimuth_deg") or 0.0), + bool(properties.get("reaches_road")), + int(properties.get("cells") or 0), + ] + ) elif kind == "pipe" and geometry.get("type") == "Point": routing.base_pipes.append( StructureCandidate( diff --git a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py index 7861567a..1b99378e 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py +++ b/B05_wf2_Route/B05_wf2_Route_Router_Drainage.py @@ -112,6 +112,9 @@ async def post_drainage_basins( "route_lonlat": detail.route_lonlat, "main_polygon_lonlat": detail.basin_lonlat, "grid_cell_m": detail.grid_cell_m, + # 평균 흐름 화살표 — B04가 계산해 저장한 것을 그대로 넘긴다(사업지 CRS m). + "flow_arrows": detail.flow_arrows, + "arrow_spacing_m": detail.arrow_spacing_m, # 계획선 위 배관 지점 — 유역이 없는 관도 마커로 표시해야 하므로 별도 목록. "pipes": [_candidate_payload(pipe, to_lonlat) for pipe in detail.pipes], "basins": [ diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 5349c7a4..4197183a 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -24,6 +24,7 @@ import { type DrainageBasin, type RoutePoint, } from "./B05_wf2_Route_Api_Fetch"; +import { drawFlowArrows, type FlowArrow } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows"; import { createPipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes"; // 배수유역도 패널 — 하단 종단 패널 안쪽 우측에 도킹되는 2단 사이드 패널. @@ -109,7 +110,20 @@ export function createDrainagePanel(): DrainagePanel { autoButton.type = "button"; autoButton.className = "b05-drainage__analyze b05-drainage__tool"; autoButton.textContent = "자동 제안"; - header.append(analyzeButton, editButton, deleteButton, autoButton); + // 흐름 화살표 보기/숨기기 — 도면이 지저분해질 때 끄기 위한 토글. + const arrowButton = document.createElement("button"); + arrowButton.type = "button"; + arrowButton.className = "b05-drainage__analyze b05-drainage__tool is-active"; + arrowButton.textContent = "흐름 화살표"; + arrowButton.title = "B04에서 산출한 평균 흐름 방향을 보이거나 숨깁니다."; + arrowButton.setAttribute("aria-pressed", "true"); + arrowButton.addEventListener("click", () => { + showArrows = !showArrows; + arrowButton.classList.toggle("is-active", showArrows); + arrowButton.setAttribute("aria-pressed", String(showArrows)); + scheduleDraw(); + }); + header.append(analyzeButton, editButton, deleteButton, autoButton, arrowButton); const viewport = document.createElement("div"); viewport.className = "b05-drainage__viewport"; @@ -147,6 +161,10 @@ export function createDrainagePanel(): DrainagePanel { // 2차 전체 배수유역 외곽선 = 분수령. 별도 "능선" 레이어를 두지 않고 이 선 하나로 표시한다 // (전체 유역 외곽선과 능선이 같은 선이므로 — 2026-07-31 사용자 지시). let mainBoundary: Array<[number, number]> = []; + // 평균 흐름 화살표 — B04가 계산해 둔 것을 그대로 받아 그린다(여기서 계산하지 않는다). + let flowArrows: FlowArrow[] = []; + let arrowSpacingM = 0; + let showArrows = true; let scale = 1; let offsetX = 0; let offsetY = 0; @@ -230,6 +248,27 @@ export function createDrainagePanel(): DrainagePanel { context.strokeStyle = ROUTE_COLOR; drawPreparedLayer(context, routeLayer, view, "dot"); } + // 평균 흐름 화살표 — 유역 채움 위, 배관 마커 아래. 좌표는 사업지 CRS(m)라 + // 도엽 메타로 바로 화면에 옮긴다(배관 마커와 같은 변환). + if (showArrows && meta && flowArrows.length > 0) { + const spanX = meta.width_meters || 1; + const spanY = meta.height_meters || 1; + const pxPerMeter = (view.mapRect.width * view.scale) / spanX; + const ax = view.mapRect.width * view.scale; + const bx = view.mapRect.x * view.scale + (width / 2) * (1 - view.scale) + view.offsetX; + const ay = view.mapRect.height * view.scale; + const by = view.mapRect.y * view.scale + (height / 2) * (1 - view.scale) + view.offsetY; + const originX = meta.x_min; + const originY = meta.y_min; + drawFlowArrows( + context, + flowArrows, + arrowSpacingM, + pxPerMeter, + (x, y) => [((x - originX) / spanX) * ax + bx, (1 - (y - originY) / spanY) * ay + by], + view, + ); + } // 배관(관 매설) 마커 — 계획선 위 최상단. pipeEditor.draw(context, view, pipeColor); updateImageTransform(); @@ -317,6 +356,8 @@ export function createDrainagePanel(): DrainagePanel { const response = await fetchDrainageBasins(projectId, chainages); basins = response.basins; mainBoundary = response.main_polygon_lonlat ?? []; + flowArrows = (response.flow_arrows ?? []) as FlowArrow[]; + arrowSpacingM = response.arrow_spacing_m ?? 0; // 계획도로선·2차 유역 외곽선은 B04 산출물을 그대로 받는다 — 여기서 다시 계산하지 않는다. // 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함). pipeEditor.setPipes(