574 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|