- 진행단계 오버레이 버튼과 B그룹 페이지 제목을 파일입력/전처리/종단설계/횡단설계/
상세설계/수량산출/설계도서로 통일(ko/en). i18n 키는 유지하고 값만 교체.
B05 페이지 제목의 하드코딩 문구를 locale 참조로 교체.
- ui_template_resizer 공통 스플리터 신설. B05 하단 종단 패널은 위쪽 경계를 끌어
높이 조절(늘어난 만큼 그래프만 신축, 도면 테이블 높이는 고정), 안쪽 우측
배수유역 패널은 왼쪽 경계를 끌어 폭 조절(상한 = 하단 패널 폭의 70%).
크기는 sessionStorage에만 저장하고 CSS 변수로 적용해 접기 규칙과 충돌하지 않게 함.
- B04 2D 지도에 계획선 레이어 추가. 계획노선 좌표를 주는
GET /api/projects/{id}/planned-route 신설(B03 업로드 CSV → 사업지 좌표계 m).
선 색·굵기는 MapRender에 상수로 두고 B05 배수유역도와 공유.
- B04 지도 오버레이 재배치: 객체 표시 라벨은 우측 최하단, 배수유역 정보는 좌측 최상단.
- B04 지도·B05 배수유역도에서 좌버튼 드래그 팬 제거(가운데 버튼 전용).
유역선 핸들·배관 마커 편집은 좌버튼 그대로 유지, 팬 중에만 grabbing 커서.
- B05 배수유역도에 위성사진 표시 토글 추가(등고선 앞, 기본 on).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
569 lines
24 KiB
TypeScript
569 lines
24 KiB
TypeScript
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
|
import { createProgressCircle } from "@ui/ui_template_progress";
|
|
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
|
import {
|
|
fetchGisGeoJson,
|
|
fetchPlannedRoute,
|
|
fetchVWorldMeta,
|
|
getVWorldMapUrl,
|
|
type VWorldMeta,
|
|
} from "./B04_wf1_Surface_Api_Fetch";
|
|
import { niceScaleDistance } from "./B04_wf1_Surface_UI_Camera";
|
|
import { createWatershedOverlay } from "./B04_wf1_Surface_UI_Watershed";
|
|
import {
|
|
computeMapRect,
|
|
computeRouteView,
|
|
createNormalizer,
|
|
drawPreparedLabels,
|
|
drawPreparedLayer,
|
|
prepareLayer,
|
|
prepareMetricPolyline,
|
|
ROUTE_LINE_COLOR,
|
|
ROUTE_LINE_WIDTH,
|
|
type GeoJsonCollection,
|
|
type MapRect,
|
|
type Normalizer,
|
|
type PlanBounds,
|
|
type PreparedLayer,
|
|
type ViewState,
|
|
} from "./B04_wf1_Surface_UI_MapRender";
|
|
|
|
export interface SurfaceMapViewer {
|
|
root: HTMLElement;
|
|
/** routeBounds: 계획노선 평면 범위 — 초기 화면을 도로 중심으로 맞추는 데 쓴다. */
|
|
render: (projectId: string, routeBounds?: PlanBounds | null) => void;
|
|
dispose: () => void;
|
|
}
|
|
|
|
const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const;
|
|
// 유수방향은 화면에 쓰지 않기로 해 목록에서 뺐다(2026-08-01 사용자 지시).
|
|
const GIS_LAYERS = [
|
|
"지적도",
|
|
"행정구역_시군구",
|
|
"행정구역_읍면동",
|
|
"등고선",
|
|
"도엽_등고선",
|
|
"도엽_하천중심선",
|
|
"도엽_표고점",
|
|
"도엽_성절토",
|
|
"도엽_옹벽석축",
|
|
] as const;
|
|
type BackgroundLayer = (typeof BACKGROUND_LAYERS)[number];
|
|
type GisLayer = (typeof GIS_LAYERS)[number];
|
|
|
|
/* ── 처음 열었을 때 켜져 있을 레이어 (2026-08-01 사용자 지정) ──────────────
|
|
* 지도가 복잡해지지 않도록 실제로 자주 보는 것만 켠 채로 시작한다.
|
|
* 나머지는 버튼으로 그때그때 켠다. */
|
|
const BACKGROUND_DEFAULT_ON: Record<BackgroundLayer, boolean> = {
|
|
white: true,
|
|
satellite: true,
|
|
hybrid: false,
|
|
};
|
|
|
|
const GIS_DEFAULT_ON: Record<GisLayer, boolean> = {
|
|
지적도: false,
|
|
행정구역_시군구: true,
|
|
행정구역_읍면동: true,
|
|
등고선: false,
|
|
도엽_등고선: false,
|
|
도엽_하천중심선: true,
|
|
도엽_표고점: false,
|
|
도엽_성절토: false,
|
|
도엽_옹벽석축: false,
|
|
};
|
|
|
|
/** 등고 라벨(계곡선 수치) 기본 표시 여부. */
|
|
const CONTOUR_LABEL_DEFAULT_ON = false;
|
|
|
|
const GIS_LAYER_COLORS: Record<GisLayer, string> = {
|
|
지적도: "#f97316",
|
|
행정구역_시군구: "#7c3aed",
|
|
행정구역_읍면동: "#22c55e",
|
|
등고선: "#fdba74",
|
|
도엽_등고선: "#a5b4fc",
|
|
도엽_하천중심선: "#2563eb",
|
|
도엽_표고점: "#f9a8d4",
|
|
도엽_성절토: "#f43f5e",
|
|
도엽_옹벽석축: "#0f766e",
|
|
};
|
|
|
|
// 등고 라벨 표기 대상 레이어와 표고 속성 키 (gpkg=CTRLN_HG, 도엽=등고수치)
|
|
const CONTOUR_LABEL_KEYS: Partial<Record<GisLayer, string[]>> = {
|
|
등고선: ["CTRLN_HG"],
|
|
도엽_등고선: ["등고수치"],
|
|
};
|
|
|
|
function L(key: keyof typeof ui_locales): string {
|
|
return ui_locales[key][currentLanguageIndex];
|
|
}
|
|
|
|
export function createSurfaceMapViewer(): SurfaceMapViewer {
|
|
const root = document.createElement("section");
|
|
root.className = "b04-map";
|
|
|
|
const header = document.createElement("div");
|
|
header.className = "b04-map__header";
|
|
const title = document.createElement("h3");
|
|
title.textContent = L("B04_Surface_Map_Title");
|
|
|
|
const controls = document.createElement("div");
|
|
controls.className = "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 = document.createElement("button");
|
|
resetButton.type = "button";
|
|
resetButton.textContent = L("B04_Surface_Map_Reset");
|
|
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;
|
|
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 statusCorner = document.createElement("div");
|
|
statusCorner.className = "b04-map__status-corner";
|
|
statusCorner.append(status);
|
|
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,
|
|
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;
|
|
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 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 {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
const initialActive = activeLayers.has(layer);
|
|
button.className = "b04-map__layer-button" + (initialActive ? " is-active" : "");
|
|
button.textContent = label;
|
|
button.setAttribute("aria-pressed", String(initialActive));
|
|
if (color) {
|
|
button.classList.add("b04-map__layer-button--gis");
|
|
button.style.setProperty("--b04-layer-color", color);
|
|
}
|
|
button.addEventListener("click", () => {
|
|
if (activeLayers.has(layer)) activeLayers.delete(layer);
|
|
else activeLayers.add(layer);
|
|
const isActive = activeLayers.has(layer);
|
|
button.classList.toggle("is-active", isActive);
|
|
button.setAttribute("aria-pressed", String(isActive));
|
|
syncLayerVisibility();
|
|
});
|
|
return button;
|
|
}
|
|
|
|
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));
|
|
});
|
|
|
|
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 routeButton = document.createElement("button");
|
|
routeButton.type = "button";
|
|
routeButton.className = "b04-map__layer-button b04-map__layer-button--gis is-active";
|
|
routeButton.textContent = L("B04_Surface_Map_PlannedRoute");
|
|
routeButton.style.setProperty("--b04-layer-color", ROUTE_LINE_COLOR);
|
|
routeButton.setAttribute("aria-pressed", "true");
|
|
routeButton.addEventListener("click", () => {
|
|
showRoute = !showRoute;
|
|
routeButton.classList.toggle("is-active", showRoute);
|
|
routeButton.setAttribute("aria-pressed", String(showRoute));
|
|
scheduleDraw();
|
|
});
|
|
gisButtons.append(routeButton);
|
|
|
|
GIS_LAYERS.forEach((layer) => {
|
|
gisButtons.append(
|
|
makeLayerButton(gisLabels[layer], activeGisLayers, layer, GIS_LAYER_COLORS[layer]),
|
|
);
|
|
});
|
|
|
|
// 등고 라벨 보기/숨기기 (등고선·도엽 등고선의 계곡선 수치 표기)
|
|
const contourLabelButton = document.createElement("button");
|
|
contourLabelButton.type = "button";
|
|
contourLabelButton.className =
|
|
"b04-map__layer-button" + (CONTOUR_LABEL_DEFAULT_ON ? " is-active" : "");
|
|
contourLabelButton.textContent = L("B04_Surface_Map_ContourLabel");
|
|
contourLabelButton.setAttribute("aria-pressed", String(CONTOUR_LABEL_DEFAULT_ON));
|
|
contourLabelButton.addEventListener("click", () => {
|
|
showContourLabels = !showContourLabels;
|
|
contourLabelButton.classList.toggle("is-active", showContourLabels);
|
|
contourLabelButton.setAttribute("aria-pressed", String(showContourLabels));
|
|
scheduleDraw();
|
|
});
|
|
gisButtons.append(contourLabelButton);
|
|
|
|
// 배수유역 분석 오버레이 — 계산은 백엔드가 하고 여기서는 겹쳐 그리기만 한다.
|
|
// 상태 문구는 오버레이 전용 줄에 쓴다 — 지도 자체 상태(레이어 로딩)와 같은 칸을 쓰면
|
|
// 나중에 끝난 쪽이 상대 문구를 지워 버린다.
|
|
const watershed = createWatershedOverlay(() => 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";
|
|
watershedButtons.append(watershed.button, ...watershed.partButtons);
|
|
watershedGroup.append(watershedTitle, watershedButtons);
|
|
controls.insertBefore(watershedGroup, resetButton);
|
|
// 안내·결과 문구는 컨트롤 줄이 아니라 지도 위에 얹는다 — 컨트롤 영역 세로 공간을 먹지 않는다.
|
|
statusStack.append(watershed.statusElement);
|
|
|
|
function updateImageTransform(): void {
|
|
backgroundImages.forEach((image) => {
|
|
image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
|
|
});
|
|
}
|
|
|
|
function syncLayerVisibility(): void {
|
|
backgroundImages.forEach((image, layer) => {
|
|
image.hidden = !activeBackgrounds.has(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();
|
|
updateImageTransform();
|
|
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)) return;
|
|
const prepared = preparedLayers.get(layer);
|
|
if (!prepared) return;
|
|
context.lineWidth = isContourLayer(layer) ? 0.7 : 1.5;
|
|
context.strokeStyle = GIS_LAYER_COLORS[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, GIS_LAYER_COLORS[layer]);
|
|
});
|
|
}
|
|
// 배수유역 오버레이는 GIS 레이어 위에 얹는다 — 격자·화살표가 등고선을 덮어야 읽힌다.
|
|
if (normalizer) watershed.draw(context, normalizer, view);
|
|
// 계획선은 맨 위에 둔다 — 다른 레이어에 덮이면 노선이 어디로 지나는지 읽을 수 없다.
|
|
if (showRoute && routeLayer) {
|
|
context.lineWidth = ROUTE_LINE_WIDTH;
|
|
context.strokeStyle = ROUTE_LINE_COLOR;
|
|
drawPreparedLayer(context, routeLayer, view, "dot");
|
|
}
|
|
updateImageTransform();
|
|
drawScaleBar(mapRect);
|
|
}
|
|
|
|
/** 팬/줌 등 연속 이벤트에서는 프레임당 1회만 실제 드로잉이 일어나게 한다. */
|
|
function scheduleDraw(): void {
|
|
if (frameHandle) return;
|
|
frameHandle = window.requestAnimationFrame(() => {
|
|
frameHandle = 0;
|
|
drawVectorLayer();
|
|
});
|
|
}
|
|
|
|
async function loadLayers(): Promise<void> {
|
|
if (!currentProjectId) return;
|
|
const projectId = currentProjectId;
|
|
const sequence = ++loadSequence;
|
|
backgroundImages.forEach((image) => image.removeAttribute("src"));
|
|
meta = null;
|
|
preparedLayers.clear();
|
|
routeLayer = null;
|
|
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: [] })),
|
|
]);
|
|
// 레이어가 끝나는 대로 진행률을 올린다 — 10종을 다 받을 때까지 화면이 비어 있어서다.
|
|
let done = 0;
|
|
const loadedLayers = await Promise.all(
|
|
GIS_LAYERS.map(async (layer) => {
|
|
try {
|
|
// 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다.
|
|
const data = (
|
|
layer.startsWith("도엽_")
|
|
? await fetchCachedSheetLayer<GeoJsonCollection>(projectId, layer)
|
|
: await fetchGisGeoJson(projectId, layer)
|
|
) as GeoJsonCollection;
|
|
return [layer, data] as const;
|
|
} catch {
|
|
return [layer, null] as const;
|
|
} finally {
|
|
done += 1;
|
|
if (sequence === loadSequence) {
|
|
showProgress(done / GIS_LAYERS.length, `도엽 레이어 ${done}/${GIS_LAYERS.length}`);
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
if (sequence !== loadSequence) return;
|
|
meta = nextMeta;
|
|
// 좌표 변환은 여기서 1회만 수행하고, 이후 프레임은 사전 투영 결과만 사용한다.
|
|
normalizer = createNormalizer(nextMeta);
|
|
routeLayer =
|
|
planned.points.length > 1 ? prepareMetricPolyline(planned.points, nextMeta) : null;
|
|
// 계획노선을 아직 올리지 않은 프로젝트에서는 켤 것이 없으니 버튼을 잠근다.
|
|
routeButton.disabled = routeLayer === null;
|
|
routeButton.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]));
|
|
});
|
|
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;
|
|
scale = Math.min(8, 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).
|
|
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 },
|
|
);
|
|
viewport.addEventListener("pointerdown", (event) => {
|
|
// 중간 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹쳐 페이지 전체를
|
|
// 흔들므로 기본 동작을 차단하고 지도 팬으로만 사용한다.
|
|
if (event.button === 1) event.preventDefault();
|
|
// 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 화면 위 객체를 고르는 데만 쓴다
|
|
// (좌버튼 드래그가 팬까지 겸하면 객체를 집으려다 지도가 딸려 움직인다 — 2026-08-01 사용자 지시).
|
|
// 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 할 수 있게 둔다.
|
|
if (event.pointerType === "mouse" && event.button !== 1) return;
|
|
dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY };
|
|
viewport.style.cursor = "grabbing";
|
|
viewport.setPointerCapture(event.pointerId);
|
|
});
|
|
viewport.addEventListener("pointermove", (event) => {
|
|
if (!dragStart) return;
|
|
offsetX = dragStart.offsetX + event.clientX - dragStart.x;
|
|
offsetY = dragStart.offsetY + event.clientY - dragStart.y;
|
|
scheduleDraw();
|
|
});
|
|
const stopDragging = (): void => {
|
|
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);
|
|
void loadLayers();
|
|
},
|
|
dispose() {
|
|
loadSequence += 1;
|
|
if (frameHandle) {
|
|
window.cancelAnimationFrame(frameHandle);
|
|
frameHandle = 0;
|
|
}
|
|
resizeObserver.disconnect();
|
|
},
|
|
};
|
|
}
|