Merge pull request 'feat(map): AIS 로딩 인디케이터 및 UX 개선' (#6) from feat/ais-loading-and-ux into develop
This commit is contained in:
커밋
78b7212bd1
@ -4,6 +4,13 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 추가
|
||||
- 선박 신호 수신 중 지도 오버레이 + 스피너 로딩 인디케이터 표시
|
||||
|
||||
### 변경
|
||||
- 초기 뷰포트를 대한민국 전역 중심(zoom 7)으로 변경
|
||||
- deck.gl 선박 툴팁 배경색을 흰색 계통으로 변경하여 가독성 개선
|
||||
|
||||
## [2026-03-31]
|
||||
|
||||
### 수정
|
||||
|
||||
@ -63,11 +63,11 @@ body {
|
||||
|
||||
/* deck.gl tooltip */
|
||||
.deck-tooltip{
|
||||
background:rgba(26,58,74,.96)!important;
|
||||
color:#fff!important;
|
||||
border:1px solid rgba(0,147,178,.35)!important;
|
||||
background:rgba(255,255,255,.97)!important;
|
||||
color:#1a2e38!important;
|
||||
border:1px solid #d4dde2!important;
|
||||
border-radius:6px!important;
|
||||
box-shadow:0 6px 20px rgba(0,0,0,.35)!important;
|
||||
box-shadow:0 4px 16px rgba(0,0,0,.15)!important;
|
||||
padding:7px 9px!important;
|
||||
font-size:12px!important;
|
||||
line-height:1.35!important;
|
||||
|
||||
@ -30,6 +30,8 @@ const MapViewML: React.FC<MapViewMLProps> = ({ children }) => {
|
||||
const [scaleText, setScaleText] = React.useState('1:50,000');
|
||||
const [zoom, setZoom] = React.useState(10);
|
||||
|
||||
const aisLoading = useStore((s) => s.aisLoading);
|
||||
const aisEmpty = useStore((s) => s.aisTargets.size === 0);
|
||||
const photoModal = useStore((s) => s.photoModal);
|
||||
const openPhotoModal = useStore((s) => s.openPhotoModal);
|
||||
const closePhotoModal = useStore((s) => s.closePhotoModal);
|
||||
@ -97,6 +99,20 @@ const MapViewML: React.FC<MapViewMLProps> = ({ children }) => {
|
||||
<ScaleBar text={`${scaleText} ━━━ 5 km`} />
|
||||
<MapLegend />
|
||||
{children}
|
||||
|
||||
{(aisLoading || aisEmpty) && (
|
||||
<>
|
||||
<div style={{ position: 'absolute', inset: 0, background: 'rgba(255,255,255,.45)', zIndex: 15, pointerEvents: 'none' }} />
|
||||
<div style={{ position: 'absolute', top: 12, left: '50%', transform: 'translateX(-50%)', background: 'rgba(26,58,74,.88)', color: '#fff', padding: '8px 20px', borderRadius: 6, fontSize: 13, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 10, zIndex: 20, boxShadow: '0 2px 8px rgba(0,0,0,.25)', pointerEvents: 'none' }}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" style={{ animation: 'spin 1s linear infinite' }}>
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke="rgba(255,255,255,.3)" strokeWidth="3" />
|
||||
<path d="M12 2a10 10 0 0 1 10 10" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
선박 신호 수신 중...
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<CoordStatusBar coord={coord} zoom={zoom} scale={scaleText} />
|
||||
|
||||
{photoModal && (
|
||||
|
||||
@ -10,6 +10,7 @@ const RETENTION_MS = 120 * 60 * 1000;
|
||||
|
||||
export function useAisPolling() {
|
||||
const setAisTargets = useStore((s) => s.setAisTargets);
|
||||
const setAisLoading = useStore((s) => s.setAisLoading);
|
||||
const storeRef = useRef(useStore.getState);
|
||||
|
||||
useEffect(() => {
|
||||
@ -19,6 +20,7 @@ export function useAisPolling() {
|
||||
useEffect(() => {
|
||||
const ac = new AbortController();
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let isInitial = true;
|
||||
|
||||
function mergeAndPrune(incoming: AisTarget[]) {
|
||||
const prev = storeRef.current().aisTargets;
|
||||
@ -43,12 +45,18 @@ export function useAisPolling() {
|
||||
|
||||
async function poll(minutes: number) {
|
||||
try {
|
||||
if (isInitial) setAisLoading(true);
|
||||
const targets = await fetchRecentPositions(minutes, ac.signal);
|
||||
mergeAndPrune(targets);
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== 'AbortError') {
|
||||
console.warn('[AIS] Poll failed:', err);
|
||||
}
|
||||
} finally {
|
||||
if (isInitial) {
|
||||
setAisLoading(false);
|
||||
isInitial = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,5 +69,5 @@ export function useAisPolling() {
|
||||
ac.abort();
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [setAisTargets]);
|
||||
}, [setAisTargets, setAisLoading]);
|
||||
}
|
||||
|
||||
@ -28,6 +28,8 @@ interface AppState {
|
||||
// AIS 실시간 데이터
|
||||
aisTargets: Map<number, AisTarget>;
|
||||
setAisTargets: (targets: Map<number, AisTarget>) => void;
|
||||
aisLoading: boolean;
|
||||
setAisLoading: (loading: boolean) => void;
|
||||
|
||||
// 선박 사진 모달
|
||||
photoModal: { imo: number; name: string; imagePath: string; imageCount: number } | null;
|
||||
@ -72,6 +74,8 @@ export const useStore = create<AppState>((set) => ({
|
||||
|
||||
aisTargets: new Map(),
|
||||
setAisTargets: (targets) => set({ aisTargets: targets }),
|
||||
aisLoading: true,
|
||||
setAisLoading: (loading) => set({ aisLoading: loading }),
|
||||
|
||||
photoModal: null,
|
||||
openPhotoModal: (info) => set({ photoModal: info }),
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import type maplibregl from 'maplibre-gl';
|
||||
|
||||
/** 인천 해역 기본 중심 좌표 [lng, lat] */
|
||||
export const DEFAULT_CENTER: [number, number] = [126.7052, 37.4563];
|
||||
/** 대한민국 전역 중심 좌표 [lng, lat] */
|
||||
export const DEFAULT_CENTER: [number, number] = [127.8, 35.5];
|
||||
|
||||
/** 기본 줌 레벨 */
|
||||
export const DEFAULT_ZOOM = 10;
|
||||
/** 기본 줌 레벨 (대한민국 전역 표시) */
|
||||
export const DEFAULT_ZOOM = 7;
|
||||
|
||||
/** Martin ENC 타일 서버 프록시 경로 */
|
||||
export const MARTIN_BASE_PATH = '/martin';
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user