Merge pull request 'release: 2026-03-24.1 (5건 커밋)' (#176) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 2m0s

This commit is contained in:
htlee 2026-03-24 10:18:04 +09:00
커밋 5384092b21
32개의 변경된 파일270개의 추가작업 그리고 261개의 파일을 삭제

파일 보기

@ -4,6 +4,18 @@
## [Unreleased] ## [Unreleased]
## [2026-03-24.1]
### 추가
- 웹폰트 내장: @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] ## [2026-03-24]
### 추가 ### 추가
@ -21,109 +33,33 @@
- useIranData dataSource 분기 (dummy=sampleData, api=Backend DB 3월1일~오늘) - useIranData dataSource 분기 (dummy=sampleData, api=Backend DB 3월1일~오늘)
- fetchAircraftByRange, fetchOsintByRange, fetchEventsByRange 서비스 함수 - 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] ## [2026-03-23]
### 추가 ### 추가
- 해외시설 레이어: 위험시설(원전/화학/연료저장) + 중국·일본 발전소/군사시설 - 해외시설 레이어: 위험시설(원전/화학/연료저장) + 중국·일본 발전소/군사시설
- 현장분석 Python 연동 + FieldAnalysisModal (어구/선단 분석 대시보드) - 현장분석 Python 연동 + FieldAnalysisModal (어구/선단 분석 대시보드)
- 선단 선택 시 소속 선박 deck.gl 강조 (어구 그룹과 동일 패턴) - 이란 시설 deck.gl SVG 전환: 26개 고유 SVG 아이콘 (IconLayer + TextLayer)
- 전 시설 kind에 리치 Popup 디자인 통합 (헤더·배지·상세정보) - 중동 에너지/위험시설 데이터 84개 (meEnergyHazardFacilities)
- LAYERS 패널 카운트 통일 — 하드코딩→실제 데이터 기반 동적 표기 - 환적탐지 Python 이관: 서버사이드 그리드 공간인덱스 O(n log n)
- 중국어선감시 탭: CN 어선 + 어구 패턴 필터링, 조업수역 폴리곤
- AI 해양분석 챗 UI (AiChatPanel, placeholder)
- localStorage 기반 레이어/필터 상태 영속화 (13개 항목)
### 변경 ### 변경
- DOM Marker → deck.gl 전환 (폴리곤 인터랙션 포함) - App.tsx 분해: IranDashboard + KoreaDashboard 추출 (771줄→163줄)
- 줌 레벨별 아이콘/텍스트 스케일 연동 (z4=0.8x ~ z14=4.2x) - 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 필터 에러 수정 - 불법어선 탭 복원 + ShipLayer feature-state 필터 에러 수정
- 해외시설 토글을 militaryOnly에서 분리 (선박/항공기 필터 간섭 해소) - prediction 증분 수집 버그 수정
- deck.gl 레이어 호버 시 pointer 커서 표시
- prediction 증분 수집 버그 수정 (vessel_store.py)
## [2026-03-20] ## [2026-03-20]

파일 보기

@ -11,6 +11,9 @@
"@deck.gl/core": "^9.2.11", "@deck.gl/core": "^9.2.11",
"@deck.gl/layers": "^9.2.11", "@deck.gl/layers": "^9.2.11",
"@deck.gl/mapbox": "^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", "@tailwindcss/vite": "^4.2.1",
"@turf/boolean-point-in-polygon": "^7.3.4", "@turf/boolean-point-in-polygon": "^7.3.4",
"@turf/helpers": "^7.3.4", "@turf/helpers": "^7.3.4",
@ -915,6 +918,33 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "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": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"dev": true, "dev": true,

파일 보기

@ -13,6 +13,9 @@
"@deck.gl/core": "^9.2.11", "@deck.gl/core": "^9.2.11",
"@deck.gl/layers": "^9.2.11", "@deck.gl/layers": "^9.2.11",
"@deck.gl/mapbox": "^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", "@tailwindcss/vite": "^4.2.1",
"@turf/boolean-point-in-polygon": "^7.3.4", "@turf/boolean-point-in-polygon": "^7.3.4",
"@turf/helpers": "^7.3.4", "@turf/helpers": "^7.3.4",

파일 보기

