Files
frontend/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingLocationMap.tsx
T

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;