Files
Aislo/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts
T

493 lines
19 KiB
TypeScript

import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
fetchGisGeoJson,
fetchVWorldMeta,
getVWorldMapUrl,
type SurfaceBounds,
type VWorldMeta,
} from "./B04_wf1_Surface_Api_Fetch";
import { niceScaleDistance } from "./B04_wf1_Surface_UI_Camera";
import {
computeMapRect,
createNormalizer,
drawPreparedLabels,
drawPreparedLayer,
prepareLayer,
type GeoJsonCollection,
type MapRect,
type PreparedLayer,
type ViewState,
} from "./B04_wf1_Surface_UI_MapRender";
export interface SurfaceMapViewer {
root: HTMLElement;
render: (projectId: string, referenceBounds?: SurfaceBounds) => void;
dispose: () => void;
}
const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const;
const GIS_LAYERS = [
"지적도",
"행정구역_시군구",
"행정구역_읍면동",
"등고선",
"도엽_등고선",
"도엽_하천중심선",
"도엽_표고점",
"도엽_성절토",
"도엽_옹벽석축",
"도엽_유수방향",
] as const;
type BackgroundLayer = (typeof BACKGROUND_LAYERS)[number];
type GisLayer = (typeof GIS_LAYERS)[number];
const GIS_LAYER_COLORS: Record<GisLayer, string> = {
지적도: "#f97316",
행정구역_시군구: "#7c3aed",
행정구역_읍면동: "#22c55e",
등고선: "#fdba74",
도엽_등고선: "#a5b4fc",
도엽_하천중심선: "#2563eb",
도엽_표고점: "#f9a8d4",
도엽_성절토: "#f43f5e",
도엽_옹벽석축: "#0f766e",
도엽_유수방향: "#0891b2",
};
// 등고 라벨 표기 대상 레이어와 표고 속성 키 (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";
const scaleBar = document.createElement("div");
scaleBar.className = "b04-map__scale";
const scaleText = document.createElement("span");
scaleBar.append(scaleText);
viewport.append(
...BACKGROUND_LAYERS.map((layer) => backgroundImages.get(layer)!),
canvas,
empty,
status,
scaleBar,
);
root.append(header, viewport);
let currentProjectId: string | null = null;
let referenceBounds: SurfaceBounds | null = null;
let meta: VWorldMeta | null = null;
// 사전 투영된 렌더용 레이어. 원본 GeoJSON은 변형하지 않으며 투영 후에는 참조를 잡아두지 않는다.
const preparedLayers = new Map<GisLayer, PreparedLayer>();
const activeBackgrounds = new Set<BackgroundLayer>(BACKGROUND_LAYERS);
// gpkg 등고선은 기본 꺼짐(도엽 등고선이 기본 표기), 등고 라벨은 기본 켜짐 (2026-07-26 사용자 지시)
const activeGisLayers = new Set<GisLayer>(GIS_LAYERS.filter((layer) => layer !== "등고선"));
let showContourLabels = 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회만 처리한다.
let frameHandle = 0;
let framePendingFull = false;
// 캔버스 버퍼는 크기가 실제로 변할 때만 재할당한다(재할당 시 내용이 지워지므로 매 프레임 금지).
let canvasWidth = 0;
let canvasHeight = 0;
let canvasDpr = 0;
// 비트맵 캐시: 캔버스에 마지막으로 풀 렌더한 시점의 뷰 상태.
// 팬/줌 제스처 중에는 재드로잉 대신 이 상태 대비 CSS transform만 적용하고,
// 제스처가 끝나면(settle) 현재 뷰로 다시 풀 렌더해 선명도를 복원한다.
let cachedScale = 0;
let cachedOffsetX = 0;
let cachedOffsetY = 0;
let hasCachedFrame = false;
let settleTimer = 0;
// 팬 여유분: 뷰포트 밖까지 미리 그려 두는 폭(px). 이 범위 안의 팬은 가장자리 공백 없이 즉시 표시된다.
const OVERSCAN = 256;
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"),
도엽_유수방향: L("B04_Surface_Map_SheetFlowDir"),
};
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 is-active";
contourLabelButton.textContent = L("B04_Surface_Map_ContourLabel");
contourLabelButton.setAttribute("aria-pressed", "true");
contourLabelButton.addEventListener("click", () => {
showContourLabels = !showContourLabels;
contourLabelButton.classList.toggle("is-active", showContourLabels);
contourLabelButton.setAttribute("aria-pressed", String(showContourLabels));
scheduleDraw();
});
gisButtons.append(contourLabelButton);
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();
}
function fitReferenceBounds(): void {
if (!meta || !referenceBounds) return;
const rect = viewport.getBoundingClientRect();
const width = Math.max(rect.width, 1);
const height = Math.max(rect.height, 1);
const mapRect = computeMapRect(meta, width, height);
const referenceWidth = Math.max(referenceBounds.x_max - referenceBounds.x_min, 1);
const referenceHeight = Math.max(referenceBounds.y_max - referenceBounds.y_min, 1);
scale =
Math.min(meta.width_meters / referenceWidth, meta.height_meters / referenceHeight) * 0.9;
const centerX = (referenceBounds.x_min + referenceBounds.x_max) / 2;
const centerY = (referenceBounds.y_min + referenceBounds.y_max) / 2;
const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width;
const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height;
offsetX = -(baseX - width / 2) * scale;
offsetY = -(baseY - height / 2) * scale;
}
function resetView(): void {
scale = 1;
offsetX = 0;
offsetY = 0;
fitReferenceBounds();
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;
// 버퍼 재할당은 캔버스 내용을 지우므로 크기가 실제로 변할 때만 수행한다.
// 버퍼는 뷰포트보다 OVERSCAN만큼 사방으로 크게 잡아, 제스처 중 팬 시 가장자리 공백을 막는다.
if (width !== canvasWidth || height !== canvasHeight || dpr !== canvasDpr) {
canvasWidth = width;
canvasHeight = height;
canvasDpr = dpr;
canvas.width = Math.floor((width + OVERSCAN * 2) * dpr);
canvas.height = Math.floor((height + OVERSCAN * 2) * dpr);
canvas.style.width = `${width + OVERSCAN * 2}px`;
canvas.style.height = `${height + OVERSCAN * 2}px`;
canvas.style.left = `${-OVERSCAN}px`;
canvas.style.top = `${-OVERSCAN}px`;
}
const context = canvas.getContext("2d");
if (!context) return;
// 뷰포트 좌표계로 그리되 버퍼 원점을 OVERSCAN만큼 밀어 여유분까지 채운다.
context.setTransform(dpr, 0, 0, dpr, OVERSCAN * dpr, OVERSCAN * dpr);
context.clearRect(-OVERSCAN, -OVERSCAN, width + OVERSCAN * 2, height + OVERSCAN * 2);
const mapRect = computeMapRect(meta, width, height);
const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect, overscan: OVERSCAN };
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]);
});
}
// 풀 렌더 완료 — 이 시점의 뷰 상태를 캐시 기준으로 삼고 제스처용 transform을 초기화한다.
canvas.style.transform = "";
cachedScale = scale;
cachedOffsetX = offsetX;
cachedOffsetY = offsetY;
hasCachedFrame = true;
updateImageTransform();
drawScaleBar(mapRect);
}
/**
* 제스처 중 프레임: 재드로잉 없이 캐시된 비트맵에 CSS transform만 적용한다.
* 캐시 시점 뷰(k0, o0)와 현재 뷰(k1, o1)의 관계는 뷰포트 중심 기준
* scale(r = k1/k0) + translate(o1 - o0·r)와 정확히 일치한다.
*/
function applyInteractiveTransform(): void {
if (!hasCachedFrame || !cachedScale) {
drawVectorLayer();
return;
}
const ratio = scale / cachedScale;
const tx = offsetX - cachedOffsetX * ratio;
const ty = offsetY - cachedOffsetY * ratio;
canvas.style.transform = `translate(${tx}px, ${ty}px) scale(${ratio})`;
updateImageTransform();
const rect = viewport.getBoundingClientRect();
drawScaleBar(computeMapRect(meta, Math.max(1, rect.width), Math.max(1, rect.height)));
}
/** 다음 프레임에 풀 렌더 1회 수행 (토글·리셋·로드·제스처 종료 등). */
function scheduleDraw(): void {
if (settleTimer) {
window.clearTimeout(settleTimer);
settleTimer = 0;
}
framePendingFull = true;
if (frameHandle) return;
frameHandle = window.requestAnimationFrame(runFrame);
}
/** 제스처 중: 다음 프레임에 transform만 갱신하고, 잠잠해지면 풀 렌더로 선명도를 복원한다. */
function scheduleInteractive(): void {
if (!frameHandle) frameHandle = window.requestAnimationFrame(runFrame);
if (settleTimer) window.clearTimeout(settleTimer);
settleTimer = window.setTimeout(() => {
settleTimer = 0;
scheduleDraw();
}, 150);
}
function runFrame(): void {
frameHandle = 0;
if (framePendingFull) {
framePendingFull = false;
drawVectorLayer();
} else {
applyInteractiveTransform();
}
}
async function loadLayers(): Promise<void> {
if (!currentProjectId) return;
const projectId = currentProjectId;
const sequence = ++loadSequence;
backgroundImages.forEach((image) => image.removeAttribute("src"));
meta = null;
preparedLayers.clear();
resetView();
status.textContent = L("B04_Surface_Map_Loading");
try {
const nextMeta = await fetchVWorldMeta(projectId, "satellite");
const loadedLayers = await Promise.all(
GIS_LAYERS.map(async (layer) => {
try {
const data = (await fetchGisGeoJson(projectId, layer)) as GeoJsonCollection;
return [layer, data] as const;
} catch {
return [layer, null] as const;
}
}),
);
if (sequence !== loadSequence) return;
meta = nextMeta;
// 좌표 변환은 여기서 1회만 수행하고, 이후 프레임은 사전 투영 결과만 사용한다.
const normalizer = createNormalizer(nextMeta);
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) => {
backgroundImages.get(layer)!.src = `${getVWorldMapUrl(projectId, layer)}&_t=${Date.now()}`;
});
status.textContent = L("B04_Surface_Map_Features").replace(
"{count}",
featureCount.toLocaleString(),
);
resetView();
syncLayerVisibility();
} catch (error) {
if (sequence !== loadSequence) return;
status.textContent = error instanceof Error ? error.message : L("B04_Surface_Map_LoadFailed");
}
}
resetButton.addEventListener("click", resetView);
viewport.addEventListener(
"wheel",
(event) => {
event.preventDefault();
scale = Math.min(8, Math.max(0.5, scale * (event.deltaY < 0 ? 1.15 : 0.87)));
scheduleInteractive();
},
{ passive: false },
);
viewport.addEventListener("pointerdown", (event) => {
dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY };
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;
scheduleInteractive();
});
const stopDragging = (): void => {
if (!dragStart) return;
dragStart = null;
// 팬 종료 즉시 현재 뷰로 풀 렌더 — 가장자리 여유분을 채우고 선명도를 복원한다.
scheduleDraw();
};
viewport.addEventListener("pointerup", stopDragging);
viewport.addEventListener("pointercancel", stopDragging);
const resizeObserver = new ResizeObserver(scheduleDraw);
resizeObserver.observe(viewport);
return {
root,
render(projectId, nextReferenceBounds) {
currentProjectId = projectId;
referenceBounds = nextReferenceBounds ?? null;
void loadLayers();
},
dispose() {
loadSequence += 1;
if (frameHandle) {
window.cancelAnimationFrame(frameHandle);
frameHandle = 0;
}
if (settleTimer) {
window.clearTimeout(settleTimer);
settleTimer = 0;
}
resizeObserver.disconnect();
},
};
}