diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 9b0db43a..48545281 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -8,6 +8,8 @@ import { Button } from "@/components/ui/button"; import { Loader2, RefreshCw } from "lucide-react"; import { applyColumnMapping } from "@/lib/utils/columnMapping"; import { getApiUrl } from "@/lib/utils/apiUrl"; +import { regionOptions, filterVehiclesByRegion } from "@/lib/constants/regionBounds"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import "leaflet/dist/leaflet.css"; // Popup 말풍선 꼬리 제거 스타일 @@ -101,6 +103,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const [routeLoading, setRouteLoading] = useState(false); const [routeDate, setRouteDate] = useState(new Date().toISOString().split("T")[0]); // YYYY-MM-DD 형식 + // 지역 필터 상태 + const [selectedRegion, setSelectedRegion] = useState("all"); + // dataSources를 useMemo로 추출 (circular reference 방지) const dataSources = useMemo(() => { return element?.dataSources || element?.chartConfig?.dataSources; @@ -1165,6 +1170,20 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {

+ {/* 지역 필터 */} + + {/* 이동경로 날짜 선택 */} {selectedUserId && (
@@ -1442,8 +1461,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { ); })} - {/* 마커 렌더링 */} - {markers.map((marker) => { + {/* 마커 렌더링 (지역 필터 적용) */} + {filterVehiclesByRegion(markers, selectedRegion).map((marker) => { // 마커의 소스에 해당하는 데이터 소스 찾기 const sourceDataSource = dataSources?.find((ds) => ds.name === marker.source) || dataSources?.[0]; const markerType = sourceDataSource?.markerType || "circle"; @@ -1771,7 +1790,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { {/* 하단 정보 */} {(markers.length > 0 || polygons.length > 0) && (
- {markers.length > 0 && `마커 ${markers.length}개`} + {markers.length > 0 && ( + <> + 마커 {filterVehiclesByRegion(markers, selectedRegion).length}개 + {selectedRegion !== "all" && ` (전체 ${markers.length}개)`} + + )} {markers.length > 0 && polygons.length > 0 && " · "} {polygons.length > 0 && `영역 ${polygons.length}개`}
diff --git a/frontend/lib/constants/regionBounds.ts b/frontend/lib/constants/regionBounds.ts new file mode 100644 index 00000000..2b0f15ba --- /dev/null +++ b/frontend/lib/constants/regionBounds.ts @@ -0,0 +1,238 @@ +/** + * 전국 시/도별 좌표 범위 (경계 좌표) + * 차량 위치 필터링에 사용 + */ + +export interface RegionBounds { + south: number; // 최남단 위도 + north: number; // 최북단 위도 + west: number; // 최서단 경도 + east: number; // 최동단 경도 +} + +export interface RegionOption { + value: string; + label: string; + bounds?: RegionBounds; +} + +// 전국 시/도별 좌표 범위 +export const regionBounds: Record = { + // 서울특별시 + seoul: { + south: 37.413, + north: 37.715, + west: 126.734, + east: 127.183, + }, + // 부산광역시 + busan: { + south: 34.879, + north: 35.389, + west: 128.758, + east: 129.314, + }, + // 대구광역시 + daegu: { + south: 35.601, + north: 36.059, + west: 128.349, + east: 128.761, + }, + // 인천광역시 + incheon: { + south: 37.166, + north: 37.592, + west: 126.349, + east: 126.775, + }, + // 광주광역시 + gwangju: { + south: 35.053, + north: 35.267, + west: 126.652, + east: 127.013, + }, + // 대전광역시 + daejeon: { + south: 36.197, + north: 36.488, + west: 127.246, + east: 127.538, + }, + // 울산광역시 + ulsan: { + south: 35.360, + north: 35.710, + west: 128.958, + east: 129.464, + }, + // 세종특별자치시 + sejong: { + south: 36.432, + north: 36.687, + west: 127.044, + east: 127.364, + }, + // 경기도 + gyeonggi: { + south: 36.893, + north: 38.284, + west: 126.387, + east: 127.839, + }, + // 강원도 (강원특별자치도) + gangwon: { + south: 37.017, + north: 38.613, + west: 127.085, + east: 129.359, + }, + // 충청북도 + chungbuk: { + south: 36.012, + north: 37.261, + west: 127.282, + east: 128.657, + }, + // 충청남도 + chungnam: { + south: 35.972, + north: 37.029, + west: 125.927, + east: 127.380, + }, + // 전라북도 (전북특별자치도) + jeonbuk: { + south: 35.287, + north: 36.133, + west: 126.392, + east: 127.923, + }, + // 전라남도 + jeonnam: { + south: 33.959, + north: 35.507, + west: 125.979, + east: 127.921, + }, + // 경상북도 + gyeongbuk: { + south: 35.571, + north: 37.144, + west: 128.113, + east: 130.922, + }, + // 경상남도 + gyeongnam: { + south: 34.599, + north: 35.906, + west: 127.555, + east: 129.224, + }, + // 제주특별자치도 + jeju: { + south: 33.106, + north: 33.959, + west: 126.117, + east: 126.978, + }, +}; + +// 지역 선택 옵션 (드롭다운용) +export const regionOptions: RegionOption[] = [ + { value: "all", label: "전체" }, + { value: "seoul", label: "서울특별시", bounds: regionBounds.seoul }, + { value: "busan", label: "부산광역시", bounds: regionBounds.busan }, + { value: "daegu", label: "대구광역시", bounds: regionBounds.daegu }, + { value: "incheon", label: "인천광역시", bounds: regionBounds.incheon }, + { value: "gwangju", label: "광주광역시", bounds: regionBounds.gwangju }, + { value: "daejeon", label: "대전광역시", bounds: regionBounds.daejeon }, + { value: "ulsan", label: "울산광역시", bounds: regionBounds.ulsan }, + { value: "sejong", label: "세종특별자치시", bounds: regionBounds.sejong }, + { value: "gyeonggi", label: "경기도", bounds: regionBounds.gyeonggi }, + { value: "gangwon", label: "강원특별자치도", bounds: regionBounds.gangwon }, + { value: "chungbuk", label: "충청북도", bounds: regionBounds.chungbuk }, + { value: "chungnam", label: "충청남도", bounds: regionBounds.chungnam }, + { value: "jeonbuk", label: "전북특별자치도", bounds: regionBounds.jeonbuk }, + { value: "jeonnam", label: "전라남도", bounds: regionBounds.jeonnam }, + { value: "gyeongbuk", label: "경상북도", bounds: regionBounds.gyeongbuk }, + { value: "gyeongnam", label: "경상남도", bounds: regionBounds.gyeongnam }, + { value: "jeju", label: "제주특별자치도", bounds: regionBounds.jeju }, +]; + +/** + * 좌표가 특정 지역 범위 내에 있는지 확인 + */ +export function isInRegion( + latitude: number, + longitude: number, + region: string +): boolean { + if (region === "all") return true; + + const bounds = regionBounds[region]; + if (!bounds) return false; + + return ( + latitude >= bounds.south && + latitude <= bounds.north && + longitude >= bounds.west && + longitude <= bounds.east + ); +} + +/** + * 좌표로 지역 찾기 (해당하는 첫 번째 지역 반환) + */ +export function findRegionByCoords( + latitude: number, + longitude: number +): string | null { + for (const [region, bounds] of Object.entries(regionBounds)) { + if ( + latitude >= bounds.south && + latitude <= bounds.north && + longitude >= bounds.west && + longitude <= bounds.east + ) { + return region; + } + } + return null; +} + +/** + * 차량 목록을 지역별로 필터링 + */ +export function filterVehiclesByRegion< + T extends { latitude?: number; longitude?: number; lat?: number; lng?: number } +>(vehicles: T[], region: string): T[] { + if (region === "all") return vehicles; + + const bounds = regionBounds[region]; + if (!bounds) return vehicles; + + return vehicles.filter((v) => { + const lat = v.latitude ?? v.lat; + const lng = v.longitude ?? v.lng; + + if (lat === undefined || lng === undefined) return false; + + return ( + lat >= bounds.south && + lat <= bounds.north && + lng >= bounds.west && + lng <= bounds.east + ); + }); +} + +/** + * 지역명(한글) 가져오기 + */ +export function getRegionLabel(regionValue: string): string { + const option = regionOptions.find((opt) => opt.value === regionValue); + return option?.label ?? regionValue; +} +