Merge pull request 'release: 2026-03-24 (14건 커밋)' (#169) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 2m14s
All checks were successful
Deploy KCG / deploy (push) Successful in 2m14s
This commit is contained in:
커밋
a3a933f096
@ -26,6 +26,7 @@ public class AuthFilter extends OncePerRequestFilter {
|
||||
private static final String VESSEL_ANALYSIS_PATH_PREFIX = "/api/vessel-analysis";
|
||||
private static final String PREDICTION_PATH_PREFIX = "/api/prediction/";
|
||||
private static final String FLEET_PATH_PREFIX = "/api/fleet-";
|
||||
private static final String EVENTS_PATH_PREFIX = "/api/events";
|
||||
|
||||
private final JwtProvider jwtProvider;
|
||||
|
||||
@ -37,7 +38,8 @@ public class AuthFilter extends OncePerRequestFilter {
|
||||
|| path.startsWith(CCTV_PATH_PREFIX)
|
||||
|| path.startsWith(VESSEL_ANALYSIS_PATH_PREFIX)
|
||||
|| path.startsWith(PREDICTION_PATH_PREFIX)
|
||||
|| path.startsWith(FLEET_PATH_PREFIX);
|
||||
|| path.startsWith(FLEET_PATH_PREFIX)
|
||||
|| path.startsWith(EVENTS_PATH_PREFIX);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@ -2,12 +2,14 @@ package gc.mda.kcg.domain.aircraft;
|
||||
|
||||
import gc.mda.kcg.collector.aircraft.AircraftCacheStore;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
@ -20,16 +22,24 @@ public class AircraftController {
|
||||
private static final Set<String> VALID_REGIONS = Set.of("iran", "korea");
|
||||
|
||||
private final AircraftCacheStore cacheStore;
|
||||
private final AircraftService aircraftService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<Map<String, Object>> getAircraft(
|
||||
@RequestParam(defaultValue = "iran") String region) {
|
||||
@RequestParam(defaultValue = "iran") String region,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) {
|
||||
|
||||
if (!VALID_REGIONS.contains(region)) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "유효하지 않은 region: " + region));
|
||||
}
|
||||
|
||||
if (from != null && to != null) {
|
||||
List<AircraftDto> results = aircraftService.getByDateRange(region, from, to);
|
||||
return ResponseEntity.ok(Map.of("region", region, "count", results.size(), "items", results));
|
||||
}
|
||||
|
||||
List<AircraftDto> aircraft = cacheStore.get(region);
|
||||
long lastUpdated = cacheStore.getLastUpdated(region);
|
||||
|
||||
|
||||
@ -1,6 +1,17 @@
|
||||
package gc.mda.kcg.domain.aircraft;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public interface AircraftPositionRepository extends JpaRepository<AircraftPosition, Long> {
|
||||
|
||||
@Query("SELECT a FROM AircraftPosition a WHERE a.region = :region AND a.collectedAt BETWEEN :from AND :to ORDER BY a.collectedAt DESC")
|
||||
List<AircraftPosition> findByRegionAndDateRange(
|
||||
@Param("region") String region,
|
||||
@Param("from") Instant from,
|
||||
@Param("to") Instant to);
|
||||
}
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
package gc.mda.kcg.domain.aircraft;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AircraftService {
|
||||
|
||||
private final AircraftPositionRepository repository;
|
||||
|
||||
/**
|
||||
* 시간 범위 내 항공기 위치를 조회하고 icao24 기준 최신 위치로 중복 제거하여 반환.
|
||||
*/
|
||||
public List<AircraftDto> getByDateRange(String region, Instant from, Instant to) {
|
||||
List<AircraftPosition> positions = repository.findByRegionAndDateRange(region, from, to);
|
||||
|
||||
Map<String, AircraftDto> deduplicated = new LinkedHashMap<>();
|
||||
for (AircraftPosition p : positions) {
|
||||
deduplicated.putIfAbsent(p.getIcao24(), toDto(p));
|
||||
}
|
||||
return List.copyOf(deduplicated.values());
|
||||
}
|
||||
|
||||
private AircraftDto toDto(AircraftPosition p) {
|
||||
return AircraftDto.builder()
|
||||
.icao24(p.getIcao24())
|
||||
.callsign(p.getCallsign())
|
||||
.lat(p.getPosition() != null ? p.getPosition().getY() : 0.0)
|
||||
.lng(p.getPosition() != null ? p.getPosition().getX() : 0.0)
|
||||
.altitude(p.getAltitude() != null ? p.getAltitude() : 0.0)
|
||||
.velocity(p.getVelocity() != null ? p.getVelocity() : 0.0)
|
||||
.heading(p.getHeading() != null ? p.getHeading() : 0.0)
|
||||
.verticalRate(p.getVerticalRate() != null ? p.getVerticalRate() : 0.0)
|
||||
.onGround(p.getOnGround() != null && p.getOnGround())
|
||||
.category(p.getCategory())
|
||||
.typecode(p.getTypecode())
|
||||
.typeDesc(p.getTypeDesc())
|
||||
.registration(p.getRegistration())
|
||||
.operator(p.getOperator())
|
||||
.squawk(p.getSquawk())
|
||||
.lastSeen(p.getLastSeen() != null ? p.getLastSeen().toEpochMilli()
|
||||
: p.getCollectedAt().toEpochMilli())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
42
backend/src/main/java/gc/mda/kcg/domain/event/Event.java
Normal file
42
backend/src/main/java/gc/mda/kcg/domain/event/Event.java
Normal file
@ -0,0 +1,42 @@
|
||||
package gc.mda.kcg.domain.event;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
@Entity
|
||||
@Table(name = "events", schema = "kcg")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Event {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "event_id", unique = true)
|
||||
private String eventId;
|
||||
|
||||
private String title;
|
||||
private String description;
|
||||
private String source;
|
||||
|
||||
@Column(name = "latitude")
|
||||
private Double latitude;
|
||||
|
||||
@Column(name = "longitude")
|
||||
private Double longitude;
|
||||
|
||||
private Instant timestamp;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(columnDefinition = "jsonb")
|
||||
private Map<String, Object> rawData;
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package gc.mda.kcg.domain.event;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/events")
|
||||
@RequiredArgsConstructor
|
||||
public class EventController {
|
||||
|
||||
private final EventService eventService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<Map<String, Object>> getEvents(
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) {
|
||||
Instant f = from != null ? from : Instant.parse("2026-03-01T00:00:00Z");
|
||||
Instant t = to != null ? to : Instant.now();
|
||||
List<EventDto> results = eventService.getByDateRange(f, t);
|
||||
return ResponseEntity.ok(Map.of("count", results.size(), "items", results));
|
||||
}
|
||||
|
||||
@PostMapping("/import")
|
||||
public ResponseEntity<Map<String, Object>> importEvents(@RequestBody List<EventDto> events) {
|
||||
int imported = eventService.importEvents(events);
|
||||
return ResponseEntity.ok(Map.of("imported", imported, "total", events.size()));
|
||||
}
|
||||
}
|
||||
49
backend/src/main/java/gc/mda/kcg/domain/event/EventDto.java
Normal file
49
backend/src/main/java/gc/mda/kcg/domain/event/EventDto.java
Normal file
@ -0,0 +1,49 @@
|
||||
package gc.mda.kcg.domain.event;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class EventDto {
|
||||
|
||||
private String id;
|
||||
private long timestamp;
|
||||
private Double lat;
|
||||
private Double lng;
|
||||
private String type;
|
||||
private String source;
|
||||
private String label;
|
||||
private String description;
|
||||
private Integer intensity;
|
||||
|
||||
public static EventDto from(Event e) {
|
||||
return EventDto.builder()
|
||||
.id(e.getEventId())
|
||||
.timestamp(e.getTimestamp() != null ? e.getTimestamp().toEpochMilli() : 0)
|
||||
.lat(e.getLatitude())
|
||||
.lng(e.getLongitude())
|
||||
.type(extractType(e))
|
||||
.source(e.getSource())
|
||||
.label(e.getTitle())
|
||||
.description(e.getDescription())
|
||||
.intensity(extractIntensity(e))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static String extractType(Event e) {
|
||||
if (e.getRawData() != null && e.getRawData().containsKey("type")) {
|
||||
return String.valueOf(e.getRawData().get("type"));
|
||||
}
|
||||
return "alert";
|
||||
}
|
||||
|
||||
private static Integer extractIntensity(Event e) {
|
||||
if (e.getRawData() != null && e.getRawData().containsKey("intensity")) {
|
||||
return ((Number) e.getRawData().get("intensity")).intValue();
|
||||
}
|
||||
return 50;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package gc.mda.kcg.domain.event;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public interface EventRepository extends JpaRepository<Event, Long> {
|
||||
|
||||
List<Event> findByTimestampBetweenOrderByTimestampAsc(Instant from, Instant to);
|
||||
|
||||
boolean existsByEventId(String eventId);
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
package gc.mda.kcg.domain.event;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class EventService {
|
||||
|
||||
private final EventRepository repository;
|
||||
|
||||
public List<EventDto> getByDateRange(Instant from, Instant to) {
|
||||
return repository.findByTimestampBetweenOrderByTimestampAsc(from, to)
|
||||
.stream().map(EventDto::from).toList();
|
||||
}
|
||||
|
||||
public int importEvents(List<EventDto> dtos) {
|
||||
int count = 0;
|
||||
for (EventDto dto : dtos) {
|
||||
if (dto.getId() != null && repository.existsByEventId(dto.getId())) continue;
|
||||
Event e = Event.builder()
|
||||
.eventId(dto.getId())
|
||||
.title(dto.getLabel())
|
||||
.description(dto.getDescription())
|
||||
.source(dto.getSource())
|
||||
.latitude(dto.getLat())
|
||||
.longitude(dto.getLng())
|
||||
.timestamp(Instant.ofEpochMilli(dto.getTimestamp()))
|
||||
.rawData(Map.of(
|
||||
"type", dto.getType() != null ? dto.getType() : "alert",
|
||||
"intensity", dto.getIntensity() != null ? dto.getIntensity() : 50
|
||||
))
|
||||
.build();
|
||||
repository.save(e);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
@ -4,12 +4,14 @@ import gc.mda.kcg.config.CacheConfig;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
@ -23,18 +25,26 @@ public class OsintController {
|
||||
private static final Set<String> VALID_REGIONS = Set.of("iran", "korea");
|
||||
|
||||
private final CacheManager cacheManager;
|
||||
private final OsintService osintService;
|
||||
|
||||
private final Map<String, Long> lastUpdated = new ConcurrentHashMap<>();
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<Map<String, Object>> getOsint(
|
||||
@RequestParam(defaultValue = "iran") String region) {
|
||||
@RequestParam(defaultValue = "iran") String region,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) {
|
||||
|
||||
if (!VALID_REGIONS.contains(region)) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "유효하지 않은 region: " + region));
|
||||
}
|
||||
|
||||
if (from != null && to != null) {
|
||||
List<OsintDto> results = osintService.getByDateRange(region, from, to);
|
||||
return ResponseEntity.ok(Map.of("region", region, "count", results.size(), "items", results));
|
||||
}
|
||||
|
||||
String cacheName = "iran".equals(region) ? CacheConfig.OSINT_IRAN : CacheConfig.OSINT_KOREA;
|
||||
List<OsintDto> items = getCachedItems(cacheName);
|
||||
long updatedAt = lastUpdated.getOrDefault(region, 0L);
|
||||
|
||||
@ -12,4 +12,6 @@ public interface OsintFeedRepository extends JpaRepository<OsintFeed, Long> {
|
||||
boolean existsByTitle(String title);
|
||||
|
||||
List<OsintFeed> findByRegionAndCollectedAtAfterOrderByPublishedAtDesc(String region, Instant since);
|
||||
|
||||
List<OsintFeed> findByRegionAndPublishedAtBetweenOrderByPublishedAtDesc(String region, Instant from, Instant to);
|
||||
}
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
package gc.mda.kcg.domain.osint;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OsintService {
|
||||
|
||||
private final OsintFeedRepository repository;
|
||||
|
||||
/**
|
||||
* 시간 범위 내 OSINT 피드를 조회하여 반환.
|
||||
* focus(region) 필드 기준 필터링, publishedAt 기준 정렬.
|
||||
*/
|
||||
public List<OsintDto> getByDateRange(String region, Instant from, Instant to) {
|
||||
return repository.findByRegionAndPublishedAtBetweenOrderByPublishedAtDesc(region, from, to)
|
||||
.stream()
|
||||
.map(OsintDto::from)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,23 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-03-24]
|
||||
|
||||
### 추가
|
||||
- LayerPanel 공통 트리 구조: LayerTreeNode 재귀 렌더러 (한국/이란 양쪽 적용)
|
||||
- 위험시설/해외시설 emoji→SVG IconLayer 전환 (12 SVG 함수, hazard/CN/JP 3개 IconLayer)
|
||||
- S&P Global 피격 선박 27척 데이터 (damagedShips.ts)
|
||||
- 이란 리플레이 실데이터 전환: Backend 시점 조회 API + Events CRUD
|
||||
- GeoEvent `sea_attack` 타입 + SEA ATK 배지 (피격 선박 이벤트 로그 통합)
|
||||
- 더미↔API 토글 UI (리플레이 배속 우측)
|
||||
- 대시보드 탭 localStorage 영속화
|
||||
- 지도 글꼴 크기 커스텀: 시설/선박/분석/지역 4그룹 슬라이더 (0.5~2.0x, LAYERS 하단)
|
||||
|
||||
### 변경
|
||||
- 부모 노드 토글→하위 전체 ON/OFF 캐스케이드 + 카운트 합산
|
||||
- useIranData dataSource 분기 (dummy=sampleData, api=Backend DB 3월1일~오늘)
|
||||
- fetchAircraftByRange, fetchOsintByRange, fetchEventsByRange 서비스 함수
|
||||
|
||||
## [2026-03-23.6]
|
||||
|
||||
### 수정
|
||||
|
||||
@ -1431,6 +1431,10 @@
|
||||
gap: 3px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.data-source-toggle {
|
||||
border-left: 1px solid rgba(255,255,255,0.15);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.speed-btn {
|
||||
padding: 3px 8px;
|
||||
@ -2458,3 +2462,42 @@
|
||||
text-align: center;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ── FontScalePanel ──────────────────────── */
|
||||
.font-scale-section { margin-top: 4px; }
|
||||
.font-scale-toggle {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
color: var(--kcg-text);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.font-scale-toggle:hover { background: rgba(255,255,255,0.05); }
|
||||
.font-scale-sliders { padding: 4px 8px; }
|
||||
.font-scale-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 9px;
|
||||
color: var(--kcg-dim);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.font-scale-row label { width: 60px; flex-shrink: 0; }
|
||||
.font-scale-row input[type="range"] { flex: 1; height: 12px; accent-color: var(--kcg-primary, #3b82f6); }
|
||||
.font-scale-row span { width: 24px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.font-scale-reset {
|
||||
width: 100%;
|
||||
padding: 2px;
|
||||
font-size: 9px;
|
||||
color: var(--kcg-dim);
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useReplay } from './hooks/useReplay';
|
||||
import { useMonitor } from './hooks/useMonitor';
|
||||
import { useLocalStorage } from './hooks/useLocalStorage';
|
||||
import type { AppMode } from './types';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
@ -8,6 +9,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import LoginPage from './components/auth/LoginPage';
|
||||
import CollectorMonitor from './components/common/CollectorMonitor';
|
||||
import { SharedFilterProvider } from './contexts/SharedFilterContext';
|
||||
import { FontScaleProvider } from './contexts/FontScaleContext';
|
||||
import { IranDashboard } from './components/iran/IranDashboard';
|
||||
import { KoreaDashboard } from './components/korea/KoreaDashboard';
|
||||
import './App.css';
|
||||
@ -40,7 +42,7 @@ interface AuthenticatedAppProps {
|
||||
|
||||
function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
const [appMode, setAppMode] = useState<AppMode>('live');
|
||||
const [dashboardTab, setDashboardTab] = useState<'iran' | 'korea'>('iran');
|
||||
const [dashboardTab, setDashboardTab] = useLocalStorage<'iran' | 'korea'>('dashboardTab', 'iran');
|
||||
const [showCollectorMonitor, setShowCollectorMonitor] = useState(false);
|
||||
const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST');
|
||||
|
||||
@ -64,6 +66,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
const currentTime = isLive ? monitor.state.currentTime : replay.state.currentTime;
|
||||
|
||||
return (
|
||||
<FontScaleProvider>
|
||||
<SharedFilterProvider>
|
||||
<div className={`app ${isLive ? 'app-live' : ''}`}>
|
||||
<header className="app-header">
|
||||
@ -157,6 +160,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
)}
|
||||
</div>
|
||||
</SharedFilterProvider>
|
||||
</FontScaleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -260,6 +260,7 @@ const TYPE_LABELS: Record<GeoEvent['type'], string> = {
|
||||
alert: 'ALERT',
|
||||
impact: 'IMPACT',
|
||||
osint: 'OSINT',
|
||||
sea_attack: 'SEA ATK',
|
||||
};
|
||||
|
||||
const TYPE_COLORS: Record<GeoEvent['type'], string> = {
|
||||
@ -270,6 +271,7 @@ const TYPE_COLORS: Record<GeoEvent['type'], string> = {
|
||||
alert: 'var(--kcg-event-alert)',
|
||||
impact: 'var(--kcg-event-impact)',
|
||||
osint: 'var(--kcg-event-osint)',
|
||||
sea_attack: '#0ea5e9',
|
||||
};
|
||||
|
||||
// MarineTraffic-style ship type classification
|
||||
|
||||
@ -20,6 +20,7 @@ const TYPE_COLORS: Record<string, string> = {
|
||||
alert: '#a855f7',
|
||||
impact: '#ff0000',
|
||||
osint: '#06b6d4',
|
||||
sea_attack: '#0ea5e9',
|
||||
};
|
||||
|
||||
const TYPE_KEYS: Record<string, string> = {
|
||||
|
||||
45
frontend/src/components/common/FontScalePanel.tsx
Normal file
45
frontend/src/components/common/FontScalePanel.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { useState } from 'react';
|
||||
import { useFontScale } from '../../hooks/useFontScale';
|
||||
import type { FontScaleConfig } from '../../contexts/fontScaleState';
|
||||
|
||||
const LABELS: Record<keyof FontScaleConfig, string> = {
|
||||
facility: '시설 라벨',
|
||||
ship: '선박 이름',
|
||||
analysis: '분석 라벨',
|
||||
area: '지역/국가명',
|
||||
};
|
||||
|
||||
export function FontScalePanel() {
|
||||
const { fontScale, setFontScale } = useFontScale();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const update = (key: keyof FontScaleConfig, val: number) => {
|
||||
setFontScale({ ...fontScale, [key]: Math.round(val * 10) / 10 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="font-scale-section">
|
||||
<button type="button" className="font-scale-toggle" onClick={() => setOpen(!open)}>
|
||||
<span>Aa 글꼴 크기</span>
|
||||
<span>{open ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="font-scale-sliders">
|
||||
{(Object.keys(LABELS) as (keyof FontScaleConfig)[]).map(key => (
|
||||
<div key={key} className="font-scale-row">
|
||||
<label>{LABELS[key]}</label>
|
||||
<input type="range" min={0.5} max={2.0} step={0.1}
|
||||
value={fontScale[key]}
|
||||
onChange={e => update(key, parseFloat(e.target.value))} />
|
||||
<span>{fontScale[key].toFixed(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="font-scale-reset"
|
||||
onClick={() => setFontScale({ facility: 1.0, ship: 1.0, analysis: 1.0, area: 1.0 })}>
|
||||
초기화
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalStorageSet } from '../../hooks/useLocalStorage';
|
||||
import { FontScalePanel } from './FontScalePanel';
|
||||
|
||||
// Aircraft category colors (matches AircraftLayer military fixed colors)
|
||||
const AC_CAT_COLORS: Record<string, string> = {
|
||||
@ -100,6 +101,19 @@ const NAT_COLORS: Record<string, string> = {
|
||||
unclassified: '#6b7280',
|
||||
};
|
||||
|
||||
// ── New tree interface ────────────────────────────────
|
||||
|
||||
export interface LayerTreeNode {
|
||||
key: string;
|
||||
label: string;
|
||||
color: string;
|
||||
count?: number;
|
||||
children?: LayerTreeNode[];
|
||||
specialRenderer?: 'shipCategories' | 'aircraftCategories' | 'nationalityCategories';
|
||||
}
|
||||
|
||||
// ── Legacy interfaces (kept for backward compat) ─────
|
||||
|
||||
interface ExtraLayer {
|
||||
key: string;
|
||||
label: string;
|
||||
@ -141,14 +155,39 @@ function countOverseasActiveLeaves(items: OverseasItem[], layers: Record<string,
|
||||
return count;
|
||||
}
|
||||
|
||||
// ── Tree helpers ──────────────────────────────────────
|
||||
|
||||
function getAllLeafKeys(node: LayerTreeNode): string[] {
|
||||
if (!node.children) return [node.key];
|
||||
return node.children.flatMap(getAllLeafKeys);
|
||||
}
|
||||
|
||||
function isNodeActive(node: LayerTreeNode, layers: Record<string, boolean>): boolean {
|
||||
if (!node.children) return !!layers[node.key];
|
||||
return node.children.some(c => isNodeActive(c, layers));
|
||||
}
|
||||
|
||||
function getTreeCount(node: LayerTreeNode, layers: Record<string, boolean>): number {
|
||||
if (!node.children) return node.count ?? 0;
|
||||
return node.children.reduce((sum, c) => {
|
||||
if (!c.children && !layers[c.key]) return sum;
|
||||
if (c.children && !isNodeActive(c, layers)) return sum;
|
||||
return sum + getTreeCount(c, layers);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// ── Props ─────────────────────────────────────────────
|
||||
|
||||
interface LayerPanelProps {
|
||||
layers: Record<string, boolean>;
|
||||
onToggle: (key: string) => void;
|
||||
onBatchToggle?: (keys: string[], value: boolean) => void;
|
||||
aircraftByCategory: Record<string, number>;
|
||||
aircraftTotal: number;
|
||||
shipsByMtCategory: Record<string, number>;
|
||||
shipTotal: number;
|
||||
satelliteCount: number;
|
||||
tree?: LayerTreeNode[];
|
||||
extraLayers?: ExtraLayer[];
|
||||
overseasItems?: OverseasItem[];
|
||||
hiddenAcCategories: Set<string>;
|
||||
@ -163,76 +202,61 @@ interface LayerPanelProps {
|
||||
onFishingNatToggle?: (nat: string) => void;
|
||||
}
|
||||
|
||||
export function LayerPanel({
|
||||
layers,
|
||||
onToggle,
|
||||
aircraftByCategory,
|
||||
aircraftTotal,
|
||||
// ── Special renderer props (shared across recursive calls) ──
|
||||
|
||||
interface SpecialRendererProps {
|
||||
shipsByMtCategory: Record<string, number>;
|
||||
hiddenShipCategories: Set<string>;
|
||||
onShipCategoryToggle: (cat: string) => void;
|
||||
aircraftByCategory: Record<string, number>;
|
||||
hiddenAcCategories: Set<string>;
|
||||
onAcCategoryToggle: (cat: string) => void;
|
||||
fishingByNationality?: Record<string, number>;
|
||||
hiddenFishingNats?: Set<string>;
|
||||
onFishingNatToggle?: (nat: string) => void;
|
||||
shipsByNationality?: Record<string, number>;
|
||||
hiddenNationalities?: Set<string>;
|
||||
onNationalityToggle?: (nat: string) => void;
|
||||
legendOpen: Set<string>;
|
||||
toggleLegend: (key: string) => void;
|
||||
expanded: Set<string>;
|
||||
toggleExpand: (key: string) => void;
|
||||
}
|
||||
|
||||
// ── Ship categories special renderer ─────────────────
|
||||
|
||||
function ShipCategoriesContent({
|
||||
shipsByMtCategory,
|
||||
shipTotal,
|
||||
satelliteCount,
|
||||
extraLayers,
|
||||
overseasItems,
|
||||
hiddenAcCategories,
|
||||
hiddenShipCategories,
|
||||
onAcCategoryToggle,
|
||||
onShipCategoryToggle,
|
||||
shipsByNationality,
|
||||
hiddenNationalities,
|
||||
onNationalityToggle,
|
||||
fishingByNationality,
|
||||
hiddenFishingNats,
|
||||
onFishingNatToggle,
|
||||
}: LayerPanelProps) {
|
||||
shipsByNationality,
|
||||
legendOpen,
|
||||
toggleLegend,
|
||||
expanded,
|
||||
toggleExpand,
|
||||
}: {
|
||||
shipsByMtCategory: Record<string, number>;
|
||||
hiddenShipCategories: Set<string>;
|
||||
onShipCategoryToggle: (cat: string) => void;
|
||||
fishingByNationality?: Record<string, number>;
|
||||
hiddenFishingNats?: Set<string>;
|
||||
onFishingNatToggle?: (nat: string) => void;
|
||||
shipsByNationality?: Record<string, number>;
|
||||
legendOpen: Set<string>;
|
||||
toggleLegend: (key: string) => void;
|
||||
expanded: Set<string>;
|
||||
toggleExpand: (key: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(['common', 'ships']);
|
||||
const [expanded, setExpanded] = useLocalStorageSet('layerPanelExpanded', new Set(['ships']));
|
||||
const [legendOpen, setLegendOpen] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleExpand = useCallback((key: string) => {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) { next.delete(key); } else { next.add(key); }
|
||||
return next;
|
||||
});
|
||||
}, [setExpanded]);
|
||||
|
||||
const toggleLegend = useCallback((key: string) => {
|
||||
setLegendOpen(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) { next.delete(key); } else { next.add(key); }
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const _militaryCount = Object.entries(aircraftByCategory)
|
||||
.filter(([cat]) => cat !== 'civilian' && cat !== 'unknown')
|
||||
.reduce((sum, [, c]) => sum + c, 0);
|
||||
void _militaryCount; // 이란 탭에서 사용 가능 — 해외시설 분리 후 미사용
|
||||
|
||||
return (
|
||||
<div className="layer-panel">
|
||||
<h3>LAYERS</h3>
|
||||
<div className="layer-items">
|
||||
{/* ═══ 선박 (최상위) ═══ */}
|
||||
{/* Ships tree */}
|
||||
<LayerTreeItem
|
||||
layerKey="ships"
|
||||
label={t('layers.ships')}
|
||||
count={shipTotal}
|
||||
color="#fb923c"
|
||||
active={layers.ships}
|
||||
expandable
|
||||
isExpanded={expanded.has('ships')}
|
||||
onToggle={() => onToggle('ships')}
|
||||
onExpand={() => toggleExpand('ships')}
|
||||
/>
|
||||
{layers.ships && expanded.has('ships') && (
|
||||
<div className="layer-tree-children">
|
||||
{MT_CATEGORIES.map(cat => {
|
||||
const count = shipsByMtCategory[cat] || 0;
|
||||
if (count === 0) return null;
|
||||
|
||||
// 어선은 국적별 하위 분류 표시
|
||||
if (cat === 'fishing' && fishingByNationality && hiddenFishingNats && onFishingNatToggle) {
|
||||
const isFishingExpanded = expanded.has('fishing-sub');
|
||||
return (
|
||||
@ -288,7 +312,6 @@ export function LayerPanel({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Ship type legend (Korea tab only) */}
|
||||
{shipsByNationality && (
|
||||
<>
|
||||
<button
|
||||
@ -320,71 +343,27 @@ export function LayerPanel({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nationality tree (Korea tab only) */}
|
||||
{shipsByNationality && hiddenNationalities && onNationalityToggle && (
|
||||
<>
|
||||
<LayerTreeItem
|
||||
layerKey="nationality"
|
||||
label="국적 분류"
|
||||
count={Object.values(shipsByNationality).reduce((a, b) => a + b, 0)}
|
||||
color="#8b5cf6"
|
||||
active
|
||||
expandable
|
||||
isExpanded={expanded.has('nationality')}
|
||||
onToggle={() => toggleExpand('nationality')}
|
||||
onExpand={() => toggleExpand('nationality')}
|
||||
/>
|
||||
{expanded.has('nationality') && (
|
||||
<div className="layer-tree-children">
|
||||
{NAT_CATEGORIES.map(nat => {
|
||||
const count = shipsByNationality[nat] || 0;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<CategoryToggle
|
||||
key={nat}
|
||||
label={NAT_LABELS[nat] || nat}
|
||||
color={NAT_COLORS[nat] || '#888'}
|
||||
count={count}
|
||||
hidden={hiddenNationalities.has(nat)}
|
||||
onClick={() => onNationalityToggle(nat)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
}
|
||||
|
||||
{/* ═══ 항공망 그룹 ═══ */}
|
||||
<LayerTreeItem
|
||||
layerKey="group-항공망"
|
||||
label="항공망"
|
||||
color="#22d3ee"
|
||||
active
|
||||
expandable
|
||||
isExpanded={expanded.has('group-항공망')}
|
||||
onToggle={() => toggleExpand('group-항공망')}
|
||||
onExpand={() => toggleExpand('group-항공망')}
|
||||
/>
|
||||
{expanded.has('group-항공망') && (
|
||||
<div className="layer-tree-children">
|
||||
{/* Aircraft tree */}
|
||||
<LayerTreeItem
|
||||
layerKey="aircraft"
|
||||
label={t('layers.aircraft')}
|
||||
count={aircraftTotal}
|
||||
color="#22d3ee"
|
||||
active={layers.aircraft}
|
||||
expandable
|
||||
isExpanded={expanded.has('aircraft')}
|
||||
onToggle={() => onToggle('aircraft')}
|
||||
onExpand={() => toggleExpand('aircraft')}
|
||||
/>
|
||||
{layers.aircraft && expanded.has('aircraft') && (
|
||||
// ── Aircraft categories special renderer ──────────────
|
||||
|
||||
function AircraftCategoriesContent({
|
||||
aircraftByCategory,
|
||||
hiddenAcCategories,
|
||||
onAcCategoryToggle,
|
||||
legendOpen,
|
||||
toggleLegend,
|
||||
}: {
|
||||
aircraftByCategory: Record<string, number>;
|
||||
hiddenAcCategories: Set<string>;
|
||||
onAcCategoryToggle: (cat: string) => void;
|
||||
legendOpen: Set<string>;
|
||||
toggleLegend: (key: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(['common', 'ships']);
|
||||
return (
|
||||
<div className="layer-tree-children">
|
||||
{AC_CATEGORIES.map(cat => {
|
||||
const count = aircraftByCategory[cat] || 0;
|
||||
@ -431,8 +410,347 @@ export function LayerPanel({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Nationality categories special renderer ───────────
|
||||
|
||||
function NationalityCategoriesContent({
|
||||
shipsByNationality,
|
||||
hiddenNationalities,
|
||||
onNationalityToggle,
|
||||
}: {
|
||||
shipsByNationality: Record<string, number>;
|
||||
hiddenNationalities: Set<string>;
|
||||
onNationalityToggle: (nat: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="layer-tree-children">
|
||||
{NAT_CATEGORIES.map(nat => {
|
||||
const count = shipsByNationality[nat] || 0;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<CategoryToggle
|
||||
key={nat}
|
||||
label={NAT_LABELS[nat] || nat}
|
||||
color={NAT_COLORS[nat] || '#888'}
|
||||
count={count}
|
||||
hidden={hiddenNationalities.has(nat)}
|
||||
onClick={() => onNationalityToggle(nat)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Recursive tree renderer ───────────────────────────
|
||||
|
||||
interface LayerTreeRendererProps {
|
||||
node: LayerTreeNode;
|
||||
depth: number;
|
||||
layers: Record<string, boolean>;
|
||||
expanded: Set<string>;
|
||||
onToggle: (key: string) => void;
|
||||
onBatchToggle?: (keys: string[], value: boolean) => void;
|
||||
toggleExpand: (key: string) => void;
|
||||
special: SpecialRendererProps;
|
||||
}
|
||||
|
||||
function LayerTreeRenderer({
|
||||
node,
|
||||
depth,
|
||||
layers,
|
||||
expanded,
|
||||
onToggle,
|
||||
onBatchToggle,
|
||||
toggleExpand,
|
||||
special,
|
||||
}: LayerTreeRendererProps) {
|
||||
const isLeaf = !node.children && !node.specialRenderer;
|
||||
const hasSpecial = !!node.specialRenderer;
|
||||
const isExpandable = !isLeaf;
|
||||
const active = isLeaf ? !!layers[node.key] : isNodeActive(node, layers);
|
||||
const count = isLeaf ? node.count : getTreeCount(node, layers);
|
||||
const isExp = expanded.has(node.key);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isLeaf) {
|
||||
onToggle(node.key);
|
||||
return;
|
||||
}
|
||||
if (hasSpecial) {
|
||||
// Special nodes (ships/aircraft) toggle the underlying layer key
|
||||
onToggle(node.key);
|
||||
return;
|
||||
}
|
||||
// Parent cascade: toggle all leaf keys under this node
|
||||
const leaves = getAllLeafKeys(node);
|
||||
const allOn = leaves.every(k => layers[k]);
|
||||
if (onBatchToggle) {
|
||||
onBatchToggle(leaves, !allOn);
|
||||
} else {
|
||||
for (const k of leaves) {
|
||||
if (allOn || !layers[k]) onToggle(k);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderChildren = () => {
|
||||
if (node.specialRenderer === 'shipCategories') {
|
||||
return (
|
||||
<ShipCategoriesContent
|
||||
shipsByMtCategory={special.shipsByMtCategory}
|
||||
hiddenShipCategories={special.hiddenShipCategories}
|
||||
onShipCategoryToggle={special.onShipCategoryToggle}
|
||||
fishingByNationality={special.fishingByNationality}
|
||||
hiddenFishingNats={special.hiddenFishingNats}
|
||||
onFishingNatToggle={special.onFishingNatToggle}
|
||||
shipsByNationality={special.shipsByNationality}
|
||||
legendOpen={special.legendOpen}
|
||||
toggleLegend={special.toggleLegend}
|
||||
expanded={special.expanded}
|
||||
toggleExpand={special.toggleExpand}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (node.specialRenderer === 'aircraftCategories') {
|
||||
return (
|
||||
<AircraftCategoriesContent
|
||||
aircraftByCategory={special.aircraftByCategory}
|
||||
hiddenAcCategories={special.hiddenAcCategories}
|
||||
onAcCategoryToggle={special.onAcCategoryToggle}
|
||||
legendOpen={special.legendOpen}
|
||||
toggleLegend={special.toggleLegend}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (node.specialRenderer === 'nationalityCategories') {
|
||||
if (!special.shipsByNationality || !special.hiddenNationalities || !special.onNationalityToggle) return null;
|
||||
return (
|
||||
<NationalityCategoriesContent
|
||||
shipsByNationality={special.shipsByNationality}
|
||||
hiddenNationalities={special.hiddenNationalities}
|
||||
onNationalityToggle={special.onNationalityToggle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (node.children) {
|
||||
return (
|
||||
<div className="layer-tree-children">
|
||||
{node.children.map(child => (
|
||||
<LayerTreeRenderer
|
||||
key={child.key}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
layers={layers}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
onBatchToggle={onBatchToggle}
|
||||
toggleExpand={toggleExpand}
|
||||
special={special}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={depth > 0 ? { marginLeft: depth > 1 ? 12 : 0 } : undefined}>
|
||||
<LayerTreeItem
|
||||
layerKey={node.key}
|
||||
label={node.label}
|
||||
color={node.color}
|
||||
active={active}
|
||||
expandable={isExpandable}
|
||||
isExpanded={isExp}
|
||||
count={count}
|
||||
onToggle={handleToggle}
|
||||
onExpand={isExpandable ? () => toggleExpand(node.key) : undefined}
|
||||
/>
|
||||
{isExpandable && isExp && renderChildren()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────
|
||||
|
||||
export function LayerPanel({
|
||||
layers,
|
||||
onToggle,
|
||||
onBatchToggle,
|
||||
aircraftByCategory,
|
||||
aircraftTotal,
|
||||
shipsByMtCategory,
|
||||
shipTotal,
|
||||
satelliteCount,
|
||||
tree,
|
||||
extraLayers,
|
||||
overseasItems,
|
||||
hiddenAcCategories,
|
||||
hiddenShipCategories,
|
||||
onAcCategoryToggle,
|
||||
onShipCategoryToggle,
|
||||
shipsByNationality,
|
||||
hiddenNationalities,
|
||||
onNationalityToggle,
|
||||
fishingByNationality,
|
||||
hiddenFishingNats,
|
||||
onFishingNatToggle,
|
||||
}: LayerPanelProps) {
|
||||
const { t } = useTranslation(['common', 'ships']);
|
||||
const [expanded, setExpanded] = useLocalStorageSet('layerPanelExpanded', new Set(['ships']));
|
||||
const [legendOpen, setLegendOpen] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleExpand = useCallback((key: string) => {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) { next.delete(key); } else { next.add(key); }
|
||||
return next;
|
||||
});
|
||||
}, [setExpanded]);
|
||||
|
||||
const toggleLegend = useCallback((key: string) => {
|
||||
setLegendOpen(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) { next.delete(key); } else { next.add(key); }
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const _militaryCount = Object.entries(aircraftByCategory)
|
||||
.filter(([cat]) => cat !== 'civilian' && cat !== 'unknown')
|
||||
.reduce((sum, [, c]) => sum + c, 0);
|
||||
void _militaryCount;
|
||||
|
||||
const specialProps: SpecialRendererProps = {
|
||||
shipsByMtCategory,
|
||||
hiddenShipCategories,
|
||||
onShipCategoryToggle,
|
||||
aircraftByCategory,
|
||||
hiddenAcCategories,
|
||||
onAcCategoryToggle,
|
||||
fishingByNationality,
|
||||
hiddenFishingNats,
|
||||
onFishingNatToggle,
|
||||
shipsByNationality,
|
||||
hiddenNationalities,
|
||||
onNationalityToggle,
|
||||
legendOpen,
|
||||
toggleLegend,
|
||||
expanded,
|
||||
toggleExpand,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="layer-panel">
|
||||
<h3>LAYERS</h3>
|
||||
<div className="layer-items">
|
||||
{tree ? (
|
||||
// ── Unified tree rendering ──
|
||||
tree.map(node => (
|
||||
<LayerTreeRenderer
|
||||
key={node.key}
|
||||
node={node}
|
||||
depth={0}
|
||||
layers={layers}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
onBatchToggle={onBatchToggle}
|
||||
toggleExpand={toggleExpand}
|
||||
special={specialProps}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
// ── Legacy rendering (backward compat) ──
|
||||
<>
|
||||
{/* Ships tree */}
|
||||
<LayerTreeItem
|
||||
layerKey="ships"
|
||||
label={t('layers.ships')}
|
||||
count={shipTotal}
|
||||
color="#fb923c"
|
||||
active={layers.ships}
|
||||
expandable
|
||||
isExpanded={expanded.has('ships')}
|
||||
onToggle={() => onToggle('ships')}
|
||||
onExpand={() => toggleExpand('ships')}
|
||||
/>
|
||||
{layers.ships && expanded.has('ships') && (
|
||||
<ShipCategoriesContent
|
||||
shipsByMtCategory={shipsByMtCategory}
|
||||
hiddenShipCategories={hiddenShipCategories}
|
||||
onShipCategoryToggle={onShipCategoryToggle}
|
||||
fishingByNationality={fishingByNationality}
|
||||
hiddenFishingNats={hiddenFishingNats}
|
||||
onFishingNatToggle={onFishingNatToggle}
|
||||
shipsByNationality={shipsByNationality}
|
||||
legendOpen={legendOpen}
|
||||
toggleLegend={toggleLegend}
|
||||
expanded={expanded}
|
||||
toggleExpand={toggleExpand}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Nationality tree (Korea tab only) */}
|
||||
{shipsByNationality && hiddenNationalities && onNationalityToggle && (
|
||||
<>
|
||||
<LayerTreeItem
|
||||
layerKey="nationality"
|
||||
label="국적 분류"
|
||||
count={Object.values(shipsByNationality).reduce((a, b) => a + b, 0)}
|
||||
color="#8b5cf6"
|
||||
active
|
||||
expandable
|
||||
isExpanded={expanded.has('nationality')}
|
||||
onToggle={() => toggleExpand('nationality')}
|
||||
onExpand={() => toggleExpand('nationality')}
|
||||
/>
|
||||
{expanded.has('nationality') && (
|
||||
<NationalityCategoriesContent
|
||||
shipsByNationality={shipsByNationality}
|
||||
hiddenNationalities={hiddenNationalities}
|
||||
onNationalityToggle={onNationalityToggle}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 항공망 그룹 */}
|
||||
<LayerTreeItem
|
||||
layerKey="group-항공망"
|
||||
label="항공망"
|
||||
color="#22d3ee"
|
||||
active
|
||||
expandable
|
||||
isExpanded={expanded.has('group-항공망')}
|
||||
onToggle={() => toggleExpand('group-항공망')}
|
||||
onExpand={() => toggleExpand('group-항공망')}
|
||||
/>
|
||||
{expanded.has('group-항공망') && (
|
||||
<div className="layer-tree-children">
|
||||
<LayerTreeItem
|
||||
layerKey="aircraft"
|
||||
label={t('layers.aircraft')}
|
||||
count={aircraftTotal}
|
||||
color="#22d3ee"
|
||||
active={layers.aircraft}
|
||||
expandable
|
||||
isExpanded={expanded.has('aircraft')}
|
||||
onToggle={() => onToggle('aircraft')}
|
||||
onExpand={() => toggleExpand('aircraft')}
|
||||
/>
|
||||
{layers.aircraft && expanded.has('aircraft') && (
|
||||
<AircraftCategoriesContent
|
||||
aircraftByCategory={aircraftByCategory}
|
||||
hiddenAcCategories={hiddenAcCategories}
|
||||
onAcCategoryToggle={onAcCategoryToggle}
|
||||
legendOpen={legendOpen}
|
||||
toggleLegend={toggleLegend}
|
||||
/>
|
||||
)}
|
||||
{/* Satellites */}
|
||||
<LayerTreeItem
|
||||
layerKey="satellites"
|
||||
label={t('layers.satellites')}
|
||||
@ -457,8 +775,7 @@ export function LayerPanel({
|
||||
}
|
||||
}
|
||||
|
||||
// 수퍼그룹 별로 그룹 분류
|
||||
const superGrouped: Record<string, string[]> = {}; // superGroup → groupNames[]
|
||||
const superGrouped: Record<string, string[]> = {};
|
||||
const noSuperGroup: string[] = [];
|
||||
for (const groupName of Object.keys(grouped)) {
|
||||
const sg = GROUP_META[groupName]?.superGroup;
|
||||
@ -507,10 +824,7 @@ export function LayerPanel({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 수퍼그룹 없는 그룹들 (항공망·해양안전·국가기관망) */}
|
||||
{noSuperGroup.map(g => renderGroup(g))}
|
||||
|
||||
{/* 수퍼그룹으로 묶인 그룹들 */}
|
||||
{Object.entries(superGrouped).map(([sgName, groupNames]) => {
|
||||
const sgMeta = SUPER_GROUP_META[sgName] || { label: sgName, color: '#f97316' };
|
||||
const isSgExpanded = expanded.has(`supergroup-${sgName}`);
|
||||
@ -534,8 +848,6 @@ export function LayerPanel({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 그룹 없는 개별 레이어 */}
|
||||
{ungrouped.map(el => (
|
||||
<LayerTreeItem
|
||||
key={el.key}
|
||||
@ -553,7 +865,7 @@ export function LayerPanel({
|
||||
|
||||
<div className="layer-divider" />
|
||||
|
||||
{/* 해외시설 — 접기/펼치기 전용 (토글은 하위 항목에서 개별 제어) */}
|
||||
{/* 해외시설 */}
|
||||
<LayerTreeItem
|
||||
layerKey="overseas-section"
|
||||
label="해외시설"
|
||||
@ -581,7 +893,10 @@ export function LayerPanel({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<FontScalePanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -606,7 +921,6 @@ function OverseasTreeNode({ item, depth, layers, expanded, onToggle, onToggleAll
|
||||
|
||||
const handleToggle = () => {
|
||||
if (hasChildren) {
|
||||
// 부모 토글 → 모든 하위 리프 on/off
|
||||
const allLeaves: string[] = [];
|
||||
const collectLeaves = (node: OverseasItem) => {
|
||||
if (node.children?.length) node.children.forEach(collectLeaves);
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type DataSource = 'dummy' | 'api';
|
||||
|
||||
interface Props {
|
||||
isPlaying: boolean;
|
||||
speed: number;
|
||||
@ -11,6 +13,8 @@ interface Props {
|
||||
onReset: () => void;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
onRangeChange: (start: number, end: number) => void;
|
||||
dataSource?: DataSource;
|
||||
onDataSourceChange?: (ds: DataSource) => void;
|
||||
}
|
||||
|
||||
const SPEEDS = [1, 2, 4, 8, 16];
|
||||
@ -51,6 +55,8 @@ export function ReplayControls({
|
||||
onReset,
|
||||
onSpeedChange,
|
||||
onRangeChange,
|
||||
dataSource,
|
||||
onDataSourceChange,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
@ -110,6 +116,24 @@ export function ReplayControls({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Data source toggle */}
|
||||
{dataSource && onDataSourceChange && (
|
||||
<div className="speed-controls data-source-toggle">
|
||||
<button
|
||||
className={`speed-btn ${dataSource === 'dummy' ? 'active' : ''}`}
|
||||
onClick={() => onDataSourceChange('dummy')}
|
||||
>
|
||||
더미
|
||||
</button>
|
||||
<button
|
||||
className={`speed-btn ${dataSource === 'api' ? 'active' : ''}`}
|
||||
onClick={() => onDataSourceChange('api')}
|
||||
>
|
||||
API
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ const TYPE_COLORS: Record<string, string> = {
|
||||
alert: '#a855f7',
|
||||
impact: '#ff0000',
|
||||
osint: '#06b6d4',
|
||||
sea_attack: '#0ea5e9',
|
||||
};
|
||||
|
||||
const TYPE_I18N_KEYS: Record<string, string> = {
|
||||
|
||||
@ -21,6 +21,7 @@ const EVENT_COLORS: Record<string, string> = {
|
||||
alert: '#a855f7',
|
||||
impact: '#ff0000',
|
||||
osint: '#06b6d4',
|
||||
sea_attack: '#0ea5e9',
|
||||
};
|
||||
|
||||
// Navy flag-based colors for military vessels
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { IRAN_OIL_COUNT } from './createIranOilLayers';
|
||||
import { IRAN_AIRPORT_COUNT } from './createIranAirportLayers';
|
||||
@ -10,9 +11,9 @@ import { GlobeMap } from './GlobeMap';
|
||||
import { SatelliteMap } from './SatelliteMap';
|
||||
import { SensorChart } from '../common/SensorChart';
|
||||
import { EventLog } from '../common/EventLog';
|
||||
import { LayerPanel } from '../common/LayerPanel';
|
||||
import { LayerPanel, type LayerTreeNode } from '../common/LayerPanel';
|
||||
import { LiveControls } from '../common/LiveControls';
|
||||
import { ReplayControls } from '../common/ReplayControls';
|
||||
import { ReplayControls, type DataSource } from '../common/ReplayControls';
|
||||
import { TimelineSlider } from '../common/TimelineSlider';
|
||||
import { useIranData } from '../../hooks/useIranData';
|
||||
import { useSharedFilters } from '../../hooks/useSharedFilters';
|
||||
@ -95,6 +96,7 @@ const IranDashboard = ({
|
||||
const [flyToTarget, setFlyToTarget] = useState<FlyToTarget | null>(null);
|
||||
const [hoveredShipMmsi, setHoveredShipMmsi] = useState<string | null>(null);
|
||||
const [focusShipMmsi, setFocusShipMmsi] = useState<string | null>(null);
|
||||
const [dataSource, setDataSource] = useLocalStorage<DataSource>('iranDataSource', 'dummy');
|
||||
|
||||
const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } =
|
||||
useSharedFilters();
|
||||
@ -107,17 +109,58 @@ const IranDashboard = ({
|
||||
hiddenShipCategories,
|
||||
refreshKey,
|
||||
dashboardTab: 'iran',
|
||||
dataSource,
|
||||
});
|
||||
|
||||
const toggleLayer = useCallback((key: keyof LayerVisibility) => {
|
||||
setLayers(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
}, []);
|
||||
|
||||
const batchToggleLayer = useCallback((keys: string[], value: boolean) => {
|
||||
setLayers(prev => {
|
||||
const next = { ...prev } as Record<string, boolean>;
|
||||
for (const k of keys) next[k] = value;
|
||||
return next as LayerVisibility;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleEventFlyTo = useCallback((event: GeoEvent) => {
|
||||
setFlyToTarget({ lat: event.lat, lng: event.lng, zoom: 8 });
|
||||
}, []);
|
||||
|
||||
const meCountByCountry = (ck: string) => ME_ENERGY_HAZARD_FACILITIES.filter(f => f.countryKey === ck).length;
|
||||
const meCountByCountry = useCallback((ck: string) => ME_ENERGY_HAZARD_FACILITIES.filter(f => f.countryKey === ck).length, []);
|
||||
|
||||
const layerTree = useMemo((): LayerTreeNode[] => [
|
||||
{ key: 'ships', label: t('layers.ships'), color: '#fb923c', count: iranData.ships.length, specialRenderer: 'shipCategories' },
|
||||
{
|
||||
key: 'aviation', label: '항공망', color: '#22d3ee',
|
||||
children: [
|
||||
{ key: 'aircraft', label: t('layers.aircraft'), color: '#22d3ee', count: iranData.aircraft.length, specialRenderer: 'aircraftCategories' },
|
||||
{ key: 'satellites', label: t('layers.satellites'), color: '#ef4444', count: iranData.satPositions.length },
|
||||
],
|
||||
},
|
||||
{ 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: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' },
|
||||
{
|
||||
key: 'overseas', label: '해외시설', color: '#f97316',
|
||||
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') },
|
||||
],
|
||||
},
|
||||
], [iranData, t, meCountByCountry]);
|
||||
|
||||
// 헤더 슬롯 Portal — 이란 모드 토글 + 맵 모드 + 카운트
|
||||
const headerSlot = document.getElementById('dashboard-header-slot');
|
||||
@ -214,31 +257,13 @@ const IranDashboard = ({
|
||||
<LayerPanel
|
||||
layers={layers as unknown as Record<string, boolean>}
|
||||
onToggle={toggleLayer as (key: string) => void}
|
||||
onBatchToggle={batchToggleLayer}
|
||||
tree={layerTree}
|
||||
aircraftByCategory={iranData.aircraftByCategory}
|
||||
aircraftTotal={iranData.aircraft.length}
|
||||
shipsByMtCategory={iranData.shipsByCategory}
|
||||
shipTotal={iranData.ships.length}
|
||||
satelliteCount={iranData.satPositions.length}
|
||||
extraLayers={[
|
||||
{ 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: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' },
|
||||
]}
|
||||
overseasItems={[
|
||||
{ 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') },
|
||||
]}
|
||||
hiddenAcCategories={hiddenAcCategories}
|
||||
hiddenShipCategories={hiddenShipCategories}
|
||||
onAcCategoryToggle={toggleAcCategory}
|
||||
@ -309,6 +334,8 @@ const IranDashboard = ({
|
||||
onReset={replay.reset}
|
||||
onSpeedChange={replay.setSpeed}
|
||||
onRangeChange={replay.setRange}
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={setDataSource}
|
||||
/>
|
||||
<TimelineSlider
|
||||
currentTime={replay.state.currentTime}
|
||||
|
||||
@ -54,6 +54,7 @@ export { layerKeyToSubType, layerKeyToCountry };
|
||||
export interface MELayerConfig {
|
||||
layers: Record<string, boolean>;
|
||||
sc: number;
|
||||
fs?: number;
|
||||
onPick: (facility: EnergyHazardFacility) => void;
|
||||
}
|
||||
|
||||
@ -174,6 +175,7 @@ function getIconUrl(subType: FacilitySubType): string {
|
||||
|
||||
export function createMEEnergyHazardLayers(config: MELayerConfig): Layer[] {
|
||||
const { layers, sc, onPick } = config;
|
||||
const fs = config.fs ?? 1;
|
||||
|
||||
const visibleFacilities = ME_ENERGY_HAZARD_FACILITIES.filter(f =>
|
||||
isFacilityVisible(f, layers),
|
||||
@ -200,7 +202,7 @@ export function createMEEnergyHazardLayers(config: MELayerConfig): Layer[] {
|
||||
data: visibleFacilities,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo,
|
||||
getSize: 12 * sc,
|
||||
getSize: 12 * sc * fs,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => hexToRgba(SUB_TYPE_META[d.subType].color, 200),
|
||||
getTextAnchor: 'middle',
|
||||
|
||||
@ -8,6 +8,7 @@ import { ShipLayer } from '../layers/ShipLayer';
|
||||
import { DamagedShipLayer } from '../layers/DamagedShipLayer';
|
||||
import { SeismicMarker } from '../layers/SeismicMarker';
|
||||
import { DeckGLOverlay } from '../layers/DeckGLOverlay';
|
||||
import { useFontScale } from '../../hooks/useFontScale';
|
||||
import { createMEEnergyHazardLayers } from './MEEnergyHazardLayer';
|
||||
import type { EnergyHazardFacility } from '../../data/meEnergyHazardFacilities';
|
||||
import { SUB_TYPE_META } from '../../data/meEnergyHazardFacilities';
|
||||
@ -91,6 +92,7 @@ const EVENT_COLORS: Record<GeoEvent['type'], string> = {
|
||||
alert: '#a855f7',
|
||||
impact: '#ff0000',
|
||||
osint: '#06b6d4',
|
||||
sea_attack: '#0ea5e9',
|
||||
};
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
@ -127,6 +129,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
||||
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
|
||||
const [mePickedFacility, setMePickedFacility] = useState<EnergyHazardFacility | null>(null);
|
||||
const [iranPickedFacility, setIranPickedFacility] = useState<IranPickedFacility | null>(null);
|
||||
const { fontScale } = useFontScale();
|
||||
const [zoomLevel, setZoomLevel] = useState(5);
|
||||
const zoomRef = useRef(5);
|
||||
|
||||
@ -153,11 +156,11 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
||||
}, [zoomLevel]);
|
||||
|
||||
const iranDeckLayers = useMemo(() => [
|
||||
...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }),
|
||||
...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }),
|
||||
...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }),
|
||||
...createMEEnergyHazardLayers({ layers: layers as Record<string, boolean>, sc: zoomScale, onPick: setMePickedFacility }),
|
||||
], [layers, zoomScale]);
|
||||
...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }),
|
||||
...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }),
|
||||
...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }),
|
||||
...createMEEnergyHazardLayers({ layers: layers as Record<string, boolean>, sc: zoomScale, fs: fontScale.facility, onPick: setMePickedFacility }),
|
||||
], [layers, zoomScale, fontScale.facility]);
|
||||
|
||||
useEffect(() => {
|
||||
if (flyToTarget && mapRef.current) {
|
||||
@ -241,7 +244,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
||||
filter={['==', ['get', 'rank'], 1]}
|
||||
layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': 15,
|
||||
'text-size': 15 * fontScale.area,
|
||||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
@ -260,7 +263,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
||||
filter={['==', ['get', 'rank'], 2]}
|
||||
layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': 12,
|
||||
'text-size': 12 * fontScale.area,
|
||||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
@ -280,7 +283,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
||||
minzoom={5}
|
||||
layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': 10,
|
||||
'text-size': 10 * fontScale.area,
|
||||
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
|
||||
@ -8,6 +8,7 @@ import { ShipLayer } from '../layers/ShipLayer';
|
||||
import { DamagedShipLayer } from '../layers/DamagedShipLayer';
|
||||
import { SeismicMarker } from '../layers/SeismicMarker';
|
||||
import { DeckGLOverlay } from '../layers/DeckGLOverlay';
|
||||
import { useFontScale } from '../../hooks/useFontScale';
|
||||
import { createMEEnergyHazardLayers } from './MEEnergyHazardLayer';
|
||||
import type { EnergyHazardFacility } from '../../data/meEnergyHazardFacilities';
|
||||
import { SUB_TYPE_META } from '../../data/meEnergyHazardFacilities';
|
||||
@ -74,6 +75,7 @@ const EVENT_COLORS: Record<GeoEvent['type'], string> = {
|
||||
alert: '#a855f7',
|
||||
impact: '#ff0000',
|
||||
osint: '#06b6d4',
|
||||
sea_attack: '#0ea5e9',
|
||||
};
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
@ -110,6 +112,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
|
||||
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
|
||||
const [mePickedFacility, setMePickedFacility] = useState<EnergyHazardFacility | null>(null);
|
||||
const [iranPickedFacility, setIranPickedFacility] = useState<IranPickedFacility | null>(null);
|
||||
const { fontScale } = useFontScale();
|
||||
const [zoomLevel, setZoomLevel] = useState(5);
|
||||
const zoomRef = useRef(5);
|
||||
|
||||
@ -136,11 +139,11 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
|
||||
}, [zoomLevel]);
|
||||
|
||||
const iranDeckLayers = useMemo(() => [
|
||||
...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }),
|
||||
...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }),
|
||||
...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }),
|
||||
...createMEEnergyHazardLayers({ layers: layers as Record<string, boolean>, sc: zoomScale, onPick: setMePickedFacility }),
|
||||
], [layers, zoomScale]);
|
||||
...createIranOilLayers({ visible: layers.oilFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'oil', data: f }) }),
|
||||
...createIranAirportLayers({ visible: layers.airports, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'airport', data: f }) }),
|
||||
...createMEFacilityLayers({ visible: layers.meFacilities, sc: zoomScale, fs: fontScale.facility, onPick: (f) => setIranPickedFacility({ kind: 'meFacility', data: f }) }),
|
||||
...createMEEnergyHazardLayers({ layers: layers as Record<string, boolean>, sc: zoomScale, fs: fontScale.facility, onPick: setMePickedFacility }),
|
||||
], [layers, zoomScale, fontScale.facility]);
|
||||
|
||||
useEffect(() => {
|
||||
if (flyToTarget && mapRef.current) {
|
||||
@ -233,7 +236,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
|
||||
layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-font': ['Open Sans Bold'],
|
||||
'text-size': 15,
|
||||
'text-size': 15 * fontScale.area,
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
}}
|
||||
@ -250,7 +253,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
|
||||
layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-font': ['Open Sans Bold'],
|
||||
'text-size': 12,
|
||||
'text-size': 12 * fontScale.area,
|
||||
'text-allow-overlap': false,
|
||||
}}
|
||||
paint={{
|
||||
@ -267,7 +270,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
|
||||
layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-font': ['Open Sans Bold'],
|
||||
'text-size': 10,
|
||||
'text-size': 10 * fontScale.area,
|
||||
'text-allow-overlap': false,
|
||||
}}
|
||||
paint={{
|
||||
|
||||
@ -51,11 +51,13 @@ function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
|
||||
export interface IranAirportLayerConfig {
|
||||
visible: boolean;
|
||||
sc: number;
|
||||
fs?: number;
|
||||
onPick: (airport: Airport) => void;
|
||||
}
|
||||
|
||||
export function createIranAirportLayers(config: IranAirportLayerConfig): Layer[] {
|
||||
const { visible, sc, onPick } = config;
|
||||
const fs = config.fs ?? 1;
|
||||
if (!visible) return [];
|
||||
|
||||
const iconLayer = new IconLayer<Airport>({
|
||||
@ -84,7 +86,7 @@ export function createIranAirportLayers(config: IranAirportLayerConfig): Layer[]
|
||||
const nameKo = d.nameKo ?? d.name;
|
||||
return nameKo.length > 10 ? nameKo.slice(0, 10) + '..' : nameKo;
|
||||
},
|
||||
getSize: 11 * sc,
|
||||
getSize: 11 * sc * fs,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => hexToRgba(getAirportColor(d)),
|
||||
getTextAnchor: 'middle',
|
||||
|
||||
@ -108,11 +108,13 @@ function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
|
||||
export interface IranOilLayerConfig {
|
||||
visible: boolean;
|
||||
sc: number;
|
||||
fs?: number;
|
||||
onPick: (facility: OilFacility) => void;
|
||||
}
|
||||
|
||||
export function createIranOilLayers(config: IranOilLayerConfig): Layer[] {
|
||||
const { visible, sc, onPick } = config;
|
||||
const fs = config.fs ?? 1;
|
||||
if (!visible) return [];
|
||||
|
||||
const iconLayer = new IconLayer<OilFacility>({
|
||||
@ -134,7 +136,7 @@ export function createIranOilLayers(config: IranOilLayerConfig): Layer[] {
|
||||
data: iranOilFacilities,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo,
|
||||
getSize: 12 * sc,
|
||||
getSize: 12 * sc * fs,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => hexToRgba(TYPE_COLORS[d.type]),
|
||||
getTextAnchor: 'middle',
|
||||
|
||||
@ -103,11 +103,13 @@ function getIconUrl(type: MEFacilityType): string {
|
||||
export interface MEFacilityLayerConfig {
|
||||
visible: boolean;
|
||||
sc: number;
|
||||
fs?: number;
|
||||
onPick: (facility: MEFacility) => void;
|
||||
}
|
||||
|
||||
export function createMEFacilityLayers(config: MEFacilityLayerConfig): Layer[] {
|
||||
const { visible, sc, onPick } = config;
|
||||
const fs = config.fs ?? 1;
|
||||
if (!visible) return [];
|
||||
|
||||
const iconLayer = new IconLayer<MEFacility>({
|
||||
@ -129,7 +131,7 @@ export function createMEFacilityLayers(config: MEFacilityLayerConfig): Layer[] {
|
||||
data: ME_FACILITIES,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo,
|
||||
getSize: 12 * sc,
|
||||
getSize: 12 * sc * fs,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => hexToRgba(ME_FACILITY_TYPE_META[d.type].color, 200),
|
||||
getTextAnchor: 'middle',
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalStorage, useLocalStorageSet } from '../../hooks/useLocalStorage';
|
||||
import { KoreaMap } from './KoreaMap';
|
||||
import { FieldAnalysisModal } from './FieldAnalysisModal';
|
||||
import { LayerPanel } from '../common/LayerPanel';
|
||||
import { LayerPanel, type LayerTreeNode } from '../common/LayerPanel';
|
||||
import { EventLog } from '../common/EventLog';
|
||||
import { LiveControls } from '../common/LiveControls';
|
||||
import { ReplayControls } from '../common/ReplayControls';
|
||||
@ -125,6 +125,14 @@ export const KoreaDashboard = ({
|
||||
setKoreaLayers(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
}, [setKoreaLayers]);
|
||||
|
||||
const batchToggleKoreaLayer = useCallback((keys: string[], value: boolean) => {
|
||||
setKoreaLayers(prev => {
|
||||
const next = { ...prev };
|
||||
for (const k of keys) next[k] = value;
|
||||
return next;
|
||||
});
|
||||
}, [setKoreaLayers]);
|
||||
|
||||
const [hiddenNationalities, setHiddenNationalities] = useLocalStorageSet('hiddenNationalities', new Set());
|
||||
const toggleNationality = useCallback((nat: string) => {
|
||||
setHiddenNationalities(prev => {
|
||||
@ -164,6 +172,85 @@ export const KoreaDashboard = ({
|
||||
// Tab switching is managed by parent (App.tsx); no-op here
|
||||
}, []);
|
||||
|
||||
const layerTree = useMemo((): LayerTreeNode[] => [
|
||||
{ key: 'ships', label: t('layers.ships'), color: '#fb923c', count: koreaData.ships.length, specialRenderer: 'shipCategories' },
|
||||
{ key: 'nationality', label: '국적 분류', color: '#8b5cf6', count: koreaData.ships.length, specialRenderer: 'nationalityCategories' },
|
||||
{
|
||||
key: 'aviation', label: '항공망', color: '#22d3ee',
|
||||
children: [
|
||||
{ key: 'aircraft', label: t('layers.aircraft'), color: '#22d3ee', count: koreaData.aircraft.length, specialRenderer: 'aircraftCategories' },
|
||||
{ key: 'satellites', label: t('layers.satellites'), color: '#ef4444', count: koreaData.satPositions.length },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'maritime-safety', label: '해양안전', color: '#3b82f6',
|
||||
children: [
|
||||
{ key: 'cables', label: t('layers.cables'), color: '#00e5ff', count: KOREA_SUBMARINE_CABLES.length },
|
||||
{ key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: COAST_GUARD_FACILITIES.length },
|
||||
{ key: 'navWarning', label: t('layers.navWarning'), color: '#eab308', count: NAV_WARNINGS.length },
|
||||
{ key: 'osint', label: t('layers.osint'), color: '#ef4444', count: koreaData.osintFeed.length },
|
||||
{ key: 'eez', label: t('layers.eez'), color: '#3b82f6', count: 1 },
|
||||
{ key: 'piracy', label: t('layers.piracy'), color: '#ef4444', count: PIRACY_ZONES.length },
|
||||
{ key: 'nkMissile', label: '미사일 낙하', color: '#ef4444', count: NK_MISSILE_EVENTS.length },
|
||||
{ key: 'nkLaunch', label: '발사/포병진지', color: '#dc2626', count: NK_LAUNCH_SITES.length },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'govt-infra', label: '국가기관망', color: '#f59e0b',
|
||||
children: [
|
||||
{ key: 'ports', label: '항구', color: '#3b82f6', count: EAST_ASIA_PORTS.length },
|
||||
{ key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: KOREAN_AIRPORTS.length },
|
||||
{ key: 'militaryBases', label: '군사시설', color: '#ef4444', count: MILITARY_BASES.length },
|
||||
{ key: 'govBuildings', label: '정부기관', color: '#f59e0b', count: GOV_BUILDINGS.length },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'energy', label: '에너지/발전시설', color: '#a855f7',
|
||||
children: [
|
||||
{ key: 'infra', label: t('layers.infra'), color: '#ffc107' },
|
||||
{ key: 'windFarm', label: '풍력단지', color: '#00bcd4', count: KOREA_WIND_FARMS.length },
|
||||
{ key: 'energyNuclear', label: '원자력발전', color: '#a855f7', count: HAZARD_FACILITIES.filter(f => f.type === 'nuclear').length },
|
||||
{ key: 'energyThermal', label: '화력발전소', color: '#64748b', count: HAZARD_FACILITIES.filter(f => f.type === 'thermal').length },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'hazard', label: '위험시설', color: '#ef4444',
|
||||
children: [
|
||||
{ key: 'hazardPetrochemical', label: '해안인접석유화학단지', color: '#f97316', count: HAZARD_FACILITIES.filter(f => f.type === 'petrochemical').length },
|
||||
{ key: 'hazardLng', label: 'LNG저장기지', color: '#06b6d4', count: HAZARD_FACILITIES.filter(f => f.type === 'lng').length },
|
||||
{ key: 'hazardOilTank', label: '유류저장탱크', color: '#eab308', count: HAZARD_FACILITIES.filter(f => f.type === 'oilTank').length },
|
||||
{ key: 'hazardPort', label: '위험물항만하역시설', color: '#ef4444', count: HAZARD_FACILITIES.filter(f => f.type === 'hazardPort').length },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'industry', label: '산업공정/제조시설', color: '#0ea5e9',
|
||||
children: [
|
||||
{ key: 'industryShipyard', label: '조선소 도장시설', color: '#0ea5e9', count: HAZARD_FACILITIES.filter(f => f.type === 'shipyard').length },
|
||||
{ key: 'industryWastewater', label: '폐수/하수처리장', color: '#10b981', count: HAZARD_FACILITIES.filter(f => f.type === 'wastewater').length },
|
||||
{ key: 'industryHeavy', label: '시멘트/제철소', color: '#94a3b8', count: HAZARD_FACILITIES.filter(f => f.type === 'heavyIndustry').length },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'overseas', label: '해외시설', color: '#f97316',
|
||||
children: [
|
||||
{
|
||||
key: 'overseasChina', label: '🇨🇳 중국', color: '#ef4444',
|
||||
children: [
|
||||
{ key: 'cnPower', label: '발전소', color: '#a855f7', count: CN_POWER_PLANTS.length },
|
||||
{ key: 'cnMilitary', label: '주요군사시설', color: '#ef4444', count: CN_MILITARY_FACILITIES.length },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'overseasJapan', label: '🇯🇵 일본', color: '#f472b6',
|
||||
children: [
|
||||
{ key: 'jpPower', label: '발전소', color: '#a855f7', count: JP_POWER_PLANTS.length },
|
||||
{ key: 'jpMilitary', label: '주요군사시설', color: '#ef4444', count: JP_MILITARY_FACILITIES.length },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
], [koreaData, t]);
|
||||
|
||||
// 헤더 슬롯 Portal — 한국 필터 버튼 + 카운트
|
||||
const headerSlot = document.getElementById('dashboard-header-slot');
|
||||
const countsSlot = document.getElementById('dashboard-counts-slot');
|
||||
@ -247,59 +334,13 @@ export const KoreaDashboard = ({
|
||||
<LayerPanel
|
||||
layers={koreaLayers}
|
||||
onToggle={toggleKoreaLayer}
|
||||
onBatchToggle={batchToggleKoreaLayer}
|
||||
tree={layerTree}
|
||||
aircraftByCategory={koreaData.aircraftByCategory}
|
||||
aircraftTotal={koreaData.aircraft.length}
|
||||
shipsByMtCategory={koreaData.shipsByCategory}
|
||||
shipTotal={koreaData.ships.length}
|
||||
satelliteCount={koreaData.satPositions.length}
|
||||
extraLayers={[
|
||||
// 해양안전
|
||||
{ key: 'cables', label: t('layers.cables'), color: '#00e5ff', count: KOREA_SUBMARINE_CABLES.length, group: '해양안전' },
|
||||
{ key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: COAST_GUARD_FACILITIES.length, group: '해양안전' },
|
||||
{ key: 'navWarning', label: t('layers.navWarning'), color: '#eab308', count: NAV_WARNINGS.length, group: '해양안전' },
|
||||
{ key: 'osint', label: t('layers.osint'), color: '#ef4444', count: koreaData.osintFeed.length, group: '해양안전' },
|
||||
{ key: 'eez', label: t('layers.eez'), color: '#3b82f6', count: 1, group: '해양안전' },
|
||||
{ key: 'piracy', label: t('layers.piracy'), color: '#ef4444', count: PIRACY_ZONES.length, group: '해양안전' },
|
||||
{ key: 'nkMissile', label: '🚀 미사일 낙하', color: '#ef4444', count: NK_MISSILE_EVENTS.length, group: '해양안전' },
|
||||
{ key: 'nkLaunch', label: '🇰🇵 발사/포병진지', color: '#dc2626', count: NK_LAUNCH_SITES.length, group: '해양안전' },
|
||||
// 국가기관망
|
||||
{ key: 'ports', label: '항구', color: '#3b82f6', count: EAST_ASIA_PORTS.length, group: '국가기관망' },
|
||||
{ key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: KOREAN_AIRPORTS.length, group: '국가기관망' },
|
||||
{ key: 'militaryBases', label: '군사시설', color: '#ef4444', count: MILITARY_BASES.length, group: '국가기관망' },
|
||||
{ key: 'govBuildings', label: '정부기관', color: '#f59e0b', count: GOV_BUILDINGS.length, group: '국가기관망' },
|
||||
// 에너지/발전시설
|
||||
{ key: 'infra', label: t('layers.infra'), color: '#ffc107', group: '에너지/발전시설' },
|
||||
{ key: 'windFarm', label: '풍력단지', color: '#00bcd4', count: KOREA_WIND_FARMS.length, group: '에너지/발전시설' },
|
||||
{ key: 'energyNuclear', label: '원자력발전', color: '#a855f7', count: HAZARD_FACILITIES.filter(f => f.type === 'nuclear').length, group: '에너지/발전시설' },
|
||||
{ key: 'energyThermal', label: '화력발전소', color: '#64748b', count: HAZARD_FACILITIES.filter(f => f.type === 'thermal').length, group: '에너지/발전시설' },
|
||||
// 위험시설
|
||||
{ key: 'hazardPetrochemical', label: '해안인접석유화학단지', color: '#f97316', count: HAZARD_FACILITIES.filter(f => f.type === 'petrochemical').length, group: '위험시설' },
|
||||
{ key: 'hazardLng', label: 'LNG저장기지', color: '#06b6d4', count: HAZARD_FACILITIES.filter(f => f.type === 'lng').length, group: '위험시설' },
|
||||
{ key: 'hazardOilTank', label: '유류저장탱크', color: '#eab308', count: HAZARD_FACILITIES.filter(f => f.type === 'oilTank').length, group: '위험시설' },
|
||||
{ key: 'hazardPort', label: '위험물항만하역시설', color: '#ef4444', count: HAZARD_FACILITIES.filter(f => f.type === 'hazardPort').length, group: '위험시설' },
|
||||
// 산업공정/제조시설
|
||||
{ key: 'industryShipyard', label: '조선소 도장시설', color: '#0ea5e9', count: HAZARD_FACILITIES.filter(f => f.type === 'shipyard').length, group: '산업공정/제조시설' },
|
||||
{ key: 'industryWastewater', label: '폐수/하수처리장', color: '#10b981', count: HAZARD_FACILITIES.filter(f => f.type === 'wastewater').length, group: '산업공정/제조시설' },
|
||||
{ key: 'industryHeavy', label: '시멘트/제철소', color: '#94a3b8', count: HAZARD_FACILITIES.filter(f => f.type === 'heavyIndustry').length, group: '산업공정/제조시설' },
|
||||
]}
|
||||
overseasItems={[
|
||||
{
|
||||
key: 'overseasChina', label: '🇨🇳 중국', color: '#ef4444',
|
||||
count: CN_POWER_PLANTS.length + CN_MILITARY_FACILITIES.length,
|
||||
children: [
|
||||
{ key: 'cnPower', label: '발전소', color: '#a855f7', count: CN_POWER_PLANTS.length },
|
||||
{ key: 'cnMilitary', label: '주요군사시설', color: '#ef4444', count: CN_MILITARY_FACILITIES.length },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'overseasJapan', label: '🇯🇵 일본', color: '#f472b6',
|
||||
count: JP_POWER_PLANTS.length + JP_MILITARY_FACILITIES.length,
|
||||
children: [
|
||||
{ key: 'jpPower', label: '발전소', color: '#a855f7', count: JP_POWER_PLANTS.length },
|
||||
{ key: 'jpMilitary', label: '주요군사시설', color: '#ef4444', count: JP_MILITARY_FACILITIES.length },
|
||||
],
|
||||
},
|
||||
]}
|
||||
hiddenAcCategories={hiddenAcCategories}
|
||||
hiddenShipCategories={hiddenShipCategories}
|
||||
onAcCategoryToggle={toggleAcCategory}
|
||||
|
||||
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||
import { useFontScale } from '../../hooks/useFontScale';
|
||||
import { DeckGLOverlay } from '../layers/DeckGLOverlay';
|
||||
import { useAnalysisDeckLayers } from '../../hooks/useAnalysisDeckLayers';
|
||||
import { useStaticDeckLayers } from '../../hooks/useStaticDeckLayers';
|
||||
@ -149,6 +150,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
const [trackCoords, setTrackCoords] = useState<[number, number][] | null>(null);
|
||||
const [selectedGearData, setSelectedGearData] = useState<SelectedGearGroupData | null>(null);
|
||||
const [selectedFleetData, setSelectedFleetData] = useState<SelectedFleetData | null>(null);
|
||||
const { fontScale } = useFontScale();
|
||||
const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM);
|
||||
const zoomRef = useRef(KOREA_MAP_ZOOM);
|
||||
const handleZoom = useCallback((e: { viewState: { zoom: number } }) => {
|
||||
@ -242,7 +244,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
data: illegalFishingData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.name || d.mmsi,
|
||||
getSize: 11 * zoomScale,
|
||||
getSize: 11 * zoomScale * fontScale.analysis,
|
||||
getColor: [239, 68, 68, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -253,8 +255,8 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
updateTriggers: { getSize: [zoomScale] },
|
||||
}), [illegalFishingData, zoomScale]);
|
||||
updateTriggers: { getSize: [zoomScale, fontScale.analysis] },
|
||||
}), [illegalFishingData, zoomScale, fontScale.analysis]);
|
||||
|
||||
// 수역 라벨 TextLayer — illegalFishing 또는 cnFishing 필터 활성 시 표시
|
||||
const zoneLabelsLayer = useMemo(() => {
|
||||
@ -281,7 +283,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
data,
|
||||
getPosition: (d: { lng: number; lat: number }) => [d.lng, d.lat],
|
||||
getText: (d: { name: string }) => d.name,
|
||||
getSize: 14 * zoomScale,
|
||||
getSize: 14 * zoomScale * fontScale.area,
|
||||
getColor: [255, 255, 255, 220],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
@ -292,9 +294,9 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
outlineColor: [0, 0, 0, 255],
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
updateTriggers: { getSize: [zoomScale] },
|
||||
updateTriggers: { getSize: [zoomScale, fontScale.area] },
|
||||
});
|
||||
}, [koreaFilters.illegalFishing, koreaFilters.cnFishing, zoomScale]);
|
||||
}, [koreaFilters.illegalFishing, koreaFilters.cnFishing, zoomScale, fontScale.area]);
|
||||
|
||||
// 정적 레이어 (deck.gl) — 항구, 공항, 군사기지, 어항경고 등
|
||||
const staticDeckLayers = useStaticDeckLayers({
|
||||
@ -357,7 +359,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
data: gears,
|
||||
getPosition: (d: Ship) => [d.lng, d.lat],
|
||||
getText: (d: Ship) => d.name || d.mmsi,
|
||||
getSize: 10 * zoomScale,
|
||||
getSize: 10 * zoomScale * fontScale.analysis,
|
||||
getColor: [249, 115, 22, 255],
|
||||
getTextAnchor: 'middle' as const,
|
||||
getAlignmentBaseline: 'top' as const,
|
||||
@ -392,7 +394,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
data: [parent],
|
||||
getPosition: (d: Ship) => [d.lng, d.lat],
|
||||
getText: (d: Ship) => `▼ ${d.name || groupName} (모선)`,
|
||||
getSize: 11 * zoomScale,
|
||||
getSize: 11 * zoomScale * fontScale.analysis,
|
||||
getColor: [249, 115, 22, 255],
|
||||
getTextAnchor: 'middle' as const,
|
||||
getAlignmentBaseline: 'top' as const,
|
||||
@ -409,7 +411,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
}
|
||||
|
||||
return layers;
|
||||
}, [selectedGearData, zoomScale]);
|
||||
}, [selectedGearData, zoomScale, fontScale.analysis]);
|
||||
|
||||
// 선택된 선단 소속 선박 강조 레이어 (deck.gl)
|
||||
const selectedFleetLayers = useMemo(() => {
|
||||
@ -457,7 +459,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
const prefix = role === 'LEADER' ? '★ ' : '';
|
||||
return `${prefix}${d.name || d.mmsi}`;
|
||||
},
|
||||
getSize: 10 * zoomScale,
|
||||
getSize: 10 * zoomScale * fontScale.analysis,
|
||||
getColor: color,
|
||||
getTextAnchor: 'middle' as const,
|
||||
getAlignmentBaseline: 'top' as const,
|
||||
@ -495,7 +497,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [selectedFleetData, zoomScale, vesselAnalysis]);
|
||||
}, [selectedFleetData, zoomScale, vesselAnalysis, fontScale.analysis]);
|
||||
|
||||
// 분석 결과 deck.gl 레이어
|
||||
const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing'
|
||||
@ -527,7 +529,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
filter={['==', ['get', 'rank'], 1]}
|
||||
layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': 15,
|
||||
'text-size': 15 * fontScale.area,
|
||||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
@ -546,7 +548,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
filter={['==', ['get', 'rank'], 2]}
|
||||
layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': 12,
|
||||
'text-size': 12 * fontScale.area,
|
||||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
@ -566,7 +568,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
minzoom={5}
|
||||
layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': 10,
|
||||
'text-size': 10 * fontScale.area,
|
||||
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
|
||||
@ -6,6 +6,7 @@ import maplibregl from 'maplibre-gl';
|
||||
import { MT_TYPE_COLORS, MT_TYPE_HEX, getMTType, NAVY_COLORS, FLAG_EMOJI, SIZE_MAP, isMilitary, getShipColor } from '../../utils/shipClassification';
|
||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||
import { getNationalityGroup } from '../../hooks/useKoreaData';
|
||||
import { useFontScale } from '../../hooks/useFontScale';
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
@ -274,6 +275,8 @@ function ensureTriangleImage(map: maplibregl.Map) {
|
||||
// ── Main layer (WebGL symbol rendering — triangles) ──
|
||||
export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusMmsi, onFocusClear, analysisMap, hiddenShipCategories, hiddenNationalities }: Props) {
|
||||
const { current: map } = useMap();
|
||||
const { fontScale } = useFontScale();
|
||||
const sfs = fontScale.ship;
|
||||
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
|
||||
const [imageReady, setImageReady] = useState(false);
|
||||
const highlightKorean = !!koreanOnly;
|
||||
@ -479,13 +482,14 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
/>
|
||||
{/* Korean ship label — always mounted, visibility으로 제어 */}
|
||||
<Layer
|
||||
id="ships-korean-label"
|
||||
key={`ships-korean-label-${sfs}`}
|
||||
id={`ships-korean-label-${sfs}`}
|
||||
type="symbol"
|
||||
filter={['==', ['get', 'isKorean'], 1]}
|
||||
layout={{
|
||||
'visibility': highlightKorean ? 'visible' : 'none',
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 6, 6, 7, 8, 9, 10, 11, 12, 13, 13, 15, 14, 17],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 6*sfs, 6, 7*sfs, 8, 9*sfs, 10, 11*sfs, 12, 13*sfs, 13, 15*sfs, 14, 17*sfs],
|
||||
'text-offset': [0, 2.2],
|
||||
'text-anchor': 'top',
|
||||
'text-allow-overlap': false,
|
||||
|
||||
10
frontend/src/contexts/FontScaleContext.tsx
Normal file
10
frontend/src/contexts/FontScaleContext.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLocalStorage } from '../hooks/useLocalStorage';
|
||||
import { FontScaleCtx, DEFAULT_FONT_SCALE } from './fontScaleState';
|
||||
|
||||
export type { FontScaleConfig } from './fontScaleState';
|
||||
|
||||
export function FontScaleProvider({ children }: { children: ReactNode }) {
|
||||
const [fontScale, setFontScale] = useLocalStorage('mapFontScale', DEFAULT_FONT_SCALE);
|
||||
return <FontScaleCtx.Provider value={{ fontScale, setFontScale }}>{children}</FontScaleCtx.Provider>;
|
||||
}
|
||||
14
frontend/src/contexts/fontScaleState.ts
Normal file
14
frontend/src/contexts/fontScaleState.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export interface FontScaleConfig {
|
||||
facility: number;
|
||||
ship: number;
|
||||
analysis: number;
|
||||
area: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_FONT_SCALE: FontScaleConfig = { facility: 1.0, ship: 1.0, analysis: 1.0, area: 1.0 };
|
||||
|
||||
export const FontScaleCtx = createContext<{ fontScale: FontScaleConfig; setFontScale: (c: FontScaleConfig) => void }>({
|
||||
fontScale: DEFAULT_FONT_SCALE, setFontScale: () => {},
|
||||
});
|
||||
@ -145,4 +145,57 @@ export const damagedShips: DamagedShip[] = [
|
||||
description: 'IRGC 게쉼섬 고속정 기지 공습. 고속정 20여 척 파괴/대파.',
|
||||
eventId: 'd12-us5',
|
||||
},
|
||||
|
||||
// ═══ S&P Global Marine Risk Note (2026-03-19) ═══
|
||||
// 이란의 상선 공격 30건 — 호르무즈 해협 중심
|
||||
// UAE 해역 48%, 오만 해역 28%, 기타 24%
|
||||
|
||||
// DAY 0 — 2026-03-01 (6척 동시 공격)
|
||||
{ id: 'spg-01', name: 'SKYLIGHT', flag: 'MH', type: 'Chemical/Products Tanker', lat: 26.12, lng: 56.28, damagedAt: T0, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9396737, 마셜제도 국적 화학탱커. UAE 해역 피격.', eventId: 'imp1' },
|
||||
{ id: 'spg-02', name: 'STAR ELECTRA', flag: 'LR', type: 'Bulk Carrier', lat: 25.85, lng: 56.42, damagedAt: T0, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9442536, 라이베리아 국적 벌크선. UAE 해역 피격.', eventId: 'imp1' },
|
||||
{ id: 'spg-03', name: 'HERCULES STAR', flag: 'GI', type: 'Products Tanker', lat: 26.35, lng: 56.15, damagedAt: T0, cause: 'IRGC 공격', damage: 'minor', description: 'IMO 9916135, 지브롤터 국적 유조선. UAE 해역.', eventId: 'imp1' },
|
||||
{ id: 'spg-04', name: 'SEA LA DONNA', flag: 'LR', type: 'Chemical/Products Tanker', lat: 25.98, lng: 56.55, damagedAt: T0, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9380532, 라이베리아 화학탱커.', eventId: 'imp1' },
|
||||
{ id: 'spg-05', name: 'OCEAN ELECTRA', flag: 'LR', type: 'Products Tanker', lat: 26.22, lng: 56.35, damagedAt: T0, cause: 'IRGC 공격', damage: 'minor', description: 'IMO 9402782, 라이베리아 유조선.', eventId: 'imp1' },
|
||||
{ id: 'spg-06', name: 'AYEH', flag: 'AE', type: 'Deck Cargo Ship', lat: 25.55, lng: 55.80, damagedAt: T0, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 1075181, UAE 국적 갑판화물선.', eventId: 'imp1' },
|
||||
|
||||
// DAY 1 — 2026-03-02 (3척)
|
||||
{ id: 'spg-07', name: 'STENA IMPERATIVE', flag: 'US', type: 'Chemical/Products Tanker', lat: 26.55, lng: 56.10, damagedAt: T0 + 1 * DAY, cause: 'IRGC 대함미사일', damage: 'severe', description: 'IMO 9666077, 미국 국적 화학탱커. 미국 선박 최초 피격.', eventId: 'imp1' },
|
||||
{ id: 'spg-08', name: 'MKD VYOM', flag: 'MH', type: 'Crude/Oil Products Tanker', lat: 25.70, lng: 56.90, damagedAt: T0 + 1 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9284386, 마셜제도 원유탱커. 오만 해역.', eventId: 'imp1' },
|
||||
{ id: 'spg-09', name: 'ATHE NOVA', flag: 'HN', type: 'Asphalt/Bitumen Tanker', lat: 25.40, lng: 57.20, damagedAt: T0 + 1 * DAY, cause: 'IRGC 공격', damage: 'minor', description: 'IMO 9188116, 온두라스 아스팔트탱커. 오만 해역.', eventId: 'imp1' },
|
||||
|
||||
// DAY 2 — 2026-03-03 (3척)
|
||||
{ id: 'spg-10', name: 'PELAGIA', flag: 'MT', type: 'Bulk Carrier', lat: 26.30, lng: 56.50, damagedAt: T0 + 2 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9433626, 몰타 벌크선.', eventId: 'd3-sea1' },
|
||||
{ id: 'spg-11', name: 'GOLD OAK', flag: 'PA', type: 'Bulk Carrier', lat: 25.60, lng: 57.10, damagedAt: T0 + 2 * DAY, cause: '기뢰 접촉', damage: 'moderate', description: 'IMO 9806342, 파나마 벌크선. 오만 해역.', eventId: 'd3-sea1' },
|
||||
{ id: 'spg-12', name: 'LIBRA TRADER', flag: 'IN', type: 'Crude Oil Tanker', lat: 26.05, lng: 56.30, damagedAt: T0 + 2 * DAY, cause: 'IRGC 공격', damage: 'severe', description: 'IMO 9562673, 인도 원유탱커.', eventId: 'd3-sea1' },
|
||||
|
||||
// DAY 3 — 2026-03-04 (3척)
|
||||
{ id: 'spg-13', name: 'SAFEEN PRESTIGE', flag: 'MT', type: 'Container Ship', lat: 25.90, lng: 56.40, damagedAt: T0 + 3 * DAY, cause: 'IRGC 드론', damage: 'moderate', description: 'IMO 9593517, 몰타 컨테이너선.', eventId: 'imp2' },
|
||||
{ id: 'spg-14', name: 'MSC GRACE', flag: 'LR', type: 'Container Ship', lat: 26.40, lng: 56.20, damagedAt: T0 + 3 * DAY, cause: 'IRGC 드론', damage: 'minor', description: 'IMO 9987366, 라이베리아 MSC 컨테이너선.', eventId: 'imp2' },
|
||||
|
||||
// DAY 4 — 2026-03-05 (1척)
|
||||
{ id: 'spg-15', name: 'SONANGOL NAMIBE', flag: 'BS', type: 'Crude Oil Tanker', lat: 26.15, lng: 56.45, damagedAt: T0 + 4 * DAY, cause: 'IRGC 대함미사일', damage: 'severe', description: 'IMO 9325049, 바하마 원유탱커. UAE 해역.', eventId: 'imp2' },
|
||||
|
||||
// DAY 5 — 2026-03-06 (2척)
|
||||
{ id: 'spg-16', name: 'PRIMA', flag: 'MT', type: 'Tanker', lat: 25.80, lng: 56.60, damagedAt: T0 + 5 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9427433, 몰타 탱커.', eventId: 'imp3' },
|
||||
{ id: 'spg-17', name: 'MUSSAFAH 2', flag: 'AE', type: 'Tug', lat: 25.50, lng: 55.60, damagedAt: T0 + 5 * DAY, cause: 'IRGC 공격', damage: 'minor', description: 'IMO 9522051, UAE 예인선.', eventId: 'imp3' },
|
||||
|
||||
// DAY 6 — 2026-03-07 (1척)
|
||||
{ id: 'spg-18', name: 'LOUIS P', flag: 'MH', type: 'Chemical/Products Tanker', lat: 26.60, lng: 56.05, damagedAt: T0 + 6 * DAY, cause: 'IRGC 드론', damage: 'moderate', description: 'IMO 9749336, 마셜제도 화학탱커.', eventId: 'imp3' },
|
||||
|
||||
// DAY 10 — 2026-03-11 (4척)
|
||||
{ id: 'spg-19', name: 'MAYUREE NAREE', flag: 'TH', type: 'Bulk Carrier', lat: 25.45, lng: 57.30, damagedAt: T0 + 10 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9323649, 태국 벌크선. 오만 해역.', eventId: 'd12-sea1' },
|
||||
{ id: 'spg-20', name: 'STAR GWYNETH', flag: 'MH', type: 'Bulk Carrier', lat: 26.20, lng: 56.55, damagedAt: T0 + 10 * DAY, cause: '기뢰 접촉', damage: 'moderate', description: 'IMO 9301031, 마셜제도 벌크선.', eventId: 'd12-sea1' },
|
||||
{ id: 'spg-21', name: 'ONE MAJESTY', flag: 'JP', type: 'Container Ship', lat: 25.75, lng: 56.80, damagedAt: T0 + 10 * DAY, cause: 'IRGC 대함미사일', damage: 'severe', description: 'IMO 9424912, 일본 ONE 컨테이너선. 오만 해역.', eventId: 'd12-sea1' },
|
||||
{ id: 'spg-22', name: 'EXPRESS ROME', flag: 'LR', type: 'Container Ship', lat: 26.00, lng: 56.35, damagedAt: T0 + 10 * DAY, cause: 'IRGC 드론', damage: 'minor', description: 'IMO 9484936, 라이베리아 컨테이너선.', eventId: 'd12-sea1' },
|
||||
|
||||
// DAY 11 — 2026-03-12 (3척)
|
||||
{ id: 'spg-23', name: 'ZEFYROS', flag: 'MT', type: 'Chemical/Products Tanker', lat: 26.45, lng: 56.15, damagedAt: T0 + 11 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9515917, 몰타 화학탱커.', eventId: 'd12-sea3' },
|
||||
{ id: 'spg-24', name: 'SAFESEA VISHNU', flag: 'MH', type: 'Crude/Oil Products Tanker', lat: 25.65, lng: 57.00, damagedAt: T0 + 11 * DAY, cause: 'IRGC 대함미사일', damage: 'severe', description: 'IMO 9327009, 마셜제도 원유탱커. 오만 해역.', eventId: 'd12-sea3' },
|
||||
{ id: 'spg-25', name: 'SOURCE BLESSING', flag: 'LR', type: 'Container Ship', lat: 26.10, lng: 56.40, damagedAt: T0 + 11 * DAY, cause: 'IRGC 드론', damage: 'minor', description: 'IMO 9243198, 라이베리아 컨테이너선.', eventId: 'd12-sea3' },
|
||||
|
||||
// DAY 15 — 2026-03-16 (1척)
|
||||
{ id: 'spg-26', name: 'GAS AL AHMADIAH', flag: 'KW', type: 'LPG Tanker', lat: 29.20, lng: 48.80, damagedAt: T0 + 15 * DAY, cause: 'IRGC 대함미사일', damage: 'severe', description: 'IMO 9849629, 쿠웨이트 LPG탱커. 쿠웨이트 해역.', eventId: 'd12-sea6' },
|
||||
|
||||
// DAY 18 — 2026-03-19 (2척)
|
||||
{ id: 'spg-27', name: 'HALUL 69', flag: 'QA', type: 'Anchor Handling Tug Supply', lat: 25.95, lng: 51.55, damagedAt: T0 + 18 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9671577, 카타르 AHTS. 카타르 해역.', eventId: 'd12-p5' },
|
||||
];
|
||||
|
||||
@ -59,9 +59,236 @@ function infraSvg(f: PowerFacility): string {
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── Hazard SVG ───────────────────────────────────────────────────────────────
|
||||
|
||||
function nuclearSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<circle cx="12" cy="12" r="2" fill="${color}"/>
|
||||
<path d="M12 10 Q10 7 7 7 Q6 9 7 11 Q9 12 12 12" fill="${color}" opacity="0.7"/>
|
||||
<path d="M13.7 11 Q16 9 17 7 Q15 5 13 6 Q11 8 12 10" fill="${color}" opacity="0.7"/>
|
||||
<path d="M10.3 13 Q7 13 6 16 Q8 18 11 17 Q13 15 13.7 13" fill="${color}" opacity="0.7"/>
|
||||
<path d="M13.7 13 Q15 16 17 17 Q19 15 18 12 Q16 11 13.7 12" fill="${color}" opacity="0.7"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function thermalSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<rect x="5" y="11" width="14" height="7" rx="1" fill="${color}" opacity="0.6"/>
|
||||
<rect x="7" y="7" width="2" height="5" fill="${color}" opacity="0.6"/>
|
||||
<rect x="11" y="5" width="2" height="7" fill="${color}" opacity="0.6"/>
|
||||
<rect x="15" y="8" width="2" height="4" fill="${color}" opacity="0.6"/>
|
||||
<path d="M8 5 Q8.5 3.5 9 5 Q9.5 3 10 5" fill="none" stroke="${color}" stroke-width="0.8" opacity="0.8"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function petrochemSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<rect x="10" y="5" width="4" height="8" rx="1" fill="${color}" opacity="0.65"/>
|
||||
<ellipse cx="12" cy="14.5" rx="4.5" ry="2.5" fill="${color}" opacity="0.75"/>
|
||||
<line x1="7" y1="10" x2="10" y2="10" stroke="${color}" stroke-width="1"/>
|
||||
<line x1="14" y1="10" x2="17" y2="10" stroke="${color}" stroke-width="1"/>
|
||||
<path d="M7 10 Q5.5 13 8 15" fill="none" stroke="${color}" stroke-width="0.8"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function lngSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="9" y1="7" x2="9" y2="10" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="12" y1="5" x2="12" y2="10" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="15" y1="7" x2="15" y2="10" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="7" y1="9" x2="17" y2="9" stroke="${color}" stroke-width="1"/>
|
||||
<ellipse cx="12" cy="15" rx="5" ry="3.5" fill="${color}" opacity="0.65"/>
|
||||
<line x1="12" y1="10" x2="12" y2="11.5" stroke="${color}" stroke-width="1"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function oilTankSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<ellipse cx="12" cy="8" rx="5" ry="2" fill="${color}" opacity="0.5"/>
|
||||
<rect x="7" y="8" width="10" height="8" fill="${color}" opacity="0.6"/>
|
||||
<ellipse cx="12" cy="16" rx="5" ry="2" fill="${color}" opacity="0.8"/>
|
||||
<line x1="9" y1="8" x2="9" y2="16" stroke="rgba(0,0,0,0.2)" stroke-width="0.5"/>
|
||||
<line x1="15" y1="8" x2="15" y2="16" stroke="rgba(0,0,0,0.2)" stroke-width="0.5"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function hazPortSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M12 5 L20 18 L4 18 Z" fill="${color}" opacity="0.7"/>
|
||||
<line x1="12" y1="10" x2="12" y2="14" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="16" r="1" fill="#fff"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function shipyardSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M6 15 Q7 13 9 13 L15 13 Q17 13 18 15 L17 17 Q12 19 7 17 Z" fill="${color}" opacity="0.75"/>
|
||||
<line x1="12" y1="6" x2="12" y2="13" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="12" y1="6" x2="16" y2="10" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="16" y1="10" x2="16" y2="13" stroke="${color}" stroke-width="1"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function wastewaterSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M12 5 Q15 9 15 12 Q15 15.3 12 17 Q9 15.3 9 12 Q9 9 12 5 Z" fill="${color}" opacity="0.75"/>
|
||||
<path d="M9 14 Q10.5 15.5 12 16" fill="none" stroke="#fff" stroke-width="0.7" opacity="0.5"/>
|
||||
<path d="M8.5 12 Q9.5 13.5 11 14" fill="none" stroke="#fff" stroke-width="0.7" opacity="0.5"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function heavyIndustrySvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<circle cx="12" cy="12" r="4" fill="none" stroke="${color}" stroke-width="1.2"/>
|
||||
<circle cx="12" cy="12" r="1.5" fill="${color}"/>
|
||||
<line x1="12" y1="6" x2="12" y2="8" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="12" y1="16" x2="12" y2="18" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="6" y1="12" x2="8" y2="12" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="16" y1="12" x2="18" y2="12" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="7.8" y1="7.8" x2="9.2" y2="9.2" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="14.8" y1="14.8" x2="16.2" y2="16.2" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="16.2" y1="7.8" x2="14.8" y2="9.2" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="9.2" y1="14.8" x2="7.8" y2="16.2" stroke="${color}" stroke-width="1.2"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── Naval/Airbase/Army SVG (reused from createMilitaryLayers pattern) ─────────
|
||||
|
||||
function navalFacSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="${color}" stroke-width="1.5"/>
|
||||
<line x1="7" y1="9" x2="17" y2="9" stroke="${color}" stroke-width="1.5"/>
|
||||
<path d="M7 9 Q6 13 8 15" fill="none" stroke="${color}" stroke-width="1"/>
|
||||
<path d="M17 9 Q18 13 16 15" fill="none" stroke="${color}" stroke-width="1"/>
|
||||
<path d="M8 15 Q12 18 16 15" fill="none" stroke="${color}" stroke-width="1.2"/>
|
||||
<circle cx="12" cy="5" r="1.5" fill="${color}"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function airbaseFacSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M12 5 C11.4 5 11 5.4 11 5.8 L11 10 L6 13 L6 14.2 L11 12.5 L11 16.5 L9.2 17.8 L9.2 18.8 L12 18 L14.8 18.8 L14.8 17.8 L13 16.5 L13 12.5 L18 14.2 L18 13 L13 10 L13 5.8 C13 5.4 12.6 5 12 5Z"
|
||||
fill="${color}" stroke="#fff" stroke-width="0.3"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function armyFacSvg(color: string, size: number): string {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke="${color}" stroke-width="1.2"/>
|
||||
<path d="M6 10 Q6 8 8 8 L12 7 L16 8 Q18 8 18 10 L18 12 Q18 15 12 17 Q6 15 6 12 Z" fill="${color}" opacity="0.75"/>
|
||||
<line x1="9" y1="10" x2="15" y2="10" stroke="#fff" stroke-width="0.8" opacity="0.5"/>
|
||||
<line x1="8.5" y1="12" x2="15.5" y2="12" stroke="#fff" stroke-width="0.8" opacity="0.5"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ─── Module-level icon caches ─────────────────────────────────────────────────
|
||||
|
||||
const infraIconCache = new Map<string, string>();
|
||||
const hazardIconCache = new Map<string, string>();
|
||||
const cnIconCache = new Map<string, string>();
|
||||
const jpIconCache = new Map<string, string>();
|
||||
|
||||
// ─── Hazard icon helpers ───────────────────────────────────────────────────────
|
||||
|
||||
const HAZARD_SVG: Record<string, (c: string, s: number) => string> = {
|
||||
petrochemical: petrochemSvg,
|
||||
lng: lngSvg,
|
||||
oilTank: oilTankSvg,
|
||||
hazardPort: hazPortSvg,
|
||||
nuclear: nuclearSvg,
|
||||
thermal: thermalSvg,
|
||||
shipyard: shipyardSvg,
|
||||
wastewater: wastewaterSvg,
|
||||
heavyIndustry: heavyIndustrySvg,
|
||||
};
|
||||
|
||||
const HAZARD_COLOR: Record<string, string> = {
|
||||
petrochemical: '#f97316',
|
||||
lng: '#06b6d4',
|
||||
oilTank: '#eab308',
|
||||
hazardPort: '#ef4444',
|
||||
nuclear: '#a855f7',
|
||||
thermal: '#64748b',
|
||||
shipyard: '#0ea5e9',
|
||||
wastewater: '#10b981',
|
||||
heavyIndustry: '#94a3b8',
|
||||
};
|
||||
|
||||
function getHazardIconUrl(type: string): string {
|
||||
if (!hazardIconCache.has(type)) {
|
||||
const color = HAZARD_COLOR[type] ?? '#888';
|
||||
const svgFn = HAZARD_SVG[type] ?? hazPortSvg;
|
||||
hazardIconCache.set(type, svgToDataUri(svgFn(color, 64)));
|
||||
}
|
||||
return hazardIconCache.get(type)!;
|
||||
}
|
||||
|
||||
// ─── CN icon helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
const CN_SVG: Record<string, (c: string, s: number) => string> = {
|
||||
nuclear: nuclearSvg,
|
||||
thermal: thermalSvg,
|
||||
naval: navalFacSvg,
|
||||
airbase: airbaseFacSvg,
|
||||
army: armyFacSvg,
|
||||
shipyard: shipyardSvg,
|
||||
};
|
||||
|
||||
const CN_COLOR: Record<string, string> = {
|
||||
nuclear: '#ef4444',
|
||||
thermal: '#f97316',
|
||||
naval: '#3b82f6',
|
||||
airbase: '#22d3ee',
|
||||
army: '#22c55e',
|
||||
shipyard: '#94a3b8',
|
||||
};
|
||||
|
||||
function getCnIconUrl(subType: string): string {
|
||||
if (!cnIconCache.has(subType)) {
|
||||
const color = CN_COLOR[subType] ?? '#888';
|
||||
const svgFn = CN_SVG[subType] ?? armyFacSvg;
|
||||
cnIconCache.set(subType, svgToDataUri(svgFn(color, 64)));
|
||||
}
|
||||
return cnIconCache.get(subType)!;
|
||||
}
|
||||
|
||||
// ─── JP icon helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
const JP_SVG: Record<string, (c: string, s: number) => string> = {
|
||||
nuclear: nuclearSvg,
|
||||
thermal: thermalSvg,
|
||||
naval: navalFacSvg,
|
||||
airbase: airbaseFacSvg,
|
||||
army: armyFacSvg,
|
||||
};
|
||||
|
||||
const JP_COLOR: Record<string, string> = {
|
||||
nuclear: '#ef4444',
|
||||
thermal: '#f97316',
|
||||
naval: '#3b82f6',
|
||||
airbase: '#22d3ee',
|
||||
army: '#22c55e',
|
||||
};
|
||||
|
||||
function getJpIconUrl(subType: string): string {
|
||||
if (!jpIconCache.has(subType)) {
|
||||
const color = JP_COLOR[subType] ?? '#888';
|
||||
const svgFn = JP_SVG[subType] ?? armyFacSvg;
|
||||
jpIconCache.set(subType, svgToDataUri(svgFn(color, 64)));
|
||||
}
|
||||
return jpIconCache.get(subType)!;
|
||||
}
|
||||
|
||||
// ─── createFacilityLayers ─────────────────────────────────────────────────────
|
||||
|
||||
@ -126,8 +353,8 @@ export function createFacilityLayers(
|
||||
data: plants,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name),
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getSize: 12 * sc * fc.fs,
|
||||
updateTriggers: { getSize: [sc, fc.fs] },
|
||||
getColor: (d) => [...hexToRgb(infraColor(d)), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -148,37 +375,32 @@ export function createFacilityLayers(
|
||||
const hazardTypeSet = new Set(config.hazardTypes);
|
||||
const hazardData = HAZARD_FACILITIES.filter(f => hazardTypeSet.has(f.type));
|
||||
|
||||
const HAZARD_META: Record<string, { icon: string; color: [number, number, number, number] }> = {
|
||||
petrochemical: { icon: '🏭', color: [249, 115, 22, 255] },
|
||||
lng: { icon: '🔵', color: [6, 182, 212, 255] },
|
||||
oilTank: { icon: '🛢️', color: [234, 179, 8, 255] },
|
||||
hazardPort: { icon: '⚠️', color: [239, 68, 68, 255] },
|
||||
nuclear: { icon: '☢️', color: [168, 85, 247, 255] },
|
||||
thermal: { icon: '🔥', color: [100, 116, 139, 255] },
|
||||
shipyard: { icon: '🚢', color: [14, 165, 233, 255] },
|
||||
wastewater: { icon: '💧', color: [16, 185, 129, 255] },
|
||||
heavyIndustry: { icon: '⚙️', color: [148, 163, 184, 255] },
|
||||
const HAZARD_META: Record<string, { color: [number, number, number, number] }> = {
|
||||
petrochemical: { color: [249, 115, 22, 255] },
|
||||
lng: { color: [6, 182, 212, 255] },
|
||||
oilTank: { color: [234, 179, 8, 255] },
|
||||
hazardPort: { color: [239, 68, 68, 255] },
|
||||
nuclear: { color: [168, 85, 247, 255] },
|
||||
thermal: { color: [100, 116, 139, 255] },
|
||||
shipyard: { color: [14, 165, 233, 255] },
|
||||
wastewater: { color: [16, 185, 129, 255] },
|
||||
heavyIndustry: { color: [148, 163, 184, 255] },
|
||||
};
|
||||
|
||||
if (hazardData.length > 0) {
|
||||
layers.push(
|
||||
new TextLayer<HazardFacility>({
|
||||
id: 'static-hazard-emoji',
|
||||
new IconLayer<HazardFacility>({
|
||||
id: 'static-hazard-icon',
|
||||
data: hazardData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => HAZARD_META[d.type]?.icon ?? '⚠️',
|
||||
getSize: 16 * sc,
|
||||
getIcon: (d) => ({ url: getHazardIconUrl(d.type), width: 64, height: 64, anchorX: 32, anchorY: 32 }),
|
||||
getSize: 18 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<HazardFacility>) => {
|
||||
if (info.object) onPick({ kind: 'hazard', object: info.object });
|
||||
return true;
|
||||
},
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
layers.push(
|
||||
@ -187,8 +409,8 @@ export function createFacilityLayers(
|
||||
data: hazardData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo,
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getSize: 12 * sc * fc.fs,
|
||||
updateTriggers: { getSize: [sc, fc.fs] },
|
||||
getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -207,13 +429,13 @@ export function createFacilityLayers(
|
||||
|
||||
// ── CN Facilities ──────────────────────────────────────────────────────
|
||||
{
|
||||
const CN_META: Record<string, { icon: string; color: [number, number, number, number] }> = {
|
||||
nuclear: { icon: '☢️', color: [239, 68, 68, 255] },
|
||||
thermal: { icon: '🔥', color: [249, 115, 22, 255] },
|
||||
naval: { icon: '⚓', color: [59, 130, 246, 255] },
|
||||
airbase: { icon: '✈️', color: [34, 211, 238, 255] },
|
||||
army: { icon: '🪖', color: [34, 197, 94, 255] },
|
||||
shipyard: { icon: '🚢', color: [148, 163, 184, 255] },
|
||||
const CN_META: Record<string, { color: [number, number, number, number] }> = {
|
||||
nuclear: { color: [239, 68, 68, 255] },
|
||||
thermal: { color: [249, 115, 22, 255] },
|
||||
naval: { color: [59, 130, 246, 255] },
|
||||
airbase: { color: [34, 211, 238, 255] },
|
||||
army: { color: [34, 197, 94, 255] },
|
||||
shipyard: { color: [148, 163, 184, 255] },
|
||||
};
|
||||
const cnData: CnFacility[] = [
|
||||
...(config.cnPower ? CN_POWER_PLANTS : []),
|
||||
@ -221,23 +443,18 @@ export function createFacilityLayers(
|
||||
];
|
||||
if (cnData.length > 0) {
|
||||
layers.push(
|
||||
new TextLayer<CnFacility>({
|
||||
id: 'static-cn-emoji',
|
||||
new IconLayer<CnFacility>({
|
||||
id: 'static-cn-icon',
|
||||
data: cnData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => CN_META[d.subType]?.icon ?? '📍',
|
||||
getSize: 16 * sc,
|
||||
getIcon: (d) => ({ url: getCnIconUrl(d.subType), width: 64, height: 64, anchorX: 32, anchorY: 32 }),
|
||||
getSize: 18 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<CnFacility>) => {
|
||||
if (info.object) onPick({ kind: 'cnFacility', object: info.object });
|
||||
return true;
|
||||
},
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
layers.push(
|
||||
@ -246,8 +463,8 @@ export function createFacilityLayers(
|
||||
data: cnData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.name,
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getSize: 12 * sc * fc.fs,
|
||||
updateTriggers: { getSize: [sc, fc.fs] },
|
||||
getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -266,12 +483,12 @@ export function createFacilityLayers(
|
||||
|
||||
// ── JP Facilities ──────────────────────────────────────────────────────
|
||||
{
|
||||
const JP_META: Record<string, { icon: string; color: [number, number, number, number] }> = {
|
||||
nuclear: { icon: '☢️', color: [239, 68, 68, 255] },
|
||||
thermal: { icon: '🔥', color: [249, 115, 22, 255] },
|
||||
naval: { icon: '⚓', color: [59, 130, 246, 255] },
|
||||
airbase: { icon: '✈️', color: [34, 211, 238, 255] },
|
||||
army: { icon: '🪖', color: [34, 197, 94, 255] },
|
||||
const JP_META: Record<string, { color: [number, number, number, number] }> = {
|
||||
nuclear: { color: [239, 68, 68, 255] },
|
||||
thermal: { color: [249, 115, 22, 255] },
|
||||
naval: { color: [59, 130, 246, 255] },
|
||||
airbase: { color: [34, 211, 238, 255] },
|
||||
army: { color: [34, 197, 94, 255] },
|
||||
};
|
||||
const jpData: JpFacility[] = [
|
||||
...(config.jpPower ? JP_POWER_PLANTS : []),
|
||||
@ -279,23 +496,18 @@ export function createFacilityLayers(
|
||||
];
|
||||
if (jpData.length > 0) {
|
||||
layers.push(
|
||||
new TextLayer<JpFacility>({
|
||||
id: 'static-jp-emoji',
|
||||
new IconLayer<JpFacility>({
|
||||
id: 'static-jp-icon',
|
||||
data: jpData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => JP_META[d.subType]?.icon ?? '📍',
|
||||
getSize: 16 * sc,
|
||||
getIcon: (d) => ({ url: getJpIconUrl(d.subType), width: 64, height: 64, anchorX: 32, anchorY: 32 }),
|
||||
getSize: 18 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 255],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<JpFacility>) => {
|
||||
if (info.object) onPick({ kind: 'jpFacility', object: info.object });
|
||||
return true;
|
||||
},
|
||||
billboard: false,
|
||||
characterSet: 'auto',
|
||||
}),
|
||||
);
|
||||
layers.push(
|
||||
@ -304,8 +516,8 @@ export function createFacilityLayers(
|
||||
data: jpData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.name,
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getSize: 12 * sc * fc.fs,
|
||||
updateTriggers: { getSize: [sc, fc.fs] },
|
||||
getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
|
||||
@ -312,8 +312,8 @@ export function createMilitaryLayers(
|
||||
data: MILITARY_BASES,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo),
|
||||
getSize: 11 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getSize: 11 * sc * fc.fs,
|
||||
updateTriggers: { getSize: [sc, fc.fs] },
|
||||
getColor: (d) => [...hexToRgb(MIL_BASE_TYPE_COLOR[d.type] ?? '#a78bfa'), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -350,8 +350,8 @@ export function createMilitaryLayers(
|
||||
data: GOV_BUILDINGS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo),
|
||||
getSize: 11 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getSize: 11 * sc * fc.fs,
|
||||
updateTriggers: { getSize: [sc, fc.fs] },
|
||||
getColor: (d) => [...hexToRgb(GOV_TYPE_COLOR[d.type] ?? '#f59e0b'), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -388,8 +388,8 @@ export function createMilitaryLayers(
|
||||
data: NK_LAUNCH_SITES,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo),
|
||||
getSize: 11 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getSize: 11 * sc * fc.fs,
|
||||
updateTriggers: { getSize: [sc, fc.fs] },
|
||||
getColor: (d) => [...hexToRgb(NK_LAUNCH_TYPE_META[d.type]?.color ?? '#f97316'), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -480,8 +480,8 @@ export function createMilitaryLayers(
|
||||
data: impactData,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => `${d.ev.date.slice(5)} ${d.ev.time} ← ${d.ev.launchNameKo}`,
|
||||
getSize: 11 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getSize: 11 * sc * fc.fs,
|
||||
updateTriggers: { getSize: [sc, fc.fs] },
|
||||
getColor: (d) => [...hexToRgb(getMissileColor(d.ev.type)), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
|
||||
@ -174,8 +174,8 @@ export function createNavigationLayers(
|
||||
if (d.type === 'navy') return d.name.replace('해군', '').replace('사령부', '사').replace('전대', '전').replace('전단', '전').trim().slice(0, 8);
|
||||
return d.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청';
|
||||
},
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getSize: 12 * sc * fc.fs,
|
||||
updateTriggers: { getSize: [sc, fc.fs] },
|
||||
getColor: (d) => [...hexToRgb(CG_TYPE_COLOR[d.type]), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -225,8 +225,8 @@ export function createNavigationLayers(
|
||||
data: KOREAN_AIRPORTS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo.replace('국제공항', '').replace('공항', ''),
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getSize: 12 * sc * fc.fs,
|
||||
updateTriggers: { getSize: [sc, fc.fs] },
|
||||
getColor: (d) => [...hexToRgb(apColor(d)), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -275,8 +275,8 @@ export function createNavigationLayers(
|
||||
data: NAV_WARNINGS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.id,
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getSize: 12 * sc * fc.fs,
|
||||
updateTriggers: { getSize: [sc, fc.fs] },
|
||||
getColor: (d) => [...hexToRgb(NW_ORG_COLOR[d.org]), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -326,8 +326,8 @@ export function createNavigationLayers(
|
||||
data: PIRACY_ZONES,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo,
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getSize: 12 * sc * fc.fs,
|
||||
updateTriggers: { getSize: [sc, fc.fs] },
|
||||
getColor: (d) => [...hexToRgb(PIRACY_LEVEL_COLOR[d.level] ?? '#ef4444'), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
|
||||
@ -95,8 +95,8 @@ export function createPortLayers(
|
||||
data: EAST_ASIA_PORTS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => d.nameKo.replace('항', ''),
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getSize: 12 * sc * fc.fs,
|
||||
updateTriggers: { getSize: [sc, fc.fs] },
|
||||
getColor: (d) => [...hexToRgb(PORT_COUNTRY_COLOR[d.country] ?? PORT_COUNTRY_COLOR.KR), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
@ -133,8 +133,8 @@ export function createPortLayers(
|
||||
data: KOREA_WIND_FARMS,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name),
|
||||
getSize: 12 * sc,
|
||||
updateTriggers: { getSize: [sc] },
|
||||
getSize: 12 * sc * fc.fs,
|
||||
updateTriggers: { getSize: [sc, fc.fs] },
|
||||
getColor: [...hexToRgb(WIND_COLOR), 255] as [number, number, number, number],
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
|
||||
@ -32,7 +32,8 @@ export interface StaticPickInfo {
|
||||
}
|
||||
|
||||
export interface LayerFactoryConfig {
|
||||
sc: number; // sizeScale
|
||||
sc: number; // sizeScale (zoom-based)
|
||||
fs: number; // fontScale (user preference, default 1.0)
|
||||
onPick: (info: StaticPickInfo) => void;
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
import type { Ship, VesselAnalysisDto } from '../types';
|
||||
import { useFontScale } from './useFontScale';
|
||||
|
||||
interface AnalyzedShip {
|
||||
ship: Ship;
|
||||
@ -57,6 +58,8 @@ export function useAnalysisDeckLayers(
|
||||
activeFilter: string | null,
|
||||
sizeScale: number = 1.0,
|
||||
): Layer[] {
|
||||
const { fontScale } = useFontScale();
|
||||
const afs = fontScale.analysis;
|
||||
// 데이터 준비: ships 필터/정렬/슬라이스 — sizeScale 변경 시 재실행 안 됨
|
||||
const { riskData, darkData, spoofData } = useMemo<AnalysisData>(() => {
|
||||
if (analysisMap.size === 0) {
|
||||
@ -123,7 +126,7 @@ export function useAnalysisDeckLayers(
|
||||
const name = d.ship.name || d.ship.mmsi;
|
||||
return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`;
|
||||
},
|
||||
getSize: 10 * sizeScale,
|
||||
getSize: 10 * sizeScale * afs,
|
||||
updateTriggers: { getSize: [sizeScale] },
|
||||
getColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [200, 200, 200, 255],
|
||||
getTextAnchor: 'middle',
|
||||
@ -167,7 +170,7 @@ export function useAnalysisDeckLayers(
|
||||
const gap = d.dto.algorithms.darkVessel.gapDurationMin;
|
||||
return gap > 0 ? `AIS 소실 ${Math.round(gap)}분` : 'DARK';
|
||||
},
|
||||
getSize: 10 * sizeScale,
|
||||
getSize: 10 * sizeScale * afs,
|
||||
updateTriggers: { getSize: [sizeScale] },
|
||||
getColor: [168, 85, 247, 255],
|
||||
getTextAnchor: 'middle',
|
||||
@ -191,7 +194,7 @@ export function useAnalysisDeckLayers(
|
||||
data: spoofData,
|
||||
getPosition: (d) => [d.ship.lng, d.ship.lat],
|
||||
getText: (d) => `GPS ${Math.round(d.dto.algorithms.gpsSpoofing.spoofingScore * 100)}%`,
|
||||
getSize: 10 * sizeScale,
|
||||
getSize: 10 * sizeScale * afs,
|
||||
getColor: [239, 68, 68, 255],
|
||||
getTextAnchor: 'start',
|
||||
getPixelOffset: [12, -8],
|
||||
@ -207,5 +210,5 @@ export function useAnalysisDeckLayers(
|
||||
}
|
||||
|
||||
return layers;
|
||||
}, [riskData, darkData, spoofData, sizeScale, activeFilter]);
|
||||
}, [riskData, darkData, spoofData, sizeScale, activeFilter, afs]);
|
||||
}
|
||||
|
||||
4
frontend/src/hooks/useFontScale.ts
Normal file
4
frontend/src/hooks/useFontScale.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { useContext } from 'react';
|
||||
import { FontScaleCtx } from '../contexts/fontScaleState';
|
||||
|
||||
export function useFontScale() { return useContext(FontScaleCtx); }
|
||||
@ -1,16 +1,19 @@
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { fetchEvents } from '../services/api';
|
||||
import { fetchAircraftFromBackend } from '../services/aircraftApi';
|
||||
import { fetchEvents, fetchEventsByRange } from '../services/api';
|
||||
import { fetchAircraftFromBackend, fetchAircraftByRange } from '../services/aircraftApi';
|
||||
import { getSampleAircraft } from '../data/sampleAircraft';
|
||||
import { fetchSatelliteTLE, propagateAll } from '../services/celestrak';
|
||||
import { fetchShips } from '../services/ships';
|
||||
import { fetchOsintFeed } from '../services/osint';
|
||||
import { fetchOsintFeed, fetchOsintByRange } from '../services/osint';
|
||||
import type { OsintItem } from '../services/osint';
|
||||
import { fetchSeismic, fetchPressure } from '../services/sensorApi';
|
||||
import type { SeismicDto, PressureDto } from '../services/sensorApi';
|
||||
import { propagateAircraft, propagateShips } from '../services/propagation';
|
||||
import { getMarineTrafficCategory } from '../utils/marineTraffic';
|
||||
import type { GeoEvent, Aircraft, Ship, Satellite, SatellitePosition, AppMode } from '../types';
|
||||
import { damagedShips } from '../data/damagedShips';
|
||||
|
||||
type DataSource = 'dummy' | 'api';
|
||||
|
||||
interface UseIranDataArgs {
|
||||
appMode: AppMode;
|
||||
@ -20,6 +23,7 @@ interface UseIranDataArgs {
|
||||
hiddenShipCategories: Set<string>;
|
||||
refreshKey: number;
|
||||
dashboardTab: 'iran' | 'korea';
|
||||
dataSource?: DataSource;
|
||||
}
|
||||
|
||||
interface UseIranDataResult {
|
||||
@ -52,7 +56,10 @@ export function useIranData({
|
||||
hiddenShipCategories,
|
||||
refreshKey,
|
||||
dashboardTab,
|
||||
dataSource = 'dummy',
|
||||
}: UseIranDataArgs): UseIranDataResult {
|
||||
const IRAN_T0 = '2026-03-01T00:00:00Z';
|
||||
const isApi = dataSource === 'api';
|
||||
const [events, setEvents] = useState<GeoEvent[]>([]);
|
||||
const [seismicData, setSeismicData] = useState<SeismicDto[]>([]);
|
||||
const [pressureData, setPressureData] = useState<PressureDto[]>([]);
|
||||
@ -66,11 +73,15 @@ export function useIranData({
|
||||
const sensorInitRef = useRef(false);
|
||||
const shipMapRef = useRef<Map<string, Ship>>(new Map());
|
||||
|
||||
// Load initial data
|
||||
// Load initial data (events + satellites)
|
||||
useEffect(() => {
|
||||
if (isApi) {
|
||||
fetchEventsByRange(IRAN_T0, new Date().toISOString()).then(setEvents).catch(() => {});
|
||||
} else {
|
||||
fetchEvents().then(setEvents).catch(() => {});
|
||||
}
|
||||
fetchSatelliteTLE().then(setSatellites).catch(() => {});
|
||||
}, [refreshKey]);
|
||||
}, [refreshKey, isApi]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Sensor data: initial full 48h load + 10min polling (incremental merge)
|
||||
useEffect(() => {
|
||||
@ -103,20 +114,23 @@ export function useIranData({
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshKey]);
|
||||
|
||||
// Fetch base aircraft data (LIVE: backend, REPLAY: sample)
|
||||
// Fetch base aircraft data (LIVE: backend, REPLAY: sample or API)
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
if (appMode === 'live') {
|
||||
const result = await fetchAircraftFromBackend('iran');
|
||||
if (result.length > 0) setBaseAircraft(result);
|
||||
} else if (isApi) {
|
||||
const result = await fetchAircraftByRange('iran', IRAN_T0, new Date().toISOString());
|
||||
if (result.length > 0) setBaseAircraft(result);
|
||||
} else {
|
||||
setBaseAircraft(getSampleAircraft());
|
||||
}
|
||||
};
|
||||
load();
|
||||
const interval = setInterval(load, 60_000);
|
||||
const interval = setInterval(load, appMode === 'live' ? 60_000 : 300_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [appMode, refreshKey]);
|
||||
}, [appMode, refreshKey, isApi]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fetch Iran ship data: initial 60min, then 5min polling with 6min window + merge + stale cleanup
|
||||
const mergeShips = useCallback((newShips: Ship[]) => {
|
||||
@ -164,15 +178,20 @@ export function useIranData({
|
||||
if (!shouldFetch) { setOsintFeed([]); return; }
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await fetchOsintFeed('iran');
|
||||
let data: OsintItem[];
|
||||
if (isApi && !isLive) {
|
||||
data = await fetchOsintByRange('iran', IRAN_T0, new Date().toISOString());
|
||||
} else {
|
||||
data = await fetchOsintFeed('iran');
|
||||
}
|
||||
if (data.length > 0) setOsintFeed(data);
|
||||
} catch { /* keep previous */ }
|
||||
};
|
||||
setOsintFeed([]);
|
||||
load();
|
||||
const interval = setInterval(load, 120_000);
|
||||
const interval = setInterval(load, isApi ? 300_000 : 120_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isLive, dashboardTab, refreshKey]);
|
||||
}, [isLive, dashboardTab, refreshKey, isApi]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Propagate satellite positions — throttle to every 2s of real time
|
||||
useEffect(() => {
|
||||
@ -259,11 +278,27 @@ export function useIranData({
|
||||
});
|
||||
}, [osintFeed, dashboardTab]);
|
||||
|
||||
// 기본 이벤트 + OSINT 이벤트 병합 (시간순 정렬)
|
||||
// 피격 선박 → GeoEvent 변환
|
||||
const damageEvents = useMemo<GeoEvent[]>(() =>
|
||||
damagedShips.map(s => ({
|
||||
id: `dmg-${s.id}`,
|
||||
timestamp: s.damagedAt,
|
||||
lat: s.lat,
|
||||
lng: s.lng,
|
||||
type: 'sea_attack' as const,
|
||||
source: s.flag === 'IR' ? 'IR' as const : undefined,
|
||||
label: `${s.name} (${s.flag}) — ${s.cause}`,
|
||||
description: s.description,
|
||||
intensity: s.damage === 'sunk' ? 100 : s.damage === 'severe' ? 75 : s.damage === 'moderate' ? 50 : 25,
|
||||
})),
|
||||
[]);
|
||||
|
||||
// 기본 이벤트 + OSINT 이벤트 + 피격 선박 병합 (시간순 정렬)
|
||||
// API 모드: DB에 이미 sampleEvents+damagedShips 포함 → damageEvents 중복 방지
|
||||
const mergedEvents = useMemo(() => {
|
||||
if (osintEvents.length === 0) return events;
|
||||
return [...events, ...osintEvents].sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [events, osintEvents]);
|
||||
const extra = isApi ? [] : damageEvents;
|
||||
return [...events, ...osintEvents, ...extra].sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [events, osintEvents, damageEvents, isApi]);
|
||||
|
||||
// Aircraft stats
|
||||
const aircraftByCategory = useMemo(() => {
|
||||
|
||||
@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
import type { PowerFacility } from '../services/infra';
|
||||
import type { HazardType } from '../data/hazardFacilities';
|
||||
import { useFontScale } from './useFontScale';
|
||||
|
||||
// Re-export types for consumers
|
||||
export type { StaticPickedObject, StaticLayerKind, StaticPickInfo } from './layers/types';
|
||||
@ -34,8 +35,9 @@ interface StaticLayerConfig {
|
||||
}
|
||||
|
||||
export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] {
|
||||
const { fontScale } = useFontScale();
|
||||
return useMemo(() => {
|
||||
const fc = { sc: config.sizeScale ?? 1.0, onPick: config.onPick };
|
||||
const fc = { sc: config.sizeScale ?? 1.0, fs: fontScale.facility, onPick: config.onPick };
|
||||
|
||||
return [
|
||||
...createPortLayers({ ports: config.ports, windFarm: config.windFarm }, fc),
|
||||
@ -81,5 +83,6 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] {
|
||||
config.jpMilitary,
|
||||
config.onPick,
|
||||
config.sizeScale,
|
||||
fontScale.facility,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -19,3 +19,17 @@ export async function fetchAircraftFromBackend(region: 'iran' | 'korea'): Promis
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 시점 범위 조회 (리플레이용) */
|
||||
export async function fetchAircraftByRange(region: string, from: string, to: string): Promise<Aircraft[]> {
|
||||
try {
|
||||
const res = await fetch(`/api/kcg/aircraft?region=${region}&from=${from}&to=${to}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.items ?? data.aircraft ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,12 +10,32 @@ const defaultConfig: ApiConfig = {
|
||||
let cachedSensorData: SensorLog[] | null = null;
|
||||
|
||||
export async function fetchEvents(_config?: Partial<ApiConfig>): Promise<GeoEvent[]> {
|
||||
// In production, replace with actual API call:
|
||||
// const res = await fetch(config.eventsEndpoint);
|
||||
// return res.json();
|
||||
// 더미 모드: sampleEvents 반환
|
||||
return Promise.resolve(sampleEvents);
|
||||
}
|
||||
|
||||
/** Backend DB에서 이벤트 범위 조회 (API 모드 리플레이용) */
|
||||
export async function fetchEventsByRange(from: string, to: string): Promise<GeoEvent[]> {
|
||||
try {
|
||||
const res = await fetch(`/api/kcg/events?from=${from}&to=${to}`);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return (data.items ?? []).map((d: Record<string, unknown>) => ({
|
||||
id: String(d.id ?? ''),
|
||||
timestamp: Number(d.timestamp ?? 0),
|
||||
lat: Number(d.lat ?? 0),
|
||||
lng: Number(d.lng ?? 0),
|
||||
type: (String(d.type ?? 'alert')) as GeoEvent['type'],
|
||||
source: d.source ? String(d.source) as GeoEvent['source'] : undefined,
|
||||
label: String(d.label ?? ''),
|
||||
description: d.description ? String(d.description) : undefined,
|
||||
intensity: d.intensity != null ? Number(d.intensity) : undefined,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSensorData(_config?: Partial<ApiConfig>): Promise<SensorLog[]> {
|
||||
// In production, replace with actual API call:
|
||||
// const res = await fetch(config.sensorEndpoint);
|
||||
|
||||
@ -945,3 +945,29 @@ export async function fetchOsintFeed(focus: 'iran' | 'korea' = 'iran'): Promise<
|
||||
|
||||
return unique.slice(0, 50); // cap at 50 items
|
||||
}
|
||||
|
||||
/** 시점 범위 조회 (리플레이용) — Backend DB에서 날짜 범위로 OSINT 조회 */
|
||||
export async function fetchOsintByRange(region: string, from: string, to: string): Promise<OsintItem[]> {
|
||||
try {
|
||||
const res = await fetch(`/api/kcg/osint?region=${region}&from=${from}&to=${to}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const items = data.items ?? [];
|
||||
return items.map((d: Record<string, unknown>) => ({
|
||||
id: String(d.id ?? ''),
|
||||
timestamp: Number(d.timestamp ?? 0),
|
||||
title: String(d.title ?? ''),
|
||||
source: String(d.source ?? ''),
|
||||
url: String(d.url ?? ''),
|
||||
category: (d.category as OsintItem['category']) ?? 'general',
|
||||
language: (d.language as OsintItem['language']) ?? 'other',
|
||||
imageUrl: d.imageUrl ? String(d.imageUrl) : undefined,
|
||||
lat: d.lat != null ? Number(d.lat) : undefined,
|
||||
lng: d.lng != null ? Number(d.lng) : undefined,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ export interface GeoEvent {
|
||||
timestamp: number; // unix ms
|
||||
lat: number;
|
||||
lng: number;
|
||||
type: 'airstrike' | 'explosion' | 'missile_launch' | 'intercept' | 'alert' | 'impact' | 'osint';
|
||||
type: 'airstrike' | 'explosion' | 'missile_launch' | 'intercept' | 'alert' | 'impact' | 'osint' | 'sea_attack';
|
||||
source?: 'US' | 'IL' | 'IR' | 'proxy'; // 공격 주체: 미국, 이스라엘, 이란, 대리세력
|
||||
label: string;
|
||||
description?: string;
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user