400 lines
12 KiB
TypeScript
400 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import Feature from "ol/Feature.js";
|
|
import type { FeatureLike } from "ol/Feature.js";
|
|
import Point from "ol/geom/Point.js";
|
|
import type OlMap from "ol/Map.js";
|
|
import type { EventsKey } from "ol/events.js";
|
|
import { unByKey } from "ol/Observable.js";
|
|
import { transform } from "ol/proj.js";
|
|
import VectorLayer from "ol/layer/Vector.js";
|
|
import VectorSource from "ol/source/Vector.js";
|
|
import CircleStyle from "ol/style/Circle.js";
|
|
import Fill from "ol/style/Fill.js";
|
|
import Stroke from "ol/style/Stroke.js";
|
|
import Style from "ol/style/Style.js";
|
|
import type { StyleFunction, StyleLike } from "ol/style/Style.js";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import { getRawJson } from "@/components/lib/api";
|
|
import Gis, { useGisContext } from "@common/business_components/ui/map/Gis";
|
|
import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
|
|
|
import type { GeoPoint, PipeMatch } from "../lib/types";
|
|
|
|
type DredgingLocationMapProps = {
|
|
value: GeoPoint | null;
|
|
onChange?: (point: GeoPoint) => void;
|
|
interactive?: boolean;
|
|
statusText?: string;
|
|
matchedPipe?: PipeMatch | null;
|
|
height?: string;
|
|
};
|
|
|
|
type LayerSetting = Record<string, unknown> & {
|
|
id?: string;
|
|
lyrSn?: string;
|
|
lyrTyNm?: string;
|
|
useYn?: string;
|
|
actlLyrYn?: string;
|
|
exprsYn?: string;
|
|
children?: LayerSetting[];
|
|
};
|
|
|
|
type GisCore = {
|
|
addWfsLayer: (setting: LayerSetting) => VectorLayer<VectorSource>;
|
|
};
|
|
|
|
type GisContextValue = {
|
|
gisMap: OlMap | null;
|
|
gisCore: GisCore | null;
|
|
};
|
|
|
|
const confirmedMarkerStyle = new Style({
|
|
image: new CircleStyle({
|
|
radius: 9,
|
|
fill: new Fill({ color: "rgba(220, 38, 38, 0.9)" }),
|
|
stroke: new Stroke({ color: "#ffffff", width: 3 }),
|
|
}),
|
|
});
|
|
|
|
const draftMarkerStyle = new Style({
|
|
image: new CircleStyle({
|
|
radius: 9,
|
|
fill: new Fill({ color: "rgba(37, 99, 235, 0.9)" }),
|
|
stroke: new Stroke({ color: "#ffffff", width: 3 }),
|
|
}),
|
|
});
|
|
|
|
const matchedPipeStyle = [
|
|
new Style({
|
|
stroke: new Stroke({ color: "rgba(255, 255, 255, 0.98)", width: 7 }),
|
|
zIndex: 20,
|
|
}),
|
|
new Style({
|
|
stroke: new Stroke({ color: "#f97316", width: 4 }),
|
|
zIndex: 21,
|
|
}),
|
|
];
|
|
|
|
const normalizePipeFid = (fid: unknown) => {
|
|
const value = String(fid ?? "").trim();
|
|
const separatorIndex = value.lastIndexOf(".");
|
|
return separatorIndex >= 0 ? value.slice(separatorIndex + 1) : value;
|
|
};
|
|
|
|
const flattenLayerSettings = (items: LayerSetting[]): LayerSetting[] =>
|
|
items.flatMap((item) => [
|
|
item,
|
|
...flattenLayerSettings(Array.isArray(item.children) ? item.children : []),
|
|
]);
|
|
|
|
const isPipeLayerSetting = (setting: LayerSetting) =>
|
|
setting.lyrTyNm === "tb_ms_swage_ppl" &&
|
|
setting.useYn === "Y" &&
|
|
setting.actlLyrYn === "Y" &&
|
|
setting.exprsYn !== "N";
|
|
|
|
const resolveStyle = (
|
|
style: StyleLike | null | undefined,
|
|
feature: FeatureLike,
|
|
resolution: number,
|
|
) => (typeof style === "function" ? style(feature, resolution) : style);
|
|
|
|
/**
|
|
* 공통 관망도와 같은 DB 레이어 설정으로 관로를 그리고, 이 화면이 소유한 마커와
|
|
* 클릭 이벤트만 추가·정리한다.
|
|
*/
|
|
const LocationInteraction = ({
|
|
value,
|
|
onChange,
|
|
interactive,
|
|
matchedPipe,
|
|
pipeLayerSettings,
|
|
}: Required<Pick<DredgingLocationMapProps, "interactive">> &
|
|
Omit<DredgingLocationMapProps, "interactive" | "height"> & {
|
|
pipeLayerSettings: LayerSetting[];
|
|
}) => {
|
|
const { gisMap, gisCore } = useGisContext() as GisContextValue;
|
|
const markerLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
|
const pipeLayersRef = useRef<
|
|
Array<{ layer: VectorLayer<VectorSource>; baseStyle: StyleLike | null }>
|
|
>([]);
|
|
const onChangeRef = useRef(onChange);
|
|
const previousValueRef = useRef<GeoPoint | null>(null);
|
|
const [pipeLoadState, setPipeLoadState] = useState<
|
|
"idle" | "loading" | "loaded" | "error"
|
|
>("idle");
|
|
const [visiblePipeCount, setVisiblePipeCount] = useState(0);
|
|
|
|
useEffect(() => {
|
|
onChangeRef.current = onChange;
|
|
}, [onChange]);
|
|
|
|
useEffect(() => {
|
|
if (!gisMap) return;
|
|
const markerSource = new VectorSource();
|
|
const markerLayer = new VectorLayer({
|
|
source: markerSource,
|
|
style: interactive ? draftMarkerStyle : confirmedMarkerStyle,
|
|
zIndex: 999,
|
|
});
|
|
markerLayer.set("dredgingLocationLayer", true);
|
|
gisMap.addLayer(markerLayer);
|
|
markerLayerRef.current = markerLayer;
|
|
|
|
return () => {
|
|
gisMap.removeLayer(markerLayer);
|
|
markerLayerRef.current = null;
|
|
};
|
|
}, [gisMap]);
|
|
|
|
useEffect(() => {
|
|
markerLayerRef.current?.setStyle(
|
|
interactive ? draftMarkerStyle : confirmedMarkerStyle,
|
|
);
|
|
}, [interactive]);
|
|
|
|
useEffect(() => {
|
|
if (!gisMap || !interactive) return;
|
|
const clickKey = gisMap.on("singleclick", (event) => {
|
|
const [longitude, latitude] = transform(
|
|
event.coordinate,
|
|
GisApp.TO_PROJ,
|
|
GisApp.FROM_PROJ,
|
|
);
|
|
if (
|
|
longitude === undefined ||
|
|
latitude === undefined ||
|
|
!Number.isFinite(longitude) ||
|
|
!Number.isFinite(latitude)
|
|
) {
|
|
return;
|
|
}
|
|
onChangeRef.current?.({ longitude, latitude });
|
|
});
|
|
return () => unByKey(clickKey);
|
|
}, [gisMap, interactive]);
|
|
|
|
useEffect(() => {
|
|
if (!gisMap || !gisCore || pipeLayerSettings.length === 0) return;
|
|
setPipeLoadState("loading");
|
|
let loadErrors = 0;
|
|
const sourceEventKeys: EventsKey[] = [];
|
|
const layers = pipeLayerSettings.map((setting, index) => {
|
|
const layer = gisCore.addWfsLayer({
|
|
...setting,
|
|
id: `dredging-pipe-${setting.lyrSn ?? setting.id ?? index}`,
|
|
clickPsblYn: "N",
|
|
});
|
|
layer.set("dredgingPipeLayer", true);
|
|
const source = layer.getSource();
|
|
const refreshCount = () => {
|
|
setVisiblePipeCount(
|
|
pipeLayersRef.current.reduce(
|
|
(count, item) =>
|
|
count + (item.layer.getSource()?.getFeatures().length ?? 0),
|
|
0,
|
|
),
|
|
);
|
|
setPipeLoadState(loadErrors > 0 ? "error" : "loaded");
|
|
};
|
|
if (source) {
|
|
sourceEventKeys.push(
|
|
source.on("featuresloadstart", () =>
|
|
setPipeLoadState("loading"),
|
|
) as EventsKey,
|
|
source.on("featuresloadend", refreshCount) as EventsKey,
|
|
source.on("featuresloaderror", () => {
|
|
loadErrors += 1;
|
|
refreshCount();
|
|
}) as EventsKey,
|
|
);
|
|
}
|
|
return { layer, baseStyle: layer.getStyle() ?? null };
|
|
});
|
|
pipeLayersRef.current = layers;
|
|
|
|
return () => {
|
|
unByKey(sourceEventKeys);
|
|
layers.forEach(({ layer }) => gisMap.removeLayer(layer));
|
|
pipeLayersRef.current = [];
|
|
setVisiblePipeCount(0);
|
|
setPipeLoadState("idle");
|
|
};
|
|
}, [gisCore, gisMap, pipeLayerSettings]);
|
|
|
|
useEffect(() => {
|
|
const matchedFid = normalizePipeFid(matchedPipe?.fid);
|
|
pipeLayersRef.current.forEach(({ layer, baseStyle }) => {
|
|
const styleFunction: StyleFunction = (feature, resolution) =>
|
|
matchedFid && normalizePipeFid(feature.getId()) === matchedFid
|
|
? matchedPipeStyle
|
|
: (resolveStyle(baseStyle, feature, resolution) ?? undefined);
|
|
layer.setStyle(styleFunction);
|
|
layer.changed();
|
|
});
|
|
}, [matchedPipe?.fid, pipeLayerSettings]);
|
|
|
|
useEffect(() => {
|
|
if (!gisMap || !markerLayerRef.current) return;
|
|
const source = markerLayerRef.current.getSource();
|
|
source?.clear();
|
|
if (!value) {
|
|
previousValueRef.current = null;
|
|
return;
|
|
}
|
|
|
|
const coordinate = transform(
|
|
[value.longitude, value.latitude],
|
|
GisApp.FROM_PROJ,
|
|
GisApp.TO_PROJ,
|
|
);
|
|
source?.addFeature(new Feature({ geometry: new Point(coordinate) }));
|
|
|
|
const previous = previousValueRef.current;
|
|
const locationChanged =
|
|
!previous ||
|
|
previous.longitude !== value.longitude ||
|
|
previous.latitude !== value.latitude;
|
|
// 사진 GPS가 처음 들어오거나 등록 건을 바꾼 때만 이동한다. 위치수정 진입처럼
|
|
// interactive 상태만 바뀐 경우에는 사용자가 보고 있던 지도 범위를 유지한다.
|
|
if (locationChanged && (!interactive || previous === null)) {
|
|
gisMap.getView().animate({ center: coordinate, zoom: 17, duration: 250 });
|
|
}
|
|
previousValueRef.current = value;
|
|
}, [gisMap, interactive, value?.latitude, value?.longitude]);
|
|
|
|
const pipeStatusText = (() => {
|
|
if (pipeLayerSettings.length === 0)
|
|
return "관망도 레이어 설정을 불러오는 중입니다.";
|
|
if (pipeLoadState === "loading") return "관망도 관로를 불러오는 중입니다.";
|
|
if (pipeLoadState === "error")
|
|
return visiblePipeCount > 0
|
|
? `일부 관로만 표시 중 · ${visiblePipeCount.toLocaleString()}건`
|
|
: "관망도 관로를 불러오지 못했습니다.";
|
|
if (pipeLoadState === "loaded")
|
|
return visiblePipeCount > 0
|
|
? `관망도 관로 ${visiblePipeCount.toLocaleString()}건 표시`
|
|
: "현재 지도 범위에 표시할 관로가 없습니다.";
|
|
return "지도를 확대하면 관망도 관로가 표시됩니다.";
|
|
})();
|
|
|
|
return (
|
|
<div className="rounded-sm border border-border bg-background px-2 py-1.5 text-xs text-label shadow">
|
|
<p className="font-medium">
|
|
{interactive
|
|
? "지도를 클릭해 준설위치를 선택하세요."
|
|
: "등록 위치와 관망도 관로를 함께 확인합니다."}
|
|
</p>
|
|
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1">
|
|
<span>관로 색상·굵기: 관망도 레이어 설정</span>
|
|
<span className="inline-flex items-center gap-1.5">
|
|
{/* 선택 관로를 지도에서 식별하는 범례 의미색이므로 고정 주황색을 유지한다. */}
|
|
<span className="inline-block h-1.5 w-6 rounded bg-[#f97316]" />
|
|
자동 연결 관로
|
|
</span>
|
|
<span
|
|
className={
|
|
pipeLoadState === "error"
|
|
? "text-rose-600"
|
|
: "text-muted-foreground"
|
|
}
|
|
data-testid="dredging-pipe-map-status"
|
|
>
|
|
{pipeStatusText}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const DredgingLocationMap = ({
|
|
value,
|
|
onChange,
|
|
interactive = true,
|
|
statusText,
|
|
matchedPipe,
|
|
height = "h-[540px]",
|
|
}: DredgingLocationMapProps) => {
|
|
const gisMaps = useRef<OlMap | null>(null);
|
|
const [pipeLayerSettings, setPipeLayerSettings] = useState<LayerSetting[]>(
|
|
[],
|
|
);
|
|
const defaultLonLat = useMemo<[number, number]>(
|
|
() => (value ? [value.longitude, value.latitude] : [127.75, 36]),
|
|
// 지도 최초 생성 위치만 사용하며 이후 위치 변경은 marker effect가 담당한다.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const load = async () => {
|
|
try {
|
|
const body = await getRawJson(
|
|
"/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK",
|
|
);
|
|
const rows = (body?.data ?? body ?? []) as LayerSetting[];
|
|
if (!cancelled) {
|
|
setPipeLayerSettings(
|
|
flattenLayerSettings(rows).filter(isPipeLayerSetting),
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error("준설 등록 관망도 레이어 설정 조회 실패", error);
|
|
if (!cancelled) setPipeLayerSettings([]);
|
|
}
|
|
};
|
|
void load();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-sm border border-border">
|
|
<Gis
|
|
treeData={[]}
|
|
gisMaps={gisMaps}
|
|
isSearch={false}
|
|
isMiniMap={false}
|
|
isOpen={false}
|
|
setIsOpen={() => undefined}
|
|
featureData={null}
|
|
setFeatureData={() => undefined}
|
|
isSearchModalOpen={false}
|
|
setIsSearchModalOpen={() => undefined}
|
|
searchBar={
|
|
<LocationInteraction
|
|
value={value}
|
|
onChange={onChange}
|
|
interactive={interactive}
|
|
matchedPipe={matchedPipe}
|
|
pipeLayerSettings={pipeLayerSettings}
|
|
/>
|
|
}
|
|
defaultZoom={value ? 17 : 7.5}
|
|
defaultLonLat={defaultLonLat}
|
|
height={height}
|
|
showZoomLevel={true}
|
|
showFloatingSearchButton={false}
|
|
treeTogglePosition="top"
|
|
/>
|
|
<div
|
|
className="shrink-0 border-t bg-secondary px-3 py-1.5 text-xs text-label"
|
|
data-testid="dredging-map-status"
|
|
>
|
|
{statusText ??
|
|
(interactive
|
|
? "지도에서 준설위치를 선택해주세요."
|
|
: value
|
|
? "등록된 준설위치가 표시되었습니다."
|
|
: "위치가 등록되지 않았습니다.")}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default DredgingLocationMap;
|