feat: 항공기 줌 스케일 + 선박/항공기 심볼 크기 조정 패널

- 항공기 아이콘에 정수레벨 줌 기반 스케일 적용 (getZoomScale export)
- 심볼 크기 조정: SymbolScaleContext + SymbolScalePanel (0.5~2.0x)
- LayerPanel에 '심볼 크기' 섹션 추가 (선박/항공기 개별 조정)
- localStorage 영속화 (mapSymbolScale)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-01 12:29:22 +09:00
부모 71d607e499
커밋 77efab8652
8개의 변경된 파일89개의 추가작업 그리고 4개의 파일을 삭제

파일 보기

@ -10,6 +10,7 @@ import LoginPage from './components/auth/LoginPage';
import CollectorMonitor from './components/common/CollectorMonitor';
import { SharedFilterProvider } from './contexts/SharedFilterContext';
import { FontScaleProvider } from './contexts/FontScaleContext';
import { SymbolScaleProvider } from './contexts/SymbolScaleContext';
import { IranDashboard } from './components/iran/IranDashboard';
import { KoreaDashboard } from './components/korea/KoreaDashboard';
import './App.css';
@ -67,6 +68,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
return (
<FontScaleProvider>
<SymbolScaleProvider>
<SharedFilterProvider>
<div className={`app ${isLive ? 'app-live' : ''}`}>
<header className="app-header">
@ -160,6 +162,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
)}
</div>
</SharedFilterProvider>
</SymbolScaleProvider>
</FontScaleProvider>
);
}

파일 보기

@ -2,6 +2,7 @@ import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocalStorageSet } from '../../hooks/useLocalStorage';
import { FontScalePanel } from './FontScalePanel';
import { SymbolScalePanel } from './SymbolScalePanel';
// Aircraft category colors (matches AircraftLayer military fixed colors)
const AC_CAT_COLORS: Record<string, string> = {
@ -897,6 +898,7 @@ export function LayerPanel({
)}
</div>
<FontScalePanel />
<SymbolScalePanel />
</div>
);
}

파일 보기

@ -0,0 +1,43 @@
import { useState } from 'react';
import { useSymbolScale } from '../../hooks/useSymbolScale';
import type { SymbolScaleConfig } from '../../contexts/symbolScaleState';
const LABELS: Record<keyof SymbolScaleConfig, string> = {
ship: '선박 심볼',
aircraft: '항공기 심볼',
};
export function SymbolScalePanel() {
const { symbolScale, setSymbolScale } = useSymbolScale();
const [open, setOpen] = useState(false);
const update = (key: keyof SymbolScaleConfig, val: number) => {
setSymbolScale({ ...symbolScale, [key]: Math.round(val * 10) / 10 });
};
return (
<div className="font-scale-section">
<button type="button" className="font-scale-toggle" onClick={() => setOpen(!open)}>
<span>&#9670; </span>
<span>{open ? '▼' : '▶'}</span>
</button>
{open && (
<div className="font-scale-sliders">
{(Object.keys(LABELS) as (keyof SymbolScaleConfig)[]).map(key => (
<div key={key} className="font-scale-row">
<label>{LABELS[key]}</label>
<input type="range" min={0.5} max={2.0} step={0.1}
value={symbolScale[key]}
onChange={e => update(key, parseFloat(e.target.value))} />
<span>{symbolScale[key].toFixed(1)}</span>
</div>
))}
<button type="button" className="font-scale-reset"
onClick={() => setSymbolScale({ ship: 1.0, aircraft: 1.0 })}>
</button>
</div>
)}
</div>
);
}

파일 보기

