Files
Aislo/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts
T
eomsangdonandClaude Opus 5.5 21afc379e4 feat(B04·B05): 하단 지도 · 평면 노선 편집 · 배수유역도 바탕 MapLibre 적용 — 공용 GL 부품 · TM 주인 카메라 · 처음 = 켬 · WebGL 못 쓰면 옛 캔버스 (PLAN 589-2 · 요구 448)
- 공용 부품 ui_template_gl_map(지도 세우기 · 자료 · 켬끔 · 색 다시 · 카메라) — D03 GL 껍데기를 올림 · D03 은 겹 꼴만(바뀜 없음)
- TM 주인 따라가기(B04_PreProcess_UI_MapGl · logic/_MapGl_Frame) — 화면 가운데 1차 맞춤 닮음꼴 카메라 + 남는 늘림(웹 메르카토르 가로세로 0.43 %)은 지도 칸 CSS 행렬 · 칸 둘레 여유 8 px
- B04 하단 지도 — 위성 조각(줌인 선명) · 백지도 · 혼합 · 도엽 · 지적 · 행정 · 등고선 겹 GL · 등고 글 · 배수유역 · 계획선 · 관 = 위 캔버스
- B05 배수유역도 — 위성 · 세부유역 채움(짝홀 다각형) · 분수령 · 도엽 GL · 번호 · 상류 세류 · 관 · 강도 = 위 캔버스
- B05 평면 노선 편집 — 바탕칠 · LAS · 도엽 등고선(간격 거름 · 1 m 선) · 하천(LAS 상자 자름) GL · 노선 둘레 띠 = 지도 칸 clip-path · 고른 등고선 · 글 · 노선 · 꼭짓점 = 위 캔버스
- 켬끔 칸(GPU · 세 화면 한 취향 aislo:pref:map.canvas) · WebGL 못 씀 · 잃음 = 옛 캔버스 + 알림 · 누름 · 끌기 판정 손 안 댐
- B04 층 단추 글 = MapLayers gisLayerLabel(지도 본체 700 줄)
- 지킴 시험 test_589_2_gl_frame(카메라 대 캔버스 0.5 px · 칸 행렬 늘이기만 · 되돌림 1 mm · 상자 자르기 · 짝홀 다각형)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:45:41 +09:00

695 lines
30 KiB
TypeScript

