feat(B02/B03/B04/B05): 계획노선 사용 범위 · 절단 여유 3m · 배수유역도 줌·측점 표기

- 계획노선 사용 범위: B02 등록에 시작·종료 누가거리 두 칸 추가, B01 수정 모달에서도
  변경. projects.route_start_m·route_end_m 신설(015_route_range.sql).
  load_design_route 가 범위 절단 → 서피스 트림 순서로 적용. 시작 >= 종료는 화면·서버
  양쪽에서 차단. 비우면 전 구간으로 종전과 같음.
- 서피스 절단 여유 기본값 30m → 3m (SURFACE_ROUTE_EDGE_TRIM_M).
- B04 지도·B05 배수유역도 줌 상한을 「화면 폭 20m」 기준으로 계산(고정 8배·16배 폐지).
  4배를 넘으면 배경 그림 흐림 보간 해제.
- 계획선 위 측점 눈금·번호 표기(측점번호+잔여거리). 관 마커와 겹치면 반대쪽으로 밀고,
  되꺾임 구간에서 라벨이 겹치면 건너뜀. 그리기 코드는 두 화면 공용.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-04 19:08:11 +09:00
co-authored by Claude Opus 5
parent cc15a83f67
commit 4e4bfa2354
20 changed files with 412 additions and 36 deletions
+31 -2
View File
@@ -26,7 +26,10 @@ import { createFlowStrengthOverlay } from "./B04_PreProcess_UI_FlowStrength";
import { createWatershedOverlay } from "./B04_PreProcess_UI_Watershed";
import {
computeMapRect,
computeMaxScale,
computeRouteView,
createMetricProjector,
MAP_STATION_INTERVAL_M,
createNormalizer,
drawPreparedLabels,
drawPreparedLayer,
@@ -41,6 +44,7 @@ import {
type PreparedLayer,
type ViewState,
} from "./B04_PreProcess_UI_MapRender";
import { drawStationTicks } from "./B04_PreProcess_UI_MapOverlays";
import type { WatershedAnalysis } from "./B04_PreProcess_Api_Fetch";
export interface SurfaceMapViewer {
@@ -160,8 +164,10 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
);
const activeGisLayers = new Set<GisLayer>(GIS_LAYERS.filter((layer) => GIS_DEFAULT_ON[layer]));
let showContourLabels = CONTOUR_LABEL_DEFAULT_ON;
// 계획선(B03 업로드 계획노선) — 사업지 좌표계(m) 폴리라인을 배경 지도 위에 겹친다.
// 계획선(B03 계획노선 정본) — 사업지 좌표계(m) 폴리라인을 배경 지도 위에 겹친다.
let routeLayer: PreparedLayer | null = null;
// 측점 눈금·번호를 찍기 위한 원본 점 목록 (2026-09-04 사용자 지시).
let routePoints: ReadonlyArray<{ x: number; y: number }> = [];
let showRoute = true;
let scale = 1;
let offsetX = 0;
@@ -314,6 +320,9 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
function updateImageTransform(): void {
backgroundImages.forEach((image) => {
image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
// 크게 당기면 배경 그림이 뭉개진다 — 흐림 보간을 끄고 픽셀을 그대로 보인다
// (2026-09-04 사용자 지시). 실제 크기는 축척 막대로 읽는다.
image.style.imageRendering = scale > 4 ? "pixelated" : "auto";
});
}
@@ -416,6 +425,15 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
context.strokeStyle = routeLineColor();
drawPreparedLayer(context, routeLayer, view, "dot");
}
// 측점 눈금·번호 — 계획선 위, 유역 오버레이 아래. B05 배수유역도와 같은 규칙이다.
if (showRoute && meta && routePoints.length > 1) {
const projector = createMetricProjector(meta, view);
drawStationTicks(context, routePoints, {
intervalM: MAP_STATION_INTERVAL_M,
pxPerMeter: projector.pxPerMeter,
toScreen: projector.toScreen,
});
}
// 흐름 강도(도로 색·유입 집중점 마커)는 계획선 위에 얹는다.
flowStrength.draw(context, normalizer, view);
// 세부유역 채움과 관 마커는 그 위 — 편집 대상이라 다른 레이어에 가려지면 집을 수 없다.
@@ -443,6 +461,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
meta = null;
preparedLayers.clear();
routeLayer = null;
routePoints = [];
resetView();
status.textContent = L("B04_Surface_Map_Loading");
showProgress(0, L("B04_Surface_Map_Loading"));
@@ -480,6 +499,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
normalizer = createNormalizer(nextMeta);
routeLayer =
planned.points.length > 1 ? prepareMetricPolyline(planned.points, nextMeta) : null;
routePoints = planned.points;
// 흐름 강도는 계획선 위에 칠하므로 같은 점 목록·같은 메타를 쓴다(어긋나면 색이 밀린다).
flowStrength.setRoute(planned.points, nextMeta);
// 관 마커도 같은 계획선 위에 스냅한다 — 목록이 다르면 마커가 노선을 벗어난다.
@@ -518,7 +538,16 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
event.preventDefault();
const prevScale = scale;
// 휠을 **당기면 확대**, 밀면 축소한다(2026-08-02 사용자 지시). 일반 스크롤과 반대 방향이다.
scale = Math.min(8, Math.max(0.5, scale * (event.deltaY > 0 ? 1.15 : 0.87)));
// 상한은 「화면 폭 20m」로 계산한다 — 도엽 크기가 달라도 체감이 같다(2026-09-04 사용자 지시).
const wheelRect = viewport.getBoundingClientRect();
const wheelWidth = Math.max(1, Math.floor(wheelRect.width));
const maxScale = computeMaxScale(
meta,
computeMapRect(meta, wheelWidth, Math.max(1, Math.floor(wheelRect.height))).width,
wheelWidth,
8,
);
scale = Math.min(maxScale, Math.max(0.5, scale * (event.deltaY > 0 ? 1.15 : 0.87)));
// 마우스 커서 아래 지점이 줌 전후로 같은 화면 위치에 머물도록 offset 보정.
// screen = center + (base - center)·scale + offset 이므로,
// 커서 고정 조건을 풀면 offset' = (cursor - center)·(1 - r) + offset·r (r = scale'/scale).