@ -2,6 +2,9 @@ import { memo, useMemo, useState, useEffect } from 'react';
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
import { useTranslation } from 'react-i18next';
import type { Aircraft, AircraftCategory } from '../../types';
import { useShipDeckStore } from '../../stores/shipDeckStore';
import { getZoomScale } from '../../hooks/useShipDeckLayers';
import { useSymbolScale } from '../../hooks/useSymbolScale';
interface Props {
aircraft: Aircraft[];
@ -187,9 +190,12 @@ export function AircraftLayer({ aircraft, militaryOnly }: Props) {
const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
const { t } = useTranslation('ships');
const [showPopup, setShowPopup] = useState(false);
const zoomLevel = useShipDeckStore(s => s.zoomLevel);
const { symbolScale } = useSymbolScale();
const color = getAircraftColor(ac);
const shape = getShape(ac);
const size = shape.w;
const zs = getZoomScale(zoomLevel);
const size = Math.round(shape.w * zs * symbolScale.aircraft / 0.8);
const showLabel = ac.category === 'fighter' || ac.category === 'surveillance';
const strokeWidth = ac.category === 'fighter' || ac.category === 'military' ? 1.5 : 0.8;

파일 보기

@ -0,0 +1,10 @@
import type { ReactNode } from 'react';
import { useLocalStorage } from '../hooks/useLocalStorage';
import { SymbolScaleCtx, DEFAULT_SYMBOL_SCALE } from './symbolScaleState';
export type { SymbolScaleConfig } from './symbolScaleState';
export function SymbolScaleProvider({ children }: { children: ReactNode }) {
const [symbolScale, setSymbolScale] = useLocalStorage('mapSymbolScale', DEFAULT_SYMBOL_SCALE);
return <SymbolScaleCtx.Provider value={{ symbolScale, setSymbolScale }}>{children}</SymbolScaleCtx.Provider>;
}

파일 보기

@ -0,0 +1,12 @@
import { createContext } from 'react';
export interface SymbolScaleConfig {
ship: number;
aircraft: number;
}
export const DEFAULT_SYMBOL_SCALE: SymbolScaleConfig = { ship: 1.0, aircraft: 1.0 };
export const SymbolScaleCtx = createContext<{ symbolScale: SymbolScaleConfig; setSymbolScale: (c: SymbolScaleConfig) => void }>({
symbolScale: DEFAULT_SYMBOL_SCALE, setSymbolScale: () => {},
});

파일 보기

@ -9,6 +9,7 @@ import { getMarineTrafficCategory } from '../utils/marineTraffic';
import { getNationalityGroup } from './useKoreaData';
import { FONT_MONO } from '../styles/fonts';
import type { Ship, VesselAnalysisDto } from '../types';
import { useSymbolScale } from './useSymbolScale';
// ── Constants ─────────────────────────────────────────────────────────────────
@ -19,7 +20,7 @@ const ZOOM_SCALE: Record<number, number> = {
};
const ZOOM_SCALE_DEFAULT = 4.2; // z14+
function getZoomScale(zoom: number): number {
export function getZoomScale(zoom: number): number {
if (zoom >= 14) return ZOOM_SCALE_DEFAULT;
return ZOOM_SCALE[zoom] ?? ZOOM_SCALE_DEFAULT;
}
@ -156,6 +157,8 @@ export function useShipDeckLayers(
shipLayerRef: React.MutableRefObject<Layer[]>,
requestRender: () => void,
): void {
const { symbolScale } = useSymbolScale();
const shipSymbolScale = symbolScale.ship;
const renderFrame = useCallback(() => {
const state = useShipDeckStore.getState();
@ -170,7 +173,7 @@ export function useShipDeckLayers(
return;
}
const zoomScale = getZoomScale(zoomLevel);
const zoomScale = getZoomScale(zoomLevel) * shipSymbolScale;
const layers: Layer[] = [];
// 1. Build filtered ship render data (~3K ships, <1ms)
@ -316,7 +319,7 @@ export function useShipDeckLayers(
shipLayerRef.current = layers;
requestRender();
}, [shipLayerRef, requestRender]);
}, [shipLayerRef, requestRender, shipSymbolScale]);
// Subscribe to all relevant state changes
useEffect(() => {

파일 보기

@ -0,0 +1,6 @@
import { useContext } from 'react';
import { SymbolScaleCtx } from '../contexts/symbolScaleState';
export function useSymbolScale() {
return useContext(SymbolScaleCtx);
}