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:
부모
71d607e499
커밋
77efab8652
@ -10,6 +10,7 @@ import LoginPage from './components/auth/LoginPage';
|
|||||||
import CollectorMonitor from './components/common/CollectorMonitor';
|
import CollectorMonitor from './components/common/CollectorMonitor';
|
||||||
import { SharedFilterProvider } from './contexts/SharedFilterContext';
|
import { SharedFilterProvider } from './contexts/SharedFilterContext';
|
||||||
import { FontScaleProvider } from './contexts/FontScaleContext';
|
import { FontScaleProvider } from './contexts/FontScaleContext';
|
||||||
|
import { SymbolScaleProvider } from './contexts/SymbolScaleContext';
|
||||||
import { IranDashboard } from './components/iran/IranDashboard';
|
import { IranDashboard } from './components/iran/IranDashboard';
|
||||||
import { KoreaDashboard } from './components/korea/KoreaDashboard';
|
import { KoreaDashboard } from './components/korea/KoreaDashboard';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
@ -67,6 +68,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FontScaleProvider>
|
<FontScaleProvider>
|
||||||
|
<SymbolScaleProvider>
|
||||||
<SharedFilterProvider>
|
<SharedFilterProvider>
|
||||||
<div className={`app ${isLive ? 'app-live' : ''}`}>
|
<div className={`app ${isLive ? 'app-live' : ''}`}>
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
@ -160,6 +162,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SharedFilterProvider>
|
</SharedFilterProvider>
|
||||||
|
</SymbolScaleProvider>
|
||||||
</FontScaleProvider>
|
</FontScaleProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useState, useCallback } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocalStorageSet } from '../../hooks/useLocalStorage';
|
import { useLocalStorageSet } from '../../hooks/useLocalStorage';
|
||||||
import { FontScalePanel } from './FontScalePanel';
|
import { FontScalePanel } from './FontScalePanel';
|
||||||
|
import { SymbolScalePanel } from './SymbolScalePanel';
|
||||||
|
|
||||||
// Aircraft category colors (matches AircraftLayer military fixed colors)
|
// Aircraft category colors (matches AircraftLayer military fixed colors)
|
||||||
const AC_CAT_COLORS: Record<string, string> = {
|
const AC_CAT_COLORS: Record<string, string> = {
|
||||||
@ -897,6 +898,7 @@ export function LayerPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<FontScalePanel />
|
<FontScalePanel />
|
||||||
|
<SymbolScalePanel />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
43
frontend/src/components/common/SymbolScalePanel.tsx
Normal file
43
frontend/src/components/common/SymbolScalePanel.tsx
Normal file
@ -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>◆ 심볼 크기</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 { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { Aircraft, AircraftCategory } from '../../types';
|
import type { Aircraft, AircraftCategory } from '../../types';
|
||||||
|
import { useShipDeckStore } from '../../stores/shipDeckStore';
|
||||||
|
import { getZoomScale } from '../../hooks/useShipDeckLayers';
|
||||||
|
import { useSymbolScale } from '../../hooks/useSymbolScale';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
aircraft: Aircraft[];
|
aircraft: Aircraft[];
|
||||||
@ -187,9 +190,12 @@ export function AircraftLayer({ aircraft, militaryOnly }: Props) {
|
|||||||
const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
|
const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
|
||||||
const { t } = useTranslation('ships');
|
const { t } = useTranslation('ships');
|
||||||
const [showPopup, setShowPopup] = useState(false);
|
const [showPopup, setShowPopup] = useState(false);
|
||||||
|
const zoomLevel = useShipDeckStore(s => s.zoomLevel);
|
||||||
|
const { symbolScale } = useSymbolScale();
|
||||||
const color = getAircraftColor(ac);
|
const color = getAircraftColor(ac);
|
||||||
const shape = getShape(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 showLabel = ac.category === 'fighter' || ac.category === 'surveillance';
|
||||||
const strokeWidth = ac.category === 'fighter' || ac.category === 'military' ? 1.5 : 0.8;
|
const strokeWidth = ac.category === 'fighter' || ac.category === 'military' ? 1.5 : 0.8;
|
||||||
|
|
||||||
|
|||||||
10
frontend/src/contexts/SymbolScaleContext.tsx
Normal file
10
frontend/src/contexts/SymbolScaleContext.tsx
Normal file
@ -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>;
|
||||||
|
}
|
||||||
12
frontend/src/contexts/symbolScaleState.ts
Normal file
12
frontend/src/contexts/symbolScaleState.ts
Normal file
@ -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 { getNationalityGroup } from './useKoreaData';
|
||||||
import { FONT_MONO } from '../styles/fonts';
|
import { FONT_MONO } from '../styles/fonts';
|
||||||
import type { Ship, VesselAnalysisDto } from '../types';
|
import type { Ship, VesselAnalysisDto } from '../types';
|
||||||
|
import { useSymbolScale } from './useSymbolScale';
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -19,7 +20,7 @@ const ZOOM_SCALE: Record<number, number> = {
|
|||||||
};
|
};
|
||||||
const ZOOM_SCALE_DEFAULT = 4.2; // z14+
|
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;
|
if (zoom >= 14) return ZOOM_SCALE_DEFAULT;
|
||||||
return ZOOM_SCALE[zoom] ?? ZOOM_SCALE_DEFAULT;
|
return ZOOM_SCALE[zoom] ?? ZOOM_SCALE_DEFAULT;
|
||||||
}
|
}
|
||||||
@ -156,6 +157,8 @@ export function useShipDeckLayers(
|
|||||||
shipLayerRef: React.MutableRefObject<Layer[]>,
|
shipLayerRef: React.MutableRefObject<Layer[]>,
|
||||||
requestRender: () => void,
|
requestRender: () => void,
|
||||||
): void {
|
): void {
|
||||||
|
const { symbolScale } = useSymbolScale();
|
||||||
|
const shipSymbolScale = symbolScale.ship;
|
||||||
|
|
||||||
const renderFrame = useCallback(() => {
|
const renderFrame = useCallback(() => {
|
||||||
const state = useShipDeckStore.getState();
|
const state = useShipDeckStore.getState();
|
||||||
@ -170,7 +173,7 @@ export function useShipDeckLayers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const zoomScale = getZoomScale(zoomLevel);
|
const zoomScale = getZoomScale(zoomLevel) * shipSymbolScale;
|
||||||
const layers: Layer[] = [];
|
const layers: Layer[] = [];
|
||||||
|
|
||||||
// 1. Build filtered ship render data (~3K ships, <1ms)
|
// 1. Build filtered ship render data (~3K ships, <1ms)
|
||||||
@ -316,7 +319,7 @@ export function useShipDeckLayers(
|
|||||||
|
|
||||||
shipLayerRef.current = layers;
|
shipLayerRef.current = layers;
|
||||||
requestRender();
|
requestRender();
|
||||||
}, [shipLayerRef, requestRender]);
|
}, [shipLayerRef, requestRender, shipSymbolScale]);
|
||||||
|
|
||||||
// Subscribe to all relevant state changes
|
// Subscribe to all relevant state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
6
frontend/src/hooks/useSymbolScale.ts
Normal file
6
frontend/src/hooks/useSymbolScale.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { SymbolScaleCtx } from '../contexts/symbolScaleState';
|
||||||
|
|
||||||
|
export function useSymbolScale() {
|
||||||
|
return useContext(SymbolScaleCtx);
|
||||||
|
}
|
||||||
불러오는 중...
Reference in New Issue
Block a user