@ -20,7 +20,7 @@
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
letter-spacing: 1.5px; letter-spacing: 1.5px;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
/* Map mode toggle */ /* Map mode toggle */
@ -47,7 +47,7 @@
color: var(--kcg-dim); color: var(--kcg-dim);
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.map-mode-btn:hover { .map-mode-btn:hover {
@ -79,7 +79,7 @@
.count-item { .count-item {
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
letter-spacing: 0.5px; letter-spacing: 0.5px;
padding: 2px 8px; padding: 2px 8px;
border-radius: 3px; border-radius: 3px;
@ -103,7 +103,7 @@
color: var(--kcg-text-secondary); color: var(--kcg-text-secondary);
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
letter-spacing: 0.5px; letter-spacing: 0.5px;
padding: 2px 8px; padding: 2px 8px;
border-radius: 4px; border-radius: 4px;
@ -126,7 +126,7 @@
font-weight: 700; font-weight: 700;
letter-spacing: 1px; letter-spacing: 1px;
color: var(--text-secondary); color: var(--text-secondary);
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.status-dot { .status-dot {
@ -240,7 +240,7 @@
letter-spacing: 1.5px; letter-spacing: 1.5px;
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: 8px; margin-bottom: 8px;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.layer-items { .layer-items {
@ -299,7 +299,7 @@
align-items: center; align-items: center;
padding: 2px 8px; padding: 2px 8px;
font-size: 10px; font-size: 10px;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.stat-cat { .stat-cat {
@ -318,7 +318,7 @@
letter-spacing: 1px; letter-spacing: 1px;
color: var(--text-secondary); color: var(--text-secondary);
padding: 2px 8px; padding: 2px 8px;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
/* Layer tree */ /* Layer tree */
@ -434,7 +434,7 @@
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
white-space: nowrap; white-space: nowrap;
} }
.dash-tab:hover { .dash-tab:hover {
@ -465,7 +465,7 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--text-secondary); color: var(--text-secondary);
border-bottom: 1px solid var(--kcg-border); border-bottom: 1px solid var(--kcg-border);
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.event-list { .event-list {
@ -504,7 +504,7 @@
white-space: nowrap; white-space: nowrap;
height: fit-content; height: fit-content;
margin-top: 2px; margin-top: 2px;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.event-content { .event-content {
@ -521,7 +521,7 @@
font-size: 10px; font-size: 10px;
color: var(--text-secondary); color: var(--text-secondary);
margin-top: 1px; margin-top: 1px;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.event-desc { .event-desc {
@ -559,7 +559,7 @@
background: var(--kcg-danger); background: var(--kcg-danger);
padding: 2px 6px; padding: 2px 6px;
border-radius: 2px; 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; animation: flash-pulse 1.5s ease-in-out infinite;
} }
@ -573,7 +573,7 @@
font-weight: 700; font-weight: 700;
color: var(--text-secondary); color: var(--text-secondary);
letter-spacing: 0.5px; letter-spacing: 0.5px;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.breaking-news-list { .breaking-news-list {
@ -616,7 +616,7 @@
.breaking-news-time { .breaking-news-time {
font-size: 9px; font-size: 9px;
color: var(--kcg-dim); color: var(--kcg-dim);
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.breaking-news-headline { .breaking-news-headline {
@ -658,13 +658,13 @@
font-weight: 700; font-weight: 700;
letter-spacing: 1.5px; letter-spacing: 1.5px;
color: var(--kcg-danger); color: var(--kcg-danger);
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.osint-count { .osint-count {
margin-left: auto; margin-left: auto;
font-size: 10px; font-size: 10px;
color: var(--kcg-muted); color: var(--kcg-muted);
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.osint-loading { .osint-loading {
margin-left: auto; margin-left: auto;
@ -722,7 +722,7 @@
font-size: 9px; font-size: 9px;
color: var(--kcg-dim); color: var(--kcg-dim);
white-space: nowrap; white-space: nowrap;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.osint-item-title { .osint-item-title {
font-size: 11px; font-size: 11px;
@ -757,10 +757,17 @@
.area-ship-header { .area-ship-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
margin-bottom: 6px; margin-bottom: 6px;
padding-bottom: 6px; padding-bottom: 6px;
border-bottom: 1px solid var(--kcg-hover); 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 { .korean-highlight-toggle {
@ -768,7 +775,7 @@
padding: 1px 8px; padding: 1px 8px;
font-size: 9px; font-size: 9px;
font-weight: 700; font-weight: 700;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
border-radius: 3px; border-radius: 3px;
border: 1px solid var(--kcg-border); border: 1px solid var(--kcg-border);
background: transparent; background: transparent;
@ -793,7 +800,11 @@
font-weight: 700; font-weight: 700;
letter-spacing: 0.5px; letter-spacing: 0.5px;
color: var(--text-secondary); 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 { .area-ship-total {
@ -801,7 +812,9 @@
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: #fb923c; 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 { .kr-ship-header {
@ -820,7 +833,7 @@
font-weight: 700; font-weight: 700;
letter-spacing: 0.5px; letter-spacing: 0.5px;
color: var(--text-primary); color: var(--text-primary);
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.kr-total { .kr-total {
@ -828,7 +841,7 @@
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: var(--kcg-accent); color: var(--kcg-accent);
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.kr-ship-breakdown { .kr-ship-breakdown {
@ -859,7 +872,7 @@
flex: 1; flex: 1;
font-size: 10px; font-size: 10px;
color: var(--text-secondary); color: var(--text-secondary);
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.kr-count { .kr-count {
@ -867,7 +880,7 @@
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
text-align: right; text-align: right;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.kr-ship-list { .kr-ship-list {
@ -881,7 +894,7 @@
gap: 6px; gap: 6px;
padding: 2px 0; padding: 2px 0;
font-size: 9px; font-size: 9px;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.kr-ship-name { .kr-ship-name {
@ -1026,7 +1039,7 @@
.korea-stat-num { .korea-stat-num {
font-size: 20px; font-size: 20px;
font-weight: 800; 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.total .korea-stat-num { color: var(--kcg-accent); }
.korea-stat-card.anchored .korea-stat-num { color: var(--kcg-danger); } .korea-stat-card.anchored .korea-stat-num { color: var(--kcg-danger); }
@ -1049,7 +1062,7 @@
letter-spacing: 1px; letter-spacing: 1px;
text-transform: uppercase; text-transform: uppercase;
margin-bottom: 4px; margin-bottom: 4px;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.korea-ship-section { .korea-ship-section {
flex: 1; flex: 1;
@ -1067,7 +1080,7 @@
color: var(--kcg-muted); color: var(--kcg-muted);
border-bottom: 1px solid var(--kcg-border); border-bottom: 1px solid var(--kcg-border);
flex-shrink: 0; flex-shrink: 0;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.korea-ship-section-count { .korea-ship-section-count {
color: var(--kcg-accent); color: var(--kcg-accent);
@ -1131,7 +1144,7 @@
.korea-ship-card-speed { .korea-ship-card-speed {
margin-left: auto; margin-left: auto;
color: var(--kcg-muted); color: var(--kcg-muted);
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.korea-ship-card-dest { .korea-ship-card-dest {
font-size: 9px; font-size: 9px;
@ -1161,7 +1174,7 @@
letter-spacing: 1.5px; letter-spacing: 1.5px;
text-transform: uppercase; text-transform: uppercase;
color: var(--text-secondary); color: var(--text-secondary);
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.chart-grid { .chart-grid {
@ -1177,7 +1190,7 @@
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: 0; margin-bottom: 0;
padding-left: 4px; padding-left: 4px;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.chart-demo-label { .chart-demo-label {
@ -1193,7 +1206,7 @@
.ship-popup-body { .ship-popup-body {
width: 300px; width: 300px;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-size: 11px; font-size: 11px;
line-height: 1.4; line-height: 1.4;
} }
@ -1347,12 +1360,12 @@
} }
.popup-body { .popup-body {
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-size: 12px; font-size: 12px;
} }
.popup-body-sm { .popup-body-sm {
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-size: 11px; font-size: 11px;
min-width: 220px; min-width: 220px;
} }
@ -1446,7 +1459,7 @@
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.speed-btn:hover { .speed-btn:hover {
@ -1485,7 +1498,7 @@
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.range-btn:hover { .range-btn:hover {
@ -1537,7 +1550,7 @@
font-weight: 700; font-weight: 700;
letter-spacing: 1px; letter-spacing: 1px;
color: var(--text-secondary); 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"] { .range-picker input[type="datetime-local"] {
@ -1547,7 +1560,7 @@
color: var(--text-primary); color: var(--text-primary);
padding: 4px 8px; padding: 4px 8px;
font-size: 11px; font-size: 11px;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
outline: none; outline: none;
transition: border-color 0.15s; transition: border-color 0.15s;
} }
@ -1571,7 +1584,7 @@
background: rgba(34, 197, 94, 0.15); background: rgba(34, 197, 94, 0.15);
color: var(--kcg-success); color: var(--kcg-success);
cursor: pointer; cursor: pointer;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
transition: all 0.15s; transition: all 0.15s;
white-space: nowrap; white-space: nowrap;
} }
@ -1591,7 +1604,7 @@
font-size: 10px; font-size: 10px;
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: 2px; margin-bottom: 2px;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.timeline-current { .timeline-current {
@ -1727,7 +1740,7 @@
font-size: 9px; font-size: 9px;
font-weight: 700; font-weight: 700;
color: var(--kcg-dim); color: var(--kcg-dim);
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
flex-shrink: 0; flex-shrink: 0;
} }
@ -1774,7 +1787,7 @@
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: 2px; margin-bottom: 2px;
padding-left: 8px; 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 */ /* Aircraft, Satellite, Ship & Oil Tooltips - override Leaflet */
@ -1788,7 +1801,7 @@
border-radius: 3px !important; border-radius: 3px !important;
padding: 2px 6px !important; padding: 2px 6px !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5) !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, .aircraft-tooltip::before,
@ -1862,7 +1875,7 @@
border-radius: 3px !important; border-radius: 3px !important;
padding: 2px 6px !important; padding: 2px 6px !important;
box-shadow: 0 2px 12px rgba(255, 0, 0, 0.4) !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 { .impact-tooltip::before {
@ -1924,7 +1937,7 @@
pointer-events: none; pointer-events: none;
font-weight: 600; font-weight: 600;
font-size: 11px; 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); text-shadow: var(--kcg-map-label-shadow);
background: var(--kcg-glass); background: var(--kcg-glass);
border: 1px solid var(--kcg-border); border: 1px solid var(--kcg-border);
@ -1942,7 +1955,7 @@
color: var(--kcg-event-impact); color: var(--kcg-event-impact);
font-weight: 700; font-weight: 700;
font-size: 10px; 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); text-shadow: var(--kcg-map-impact-shadow);
background: rgba(40, 0, 0, 0.85); background: rgba(40, 0, 0, 0.85);
border: 1px solid var(--kcg-event-impact); border: 1px solid var(--kcg-event-impact);
@ -2038,7 +2051,7 @@
color: var(--kcg-dim); color: var(--kcg-dim);
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.mode-btn:hover { .mode-btn:hover {
@ -2100,14 +2113,14 @@
font-weight: 700; font-weight: 700;
letter-spacing: 2px; letter-spacing: 2px;
color: var(--kcg-danger); color: var(--kcg-danger);
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.live-clock { .live-clock {
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
letter-spacing: 1px; letter-spacing: 1px;
padding: 4px 12px; padding: 4px 12px;
background: var(--kcg-hover); background: var(--kcg-hover);
@ -2126,7 +2139,7 @@
font-weight: 700; font-weight: 700;
letter-spacing: 1.5px; letter-spacing: 1.5px;
color: var(--text-secondary); color: var(--text-secondary);
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.history-presets { .history-presets {
@ -2144,7 +2157,7 @@
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
} }
.history-btn:hover { .history-btn:hover {
@ -2169,7 +2182,7 @@
padding: 1px 6px; padding: 1px 6px;
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
border: none; border: none;
background: transparent; background: transparent;
color: var(--text-secondary); color: var(--text-secondary);
@ -2335,7 +2348,7 @@
max-height: 80vh; max-height: 80vh;
overflow: auto; overflow: auto;
color: var(--kcg-text, #e2e8f0); color: var(--kcg-text, #e2e8f0);
font-family: monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
font-size: 13px; font-size: 13px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5); box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
} }

파일 보기

@ -1,6 +1,7 @@
import { IconLayer, TextLayer } from '@deck.gl/layers'; import { IconLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo, Layer } from '@deck.gl/core'; import type { PickingInfo, Layer } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri'; import { svgToDataUri } from '../../utils/svgToDataUri';
import { FONT_MONO } from '../../styles/fonts';
import { middleEastAirports } from '../../data/airports'; import { middleEastAirports } from '../../data/airports';
import type { Airport } from '../../data/airports'; import type { Airport } from '../../data/airports';
@ -42,9 +43,9 @@ export const IRAN_AIRPORT_COUNT = DEDUPLICATED_AIRPORTS.length;
// ─── Colors ────────────────────────────────────────────────────────────────── // ─── Colors ──────────────────────────────────────────────────────────────────
function getAirportColor(airport: Airport): string { function getAirportColor(airport: Airport): string {
if (isUSBase(airport)) return '#3b82f6'; if (isUSBase(airport)) return '#60a5fa';
if (airport.type === 'military') return '#ef4444'; if (airport.type === 'military') return '#f87171';
return '#f59e0b'; return '#38bdf8';
} }
// ─── SVG generators ────────────────────────────────────────────────────────── // ─── SVG generators ──────────────────────────────────────────────────────────
@ -140,7 +141,7 @@ export function createIranAirportLayers(config: AirportLayerConfig): Layer[] {
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 10], getPixelOffset: [0, 10],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 700, fontWeight: 700,
outlineWidth: 2, outlineWidth: 2,
outlineColor: [0, 0, 0, 200], outlineColor: [0, 0, 0, 200],

파일 보기

@ -141,23 +141,23 @@ const IranDashboard = ({
}, },
{ key: 'events', label: t('layers.events'), color: '#a855f7' }, { 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: '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: 'airports', label: t('layers.airports'), color: '#38bdf8', count: IRAN_AIRPORT_COUNT },
{ key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#d97706', count: IRAN_OIL_COUNT }, { key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#fb7185', count: IRAN_OIL_COUNT },
{ key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: ME_FACILITY_COUNT }, { key: 'meFacilities', label: '주요시설/군사', color: '#f87171', count: ME_FACILITY_COUNT },
{ key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' }, { key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' },
{ {
key: 'overseas', label: '해외시설', color: '#f97316', key: 'overseas', label: '해외시설', color: '#c084fc',
children: [ children: [
{ key: 'overseasUS', label: '🇺🇸 미국', color: '#3b82f6', count: meCountByCountry('us') }, { key: 'overseasUS', label: '🇺🇸 미국', color: '#60a5fa', count: meCountByCountry('us') },
{ key: 'overseasIsrael', label: '🇮🇱 이스라엘', color: '#dc2626', count: meCountByCountry('il') }, { key: 'overseasIsrael', label: '🇮🇱 이스라엘', color: '#ef4444', count: meCountByCountry('il') },
{ key: 'overseasIran', label: '🇮🇷 이란', color: '#22c55e', count: meCountByCountry('ir') }, { key: 'overseasIran', label: '🇮🇷 이란', color: '#34d399', count: meCountByCountry('ir') },
{ key: 'overseasUAE', label: '🇦🇪 UAE', color: '#f59e0b', count: meCountByCountry('ae') }, { key: 'overseasUAE', label: '🇦🇪 UAE', color: '#38bdf8', count: meCountByCountry('ae') },
{ key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#84cc16', count: meCountByCountry('sa') }, { key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#a3e635', count: meCountByCountry('sa') },
{ key: 'overseasOman', label: '🇴🇲 오만', color: '#e11d48', count: meCountByCountry('om') }, { key: 'overseasOman', label: '🇴🇲 오만', color: '#fb7185', count: meCountByCountry('om') },
{ key: 'overseasQatar', label: '🇶🇦 카타르', color: '#8b5cf6', count: meCountByCountry('qa') }, { key: 'overseasQatar', label: '🇶🇦 카타르', color: '#a78bfa', count: meCountByCountry('qa') },
{ key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f97316', count: meCountByCountry('kw') }, { key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f472b6', count: meCountByCountry('kw') },
{ key: 'overseasIraq', label: '🇮🇶 이라크', color: '#65a30d', count: meCountByCountry('iq') }, { key: 'overseasIraq', label: '🇮🇶 이라크', color: '#86efac', count: meCountByCountry('iq') },
{ key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#e11d48', count: meCountByCountry('bh') }, { key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#f87171', count: meCountByCountry('bh') },
], ],
}, },
], [iranData, t, meCountByCountry]); ], [iranData, t, meCountByCountry]);

파일 보기

@ -1,6 +1,7 @@
import { IconLayer, TextLayer } from '@deck.gl/layers'; import { IconLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo, Layer } from '@deck.gl/core'; import type { PickingInfo, Layer } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri'; import { svgToDataUri } from '../../utils/svgToDataUri';
import { FONT_MONO } from '../../styles/fonts';
import { import {
ME_ENERGY_HAZARD_FACILITIES, ME_ENERGY_HAZARD_FACILITIES,
SUB_TYPE_META, 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, getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo,
getSize: 12 * sc * fs, getSize: 12 * sc * fs,
updateTriggers: { getSize: [sc] }, updateTriggers: { getSize: [sc] },
getColor: (d) => hexToRgba(SUB_TYPE_META[d.subType].color, 200), getColor: (d) => hexToRgba(SUB_TYPE_META[d.subType].color),
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 12], getPixelOffset: [0, 12],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 600, fontWeight: 700,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,
outlineColor: [0, 0, 0, 255], outlineColor: [0, 0, 0, 255],

파일 보기

@ -1,6 +1,7 @@
import { IconLayer, TextLayer } from '@deck.gl/layers'; import { IconLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo, Layer } from '@deck.gl/core'; import type { PickingInfo, Layer } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri'; import { svgToDataUri } from '../../utils/svgToDataUri';
import { FONT_MONO } from '../../styles/fonts';
import { ME_FACILITIES } from '../../data/middleEastFacilities'; import { ME_FACILITIES } from '../../data/middleEastFacilities';
import type { MEFacility } from '../../data/middleEastFacilities'; import type { MEFacility } from '../../data/middleEastFacilities';
@ -9,12 +10,12 @@ export const ME_FACILITY_COUNT = ME_FACILITIES.length;
// ─── Type colors ────────────────────────────────────────────────────────────── // ─── Type colors ──────────────────────────────────────────────────────────────
const TYPE_COLORS: Record<MEFacility['type'], string> = { const TYPE_COLORS: Record<MEFacility['type'], string> = {
naval: '#3b82f6', naval: '#60a5fa',
military_hq: '#ef4444', military_hq: '#f87171',
missile: '#dc2626', missile: '#ef4444',
intelligence: '#8b5cf6', intelligence: '#a78bfa',
government: '#f59e0b', government: '#c084fc',
radar: '#06b6d4', radar: '#22d3ee',
}; };
// ─── SVG generators ────────────────────────────────────────────────────────── // ─── SVG generators ──────────────────────────────────────────────────────────
@ -168,7 +169,7 @@ export function createMEFacilityLayers(config: MEFacilityLayerConfig): Layer[] {
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 10], getPixelOffset: [0, 10],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 700, fontWeight: 700,
outlineWidth: 2, outlineWidth: 2,
outlineColor: [0, 0, 0, 200], outlineColor: [0, 0, 0, 200],

파일 보기

@ -1,6 +1,7 @@
import { IconLayer, TextLayer } from '@deck.gl/layers'; import { IconLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo, Layer } from '@deck.gl/core'; import type { PickingInfo, Layer } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri'; import { svgToDataUri } from '../../utils/svgToDataUri';
import { FONT_MONO } from '../../styles/fonts';
import { iranOilFacilities } from '../../data/oilFacilities'; import { iranOilFacilities } from '../../data/oilFacilities';
import type { OilFacility, OilFacilityType } from '../../types'; import type { OilFacility, OilFacilityType } from '../../types';
@ -9,12 +10,12 @@ export const IRAN_OIL_COUNT = iranOilFacilities.length;
// ─── Type colors ────────────────────────────────────────────────────────────── // ─── Type colors ──────────────────────────────────────────────────────────────
const TYPE_COLORS: Record<OilFacilityType, string> = { const TYPE_COLORS: Record<OilFacilityType, string> = {
refinery: '#f59e0b', refinery: '#fb7185',
oilfield: '#10b981', oilfield: '#34d399',
gasfield: '#6366f1', gasfield: '#818cf8',
terminal: '#ec4899', terminal: '#c084fc',
petrochemical: '#8b5cf6', petrochemical: '#f472b6',
desalination: '#06b6d4', desalination: '#22d3ee',
}; };
// ─── SVG generators ────────────────────────────────────────────────────────── // ─── SVG generators ──────────────────────────────────────────────────────────
@ -241,7 +242,7 @@ export function createIranOilLayers(config: OilLayerConfig): Layer[] {
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 10], getPixelOffset: [0, 10],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 700, fontWeight: 700,
outlineWidth: 2, outlineWidth: 2,
outlineColor: [0, 0, 0, 200], outlineColor: [0, 0, 0, 200],

파일 보기

@ -529,8 +529,8 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
const { kind, data } = iranPickedFacility; const { kind, data } = iranPickedFacility;
if (kind === 'oil') { if (kind === 'oil') {
const OIL_TYPE_COLORS: Record<string, string> = { const OIL_TYPE_COLORS: Record<string, string> = {
refinery: '#f59e0b', oilfield: '#10b981', gasfield: '#6366f1', refinery: '#fb7185', oilfield: '#34d399', gasfield: '#818cf8',
terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4', terminal: '#c084fc', petrochemical: '#f472b6', desalination: '#22d3ee',
}; };
const color = OIL_TYPE_COLORS[data.type] ?? '#888'; const color = OIL_TYPE_COLORS[data.type] ?? '#888';
return ( return (
@ -568,13 +568,13 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
} }
if (kind === 'airport') { if (kind === 'airport') {
const isMil = data.type === 'military'; const isMil = data.type === 'military';
const color = isMil ? '#ef4444' : '#f59e0b'; const color = isMil ? '#f87171' : '#38bdf8';
return ( return (
<Popup longitude={data.lng} latitude={data.lat} <Popup longitude={data.lng} latitude={data.lat}
onClose={() => setIranPickedFacility(null)} closeOnClick={false} onClose={() => setIranPickedFacility(null)} closeOnClick={false}
anchor="bottom" maxWidth="300px" className="gl-popup"> anchor="bottom" maxWidth="300px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 240 }}> <div className="popup-body-sm" style={{ minWidth: 240 }}>
<div className="popup-header" style={{ background: isMil ? '#991b1b' : '#92400e', color: '#fff', gap: 6, padding: '6px 10px' }}> <div className="popup-header" style={{ background: isMil ? '#991b1b' : '#0369a1', color: '#fff', gap: 6, padding: '6px 10px' }}>
<strong style={{ fontSize: 13 }}>{data.nameKo ?? data.name}</strong> <strong style={{ fontSize: 13 }}>{data.nameKo ?? data.name}</strong>
</div> </div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
@ -598,7 +598,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
); );
} }
if (kind === 'meFacility') { 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 ( return (
<Popup longitude={data.lng} latitude={data.lat} <Popup longitude={data.lng} latitude={data.lat}
onClose={() => setIranPickedFacility(null)} closeOnClick={false} onClose={() => setIranPickedFacility(null)} closeOnClick={false}

파일 보기

@ -359,8 +359,8 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
const { kind, data } = iranPickedFacility; const { kind, data } = iranPickedFacility;
if (kind === 'oil') { if (kind === 'oil') {
const OIL_TYPE_COLORS: Record<string, string> = { const OIL_TYPE_COLORS: Record<string, string> = {
refinery: '#f59e0b', oilfield: '#10b981', gasfield: '#6366f1', refinery: '#fb7185', oilfield: '#34d399', gasfield: '#818cf8',
terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4', terminal: '#c084fc', petrochemical: '#f472b6', desalination: '#22d3ee',
}; };
const color = OIL_TYPE_COLORS[data.type] ?? '#888'; const color = OIL_TYPE_COLORS[data.type] ?? '#888';
return ( return (
@ -398,13 +398,13 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
} }
if (kind === 'airport') { if (kind === 'airport') {
const isMil = data.type === 'military'; const isMil = data.type === 'military';
const color = isMil ? '#ef4444' : '#f59e0b'; const color = isMil ? '#f87171' : '#38bdf8';
return ( return (
<Popup longitude={data.lng} latitude={data.lat} <Popup longitude={data.lng} latitude={data.lat}
onClose={() => setIranPickedFacility(null)} closeOnClick={false} onClose={() => setIranPickedFacility(null)} closeOnClick={false}
anchor="bottom" maxWidth="300px" className="gl-popup"> anchor="bottom" maxWidth="300px" className="gl-popup">
<div className="popup-body-sm" style={{ minWidth: 240 }}> <div className="popup-body-sm" style={{ minWidth: 240 }}>
<div className="popup-header" style={{ background: isMil ? '#991b1b' : '#92400e', color: '#fff', gap: 6, padding: '6px 10px' }}> <div className="popup-header" style={{ background: isMil ? '#991b1b' : '#0369a1', color: '#fff', gap: 6, padding: '6px 10px' }}>
<strong style={{ fontSize: 13 }}>{data.nameKo ?? data.name}</strong> <strong style={{ fontSize: 13 }}>{data.nameKo ?? data.name}</strong>
</div> </div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
@ -428,7 +428,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
); );
} }
if (kind === 'meFacility') { 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 ( return (
<Popup longitude={data.lng} latitude={data.lat} <Popup longitude={data.lng} latitude={data.lat}
onClose={() => setIranPickedFacility(null)} closeOnClick={false} onClose={() => setIranPickedFacility(null)} closeOnClick={false}

파일 보기

@ -3,6 +3,7 @@ import type { Layer, PickingInfo } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri'; import { svgToDataUri } from '../../utils/svgToDataUri';
import { middleEastAirports } from '../../data/airports'; import { middleEastAirports } from '../../data/airports';
import type { Airport } from '../../data/airports'; import type { Airport } from '../../data/airports';
import { FONT_MONO } from '../../styles/fonts';
export { type Airport }; export { type Airport };
@ -15,10 +16,10 @@ const US_BASE_ICAOS = new Set([
function getAirportColor(airport: Airport): string { function getAirportColor(airport: Airport): string {
const isMil = airport.type === 'military'; const isMil = airport.type === 'military';
const isUS = isMil && US_BASE_ICAOS.has(airport.icao); const isUS = isMil && US_BASE_ICAOS.has(airport.icao);
if (isUS) return '#3b82f6'; if (isUS) return '#60a5fa'; // blue-400
if (isMil) return '#ef4444'; if (isMil) return '#f87171'; // red-400
if (airport.type === 'international') return '#f59e0b'; if (airport.type === 'international') return '#38bdf8'; // sky-400 (was amber)
return '#7c8aaa'; return '#a5b4fc'; // indigo-200 (was gray)
} }
function airportSvg(color: string, size: number): string { function airportSvg(color: string, size: number): string {
@ -92,8 +93,8 @@ export function createIranAirportLayers(config: IranAirportLayerConfig): Layer[]
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 10], getPixelOffset: [0, 10],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 600, fontWeight: 700,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,
outlineColor: [0, 0, 0, 255], outlineColor: [0, 0, 0, 255],

파일 보기

@ -3,18 +3,19 @@ import type { Layer, PickingInfo } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri'; import { svgToDataUri } from '../../utils/svgToDataUri';
import { iranOilFacilities } from '../../data/oilFacilities'; import { iranOilFacilities } from '../../data/oilFacilities';
import type { OilFacility, OilFacilityType } from '../../types'; import type { OilFacility, OilFacilityType } from '../../types';
import { FONT_MONO } from '../../styles/fonts';
export { type OilFacility }; export { type OilFacility };
export const IRAN_OIL_COUNT = iranOilFacilities.length; export const IRAN_OIL_COUNT = iranOilFacilities.length;
const TYPE_COLORS: Record<OilFacilityType, string> = { const TYPE_COLORS: Record<OilFacilityType, string> = {
refinery: '#f59e0b', refinery: '#fb7185', // rose-400 (was amber — desert에 묻힘)
oilfield: '#10b981', oilfield: '#34d399', // emerald-400
gasfield: '#6366f1', gasfield: '#818cf8', // indigo-400
terminal: '#ec4899', terminal: '#c084fc', // purple-400
petrochemical: '#8b5cf6', petrochemical: '#f472b6', // pink-400
desalination: '#06b6d4', desalination: '#22d3ee', // cyan-400
}; };
function refinerySvg(color: string, size: number): string { function refinerySvg(color: string, size: number): string {
@ -142,8 +143,8 @@ export function createIranOilLayers(config: IranOilLayerConfig): Layer[] {
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 10], getPixelOffset: [0, 10],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 600, fontWeight: 700,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,
outlineColor: [0, 0, 0, 255], outlineColor: [0, 0, 0, 255],

파일 보기

@ -3,6 +3,7 @@ import type { Layer, PickingInfo } from '@deck.gl/core';
import { svgToDataUri } from '../../utils/svgToDataUri'; import { svgToDataUri } from '../../utils/svgToDataUri';
import { ME_FACILITIES, ME_FACILITY_TYPE_META } from '../../data/middleEastFacilities'; import { ME_FACILITIES, ME_FACILITY_TYPE_META } from '../../data/middleEastFacilities';
import type { MEFacility } from '../../data/middleEastFacilities'; import type { MEFacility } from '../../data/middleEastFacilities';
import { FONT_MONO } from '../../styles/fonts';
export { type MEFacility }; 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, getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo,
getSize: 12 * sc * fs, getSize: 12 * sc * fs,
updateTriggers: { getSize: [sc] }, updateTriggers: { getSize: [sc] },
getColor: (d) => hexToRgba(ME_FACILITY_TYPE_META[d.type].color, 200), getColor: (d) => hexToRgba(ME_FACILITY_TYPE_META[d.type].color),
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 12], getPixelOffset: [0, 12],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 600, fontWeight: 700,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,
outlineColor: [0, 0, 0, 255], outlineColor: [0, 0, 0, 255],

파일 보기

@ -1,5 +1,6 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import type { VesselAnalysisDto, RiskLevel, Ship } from '../../types'; import type { VesselAnalysisDto, RiskLevel, Ship } from '../../types';
import { FONT_MONO } from '../../styles/fonts';
import type { AnalysisStats } from '../../hooks/useVesselAnalysis'; import type { AnalysisStats } from '../../hooks/useVesselAnalysis';
import { useLocalStorage } from '../../hooks/useLocalStorage'; import { useLocalStorage } from '../../hooks/useLocalStorage';
import { fetchVesselTrack } from '../../services/vesselTrack'; 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)', border: '1px solid rgba(99, 179, 237, 0.25)',
borderRadius: 8, borderRadius: 8,
color: '#e2e8f0', color: '#e2e8f0',
fontFamily: 'monospace, sans-serif', fontFamily: FONT_MONO,
fontSize: 11, fontSize: 11,
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)', boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
overflow: 'hidden', overflow: 'hidden',
@ -311,7 +312,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
fontSize: 10, fontSize: 10,
cursor: 'pointer', cursor: 'pointer',
padding: '2px 4px', padding: '2px 4px',
fontFamily: 'monospace, sans-serif', fontFamily: FONT_MONO,
}} }}
> >
<span>{RISK_EMOJI[level]}</span> <span>{RISK_EMOJI[level]}</span>

파일 보기

@ -1,5 +1,6 @@
import { useState, useMemo, useEffect, useCallback } from 'react'; import { useState, useMemo, useEffect, useCallback } from 'react';
import type { Ship, ChnPrmShipInfo, VesselAnalysisDto } from '../../types'; import type { Ship, ChnPrmShipInfo, VesselAnalysisDto } from '../../types';
import { FONT_MONO } from '../../styles/fonts';
import { classifyFishingZone } from '../../utils/fishingAnalysis'; import { classifyFishingZone } from '../../utils/fishingAnalysis';
import { getMarineTrafficCategory } from '../../utils/marineTraffic'; import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { lookupPermittedShip } from '../../services/chnPrmShip'; import { lookupPermittedShip } from '../../services/chnPrmShip';
@ -330,7 +331,7 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
position: 'absolute', inset: 0, zIndex: 2000, position: 'absolute', inset: 0, zIndex: 2000,
background: 'rgba(2,6,14,0.96)', background: 'rgba(2,6,14,0.96)',
display: 'flex', flexDirection: 'column', display: 'flex', flexDirection: 'column',
fontFamily: "'IBM Plex Mono', 'Noto Sans KR', monospace", fontFamily: FONT_MONO,
}}> }}>
{/* ── 헤더 */} {/* ── 헤더 */}
<div style={{ <div style={{

파일 보기

@ -1,5 +1,6 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Source, Layer, Popup, useMap } from 'react-map-gl/maplibre'; import { Source, Layer, Popup, useMap } from 'react-map-gl/maplibre';
import { FONT_MONO } from '../../styles/fonts';
import type { GeoJSON } from 'geojson'; import type { GeoJSON } from 'geojson';
import type { MapLayerMouseEvent } from 'maplibre-gl'; import type { MapLayerMouseEvent } from 'maplibre-gl';
import type { Ship, VesselAnalysisDto } from '../../types'; import type { Ship, VesselAnalysisDto } from '../../types';
@ -458,7 +459,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
border: '1px solid rgba(99, 179, 237, 0.25)', border: '1px solid rgba(99, 179, 237, 0.25)',
borderRadius: 8, borderRadius: 8,
color: '#e2e8f0', color: '#e2e8f0',
fontFamily: 'monospace, sans-serif', fontFamily: FONT_MONO,
fontSize: 11, fontSize: 11,
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)', boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
overflow: 'hidden', overflow: 'hidden',
@ -599,7 +600,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
closeButton={false} closeOnClick={false} anchor="bottom" closeButton={false} closeOnClick={false} anchor="bottom"
className="gl-popup" maxWidth="220px" className="gl-popup" maxWidth="220px"
> >
<div style={{ fontFamily: 'monospace', fontSize: 10, padding: 4, color: '#e2e8f0' }}> <div style={{ fontFamily: FONT_MONO, fontSize: 10, padding: 4, color: '#e2e8f0' }}>
<div style={{ fontWeight: 700, color: clusterColor(cid), marginBottom: 3 }}> <div style={{ fontWeight: 700, color: clusterColor(cid), marginBottom: 3 }}>
{company?.nameCn || `선단 #${cid}`} {company?.nameCn || `선단 #${cid}`}
</div> </div>
@ -628,7 +629,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
closeButton={false} closeOnClick={false} anchor="bottom" closeButton={false} closeOnClick={false} anchor="bottom"
className="gl-popup" maxWidth="220px" className="gl-popup" maxWidth="220px"
> >
<div style={{ fontFamily: 'monospace', fontSize: 10, padding: 4, color: '#e2e8f0' }}> <div style={{ fontFamily: FONT_MONO, fontSize: 10, padding: 4, color: '#e2e8f0' }}>
<div style={{ fontWeight: 700, color: '#f97316', marginBottom: 3 }}> <div style={{ fontWeight: 700, color: '#f97316', marginBottom: 3 }}>
{name} <span style={{ fontWeight: 400, color: '#94a3b8' }}> {entry.gears.length}</span> {name} <span style={{ fontWeight: 400, color: '#94a3b8' }}> {entry.gears.length}</span>
</div> </div>

파일 보기

@ -4,6 +4,7 @@ import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/mapl
import type { MapRef } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre';
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers'; import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import { useFontScale } from '../../hooks/useFontScale'; import { useFontScale } from '../../hooks/useFontScale';
import { FONT_MONO } from '../../styles/fonts';
import { DeckGLOverlay } from '../layers/DeckGLOverlay'; import { DeckGLOverlay } from '../layers/DeckGLOverlay';
import { useAnalysisDeckLayers } from '../../hooks/useAnalysisDeckLayers'; import { useAnalysisDeckLayers } from '../../hooks/useAnalysisDeckLayers';
import { useStaticDeckLayers } from '../../hooks/useStaticDeckLayers'; import { useStaticDeckLayers } from '../../hooks/useStaticDeckLayers';
@ -249,7 +250,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 14], getPixelOffset: [0, 14],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,
outlineColor: [0, 0, 0, 255], outlineColor: [0, 0, 0, 255],
@ -287,7 +288,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
getColor: [255, 255, 255, 220], getColor: [255, 255, 255, 220],
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'center', getAlignmentBaseline: 'center',
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 700, fontWeight: 700,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,
@ -364,7 +365,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
getTextAnchor: 'middle' as const, getTextAnchor: 'middle' as const,
getAlignmentBaseline: 'top' as const, getAlignmentBaseline: 'top' as const,
getPixelOffset: [0, 10], getPixelOffset: [0, 10],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,
outlineColor: [0, 0, 0, 220], outlineColor: [0, 0, 0, 220],
@ -399,7 +400,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
getTextAnchor: 'middle' as const, getTextAnchor: 'middle' as const,
getAlignmentBaseline: 'top' as const, getAlignmentBaseline: 'top' as const,
getPixelOffset: [0, 18], getPixelOffset: [0, 18],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 700, fontWeight: 700,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,
@ -464,7 +465,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
getTextAnchor: 'middle' as const, getTextAnchor: 'middle' as const,
getAlignmentBaseline: 'top' as const, getAlignmentBaseline: 'top' as const,
getPixelOffset: [0, 12], getPixelOffset: [0, 12],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 600, fontWeight: 600,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,

파일 보기

@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre'; 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 { KOREA_SUBMARINE_CABLES, KOREA_LANDING_POINTS } from '../../services/submarineCable';
import type { SubmarineCable } from '../../services/submarineCable'; import type { SubmarineCable } from '../../services/submarineCable';
@ -90,7 +91,7 @@ export function SubmarineCableLayer() {
<Marker key={`label-${cable.id}`} longitude={mid[0]} latitude={mid[1]} anchor="center" <Marker key={`label-${cable.id}`} longitude={mid[0]} latitude={mid[1]} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelectedCable(cable); setSelectedPoint(null); }}> onClick={(e) => { e.originalEvent.stopPropagation(); setSelectedCable(cable); setSelectedPoint(null); }}>
<div style={{ <div style={{
fontSize: 7, fontFamily: 'monospace', fontWeight: 600, fontSize: 7, fontFamily: FONT_MONO, fontWeight: 600,
color: cable.color, cursor: 'pointer', color: cable.color, cursor: 'pointer',
textShadow: '0 0 3px #000, 0 0 3px #000, 0 0 6px #000', textShadow: '0 0 3px #000, 0 0 3px #000, 0 0 6px #000',
whiteSpace: 'nowrap', opacity: 0.8, whiteSpace: 'nowrap', opacity: 0.8,

파일 보기

@ -41,7 +41,7 @@
margin-top: 4px; margin-top: 4px;
white-space: nowrap; white-space: nowrap;
text-align: center; text-align: center;
font-family: 'Courier New', monospace; font-family: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
text-shadow: 0 0 4px rgba(0, 0, 0, 0.9); text-shadow: 0 0 4px rgba(0, 0, 0, 0.9);
pointer-events: none; pointer-events: none;
} }

파일 보기

@ -19,14 +19,14 @@ export interface EnergyHazardFacility {
} }
export const SUB_TYPE_META: Record<FacilitySubType, { label: string; color: string; icon: string }> = { export const SUB_TYPE_META: Record<FacilitySubType, { label: string; color: string; icon: string }> = {
power: { label: '발전소', color: '#a855f7', icon: '⚡' }, power: { label: '발전소', color: '#c084fc', icon: '⚡' }, // purple-400
wind: { label: '풍력단지', color: '#22d3ee', icon: '🌬' }, wind: { label: '풍력단지', color: '#22d3ee', icon: '🌬' }, // cyan-400
nuclear: { label: '원자력발전소', color: '#f59e0b', icon: '☢' }, nuclear: { label: '원자력발전소', color: '#f472b6', icon: '☢' }, // pink-400 (was amber)
thermal: { label: '화력발전소', color: '#64748b', icon: '🏭' }, thermal: { label: '화력발전소', color: '#94a3b8', icon: '🏭' }, // slate-400 (was 500)
petrochem: { label: '석유화학단지', color: '#f97316', icon: '🛢' }, petrochem: { label: '석유화학단지', color: '#fb7185', icon: '🛢' }, // rose-400 (was orange)
lng: { label: 'LNG저장기지', color: '#0ea5e9', icon: '❄' }, lng: { label: 'LNG저장기지', color: '#38bdf8', icon: '❄' }, // sky-400
oil_tank: { label: '유류저장탱크', color: '#eab308', icon: '🛢' }, oil_tank: { label: '유류저장탱크', color: '#a3e635', icon: '🛢' }, // lime-400 (was yellow)
haz_port: { label: '위험물항만하역시설', color: '#dc2626', icon: '⚠' }, haz_port: { label: '위험물항만하역시설', color: '#f87171', icon: '⚠' }, // red-400
}; };
// layer key -> subType mapping // layer key -> subType mapping

파일 보기

@ -14,12 +14,12 @@ export interface MEFacility {
} }
const TYPE_META: Record<string, { label: string; color: string; icon: string }> = { const TYPE_META: Record<string, { label: string; color: string; icon: string }> = {
naval: { label: 'Naval Base', color: '#3b82f6', icon: '⚓' }, naval: { label: 'Naval Base', color: '#60a5fa', icon: '⚓' }, // blue-400
military_hq: { label: 'Military HQ', color: '#ef4444', icon: '⭐' }, military_hq: { label: 'Military HQ', color: '#f87171', icon: '⭐' }, // red-400
missile: { label: 'Missile/Air Defense', color: '#dc2626', icon: '🚀' }, missile: { label: 'Missile/Air Defense', color: '#ef4444', icon: '🚀' }, // red-500
intelligence: { label: 'Intelligence', color: '#8b5cf6', icon: '🔍' }, intelligence: { label: 'Intelligence', color: '#a78bfa', icon: '🔍' }, // violet-400
government: { label: 'Government', color: '#f59e0b', icon: '🏛️' }, government: { label: 'Government', color: '#c084fc', icon: '🏛️' }, // purple-400 (was amber)
radar: { label: 'Radar/C2', color: '#06b6d4', icon: '📡' }, radar: { label: 'Radar/C2', color: '#22d3ee', icon: '📡' }, // cyan-400
}; };
export { TYPE_META as ME_FACILITY_TYPE_META }; export { TYPE_META as ME_FACILITY_TYPE_META };

파일 보기

@ -1,5 +1,9 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare module '@fontsource-variable/inter';
declare module '@fontsource-variable/noto-sans-kr';
declare module '@fontsource-variable/fira-code';
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_SPG_API_KEY?: string; readonly VITE_SPG_API_KEY?: string;
readonly VITE_GOOGLE_CLIENT_ID?: string; readonly VITE_GOOGLE_CLIENT_ID?: string;

파일 보기

@ -14,6 +14,7 @@ import {
type CnFacility, type CnFacility,
type JpFacility, type JpFacility,
} from './types'; } from './types';
import { FONT_MONO } from '../../styles/fonts';
// ─── Infra SVG ──────────────────────────────────────────────────────────────── // ─── Infra SVG ────────────────────────────────────────────────────────────────
@ -359,8 +360,8 @@ export function createFacilityLayers(
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 8], getPixelOffset: [0, 8],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 600, fontWeight: 700,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,
outlineColor: [0, 0, 0, 255], outlineColor: [0, 0, 0, 255],
@ -415,8 +416,8 @@ export function createFacilityLayers(
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 12], getPixelOffset: [0, 12],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 600, fontWeight: 700,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,
outlineColor: [0, 0, 0, 255], outlineColor: [0, 0, 0, 255],
@ -469,8 +470,8 @@ export function createFacilityLayers(
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 12], getPixelOffset: [0, 12],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 600, fontWeight: 700,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,
outlineColor: [0, 0, 0, 255], outlineColor: [0, 0, 0, 255],
@ -522,8 +523,8 @@ export function createFacilityLayers(
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 12], getPixelOffset: [0, 12],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 600, fontWeight: 700,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,
outlineColor: [0, 0, 0, 255], outlineColor: [0, 0, 0, 255],

파일 보기

@ -11,6 +11,7 @@ import { NK_MISSILE_EVENTS } from '../../data/nkMissileEvents';
import type { NKMissileEvent } from '../../data/nkMissileEvents'; import type { NKMissileEvent } from '../../data/nkMissileEvents';
import { hexToRgb } from './types'; import { hexToRgb } from './types';
import type { LayerFactoryConfig } from './types'; import type { LayerFactoryConfig } from './types';
import { FONT_MONO } from '../../styles/fonts';
// ─── NKMissile SVG ──────────────────────────────────────────────────────────── // ─── NKMissile SVG ────────────────────────────────────────────────────────────
@ -318,7 +319,7 @@ export function createMilitaryLayers(
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 9], getPixelOffset: [0, 9],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 700, fontWeight: 700,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,
@ -356,7 +357,7 @@ export function createMilitaryLayers(
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 8], getPixelOffset: [0, 8],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 700, fontWeight: 700,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,
@ -394,7 +395,7 @@ export function createMilitaryLayers(
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 8], getPixelOffset: [0, 8],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 700, fontWeight: 700,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,
@ -486,7 +487,7 @@ export function createMilitaryLayers(
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 10], getPixelOffset: [0, 10],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 700, fontWeight: 700,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,

파일 보기

@ -11,6 +11,7 @@ import { PIRACY_ZONES, PIRACY_LEVEL_COLOR } from '../../services/piracy';
import type { PiracyZone } from '../../services/piracy'; import type { PiracyZone } from '../../services/piracy';
import { hexToRgb } from './types'; import { hexToRgb } from './types';
import type { LayerFactoryConfig } from './types'; import type { LayerFactoryConfig } from './types';
import { FONT_MONO } from '../../styles/fonts';
// ─── CoastGuard ─────────────────────────────────────────────────────────────── // ─── CoastGuard ───────────────────────────────────────────────────────────────
@ -180,7 +181,7 @@ export function createNavigationLayers(
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 8], getPixelOffset: [0, 8],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 700, fontWeight: 700,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,
@ -231,7 +232,7 @@ export function createNavigationLayers(
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 10], getPixelOffset: [0, 10],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 700, fontWeight: 700,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,
@ -281,7 +282,7 @@ export function createNavigationLayers(
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 9], getPixelOffset: [0, 9],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 700, fontWeight: 700,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,
@ -332,7 +333,7 @@ export function createNavigationLayers(
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 14], getPixelOffset: [0, 14],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 700, fontWeight: 700,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,

파일 보기

@ -7,6 +7,7 @@ import { KOREA_WIND_FARMS } from '../../data/windFarms';
import type { WindFarm } from '../../data/windFarms'; import type { WindFarm } from '../../data/windFarms';
import { hexToRgb } from './types'; import { hexToRgb } from './types';
import type { LayerFactoryConfig } from './types'; import type { LayerFactoryConfig } from './types';
import { FONT_MONO } from '../../styles/fonts';
// ─── Port colors ────────────────────────────────────────────────────────────── // ─── Port colors ──────────────────────────────────────────────────────────────
@ -101,7 +102,7 @@ export function createPortLayers(
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 8], getPixelOffset: [0, 8],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 700, fontWeight: 700,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,
@ -139,7 +140,7 @@ export function createPortLayers(
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 10], getPixelOffset: [0, 10],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 700, fontWeight: 700,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,

파일 보기

@ -3,6 +3,7 @@ import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import type { Layer } from '@deck.gl/core'; import type { Layer } from '@deck.gl/core';
import type { Ship, VesselAnalysisDto } from '../types'; import type { Ship, VesselAnalysisDto } from '../types';
import { useFontScale } from './useFontScale'; import { useFontScale } from './useFontScale';
import { FONT_MONO } from '../styles/fonts';
interface AnalyzedShip { interface AnalyzedShip {
ship: Ship; ship: Ship;
@ -132,7 +133,7 @@ export function useAnalysisDeckLayers(
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 16], getPixelOffset: [0, 16],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,
outlineColor: [0, 0, 0, 255], outlineColor: [0, 0, 0, 255],
@ -176,7 +177,7 @@ export function useAnalysisDeckLayers(
getTextAnchor: 'middle', getTextAnchor: 'middle',
getAlignmentBaseline: 'top', getAlignmentBaseline: 'top',
getPixelOffset: [0, 14], getPixelOffset: [0, 14],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,
outlineColor: [0, 0, 0, 255], outlineColor: [0, 0, 0, 255],
@ -198,7 +199,7 @@ export function useAnalysisDeckLayers(
getColor: [239, 68, 68, 255], getColor: [239, 68, 68, 255],
getTextAnchor: 'start', getTextAnchor: 'start',
getPixelOffset: [12, -8], getPixelOffset: [12, -8],
fontFamily: 'monospace', fontFamily: FONT_MONO,
fontWeight: 700, fontWeight: 700,
fontSettings: { sdf: true }, fontSettings: { sdf: true },
outlineWidth: 3, outlineWidth: 3,

파일 보기

@ -17,7 +17,7 @@
} }
body { 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); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100vh;

파일 보기

@ -1,5 +1,8 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import '@fontsource-variable/inter'
import '@fontsource-variable/noto-sans-kr'
import '@fontsource-variable/fira-code'
import './i18n' import './i18n'
import './styles/tailwind.css' import './styles/tailwind.css'
import './index.css' import './index.css'

파일 보기

@ -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";

파일 보기

@ -184,6 +184,6 @@
--color-kcg-danger-bg: var(--kcg-danger-bg); --color-kcg-danger-bg: var(--kcg-danger-bg);
/* 폰트 */ /* 폰트 */
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; --font-mono: 'Fira Code Variable', 'Noto Sans KR Variable', monospace;
--font-sans: 'Inter', system-ui, -apple-system, sans-serif; --font-sans: 'Inter Variable', 'Noto Sans KR Variable', system-ui, -apple-system, sans-serif;
} }