Files
frontend/apps/sn/app/(map-layout)/map/components/gisManagement/GisUploadPreviewMap.tsx
T

574 lines
17 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { FeatureLike } from "ol/Feature";
import GeoJSON from "ol/format/GeoJSON";
import Map from "ol/Map";
import View from "ol/View";
import { Stroke, Style } from "ol/style";
import TileLayer from "ol/layer/Tile";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import TileWMTS from "ol/source/WMTS";
import WMTSTileGrid from "ol/tilegrid/WMTS";
import { getTopLeft } from "ol/extent";
import { get as getProjection } from "ol/proj";
import { register } from "ol/proj/proj4";
import proj4 from "proj4";
import { getRawJson } from "@/components/lib/api";
type GisUploadPreviewMapProps = {
uploadId: string;
mngInstCd?: string | null;
};
type LayerSetting = {
id?: string;
lyrSn?: string;
lyrNm?: string;
lyrTyNm?: string;
lyrTypeNm?: string;
lyr_type_nm?: string;
flterSql?: string | null;
minViewSize?: string | null;
minZoom?: string | null;
lnColor?: string | null;
lineColr?: string | null;
lnWdth?: string | null;
lineWdth?: string | null;
drawOrdr?: string | null;
exprsYn?: string | null;
useYn?: string | null;
actlLyrYn?: string | null;
children?: LayerSetting[];
};
const MAP_SRS = "EPSG:5179";
const INITIAL_CENTER_5179 = [965000, 1900000];
const INITIAL_ZOOM = 8;
const FALLBACK_PIPE_MIN_ZOOM = 13;
// OpenLayers는 minZoom 경계값에서 레이어를 숨기므로 자동 포커스는 임계값보다 소폭 크게 설정한다.
const MIN_ZOOM_VISIBILITY_OFFSET = 0.01;
const NETWORK_LAYER_GROUP = "SN_NETWORK";
const PIPE_LAYER_TYPE = "tb_ms_swage_ppl";
const NGII_WMTS_URL = `/ngii-server/openapi/Gettile.do?apikey=${process.env.NEXT_PUBLIC_NGII_PARAM ?? ""}`;
const NGII_RESOLUTIONS = [
1044.48, 522.24, 261.12, 130.56, 65.28, 32.64, 16.32, 8.16, 4.08, 2.04, 1.02,
0.51, 0.255,
];
const NGII_MATRIX_IDS = [
"L06",
"L07",
"L08",
"L09",
"L10",
"L11",
"L12",
"L13",
"L14",
"L15",
"L16",
"L17",
"L18",
];
let isProjectionRegistered = false;
const registerMapProjection = () => {
if (isProjectionRegistered) return;
proj4.defs(
MAP_SRS,
"+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs",
);
register(proj4);
const projection = getProjection(MAP_SRS);
projection?.setExtent([-200000.0, -28024123.62, 31824123.62, 4000000.0]);
isProjectionRegistered = true;
};
const createNgiiBaseLayer = () => {
registerMapProjection();
const projection = getProjection(MAP_SRS);
const tileGrid = new WMTSTileGrid({
origin: getTopLeft(
projection?.getExtent() ?? [
-200000.0, -28024123.62, 31824123.62, 4000000.0,
],
),
resolutions: NGII_RESOLUTIONS,
matrixIds: NGII_MATRIX_IDS,
tileSize: [256, 256],
});
const source = new TileWMTS({
projection: MAP_SRS,
tileGrid,
crossOrigin: "anonymous",
requestEncoding: "KVP",
layer: "korean_map",
matrixSet: MAP_SRS,
format: "image/png",
style: "korean",
});
source.setTileUrlFunction((tileCoord) => {
const [z, x, y] = tileCoord;
return NGII_WMTS_URL.replace(
"{z}",
NGII_MATRIX_IDS[z ?? 0] ?? NGII_MATRIX_IDS[0] ?? "L06",
)
.replace("{x}", String(x))
.replace("{y}", String(y));
});
return new TileLayer({
source,
zIndex: 0,
});
};
const flattenLayerSettings = (items: LayerSetting[] = []): LayerSetting[] =>
items.flatMap((item) => [item, ...flattenLayerSettings(item.children ?? [])]);
const getLayerTypeName = (layer: LayerSetting) =>
String(layer.lyrTyNm ?? layer.lyrTypeNm ?? layer.lyr_type_nm ?? "").trim();
const toFiniteNumber = (value: unknown, fallback: number) => {
if (value === null || value === undefined || String(value).trim() === "")
return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const normalizeColor = (value: unknown, fallback: string) => {
const color = String(value ?? "").trim();
return color || fallback;
};
const getLineColor = (layer?: LayerSetting | null) =>
normalizeColor(layer?.lnColor ?? layer?.lineColr, "#f97316");
const getLayerMinZoom = (
layer: LayerSetting | null | undefined,
fallback: number,
) => toFiniteNumber(layer?.minViewSize ?? layer?.minZoom, fallback);
const getNetworkPipeLineWidth = (layer?: LayerSetting | null) =>
Math.max(1, toFiniteNumber(layer?.lnWdth ?? layer?.lineWdth, 1));
const getHighlightedPipeLineWidth = (layer?: LayerSetting | null) =>
Math.max(3, getNetworkPipeLineWidth(layer) + 2) + 3;
const pipePreviewStyle =
(layerSetting?: LayerSetting | null) => (feature: FeatureLike) => {
const status = feature.get("previewStatus");
if (status !== "CREATED" && status !== "UPDATED") {
return new Style({
stroke: new Stroke({
color: getLineColor(layerSetting),
width: getNetworkPipeLineWidth(layerSetting),
}),
});
}
const baseColor = status === "CREATED" ? "#16a34a" : "#f97316";
const width = getHighlightedPipeLineWidth(layerSetting);
return [
new Style({
stroke: new Stroke({
color: "#ffffff",
width: width + 3,
}),
}),
new Style({
stroke: new Stroke({
color: baseColor,
width,
}),
}),
];
};
const unwrapFeatureCollection = (response: any) => response?.data ?? response;
const refreshMapSize = (map: Map) => {
window.requestAnimationFrame(() => {
map.updateSize();
});
};
const getFeatureExtent = (features: ReturnType<GeoJSON["readFeatures"]>) => {
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
features.forEach((feature) => {
const geometry = feature.getGeometry();
if (!geometry) return;
const extent = geometry.getExtent();
minX = Math.min(minX, extent[0]!);
minY = Math.min(minY, extent[1]!);
maxX = Math.max(maxX, extent[2]!);
maxY = Math.max(maxY, extent[3]!);
});
if (![minX, minY, maxX, maxY].every(Number.isFinite)) {
return null;
}
return [minX, minY, maxX, maxY] as [number, number, number, number];
};
const getFeatureExtentCenter = (
features: ReturnType<GeoJSON["readFeatures"]>,
) => {
const extent = getFeatureExtent(features);
if (!extent) return null;
const targetCenter: [number, number] = [
(extent[0] + extent[2]) / 2,
(extent[1] + extent[3]) / 2,
];
return features.reduce<[number, number] | null>((nearestCenter, feature) => {
const featureExtent = feature.getGeometry()?.getExtent();
if (!featureExtent) return nearestCenter;
const featureCenter: [number, number] = [
(featureExtent[0]! + featureExtent[2]!) / 2,
(featureExtent[1]! + featureExtent[3]!) / 2,
];
if (!nearestCenter) return featureCenter;
const nearestDistance =
(nearestCenter[0] - targetCenter[0]) ** 2 +
(nearestCenter[1] - targetCenter[1]) ** 2;
const featureDistance =
(featureCenter[0] - targetCenter[0]) ** 2 +
(featureCenter[1] - targetCenter[1]) ** 2;
return featureDistance < nearestDistance ? featureCenter : nearestCenter;
}, null);
};
export default function GisUploadPreviewMap({
uploadId,
mngInstCd,
}: GisUploadPreviewMapProps) {
const mapElementRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<Map | null>(null);
const pipeSourceRef = useRef(new VectorSource());
const pipeLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const loadTimerRef = useRef<number | null>(null);
const didFitPreviewRef = useRef(false);
const [layerSettings, setLayerSettings] = useState<LayerSetting[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [featureCount, setFeatureCount] = useState(0);
const [deletedCount, setDeletedCount] = useState(0);
const [truncated, setTruncated] = useState(false);
const [message, setMessage] = useState("");
const flatLayerSettings = useMemo(
() => flattenLayerSettings(layerSettings),
[layerSettings],
);
const pipeLayerSetting = useMemo(
() =>
flatLayerSettings.find(
(layer) => getLayerTypeName(layer) === PIPE_LAYER_TYPE,
) ?? null,
[flatLayerSettings],
);
const pipeMinZoom = getLayerMinZoom(pipeLayerSetting, FALLBACK_PIPE_MIN_ZOOM);
useEffect(() => {
let mounted = true;
const loadLayerSettings = async () => {
try {
const networkSettings = await getRawJson(
`/sewerage-service/api/v1/map/lyrStngs/${NETWORK_LAYER_GROUP}`,
);
const filtered = flattenLayerSettings(
Array.isArray(networkSettings) ? networkSettings : [],
)
.filter((layer) => getLayerTypeName(layer) === PIPE_LAYER_TYPE)
.filter(
(layer) =>
(layer.useYn ?? "Y") === "Y" &&
(layer.exprsYn ?? "Y") === "Y" &&
(layer.actlLyrYn ?? "Y") === "Y",
);
if (mounted) {
setLayerSettings(filtered);
}
} catch (error) {
console.error("GIS 업로드 미리보기 레이어 설정 조회 실패:", error);
if (mounted) {
setLayerSettings([]);
}
}
};
void loadLayerSettings();
return () => {
mounted = false;
};
}, []);
useEffect(() => {
let disposed = false;
let initializeTimer: number | null = null;
const initializeMap = () => {
const mapElement = mapElementRef.current;
if (disposed || mapRef.current) return;
if (
!mapElement ||
mapElement.clientWidth <= 0 ||
mapElement.clientHeight <= 0
) {
initializeTimer = window.setTimeout(initializeMap, 100);
return;
}
registerMapProjection();
const pipeLayer = new VectorLayer({
source: pipeSourceRef.current,
style: pipePreviewStyle(pipeLayerSetting),
minZoom: pipeMinZoom,
zIndex: toFiniteNumber(pipeLayerSetting?.drawOrdr, 300),
});
const map = new Map({
target: mapElement,
layers: [createNgiiBaseLayer(), pipeLayer],
view: new View({
center: INITIAL_CENTER_5179,
zoom: INITIAL_ZOOM,
projection: MAP_SRS,
}),
controls: [],
});
pipeLayerRef.current = pipeLayer;
mapRef.current = map;
refreshMapSize(map);
};
initializeMap();
return () => {
disposed = true;
if (initializeTimer) {
window.clearTimeout(initializeTimer);
}
abortControllerRef.current?.abort();
if (loadTimerRef.current) {
window.clearTimeout(loadTimerRef.current);
}
mapRef.current?.setTarget(undefined);
mapRef.current = null;
pipeLayerRef.current = null;
pipeSourceRef.current.clear();
};
}, []);
useEffect(() => {
pipeLayerRef.current?.setStyle(pipePreviewStyle(pipeLayerSetting));
pipeLayerRef.current?.setMinZoom(pipeMinZoom);
}, [pipeLayerSetting, pipeMinZoom]);
useEffect(() => {
const map = mapRef.current;
const mapElement = mapElementRef.current;
if (!map || !mapElement) return;
const resizeObserver = new ResizeObserver(() => {
refreshMapSize(map);
});
resizeObserver.observe(mapElement);
const updateTimer = window.setTimeout(() => {
map.updateSize();
}, 0);
return () => {
window.clearTimeout(updateTimer);
resizeObserver.disconnect();
};
}, []);
useEffect(() => {
didFitPreviewRef.current = false;
}, [mngInstCd, uploadId]);
const loadPreview = useCallback(
async function loadPreviewRequest() {
const map = mapRef.current;
if (!map || !uploadId) return;
map.updateSize();
let size = map.getSize();
if (!size) {
map.updateSize();
size = map.getSize();
}
if (!size || size[0]! <= 0 || size[1]! <= 0) {
if (loadTimerRef.current) {
window.clearTimeout(loadTimerRef.current);
}
loadTimerRef.current = window.setTimeout(() => {
void loadPreviewRequest();
}, 150);
return;
}
const extent = map.getView().calculateExtent(size);
const previewParams = new URLSearchParams({
bbox: extent.join(","),
srs: MAP_SRS,
});
if (mngInstCd) {
previewParams.set("mngInstCd", mngInstCd);
}
abortControllerRef.current?.abort();
const abortController = new AbortController();
abortControllerRef.current = abortController;
setIsLoading(true);
setMessage("");
pipeSourceRef.current.clear();
setFeatureCount(0);
setDeletedCount(0);
setTruncated(false);
try {
const previewResponse = await getRawJson(
`v1/map/gisUploads/${encodeURIComponent(uploadId)}/preview?${previewParams.toString()}`,
{ signal: abortController.signal },
);
const previewCollection = unwrapFeatureCollection(previewResponse);
const pipeFeatures = new GeoJSON({
dataProjection: MAP_SRS,
featureProjection: MAP_SRS,
}).readFeatures(previewCollection);
pipeSourceRef.current.addFeatures(pipeFeatures);
if (!didFitPreviewRef.current && pipeFeatures.length > 0) {
const changedFeatures = pipeFeatures.filter(
(feature) => feature.get("previewStatus") !== "LATEST",
);
const center = getFeatureExtentCenter(
changedFeatures.length > 0 ? changedFeatures : pipeFeatures,
);
if (center) {
map.getView().setCenter(center);
map.getView().setZoom(pipeMinZoom + MIN_ZOOM_VISIBILITY_OFFSET);
didFitPreviewRef.current = true;
}
}
setFeatureCount(pipeFeatures.length);
setDeletedCount(Number(previewCollection?.meta?.deletedCount ?? 0));
setTruncated(Boolean(previewCollection?.meta?.truncated));
} catch (error: any) {
if (error?.name === "AbortError") return;
console.error("GIS 업로드 미리보기 조회 실패:", error);
setMessage("미리보기 데이터를 불러오지 못했습니다.");
} finally {
if (!abortController.signal.aborted) {
setIsLoading(false);
}
}
},
[mngInstCd, pipeMinZoom, uploadId],
);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const scheduleLoad = () => {
if (loadTimerRef.current) {
window.clearTimeout(loadTimerRef.current);
}
loadTimerRef.current = window.setTimeout(() => {
void loadPreview();
}, 250);
};
map.on("moveend", scheduleLoad);
scheduleLoad();
return () => {
map.un("moveend", scheduleLoad);
};
}, [loadPreview]);
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-2xl font-medium text-label">
업로드 이력 미리보기
</h3>
<p className="mt-1 text-[1rem] text-label">
변경 관로는 업로드 이력 기준으로 강조하고, 변경 없는 관로는 관망도와
동일한 스타일로 표시합니다.
</p>
</div>
</div>
<div className="mb-2 flex flex-wrap gap-3 text-[0.95rem] text-label">
<span>
<b className="text-green-600">신규</b> /{" "}
<b className="text-orange-500">수정</b> /{" "}
<b style={{ color: getLineColor(pipeLayerSetting) }}>관망도 기본</b>
</span>
<span>관로 표출 줌 {pipeMinZoom} 이상</span>
<span>관로 {featureCount.toLocaleString("ko-KR")}건</span>
<span>삭제 미표시 {deletedCount.toLocaleString("ko-KR")}건</span>
{truncated ? (
<span className="text-red-600">조회 상한 초과</span>
) : null}
{isLoading ? <span>불러오는 중...</span> : null}
</div>
{message ? (
<div className="mb-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-[1rem] text-red-700">
{message}
</div>
) : null}
<div
ref={mapElementRef}
className="gis-upload-preview-map min-h-0 flex-1 overflow-hidden rounded-md border border-border bg-secondary"
/>
<style jsx global>{`
.gis-upload-preview-map {
position: relative;
}
.gis-upload-preview-map .ol-viewport {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
touch-action: none;
}
.gis-upload-preview-map .ol-unselectable {
user-select: none;
}
.gis-upload-preview-map .ol-layers,
.gis-upload-preview-map .ol-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.gis-upload-preview-map canvas {
display: block;
}
`}</style>
</div>
);
}