From 3f2052a46eb48edc6869ee053791000f84c5b174 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 24 Mar 2026 10:11:59 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=9B=B9=ED=8F=B0=ED=8A=B8=20?= =?UTF-8?q?=EB=82=B4=EC=9E=A5=20+=20=EC=9D=B4=EB=9E=80=20=EC=8B=9C?= =?UTF-8?q?=EC=84=A4=EB=AC=BC=20=EC=83=89=EC=83=81/=EA=B0=80=EB=8F=85?= =?UTF-8?q?=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @fontsource-variable Inter, Noto Sans KR, Fira Code 자체 호스팅 - 전체 font-family 통일 (CSS, deck.gl, 인라인 스타일) - 이란 시설물 색상 사막 대비 고채도 팔레트로 교체 - 이란 라벨 fontWeight 600→700, alpha 200→255 - 접힘 패널 상하 패딩 균일화 --- frontend/package-lock.json | 30 +++++ frontend/package.json | 3 + frontend/src/App.css | 123 ++++++++++-------- frontend/src/components/iran/AirportLayer.ts | 9 +- .../src/components/iran/IranDashboard.tsx | 28 ++-- .../components/iran/MEEnergyHazardLayer.tsx | 7 +- .../src/components/iran/MEFacilityLayer.ts | 15 ++- .../src/components/iran/OilFacilityLayer.ts | 15 ++- frontend/src/components/iran/ReplayMap.tsx | 10 +- frontend/src/components/iran/SatelliteMap.tsx | 10 +- .../iran/createIranAirportLayers.ts | 13 +- .../components/iran/createIranOilLayers.ts | 17 +-- .../components/iran/createMEFacilityLayers.ts | 7 +- .../components/korea/AnalysisStatsPanel.tsx | 5 +- .../components/korea/FieldAnalysisModal.tsx | 3 +- .../components/korea/FleetClusterLayer.tsx | 7 +- frontend/src/components/korea/KoreaMap.tsx | 11 +- .../components/korea/SubmarineCableLayer.tsx | 3 +- .../src/components/layers/SeismicMarker.css | 2 +- frontend/src/data/meEnergyHazardFacilities.ts | 16 +-- frontend/src/data/middleEastFacilities.ts | 12 +- frontend/src/env.d.ts | 4 + .../src/hooks/layers/createFacilityLayers.ts | 17 +-- .../src/hooks/layers/createMilitaryLayers.ts | 9 +- .../hooks/layers/createNavigationLayers.ts | 9 +- frontend/src/hooks/layers/createPortLayers.ts | 5 +- frontend/src/hooks/useAnalysisDeckLayers.ts | 7 +- frontend/src/index.css | 2 +- frontend/src/main.tsx | 3 + frontend/src/styles/fonts.ts | 3 + frontend/src/styles/tokens.css | 4 +- 31 files changed, 241 insertions(+), 168 deletions(-) create mode 100644 frontend/src/styles/fonts.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2c88106..c29507d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,9 @@ "@deck.gl/core": "^9.2.11", "@deck.gl/layers": "^9.2.11", "@deck.gl/mapbox": "^9.2.11", + "@fontsource-variable/fira-code": "^5.2.7", + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/noto-sans-kr": "^5.2.10", "@tailwindcss/vite": "^4.2.1", "@turf/boolean-point-in-polygon": "^7.3.4", "@turf/helpers": "^7.3.4", @@ -915,6 +918,33 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fontsource-variable/fira-code": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource-variable/fira-code/-/fira-code-5.2.7.tgz", + "integrity": "sha512-J2bxN7fz5rd8WpQYyau4o19WqTzxoTqaNj9jhsv4p21GSu1Rf34tbqsxqjyDCR+wDMHM3SajyFqtq+5uvRUQ7w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/noto-sans-kr": { + "version": "5.2.10", + "resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-kr/-/noto-sans-kr-5.2.10.tgz", + "integrity": "sha512-UZOO7HF44Rt5+7SCeFMHYVgbKu36Jet6IxrAd7jjEkQMVDmeefwd0H8V5pSZydvBOOxClk3V5cQsujJqGHhQMw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index 10d7a99..40a475d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,9 @@ "@deck.gl/core": "^9.2.11", "@deck.gl/layers": "^9.2.11", "@deck.gl/mapbox": "^9.2.11", + "@fontsource-variable/fira-code": "^5.2.7", + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/noto-sans-kr": "^5.2.10", "@tailwindcss/vite": "^4.2.1", "@turf/boolean-point-in-polygon": "^7.3.4", "@turf/helpers": "^7.3.4", diff --git a/frontend/src/App.css b/frontend/src/App.css index 259b72c..6cce3fa 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -20,7 +20,7 @@ font-size: 14px; font-weight: 700; letter-spacing: 1.5px; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } /* Map mode toggle */ @@ -47,7 +47,7 @@ color: var(--kcg-dim); cursor: pointer; transition: all 0.15s; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .map-mode-btn:hover { @@ -79,7 +79,7 @@ .count-item { font-size: 11px; font-weight: 700; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; letter-spacing: 0.5px; padding: 2px 8px; border-radius: 3px; @@ -103,7 +103,7 @@ color: var(--kcg-text-secondary); font-size: 10px; font-weight: 700; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; letter-spacing: 0.5px; padding: 2px 8px; border-radius: 4px; @@ -126,7 +126,7 @@ font-weight: 700; letter-spacing: 1px; color: var(--text-secondary); - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .status-dot { @@ -240,7 +240,7 @@ letter-spacing: 1.5px; color: var(--text-secondary); margin-bottom: 8px; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .layer-items { @@ -299,7 +299,7 @@ align-items: center; padding: 2px 8px; font-size: 10px; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .stat-cat { @@ -318,7 +318,7 @@ letter-spacing: 1px; color: var(--text-secondary); padding: 2px 8px; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } /* Layer tree */ @@ -434,7 +434,7 @@ border-radius: 4px; cursor: pointer; transition: all 0.2s; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; white-space: nowrap; } .dash-tab:hover { @@ -465,7 +465,7 @@ text-transform: uppercase; color: var(--text-secondary); border-bottom: 1px solid var(--kcg-border); - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .event-list { @@ -504,7 +504,7 @@ white-space: nowrap; height: fit-content; margin-top: 2px; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .event-content { @@ -521,7 +521,7 @@ font-size: 10px; color: var(--text-secondary); margin-top: 1px; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .event-desc { @@ -559,7 +559,7 @@ background: var(--kcg-danger); padding: 2px 6px; border-radius: 2px; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; animation: flash-pulse 1.5s ease-in-out infinite; } @@ -573,7 +573,7 @@ font-weight: 700; color: var(--text-secondary); letter-spacing: 0.5px; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .breaking-news-list { @@ -616,7 +616,7 @@ .breaking-news-time { font-size: 9px; color: var(--kcg-dim); - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .breaking-news-headline { @@ -658,13 +658,13 @@ font-weight: 700; letter-spacing: 1.5px; color: var(--kcg-danger); - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .osint-count { margin-left: auto; font-size: 10px; color: var(--kcg-muted); - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .osint-loading { margin-left: auto; @@ -722,7 +722,7 @@ font-size: 9px; color: var(--kcg-dim); white-space: nowrap; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .osint-item-title { font-size: 11px; @@ -757,10 +757,17 @@ .area-ship-header { display: flex; align-items: center; - gap: 8px; + gap: 6px; margin-bottom: 6px; padding-bottom: 6px; border-bottom: 1px solid var(--kcg-hover); + flex-wrap: nowrap; +} + +.area-ship-header:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; } .korean-highlight-toggle { @@ -768,7 +775,7 @@ padding: 1px 8px; font-size: 9px; font-weight: 700; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; border-radius: 3px; border: 1px solid var(--kcg-border); background: transparent; @@ -793,7 +800,11 @@ font-weight: 700; letter-spacing: 0.5px; color: var(--text-secondary); - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; } .area-ship-total { @@ -801,7 +812,9 @@ font-size: 16px; font-weight: 700; color: #fb923c; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; + white-space: nowrap; + flex-shrink: 0; } .kr-ship-header { @@ -820,7 +833,7 @@ font-weight: 700; letter-spacing: 0.5px; color: var(--text-primary); - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .kr-total { @@ -828,7 +841,7 @@ font-size: 14px; font-weight: 700; color: var(--kcg-accent); - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .kr-ship-breakdown { @@ -859,7 +872,7 @@ flex: 1; font-size: 10px; color: var(--text-secondary); - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .kr-count { @@ -867,7 +880,7 @@ font-weight: 700; color: var(--text-primary); text-align: right; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .kr-ship-list { @@ -881,7 +894,7 @@ gap: 6px; padding: 2px 0; font-size: 9px; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .kr-ship-name { @@ -1026,7 +1039,7 @@ .korea-stat-num { font-size: 20px; font-weight: 800; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .korea-stat-card.total .korea-stat-num { color: var(--kcg-accent); } .korea-stat-card.anchored .korea-stat-num { color: var(--kcg-danger); } @@ -1049,7 +1062,7 @@ letter-spacing: 1px; text-transform: uppercase; margin-bottom: 4px; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .korea-ship-section { flex: 1; @@ -1067,7 +1080,7 @@ color: var(--kcg-muted); border-bottom: 1px solid var(--kcg-border); flex-shrink: 0; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .korea-ship-section-count { color: var(--kcg-accent); @@ -1131,7 +1144,7 @@ .korea-ship-card-speed { margin-left: auto; color: var(--kcg-muted); - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .korea-ship-card-dest { font-size: 9px; @@ -1161,7 +1174,7 @@ letter-spacing: 1.5px; text-transform: uppercase; color: var(--text-secondary); - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .chart-grid { @@ -1177,7 +1190,7 @@ color: var(--text-secondary); margin-bottom: 0; padding-left: 4px; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .chart-demo-label { @@ -1193,7 +1206,7 @@ .ship-popup-body { width: 300px; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; font-size: 11px; line-height: 1.4; } @@ -1347,12 +1360,12 @@ } .popup-body { - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; font-size: 12px; } .popup-body-sm { - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; font-size: 11px; min-width: 220px; } @@ -1446,7 +1459,7 @@ color: var(--text-secondary); cursor: pointer; transition: all 0.15s; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .speed-btn:hover { @@ -1485,7 +1498,7 @@ color: var(--text-secondary); cursor: pointer; transition: all 0.15s; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .range-btn:hover { @@ -1537,7 +1550,7 @@ font-weight: 700; letter-spacing: 1px; color: var(--text-secondary); - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .range-picker input[type="datetime-local"] { @@ -1547,7 +1560,7 @@ color: var(--text-primary); padding: 4px 8px; font-size: 11px; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; outline: none; transition: border-color 0.15s; } @@ -1571,7 +1584,7 @@ background: rgba(34, 197, 94, 0.15); color: var(--kcg-success); cursor: pointer; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; transition: all 0.15s; white-space: nowrap; } @@ -1591,7 +1604,7 @@ font-size: 10px; color: var(--text-secondary); margin-bottom: 2px; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .timeline-current { @@ -1727,7 +1740,7 @@ font-size: 9px; font-weight: 700; color: var(--kcg-dim); - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; flex-shrink: 0; } @@ -1774,7 +1787,7 @@ color: var(--text-secondary); margin-bottom: 2px; padding-left: 8px; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } /* Aircraft, Satellite, Ship & Oil Tooltips - override Leaflet */ @@ -1788,7 +1801,7 @@ border-radius: 3px !important; padding: 2px 6px !important; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5) !important; - font-family: 'Courier New', monospace !important; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace !important; } .aircraft-tooltip::before, @@ -1862,7 +1875,7 @@ border-radius: 3px !important; padding: 2px 6px !important; box-shadow: 0 2px 12px rgba(255, 0, 0, 0.4) !important; - font-family: 'Courier New', monospace !important; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace !important; } .impact-tooltip::before { @@ -1924,7 +1937,7 @@ pointer-events: none; font-weight: 600; font-size: 11px; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; text-shadow: var(--kcg-map-label-shadow); background: var(--kcg-glass); border: 1px solid var(--kcg-border); @@ -1942,7 +1955,7 @@ color: var(--kcg-event-impact); font-weight: 700; font-size: 10px; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; text-shadow: var(--kcg-map-impact-shadow); background: rgba(40, 0, 0, 0.85); border: 1px solid var(--kcg-event-impact); @@ -2038,7 +2051,7 @@ color: var(--kcg-dim); cursor: pointer; transition: all 0.15s; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .mode-btn:hover { @@ -2100,14 +2113,14 @@ font-weight: 700; letter-spacing: 2px; color: var(--kcg-danger); - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .live-clock { font-size: 14px; font-weight: 700; color: var(--text-primary); - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; letter-spacing: 1px; padding: 4px 12px; background: var(--kcg-hover); @@ -2126,7 +2139,7 @@ font-weight: 700; letter-spacing: 1.5px; color: var(--text-secondary); - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .history-presets { @@ -2144,7 +2157,7 @@ color: var(--text-secondary); cursor: pointer; transition: all 0.15s; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; } .history-btn:hover { @@ -2169,7 +2182,7 @@ padding: 1px 6px; font-size: 10px; font-weight: 700; - font-family: 'Courier New', monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; border: none; background: transparent; color: var(--text-secondary); @@ -2335,7 +2348,7 @@ max-height: 80vh; overflow: auto; color: var(--kcg-text, #e2e8f0); - font-family: monospace; + font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; font-size: 13px; box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5); } diff --git a/frontend/src/components/iran/AirportLayer.ts b/frontend/src/components/iran/AirportLayer.ts index fdf9fb4..0a091e7 100644 --- a/frontend/src/components/iran/AirportLayer.ts +++ b/frontend/src/components/iran/AirportLayer.ts @@ -1,6 +1,7 @@ import { IconLayer, TextLayer } from '@deck.gl/layers'; import type { PickingInfo, Layer } from '@deck.gl/core'; import { svgToDataUri } from '../../utils/svgToDataUri'; +import { FONT_MONO } from '../../styles/fonts'; import { middleEastAirports } from '../../data/airports'; import type { Airport } from '../../data/airports'; @@ -42,9 +43,9 @@ export const IRAN_AIRPORT_COUNT = DEDUPLICATED_AIRPORTS.length; // ─── Colors ────────────────────────────────────────────────────────────────── function getAirportColor(airport: Airport): string { - if (isUSBase(airport)) return '#3b82f6'; - if (airport.type === 'military') return '#ef4444'; - return '#f59e0b'; + if (isUSBase(airport)) return '#60a5fa'; + if (airport.type === 'military') return '#f87171'; + return '#38bdf8'; } // ─── SVG generators ────────────────────────────────────────────────────────── @@ -140,7 +141,7 @@ export function createIranAirportLayers(config: AirportLayerConfig): Layer[] { getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 10], - fontFamily: 'monospace', + fontFamily: FONT_MONO, fontWeight: 700, outlineWidth: 2, outlineColor: [0, 0, 0, 200], diff --git a/frontend/src/components/iran/IranDashboard.tsx b/frontend/src/components/iran/IranDashboard.tsx index 421bfe3..85dc2db 100644 --- a/frontend/src/components/iran/IranDashboard.tsx +++ b/frontend/src/components/iran/IranDashboard.tsx @@ -141,23 +141,23 @@ const IranDashboard = ({ }, { key: 'events', label: t('layers.events'), color: '#a855f7' }, { key: 'koreanShips', label: `\u{1F1F0}\u{1F1F7} ${t('layers.koreanShips')}`, color: '#00e5ff', count: iranData.koreanShips.length }, - { key: 'airports', label: t('layers.airports'), color: '#f59e0b', count: IRAN_AIRPORT_COUNT }, - { key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#d97706', count: IRAN_OIL_COUNT }, - { key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: ME_FACILITY_COUNT }, + { key: 'airports', label: t('layers.airports'), color: '#38bdf8', count: IRAN_AIRPORT_COUNT }, + { key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#fb7185', count: IRAN_OIL_COUNT }, + { key: 'meFacilities', label: '주요시설/군사', color: '#f87171', count: ME_FACILITY_COUNT }, { key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' }, { - key: 'overseas', label: '해외시설', color: '#f97316', + key: 'overseas', label: '해외시설', color: '#c084fc', children: [ - { key: 'overseasUS', label: '🇺🇸 미국', color: '#3b82f6', count: meCountByCountry('us') }, - { key: 'overseasIsrael', label: '🇮🇱 이스라엘', color: '#dc2626', count: meCountByCountry('il') }, - { key: 'overseasIran', label: '🇮🇷 이란', color: '#22c55e', count: meCountByCountry('ir') }, - { key: 'overseasUAE', label: '🇦🇪 UAE', color: '#f59e0b', count: meCountByCountry('ae') }, - { key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#84cc16', count: meCountByCountry('sa') }, - { key: 'overseasOman', label: '🇴🇲 오만', color: '#e11d48', count: meCountByCountry('om') }, - { key: 'overseasQatar', label: '🇶🇦 카타르', color: '#8b5cf6', count: meCountByCountry('qa') }, - { key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f97316', count: meCountByCountry('kw') }, - { key: 'overseasIraq', label: '🇮🇶 이라크', color: '#65a30d', count: meCountByCountry('iq') }, - { key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#e11d48', count: meCountByCountry('bh') }, + { key: 'overseasUS', label: '🇺🇸 미국', color: '#60a5fa', count: meCountByCountry('us') }, + { key: 'overseasIsrael', label: '🇮🇱 이스라엘', color: '#ef4444', count: meCountByCountry('il') }, + { key: 'overseasIran', label: '🇮🇷 이란', color: '#34d399', count: meCountByCountry('ir') }, + { key: 'overseasUAE', label: '🇦🇪 UAE', color: '#38bdf8', count: meCountByCountry('ae') }, + { key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#a3e635', count: meCountByCountry('sa') }, + { key: 'overseasOman', label: '🇴🇲 오만', color: '#fb7185', count: meCountByCountry('om') }, + { key: 'overseasQatar', label: '🇶🇦 카타르', color: '#a78bfa', count: meCountByCountry('qa') }, + { key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f472b6', count: meCountByCountry('kw') }, + { key: 'overseasIraq', label: '🇮🇶 이라크', color: '#86efac', count: meCountByCountry('iq') }, + { key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#f87171', count: meCountByCountry('bh') }, ], }, ], [iranData, t, meCountByCountry]); diff --git a/frontend/src/components/iran/MEEnergyHazardLayer.tsx b/frontend/src/components/iran/MEEnergyHazardLayer.tsx index 97294b0..3113bee 100644 --- a/frontend/src/components/iran/MEEnergyHazardLayer.tsx +++ b/frontend/src/components/iran/MEEnergyHazardLayer.tsx @@ -1,6 +1,7 @@ import { IconLayer, TextLayer } from '@deck.gl/layers'; import type { PickingInfo, Layer } from '@deck.gl/core'; import { svgToDataUri } from '../../utils/svgToDataUri'; +import { FONT_MONO } from '../../styles/fonts'; import { ME_ENERGY_HAZARD_FACILITIES, SUB_TYPE_META, @@ -204,12 +205,12 @@ export function createMEEnergyHazardLayers(config: MELayerConfig): Layer[] { getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, getSize: 12 * sc * fs, updateTriggers: { getSize: [sc] }, - getColor: (d) => hexToRgba(SUB_TYPE_META[d.subType].color, 200), + getColor: (d) => hexToRgba(SUB_TYPE_META[d.subType].color), getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 12], - fontFamily: 'monospace', - fontWeight: 600, + fontFamily: FONT_MONO, + fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, outlineColor: [0, 0, 0, 255], diff --git a/frontend/src/components/iran/MEFacilityLayer.ts b/frontend/src/components/iran/MEFacilityLayer.ts index 626e529..ab2d876 100644 --- a/frontend/src/components/iran/MEFacilityLayer.ts +++ b/frontend/src/components/iran/MEFacilityLayer.ts @@ -1,6 +1,7 @@ import { IconLayer, TextLayer } from '@deck.gl/layers'; import type { PickingInfo, Layer } from '@deck.gl/core'; import { svgToDataUri } from '../../utils/svgToDataUri'; +import { FONT_MONO } from '../../styles/fonts'; import { ME_FACILITIES } from '../../data/middleEastFacilities'; import type { MEFacility } from '../../data/middleEastFacilities'; @@ -9,12 +10,12 @@ export const ME_FACILITY_COUNT = ME_FACILITIES.length; // ─── Type colors ────────────────────────────────────────────────────────────── const TYPE_COLORS: Record = { - naval: '#3b82f6', - military_hq: '#ef4444', - missile: '#dc2626', - intelligence: '#8b5cf6', - government: '#f59e0b', - radar: '#06b6d4', + naval: '#60a5fa', + military_hq: '#f87171', + missile: '#ef4444', + intelligence: '#a78bfa', + government: '#c084fc', + radar: '#22d3ee', }; // ─── SVG generators ────────────────────────────────────────────────────────── @@ -168,7 +169,7 @@ export function createMEFacilityLayers(config: MEFacilityLayerConfig): Layer[] { getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 10], - fontFamily: 'monospace', + fontFamily: FONT_MONO, fontWeight: 700, outlineWidth: 2, outlineColor: [0, 0, 0, 200], diff --git a/frontend/src/components/iran/OilFacilityLayer.ts b/frontend/src/components/iran/OilFacilityLayer.ts index 1ab4d1f..bac3f88 100644 --- a/frontend/src/components/iran/OilFacilityLayer.ts +++ b/frontend/src/components/iran/OilFacilityLayer.ts @@ -1,6 +1,7 @@ import { IconLayer, TextLayer } from '@deck.gl/layers'; import type { PickingInfo, Layer } from '@deck.gl/core'; import { svgToDataUri } from '../../utils/svgToDataUri'; +import { FONT_MONO } from '../../styles/fonts'; import { iranOilFacilities } from '../../data/oilFacilities'; import type { OilFacility, OilFacilityType } from '../../types'; @@ -9,12 +10,12 @@ export const IRAN_OIL_COUNT = iranOilFacilities.length; // ─── Type colors ────────────────────────────────────────────────────────────── const TYPE_COLORS: Record = { - refinery: '#f59e0b', - oilfield: '#10b981', - gasfield: '#6366f1', - terminal: '#ec4899', - petrochemical: '#8b5cf6', - desalination: '#06b6d4', + refinery: '#fb7185', + oilfield: '#34d399', + gasfield: '#818cf8', + terminal: '#c084fc', + petrochemical: '#f472b6', + desalination: '#22d3ee', }; // ─── SVG generators ────────────────────────────────────────────────────────── @@ -241,7 +242,7 @@ export function createIranOilLayers(config: OilLayerConfig): Layer[] { getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 10], - fontFamily: 'monospace', + fontFamily: FONT_MONO, fontWeight: 700, outlineWidth: 2, outlineColor: [0, 0, 0, 200], diff --git a/frontend/src/components/iran/ReplayMap.tsx b/frontend/src/components/iran/ReplayMap.tsx index 76e350d..abe311d 100644 --- a/frontend/src/components/iran/ReplayMap.tsx +++ b/frontend/src/components/iran/ReplayMap.tsx @@ -529,8 +529,8 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la const { kind, data } = iranPickedFacility; if (kind === 'oil') { const OIL_TYPE_COLORS: Record = { - refinery: '#f59e0b', oilfield: '#10b981', gasfield: '#6366f1', - terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4', + refinery: '#fb7185', oilfield: '#34d399', gasfield: '#818cf8', + terminal: '#c084fc', petrochemical: '#f472b6', desalination: '#22d3ee', }; const color = OIL_TYPE_COLORS[data.type] ?? '#888'; return ( @@ -568,13 +568,13 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la } if (kind === 'airport') { const isMil = data.type === 'military'; - const color = isMil ? '#ef4444' : '#f59e0b'; + const color = isMil ? '#f87171' : '#38bdf8'; return ( setIranPickedFacility(null)} closeOnClick={false} anchor="bottom" maxWidth="300px" className="gl-popup">
-
+
{data.nameKo ?? data.name}
@@ -598,7 +598,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la ); } if (kind === 'meFacility') { - const meta = { naval: { label: 'Naval Base', color: '#3b82f6', icon: '⚓' }, military_hq: { label: 'Military HQ', color: '#ef4444', icon: '⭐' }, missile: { label: 'Missile/Air Defense', color: '#dc2626', icon: '🚀' }, intelligence: { label: 'Intelligence', color: '#8b5cf6', icon: '🔍' }, government: { label: 'Government', color: '#f59e0b', icon: '🏛️' }, radar: { label: 'Radar/C2', color: '#06b6d4', icon: '📡' } }[data.type] ?? { label: data.type, color: '#888', icon: '📍' }; + const meta = { naval: { label: 'Naval Base', color: '#60a5fa', icon: '⚓' }, military_hq: { label: 'Military HQ', color: '#f87171', icon: '⭐' }, missile: { label: 'Missile/Air Defense', color: '#ef4444', icon: '🚀' }, intelligence: { label: 'Intelligence', color: '#a78bfa', icon: '🔍' }, government: { label: 'Government', color: '#c084fc', icon: '🏛️' }, radar: { label: 'Radar/C2', color: '#22d3ee', icon: '📡' } }[data.type] ?? { label: data.type, color: '#888', icon: '📍' }; return ( setIranPickedFacility(null)} closeOnClick={false} diff --git a/frontend/src/components/iran/SatelliteMap.tsx b/frontend/src/components/iran/SatelliteMap.tsx index 49a0182..2f0f0bc 100644 --- a/frontend/src/components/iran/SatelliteMap.tsx +++ b/frontend/src/components/iran/SatelliteMap.tsx @@ -359,8 +359,8 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, const { kind, data } = iranPickedFacility; if (kind === 'oil') { const OIL_TYPE_COLORS: Record = { - refinery: '#f59e0b', oilfield: '#10b981', gasfield: '#6366f1', - terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4', + refinery: '#fb7185', oilfield: '#34d399', gasfield: '#818cf8', + terminal: '#c084fc', petrochemical: '#f472b6', desalination: '#22d3ee', }; const color = OIL_TYPE_COLORS[data.type] ?? '#888'; return ( @@ -398,13 +398,13 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, } if (kind === 'airport') { const isMil = data.type === 'military'; - const color = isMil ? '#ef4444' : '#f59e0b'; + const color = isMil ? '#f87171' : '#38bdf8'; return ( setIranPickedFacility(null)} closeOnClick={false} anchor="bottom" maxWidth="300px" className="gl-popup">
-
+
{data.nameKo ?? data.name}
@@ -428,7 +428,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, ); } if (kind === 'meFacility') { - const meta = { naval: { label: 'Naval Base', color: '#3b82f6', icon: '⚓' }, military_hq: { label: 'Military HQ', color: '#ef4444', icon: '⭐' }, missile: { label: 'Missile/Air Defense', color: '#dc2626', icon: '🚀' }, intelligence: { label: 'Intelligence', color: '#8b5cf6', icon: '🔍' }, government: { label: 'Government', color: '#f59e0b', icon: '🏛️' }, radar: { label: 'Radar/C2', color: '#06b6d4', icon: '📡' } }[data.type] ?? { label: data.type, color: '#888', icon: '📍' }; + const meta = { naval: { label: 'Naval Base', color: '#60a5fa', icon: '⚓' }, military_hq: { label: 'Military HQ', color: '#f87171', icon: '⭐' }, missile: { label: 'Missile/Air Defense', color: '#ef4444', icon: '🚀' }, intelligence: { label: 'Intelligence', color: '#a78bfa', icon: '🔍' }, government: { label: 'Government', color: '#c084fc', icon: '🏛️' }, radar: { label: 'Radar/C2', color: '#22d3ee', icon: '📡' } }[data.type] ?? { label: data.type, color: '#888', icon: '📍' }; return ( setIranPickedFacility(null)} closeOnClick={false} diff --git a/frontend/src/components/iran/createIranAirportLayers.ts b/frontend/src/components/iran/createIranAirportLayers.ts index 2ff899c..99d279c 100644 --- a/frontend/src/components/iran/createIranAirportLayers.ts +++ b/frontend/src/components/iran/createIranAirportLayers.ts @@ -3,6 +3,7 @@ import type { Layer, PickingInfo } from '@deck.gl/core'; import { svgToDataUri } from '../../utils/svgToDataUri'; import { middleEastAirports } from '../../data/airports'; import type { Airport } from '../../data/airports'; +import { FONT_MONO } from '../../styles/fonts'; export { type Airport }; @@ -15,10 +16,10 @@ const US_BASE_ICAOS = new Set([ function getAirportColor(airport: Airport): string { const isMil = airport.type === 'military'; const isUS = isMil && US_BASE_ICAOS.has(airport.icao); - if (isUS) return '#3b82f6'; - if (isMil) return '#ef4444'; - if (airport.type === 'international') return '#f59e0b'; - return '#7c8aaa'; + if (isUS) return '#60a5fa'; // blue-400 + if (isMil) return '#f87171'; // red-400 + if (airport.type === 'international') return '#38bdf8'; // sky-400 (was amber) + return '#a5b4fc'; // indigo-200 (was gray) } function airportSvg(color: string, size: number): string { @@ -92,8 +93,8 @@ export function createIranAirportLayers(config: IranAirportLayerConfig): Layer[] getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 10], - fontFamily: 'monospace', - fontWeight: 600, + fontFamily: FONT_MONO, + fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, outlineColor: [0, 0, 0, 255], diff --git a/frontend/src/components/iran/createIranOilLayers.ts b/frontend/src/components/iran/createIranOilLayers.ts index d28798a..e238437 100644 --- a/frontend/src/components/iran/createIranOilLayers.ts +++ b/frontend/src/components/iran/createIranOilLayers.ts @@ -3,18 +3,19 @@ import type { Layer, PickingInfo } from '@deck.gl/core'; import { svgToDataUri } from '../../utils/svgToDataUri'; import { iranOilFacilities } from '../../data/oilFacilities'; import type { OilFacility, OilFacilityType } from '../../types'; +import { FONT_MONO } from '../../styles/fonts'; export { type OilFacility }; export const IRAN_OIL_COUNT = iranOilFacilities.length; const TYPE_COLORS: Record = { - refinery: '#f59e0b', - oilfield: '#10b981', - gasfield: '#6366f1', - terminal: '#ec4899', - petrochemical: '#8b5cf6', - desalination: '#06b6d4', + refinery: '#fb7185', // rose-400 (was amber — desert에 묻힘) + oilfield: '#34d399', // emerald-400 + gasfield: '#818cf8', // indigo-400 + terminal: '#c084fc', // purple-400 + petrochemical: '#f472b6', // pink-400 + desalination: '#22d3ee', // cyan-400 }; function refinerySvg(color: string, size: number): string { @@ -142,8 +143,8 @@ export function createIranOilLayers(config: IranOilLayerConfig): Layer[] { getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 10], - fontFamily: 'monospace', - fontWeight: 600, + fontFamily: FONT_MONO, + fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, outlineColor: [0, 0, 0, 255], diff --git a/frontend/src/components/iran/createMEFacilityLayers.ts b/frontend/src/components/iran/createMEFacilityLayers.ts index 1943b92..63de4d0 100644 --- a/frontend/src/components/iran/createMEFacilityLayers.ts +++ b/frontend/src/components/iran/createMEFacilityLayers.ts @@ -3,6 +3,7 @@ import type { Layer, PickingInfo } from '@deck.gl/core'; import { svgToDataUri } from '../../utils/svgToDataUri'; import { ME_FACILITIES, ME_FACILITY_TYPE_META } from '../../data/middleEastFacilities'; import type { MEFacility } from '../../data/middleEastFacilities'; +import { FONT_MONO } from '../../styles/fonts'; export { type MEFacility }; @@ -133,12 +134,12 @@ export function createMEFacilityLayers(config: MEFacilityLayerConfig): Layer[] { getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, getSize: 12 * sc * fs, updateTriggers: { getSize: [sc] }, - getColor: (d) => hexToRgba(ME_FACILITY_TYPE_META[d.type].color, 200), + getColor: (d) => hexToRgba(ME_FACILITY_TYPE_META[d.type].color), getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 12], - fontFamily: 'monospace', - fontWeight: 600, + fontFamily: FONT_MONO, + fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, outlineColor: [0, 0, 0, 255], diff --git a/frontend/src/components/korea/AnalysisStatsPanel.tsx b/frontend/src/components/korea/AnalysisStatsPanel.tsx index 836d8bc..a9b80b9 100644 --- a/frontend/src/components/korea/AnalysisStatsPanel.tsx +++ b/frontend/src/components/korea/AnalysisStatsPanel.tsx @@ -1,5 +1,6 @@ import { useState, useMemo, useEffect } from 'react'; import type { VesselAnalysisDto, RiskLevel, Ship } from '../../types'; +import { FONT_MONO } from '../../styles/fonts'; import type { AnalysisStats } from '../../hooks/useVesselAnalysis'; import { useLocalStorage } from '../../hooks/useLocalStorage'; import { fetchVesselTrack } from '../../services/vesselTrack'; @@ -143,7 +144,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, border: '1px solid rgba(99, 179, 237, 0.25)', borderRadius: 8, color: '#e2e8f0', - fontFamily: 'monospace, sans-serif', + fontFamily: FONT_MONO, fontSize: 11, boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)', overflow: 'hidden', @@ -311,7 +312,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, fontSize: 10, cursor: 'pointer', padding: '2px 4px', - fontFamily: 'monospace, sans-serif', + fontFamily: FONT_MONO, }} > {RISK_EMOJI[level]} diff --git a/frontend/src/components/korea/FieldAnalysisModal.tsx b/frontend/src/components/korea/FieldAnalysisModal.tsx index 7343949..d2eb546 100644 --- a/frontend/src/components/korea/FieldAnalysisModal.tsx +++ b/frontend/src/components/korea/FieldAnalysisModal.tsx @@ -1,5 +1,6 @@ import { useState, useMemo, useEffect, useCallback } from 'react'; import type { Ship, ChnPrmShipInfo, VesselAnalysisDto } from '../../types'; +import { FONT_MONO } from '../../styles/fonts'; import { classifyFishingZone } from '../../utils/fishingAnalysis'; import { getMarineTrafficCategory } from '../../utils/marineTraffic'; import { lookupPermittedShip } from '../../services/chnPrmShip'; @@ -330,7 +331,7 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) { position: 'absolute', inset: 0, zIndex: 2000, background: 'rgba(2,6,14,0.96)', display: 'flex', flexDirection: 'column', - fontFamily: "'IBM Plex Mono', 'Noto Sans KR', monospace", + fontFamily: FONT_MONO, }}> {/* ── 헤더 */}
-
+
{company?.nameCn || `선단 #${cid}`}
@@ -628,7 +629,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster closeButton={false} closeOnClick={false} anchor="bottom" className="gl-popup" maxWidth="220px" > -
+
{name} 어구 {entry.gears.length}개
diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 8d7621b..080517a 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -4,6 +4,7 @@ import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/mapl import type { MapRef } from 'react-map-gl/maplibre'; import { ScatterplotLayer, TextLayer } from '@deck.gl/layers'; import { useFontScale } from '../../hooks/useFontScale'; +import { FONT_MONO } from '../../styles/fonts'; import { DeckGLOverlay } from '../layers/DeckGLOverlay'; import { useAnalysisDeckLayers } from '../../hooks/useAnalysisDeckLayers'; import { useStaticDeckLayers } from '../../hooks/useStaticDeckLayers'; @@ -249,7 +250,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 14], - fontFamily: 'monospace', + fontFamily: FONT_MONO, fontSettings: { sdf: true }, outlineWidth: 3, outlineColor: [0, 0, 0, 255], @@ -287,7 +288,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF getColor: [255, 255, 255, 220], getTextAnchor: 'middle', getAlignmentBaseline: 'center', - fontFamily: 'monospace', + fontFamily: FONT_MONO, fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, @@ -364,7 +365,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF getTextAnchor: 'middle' as const, getAlignmentBaseline: 'top' as const, getPixelOffset: [0, 10], - fontFamily: 'monospace', + fontFamily: FONT_MONO, fontSettings: { sdf: true }, outlineWidth: 3, outlineColor: [0, 0, 0, 220], @@ -399,7 +400,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF getTextAnchor: 'middle' as const, getAlignmentBaseline: 'top' as const, getPixelOffset: [0, 18], - fontFamily: 'monospace', + fontFamily: FONT_MONO, fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, @@ -464,7 +465,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF getTextAnchor: 'middle' as const, getAlignmentBaseline: 'top' as const, getPixelOffset: [0, 12], - fontFamily: 'monospace', + fontFamily: FONT_MONO, fontWeight: 600, fontSettings: { sdf: true }, outlineWidth: 3, diff --git a/frontend/src/components/korea/SubmarineCableLayer.tsx b/frontend/src/components/korea/SubmarineCableLayer.tsx index 1bb58b8..d0f10d1 100644 --- a/frontend/src/components/korea/SubmarineCableLayer.tsx +++ b/frontend/src/components/korea/SubmarineCableLayer.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre'; +import { FONT_MONO } from '../../styles/fonts'; import { KOREA_SUBMARINE_CABLES, KOREA_LANDING_POINTS } from '../../services/submarineCable'; import type { SubmarineCable } from '../../services/submarineCable'; @@ -90,7 +91,7 @@ export function SubmarineCableLayer() { { e.originalEvent.stopPropagation(); setSelectedCable(cable); setSelectedPoint(null); }}>
= { - power: { label: '발전소', color: '#a855f7', icon: '⚡' }, - wind: { label: '풍력단지', color: '#22d3ee', icon: '🌬' }, - nuclear: { label: '원자력발전소', color: '#f59e0b', icon: '☢' }, - thermal: { label: '화력발전소', color: '#64748b', icon: '🏭' }, - petrochem: { label: '석유화학단지', color: '#f97316', icon: '🛢' }, - lng: { label: 'LNG저장기지', color: '#0ea5e9', icon: '❄' }, - oil_tank: { label: '유류저장탱크', color: '#eab308', icon: '🛢' }, - haz_port: { label: '위험물항만하역시설', color: '#dc2626', icon: '⚠' }, + power: { label: '발전소', color: '#c084fc', icon: '⚡' }, // purple-400 + wind: { label: '풍력단지', color: '#22d3ee', icon: '🌬' }, // cyan-400 + nuclear: { label: '원자력발전소', color: '#f472b6', icon: '☢' }, // pink-400 (was amber) + thermal: { label: '화력발전소', color: '#94a3b8', icon: '🏭' }, // slate-400 (was 500) + petrochem: { label: '석유화학단지', color: '#fb7185', icon: '🛢' }, // rose-400 (was orange) + lng: { label: 'LNG저장기지', color: '#38bdf8', icon: '❄' }, // sky-400 + oil_tank: { label: '유류저장탱크', color: '#a3e635', icon: '🛢' }, // lime-400 (was yellow) + haz_port: { label: '위험물항만하역시설', color: '#f87171', icon: '⚠' }, // red-400 }; // layer key -> subType mapping diff --git a/frontend/src/data/middleEastFacilities.ts b/frontend/src/data/middleEastFacilities.ts index 6bf9e61..81a4677 100644 --- a/frontend/src/data/middleEastFacilities.ts +++ b/frontend/src/data/middleEastFacilities.ts @@ -14,12 +14,12 @@ export interface MEFacility { } const TYPE_META: Record = { - naval: { label: 'Naval Base', color: '#3b82f6', icon: '⚓' }, - military_hq: { label: 'Military HQ', color: '#ef4444', icon: '⭐' }, - missile: { label: 'Missile/Air Defense', color: '#dc2626', icon: '🚀' }, - intelligence: { label: 'Intelligence', color: '#8b5cf6', icon: '🔍' }, - government: { label: 'Government', color: '#f59e0b', icon: '🏛️' }, - radar: { label: 'Radar/C2', color: '#06b6d4', icon: '📡' }, + naval: { label: 'Naval Base', color: '#60a5fa', icon: '⚓' }, // blue-400 + military_hq: { label: 'Military HQ', color: '#f87171', icon: '⭐' }, // red-400 + missile: { label: 'Missile/Air Defense', color: '#ef4444', icon: '🚀' }, // red-500 + intelligence: { label: 'Intelligence', color: '#a78bfa', icon: '🔍' }, // violet-400 + government: { label: 'Government', color: '#c084fc', icon: '🏛️' }, // purple-400 (was amber) + radar: { label: 'Radar/C2', color: '#22d3ee', icon: '📡' }, // cyan-400 }; export { TYPE_META as ME_FACILITY_TYPE_META }; diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts index d1719dc..2e02f3d 100644 --- a/frontend/src/env.d.ts +++ b/frontend/src/env.d.ts @@ -1,5 +1,9 @@ /// +declare module '@fontsource-variable/inter'; +declare module '@fontsource-variable/noto-sans-kr'; +declare module '@fontsource-variable/fira-code'; + interface ImportMetaEnv { readonly VITE_SPG_API_KEY?: string; readonly VITE_GOOGLE_CLIENT_ID?: string; diff --git a/frontend/src/hooks/layers/createFacilityLayers.ts b/frontend/src/hooks/layers/createFacilityLayers.ts index b510f77..7ce1578 100644 --- a/frontend/src/hooks/layers/createFacilityLayers.ts +++ b/frontend/src/hooks/layers/createFacilityLayers.ts @@ -14,6 +14,7 @@ import { type CnFacility, type JpFacility, } from './types'; +import { FONT_MONO } from '../../styles/fonts'; // ─── Infra SVG ──────────────────────────────────────────────────────────────── @@ -359,8 +360,8 @@ export function createFacilityLayers( getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 8], - fontFamily: 'monospace', - fontWeight: 600, + fontFamily: FONT_MONO, + fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, outlineColor: [0, 0, 0, 255], @@ -415,8 +416,8 @@ export function createFacilityLayers( getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 12], - fontFamily: 'monospace', - fontWeight: 600, + fontFamily: FONT_MONO, + fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, outlineColor: [0, 0, 0, 255], @@ -469,8 +470,8 @@ export function createFacilityLayers( getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 12], - fontFamily: 'monospace', - fontWeight: 600, + fontFamily: FONT_MONO, + fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, outlineColor: [0, 0, 0, 255], @@ -522,8 +523,8 @@ export function createFacilityLayers( getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 12], - fontFamily: 'monospace', - fontWeight: 600, + fontFamily: FONT_MONO, + fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, outlineColor: [0, 0, 0, 255], diff --git a/frontend/src/hooks/layers/createMilitaryLayers.ts b/frontend/src/hooks/layers/createMilitaryLayers.ts index b3ae0d1..211755d 100644 --- a/frontend/src/hooks/layers/createMilitaryLayers.ts +++ b/frontend/src/hooks/layers/createMilitaryLayers.ts @@ -11,6 +11,7 @@ import { NK_MISSILE_EVENTS } from '../../data/nkMissileEvents'; import type { NKMissileEvent } from '../../data/nkMissileEvents'; import { hexToRgb } from './types'; import type { LayerFactoryConfig } from './types'; +import { FONT_MONO } from '../../styles/fonts'; // ─── NKMissile SVG ──────────────────────────────────────────────────────────── @@ -318,7 +319,7 @@ export function createMilitaryLayers( getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 9], - fontFamily: 'monospace', + fontFamily: FONT_MONO, fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, @@ -356,7 +357,7 @@ export function createMilitaryLayers( getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 8], - fontFamily: 'monospace', + fontFamily: FONT_MONO, fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, @@ -394,7 +395,7 @@ export function createMilitaryLayers( getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 8], - fontFamily: 'monospace', + fontFamily: FONT_MONO, fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, @@ -486,7 +487,7 @@ export function createMilitaryLayers( getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 10], - fontFamily: 'monospace', + fontFamily: FONT_MONO, fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, diff --git a/frontend/src/hooks/layers/createNavigationLayers.ts b/frontend/src/hooks/layers/createNavigationLayers.ts index 5d8de84..9283f80 100644 --- a/frontend/src/hooks/layers/createNavigationLayers.ts +++ b/frontend/src/hooks/layers/createNavigationLayers.ts @@ -11,6 +11,7 @@ import { PIRACY_ZONES, PIRACY_LEVEL_COLOR } from '../../services/piracy'; import type { PiracyZone } from '../../services/piracy'; import { hexToRgb } from './types'; import type { LayerFactoryConfig } from './types'; +import { FONT_MONO } from '../../styles/fonts'; // ─── CoastGuard ─────────────────────────────────────────────────────────────── @@ -180,7 +181,7 @@ export function createNavigationLayers( getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 8], - fontFamily: 'monospace', + fontFamily: FONT_MONO, fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, @@ -231,7 +232,7 @@ export function createNavigationLayers( getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 10], - fontFamily: 'monospace', + fontFamily: FONT_MONO, fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, @@ -281,7 +282,7 @@ export function createNavigationLayers( getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 9], - fontFamily: 'monospace', + fontFamily: FONT_MONO, fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, @@ -332,7 +333,7 @@ export function createNavigationLayers( getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 14], - fontFamily: 'monospace', + fontFamily: FONT_MONO, fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, diff --git a/frontend/src/hooks/layers/createPortLayers.ts b/frontend/src/hooks/layers/createPortLayers.ts index 4ff363f..05f8b61 100644 --- a/frontend/src/hooks/layers/createPortLayers.ts +++ b/frontend/src/hooks/layers/createPortLayers.ts @@ -7,6 +7,7 @@ import { KOREA_WIND_FARMS } from '../../data/windFarms'; import type { WindFarm } from '../../data/windFarms'; import { hexToRgb } from './types'; import type { LayerFactoryConfig } from './types'; +import { FONT_MONO } from '../../styles/fonts'; // ─── Port colors ────────────────────────────────────────────────────────────── @@ -101,7 +102,7 @@ export function createPortLayers( getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 8], - fontFamily: 'monospace', + fontFamily: FONT_MONO, fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, @@ -139,7 +140,7 @@ export function createPortLayers( getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 10], - fontFamily: 'monospace', + fontFamily: FONT_MONO, fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, diff --git a/frontend/src/hooks/useAnalysisDeckLayers.ts b/frontend/src/hooks/useAnalysisDeckLayers.ts index 8f48ddb..7726fe8 100644 --- a/frontend/src/hooks/useAnalysisDeckLayers.ts +++ b/frontend/src/hooks/useAnalysisDeckLayers.ts @@ -3,6 +3,7 @@ import { ScatterplotLayer, TextLayer } from '@deck.gl/layers'; import type { Layer } from '@deck.gl/core'; import type { Ship, VesselAnalysisDto } from '../types'; import { useFontScale } from './useFontScale'; +import { FONT_MONO } from '../styles/fonts'; interface AnalyzedShip { ship: Ship; @@ -132,7 +133,7 @@ export function useAnalysisDeckLayers( getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 16], - fontFamily: 'monospace', + fontFamily: FONT_MONO, fontSettings: { sdf: true }, outlineWidth: 3, outlineColor: [0, 0, 0, 255], @@ -176,7 +177,7 @@ export function useAnalysisDeckLayers( getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 14], - fontFamily: 'monospace', + fontFamily: FONT_MONO, fontSettings: { sdf: true }, outlineWidth: 3, outlineColor: [0, 0, 0, 255], @@ -198,7 +199,7 @@ export function useAnalysisDeckLayers( getColor: [239, 68, 68, 255], getTextAnchor: 'start', getPixelOffset: [12, -8], - fontFamily: 'monospace', + fontFamily: FONT_MONO, fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, diff --git a/frontend/src/index.css b/frontend/src/index.css index 5536b3f..4322265 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -17,7 +17,7 @@ } body { - font-family: 'Inter', system-ui, -apple-system, sans-serif; + font-family: 'Inter Variable', 'Noto Sans KR Variable', system-ui, -apple-system, sans-serif; background: var(--bg-primary); color: var(--text-primary); min-height: 100vh; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 81dce04..c931ae6 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,5 +1,8 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import '@fontsource-variable/inter' +import '@fontsource-variable/noto-sans-kr' +import '@fontsource-variable/fira-code' import './i18n' import './styles/tailwind.css' import './index.css' diff --git a/frontend/src/styles/fonts.ts b/frontend/src/styles/fonts.ts new file mode 100644 index 0000000..18844c4 --- /dev/null +++ b/frontend/src/styles/fonts.ts @@ -0,0 +1,3 @@ +/** Self-hosted web font stacks — @fontsource-variable 패키지 기반 */ +export const FONT_SANS = "'Inter Variable', 'Noto Sans KR Variable', system-ui, sans-serif"; +export const FONT_MONO = "'Fira Code Variable', 'Noto Sans KR Variable', monospace"; diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 0253a92..24693c5 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -184,6 +184,6 @@ --color-kcg-danger-bg: var(--kcg-danger-bg); /* 폰트 */ - --font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; - --font-sans: 'Inter', system-ui, -apple-system, sans-serif; + --font-mono: 'Fira Code Variable', 'Noto Sans KR Variable', monospace; + --font-sans: 'Inter Variable', 'Noto Sans KR Variable', system-ui, -apple-system, sans-serif; } From 72291b2060ee661a7706f93baba9b9f3c7afb16a Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 24 Mar 2026 10:13:23 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index cf9486f..4be2393 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,16 @@ ## [Unreleased] +### 추가 +- 웹폰트 내장: @fontsource-variable Inter, Noto Sans KR, Fira Code 자체 호스팅 +- 폰트 상수 파일 (FONT_MONO, FONT_SANS) + 타입 선언 + +### 변경 +- 전체 font-family 통일: CSS 55곳 + deck.gl TextLayer 30곳 + 인라인 스타일 8곳 +- 이란 시설물 색상 사막 대비 고채도 팔레트 교체 (amber/orange/yellow → rose/sky/cyan/lime) +- 이란 시설 라벨 fontWeight 600→700, alpha 200→255 (가독성 개선) +- 접힘 패널 상하 패딩 균일화 (area-ship-header :last-child) + ## [2026-03-24] ### 추가 From 360ca0e3823f8421f7ed25be87b5d94f8095af7c Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 24 Mar 2026 10:16:34 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-03-24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 112 +++++++----------------------------------- 1 file changed, 19 insertions(+), 93 deletions(-) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 4be2393..bc07144 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-03-24.1] + ### 추가 - 웹폰트 내장: @fontsource-variable Inter, Noto Sans KR, Fira Code 자체 호스팅 - 폰트 상수 파일 (FONT_MONO, FONT_SANS) + 타입 선언 @@ -31,109 +33,33 @@ - useIranData dataSource 분기 (dummy=sampleData, api=Backend DB 3월1일~오늘) - fetchAircraftByRange, fetchOsintByRange, fetchEventsByRange 서비스 함수 -## [2026-03-23.6] - -### 수정 -- LIVE 모드 렌더링 최적화: useMonitor 1초 setInterval 제거 (60배 과잉 재계산 해소) -- useKoreaFilters currentTime 의존성 제거 (5분 polling 시에만 필터 재계산) -- useKoreaData aircraft/satellite LIVE↔REPLAY 분리 (LIVE에서 불필요한 매초 propagation 제거) -- 특정어업수역 실제 폴리곤 좌표 적용 (bbox 직사각형 → 원본 GeoJSON EPSG:3857→WGS84 변환) -- FishingZoneLayer zone 속성 매칭 수정 (id→zone, 폴리곤 투명 렌더링 해결) -- 선박/분석 라벨 폰트 크기 80% 축소 (가독성 개선) -- DB migration 008 적용 (is_transship_suspect 칼럼 추가 → AI 분석 API 500 에러 해결) - -## [2026-03-23.5] - -### 추가 -- 이란 시설 deck.gl SVG 전환: OilFacility/Airport/MEFacility/MEEnergyHazard → IconLayer(SVG) + TextLayer -- 26개 고유 SVG 아이콘 (배경 원형 + 색상 테두리 + 고유 실루엣) -- 중동 에너지/위험시설 데이터 84개 (meEnergyHazardFacilities) -- 나탄즈-디모나 핵시설 교차공격 리플레이 이벤트 (D+20) -- AI 해양분석 챗 UI (AiChatPanel, API placeholder) -- LayerPanel 해외시설 3단계 트리 (국가→카테고리→하위시설) - -### 변경 -- 한국 군사/정부/NK 발사장 아이콘: emoji → SVG IconLayer 업그레이드 (19종) -- 시설 라벨 SDF 테두리 적용 (fontSettings.sdf + outlineWidth:8) — 사막/위성 배경 가독성 -- 라벨 폰트 크기 ~1.2배 상향 (이란/한국 공통) -- ReplayMap/SatelliteMap: DeckGLOverlay + 줌 스케일 연동 - -### 수정 -- IranDashboard LayerPanel 카운트 전수 보정 (하드코딩→실제 데이터 기반) -- fishing-zones GeoJSON 좌표 보정 -- overseas 국가 키: overseasUK → overseasIsrael - -## [2026-03-23.4] - -### 추가 -- 환적탐지 Python 이관: 프론트엔드 O(n²) 근접탐지 → 서버사이드 그리드 공간인덱스 O(n log n) -- 필터 배지 클릭 → 대상 선박 목록 패널 (MMSI/이름/국적/유형/속도) + CSV 다운로드 -- 중국어선감시 KoreaFilters 통합: 다른 감시 탭과 동일한 선박 비활성화/배지/카운트 동작 -- 중국 어구그룹 감시 배지: 어구그룹 수(고유 모선명) 기준 집계 - -### 변경 -- deck.gl updateTriggers 적용: 줌 변경 시 레이어 accessor 재평가 최소화 -- 선박 카테고리/국적 토글: JS-level 배열 필터링 → MapLibre GPU-side filter 표현식 -- Ship.mtCategory/natGroup 사전 계산: Set.has() O(1) 필터 룩업 (getMarineTrafficCategory 매번 호출 제거) -- LIVE 모드: currentTime 의존성 분리 → 매초 선박 재계산 제거 -- 분석 레이어 데이터/스타일 useMemo 분리: 줌 변경 시 ships 필터링 스킵 -- SVG 데이터 URI 모듈 레벨 캐싱 - -### 수정 -- 비허가 어구 그룹: 2개 이상일 때만 그룹 탐지/폴리곤 생성 -- 한국 필터 토글 시 선박 표시 복원 (anyKoreaFilterOn 조건 분기) -- 필터별 개별 탐지 카운트 (합산 → 탭별 분리) -- 헤더 1행 배치 (flex-wrap:nowrap), 이란 mode-toggle 좌측/지도 모드 중앙 -- onPick useCallback 안정화 (매 렌더 28개 정적 레이어 재생성 방지) -- 감시 목록 Flag 빈값 표기: '??' → '-' - -## [2026-03-23.3] - -### 변경 -- App.tsx 분해: IranDashboard + KoreaDashboard 추출 (771줄→163줄) -- useStaticDeckLayers 분할: 레이어별 서브훅 4개 (1,086줄→85줄) -- StaticFacilityPopup 독립 컴포넌트 추출 (KoreaMap -200줄) -- geometry/shipClassification 유틸 추출 -- SharedFilterContext + useSharedFilters (카테고리 필터 공유) -- API 클라이언트 래퍼 + usePoll 폴링 유틸 추가 -- 줌 이벤트 ref 기반 디바운싱 - -## [2026-03-23.2] - -### 추가 -- 중국어선감시 탭: CN 어선 + 어구 패턴 선박 필터링 -- 중국어선감시 탭: 조업수역 Ⅰ~Ⅳ 폴리곤 동시 표시 -- 어구 그룹 수역 내/외 분류 (조업구역내 붉은색, 비허가 오렌지) -- 패널 3섹션 독립 접기/펴기 (선단 현황 / 조업구역내 어구 / 비허가 어구) -- 폴리곤 클릭·zoom 시 어구 행 자동 스크롤 -- localStorage 기반 레이어/필터 상태 영속화 (13개 항목) -- AI 분석 닫힘 시 위험도 마커 off - -### 변경 -- AI 분석 패널 위치 조정 (줌 버튼 간격 확보) -- 백엔드 vessel-analysis 조회 윈도우 1h → 2h - -### 수정 -- FleetClusterLayer 마운트 조건 완화 (clusters 의존 제거) - ## [2026-03-23] ### 추가 - 해외시설 레이어: 위험시설(원전/화학/연료저장) + 중국·일본 발전소/군사시설 - 현장분석 Python 연동 + FieldAnalysisModal (어구/선단 분석 대시보드) -- 선단 선택 시 소속 선박 deck.gl 강조 (어구 그룹과 동일 패턴) -- 전 시설 kind에 리치 Popup 디자인 통합 (헤더·배지·상세정보) -- LAYERS 패널 카운트 통일 — 하드코딩→실제 데이터 기반 동적 표기 +- 이란 시설 deck.gl SVG 전환: 26개 고유 SVG 아이콘 (IconLayer + TextLayer) +- 중동 에너지/위험시설 데이터 84개 (meEnergyHazardFacilities) +- 환적탐지 Python 이관: 서버사이드 그리드 공간인덱스 O(n log n) +- 중국어선감시 탭: CN 어선 + 어구 패턴 필터링, 조업수역 폴리곤 +- AI 해양분석 챗 UI (AiChatPanel, placeholder) +- localStorage 기반 레이어/필터 상태 영속화 (13개 항목) ### 변경 -- DOM Marker → deck.gl 전환 (폴리곤 인터랙션 포함) -- 줌 레벨별 아이콘/텍스트 스케일 연동 (z4=0.8x ~ z14=4.2x) +- App.tsx 분해: IranDashboard + KoreaDashboard 추출 (771줄→163줄) +- useStaticDeckLayers 분할: 레이어별 서브훅 4개 +- DOM Marker → deck.gl 전환 + 줌 스케일 연동 +- 한국 군사/정부/NK 아이콘: emoji → SVG IconLayer (19종) +- 선박 카테고리/국적 토글: MapLibre GPU-side filter 표현식 +- LIVE 모드 currentTime 의존성 분리 → 매초 재계산 제거 +- 시설 라벨 SDF 테두리 적용 (fontSettings.sdf + outlineWidth) ### 수정 +- LIVE 모드 렌더링 최적화: useMonitor 1초 setInterval 제거 +- 특정어업수역 실제 폴리곤 좌표 적용 (EPSG:3857→WGS84 변환) +- DB migration 008 적용 (AI 분석 API 500 에러 해결) - 불법어선 탭 복원 + ShipLayer feature-state 필터 에러 수정 -- 해외시설 토글을 militaryOnly에서 분리 (선박/항공기 필터 간섭 해소) -- deck.gl 레이어 호버 시 pointer 커서 표시 -- prediction 증분 수집 버그 수정 (vessel_store.py) +- prediction 증분 수집 버그 수정 ## [2026-03-20]