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>
This commit is contained in:
2026-10-29 21:45:41 +09:00
co-authored by Claude Opus 5.5
parent 49255c07cb
commit 21afc379e4
20 changed files with 1534 additions and 201 deletions
+13 -14
View File
@@ -22,9 +22,11 @@ import {
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";
@@ -220,17 +222,6 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
backgroundButtons.append(makeLayerButton(backgroundLabels[layer], activeBackgrounds, layer));
});
const gisLabels: Record<GisLayer, string> = {
지적도: L("B04_Surface_Map_Cadastral"),
행정구역_시군구: L("B04_Surface_Map_Sigungu"),
행정구역_읍면동: L("B04_Surface_Map_Eupmyeondong"),
등고선: L("B04_Surface_Map_Contour"),
도엽_등고선: L("B04_Surface_Map_SheetContour"),
도엽_하천중심선: L("B04_Surface_Map_SheetStream"),
도엽_표고점: L("B04_Surface_Map_SheetElevPoint"),
도엽_성절토: L("B04_Surface_Map_SheetCutFill"),
도엽_옹벽석축: L("B04_Surface_Map_SheetWall"),
};
// 계획선 토글 — 도엽 레이어와 같은 양식으로 레이어 줄 맨 앞에 둔다. 색은 B05 배수유역도와
// 같은 주황(정의처: MapRender). 계획노선을 아직 올리지 않았으면 버튼이 눌리지 않는다.
const routeToggle = createLayerToggle({
@@ -246,7 +237,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
GIS_LAYERS.forEach((layer) => {
gisButtons.append(
makeLayerButton(gisLabels[layer], activeGisLayers, layer, gisLayerColor(layer)),
makeLayerButton(gisLayerLabel(layer), activeGisLayers, layer, gisLayerColor(layer)),
);
});
@@ -260,6 +251,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
},
});
gisButtons.append(contourLabelToggle.root);
const surfaceGl = createSurfaceMapGl(viewport, backgroundButtons, () => syncLayerVisibility());
// 배수유역 분석 오버레이 — 계산은 백엔드가 하고 여기서는 겹쳐 그리기만 한다.
// 상태 문구는 오버레이 전용 줄에 쓴다 — 지도 자체 상태(레이어 로딩)와 같은 칸을 쓰면
@@ -317,8 +309,9 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
function syncLayerVisibility(): void {
backgroundImages.forEach((image, layer) => {
image.hidden = !activeBackgrounds.has(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();
@@ -389,7 +382,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
const mapRect = computeMapRect(meta, width, height);
const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect };
DRAW_ORDER.forEach((layer) => {
if (!activeGisLayers.has(layer)) return;
if (!activeGisLayers.has(layer) || surfaceGl.on()) return;
const prepared = preparedLayers.get(layer);
if (!prepared) return;
context.lineWidth = isContourLayer(layer) ? 0.7 : 1.5;
@@ -430,6 +423,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
// 유입 집중점(집중유역)은 맨 마지막 — 켰을 때 무엇에도 가리지 않아야 고를 수 있다.
flowStrength.drawTop(context, normalizer, view);
updateImageTransform(view);
surfaceGl.follow(meta, normalizer, view);
drawScaleBar(mapRect);
}
@@ -449,6 +443,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
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();
}
@@ -458,6 +453,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
const sequence = ++loadSequence;
backgroundImages.forEach((image) => image.removeAttribute("src"));
meta = null;
surfaceGl.setMeta(null, null);
preparedLayers.clear();
routeLayer = null;
routePoints = [];
@@ -487,6 +483,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
meta = nextMeta;
// 좌표 변환은 여기서 1회만 수행하고, 이후 프레임은 사전 투영 결과만 사용한다.
normalizer = createTmNormalizer(nextMeta);
surfaceGl.setMeta(projectId, nextMeta);
routeLayer =
planned.points.length > 1 ? prepareMetricPolyline(planned.points, nextMeta) : null;
routePoints = planned.points;
@@ -502,6 +499,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
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를 주므로 그대로 쓴다.
@@ -688,6 +686,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
frameHandle = 0;
}
resizeObserver.disconnect();
surfaceGl.dispose();
flowStrength.dispose();
detailBasins.dispose();
},