feat: 지도 글꼴 크기 커스텀 시스템 (4개 그룹 슬라이더)

- FontScaleContext + FontScalePanel: 시설/선박/분석/지역 4그룹 × 0.5~2.0 범위
- LAYERS 패널 하단 슬라이더 UI, localStorage 영속화
- Korea static 14개 + Iran 4개 + 분석 3개 + KoreaMap 5개 TextLayer 적용
- MapLibre 선박 라벨/국가명 실시간 반영
- 모든 useMemo deps + updateTriggers에 fontScale 포함
This commit is contained in:
htlee 2026-03-24 09:27:11 +09:00
부모 6d4ac4d3fe
커밋 44aa449b03
23개의 변경된 파일226개의 추가작업 그리고 70개의 파일을 삭제

파일 보기

@ -4,6 +4,20 @@
## [Unreleased] ## [Unreleased]
### 추가
- LayerPanel 공통 트리 구조: LayerTreeNode 재귀 렌더러 (한국/이란 양쪽 적용)
- 위험시설/해외시설 emoji→SVG IconLayer 전환 (12 SVG 함수, hazard/CN/JP 3개 IconLayer)
- S&P Global 피격 선박 27척 데이터 (damagedShips.ts)
- 이란 리플레이 실데이터 전환: Backend 시점 조회 API + Events CRUD
- GeoEvent `sea_attack` 타입 + SEA ATK 배지 (피격 선박 이벤트 로그 통합)
- 더미↔API 토글 UI (리플레이 배속 우측)
- 대시보드 탭 localStorage 영속화
### 변경
- 부모 노드 토글→하위 전체 ON/OFF 캐스케이드 + 카운트 합산
- useIranData dataSource 분기 (dummy=sampleData, api=Backend DB 3월1일~오늘)
- fetchAircraftByRange, fetchOsintByRange, fetchEventsByRange 서비스 함수
## [2026-03-23.6] ## [2026-03-23.6]
### 수정 ### 수정

파일 보기

@ -2462,3 +2462,42 @@
text-align: center; text-align: center;
opacity: 0.5; opacity: 0.5;
} }
/* ── FontScalePanel ──────────────────────── */
.font-scale-section { margin-top: 4px; }
.font-scale-toggle {
width: 100%;
padding: 4px 8px;
font-size: 10px;
color: var(--kcg-text);
background: transparent;
border: none;
border-top: 1px solid rgba(255,255,255,0.08);
cursor: pointer;
display: flex;
justify-content: space-between;
}
.font-scale-toggle:hover { background: rgba(255,255,255,0.05); }
.font-scale-sliders { padding: 4px 8px; }
.font-scale-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 9px;
color: var(--kcg-dim);
margin-bottom: 3px;
}
.font-scale-row label { width: 60px; flex-shrink: 0; }
.font-scale-row input[type="range"] { flex: 1; height: 12px; accent-color: var(--kcg-primary, #3b82f6); }
.font-scale-row span { width: 24px; text-align: right; font-variant-numeric: tabular-nums; }
.font-scale-reset {
width: 100%;
padding: 2px;
font-size: 9px;
color: var(--kcg-dim);
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 3px;
cursor: pointer;
margin-top: 4px;
}

파일 보기

@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
import LoginPage from './components/auth/LoginPage'; 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 { 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';
@ -65,6 +66,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
const currentTime = isLive ? monitor.state.currentTime : replay.state.currentTime; const currentTime = isLive ? monitor.state.currentTime : replay.state.currentTime;
return ( return (
<FontScaleProvider>
<SharedFilterProvider> <SharedFilterProvider>
<div className={`app ${isLive ? 'app-live' : ''}`}> <div className={`app ${isLive ? 'app-live' : ''}`}>
<header className="app-header"> <header className="app-header">
@ -158,6 +160,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
)} )}
</div> </div>
</SharedFilterProvider> </SharedFilterProvider>
</FontScaleProvider>
); );
} }

파일 보기

@ -0,0 +1,45 @@
import { useState } from 'react';
import { useFontScale } from '../../hooks/useFontScale';
import type { FontScaleConfig } from '../../contexts/fontScaleState';
const LABELS: Record<keyof FontScaleConfig, string> = {
facility: '시설 라벨',
ship: '선박 이름',
analysis: '분석 라벨',
area: '지역/국가명',
};
export function FontScalePanel() {
const { fontScale, setFontScale } = useFontScale();
const [open, setOpen] = useState(false);
const update = (key: keyof FontScaleConfig, val: number) => {
setFontScale({ ...fontScale, [key]: Math.round(val * 10) / 10 });
};
return (
<div className="font-scale-section">
<button type="button" className="font-scale-toggle" onClick={() => setOpen(!open)}>
<span>Aa </span>
<span>{open ? '▼' : '▶'}</span>
</button>
{open && (
<div className="font-scale-sliders">
{(Object.keys(LABELS) as (keyof FontScaleConfig)[]).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={fontScale[key]}
onChange={e => update(key, parseFloat(e.target.value))} />
<span>{fontScale[key].toFixed(1)}</span>
</div>
))}
<button type="button" className="font-scale-reset"
onClick={() => setFontScale({ facility: 1.0, ship: 1.0, analysis: 1.0, area: 1.0 })}>
</button>
</div>
)}
</div>
);
}

파일 보기

@ -1,6 +1,7 @@
import { useState, useCallback } from 'react'; 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';
// 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> = {
@ -895,6 +896,7 @@ export function LayerPanel({
</> </>
)} )}
</div> </div>
<FontScalePanel />
</div> </div>
); );
} }

파일 보기

