Files
frontend/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-feature-click.tsx
T

402 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 화면명: 침수흔적위선 연계정보
* 우측 정보 화면의 심선·위선 목록, 선택 상태와 화면 생명주기 캐시를 관리한다.
*/
"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,
};
};