Files
Aislo/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts
T
2026-07-17 17:16:09 +09:00

327 lines
11 KiB
TypeScript

import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
fetchGisGeoJson,
fetchVWorldMeta,
getVWorldMapUrl,
type VWorldMeta,
} from "./B04_wf1_Surface_Api_Fetch";
export interface SurfaceMapViewer {
root: HTMLElement;
render: (projectId: string) => void;
dispose: () => void;
}
type GeoJsonGeometry = {
type: string;
coordinates: unknown;
};
type GeoJsonFeature = {
geometry?: GeoJsonGeometry | null;
};
type GeoJsonCollection = {
features?: GeoJsonFeature[];
};
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
function makeOption(value: string, label: string): HTMLOptionElement {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
return option;
}
function prettyScaleDistance(roughMeters: number): number {
const candidates = [2, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000];
return candidates.find((value) => value >= roughMeters) ?? candidates[candidates.length - 1];
}
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 backgroundLabel = document.createElement("label");
backgroundLabel.textContent = L("B04_Surface_Map_Background");
const backgroundSelect = document.createElement("select");
backgroundSelect.append(
makeOption("none", L("B04_Surface_Map_None")),
makeOption("satellite", L("B04_Surface_Map_Satellite")),
makeOption("hybrid", L("B04_Surface_Map_Hybrid")),
makeOption("white", L("B04_Surface_Map_White")),
);
backgroundSelect.value = "satellite";
backgroundLabel.append(backgroundSelect);
const gisLabel = document.createElement("label");
gisLabel.textContent = L("B04_Surface_Map_GisLayer");
const gisSelect = document.createElement("select");
gisSelect.append(
makeOption("none", L("B04_Surface_Map_None")),
makeOption("지적도", L("B04_Surface_Map_Cadastral")),
makeOption("수계망", L("B04_Surface_Map_Water")),
makeOption("산사태", L("B04_Surface_Map_Landslide")),
makeOption("행정구역_시군구", L("B04_Surface_Map_Sigungu")),
makeOption("행정구역_읍면동", L("B04_Surface_Map_Eupmyeondong")),
);
gisSelect.value = "지적도";
gisLabel.append(gisSelect);
const resetButton = document.createElement("button");
resetButton.type = "button";
resetButton.textContent = L("B04_Surface_Map_Reset");
controls.append(backgroundLabel, gisLabel, resetButton);
header.append(title, controls);
const viewport = document.createElement("div");
viewport.className = "b04-map__viewport";
const image = document.createElement("img");
image.className = "b04-map__image";
image.alt = L("B04_Surface_Map_ImageAlt");
image.draggable = false;
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(image, canvas, empty, status, scaleBar);
root.append(header, viewport);
let currentProjectId: string | null = null;
let meta: VWorldMeta | null = null;
let geoJson: GeoJsonCollection | null = null;
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 updateImageTransform(): void {
image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
}
function resetView(): void {
scale = 1;
offsetX = 0;
offsetY = 0;
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,
fill: 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 (fill) {
context.closePath();
context.save();
context.globalAlpha = 0.16;
context.fill();
context.restore();
}
context.stroke();
}
function drawGeometry(
context: CanvasRenderingContext2D,
geometry: GeoJsonGeometry,
width: number,
height: number,
): void {
const coordinates = geometry.coordinates;
if (!Array.isArray(coordinates)) return;
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 = prettyScaleDistance(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);
const styles = getComputedStyle(root);
context.strokeStyle = styles.getPropertyValue("--b04-map-vector").trim();
context.fillStyle = styles.getPropertyValue("--b04-map-vector").trim();
context.lineWidth = 1.5;
geoJson?.features?.forEach((feature) => {
if (feature.geometry) drawGeometry(context, feature.geometry, width, height);
});
updateImageTransform();
drawScaleBar(width, height);
}
async function loadLayers(): Promise<void> {
if (!currentProjectId) return;
const sequence = ++loadSequence;
const background = backgroundSelect.value;
const gisLayer = gisSelect.value;
empty.hidden = background !== "none" || gisLayer !== "none";
image.hidden = background === "none";
image.removeAttribute("src");
meta = null;
geoJson = null;
resetView();
if (background === "none" && gisLayer === "none") {
status.textContent = "";
return;
}
status.textContent = L("B04_Surface_Map_Loading");
const mapLayer = background === "none" ? "white" : background;
try {
const [nextMeta, nextGeoJson] = await Promise.all([
fetchVWorldMeta(currentProjectId, mapLayer),
gisLayer === "none" ? Promise.resolve(null) : fetchGisGeoJson(currentProjectId, gisLayer),
]);
if (sequence !== loadSequence) return;
meta = nextMeta;
geoJson = nextGeoJson as GeoJsonCollection | null;
if (background !== "none") {
image.src = `${getVWorldMapUrl(currentProjectId, mapLayer)}&_t=${Date.now()}`;
}
status.textContent = geoJson?.features
? L("B04_Surface_Map_Features").replace("{count}", geoJson.features.length.toLocaleString())
: "";
drawVectorLayer();
} catch (error) {
if (sequence !== loadSequence) return;
status.textContent = error instanceof Error ? error.message : L("B04_Surface_Map_LoadFailed");
}
}
backgroundSelect.addEventListener("change", () => void loadLayers());
gisSelect.addEventListener("change", () => void loadLayers());
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) {
currentProjectId = projectId;
void loadLayers();
},
dispose() {
loadSequence += 1;
resizeObserver.disconnect();
},
};
}