402 lines
14 KiB
TypeScript
402 lines
14 KiB
TypeScript
/**
|
||
* 화면명: 침수흔적위선 연계정보
|
||
* 우측 정보 화면의 심선·위선 목록, 선택 상태와 화면 생명주기 캐시를 관리한다.
|
||
*/
|
||
"use client";
|
||
import { mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation";
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import type { RefObject } from "react";
|
||
import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
||
import type Feature from "ol/Feature.js";
|
||
import VectorSource from "ol/source/Vector.js";
|
||
|
||
import { loadFldngHstryDetail } from "./fldng-hstry-client";
|
||
import { FLDNG_HSTRY_DEPTH_LAYER_TYPE, FLDNG_HSTRY_LAYER_TYPE, type FldngHstryDetailCacheValue } from "@/entities/flood-history/model/flood-history-types";
|
||
|
||
type FldngHstryGisLayer = {
|
||
searchLayer?: { getSource?: () => VectorSource | null } | null;
|
||
clearSearchLayer: (option: unknown) => void;
|
||
ensureSearchLayer: (option: unknown) => {
|
||
set: (key: string, value: unknown) => void;
|
||
setSource: (source: VectorSource) => void;
|
||
};
|
||
showSearchResultsFromGeoJSON: (
|
||
layerTypeName: string,
|
||
featureId: string,
|
||
options?: { beforeFocus: (extent: unknown) => boolean },
|
||
) => void | Promise<void>;
|
||
};
|
||
|
||
type FldngHstryScrollButtonProps = {
|
||
targetRef: RefObject<HTMLDivElement | null>;
|
||
direction: -1 | 1;
|
||
label: string;
|
||
children: string;
|
||
};
|
||
|
||
const FLDNG_HSTRY_SCROLL_CLICK_DISTANCE = 120;
|
||
const FLDNG_HSTRY_SCROLL_HOLD_DELAY = 300;
|
||
const FLDNG_HSTRY_SCROLL_HOLD_SPEED = 4;
|
||
|
||
/** 짧게 누르면 한 칸, 길게 누르면 목록을 해당 방향으로 계속 이동한다. */
|
||
const FldngHstryScrollButton = ({
|
||
targetRef,
|
||
direction,
|
||
label,
|
||
children,
|
||
}: FldngHstryScrollButtonProps) => {
|
||
const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const animationFrameRef = useRef<number | null>(null);
|
||
const pointerClickHandledRef = useRef(false);
|
||
|
||
const stopContinuousScroll = useCallback(() => {
|
||
if (holdTimerRef.current !== null) {
|
||
clearTimeout(holdTimerRef.current);
|
||
holdTimerRef.current = null;
|
||
}
|
||
if (animationFrameRef.current !== null) {
|
||
cancelAnimationFrame(animationFrameRef.current);
|
||
animationFrameRef.current = null;
|
||
}
|
||
}, []);
|
||
|
||
const scrollOnce = useCallback(() => {
|
||
targetRef.current?.scrollBy({
|
||
left: direction * FLDNG_HSTRY_SCROLL_CLICK_DISTANCE,
|
||
behavior: "smooth",
|
||
});
|
||
}, [direction, targetRef]);
|
||
|
||
const startContinuousScroll = useCallback(() => {
|
||
const scroll = () => {
|
||
const target = targetRef.current;
|
||
if (!target) {
|
||
stopContinuousScroll();
|
||
return;
|
||
}
|
||
target.scrollLeft += direction * FLDNG_HSTRY_SCROLL_HOLD_SPEED;
|
||
animationFrameRef.current = requestAnimationFrame(scroll);
|
||
};
|
||
animationFrameRef.current = requestAnimationFrame(scroll);
|
||
}, [direction, stopContinuousScroll, targetRef]);
|
||
|
||
useEffect(() => stopContinuousScroll, [stopContinuousScroll]);
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
aria-label={label}
|
||
className="shrink-0 select-none px-1 text-lg leading-none text-foreground"
|
||
onPointerDown={(event) => {
|
||
if (event.button !== 0) return;
|
||
stopContinuousScroll();
|
||
pointerClickHandledRef.current = true;
|
||
event.currentTarget.setPointerCapture(event.pointerId);
|
||
scrollOnce();
|
||
holdTimerRef.current = setTimeout(
|
||
startContinuousScroll,
|
||
FLDNG_HSTRY_SCROLL_HOLD_DELAY,
|
||
);
|
||
}}
|
||
onPointerUp={stopContinuousScroll}
|
||
onPointerCancel={() => {
|
||
stopContinuousScroll();
|
||
pointerClickHandledRef.current = false;
|
||
}}
|
||
onLostPointerCapture={stopContinuousScroll}
|
||
onClick={() => {
|
||
if (pointerClickHandledRef.current) {
|
||
pointerClickHandledRef.current = false;
|
||
return;
|
||
}
|
||
scrollOnce();
|
||
}}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
};
|
||
|
||
const cloneFldngHstryFeature = (feature: Feature) => {
|
||
const clonedFeature = feature.clone();
|
||
const featureId = feature.getId();
|
||
if (featureId !== undefined) clonedFeature.setId(featureId);
|
||
return clonedFeature;
|
||
};
|
||
|
||
const displayCachedFldngHstryFeatures = (
|
||
gisLayer: FldngHstryGisLayer,
|
||
layerTypeName: string,
|
||
features: Feature[],
|
||
beforeFocus: (extent: unknown) => boolean,
|
||
) => {
|
||
const matched = Object.values(GisApp.layerCode ?? {}).find(
|
||
(item: any) => item?.lyrTyNm === layerTypeName,
|
||
) as any;
|
||
if (!matched || features.length === 0) return;
|
||
// 캐시를 재사용해도 최신 선택의 유효 도형일 때만 검색조건을 초기화한다.
|
||
if (!beforeFocus(features[0]?.getGeometry()?.getExtent())) return;
|
||
|
||
gisLayer.clearSearchLayer(matched);
|
||
const layer = gisLayer.ensureSearchLayer(matched);
|
||
layer.set("gobjTy", matched.gobjTy);
|
||
const clonedFeatures = features.map(cloneFldngHstryFeature);
|
||
layer.setSource(new VectorSource({ features: clonedFeatures }));
|
||
(GisApp.Module.select as any)?.selectFeature(clonedFeatures[0], false);
|
||
};
|
||
|
||
const showFldngHstryFeature = async (
|
||
cache: Map<string, Promise<Feature[]>>,
|
||
gisLayer: FldngHstryGisLayer,
|
||
layerTypeName: string,
|
||
featureId: string,
|
||
) => {
|
||
const cacheKey = `${layerTypeName}:${featureId}`;
|
||
/** 캐시 대기와 신규 WFS 조회가 동일한 이동 성공/취소 계약을 사용한다. */
|
||
const navigationOptions = mapSearchNavigationOptions((GisApp.Module.core as any).map);
|
||
const cachedRequest = cache.get(cacheKey);
|
||
if (cachedRequest) {
|
||
displayCachedFldngHstryFeatures(
|
||
gisLayer,
|
||
layerTypeName,
|
||
await cachedRequest,
|
||
navigationOptions.beforeFocus,
|
||
);
|
||
return;
|
||
}
|
||
|
||
await gisLayer.showSearchResultsFromGeoJSON(layerTypeName, featureId, navigationOptions);
|
||
const source = gisLayer.searchLayer?.getSource?.();
|
||
if (!source) return;
|
||
|
||
const featureRequest = new Promise<Feature[]>((resolve, reject) => {
|
||
const resolveFeatures = () => {
|
||
const features = source.getFeatures();
|
||
if (features.length > 0) {
|
||
resolve(features.map(cloneFldngHstryFeature));
|
||
} else {
|
||
reject(new Error("침수흔적 위치 정보가 없습니다."));
|
||
}
|
||
};
|
||
const rejectFeatures = () =>
|
||
reject(new Error("침수흔적 위치 조회에 실패했습니다."));
|
||
|
||
if (source.getFeatures().length > 0) {
|
||
resolveFeatures();
|
||
return;
|
||
}
|
||
source.once("featuresloadend", resolveFeatures);
|
||
source.once("featuresloaderror", rejectFeatures);
|
||
});
|
||
|
||
cache.set(cacheKey, featureRequest);
|
||
try {
|
||
await featureRequest;
|
||
} catch (error) {
|
||
cache.delete(cacheKey);
|
||
console.warn(error);
|
||
}
|
||
};
|
||
|
||
export const useFldngHstryFeatureClick = () => {
|
||
const fldngHstryDetailCacheRef = useRef(
|
||
new Map<string, Promise<FldngHstryDetailCacheValue>>(),
|
||
);
|
||
const fldngHstryFeatureCacheRef = useRef(
|
||
new Map<string, Promise<Feature[]>>(),
|
||
);
|
||
const [fldngHstryDepthRows, setFldngHstryDepthRows] = useState<
|
||
Record<string, unknown>[]
|
||
>([]);
|
||
const [fldngHstryWtlvRows, setFldngHstryWtlvRows] = useState<
|
||
Record<string, unknown>[]
|
||
>([]);
|
||
const [selectedFldngHstryDepthFid, setSelectedFldngHstryDepthFid] = useState<
|
||
string | null
|
||
>(null);
|
||
const [selectedFldngHstryWtlvFid, setSelectedFldngHstryWtlvFid] = useState<
|
||
string | null
|
||
>(null);
|
||
const fldngHstryDepthYearListRef = useRef<HTMLDivElement>(null);
|
||
const fldngHstryWtlvListRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
const detailCache = fldngHstryDetailCacheRef.current;
|
||
const featureCache = fldngHstryFeatureCacheRef.current;
|
||
return () => {
|
||
detailCache.clear();
|
||
featureCache.clear();
|
||
};
|
||
}, []);
|
||
|
||
const resetFldngHstryFeatureClick = useCallback(() => {
|
||
setFldngHstryDepthRows([]);
|
||
setFldngHstryWtlvRows([]);
|
||
setSelectedFldngHstryDepthFid(null);
|
||
setSelectedFldngHstryWtlvFid(null);
|
||
}, []);
|
||
|
||
const loadFldngHstryFeatureClickDetail = useCallback(
|
||
async (fldngHstryFid: string) => {
|
||
const cacheKey = `${FLDNG_HSTRY_LAYER_TYPE}:${fldngHstryFid}`;
|
||
let request = fldngHstryDetailCacheRef.current.get(cacheKey);
|
||
if (!request) {
|
||
request = loadFldngHstryDetail(fldngHstryFid);
|
||
fldngHstryDetailCacheRef.current.set(cacheKey, request);
|
||
}
|
||
|
||
const detail = await request;
|
||
if (!detail.complete) fldngHstryDetailCacheRef.current.delete(cacheKey);
|
||
setFldngHstryDepthRows(detail.fldngHstryDepthRows);
|
||
setFldngHstryWtlvRows(detail.fldngHstryWtlvRows);
|
||
setSelectedFldngHstryDepthFid(null);
|
||
setSelectedFldngHstryWtlvFid(null);
|
||
return detail;
|
||
},
|
||
[],
|
||
);
|
||
|
||
const headName = useMemo(
|
||
() => (
|
||
<div className="flex w-full min-w-0 flex-col items-start gap-2">
|
||
<span>기본정보</span>
|
||
{fldngHstryDepthRows.length > 0 && (
|
||
<div className="flex w-full items-center gap-1">
|
||
<span className="w-9 shrink-0 text-[1.1rem] font-semibold">
|
||
심선
|
||
</span>
|
||
<FldngHstryScrollButton
|
||
targetRef={fldngHstryDepthYearListRef}
|
||
direction={-1}
|
||
label="이전 연도"
|
||
>
|
||
‹
|
||
</FldngHstryScrollButton>
|
||
<div
|
||
ref={fldngHstryDepthYearListRef}
|
||
className="flex min-w-0 flex-1 gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||
>
|
||
{[...fldngHstryDepthRows]
|
||
.map((row, index) => ({
|
||
row,
|
||
index,
|
||
year: String(
|
||
row.fldng_yr ?? row.fldngYr ?? row.yr ?? "",
|
||
).trim(),
|
||
}))
|
||
.filter(({ year }) => Boolean(year))
|
||
.sort((left, right) => {
|
||
const yearOrder = left.year.localeCompare(
|
||
right.year,
|
||
undefined,
|
||
{ numeric: true },
|
||
);
|
||
return yearOrder !== 0 ? yearOrder : left.index - right.index;
|
||
})
|
||
.map(({ row, index, year }) => {
|
||
const depthFid = String(
|
||
row.fldng_dpth_map_sn ?? row.fid ?? "",
|
||
).trim();
|
||
return (
|
||
<button
|
||
key={`${depthFid || "row"}-${index}`}
|
||
type="button"
|
||
onClick={async () => {
|
||
if (!depthFid) return;
|
||
setSelectedFldngHstryDepthFid(depthFid);
|
||
setSelectedFldngHstryWtlvFid(null);
|
||
await showFldngHstryFeature(
|
||
fldngHstryFeatureCacheRef.current,
|
||
GisApp.Module.layer as any,
|
||
FLDNG_HSTRY_DEPTH_LAYER_TYPE,
|
||
`${FLDNG_HSTRY_DEPTH_LAYER_TYPE}.${depthFid.replace(new RegExp(`^${FLDNG_HSTRY_DEPTH_LAYER_TYPE}\\.`), "")}`,
|
||
);
|
||
}}
|
||
className={`shrink-0 rounded border px-3 py-1 text-[1.2rem] font-medium leading-5 ${selectedFldngHstryDepthFid === depthFid ? "border-blue-600 bg-white text-blue-700" : "border-border bg-muted text-muted-foreground"}`}
|
||
>
|
||
{year}년
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<FldngHstryScrollButton
|
||
targetRef={fldngHstryDepthYearListRef}
|
||
direction={1}
|
||
label="다음 연도"
|
||
>
|
||
›
|
||
</FldngHstryScrollButton>
|
||
</div>
|
||
)}
|
||
{fldngHstryWtlvRows.length > 0 && (
|
||
<div className="flex w-full items-center gap-1">
|
||
<span className="w-9 shrink-0 text-[1.1rem] font-semibold">
|
||
위선
|
||
</span>
|
||
<FldngHstryScrollButton
|
||
targetRef={fldngHstryWtlvListRef}
|
||
direction={-1}
|
||
label="이전 위선"
|
||
>
|
||
‹
|
||
</FldngHstryScrollButton>
|
||
<div
|
||
ref={fldngHstryWtlvListRef}
|
||
className="flex min-w-0 flex-1 gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||
>
|
||
{fldngHstryWtlvRows.map((row, index) => {
|
||
const waterLevelFid = String(row.fid ?? "").trim();
|
||
const disasterName = String(
|
||
row.fldng_dstr_nm ?? row.fldngDstrNm ?? "",
|
||
).trim();
|
||
return (
|
||
<button
|
||
key={`${waterLevelFid || "wtlv"}-${index}`}
|
||
type="button"
|
||
onClick={async () => {
|
||
if (!waterLevelFid) return;
|
||
setSelectedFldngHstryWtlvFid(waterLevelFid);
|
||
setSelectedFldngHstryDepthFid(null);
|
||
await showFldngHstryFeature(
|
||
fldngHstryFeatureCacheRef.current,
|
||
GisApp.Module.layer as any,
|
||
FLDNG_HSTRY_LAYER_TYPE,
|
||
`${FLDNG_HSTRY_LAYER_TYPE}.${waterLevelFid.replace(new RegExp(`^${FLDNG_HSTRY_LAYER_TYPE}\\.`), "")}`,
|
||
);
|
||
}}
|
||
className={`shrink-0 rounded border px-3 py-1 text-[1.2rem] font-medium leading-5 ${selectedFldngHstryWtlvFid === waterLevelFid ? "border-blue-600 bg-white text-blue-700" : "border-border bg-muted text-muted-foreground"}`}
|
||
>
|
||
{disasterName || "재해명 없음"}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<FldngHstryScrollButton
|
||
targetRef={fldngHstryWtlvListRef}
|
||
direction={1}
|
||
label="다음 위선"
|
||
>
|
||
›
|
||
</FldngHstryScrollButton>
|
||
</div>
|
||
)}
|
||
</div>
|
||
),
|
||
[
|
||
fldngHstryDepthRows,
|
||
fldngHstryWtlvRows,
|
||
selectedFldngHstryDepthFid,
|
||
selectedFldngHstryWtlvFid,
|
||
],
|
||
);
|
||
|
||
return {
|
||
hasFldngHstryLinkedFeatures:
|
||
fldngHstryDepthRows.length > 0 || fldngHstryWtlvRows.length > 0,
|
||
fldngHstryFeatureClickHeadName: headName,
|
||
loadFldngHstryFeatureClickDetail,
|
||
resetFldngHstryFeatureClick,
|
||
};
|
||
};
|