From 44aa449b0350b4818d883e809fae788bba77b621 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 24 Mar 2026 09:27:11 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A7=80=EB=8F=84=20=EA=B8=80=EA=BC=B4?= =?UTF-8?q?=20=ED=81=AC=EA=B8=B0=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20(4=EA=B0=9C=20=EA=B7=B8=EB=A3=B9=20?= =?UTF-8?q?=EC=8A=AC=EB=9D=BC=EC=9D=B4=EB=8D=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FontScaleContext + FontScalePanel: 시설/선박/분석/지역 4그룹 × 0.5~2.0 범위 - LAYERS 패널 하단 슬라이더 UI, localStorage 영속화 - Korea static 14개 + Iran 4개 + 분석 3개 + KoreaMap 5개 TextLayer 적용 - MapLibre 선박 라벨/국가명 실시간 반영 - 모든 useMemo deps + updateTriggers에 fontScale 포함 --- docs/RELEASE-NOTES.md | 14 ++++++ frontend/src/App.css | 39 ++++++++++++++++ frontend/src/App.tsx | 3 ++ .../src/components/common/FontScalePanel.tsx | 45 +++++++++++++++++++ frontend/src/components/common/LayerPanel.tsx | 2 + .../components/iran/MEEnergyHazardLayer.tsx | 4 +- frontend/src/components/iran/ReplayMap.tsx | 18 ++++---- frontend/src/components/iran/SatelliteMap.tsx | 18 ++++---- .../iran/createIranAirportLayers.ts | 4 +- .../components/iran/createIranOilLayers.ts | 4 +- .../components/iran/createMEFacilityLayers.ts | 4 +- frontend/src/components/korea/KoreaMap.tsx | 30 +++++++------ frontend/src/components/layers/ShipLayer.tsx | 8 +++- frontend/src/contexts/FontScaleContext.tsx | 10 +++++ frontend/src/contexts/fontScaleState.ts | 14 ++++++ .../src/hooks/layers/createFacilityLayers.ts | 16 +++---- .../src/hooks/layers/createMilitaryLayers.ts | 16 +++---- .../hooks/layers/createNavigationLayers.ts | 16 +++---- frontend/src/hooks/layers/createPortLayers.ts | 8 ++-- frontend/src/hooks/layers/types.ts | 3 +- frontend/src/hooks/useAnalysisDeckLayers.ts | 11 +++-- frontend/src/hooks/useFontScale.ts | 4 ++ frontend/src/hooks/useStaticDeckLayers.ts | 5 ++- 23 files changed, 226 insertions(+), 70 deletions(-) create mode 100644 frontend/src/components/common/FontScalePanel.tsx create mode 100644 frontend/src/contexts/FontScaleContext.tsx create mode 100644 frontend/src/contexts/fontScaleState.ts create mode 100644 frontend/src/hooks/useFontScale.ts diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 639afdd..c72ce2e 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,20 @@ ## [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] ### 수정 diff --git a/frontend/src/App.css b/frontend/src/App.css index 8ec542e..259b72c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2462,3 +2462,42 @@ text-align: center; 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; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e53296..bddd0be 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'; import LoginPage from './components/auth/LoginPage'; import CollectorMonitor from './components/common/CollectorMonitor'; import { SharedFilterProvider } from './contexts/SharedFilterContext'; +import { FontScaleProvider } from './contexts/FontScaleContext'; import { IranDashboard } from './components/iran/IranDashboard'; import { KoreaDashboard } from './components/korea/KoreaDashboard'; import './App.css'; @@ -65,6 +66,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { const currentTime = isLive ? monitor.state.currentTime : replay.state.currentTime; return ( +
@@ -158,6 +160,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { )}
+
); } diff --git a/frontend/src/components/common/FontScalePanel.tsx b/frontend/src/components/common/FontScalePanel.tsx new file mode 100644 index 0000000..c027f0c --- /dev/null +++ b/frontend/src/components/common/FontScalePanel.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react'; +import { useFontScale } from '../../hooks/useFontScale'; +import type { FontScaleConfig } from '../../contexts/fontScaleState'; + +const LABELS: Record = { + 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 ( +
+ + {open && ( +
+ {(Object.keys(LABELS) as (keyof FontScaleConfig)[]).map(key => ( +
+ + update(key, parseFloat(e.target.value))} /> + {fontScale[key].toFixed(1)} +
+ ))} + +
+ )} +
+ ); +} diff --git a/frontend/src/components/common/LayerPanel.tsx b/frontend/src/components/common/LayerPanel.tsx index f538203..d9e9f2c 100644 --- a/frontend/src/components/common/LayerPanel.tsx +++ b/frontend/src/components/common/LayerPanel.tsx @@ -1,6 +1,7 @@ import { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useLocalStorageSet } from '../../hooks/useLocalStorage'; +import { FontScalePanel } from './FontScalePanel'; // Aircraft category colors (matches AircraftLayer military fixed colors) const AC_CAT_COLORS: Record = { @@ -895,6 +896,7 @@ export function LayerPanel({ )} + ); } diff --git a/frontend/src/components/iran/MEEnergyHazardLayer.tsx b/frontend/src/components/iran/MEEnergyHazardLayer.tsx index 8e8d542..21bfd2b 100644 --- a/frontend/src/components/iran/MEEnergyHazardLayer.tsx +++ b/frontend/src/components/iran/MEEnergyHazardLayer.tsx @@ -54,6 +54,7 @@ export { layerKeyToSubType, layerKeyToCountry }; export interface MELayerConfig { layers: Record; sc: number; + fs?: number; onPick: (facility: EnergyHazardFacility) => void; } @@ -174,6 +175,7 @@ function getIconUrl(subType: FacilitySubType): string { export function createMEEnergyHazardLayers(config: MELayerConfig): Layer[] { const { layers, sc, onPick } = config; + const fs = config.fs ?? 1; const visibleFacilities = ME_ENERGY_HAZARD_FACILITIES.filter(f => isFacilityVisible(f, layers), @@ -200,7 +202,7 @@ export function createMEEnergyHazardLayers(config: MELayerConfig): Layer[] { data: visibleFacilities, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, - getSize: 12 * sc, + getSize: 12 * sc * fs, updateTriggers: { getSize: [sc] }, getColor: (d) => hexToRgba(SUB_TYPE_META[d.subType].color, 200), getTextAnchor: 'middle', diff --git a/frontend/src/components/iran/ReplayMap.tsx b/frontend/src/components/iran/ReplayMap.tsx index 16fbaaa..76e350d 100644 --- a/frontend/src/components/iran/ReplayMap.tsx +++ b/frontend/src/components/iran/ReplayMap.tsx @@ -8,6 +8,7 @@ import { ShipLayer } from '../layers/ShipLayer'; import { DamagedShipLayer } from '../layers/DamagedShipLayer'; import { SeismicMarker } from '../layers/SeismicMarker'; import { DeckGLOverlay } from '../layers/DeckGLOverlay'; +import { useFontScale } from '../../hooks/useFontScale'; import { createMEEnergyHazardLayers } from './MEEnergyHazardLayer'; import type { EnergyHazardFacility } 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(null); const [mePickedFacility, setMePickedFacility] = useState(null); const [iranPickedFacility, setIranPickedFacility] = useState(null); + const { fontScale } = useFontScale(); const [zoomLevel, setZoomLevel] = useState(5); const zoomRef = useRef(5); @@ -154,11 +156,11 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la }, [zoomLevel]); const iranDeckLayers = useMemo(() => [ - ...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }), - ...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }), - ...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }), - ...createMEEnergyHazardLayers({ layers: layers as Record, sc: zoomScale, onPick: setMePickedFacility }), - ], [layers, zoomScale]); + ...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }), + ...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }), + ...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }), + ...createMEEnergyHazardLayers({ layers: layers as Record, sc: zoomScale, fs: fontScale.facility, onPick: setMePickedFacility }), + ], [layers, zoomScale, fontScale.facility]); useEffect(() => { if (flyToTarget && mapRef.current) { @@ -242,7 +244,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la filter={['==', ['get', 'rank'], 1]} layout={{ 'text-field': ['get', 'name'], - 'text-size': 15, + 'text-size': 15 * fontScale.area, 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': false, 'text-ignore-placement': false, @@ -261,7 +263,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la filter={['==', ['get', 'rank'], 2]} layout={{ 'text-field': ['get', 'name'], - 'text-size': 12, + 'text-size': 12 * fontScale.area, 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': false, 'text-ignore-placement': false, @@ -281,7 +283,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la minzoom={5} layout={{ 'text-field': ['get', 'name'], - 'text-size': 10, + 'text-size': 10 * fontScale.area, 'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'], 'text-allow-overlap': false, 'text-ignore-placement': false, diff --git a/frontend/src/components/iran/SatelliteMap.tsx b/frontend/src/components/iran/SatelliteMap.tsx index e1d6512..49a0182 100644 --- a/frontend/src/components/iran/SatelliteMap.tsx +++ b/frontend/src/components/iran/SatelliteMap.tsx @@ -8,6 +8,7 @@ import { ShipLayer } from '../layers/ShipLayer'; import { DamagedShipLayer } from '../layers/DamagedShipLayer'; import { SeismicMarker } from '../layers/SeismicMarker'; import { DeckGLOverlay } from '../layers/DeckGLOverlay'; +import { useFontScale } from '../../hooks/useFontScale'; import { createMEEnergyHazardLayers } from './MEEnergyHazardLayer'; import type { EnergyHazardFacility } 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(null); const [mePickedFacility, setMePickedFacility] = useState(null); const [iranPickedFacility, setIranPickedFacility] = useState(null); + const { fontScale } = useFontScale(); const [zoomLevel, setZoomLevel] = useState(5); const zoomRef = useRef(5); @@ -137,11 +139,11 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, }, [zoomLevel]); const iranDeckLayers = useMemo(() => [ - ...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }), - ...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }), - ...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }), - ...createMEEnergyHazardLayers({ layers: layers as Record, sc: zoomScale, onPick: setMePickedFacility }), - ], [layers, zoomScale]); + ...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }), + ...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }), + ...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }), + ...createMEEnergyHazardLayers({ layers: layers as Record, sc: zoomScale, fs: fontScale.facility, onPick: setMePickedFacility }), + ], [layers, zoomScale, fontScale.facility]); useEffect(() => { if (flyToTarget && mapRef.current) { @@ -234,7 +236,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layout={{ 'text-field': ['get', 'name'], 'text-font': ['Open Sans Bold'], - 'text-size': 15, + 'text-size': 15 * fontScale.area, 'text-allow-overlap': false, 'text-ignore-placement': false, }} @@ -251,7 +253,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layout={{ 'text-field': ['get', 'name'], 'text-font': ['Open Sans Bold'], - 'text-size': 12, + 'text-size': 12 * fontScale.area, 'text-allow-overlap': false, }} paint={{ @@ -268,7 +270,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layout={{ 'text-field': ['get', 'name'], 'text-font': ['Open Sans Bold'], - 'text-size': 10, + 'text-size': 10 * fontScale.area, 'text-allow-overlap': false, }} paint={{ diff --git a/frontend/src/components/iran/createIranAirportLayers.ts b/frontend/src/components/iran/createIranAirportLayers.ts index e668201..bed298a 100644 --- a/frontend/src/components/iran/createIranAirportLayers.ts +++ b/frontend/src/components/iran/createIranAirportLayers.ts @@ -51,11 +51,13 @@ function hexToRgba(hex: string, alpha = 255): [number, number, number, number] { export interface IranAirportLayerConfig { visible: boolean; sc: number; + fs?: number; onPick: (airport: Airport) => void; } export function createIranAirportLayers(config: IranAirportLayerConfig): Layer[] { const { visible, sc, onPick } = config; + const fs = config.fs ?? 1; if (!visible) return []; const iconLayer = new IconLayer({ @@ -84,7 +86,7 @@ export function createIranAirportLayers(config: IranAirportLayerConfig): Layer[] const nameKo = d.nameKo ?? d.name; return nameKo.length > 10 ? nameKo.slice(0, 10) + '..' : nameKo; }, - getSize: 11 * sc, + getSize: 11 * sc * fs, updateTriggers: { getSize: [sc] }, getColor: (d) => hexToRgba(getAirportColor(d)), getTextAnchor: 'middle', diff --git a/frontend/src/components/iran/createIranOilLayers.ts b/frontend/src/components/iran/createIranOilLayers.ts index a42a137..6a62d7a 100644 --- a/frontend/src/components/iran/createIranOilLayers.ts +++ b/frontend/src/components/iran/createIranOilLayers.ts @@ -108,11 +108,13 @@ function hexToRgba(hex: string, alpha = 255): [number, number, number, number] { export interface IranOilLayerConfig { visible: boolean; sc: number; + fs?: number; onPick: (facility: OilFacility) => void; } export function createIranOilLayers(config: IranOilLayerConfig): Layer[] { const { visible, sc, onPick } = config; + const fs = config.fs ?? 1; if (!visible) return []; const iconLayer = new IconLayer({ @@ -134,7 +136,7 @@ export function createIranOilLayers(config: IranOilLayerConfig): Layer[] { data: iranOilFacilities, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo, - getSize: 12 * sc, + getSize: 12 * sc * fs, updateTriggers: { getSize: [sc] }, getColor: (d) => hexToRgba(TYPE_COLORS[d.type]), getTextAnchor: 'middle', diff --git a/frontend/src/components/iran/createMEFacilityLayers.ts b/frontend/src/components/iran/createMEFacilityLayers.ts index c7a01d9..ddd0d07 100644 --- a/frontend/src/components/iran/createMEFacilityLayers.ts +++ b/frontend/src/components/iran/createMEFacilityLayers.ts @@ -103,11 +103,13 @@ function getIconUrl(type: MEFacilityType): string { export interface MEFacilityLayerConfig { visible: boolean; sc: number; + fs?: number; onPick: (facility: MEFacility) => void; } export function createMEFacilityLayers(config: MEFacilityLayerConfig): Layer[] { const { visible, sc, onPick } = config; + const fs = config.fs ?? 1; if (!visible) return []; const iconLayer = new IconLayer({ @@ -129,7 +131,7 @@ export function createMEFacilityLayers(config: MEFacilityLayerConfig): Layer[] { data: ME_FACILITIES, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, - getSize: 12 * sc, + getSize: 12 * sc * fs, updateTriggers: { getSize: [sc] }, getColor: (d) => hexToRgba(ME_FACILITY_TYPE_META[d.type].color, 200), getTextAnchor: 'middle', diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 4ae9f0f..2a48307 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre'; import { ScatterplotLayer, TextLayer } from '@deck.gl/layers'; +import { useFontScale } from '../../hooks/useFontScale'; import { DeckGLOverlay } from '../layers/DeckGLOverlay'; import { useAnalysisDeckLayers } from '../../hooks/useAnalysisDeckLayers'; 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 [selectedGearData, setSelectedGearData] = useState(null); const [selectedFleetData, setSelectedFleetData] = useState(null); + const { fontScale } = useFontScale(); const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM); const zoomRef = useRef(KOREA_MAP_ZOOM); const handleZoom = useCallback((e: { viewState: { zoom: number } }) => { @@ -242,7 +244,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF data: illegalFishingData, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.name || d.mmsi, - getSize: 11 * zoomScale, + getSize: 11 * zoomScale * fontScale.analysis, getColor: [239, 68, 68, 255], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -253,8 +255,8 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', - updateTriggers: { getSize: [zoomScale] }, - }), [illegalFishingData, zoomScale]); + updateTriggers: { getSize: [zoomScale, fontScale.analysis] }, + }), [illegalFishingData, zoomScale, fontScale.analysis]); // 수역 라벨 TextLayer — illegalFishing 또는 cnFishing 필터 활성 시 표시 const zoneLabelsLayer = useMemo(() => { @@ -281,7 +283,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF data, getPosition: (d: { lng: number; lat: number }) => [d.lng, d.lat], getText: (d: { name: string }) => d.name, - getSize: 14 * zoomScale, + getSize: 14 * zoomScale * fontScale.area, getColor: [255, 255, 255, 220], getTextAnchor: 'middle', getAlignmentBaseline: 'center', @@ -292,9 +294,9 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', - updateTriggers: { getSize: [zoomScale] }, + updateTriggers: { getSize: [zoomScale, fontScale.area] }, }); - }, [koreaFilters.illegalFishing, koreaFilters.cnFishing, zoomScale]); + }, [koreaFilters.illegalFishing, koreaFilters.cnFishing, zoomScale, fontScale.area]); // 정적 레이어 (deck.gl) — 항구, 공항, 군사기지, 어항경고 등 const staticDeckLayers = useStaticDeckLayers({ @@ -357,7 +359,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF data: gears, getPosition: (d: Ship) => [d.lng, d.lat], getText: (d: Ship) => d.name || d.mmsi, - getSize: 10 * zoomScale, + getSize: 10 * zoomScale * fontScale.analysis, getColor: [249, 115, 22, 255], getTextAnchor: 'middle' as const, getAlignmentBaseline: 'top' as const, @@ -392,7 +394,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF data: [parent], getPosition: (d: Ship) => [d.lng, d.lat], getText: (d: Ship) => `▼ ${d.name || groupName} (모선)`, - getSize: 11 * zoomScale, + getSize: 11 * zoomScale * fontScale.analysis, getColor: [249, 115, 22, 255], getTextAnchor: 'middle' as const, getAlignmentBaseline: 'top' as const, @@ -409,7 +411,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF } return layers; - }, [selectedGearData, zoomScale]); + }, [selectedGearData, zoomScale, fontScale.analysis]); // 선택된 선단 소속 선박 강조 레이어 (deck.gl) const selectedFleetLayers = useMemo(() => { @@ -457,7 +459,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF const prefix = role === 'LEADER' ? '★ ' : ''; return `${prefix}${d.name || d.mmsi}`; }, - getSize: 10 * zoomScale, + getSize: 10 * zoomScale * fontScale.analysis, getColor: color, getTextAnchor: 'middle' as const, getAlignmentBaseline: 'top' as const, @@ -495,7 +497,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF } return result; - }, [selectedFleetData, zoomScale, vesselAnalysis]); + }, [selectedFleetData, zoomScale, vesselAnalysis, fontScale.analysis]); // 분석 결과 deck.gl 레이어 const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing' @@ -527,7 +529,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF filter={['==', ['get', 'rank'], 1]} layout={{ 'text-field': ['get', 'name'], - 'text-size': 15, + 'text-size': 15 * fontScale.area, 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': false, 'text-ignore-placement': false, @@ -546,7 +548,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF filter={['==', ['get', 'rank'], 2]} layout={{ 'text-field': ['get', 'name'], - 'text-size': 12, + 'text-size': 12 * fontScale.area, 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': false, 'text-ignore-placement': false, @@ -566,7 +568,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF minzoom={5} layout={{ 'text-field': ['get', 'name'], - 'text-size': 10, + 'text-size': 10 * fontScale.area, 'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'], 'text-allow-overlap': false, 'text-ignore-placement': false, diff --git a/frontend/src/components/layers/ShipLayer.tsx b/frontend/src/components/layers/ShipLayer.tsx index 230af22..49221d8 100644 --- a/frontend/src/components/layers/ShipLayer.tsx +++ b/frontend/src/components/layers/ShipLayer.tsx @@ -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 { getMarineTrafficCategory } from '../../utils/marineTraffic'; import { getNationalityGroup } from '../../hooks/useKoreaData'; +import { useFontScale } from '../../hooks/useFontScale'; interface Props { ships: Ship[]; @@ -274,6 +275,8 @@ function ensureTriangleImage(map: maplibregl.Map) { // ── Main layer (WebGL symbol rendering — triangles) ── export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusMmsi, onFocusClear, analysisMap, hiddenShipCategories, hiddenNationalities }: Props) { const { current: map } = useMap(); + const { fontScale } = useFontScale(); + const sfs = fontScale.ship; const [selectedMmsi, setSelectedMmsi] = useState(null); const [imageReady, setImageReady] = useState(false); const highlightKorean = !!koreanOnly; @@ -479,13 +482,14 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM /> {/* Korean ship label — always mounted, visibility으로 제어 */} {children}; +} diff --git a/frontend/src/contexts/fontScaleState.ts b/frontend/src/contexts/fontScaleState.ts new file mode 100644 index 0000000..025bb57 --- /dev/null +++ b/frontend/src/contexts/fontScaleState.ts @@ -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: () => {}, +}); diff --git a/frontend/src/hooks/layers/createFacilityLayers.ts b/frontend/src/hooks/layers/createFacilityLayers.ts index 9441216..5cb8c02 100644 --- a/frontend/src/hooks/layers/createFacilityLayers.ts +++ b/frontend/src/hooks/layers/createFacilityLayers.ts @@ -353,8 +353,8 @@ export function createFacilityLayers( data: plants, getPosition: (d) => [d.lng, d.lat], getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name), - getSize: 12 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 12 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(infraColor(d)), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -409,8 +409,8 @@ export function createFacilityLayers( data: hazardData, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, - getSize: 12 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 12 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -463,8 +463,8 @@ export function createFacilityLayers( data: cnData, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.name, - getSize: 12 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 12 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -516,8 +516,8 @@ export function createFacilityLayers( data: jpData, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.name, - getSize: 12 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 12 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200], getTextAnchor: 'middle', getAlignmentBaseline: 'top', diff --git a/frontend/src/hooks/layers/createMilitaryLayers.ts b/frontend/src/hooks/layers/createMilitaryLayers.ts index cc0160c..1202303 100644 --- a/frontend/src/hooks/layers/createMilitaryLayers.ts +++ b/frontend/src/hooks/layers/createMilitaryLayers.ts @@ -312,8 +312,8 @@ export function createMilitaryLayers( data: MILITARY_BASES, getPosition: (d) => [d.lng, d.lat], getText: (d) => (d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo), - getSize: 11 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 11 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(MIL_BASE_TYPE_COLOR[d.type] ?? '#a78bfa'), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -350,8 +350,8 @@ export function createMilitaryLayers( data: GOV_BUILDINGS, getPosition: (d) => [d.lng, d.lat], getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo), - getSize: 11 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 11 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(GOV_TYPE_COLOR[d.type] ?? '#f59e0b'), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -388,8 +388,8 @@ export function createMilitaryLayers( data: NK_LAUNCH_SITES, getPosition: (d) => [d.lng, d.lat], getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo), - getSize: 11 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 11 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(NK_LAUNCH_TYPE_META[d.type]?.color ?? '#f97316'), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -480,8 +480,8 @@ export function createMilitaryLayers( data: impactData, getPosition: (d) => [d.lng, d.lat], getText: (d) => `${d.ev.date.slice(5)} ${d.ev.time} ← ${d.ev.launchNameKo}`, - getSize: 11 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 11 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(getMissileColor(d.ev.type)), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', diff --git a/frontend/src/hooks/layers/createNavigationLayers.ts b/frontend/src/hooks/layers/createNavigationLayers.ts index cc8f3a2..dc146fa 100644 --- a/frontend/src/hooks/layers/createNavigationLayers.ts +++ b/frontend/src/hooks/layers/createNavigationLayers.ts @@ -174,8 +174,8 @@ export function createNavigationLayers( if (d.type === 'navy') return d.name.replace('해군', '').replace('사령부', '사').replace('전대', '전').replace('전단', '전').trim().slice(0, 8); return d.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'; }, - getSize: 12 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 12 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(CG_TYPE_COLOR[d.type]), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -225,8 +225,8 @@ export function createNavigationLayers( data: KOREAN_AIRPORTS, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo.replace('국제공항', '').replace('공항', ''), - getSize: 12 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 12 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(apColor(d)), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -275,8 +275,8 @@ export function createNavigationLayers( data: NAV_WARNINGS, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.id, - getSize: 12 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 12 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(NW_ORG_COLOR[d.org]), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -326,8 +326,8 @@ export function createNavigationLayers( data: PIRACY_ZONES, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo, - getSize: 12 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 12 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(PIRACY_LEVEL_COLOR[d.level] ?? '#ef4444'), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', diff --git a/frontend/src/hooks/layers/createPortLayers.ts b/frontend/src/hooks/layers/createPortLayers.ts index c9e27e4..dccdea4 100644 --- a/frontend/src/hooks/layers/createPortLayers.ts +++ b/frontend/src/hooks/layers/createPortLayers.ts @@ -95,8 +95,8 @@ export function createPortLayers( data: EAST_ASIA_PORTS, getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo.replace('항', ''), - getSize: 12 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 12 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: (d) => [...hexToRgb(PORT_COUNTRY_COLOR[d.country] ?? PORT_COUNTRY_COLOR.KR), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -133,8 +133,8 @@ export function createPortLayers( data: KOREA_WIND_FARMS, getPosition: (d) => [d.lng, d.lat], getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name), - getSize: 12 * sc, - updateTriggers: { getSize: [sc] }, + getSize: 12 * sc * fc.fs, + updateTriggers: { getSize: [sc, fc.fs] }, getColor: [...hexToRgb(WIND_COLOR), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', diff --git a/frontend/src/hooks/layers/types.ts b/frontend/src/hooks/layers/types.ts index 3f179ec..97ff285 100644 --- a/frontend/src/hooks/layers/types.ts +++ b/frontend/src/hooks/layers/types.ts @@ -32,7 +32,8 @@ export interface StaticPickInfo { } export interface LayerFactoryConfig { - sc: number; // sizeScale + sc: number; // sizeScale (zoom-based) + fs: number; // fontScale (user preference, default 1.0) onPick: (info: StaticPickInfo) => void; } diff --git a/frontend/src/hooks/useAnalysisDeckLayers.ts b/frontend/src/hooks/useAnalysisDeckLayers.ts index 13bfe8a..76a7bcd 100644 --- a/frontend/src/hooks/useAnalysisDeckLayers.ts +++ b/frontend/src/hooks/useAnalysisDeckLayers.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { ScatterplotLayer, TextLayer } from '@deck.gl/layers'; import type { Layer } from '@deck.gl/core'; import type { Ship, VesselAnalysisDto } from '../types'; +import { useFontScale } from './useFontScale'; interface AnalyzedShip { ship: Ship; @@ -57,6 +58,8 @@ export function useAnalysisDeckLayers( activeFilter: string | null, sizeScale: number = 1.0, ): Layer[] { + const { fontScale } = useFontScale(); + const afs = fontScale.analysis; // 데이터 준비: ships 필터/정렬/슬라이스 — sizeScale 변경 시 재실행 안 됨 const { riskData, darkData, spoofData } = useMemo(() => { if (analysisMap.size === 0) { @@ -123,7 +126,7 @@ export function useAnalysisDeckLayers( const name = d.ship.name || d.ship.mmsi; return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`; }, - getSize: 10 * sizeScale, + getSize: 10 * sizeScale * afs, updateTriggers: { getSize: [sizeScale] }, getColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [200, 200, 200, 255], getTextAnchor: 'middle', @@ -167,7 +170,7 @@ export function useAnalysisDeckLayers( const gap = d.dto.algorithms.darkVessel.gapDurationMin; return gap > 0 ? `AIS 소실 ${Math.round(gap)}분` : 'DARK'; }, - getSize: 10 * sizeScale, + getSize: 10 * sizeScale * afs, updateTriggers: { getSize: [sizeScale] }, getColor: [168, 85, 247, 255], getTextAnchor: 'middle', @@ -191,7 +194,7 @@ export function useAnalysisDeckLayers( data: spoofData, getPosition: (d) => [d.ship.lng, d.ship.lat], getText: (d) => `GPS ${Math.round(d.dto.algorithms.gpsSpoofing.spoofingScore * 100)}%`, - getSize: 10 * sizeScale, + getSize: 10 * sizeScale * afs, getColor: [239, 68, 68, 255], getTextAnchor: 'start', getPixelOffset: [12, -8], @@ -207,5 +210,5 @@ export function useAnalysisDeckLayers( } return layers; - }, [riskData, darkData, spoofData, sizeScale, activeFilter]); + }, [riskData, darkData, spoofData, sizeScale, activeFilter, afs]); } diff --git a/frontend/src/hooks/useFontScale.ts b/frontend/src/hooks/useFontScale.ts new file mode 100644 index 0000000..7c2247c --- /dev/null +++ b/frontend/src/hooks/useFontScale.ts @@ -0,0 +1,4 @@ +import { useContext } from 'react'; +import { FontScaleCtx } from '../contexts/fontScaleState'; + +export function useFontScale() { return useContext(FontScaleCtx); } diff --git a/frontend/src/hooks/useStaticDeckLayers.ts b/frontend/src/hooks/useStaticDeckLayers.ts index e18589c..822c9b4 100644 --- a/frontend/src/hooks/useStaticDeckLayers.ts +++ b/frontend/src/hooks/useStaticDeckLayers.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import type { Layer } from '@deck.gl/core'; import type { PowerFacility } from '../services/infra'; import type { HazardType } from '../data/hazardFacilities'; +import { useFontScale } from './useFontScale'; // Re-export types for consumers export type { StaticPickedObject, StaticLayerKind, StaticPickInfo } from './layers/types'; @@ -34,8 +35,9 @@ interface StaticLayerConfig { } export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { + const { fontScale } = useFontScale(); 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 [ ...createPortLayers({ ports: config.ports, windFarm: config.windFarm }, fc), @@ -81,5 +83,6 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] { config.jpMilitary, config.onPick, config.sizeScale, + fontScale.facility, ]); }