327 lines
11 KiB
TypeScript
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();
|
|
},
|
|
};
|
|
}
|