import { createButton } from "@ui/ui_template_elements";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
import { PAN_MOVE_PX, isPanButton, swallowNextMenu } from "../A00_Common/input_prefs";
import { createProgressCircle } from "@ui/ui_template_progress";
import { createToolbar } from "@ui/ui_template_toolbar";
import {
fetchPlannedRoute,
fetchVWorldMeta,
getVWorldMapUrl,
type VWorldMeta,
} from "./B04_PreProcess_Api_Fetch";
import { niceScaleDistance } from "./B04_PreProcess_UI_Camera";
import { createDetailBasinOverlay } from "./B04_PreProcess_UI_Basins";
import {
BACKGROUND_DEFAULT_ON,
BACKGROUND_LAYERS,
CLICK_SLOP_PX,
CONTOUR_LABEL_DEFAULT_ON,
CONTOUR_LABEL_KEYS,
GIS_DEFAULT_ON,
GIS_LAYERS,
fetchGisLayer,
gisLayerColor,
gisLayerLabel,
type BackgroundLayer,
type GisLayer,
} from "./B04_PreProcess_UI_MapLayers";
import { createSurfaceMapGl } from "./B04_PreProcess_UI_MapViewer_Gl";
import { createFlowStrengthOverlay } from "./B04_PreProcess_UI_FlowStrength";
import { createLayerToggle } from "./B04_PreProcess_UI_LayerToggle";
import { createWatershedOverlay } from "./B04_PreProcess_UI_Watershed";
import {
computeMapRect,
computeMaxScale,
computeRouteView,
createMetricProjector,
MAP_STATION_INTERVAL_M,
createTmNormalizer,
drawPreparedLabels,
drawPreparedLayer,
placeMapImage,
routeLineColor,
ROUTE_LINE_WIDTH,
type MapRect,
type Normalizer,
type PlanBounds,
type PreparedLayer,
type ViewState,
} from "./B04_PreProcess_UI_MapRender";
import { prepareLayer, prepareMetricPolyline } from "./logic/B04_PreProcess_UI_MapRender_Prepare";
import { drawStationTicks } from "./B04_PreProcess_UI_MapOverlays";
import type { WatershedAnalysis } from "./B04_PreProcess_Api_Fetch";
export interface SurfaceMapViewer {
root: HTMLElement;
/** routeBounds: 계획노선 평면 범위 — 초기 화면을 도로 중심으로 맞추는 데 쓴다. */
render: (projectId: string, routeBounds?: PlanBounds | null) => void;
/** 관 매설 지점·세부유역을 영구저장한다. 저장한 관 개수를 돌려준다(모델 확정과 함께 호출). */
commitDrainage: () => Promise<number>;
/** 관 지점을 고치고 아직 저장 안 했나(떠날 때 물음 · 572-2). */
drainageDirty: () => boolean;
dispose: () => void;
}
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export function createSurfaceMapViewer(): SurfaceMapViewer {
const root = document.createElement("section");
root.className = "ui-card b04-map";
root.dataset.pad = "16";
const header = document.createElement("div");
header.className = "b04-map__header";
const title = document.createElement("h3");
title.textContent = L("B04_Surface_Map_Title");
const controls = createToolbar({ items: [], size: 28 });
controls.classList.add("b04-map__controls");
const backgroundGroup = document.createElement("div");
backgroundGroup.className = "b04-map__control-group";
const backgroundTitle = document.createElement("span");
backgroundTitle.textContent = L("B04_Surface_Map_Background");
const backgroundButtons = document.createElement("div");
backgroundButtons.className = "b04-map__layer-buttons";
backgroundGroup.append(backgroundTitle, backgroundButtons);
const gisGroup = document.createElement("div");
gisGroup.className = "b04-map__control-group";
const gisTitle = document.createElement("span");
gisTitle.textContent = L("B04_Surface_Map_GisLayer");
const gisButtons = document.createElement("div");
gisButtons.className = "b04-map__layer-buttons";
gisGroup.append(gisTitle, gisButtons);
const resetButton = createButton({
label: L("B04_Surface_Map_Reset"),
variant: "ghost",
size: 28,
});
controls.append(backgroundGroup, gisGroup, resetButton);
header.append(title, controls);
const viewport = document.createElement("div");
viewport.className = "b04-map__viewport";
const backgroundImages = new Map<BackgroundLayer, HTMLImageElement>();
BACKGROUND_LAYERS.forEach((layer) => {
const image = document.createElement("img");
image.className = "b04-map__image";
image.alt = L("B04_Surface_Map_ImageAlt");
image.draggable = false;
image.addEventListener("load", () => scheduleDraw());
backgroundImages.set(layer, image);
});
const canvas = document.createElement("canvas");
canvas.className = "b04-map__canvas";
const empty = document.createElement("p");
empty.className = "b04-map__empty";
empty.textContent = L("B04_Surface_Map_Empty");
const status = document.createElement("span");
status.className = "b04-map__status";
// 지도 위 좌상단 묶음 — 배수유역 정보 자리다(2026-08-01 사용자 지시).
// 배경지도가 복잡해 글자가 묻히므로 각 문구에 배경 칩을 깐다.
const statusStack = document.createElement("div");
statusStack.className = "b04-map__status-stack";
// 흐름 강도 선택 요약 — 배수유역 상태 줄과 칸을 나눠 쓰면 서로 덮어쓴다.
const flowStatus = document.createElement("span");
flowStatus.className = "b04-map__watershed-status";
flowStatus.hidden = true;
// 객체 표시(지형지물 개수) 라벨은 우측 최하단으로 내린다 — 좌상단을 배수유역에 내주기 위함.
const statusCorner = document.createElement("div");
statusCorner.className = "b04-map__status-corner";
statusCorner.append(status);
// 우측 최상단 — 배수유역 "분석 중…" 진행 문구 자리(2026-08-01 사용자 지시).
// 좌상단 결과 줄을 지우지 않도록 자리를 따로 준다.
const statusTopRight = document.createElement("div");
statusTopRight.className = "b04-map__status-topright";
const scaleBar = document.createElement("div");
scaleBar.className = "b04-map__scale";
const scaleText = document.createElement("span");
scaleBar.append(scaleText);
// 지도 정중앙 로딩 서클 — 도엽 레이어가 10종이라 다 받을 때까지 화면이 비어 보인다.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
viewport.append(
...BACKGROUND_LAYERS.map((layer) => backgroundImages.get(layer)!),
canvas,
empty,
statusStack,
statusTopRight,
statusCorner,
scaleBar,
progress.root,
);
/** 진행률(0~1, 모르면 null)과 문구. label이 null이면 서클을 감춘다. */
function showProgress(ratio: number | null, label: string | null): void {
progress.root.hidden = label === null;
if (label !== null) progress.set(ratio, label);
}
root.append(header, viewport);
let currentProjectId: string | null = null;
let meta: VWorldMeta | null = null;
// 초기 화면 기준이 되는 계획노선 범위(B03 CSV). 없으면 배경 전체를 보여준다.
let routeBounds: PlanBounds | null = null;
// 배수유역 오버레이가 lon/lat을 화면 좌표로 옮길 때 쓴다. 레이어 로드 시 1회 만든다.
let normalizer: Normalizer | null = null;
// 사전 투영된 렌더용 레이어. 원본 GeoJSON은 변형하지 않으며 투영 후에는 참조를 잡아두지 않는다.
const preparedLayers = new Map<GisLayer, PreparedLayer>();
const activeBackgrounds = new Set<BackgroundLayer>(
BACKGROUND_LAYERS.filter((layer) => BACKGROUND_DEFAULT_ON[layer]),
);
const activeGisLayers = new Set<GisLayer>(GIS_LAYERS.filter((layer) => GIS_DEFAULT_ON[layer]));
let showContourLabels = CONTOUR_LABEL_DEFAULT_ON;
// 계획선(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;
let offsetY = 0;
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
/** 좌버튼을 누른 자리. 뗄 때까지 이만큼 이하로 움직였으면 클릭으로 본다. */
let clickStart: { x: number; y: number } | null = null;
let loadSequence = 0;
// rAF 스로틀: 팬/줌 이벤트는 상태만 갱신하고 프레임당 1회만 드로잉한다.
// LOD(MapRender) 덕에 프레임 렌더 비용이 낮아 매 프레임 직접 렌더가 항상 완전한 화면을 보장한다.
let frameHandle = 0;
// 캔버스 버퍼는 크기가 실제로 변할 때만 재할당한다(재할당 시 내용이 지워지므로 매 프레임 금지).
let canvasWidth = 0;
let canvasHeight = 0;
let canvasDpr = 0;
function makeLayerButton<T extends string>(
label: string,
activeLayers: Set<T>,
layer: T,
color?: string,
): HTMLButtonElement {
return createLayerToggle({
label,
color,
on: activeLayers.has(layer),
onChange: (on) => {
if (on) activeLayers.add(layer);
else activeLayers.delete(layer);
syncLayerVisibility();
},
}).root;
}
const backgroundLabels: Record<BackgroundLayer, string> = {
white: L("B04_Surface_Map_White"),
satellite: L("B04_Surface_Map_Satellite"),
hybrid: L("B04_Surface_Map_Hybrid"),
};
BACKGROUND_LAYERS.forEach((layer) => {
backgroundButtons.append(makeLayerButton(backgroundLabels[layer], activeBackgrounds, layer));
});
// 계획선 토글 — 도엽 레이어와 같은 양식으로 레이어 줄 맨 앞에 둔다. 색은 B05 배수유역도와
// 같은 주황(정의처: MapRender). 계획노선을 아직 올리지 않았으면 버튼이 눌리지 않는다.
const routeToggle = createLayerToggle({
label: L("B04_Surface_Map_PlannedRoute"),
color: routeLineColor(),
on: true,
onChange: (on) => {
showRoute = on;
scheduleDraw();
},
});
gisButtons.append(routeToggle.root);
GIS_LAYERS.forEach((layer) => {
gisButtons.append(
makeLayerButton(gisLayerLabel(layer), activeGisLayers, layer, gisLayerColor(layer)),
);
});
// 등고 라벨 보기/숨기기 (등고선·도엽 등고선의 계곡선 수치 표기)
const contourLabelToggle = createLayerToggle({
label: L("B04_Surface_Map_ContourLabel"),
on: CONTOUR_LABEL_DEFAULT_ON,
onChange: (on) => {
showContourLabels = on;
scheduleDraw();
},
});
gisButtons.append(contourLabelToggle.root);
const surfaceGl = createSurfaceMapGl(viewport, backgroundButtons, () => syncLayerVisibility());
// 배수유역 분석 오버레이 — 계산은 백엔드가 하고 여기서는 겹쳐 그리기만 한다.
// 상태 문구는 오버레이 전용 줄에 쓴다 — 지도 자체 상태(레이어 로딩)와 같은 칸을 쓰면
// 나중에 끝난 쪽이 상대 문구를 지워 버린다.
// 분석이 끝나면 강도 곡선·유입 집중점을 흐름 강도 오버레이로 넘긴다.
// 이 콜백은 갈래 토글을 누를 때도 불리므로, **분석 결과가 실제로 바뀐 경우에만** 넘긴다.
// 매번 넘기면 고른 집중점과 유입 외곽선이 버튼 한 번에 소리 없이 지워진다.
let lastAnalysis: WatershedAnalysis | null = null;
const watershed = createWatershedOverlay(() => {
const analysis = watershed.analysis();
if (analysis !== lastAnalysis) {
lastAnalysis = analysis;
if (analysis) flowStrength.setData(analysis.strength_profile, analysis.inflow_hotspots ?? []);
else flowStrength.clear();
}
scheduleDraw();
});
const watershedGroup = document.createElement("div");
watershedGroup.className = "b04-map__control-group";
const watershedTitle = document.createElement("span");
watershedTitle.textContent = "배수유역";
const watershedButtons = document.createElement("div");
watershedButtons.className = "b04-map__layer-buttons";
// 도로 유입 흐름 강도 — 배수유역 분석 결과를 받아 계획선 위에 색·마커로 얹는다.
const flowStrength = createFlowStrengthOverlay(() => {
flowStatus.textContent = flowStrength.status();
flowStatus.hidden = flowStatus.textContent === "";
scheduleDraw();
});
// 상세 배수유역 — 관 매설 지점 편집과 세부유역 분할. 계산은 버튼을 눌렀을 때만 돈다.
const detailBasins = createDetailBasinOverlay(() => {
scheduleDraw();
});
watershedButtons.append(
watershed.button,
...watershed.partButtons,
flowStrength.button,
flowStrength.markerButton,
detailBasins.button,
...detailBasins.partButtons,
);
watershedGroup.append(watershedTitle, watershedButtons);
controls.insertBefore(watershedGroup, resetButton);
// 안내·결과 문구는 컨트롤 줄이 아니라 지도 위에 얹는다 — 컨트롤 영역 세로 공간을 먹지 않는다.
statusStack.append(watershed.statusElement, flowStatus, detailBasins.statusElement);
statusTopRight.append(watershed.busyElement);
// 우클릭 메뉴는 뷰포트 기준 절대 위치라 뷰포트 안에 넣는다.
// 강도 색띠 범례도 같은 자리에 얹는다(CSS가 우측 세로로 세운다).
viewport.append(detailBasins.menuElement, flowStrength.legendElement);
/** 배경 그림을 TM 겹 자리로 놓음(589-1 · 공용 `placeMapImage`). */
function updateImageTransform(view: ViewState): void {
backgroundImages.forEach((image) => placeMapImage(image, meta, normalizer, view));
}
function syncLayerVisibility(): void {
backgroundImages.forEach((image, layer) => {
image.hidden = !activeBackgrounds.has(layer) || surfaceGl.on();
});
surfaceGl.sync(activeBackgrounds, activeGisLayers);
activeGisLayers.forEach((layer) => void loadLayerOnDemand(layer));
empty.hidden = activeBackgrounds.size > 0 || activeGisLayers.size > 0;
scheduleDraw();
}
/** 계획도로가 화면 중앙에 오고 도로 전체 + 여유 200m가 보이도록 맞춘다(B05와 같은 규칙).
*
* 라이다 범위에 맞추던 것을 도로 기준으로 바꿨다 — 3D(라이다)와 2D(지도)는 다루는 범위가
* 달라, 라이다에 맞추면 배경을 넓게 받아도 보이는 범위가 늘 같았다(2026-08-01 사용자 지시).
* 계획노선이 없으면 배경 전체를 그대로 보여준다. */
function fitRouteView(): void {
const rect = viewport.getBoundingClientRect();
const view = computeRouteView(
meta,
routeBounds,
Math.max(rect.width, 1),
Math.max(rect.height, 1),
);
scale = view.scale;
offsetX = view.offsetX;
offsetY = view.offsetY;
}
function resetView(): void {
fitRouteView();
scheduleDraw();
}
function drawScaleBar(mapRect: MapRect): void {
if (!meta || mapRect.width <= 0) {
scaleBar.hidden = true;
return;
}
const metersPerPixel = meta.width_meters / mapRect.width / scale;
const meters = niceScaleDistance(100 * metersPerPixel);
const pixels = meters / metersPerPixel;
scaleBar.hidden = false;
scaleBar.style.width = `${pixels}px`;
scaleText.textContent = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`;
}
// 등고선(전국 gpkg·도엽)은 선 수가 많아 가장 아래에 얇게 깔아 다른 레이어 판독을 방해하지 않게 한다.
const isContourLayer = (layer: GisLayer): boolean =>
layer === "등고선" || layer === "도엽_등고선";
const DRAW_ORDER = [...GIS_LAYERS].sort((a, b) =>
isContourLayer(a) ? -1 : isContourLayer(b) ? 1 : 0,
);
function drawVectorLayer(): void {
const rect = viewport.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width));
const height = Math.max(1, Math.floor(rect.height));
const dpr = window.devicePixelRatio || 1;
// 버퍼 재할당은 캔버스 내용을 지우므로 크기가 실제로 변할 때만 수행한다.
if (width !== canvasWidth || height !== canvasHeight || dpr !== canvasDpr) {
canvasWidth = width;
canvasHeight = height;
canvasDpr = dpr;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
}
const context = canvas.getContext("2d");
if (!context) return;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, width, height);
const mapRect = computeMapRect(meta, width, height);
const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect };
DRAW_ORDER.forEach((layer) => {
if (!activeGisLayers.has(layer) || surfaceGl.on()) return;
const prepared = preparedLayers.get(layer);
if (!prepared) return;
context.lineWidth = isContourLayer(layer) ? 0.7 : 1.5;
context.strokeStyle = gisLayerColor(layer);
drawPreparedLayer(context, prepared, view, layer === "도엽_표고점" ? "x" : "dot");
});
if (showContourLabels) {
context.font = "600 13px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
(Object.keys(CONTOUR_LABEL_KEYS) as GisLayer[]).forEach((layer) => {
if (!activeGisLayers.has(layer)) return;
const prepared = preparedLayers.get(layer);
if (prepared) drawPreparedLabels(context, prepared, view, gisLayerColor(layer));
});
}
// 배수유역 오버레이는 GIS 레이어 위에 얹는다 — 격자·화살표가 등고선을 덮어야 읽힌다.
if (normalizer) watershed.draw(context, normalizer, view);
// 계획선은 맨 위에 둔다 — 다른 레이어에 덮이면 노선이 어디로 지나는지 읽을 수 없다.
if (showRoute && routeLayer) {
context.lineWidth = ROUTE_LINE_WIDTH;
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);
// 세부유역 채움과 관 마커는 그 위 — 편집 대상이라 다른 레이어에 가려지면 집을 수 없다.
detailBasins.draw(context, normalizer, view);
// 유입 집중점(집중유역)은 맨 마지막 — 켰을 때 무엇에도 가리지 않아야 고를 수 있다.
flowStrength.drawTop(context, normalizer, view);
updateImageTransform(view);
surfaceGl.follow(meta, normalizer, view);
drawScaleBar(mapRect);
}
/** 팬/줌 등 연속 이벤트에서는 프레임당 1회만 실제 드로잉이 일어나게 한다. */
function scheduleDraw(): void {
if (frameHandle) return;
frameHandle = window.requestAnimationFrame(() => {
frameHandle = 0;
drawVectorLayer();
});
}
/** 켠 층이 아직 없으면 그때 받는다(같은 층 겹쳐 받기는 fetchGisLayer 가 막음). */
async function loadLayerOnDemand(layer: GisLayer): Promise<void> {
const projectId = currentProjectId;
if (!projectId || !meta || preparedLayers.has(layer)) return;
const data = await fetchGisLayer(projectId, layer);
if (!data || projectId !== currentProjectId || !normalizer) return;
preparedLayers.set(layer, prepareLayer(data, normalizer, CONTOUR_LABEL_KEYS[layer]));
surfaceGl.setLayer(layer, data);
scheduleDraw();
}
async function loadLayers(): Promise<void> {
if (!currentProjectId) return;
const projectId = currentProjectId;
const sequence = ++loadSequence;
backgroundImages.forEach((image) => image.removeAttribute("src"));
meta = null;
surfaceGl.setMeta(null, null);
preparedLayers.clear();
routeLayer = null;
routePoints = [];
resetView();
status.textContent = L("B04_Surface_Map_Loading");
showProgress(0, L("B04_Surface_Map_Loading"));
try {
// 계획선은 지도 메타와 같은 좌표계라 함께 받아 둔다. 실패해도 지도는 그대로 그린다.
const [nextMeta, planned] = await Promise.all([
fetchVWorldMeta(projectId, "satellite"),
fetchPlannedRoute(projectId).catch(() => ({ status: "error", points: [] })),
]);
// 켜진 층만 처음에 받는다(꺼진 층은 켤 때) — 끝나는 대로 진행률을 올린다.
const firstLayers = GIS_LAYERS.filter((layer) => activeGisLayers.has(layer));
let done = 0;
const loadedLayers = await Promise.all(
firstLayers.map(async (layer) => {
const data = await fetchGisLayer(projectId, layer);
done += 1;
if (sequence === loadSequence) {
showProgress(done / firstLayers.length, `도엽 레이어 ${done}/${firstLayers.length}`);
}
return [layer, data] as const;
}),
);
if (sequence !== loadSequence) return;
meta = nextMeta;
// 좌표 변환은 여기서 1회만 수행하고, 이후 프레임은 사전 투영 결과만 사용한다.
normalizer = createTmNormalizer(nextMeta);
surfaceGl.setMeta(projectId, nextMeta);
routeLayer =
planned.points.length > 1 ? prepareMetricPolyline(planned.points, nextMeta) : null;
routePoints = planned.points;
// 흐름 강도는 계획선 위에 칠하므로 같은 점 목록·같은 메타를 쓴다(어긋나면 색이 밀린다).
flowStrength.setRoute(planned.points, nextMeta);
// 관 마커도 같은 계획선 위에 스냅한다 — 목록이 다르면 마커가 노선을 벗어난다.
detailBasins.setRoute(planned.points, nextMeta);
// 계획노선을 아직 올리지 않은 프로젝트에서는 켤 것이 없으니 버튼을 잠근다.
routeToggle.root.disabled = routeLayer === null;
routeToggle.root.title = routeLayer === null ? L("B04_Surface_Map_PlannedRouteEmpty") : "";
let featureCount = 0;
loadedLayers.forEach(([layer, data]) => {
if (!data) return;
featureCount += data.features?.length ?? 0;
preparedLayers.set(layer, prepareLayer(data, normalizer!, CONTOUR_LABEL_KEYS[layer]));
surfaceGl.setLayer(layer, data);
});
BACKGROUND_LAYERS.forEach((layer) => {
// 주소에 시각을 붙이면 브라우저가 매번 다시 받는다. 서버가 ETag를 주므로 그대로 쓴다.
backgroundImages.get(layer)!.src = getVWorldMapUrl(projectId, layer);
});
status.textContent = L("B04_Surface_Map_Features").replace(
"{count}",
featureCount.toLocaleString(),
);
resetView();
syncLayerVisibility();
showProgress(null, null);
} catch (error) {
if (sequence !== loadSequence) return;
status.textContent = error instanceof Error ? error.message : L("B04_Surface_Map_LoadFailed");
showProgress(null, null);
}
}
resetButton.addEventListener("click", resetView);
viewport.addEventListener(
"wheel",
(event) => {
event.preventDefault();
const prevScale = scale;
// 휠을 **당기면 확대**, 밀면 축소한다(2026-08-02 사용자 지시). 일반 스크롤과 반대 방향이다.
// 상한은 「화면 폭 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 * (wheelZoomsIn(event) ? 1.15 : 0.87)));
// 마우스 커서 아래 지점이 줌 전후로 같은 화면 위치에 머물도록 offset 보정.
// screen = center + (base - center)·scale + offset 이므로,
// 커서 고정 조건을 풀면 offset' = (cursor - center)·(1 - r) + offset·r (r = scale'/scale).
const ratio = scale / prevScale;
const rect = viewport.getBoundingClientRect();
const cursorX = event.clientX - rect.left - rect.width / 2;
const cursorY = event.clientY - rect.top - rect.height / 2;
offsetX = cursorX * (1 - ratio) + offsetX * ratio;
offsetY = cursorY * (1 - ratio) + offsetY * ratio;
scheduleDraw();
},
{ passive: false },
);
/** 현재 화면 상태(팬·줌·지도 사각형). 마커 히트 판정과 그리기가 같은 값을 봐야 한다. */
function currentView(rect: DOMRect): ViewState {
const width = Math.max(1, Math.floor(rect.width));
const height = Math.max(1, Math.floor(rect.height));
return { width, height, scale, offsetX, offsetY, mapRect: computeMapRect(meta, width, height) };
}
/** 이벤트가 우클릭 메뉴 안에서 났는지. 메뉴는 뷰포트의 자식이라 지도 조작과 섞인다. */
function inContextMenu(event: Event): boolean {
const target = event.target;
return target instanceof Node && detailBasins.menuElement.contains(target);
}
viewport.addEventListener("contextmenu", (event) => {
if (inContextMenu(event)) {
// 메뉴 위에서 다시 우클릭하면 브라우저 메뉴만 막고 메뉴는 그대로 둔다.
event.preventDefault();
return;
}
const rect = viewport.getBoundingClientRect();
const opened = detailBasins.handleContextMenu(
currentView(rect),
event.clientX - rect.left,
event.clientY - rect.top,
);
// 계획선이나 관 마커 위에서만 전용 메뉴를 띄운다 — 그 밖은 아무 메뉴도 안 뜸(브라우저 메뉴는 공용 진입이 막음).
if (opened) event.preventDefault();
});
viewport.addEventListener("pointerdown", (event) => {
// 우클릭 메뉴 위에서 누른 것은 지도 조작이 아니다. 여기서 걸러 내지 않으면 메뉴를
// 닫는 처리가 먼저 돌아 항목의 click이 영영 발생하지 않는다(추가·삭제가 안 되던 원인).
if (inContextMenu(event)) return;
// 중간 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹쳐 페이지 전체를
// 흔들므로 기본 동작을 차단하고 지도 팬으로만 사용한다.
if (event.button === 1) event.preventDefault();
// 관 마커를 잡았으면 끌기로 넘어간다 — 지도 팬도, 마커 선택도 하지 않는다.
if (event.button === 0) {
const rect = viewport.getBoundingClientRect();
if (
detailBasins.handlePointerDown(
currentView(rect),
event.clientX - rect.left,
event.clientY - rect.top,
)
) {
clickStart = null;
viewport.setPointerCapture(event.pointerId);
return;
}
}
// 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 화면 위 객체를 고르는 데만 쓴다
// (좌버튼 드래그가 팬까지 겸하면 객체를 집으려다 지도가 딸려 움직인다 — 2026-08-01 사용자 지시).
// 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 할 수 있게 둔다.
// 누른 자리를 기억해 두었다가 pointerup에서 "끌지 않고 눌렀다 뗐다"면 클릭으로 처리한다.
// 터치·펜은 팬도 겸하므로 여기서 반환하지 않고 기록만 남긴다 — 그래야 손가락으로도 마커를
// 고를 수 있다(끌었으면 이동량 판정에서 걸러진다).
if (event.button === 0) clickStart = { x: event.clientX, y: event.clientY };
if (event.pointerType === "mouse" && !isPanButton(event.button)) {
// 팬 = ☰ [설정] 옮기기 버튼(가운데는 늘 · 588-4) — 왼쪽이면 끌지 않고 뗀 것은 위 고르기 그대로.
return;
}
dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY };
viewport.style.cursor = "grabbing";
viewport.setPointerCapture(event.pointerId);
});
viewport.addEventListener("pointerup", (event) => {
if (inContextMenu(event)) return;
// 관 마커를 끌던 중이었으면 그것으로 끝낸다 — 집중점 선택까지 겹쳐 일어나면 안 된다.
if (detailBasins.handlePointerUp()) return;
const start = clickStart;
clickStart = null;
if (!start || event.button !== 0) return;
if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > CLICK_SLOP_PX) return;
const rect = viewport.getBoundingClientRect();
const view = currentView(rect);
const localX = event.clientX - rect.left;
const localY = event.clientY - rect.top;
// 세부유역을 먼저 본다 — 유역 채움이 화면을 넓게 덮으므로 집중점 선택보다 앞선다.
if (detailBasins.handleClick(view, localX, localY)) return;
flowStrength.handleClick(normalizer, view, localX, localY);
});
viewport.addEventListener("pointermove", (event) => {
const rect = viewport.getBoundingClientRect();
if (
detailBasins.handlePointerMove(
currentView(rect),
event.clientX - rect.left,
event.clientY - rect.top,
)
) {
return;
}
if (!dragStart) return;
offsetX = dragStart.offsetX + event.clientX - dragStart.x;
offsetY = dragStart.offsetY + event.clientY - dragStart.y;
scheduleDraw();
});
const stopDragging = (event?: PointerEvent): void => {
// 오른쪽 끌어 옮김 뒤 메뉴 안 뜸(588-4)
if (dragStart && event?.button === 2) {
const moved = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (moved > PAN_MOVE_PX) swallowNextMenu();
}
dragStart = null;
viewport.style.removeProperty("cursor");
};
viewport.addEventListener("pointerup", stopDragging);
viewport.addEventListener("pointercancel", stopDragging);
const resizeObserver = new ResizeObserver(scheduleDraw);
resizeObserver.observe(viewport);
return {
root,
// 초기 화면은 계획노선 기준이다(라이다 범위는 쓰지 않는다).
render(projectId, nextRouteBounds) {
currentProjectId = projectId;
routeBounds = nextRouteBounds ?? null;
watershed.reset();
watershed.setProject(projectId);
flowStrength.clear();
flowStrength.setProject(projectId);
detailBasins.clear();
detailBasins.setProject(projectId);
void loadLayers();
},
/** 모델 확정과 함께 관 매설 지점·세부유역을 영구저장한다. */
commitDrainage: () => detailBasins.commit(),
drainageDirty: () => detailBasins.isDirty(),
dispose() {
loadSequence += 1;
if (frameHandle) {
window.cancelAnimationFrame(frameHandle);
frameHandle = 0;
}
resizeObserver.disconnect();
surfaceGl.dispose();
flowStrength.dispose();
detailBasins.dispose();
},
};
}