{formatDate(item.date)}
{item.category &&
` · ${item.category}`}
diff --git a/src/components/screens/PointHistoryScreen.jsx b/src/components/screens/PointHistoryScreen.jsx
index e2b213e..0ee2610 100644
--- a/src/components/screens/PointHistoryScreen.jsx
+++ b/src/components/screens/PointHistoryScreen.jsx
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { setActiveTab } from '../../store/slices/appSlice';
import { fetchPointDetail } from '../../store/slices/pointSlice';
+import { ArrowLeft } from 'lucide-react';
export default function PointHistoryScreen({ onNavigate }) {
const dispatch = useDispatch();
@@ -11,6 +12,18 @@ export default function PointHistoryScreen({ onNavigate }) {
(state) => state.point
);
+ const handleGoBack = () => {
+ if (window.history.length > 1) {
+ window.history.back();
+ return;
+ }
+ if (typeof onNavigate === 'function') {
+ onNavigate('home');
+ return;
+ }
+ dispatch(setActiveTab('home'));
+ };
+
useEffect(() => {
console.log(
'🔍 [PointHistoryScreen] Fetching point detail with filter:',
@@ -71,10 +84,10 @@ export default function PointHistoryScreen({ onNavigate }) {
{/* 헤더 */}
diff --git a/src/components/screens/RankingScreen.jsx b/src/components/screens/RankingScreen.jsx
index 8f0d49a..632729f 100644
--- a/src/components/screens/RankingScreen.jsx
+++ b/src/components/screens/RankingScreen.jsx
@@ -2,6 +2,7 @@ import React from 'react';
import { useDispatch } from 'react-redux';
import { setActiveTab } from '../../store/slices/appSlice';
import { usePointRanking } from '../../hooks/usePointApi';
+import { ArrowLeft } from 'lucide-react';
export default function RankingScreen({ onNavigate }) {
const dispatch = useDispatch();
@@ -15,6 +16,18 @@ export default function RankingScreen({ onNavigate }) {
dispatch(setActiveTab(tab));
};
+ const handleGoBack = () => {
+ if (window.history.length > 1) {
+ window.history.back();
+ return;
+ }
+ if (typeof onNavigate === 'function') {
+ onNavigate('home');
+ return;
+ }
+ dispatch(setActiveTab('home'));
+ };
+
const medalFor = (rank) => {
if (rank === 1)
return {
@@ -38,27 +51,10 @@ export default function RankingScreen({ onNavigate }) {
랭킹
diff --git a/src/hooks/useMarkers.js b/src/hooks/useMarkers.js
index fa4850a..3ca05f3 100644
--- a/src/hooks/useMarkers.js
+++ b/src/hooks/useMarkers.js
@@ -10,10 +10,12 @@ export const useMarkers = (
facilities,
currentInfoWindowRef,
selectedFilter,
- bookmarkedIds
+ bookmarkedIds,
+ onMarkerClick // 마커 클릭 콜백 추가
) => {
const markersRef = useRef([]);
const markerImageCacheRef = useRef({}); // MarkerImage 캐시
+ const selectedMarkerIdRef = useRef(null); // 선택된 마커 ID 추적
const _isMountedRef = useRef(true); // 마운트 상태 추적 (언더스코어로 unused 허용)
const _abortControllerRef = useRef(null); // 마커 생성 중단용 (언더스코어로 unused 허용)
const isCreatingMarkersRef = useRef(false); // 마커 생성 중인지 추적
@@ -29,19 +31,60 @@ export const useMarkers = (
}, []);
// MarkerImage 캐싱 - 카테고리별로 한 번만 생성
- const getMarkerImage = useCallback((category) => {
+ const getMarkerImage = useCallback((category, isSelected = false) => {
if (!window.kakao) return null;
- if (!markerImageCacheRef.current[category]) {
- markerImageCacheRef.current[category] = createMarkerImage(
+ const cacheKey = `${category}-${isSelected ? 'selected' : 'normal'}`;
+
+ if (!markerImageCacheRef.current[cacheKey]) {
+ markerImageCacheRef.current[cacheKey] = createMarkerImage(
window.kakao,
- category
+ category,
+ isSelected
);
}
- return markerImageCacheRef.current[category];
+ return markerImageCacheRef.current[cacheKey];
}, []);
+ // 선택된 마커 업데이트 (애니메이션 효과)
+ const updateSelectedMarker = useCallback(
+ (facilityId) => {
+ if (!window.kakao) return;
+
+ // 이전 선택된 마커를 일반 상태로 되돌림
+ if (selectedMarkerIdRef.current) {
+ const prevSelected = markersRef.current.find(
+ (m) => m.id === selectedMarkerIdRef.current
+ );
+ if (prevSelected) {
+ const normalImage = getMarkerImage(
+ prevSelected.category,
+ false
+ );
+ prevSelected.marker.setImage(normalImage);
+ prevSelected.marker.setZIndex(1);
+ }
+ }
+
+ // 새로 선택된 마커를 선택 상태로 변경
+ const newSelected = markersRef.current.find(
+ (m) => m.id === facilityId
+ );
+ if (newSelected) {
+ const selectedImage = getMarkerImage(
+ newSelected.category,
+ true
+ );
+ newSelected.marker.setImage(selectedImage);
+ newSelected.marker.setZIndex(100); // 맨 앞에 표시
+ }
+
+ selectedMarkerIdRef.current = facilityId;
+ },
+ [getMarkerImage]
+ );
+
// 지도 이동/줌 이벤트 시 마커 표시 업데이트 - 필터 적용
const updateVisibleMarkers = useCallback(() => {
if (!mapInstance || !window.kakao) return;
@@ -105,7 +148,6 @@ export const useMarkers = (
// Clear existing markers
markersRef.current.forEach((m) => {
if (m.marker) m.marker.setMap(null);
- if (m.infowindow) m.infowindow.close();
});
const newMarkers = [];
@@ -130,20 +172,15 @@ export const useMarkers = (
image: markerImage,
});
- const infoContent = `
`;
- const infowindow = new window.kakao.maps.InfoWindow({
- content: infoContent,
- });
-
+ // 마커 클릭 이벤트: InfoWindow 대신 콜백 호출
window.kakao.maps.event.addListener(
marker,
'click',
() => {
- if (currentInfoWindowRef.current) {
- currentInfoWindowRef.current.close();
+ if (onMarkerClick) {
+ updateSelectedMarker(f.id);
+ onMarkerClick(f);
}
- infowindow.open(mapInstance, marker);
- currentInfoWindowRef.current = infowindow;
}
);
@@ -151,7 +188,7 @@ export const useMarkers = (
id: f.id,
category: f.category,
marker,
- infowindow,
+ infowindow: null, // InfoWindow 더 이상 사용 안 함
data: f,
};
});
@@ -193,6 +230,8 @@ export const useMarkers = (
currentInfoWindowRef,
updateVisibleMarkers,
getMarkerImage,
+ onMarkerClick,
+ updateSelectedMarker,
]);
// 지도 이동/줌 이벤트 리스너 등록
@@ -262,7 +301,6 @@ export const useMarkers = (
window.requestIdleCallback(() => {
markers.forEach((m) => {
if (m.marker) m.marker.setMap(null);
- if (m.infowindow) m.infowindow.close();
});
});
} else if (markers.length > 0) {
@@ -270,12 +308,16 @@ export const useMarkers = (
setTimeout(() => {
markers.forEach((m) => {
if (m.marker) m.marker.setMap(null);
- if (m.infowindow) m.infowindow.close();
});
}, 0);
}
};
}, []);
- return { markersRef, updateVisibleMarkers, visibleFacilities };
+ return {
+ markersRef,
+ updateVisibleMarkers,
+ visibleFacilities,
+ updateSelectedMarker,
+ };
};
diff --git a/src/store/slices/appSlice.js b/src/store/slices/appSlice.js
index 2fc7b37..6c3a8a8 100644
--- a/src/store/slices/appSlice.js
+++ b/src/store/slices/appSlice.js
@@ -1,14 +1,17 @@
import { createSlice } from '@reduxjs/toolkit';
+const initialOnboardingCompleted =
+ sessionStorage.getItem('onboardingCompleted') === 'true';
+
const appSlice = createSlice({
name: 'app',
initialState: {
- appState: 'splash', // 'splash' | 'onboarding' | 'main'
+ appState: initialOnboardingCompleted ? 'main' : 'splash', // 'splash' | 'onboarding' | 'main'
+
activeTab: 'home',
isOnline: true,
lastSyncTime: null,
- onboardingCompleted:
- sessionStorage.getItem('onboardingCompleted') === 'true',
+ onboardingCompleted: initialOnboardingCompleted,
},
reducers: {
setAppState: (state, action) => {
@@ -40,3 +43,4 @@ export const {
} = appSlice.actions;
export default appSlice.reducer;
+
diff --git a/src/store/slices/badgeSlice.js b/src/store/slices/badgeSlice.js
index 0f8e8ec..aa0a000 100644
--- a/src/store/slices/badgeSlice.js
+++ b/src/store/slices/badgeSlice.js
@@ -1,26 +1,104 @@
-import { createSlice } from '@reduxjs/toolkit';
+import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
+import api from '../../api/axios';
+
+const STATIC_ALL_BADGES = [
+ {
+ id: 1,
+ name: '첫 발자국 (Lv. 0)',
+ requiredPoint: 0,
+ description: '가입 환영',
+ image_url: '/src/assets/lv0.png',
+ },
+ {
+ id: 2,
+ name: '새싹 지킴이 (Lv. 1)',
+ requiredPoint: 1000,
+ description: '누적 포인트 1,000점 달성',
+ image_url: '/src/assets/lv1.png',
+ },
+ {
+ id: 3,
+ name: '푸른 숲 봉사자 (Lv. 2)',
+ requiredPoint: 2000,
+ description: '누적 포인트 2,000점 달성',
+ image_url: '/src/assets/lv2.png',
+ },
+ {
+ id: 4,
+ name: '환경 보호 리더 (Lv. 3)',
+ requiredPoint: 5000,
+ description: '누적 포인트 5,000점 달성',
+ image_url: '/src/assets/lv3.png',
+ },
+ {
+ id: 5,
+ name: '그린 마스터 (Lv. 4)',
+ requiredPoint: 10000,
+ description: '누적 포인트 10,000점 달성',
+ image_url: '/src/assets/lv4.png',
+ },
+];
+
+export const fetchUserBadgeStatus = createAsyncThunk(
+ 'badge/fetchUserBadgeStatus',
+ async (_, { rejectWithValue }) => {
+ try {
+ const token = localStorage.getItem('token');
+ if (!token) throw new Error('로그인이 필요합니다');
+
+ // 뱃지 정보 API 호출 (단일 객체 응답 가정)
+ const response = await api.get('/badge', {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+
+ if (response.data.status !== 'SUCCESS') {
+ throw new Error(
+ response.data.message || '뱃지 정보를 가져올 수 없습니다.'
+ );
+ }
+ return response.data.data;
+ } catch (error) {
+ return rejectWithValue(error.message);
+ }
+ }
+);
const badgeSlice = createSlice({
name: 'badge',
initialState: {
- badges: [],
+ allBadges: STATIC_ALL_BADGES,
+ currentBadge: {},
earnedIds: [],
+ loading: false,
+ error: null,
},
reducers: {
- setBadges: (state, action) => {
- state.badges = action.payload;
- },
- earnBadge: (state, action) => {
- const { id, earnedDate } = action.payload;
- const badge = state.badges.find((b) => b.id === id);
- if (badge) {
- badge.earned = true;
- badge.earnedDate = earnedDate;
- if (!state.earnedIds.includes(id)) state.earnedIds.push(id);
- }
+ calculateEarnedBadges: (state, action) => {
+ const totalPoint = action.payload;
+
+ const newlyEarnedIds = state.allBadges
+ .filter((badge) => totalPoint >= badge.requiredPoint)
+ .map((badge) => badge.id);
+
+ state.earnedIds = newlyEarnedIds;
},
},
+ extraReducers: (builder) => {
+ builder
+ .addCase(fetchUserBadgeStatus.pending, (state) => {
+ state.loading = true;
+ state.error = null;
+ })
+ .addCase(fetchUserBadgeStatus.fulfilled, (state, action) => {
+ state.loading = false;
+ state.currentBadge = action.payload;
+ })
+ .addCase(fetchUserBadgeStatus.rejected, (state, action) => {
+ state.loading = false;
+ state.error = action.payload;
+ });
+ },
});
-export const { setBadges, earnBadge } = badgeSlice.actions;
+export const { calculateEarnedBadges } = badgeSlice.actions;
export default badgeSlice.reducer;
diff --git a/src/store/slices/userSlice.js b/src/store/slices/userSlice.js
index 6e4ce1c..12f2349 100644
--- a/src/store/slices/userSlice.js
+++ b/src/store/slices/userSlice.js
@@ -1,5 +1,206 @@
+// import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
+// import api from '../../api/axios';
+// export const fetchPointInfo = createAsyncThunk(
+// 'user/fetchPointInfo',
+// async (_, { rejectWithValue }) => {
+// try {
+// const token = localStorage.getItem('token');
+
+// if (!token) {
+// throw new Error('로그인이 필요합니다');
+// }
+
+// const response = await api.get('/point/info', {
+// headers: {
+// Authorization: `Bearer ${token}`,
+// },
+// });
+
+// const result = response.data;
+
+// if (result.status !== 'SUCCESS') {
+// throw new Error(result.message || '정보를 가져올 수 없습니다');
+// }
+
+// return result.data;
+// } catch (error) {
+// console.error(
+// '❌ 포인트 정보 조회 오류:',
+// error.response?.data || error.message
+// );
+
+// let message = '네트워크 오류가 발생했습니다.';
+// if (error.response?.data?.message) {
+// message = error.response.data.message;
+// } else if (error.message) {
+// message = error.message;
+// }
+
+// return rejectWithValue(message);
+// }
+// }
+// );
+
+// export const fetchMyPageData = createAsyncThunk(
+// 'user/fetchMyPageData',
+// async (_, { rejectWithValue }) => {
+// try {
+// const token = localStorage.getItem('token');
+
+// if (!token) {
+// throw new Error('로그인이 필요합니다');
+// }
+
+// const response = await api.get('/mypage', {
+// headers: {
+// Authorization: `Bearer ${token}`,
+// },
+// });
+
+// const result = response.data;
+
+// if (result.status !== 'SUCCESS') {
+// throw new Error(result.message || '정보를 가져올 수 없습니다');
+// }
+
+// return result.data;
+// } catch (error) {
+// console.error(
+// '❌ 마이페이지 조회 오류:',
+// error.response?.data || error.message
+// );
+
+// let message = '네트워크 오류가 발생했습니다.';
+// if (error.response?.data?.message) {
+// message = error.response.data.message;
+// } else if (error.message) {
+// message = error.message;
+// }
+
+// return rejectWithValue(message);
+// }
+// }
+// );
+
+// const userSlice = createSlice({
+// name: 'user',
+// initialState: {
+// isLoggedIn: false,
+// profile: {
+// memberId: null,
+// name: '',
+// email: '',
+// avatar: null,
+// nickname: '',
+// },
+
+// ranking: {
+// rank: null,
+// },
+
+// stats: {
+// point: 0,
+// carbonReduction: 0,
+// },
+
+// loading: false,
+// error: null,
+// },
+// reducers: {
+// logout: (state) => {
+// state.isLoggedIn = false;
+// state.profile = {
+// memberId: null,
+// name: '',
+// email: '',
+// avatar: null,
+// nickname: '',
+// };
+// state.ranking = {
+// rank: null,
+// };
+// state.stats = {
+// point: 0,
+// carbonReduction: 0,
+// };
+// localStorage.removeItem('token');
+// },
+
+// // 로그인 처리 (토큰 저장)
+// login: (state, action) => {
+// state.isLoggedIn = true;
+// if (action.payload.token) {
+// localStorage.setItem('token', action.payload.token);
+// }
+// },
+
+// // 프로필 업데이트 (MyPageScreen용)
+// updateProfile: (state, action) => {
+// state.profile = { ...state.profile, ...action.payload };
+// },
+// },
+// extraReducers: (builder) => {
+// builder
+// .addCase(fetchPointInfo.pending, (state) => {
+// state.loading = true;
+// state.error = null;
+// })
+// .addCase(fetchPointInfo.fulfilled, (state, action) => {
+// state.loading = false;
+// state.isLoggedIn = true;
+
+// state.stats = {
+// point: action.payload.point,
+// carbonReduction: action.payload.carbon_save,
+// };
+// })
+// .addCase(fetchPointInfo.rejected, (state, action) => {
+// state.loading = false;
+// state.error = action.payload;
+// state.isLoggedIn = false;
+// })
+
+// .addCase(fetchMyPageData.pending, (state) => {
+// state.loading = true;
+// state.error = null;
+// })
+// .addCase(fetchMyPageData.fulfilled, (state, action) => {
+// state.loading = false;
+// state.isLoggedIn = true;
+
+// const { member, point, ranking } = action.payload;
+
+// // 프로필 정보 저장
+// state.profile = {
+// memberId: member.memberId,
+// name: member.nickname,
+// email: member.email,
+// avatar: member.imageUrl,
+// nickname: member.nickname,
+// };
+
+// state.ranking = {
+// rank: ranking.rank,
+// };
+
+// state.stats = {
+// point: point.point,
+// carbonReduction: point.carbonSave,
+// };
+// })
+// .addCase(fetchMyPageData.rejected, (state, action) => {
+// state.loading = false;
+// state.error = action.payload;
+// state.isLoggedIn = false;
+// });
+// },
+// });
+
+// export const { logout, login, updateProfile } = userSlice.actions;
+// export default userSlice.reducer;
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import api from '../../api/axios';
+
export const fetchPointInfo = createAsyncThunk(
'user/fetchPointInfo',
async (_, { rejectWithValue }) => {
@@ -100,6 +301,7 @@ const userSlice = createSlice({
stats: {
point: 0,
+ totalPoint: 0,
carbonReduction: 0,
},
@@ -121,9 +323,11 @@ const userSlice = createSlice({
};
state.stats = {
point: 0,
+ totalPoint: 0,
carbonReduction: 0,
};
localStorage.removeItem('token');
+ localStorage.removeItem('memberId');
},
// 로그인 처리 (토큰 저장)
@@ -151,6 +355,7 @@ const userSlice = createSlice({
state.stats = {
point: action.payload.point,
+ totalPoint: action.payload.totalPoint || 0,
carbonReduction: action.payload.carbon_save,
};
})
@@ -185,6 +390,7 @@ const userSlice = createSlice({
state.stats = {
point: point.point,
+ totalPoint: point.totalPoint || 0,
carbonReduction: point.carbonSave,
};
})
@@ -198,5 +404,3 @@ const userSlice = createSlice({
export const { logout, login, updateProfile } = userSlice.actions;
export default userSlice.reducer;
-
-
diff --git a/src/util/location.js b/src/util/location.js
index 7863bbe..d184a18 100644
--- a/src/util/location.js
+++ b/src/util/location.js
@@ -64,6 +64,87 @@ export const clearLocationWatch = (watchId) => {
}
};
+/**
+ * Calculate distance between two coordinates using Haversine formula
+ * @param {number} lat1 - Latitude of first point
+ * @param {number} lng1 - Longitude of first point
+ * @param {number} lat2 - Latitude of second point
+ * @param {number} lng2 - Longitude of second point
+ * @returns {number} Distance in meters
+ */
+export const calculateDistance = (lat1, lng1, lat2, lng2) => {
+ const R = 6371e3; // Earth's radius in meters
+ const φ1 = (lat1 * Math.PI) / 180;
+ const φ2 = (lat2 * Math.PI) / 180;
+ const Δφ = ((lat2 - lat1) * Math.PI) / 180;
+ const Δλ = ((lng2 - lng1) * Math.PI) / 180;
+
+ const a =
+ Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
+ Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+
+ const distance = R * c; // Distance in meters
+ return Math.round(distance); // Round to nearest meter
+};
+
+/**
+ * Format distance for display
+ * @param {number} distance - Distance in meters
+ * @returns {string} Formatted distance string
+ */
+export const formatDistance = (distance) => {
+ if (distance === null || distance === undefined) {
+ return null;
+ }
+
+ if (distance < 1000) {
+ return `${Math.round(distance)}m`;
+ }
+
+ return `${(distance / 1000).toFixed(1)}km`;
+};
+
+/**
+ * Calculate distances for multiple facilities from a given location
+ * @param {Array} facilities - Array of facility objects with lat/lng
+ * @param {Object} currentLocation - Current location with lat/lng
+ * @returns {Array} Facilities array with added distance property
+ */
+export const calculateDistancesForFacilities = (
+ facilities,
+ currentLocation
+) => {
+ if (!currentLocation) {
+ return facilities;
+ }
+
+ return facilities.map((facility) => ({
+ ...facility,
+ distance: calculateDistance(
+ currentLocation.lat,
+ currentLocation.lng,
+ facility.lat,
+ facility.lng
+ ),
+ }));
+};
+
+/**
+ * Sort facilities by distance
+ * @param {Array} facilities - Array of facilities with distance property
+ * @returns {Array} Sorted facilities array
+ */
+export const sortByDistance = (facilities) => {
+ return [...facilities].sort((a, b) => {
+ // 거리 정보가 없는 경우 뒤로 이동
+ if (!a.distance && !b.distance) return 0;
+ if (!a.distance) return 1;
+ if (!b.distance) return -1;
+ return a.distance - b.distance;
+ });
+};
+
/**
* Create custom overlay for current location marker
*/
diff --git a/src/util/mapHelpers.js b/src/util/mapHelpers.js
index 757ad8e..80c3806 100644
--- a/src/util/mapHelpers.js
+++ b/src/util/mapHelpers.js
@@ -1,5 +1,5 @@
/**
- * Category configuration with colors, labels, and Lucide icon paths
+ * Category configuration with colors, labels, Lucide icon paths, and dummy images
*/
const CATEGORY_CONFIG = {
recycle: {
@@ -8,12 +8,24 @@ const CATEGORY_CONFIG = {
// Lucide Recycle icon
iconPath:
'M7 19H4.815a1.83 1.83 0 0 1-1.57-.881 1.785 1.785 0 0 1-.004-1.784L7.196 9.5 M11 19h8.203a1.83 1.83 0 0 0 1.556-.89 1.784 1.784 0 0 0 0-1.775l-1.226-2.12 M14 16l-3 3 3 3 M8.293 13.596 7.196 9.5 3.1 10.598 M9.344 5.811l1.093-1.892A1.83 1.83 0 0 1 11.985 3a1.784 1.784 0 0 1 1.546.888l3.943 6.843 M13.378 9.633l4.096 1.098 1.097-4.096',
+ dummyImages: [
+ 'https://images.unsplash.com/photo-1532996122724-e3c354a0b15b?w=800&q=80', // 재활용 쓰레기통
+ 'https://images.unsplash.com/photo-1611284446314-60a58ac0deb9?w=800&q=80', // 재활용 센터
+ 'https://images.unsplash.com/photo-1604187351574-c75ca79f5807?w=800&q=80', // 재활용 박스
+ 'https://images.unsplash.com/photo-1607062145718-fa1c8e7cc0ea?w=800&q=80', // 재활용 소재
+ ],
},
ev: {
color: '#2196F3',
label: '전기차 충전소',
// Lucide Zap icon
iconPath: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z',
+ dummyImages: [
+ 'https://images.unsplash.com/photo-1593941707882-a5bba14938c7?w=800&q=80', // EV 충전기
+ 'https://images.unsplash.com/photo-1617788138017-80ad40651399?w=800&q=80', // 전기차 충전 중
+ 'https://images.unsplash.com/photo-1635274540951-b7f86f6821df?w=800&q=80', // EV 충전소
+ 'https://images.unsplash.com/photo-1609557927087-f9cf8e88de18?w=800&q=80', // 테슬라 충전
+ ],
},
hcar: {
color: '#00BCD4',
@@ -21,6 +33,12 @@ const CATEGORY_CONFIG = {
// Lucide Fuel icon
iconPath:
'M3 22h12 M4 9h10 M14 22V4a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v18 M14 13h2a2 2 0 0 1 2 2v2a2 2 0 0 0 2 2h0a2 2 0 0 0 2-2V9.83a2 2 0 0 0-.59-1.42L18 5',
+ dummyImages: [
+ 'https://images.unsplash.com/photo-1628519906461-c1dd8da29e3f?w=800&q=80', // 수소 충전소
+ 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=800&q=80', // 수소 탱크
+ 'https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?w=800&q=80', // 현대 수소차
+ 'https://images.unsplash.com/photo-1542282088-fe8426682b8f?w=800&q=80', // 친환경 차량
+ ],
},
store: {
color: '#9C27B0',
@@ -28,6 +46,12 @@ const CATEGORY_CONFIG = {
// Lucide ShoppingBag icon
iconPath:
'M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z M3 6h18 M16 10a4 4 0 0 1-8 0',
+ dummyImages: [
+ 'https://images.unsplash.com/photo-1542838132-92c53300491e?w=800&q=80', // 제로웨이스트 매장
+ 'https://images.unsplash.com/photo-1610701596007-11502861dcfa?w=800&q=80', // 친환경 제품
+ 'https://images.unsplash.com/photo-1615811361523-6bd03d7748e7?w=800&q=80', // 재사용 가능한 가방
+ 'https://images.unsplash.com/photo-1591195853828-11db59a44f6b?w=800&q=80', // 벌크 상품
+ ],
},
bike: {
color: '#FF9800',
@@ -35,6 +59,12 @@ const CATEGORY_CONFIG = {
// Lucide Bike icon
iconPath:
'M18.5 21a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z M5.5 21a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z M15 6a1 1 0 1 0 0-2 1 1 0 0 0 0 2z M12 17.5V14l-3-3 4-3 2 3h2',
+ dummyImages: [
+ 'https://images.unsplash.com/photo-1559295288-da899c6b7e7c?w=800&q=80', // 공유 자전거
+ 'https://images.unsplash.com/photo-1571068316344-75bc76f77890?w=800&q=80', // 자전거 주차
+ 'https://images.unsplash.com/photo-1485965120184-e220f721d03e?w=800&q=80', // 도시 자전거
+ 'https://images.unsplash.com/photo-1507035895480-2b3156c31fc8?w=800&q=80', // 자전거 거치대
+ ],
},
};
@@ -52,19 +82,53 @@ export const getCategoryLabel = (category) => {
return CATEGORY_CONFIG[category]?.label || category;
};
+/**
+ * Get dummy image URL for a facility
+ * @param {string} category - Facility category
+ * @param {string} facilityId - Facility ID for consistent image selection
+ * @returns {string} Image URL
+ */
+export const getDummyImage = (category, facilityId) => {
+ const config = CATEGORY_CONFIG[category];
+ if (!config || !config.dummyImages || config.dummyImages.length === 0) {
+ // 기본 이미지 (친환경 일반)
+ return 'https://images.unsplash.com/photo-1542601906990-b4d3fb778b09?w=800&q=80';
+ }
+
+ // facilityId를 기반으로 일관된 이미지 선택 (같은 시설은 항상 같은 이미지)
+ const hash = facilityId
+ ? facilityId
+ .split('')
+ .reduce((acc, char) => acc + char.charCodeAt(0), 0)
+ : 0;
+ const index = hash % config.dummyImages.length;
+
+ return config.dummyImages[index];
+};
+
/**
* Create a custom marker image for Kakao Map with Lucide React icon
*/
-export const createMarkerImage = (kakao, category) => {
+export const createMarkerImage = (kakao, category, isSelected = false) => {
const config = CATEGORY_CONFIG[category] || {
color: '#666666',
iconPath: 'M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5',
};
+ // 선택된 마커는 더 크고 반짝이는 효과
+ const size = isSelected ? 48 : 40;
+ const radius = isSelected ? 20 : 16;
+ const strokeWidth = isSelected ? 3 : 2.5;
+ const animation = isSelected
+ ? `
`
+ : '';
+
const svg = `
-