@ -54,6 +54,7 @@ export { layerKeyToSubType, layerKeyToCountry };
export interface MELayerConfig { export interface MELayerConfig {
layers: Record<string, boolean>; layers: Record<string, boolean>;
sc: number; sc: number;
fs?: number;
onPick: (facility: EnergyHazardFacility) => void; onPick: (facility: EnergyHazardFacility) => void;
} }
@ -174,6 +175,7 @@ function getIconUrl(subType: FacilitySubType): string {
export function createMEEnergyHazardLayers(config: MELayerConfig): Layer[] { export function createMEEnergyHazardLayers(config: MELayerConfig): Layer[] {
const { layers, sc, onPick } = config; const { layers, sc, onPick } = config;
const fs = config.fs ?? 1;
const visibleFacilities = ME_ENERGY_HAZARD_FACILITIES.filter(f => const visibleFacilities = ME_ENERGY_HAZARD_FACILITIES.filter(f =>
isFacilityVisible(f, layers), isFacilityVisible(f, layers),
@ -200,7 +202,7 @@ export function createMEEnergyHazardLayers(config: MELayerConfig): Layer[] {
data: visibleFacilities, data: visibleFacilities,
getPosition: (d) => [d.lng, d.lat], getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo,
getSize: 12 * sc, getSize: 12 * sc * fs,
updateTriggers: { getSize: [sc] }, updateTriggers: { getSize: [sc] },
getColor: (d) => hexToRgba(SUB_TYPE_META[d.subType].color, 200), getColor: (d) => hexToRgba(SUB_TYPE_META[d.subType].color, 200),
getTextAnchor: 'middle', getTextAnchor: 'middle',

파일 보기

@ -8,6 +8,7 @@ import { ShipLayer } from '../layers/ShipLayer';
import { DamagedShipLayer } from '../layers/DamagedShipLayer'; import { DamagedShipLayer } from '../layers/DamagedShipLayer';
import { SeismicMarker } from '../layers/SeismicMarker'; import { SeismicMarker } from '../layers/SeismicMarker';
import { DeckGLOverlay } from '../layers/DeckGLOverlay'; import { DeckGLOverlay } from '../layers/DeckGLOverlay';
import { useFontScale } from '../../hooks/useFontScale';
import { createMEEnergyHazardLayers } from './MEEnergyHazardLayer'; import { createMEEnergyHazardLayers } from './MEEnergyHazardLayer';
import type { EnergyHazardFacility } from '../../data/meEnergyHazardFacilities'; import type { EnergyHazardFacility } from '../../data/meEnergyHazardFacilities';
import { SUB_TYPE_META } from '../../data/meEnergyHazardFacilities'; import { SUB_TYPE_META } from '../../data/meEnergyHazardFacilities';
@ -128,6 +129,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
const [selectedEventId, setSelectedEventId] = useState<string | null>(null); const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
const [mePickedFacility, setMePickedFacility] = useState<EnergyHazardFacility | null>(null); const [mePickedFacility, setMePickedFacility] = useState<EnergyHazardFacility | null>(null);
const [iranPickedFacility, setIranPickedFacility] = useState<IranPickedFacility | null>(null); const [iranPickedFacility, setIranPickedFacility] = useState<IranPickedFacility | null>(null);
const { fontScale } = useFontScale();
const [zoomLevel, setZoomLevel] = useState(5); const [zoomLevel, setZoomLevel] = useState(5);
const zoomRef = useRef(5); const zoomRef = useRef(5);
@ -154,11 +156,11 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
}, [zoomLevel]); }, [zoomLevel]);
const iranDeckLayers = useMemo(() => [ const iranDeckLayers = useMemo(() => [
...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }), ...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }),
...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }), ...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }),
...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }), ...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }),
...createMEEnergyHazardLayers({ layers: layers as Record<string, boolean>, sc: zoomScale, onPick: setMePickedFacility }), ...createMEEnergyHazardLayers({ layers: layers as Record<string, boolean>, sc: zoomScale, fs: fontScale.facility, onPick: setMePickedFacility }),
], [layers, zoomScale]); ], [layers, zoomScale, fontScale.facility]);
useEffect(() => { useEffect(() => {
if (flyToTarget && mapRef.current) { if (flyToTarget && mapRef.current) {
@ -242,7 +244,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
filter={['==', ['get', 'rank'], 1]} filter={['==', ['get', 'rank'], 1]}
layout={{ layout={{
'text-field': ['get', 'name'], 'text-field': ['get', 'name'],
'text-size': 15, 'text-size': 15 * fontScale.area,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false, 'text-allow-overlap': false,
'text-ignore-placement': false, 'text-ignore-placement': false,
@ -261,7 +263,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
filter={['==', ['get', 'rank'], 2]} filter={['==', ['get', 'rank'], 2]}
layout={{ layout={{
'text-field': ['get', 'name'], 'text-field': ['get', 'name'],
'text-size': 12, 'text-size': 12 * fontScale.area,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false, 'text-allow-overlap': false,
'text-ignore-placement': false, 'text-ignore-placement': false,
@ -281,7 +283,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
minzoom={5} minzoom={5}
layout={{ layout={{
'text-field': ['get', 'name'], 'text-field': ['get', 'name'],
'text-size': 10, 'text-size': 10 * fontScale.area,
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'], 'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
'text-allow-overlap': false, 'text-allow-overlap': false,
'text-ignore-placement': false, 'text-ignore-placement': false,

파일 보기

@ -8,6 +8,7 @@ import { ShipLayer } from '../layers/ShipLayer';
import { DamagedShipLayer } from '../layers/DamagedShipLayer'; import { DamagedShipLayer } from '../layers/DamagedShipLayer';
import { SeismicMarker } from '../layers/SeismicMarker'; import { SeismicMarker } from '../layers/SeismicMarker';
import { DeckGLOverlay } from '../layers/DeckGLOverlay'; import { DeckGLOverlay } from '../layers/DeckGLOverlay';
import { useFontScale } from '../../hooks/useFontScale';
import { createMEEnergyHazardLayers } from './MEEnergyHazardLayer'; import { createMEEnergyHazardLayers } from './MEEnergyHazardLayer';
import type { EnergyHazardFacility } from '../../data/meEnergyHazardFacilities'; import type { EnergyHazardFacility } from '../../data/meEnergyHazardFacilities';
import { SUB_TYPE_META } from '../../data/meEnergyHazardFacilities'; import { SUB_TYPE_META } from '../../data/meEnergyHazardFacilities';
@ -111,6 +112,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
const [selectedEventId, setSelectedEventId] = useState<string | null>(null); const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
const [mePickedFacility, setMePickedFacility] = useState<EnergyHazardFacility | null>(null); const [mePickedFacility, setMePickedFacility] = useState<EnergyHazardFacility | null>(null);
const [iranPickedFacility, setIranPickedFacility] = useState<IranPickedFacility | null>(null); const [iranPickedFacility, setIranPickedFacility] = useState<IranPickedFacility | null>(null);
const { fontScale } = useFontScale();
const [zoomLevel, setZoomLevel] = useState(5); const [zoomLevel, setZoomLevel] = useState(5);
const zoomRef = useRef(5); const zoomRef = useRef(5);
@ -137,11 +139,11 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
}, [zoomLevel]); }, [zoomLevel]);
const iranDeckLayers = useMemo(() => [ const iranDeckLayers = useMemo(() => [
...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }), ...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }),
...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }), ...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }),
...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }), ...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }),
...createMEEnergyHazardLayers({ layers: layers as Record<string, boolean>, sc: zoomScale, onPick: setMePickedFacility }), ...createMEEnergyHazardLayers({ layers: layers as Record<string, boolean>, sc: zoomScale, fs: fontScale.facility, onPick: setMePickedFacility }),
], [layers, zoomScale]); ], [layers, zoomScale, fontScale.facility]);
useEffect(() => { useEffect(() => {
if (flyToTarget && mapRef.current) { if (flyToTarget && mapRef.current) {
@ -234,7 +236,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
layout={{ layout={{
'text-field': ['get', 'name'], 'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold'], 'text-font': ['Open Sans Bold'],
'text-size': 15, 'text-size': 15 * fontScale.area,
'text-allow-overlap': false, 'text-allow-overlap': false,
'text-ignore-placement': false, 'text-ignore-placement': false,
}} }}
@ -251,7 +253,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
layout={{ layout={{
'text-field': ['get', 'name'], 'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold'], 'text-font': ['Open Sans Bold'],
'text-size': 12, 'text-size': 12 * fontScale.area,
'text-allow-overlap': false, 'text-allow-overlap': false,
}} }}
paint={{ paint={{
@ -268,7 +270,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
layout={{ layout={{
'text-field': ['get', 'name'], 'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold'], 'text-font': ['Open Sans Bold'],
'text-size': 10, 'text-size': 10 * fontScale.area,
'text-allow-overlap': false, 'text-allow-overlap': false,
}} }}
paint={{ paint={{

파일 보기

@ -51,11 +51,13 @@ function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
export interface IranAirportLayerConfig { export interface IranAirportLayerConfig {
visible: boolean; visible: boolean;
sc: number; sc: number;
fs?: number;
onPick: (airport: Airport) => void; onPick: (airport: Airport) => void;
} }
export function createIranAirportLayers(config: IranAirportLayerConfig): Layer[] { export function createIranAirportLayers(config: IranAirportLayerConfig): Layer[] {
const { visible, sc, onPick } = config; const { visible, sc, onPick } = config;
const fs = config.fs ?? 1;
if (!visible) return []; if (!visible) return [];
const iconLayer = new IconLayer<Airport>({ const iconLayer = new IconLayer<Airport>({
@ -84,7 +86,7 @@ export function createIranAirportLayers(config: IranAirportLayerConfig): Layer[]
const nameKo = d.nameKo ?? d.name; const nameKo = d.nameKo ?? d.name;
return nameKo.length > 10 ? nameKo.slice(0, 10) + '..' : nameKo; return nameKo.length > 10 ? nameKo.slice(0, 10) + '..' : nameKo;
}, },
getSize: 11 * sc, getSize: 11 * sc * fs,
updateTriggers: { getSize: [sc] }, updateTriggers: { getSize: [sc] },
getColor: (d) => hexToRgba(getAirportColor(d)), getColor: (d) => hexToRgba(getAirportColor(d)),
getTextAnchor: 'middle', getTextAnchor: 'middle',

파일 보기

@ -108,11 +108,13 @@ function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
export interface IranOilLayerConfig { export interface IranOilLayerConfig {
visible: boolean; visible: boolean;
sc: number; sc: number;
fs?: number;
onPick: (facility: OilFacility) => void; onPick: (facility: OilFacility) => void;
} }
export function createIranOilLayers(config: IranOilLayerConfig): Layer[] { export function createIranOilLayers(config: IranOilLayerConfig): Layer[] {
const { visible, sc, onPick } = config; const { visible, sc, onPick } = config;
const fs = config.fs ?? 1;
if (!visible) return []; if (!visible) return [];
const iconLayer = new IconLayer<OilFacility>({ const iconLayer = new IconLayer<OilFacility>({
@ -134,7 +136,7 @@ export function createIranOilLayers(config: IranOilLayerConfig): Layer[] {
data: iranOilFacilities, data: iranOilFacilities,
getPosition: (d) => [d.lng, d.lat], getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo, getText: (d) => d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo,
getSize: 12 * sc, getSize: 12 * sc * fs,
updateTriggers: { getSize: [sc] }, updateTriggers: { getSize: [sc] },
getColor: (d) => hexToRgba(TYPE_COLORS[d.type]), getColor: (d) => hexToRgba(TYPE_COLORS[d.type]),
getTextAnchor: 'middle', getTextAnchor: 'middle',

파일 보기

@ -103,11 +103,13 @@ function getIconUrl(type: MEFacilityType): string {
export interface MEFacilityLayerConfig { export interface MEFacilityLayerConfig {
visible: boolean; visible: boolean;
sc: number; sc: number;
fs?: number;
onPick: (facility: MEFacility) => void; onPick: (facility: MEFacility) => void;
} }
export function createMEFacilityLayers(config: MEFacilityLayerConfig): Layer[] { export function createMEFacilityLayers(config: MEFacilityLayerConfig): Layer[] {
const { visible, sc, onPick } = config; const { visible, sc, onPick } = config;
const fs = config.fs ?? 1;
if (!visible) return []; if (!visible) return [];
const iconLayer = new IconLayer<MEFacility>({ const iconLayer = new IconLayer<MEFacility>({
@ -129,7 +131,7 @@ export function createMEFacilityLayers(config: MEFacilityLayerConfig): Layer[] {
data: ME_FACILITIES, data: ME_FACILITIES,
getPosition: (d) => [d.lng, d.lat], getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo,
getSize: 12 * sc, getSize: 12 * sc * fs,
updateTriggers: { getSize: [sc] }, updateTriggers: { getSize: [sc] },
getColor: (d) => hexToRgba(ME_FACILITY_TYPE_META[d.type].color, 200), getColor: (d) => hexToRgba(ME_FACILITY_TYPE_META[d.type].color, 200),
getTextAnchor: 'middle', getTextAnchor: 'middle',

파일 보기

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre'; import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre';
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers'; import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import { useFontScale } from '../../hooks/useFontScale';
import { DeckGLOverlay } from '../layers/DeckGLOverlay'; import { DeckGLOverlay } from '../layers/DeckGLOverlay';
import { useAnalysisDeckLayers } from '../../hooks/useAnalysisDeckLayers'; import { useAnalysisDeckLayers } from '../../hooks/useAnalysisDeckLayers';
import { useStaticDeckLayers } from '../../hooks/useStaticDeckLayers'; import { useStaticDeckLayers } from '../../hooks/useStaticDeckLayers';
@ -149,6 +150,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
const [trackCoords, setTrackCoords] = useState<[number, number][] | null>(null); const [trackCoords, setTrackCoords] = useState<[number, number][] | null>(null);
const [selectedGearData, setSelectedGearData] = useState<SelectedGearGroupData | null>(null); const [selectedGearData, setSelectedGearData] = useState<SelectedGearGroupData | null>(null);
const [selectedFleetData, setSelectedFleetData] = useState<SelectedFleetData | null>(null); const [selectedFleetData, setSelectedFleetData] = useState<SelectedFleetData | null>(null);
const { fontScale } = useFontScale();
const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM); const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM);
const zoomRef = useRef(KOREA_MAP_ZOOM); const zoomRef = useRef(KOREA_MAP_ZOOM);
const handleZoom = useCallback((e: { viewState: { zoom: number } }) => { const handleZoom = useCallback((e: { viewState: { zoom: number } }) => {
@ -242,7 +244,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
data: illegalFishingData, data: illegalFishingData,
getPosition: (d) => [d.lng, d.lat], getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.name || d.mmsi, getText: (d) => d.name || d.mmsi,
getSize: 11 * zoomScale, getSize: 11 * zoomScale * fontScale.analysis,
getColor: [239, 68, 68, 255], getColor: [239, 68, 68, 255],
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
@ -253,8 +255,8 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
outlineColor: [0, 0, 0, 255], outlineColor: [0, 0, 0, 255],
billboard: false, billboard: false,
characterSet: 'auto', characterSet: 'auto',
updateTriggers: { getSize: [zoomScale] }, updateTriggers: { getSize: [zoomScale, fontScale.analysis] },
}), [illegalFishingData, zoomScale]); }), [illegalFishingData, zoomScale, fontScale.analysis]);
// 수역 라벨 TextLayer — illegalFishing 또는 cnFishing 필터 활성 시 표시 // 수역 라벨 TextLayer — illegalFishing 또는 cnFishing 필터 활성 시 표시
const zoneLabelsLayer = useMemo(() => { const zoneLabelsLayer = useMemo(() => {
@ -281,7 +283,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
data, data,
getPosition: (d: { lng: number; lat: number }) => [d.lng, d.lat], getPosition: (d: { lng: number; lat: number }) => [d.lng, d.lat],
getText: (d: { name: string }) => d.name, getText: (d: { name: string }) => d.name,
getSize: 14 * zoomScale, getSize: 14 * zoomScale * fontScale.area,
getColor: [255, 255, 255, 220], getColor: [255, 255, 255, 220],
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'center', getAlignmentBaseline: 'center',
@ -292,9 +294,9 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
outlineColor: [0, 0, 0, 255], outlineColor: [0, 0, 0, 255],
billboard: false, billboard: false,
characterSet: 'auto', characterSet: 'auto',
updateTriggers: { getSize: [zoomScale] }, updateTriggers: { getSize: [zoomScale, fontScale.area] },
}); });
}, [koreaFilters.illegalFishing, koreaFilters.cnFishing, zoomScale]); }, [koreaFilters.illegalFishing, koreaFilters.cnFishing, zoomScale, fontScale.area]);
// 정적 레이어 (deck.gl) — 항구, 공항, 군사기지, 어항경고 등 // 정적 레이어 (deck.gl) — 항구, 공항, 군사기지, 어항경고 등
const staticDeckLayers = useStaticDeckLayers({ const staticDeckLayers = useStaticDeckLayers({
@ -357,7 +359,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
data: gears, data: gears,
getPosition: (d: Ship) => [d.lng, d.lat], getPosition: (d: Ship) => [d.lng, d.lat],
getText: (d: Ship) => d.name || d.mmsi, getText: (d: Ship) => d.name || d.mmsi,
getSize: 10 * zoomScale, getSize: 10 * zoomScale * fontScale.analysis,
getColor: [249, 115, 22, 255], getColor: [249, 115, 22, 255],
getTextAnchor: 'middle' as const, getTextAnchor: 'middle' as const,
getAlignmentBaseline: 'top' as const, getAlignmentBaseline: 'top' as const,
@ -392,7 +394,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
data: [parent], data: [parent],
getPosition: (d: Ship) => [d.lng, d.lat], getPosition: (d: Ship) => [d.lng, d.lat],
getText: (d: Ship) => `${d.name || groupName} (모선)`, getText: (d: Ship) => `${d.name || groupName} (모선)`,
getSize: 11 * zoomScale, getSize: 11 * zoomScale * fontScale.analysis,
getColor: [249, 115, 22, 255], getColor: [249, 115, 22, 255],
getTextAnchor: 'middle' as const, getTextAnchor: 'middle' as const,
getAlignmentBaseline: 'top' as const, getAlignmentBaseline: 'top' as const,
@ -409,7 +411,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
} }
return layers; return layers;
}, [selectedGearData, zoomScale]); }, [selectedGearData, zoomScale, fontScale.analysis]);
// 선택된 선단 소속 선박 강조 레이어 (deck.gl) // 선택된 선단 소속 선박 강조 레이어 (deck.gl)
const selectedFleetLayers = useMemo(() => { const selectedFleetLayers = useMemo(() => {
@ -457,7 +459,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
const prefix = role === 'LEADER' ? '★ ' : ''; const prefix = role === 'LEADER' ? '★ ' : '';
return `${prefix}${d.name || d.mmsi}`; return `${prefix}${d.name || d.mmsi}`;
}, },
getSize: 10 * zoomScale, getSize: 10 * zoomScale * fontScale.analysis,
getColor: color, getColor: color,
getTextAnchor: 'middle' as const, getTextAnchor: 'middle' as const,
getAlignmentBaseline: 'top' as const, getAlignmentBaseline: 'top' as const,
@ -495,7 +497,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
} }
return result; return result;
}, [selectedFleetData, zoomScale, vesselAnalysis]); }, [selectedFleetData, zoomScale, vesselAnalysis, fontScale.analysis]);
// 분석 결과 deck.gl 레이어 // 분석 결과 deck.gl 레이어
const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing' const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing'
@ -527,7 +529,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
filter={['==', ['get', 'rank'], 1]} filter={['==', ['get', 'rank'], 1]}
layout={{ layout={{
'text-field': ['get', 'name'], 'text-field': ['get', 'name'],
'text-size': 15, 'text-size': 15 * fontScale.area,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false, 'text-allow-overlap': false,
'text-ignore-placement': false, 'text-ignore-placement': false,
@ -546,7 +548,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
filter={['==', ['get', 'rank'], 2]} filter={['==', ['get', 'rank'], 2]}
layout={{ layout={{
'text-field': ['get', 'name'], 'text-field': ['get', 'name'],
'text-size': 12, 'text-size': 12 * fontScale.area,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false, 'text-allow-overlap': false,
'text-ignore-placement': false, 'text-ignore-placement': false,
@ -566,7 +568,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
minzoom={5} minzoom={5}
layout={{ layout={{
'text-field': ['get', 'name'], 'text-field': ['get', 'name'],
'text-size': 10, 'text-size': 10 * fontScale.area,
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'], 'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
'text-allow-overlap': false, 'text-allow-overlap': false,
'text-ignore-placement': false, 'text-ignore-placement': false,

파일 보기

@ -6,6 +6,7 @@ import maplibregl from 'maplibre-gl';
import { MT_TYPE_COLORS, MT_TYPE_HEX, getMTType, NAVY_COLORS, FLAG_EMOJI, SIZE_MAP, isMilitary, getShipColor } from '../../utils/shipClassification'; import { MT_TYPE_COLORS, MT_TYPE_HEX, getMTType, NAVY_COLORS, FLAG_EMOJI, SIZE_MAP, isMilitary, getShipColor } from '../../utils/shipClassification';
import { getMarineTrafficCategory } from '../../utils/marineTraffic'; import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { getNationalityGroup } from '../../hooks/useKoreaData'; import { getNationalityGroup } from '../../hooks/useKoreaData';
import { useFontScale } from '../../hooks/useFontScale';
interface Props { interface Props {
ships: Ship[]; ships: Ship[];
@ -274,6 +275,8 @@ function ensureTriangleImage(map: maplibregl.Map) {
// ── Main layer (WebGL symbol rendering — triangles) ── // ── Main layer (WebGL symbol rendering — triangles) ──
export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusMmsi, onFocusClear, analysisMap, hiddenShipCategories, hiddenNationalities }: Props) { export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusMmsi, onFocusClear, analysisMap, hiddenShipCategories, hiddenNationalities }: Props) {
const { current: map } = useMap(); const { current: map } = useMap();
const { fontScale } = useFontScale();
const sfs = fontScale.ship;
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null); const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
const [imageReady, setImageReady] = useState(false); const [imageReady, setImageReady] = useState(false);
const highlightKorean = !!koreanOnly; const highlightKorean = !!koreanOnly;
@ -479,13 +482,14 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
/> />
{/* Korean ship label — always mounted, visibility으로 제어 */} {/* Korean ship label — always mounted, visibility으로 제어 */}
<Layer <Layer
id="ships-korean-label" key={`ships-korean-label-${sfs}`}
id={`ships-korean-label-${sfs}`}
type="symbol" type="symbol"
filter={['==', ['get', 'isKorean'], 1]} filter={['==', ['get', 'isKorean'], 1]}
layout={{ layout={{
'visibility': highlightKorean ? 'visible' : 'none', 'visibility': highlightKorean ? 'visible' : 'none',
'text-field': ['get', 'name'], 'text-field': ['get', 'name'],
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 6, 6, 7, 8, 9, 10, 11, 12, 13, 13, 15, 14, 17], 'text-size': ['interpolate', ['linear'], ['zoom'], 4, 6*sfs, 6, 7*sfs, 8, 9*sfs, 10, 11*sfs, 12, 13*sfs, 13, 15*sfs, 14, 17*sfs],
'text-offset': [0, 2.2], 'text-offset': [0, 2.2],
'text-anchor': 'top', 'text-anchor': 'top',
'text-allow-overlap': false, 'text-allow-overlap': false,

파일 보기

@ -0,0 +1,10 @@
import type { ReactNode } from 'react';
import { useLocalStorage } from '../hooks/useLocalStorage';
import { FontScaleCtx, DEFAULT_FONT_SCALE } from './fontScaleState';
export type { FontScaleConfig } from './fontScaleState';
export function FontScaleProvider({ children }: { children: ReactNode }) {
const [fontScale, setFontScale] = useLocalStorage('mapFontScale', DEFAULT_FONT_SCALE);
return <FontScaleCtx.Provider value={{ fontScale, setFontScale }}>{children}</FontScaleCtx.Provider>;
}

파일 보기

@ -0,0 +1,14 @@
import { createContext } from 'react';
export interface FontScaleConfig {
facility: number;
ship: number;
analysis: number;
area: number;
}
export const DEFAULT_FONT_SCALE: FontScaleConfig = { facility: 1.0, ship: 1.0, analysis: 1.0, area: 1.0 };
export const FontScaleCtx = createContext<{ fontScale: FontScaleConfig; setFontScale: (c: FontScaleConfig) => void }>({
fontScale: DEFAULT_FONT_SCALE, setFontScale: () => {},
});

파일 보기

@ -353,8 +353,8 @@ export function createFacilityLayers(
data: plants, data: plants,
getPosition: (d) => [d.lng, d.lat], getPosition: (d) => [d.lng, d.lat],
getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name), getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name),
getSize: 12 * sc, getSize: 12 * sc * fc.fs,
updateTriggers: { getSize: [sc] }, updateTriggers: { getSize: [sc, fc.fs] },
getColor: (d) => [...hexToRgb(infraColor(d)), 255] as [number, number, number, number], getColor: (d) => [...hexToRgb(infraColor(d)), 255] as [number, number, number, number],
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
@ -409,8 +409,8 @@ export function createFacilityLayers(
data: hazardData, data: hazardData,
getPosition: (d) => [d.lng, d.lat], getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo,
getSize: 12 * sc, getSize: 12 * sc * fc.fs,
updateTriggers: { getSize: [sc] }, updateTriggers: { getSize: [sc, fc.fs] },
getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200], getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200],
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
@ -463,8 +463,8 @@ export function createFacilityLayers(
data: cnData, data: cnData,
getPosition: (d) => [d.lng, d.lat], getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.name, getText: (d) => d.name,
getSize: 12 * sc, getSize: 12 * sc * fc.fs,
updateTriggers: { getSize: [sc] }, updateTriggers: { getSize: [sc, fc.fs] },
getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200], getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200],
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
@ -516,8 +516,8 @@ export function createFacilityLayers(
data: jpData, data: jpData,
getPosition: (d) => [d.lng, d.lat], getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.name, getText: (d) => d.name,
getSize: 12 * sc, getSize: 12 * sc * fc.fs,
updateTriggers: { getSize: [sc] }, updateTriggers: { getSize: [sc, fc.fs] },
getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200], getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200],
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',

파일 보기

@ -312,8 +312,8 @@ export function createMilitaryLayers(
data: MILITARY_BASES, data: MILITARY_BASES,
getPosition: (d) => [d.lng, d.lat], getPosition: (d) => [d.lng, d.lat],
getText: (d) => (d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo), getText: (d) => (d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo),
getSize: 11 * sc, getSize: 11 * sc * fc.fs,
updateTriggers: { getSize: [sc] }, updateTriggers: { getSize: [sc, fc.fs] },
getColor: (d) => [...hexToRgb(MIL_BASE_TYPE_COLOR[d.type] ?? '#a78bfa'), 255] as [number, number, number, number], getColor: (d) => [...hexToRgb(MIL_BASE_TYPE_COLOR[d.type] ?? '#a78bfa'), 255] as [number, number, number, number],
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
@ -350,8 +350,8 @@ export function createMilitaryLayers(
data: GOV_BUILDINGS, data: GOV_BUILDINGS,
getPosition: (d) => [d.lng, d.lat], getPosition: (d) => [d.lng, d.lat],
getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo), getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo),
getSize: 11 * sc, getSize: 11 * sc * fc.fs,
updateTriggers: { getSize: [sc] }, updateTriggers: { getSize: [sc, fc.fs] },
getColor: (d) => [...hexToRgb(GOV_TYPE_COLOR[d.type] ?? '#f59e0b'), 255] as [number, number, number, number], getColor: (d) => [...hexToRgb(GOV_TYPE_COLOR[d.type] ?? '#f59e0b'), 255] as [number, number, number, number],
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
@ -388,8 +388,8 @@ export function createMilitaryLayers(
data: NK_LAUNCH_SITES, data: NK_LAUNCH_SITES,
getPosition: (d) => [d.lng, d.lat], getPosition: (d) => [d.lng, d.lat],
getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo), getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo),
getSize: 11 * sc, getSize: 11 * sc * fc.fs,
updateTriggers: { getSize: [sc] }, updateTriggers: { getSize: [sc, fc.fs] },
getColor: (d) => [...hexToRgb(NK_LAUNCH_TYPE_META[d.type]?.color ?? '#f97316'), 255] as [number, number, number, number], getColor: (d) => [...hexToRgb(NK_LAUNCH_TYPE_META[d.type]?.color ?? '#f97316'), 255] as [number, number, number, number],
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
@ -480,8 +480,8 @@ export function createMilitaryLayers(
data: impactData, data: impactData,
getPosition: (d) => [d.lng, d.lat], getPosition: (d) => [d.lng, d.lat],
getText: (d) => `${d.ev.date.slice(5)} ${d.ev.time}${d.ev.launchNameKo}`, getText: (d) => `${d.ev.date.slice(5)} ${d.ev.time}${d.ev.launchNameKo}`,
getSize: 11 * sc, getSize: 11 * sc * fc.fs,
updateTriggers: { getSize: [sc] }, updateTriggers: { getSize: [sc, fc.fs] },
getColor: (d) => [...hexToRgb(getMissileColor(d.ev.type)), 255] as [number, number, number, number], getColor: (d) => [...hexToRgb(getMissileColor(d.ev.type)), 255] as [number, number, number, number],
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',

파일 보기

@ -174,8 +174,8 @@ export function createNavigationLayers(
if (d.type === 'navy') return d.name.replace('해군', '').replace('사령부', '사').replace('전대', '전').replace('전단', '전').trim().slice(0, 8); if (d.type === 'navy') return d.name.replace('해군', '').replace('사령부', '사').replace('전대', '전').replace('전단', '전').trim().slice(0, 8);
return d.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'; return d.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청';
}, },
getSize: 12 * sc, getSize: 12 * sc * fc.fs,
updateTriggers: { getSize: [sc] }, updateTriggers: { getSize: [sc, fc.fs] },
getColor: (d) => [...hexToRgb(CG_TYPE_COLOR[d.type]), 255] as [number, number, number, number], getColor: (d) => [...hexToRgb(CG_TYPE_COLOR[d.type]), 255] as [number, number, number, number],
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
@ -225,8 +225,8 @@ export function createNavigationLayers(
data: KOREAN_AIRPORTS, data: KOREAN_AIRPORTS,
getPosition: (d) => [d.lng, d.lat], getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.nameKo.replace('국제공항', '').replace('공항', ''), getText: (d) => d.nameKo.replace('국제공항', '').replace('공항', ''),
getSize: 12 * sc, getSize: 12 * sc * fc.fs,
updateTriggers: { getSize: [sc] }, updateTriggers: { getSize: [sc, fc.fs] },
getColor: (d) => [...hexToRgb(apColor(d)), 255] as [number, number, number, number], getColor: (d) => [...hexToRgb(apColor(d)), 255] as [number, number, number, number],
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
@ -275,8 +275,8 @@ export function createNavigationLayers(
data: NAV_WARNINGS, data: NAV_WARNINGS,
getPosition: (d) => [d.lng, d.lat], getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.id, getText: (d) => d.id,
getSize: 12 * sc, getSize: 12 * sc * fc.fs,
updateTriggers: { getSize: [sc] }, updateTriggers: { getSize: [sc, fc.fs] },
getColor: (d) => [...hexToRgb(NW_ORG_COLOR[d.org]), 255] as [number, number, number, number], getColor: (d) => [...hexToRgb(NW_ORG_COLOR[d.org]), 255] as [number, number, number, number],
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
@ -326,8 +326,8 @@ export function createNavigationLayers(
data: PIRACY_ZONES, data: PIRACY_ZONES,
getPosition: (d) => [d.lng, d.lat], getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.nameKo, getText: (d) => d.nameKo,
getSize: 12 * sc, getSize: 12 * sc * fc.fs,
updateTriggers: { getSize: [sc] }, updateTriggers: { getSize: [sc, fc.fs] },
getColor: (d) => [...hexToRgb(PIRACY_LEVEL_COLOR[d.level] ?? '#ef4444'), 255] as [number, number, number, number], getColor: (d) => [...hexToRgb(PIRACY_LEVEL_COLOR[d.level] ?? '#ef4444'), 255] as [number, number, number, number],
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',

파일 보기

@ -95,8 +95,8 @@ export function createPortLayers(
data: EAST_ASIA_PORTS, data: EAST_ASIA_PORTS,
getPosition: (d) => [d.lng, d.lat], getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.nameKo.replace('항', ''), getText: (d) => d.nameKo.replace('항', ''),
getSize: 12 * sc, getSize: 12 * sc * fc.fs,
updateTriggers: { getSize: [sc] }, updateTriggers: { getSize: [sc, fc.fs] },
getColor: (d) => [...hexToRgb(PORT_COUNTRY_COLOR[d.country] ?? PORT_COUNTRY_COLOR.KR), 255] as [number, number, number, number], getColor: (d) => [...hexToRgb(PORT_COUNTRY_COLOR[d.country] ?? PORT_COUNTRY_COLOR.KR), 255] as [number, number, number, number],
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
@ -133,8 +133,8 @@ export function createPortLayers(
data: KOREA_WIND_FARMS, data: KOREA_WIND_FARMS,
getPosition: (d) => [d.lng, d.lat], getPosition: (d) => [d.lng, d.lat],
getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name), getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name),
getSize: 12 * sc, getSize: 12 * sc * fc.fs,
updateTriggers: { getSize: [sc] }, updateTriggers: { getSize: [sc, fc.fs] },
getColor: [...hexToRgb(WIND_COLOR), 255] as [number, number, number, number], getColor: [...hexToRgb(WIND_COLOR), 255] as [number, number, number, number],
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',

파일 보기

@ -32,7 +32,8 @@ export interface StaticPickInfo {
} }
export interface LayerFactoryConfig { export interface LayerFactoryConfig {
sc: number; // sizeScale sc: number; // sizeScale (zoom-based)
fs: number; // fontScale (user preference, default 1.0)
onPick: (info: StaticPickInfo) => void; onPick: (info: StaticPickInfo) => void;
} }

파일 보기

@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers'; import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import type { Layer } from '@deck.gl/core'; import type { Layer } from '@deck.gl/core';
import type { Ship, VesselAnalysisDto } from '../types'; import type { Ship, VesselAnalysisDto } from '../types';
import { useFontScale } from './useFontScale';
interface AnalyzedShip { interface AnalyzedShip {
ship: Ship; ship: Ship;
@ -57,6 +58,8 @@ export function useAnalysisDeckLayers(
activeFilter: string | null, activeFilter: string | null,
sizeScale: number = 1.0, sizeScale: number = 1.0,
): Layer[] { ): Layer[] {
const { fontScale } = useFontScale();
const afs = fontScale.analysis;
// 데이터 준비: ships 필터/정렬/슬라이스 — sizeScale 변경 시 재실행 안 됨 // 데이터 준비: ships 필터/정렬/슬라이스 — sizeScale 변경 시 재실행 안 됨
const { riskData, darkData, spoofData } = useMemo<AnalysisData>(() => { const { riskData, darkData, spoofData } = useMemo<AnalysisData>(() => {
if (analysisMap.size === 0) { if (analysisMap.size === 0) {
@ -123,7 +126,7 @@ export function useAnalysisDeckLayers(
const name = d.ship.name || d.ship.mmsi; const name = d.ship.name || d.ship.mmsi;
return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`; return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`;
}, },
getSize: 10 * sizeScale, getSize: 10 * sizeScale * afs,
updateTriggers: { getSize: [sizeScale] }, updateTriggers: { getSize: [sizeScale] },
getColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [200, 200, 200, 255], getColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [200, 200, 200, 255],
getTextAnchor: 'middle', getTextAnchor: 'middle',
@ -167,7 +170,7 @@ export function useAnalysisDeckLayers(
const gap = d.dto.algorithms.darkVessel.gapDurationMin; const gap = d.dto.algorithms.darkVessel.gapDurationMin;
return gap > 0 ? `AIS 소실 ${Math.round(gap)}` : 'DARK'; return gap > 0 ? `AIS 소실 ${Math.round(gap)}` : 'DARK';
}, },
getSize: 10 * sizeScale, getSize: 10 * sizeScale * afs,
updateTriggers: { getSize: [sizeScale] }, updateTriggers: { getSize: [sizeScale] },
getColor: [168, 85, 247, 255], getColor: [168, 85, 247, 255],
getTextAnchor: 'middle', getTextAnchor: 'middle',
@ -191,7 +194,7 @@ export function useAnalysisDeckLayers(
data: spoofData, data: spoofData,
getPosition: (d) => [d.ship.lng, d.ship.lat], getPosition: (d) => [d.ship.lng, d.ship.lat],
getText: (d) => `GPS ${Math.round(d.dto.algorithms.gpsSpoofing.spoofingScore * 100)}%`, getText: (d) => `GPS ${Math.round(d.dto.algorithms.gpsSpoofing.spoofingScore * 100)}%`,
getSize: 10 * sizeScale, getSize: 10 * sizeScale * afs,
getColor: [239, 68, 68, 255], getColor: [239, 68, 68, 255],
getTextAnchor: 'start', getTextAnchor: 'start',
getPixelOffset: [12, -8], getPixelOffset: [12, -8],
@ -207,5 +210,5 @@ export function useAnalysisDeckLayers(
} }
return layers; return layers;
}, [riskData, darkData, spoofData, sizeScale, activeFilter]); }, [riskData, darkData, spoofData, sizeScale, activeFilter, afs]);
} }

파일 보기

@ -0,0 +1,4 @@
import { useContext } from 'react';
import { FontScaleCtx } from '../contexts/fontScaleState';
export function useFontScale() { return useContext(FontScaleCtx); }

파일 보기

@ -2,6 +2,7 @@ import { useMemo } from 'react';
import type { Layer } from '@deck.gl/core'; import type { Layer } from '@deck.gl/core';
import type { PowerFacility } from '../services/infra'; import type { PowerFacility } from '../services/infra';
import type { HazardType } from '../data/hazardFacilities'; import type { HazardType } from '../data/hazardFacilities';
import { useFontScale } from './useFontScale';
// Re-export types for consumers // Re-export types for consumers
export type { StaticPickedObject, StaticLayerKind, StaticPickInfo } from './layers/types'; export type { StaticPickedObject, StaticLayerKind, StaticPickInfo } from './layers/types';
@ -34,8 +35,9 @@ interface StaticLayerConfig {
} }
export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] {
const { fontScale } = useFontScale();
return useMemo(() => { return useMemo(() => {
const fc = { sc: config.sizeScale ?? 1.0, onPick: config.onPick }; const fc = { sc: config.sizeScale ?? 1.0, fs: fontScale.facility, onPick: config.onPick };
return [ return [
...createPortLayers({ ports: config.ports, windFarm: config.windFarm }, fc), ...createPortLayers({ ports: config.ports, windFarm: config.windFarm }, fc),
@ -81,5 +83,6 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] {
config.jpMilitary, config.jpMilitary,
config.onPick, config.onPick,
config.sizeScale, config.sizeScale,
fontScale.facility,
]); ]);
} }