Files
Aislo/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts
T

545 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";
export interface SurfaceMapViewer {
root: HTMLElement;
render: (projectId: string, referenceBounds?: SurfaceBounds) => void;
dispose: () => void;
}
type GeoJsonGeometry = {
type: string;
coordinates: unknown;
};
type GeoJsonFeature = {
geometry?: GeoJsonGeometry | null;
properties?: Record<string, unknown> | null;
};
type GeoJsonCollection = {
features?: GeoJsonFeature[];
};
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;
const geoJsonLayers = new Map<GisLayer, GeoJsonCollection>();
const activeBackgrounds = new Set<BackgroundLayer>(BACKGROUND_LAYERS);
const activeGisLayers = new Set<GisLayer>(GIS_LAYERS);
let showContourLabels = false;
let scale = 1;
let offsetX = 0;
let offsetY = 0;
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
let loadSequence = 0;
function makeLayerButton<T extends string>(
label: string,
activeLayers: Set<T>,
layer: T,
color?: string,
): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "b04-map__layer-button is-active";
button.textContent = label;
button.setAttribute("aria-pressed", "true");
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";
contourLabelButton.textContent = L("B04_Surface_Map_ContourLabel");
contourLabelButton.setAttribute("aria-pressed", "false");
contourLabelButton.addEventListener("click", () => {
showContourLabels = !showContourLabels;
contourLabelButton.classList.toggle("is-active", showContourLabels);
contourLabelButton.setAttribute("aria-pressed", String(showContourLabels));
drawVectorLayer();
});
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;
drawVectorLayer();
}
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 = getMapRect(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();
drawVectorLayer();
}
function getMapRect(width: number, height: number): DOMRect {
if (!meta) return new DOMRect(0, 0, width, height);
const mapRatio = meta.width_meters / Math.max(meta.height_meters, 1);
const viewportRatio = width / Math.max(height, 1);
const mapWidth = mapRatio > viewportRatio ? width : height * mapRatio;
const mapHeight = mapRatio > viewportRatio ? width / mapRatio : height;
return new DOMRect((width - mapWidth) / 2, (height - mapHeight) / 2, mapWidth, mapHeight);
}
function toCanvasPoint(
lon: number,
lat: number,
width: number,
height: number,
): [number, number] {
if (!meta) return [0, 0];
const mapRect = getMapRect(width, height);
const lonRange = meta.lon_max - meta.lon_min || 1;
const latRange = meta.lat_max - meta.lat_min || 1;
const baseX = mapRect.x + ((lon - meta.lon_min) / lonRange) * mapRect.width;
const baseY = mapRect.y + mapRect.height * (1 - (lat - meta.lat_min) / latRange);
const centerX = width / 2;
const centerY = height / 2;
return [
centerX + (baseX - centerX) * scale + offsetX,
centerY + (baseY - centerY) * scale + offsetY,
];
}
function drawRing(
context: CanvasRenderingContext2D,
ring: unknown,
width: number,
height: number,
closed: boolean,
): void {
if (!Array.isArray(ring) || ring.length === 0) return;
const points = ring.filter(
(point): point is [number, number] =>
Array.isArray(point) && typeof point[0] === "number" && typeof point[1] === "number",
);
if (points.length === 0) return;
context.beginPath();
points.forEach(([lon, lat], index) => {
const [x, y] = toCanvasPoint(lon, lat, width, height);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
if (closed) context.closePath();
context.stroke();
}
function drawPoint(
context: CanvasRenderingContext2D,
coordinates: unknown,
width: number,
height: number,
marker: "dot" | "x" = "dot",
): void {
if (
!Array.isArray(coordinates) ||
typeof coordinates[0] !== "number" ||
typeof coordinates[1] !== "number"
) {
return;
}
const [x, y] = toCanvasPoint(coordinates[0], coordinates[1], width, height);
if (marker === "x") {
// 표고점: 조금 굵고 큰 X 마커
const arm = 4;
const prevWidth = context.lineWidth;
context.lineWidth = 2;
context.beginPath();
context.moveTo(x - arm, y - arm);
context.lineTo(x + arm, y + arm);
context.moveTo(x - arm, y + arm);
context.lineTo(x + arm, y - arm);
context.stroke();
context.lineWidth = prevWidth;
return;
}
context.beginPath();
context.arc(x, y, 2, 0, Math.PI * 2);
context.fillStyle = context.strokeStyle;
context.fill();
}
function contourLabelAnchor(geometry: GeoJsonGeometry): [number, number] | null {
const coords = geometry.coordinates;
if (!Array.isArray(coords)) return null;
const line =
geometry.type === "LineString"
? coords
: geometry.type === "MultiLineString"
? coords[0]
: null;
if (!Array.isArray(line) || line.length === 0) return null;
const mid = line[Math.floor(line.length / 2)];
if (!Array.isArray(mid) || typeof mid[0] !== "number" || typeof mid[1] !== "number") {
return null;
}
return [mid[0], mid[1]];
}
function drawContourLabels(
context: CanvasRenderingContext2D,
width: number,
height: number,
): void {
context.font = "10px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
(Object.keys(CONTOUR_LABEL_KEYS) as GisLayer[]).forEach((layer) => {
if (!activeGisLayers.has(layer)) return;
const keys = CONTOUR_LABEL_KEYS[layer] ?? [];
geoJsonLayers.get(layer)?.features?.forEach((feature) => {
if (!feature.geometry) return;
const raw = keys.map((key) => feature.properties?.[key]).find((value) => value != null);
const elevation = typeof raw === "number" ? raw : Number(raw);
// 계곡선(25m 배수)만 라벨 — 전체 표기 시 화면이 숫자로 뒤덮이는 것 방지
if (!Number.isFinite(elevation) || elevation % 25 !== 0) return;
const anchor = contourLabelAnchor(feature.geometry);
if (!anchor) return;
const [x, y] = toCanvasPoint(anchor[0], anchor[1], width, height);
context.lineWidth = 3;
context.strokeStyle = "rgba(255, 255, 255, 0.9)";
context.strokeText(String(elevation), x, y);
context.fillStyle = GIS_LAYER_COLORS[layer];
context.fillText(String(elevation), x, y);
});
});
}
function drawGeometry(
context: CanvasRenderingContext2D,
geometry: GeoJsonGeometry,
width: number,
height: number,
marker: "dot" | "x" = "dot",
): void {
const coordinates = geometry.coordinates;
if (!Array.isArray(coordinates)) return;
if (geometry.type === "Point") {
drawPoint(context, coordinates, width, height, marker);
} else if (geometry.type === "MultiPoint") {
coordinates.forEach((point) => drawPoint(context, point, width, height, marker));
} else if (geometry.type === "LineString") {
drawRing(context, coordinates, width, height, false);
} else if (geometry.type === "MultiLineString") {
coordinates.forEach((line) => drawRing(context, line, width, height, false));
} else if (geometry.type === "Polygon") {
coordinates.forEach((ring) => drawRing(context, ring, width, height, true));
} else if (geometry.type === "MultiPolygon") {
coordinates.forEach((polygon) => {
if (Array.isArray(polygon)) {
polygon.forEach((ring) => drawRing(context, ring, width, height, true));
}
});
}
}
function drawScaleBar(width: number, height: number): void {
if (!meta || width <= 0) {
scaleBar.hidden = true;
return;
}
const metersPerPixel = meta.width_meters / getMapRect(width, height).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`;
}
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;
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);
// 등고선(전국 gpkg·도엽)은 선 수가 많아 가장 아래에 얇게 깔아 다른 레이어 판독을 방해하지 않게 한다.
const isContour = (layer: GisLayer): boolean => layer === "등고선" || layer === "도엽_등고선";
const drawOrder = [...GIS_LAYERS].sort((a, b) => (isContour(a) ? -1 : isContour(b) ? 1 : 0));
drawOrder.forEach((layer) => {
if (!activeGisLayers.has(layer)) return;
context.lineWidth = isContour(layer) ? 0.7 : 1.5;
context.strokeStyle = GIS_LAYER_COLORS[layer];
const marker = layer === "도엽_표고점" ? "x" : "dot";
geoJsonLayers.get(layer)?.features?.forEach((feature) => {
if (feature.geometry) drawGeometry(context, feature.geometry, width, height, marker);
});
});
if (showContourLabels) drawContourLabels(context, width, height);
updateImageTransform();
drawScaleBar(width, height);
}
async function loadLayers(): Promise<void> {
if (!currentProjectId) return;
const projectId = currentProjectId;
const sequence = ++loadSequence;
backgroundImages.forEach((image) => image.removeAttribute("src"));
meta = null;
geoJsonLayers.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;
loadedLayers.forEach(([layer, data]) => {
if (data) geoJsonLayers.set(layer, data);
});
BACKGROUND_LAYERS.forEach((layer) => {
backgroundImages.get(layer)!.src = `${getVWorldMapUrl(projectId, layer)}&_t=${Date.now()}`;
});
const featureCount = [...geoJsonLayers.values()].reduce(
(sum, collection) => sum + (collection.features?.length ?? 0),
0,
);
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)));
drawVectorLayer();
},
{ 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;
drawVectorLayer();
});
const stopDragging = (): void => {
dragStart = null;
};
viewport.addEventListener("pointerup", stopDragging);
viewport.addEventListener("pointercancel", stopDragging);
const resizeObserver = new ResizeObserver(drawVectorLayer);
resizeObserver.observe(viewport);
return {
root,
render(projectId, nextReferenceBounds) {
currentProjectId = projectId;
referenceBounds = nextReferenceBounds ?? null;
void loadLayers();
},
dispose() {
loadSequence += 1;
resizeObserver.disconnect();
},
};
}