diff --git a/apps/web/src/entities/subcable/api/useSubcables.ts b/apps/web/src/entities/subcable/api/useSubcables.ts new file mode 100644 index 0000000..76ba125 --- /dev/null +++ b/apps/web/src/entities/subcable/api/useSubcables.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react'; +import type { SubcableGeoJson, SubcableDetailsIndex, SubcableDetail } from '../model/types'; + +interface SubcableData { + geo: SubcableGeoJson; + details: Map; +} + +export function useSubcables( + geoUrl = '/data/subcables/cable-geo.json', + detailsUrl = '/data/subcables/cable-details.min.json', +) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function run() { + try { + setError(null); + const [geoRes, detailsRes] = await Promise.all([ + fetch(geoUrl), + fetch(detailsUrl), + ]); + if (!geoRes.ok) throw new Error(`Failed to load subcable geo: ${geoRes.status}`); + if (!detailsRes.ok) throw new Error(`Failed to load subcable details: ${detailsRes.status}`); + + const geo = (await geoRes.json()) as SubcableGeoJson; + const detailsJson = (await detailsRes.json()) as SubcableDetailsIndex; + if (cancelled) return; + + const details = new Map(); + for (const [id, detail] of Object.entries(detailsJson.by_id)) { + details.set(id, detail); + } + + setData({ geo, details }); + } catch (e) { + if (cancelled) return; + setError(e instanceof Error ? e.message : String(e)); + } + } + + void run(); + return () => { + cancelled = true; + }; + }, [geoUrl, detailsUrl]); + + return { data, error }; +} diff --git a/apps/web/src/entities/subcable/model/types.ts b/apps/web/src/entities/subcable/model/types.ts new file mode 100644 index 0000000..268e8f6 --- /dev/null +++ b/apps/web/src/entities/subcable/model/types.ts @@ -0,0 +1,39 @@ +export interface SubcableFeatureProperties { + id: string; + name: string; + color: string; + feature_id: string; + coordinates: [number, number]; +} + +export type SubcableGeoJson = GeoJSON.FeatureCollection< + GeoJSON.MultiLineString, + SubcableFeatureProperties +>; + +export interface SubcableLandingPoint { + id: string; + name: string; + country: string; + is_tbd: boolean; +} + +export interface SubcableDetail { + id: string; + name: string; + length: string | null; + rfs: string | null; + rfs_year: number | null; + is_planned: boolean; + owners: string | null; + suppliers: string | null; + landing_points: SubcableLandingPoint[]; + notes: string | null; + url: string | null; +} + +export interface SubcableDetailsIndex { + version: number; + generated_at: string; + by_id: Record; +} diff --git a/apps/web/src/features/mapToggles/MapToggles.tsx b/apps/web/src/features/mapToggles/MapToggles.tsx index cf2722a..cb4e550 100644 --- a/apps/web/src/features/mapToggles/MapToggles.tsx +++ b/apps/web/src/features/mapToggles/MapToggles.tsx @@ -6,6 +6,7 @@ export type MapToggleState = { fleetCircles: boolean; predictVectors: boolean; shipLabels: boolean; + subcables: boolean; }; type Props = { @@ -22,6 +23,7 @@ export function MapToggles({ value, onToggle }: Props) { { id: "zones", label: "수역 표시" }, { id: "predictVectors", label: "예측 벡터" }, { id: "shipLabels", label: "선박명 표시" }, + { id: "subcables", label: "해저케이블" }, ]; return ( diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 2d24f41..bbc2489 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -11,6 +11,7 @@ import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib"; import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib"; import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types"; import { useZones } from "../../entities/zone/api/useZones"; +import { useSubcables } from "../../entities/subcable/api/useSubcables"; import type { VesselTypeCode } from "../../entities/vessel/model/types"; import { AisInfoPanel } from "../../widgets/aisInfo/AisInfoPanel"; import { AisTargetList } from "../../widgets/aisTargetList/AisTargetList"; @@ -21,6 +22,7 @@ import { RelationsPanel } from "../../widgets/relations/RelationsPanel"; import { SpeedProfilePanel } from "../../widgets/speed/SpeedProfilePanel"; import { Topbar } from "../../widgets/topbar/Topbar"; import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel"; +import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel"; import { VesselList } from "../../widgets/vesselList/VesselList"; import { buildLegacyHitMap, @@ -70,6 +72,7 @@ function useLegacyIndex(data: LegacyVesselDataset | null): LegacyVesselIndex | n export function DashboardPage() { const { data: zones, error: zonesError } = useZones(); const { data: legacyData, error: legacyError } = useLegacyVessels(); + const { data: subcableData } = useSubcables(); const legacyIndex = useLegacyIndex(legacyData); const [viewBbox, setViewBbox] = useState(null); @@ -117,6 +120,7 @@ export function DashboardPage() { fleetCircles: true, predictVectors: true, shipLabels: true, + subcables: false, }); const [fleetRelationSortMode, setFleetRelationSortMode] = useState("count"); @@ -126,6 +130,9 @@ export function DashboardPage() { const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined); + const [hoveredCableId, setHoveredCableId] = useState(null); + const [selectedCableId, setSelectedCableId] = useState(null); + const [settings, setSettings] = useState({ showShips: true, showDensity: false, @@ -711,6 +718,10 @@ export function DashboardPage() { setHoveredFleetOwnerKey(null); setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : [])); }} + subcableGeo={subcableData?.geo ?? null} + hoveredCableId={hoveredCableId} + onHoverCable={setHoveredCableId} + onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))} /> {selectedLegacyVessel ? ( @@ -718,6 +729,13 @@ export function DashboardPage() { ) : selectedTarget ? ( setSelectedMmsi(null)} /> ) : null} + {selectedCableId && subcableData?.details.get(selectedCableId) ? ( + f.properties.id === selectedCableId)?.properties.color} + onClose={() => setSelectedCableId(null)} + /> + ) : null} ); diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 1d519c8..b00753c 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -25,6 +25,7 @@ import { useGlobeShips } from './hooks/useGlobeShips'; import { useGlobeOverlays } from './hooks/useGlobeOverlays'; import { useGlobeInteraction } from './hooks/useGlobeInteraction'; import { useDeckLayers } from './hooks/useDeckLayers'; +import { useSubcablesLayer } from './hooks/useSubcablesLayer'; export type { Map3DSettings, BaseMapId, MapProjectionId } from './types'; @@ -59,6 +60,10 @@ export function Map3D({ onClearMmsiHover, onHoverPair, onClearPairHover, + subcableGeo = null, + hoveredCableId = null, + onHoverCable, + onClickCable, }: Props) { void onHoverFleet; void onClearFleetHover; @@ -500,6 +505,20 @@ export function Map3D({ }, ); + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function + const noopCable = useCallback((_: string | null) => {}, []); + + useSubcablesLayer( + mapRef, projectionBusyRef, reorderGlobeFeatureLayers, + { + subcableGeo: subcableGeo ?? null, + overlays, projection, mapSyncEpoch, + hoveredCableId: hoveredCableId ?? null, + onHoverCable: onHoverCable ?? noopCable, + onClickCable: onClickCable ?? noopCable, + }, + ); + useFlyTo( mapRef, projectionRef, { selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom }, diff --git a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts new file mode 100644 index 0000000..38f7ff3 --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts @@ -0,0 +1,229 @@ +import { useEffect, useRef, type MutableRefObject } from 'react'; +import maplibregl, { type LayerSpecification } from 'maplibre-gl'; +import type { SubcableGeoJson } from '../../../entities/subcable/model/types'; +import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; +import type { MapProjectionId } from '../types'; +import { ensureGeoJsonSource, ensureLayer, setLayerVisibility, cleanupLayers } from '../lib/layerHelpers'; +import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; + +const SRC_ID = 'subcables-src'; +const LINE_ID = 'subcables-line'; +const LINE_HOVER_ID = 'subcables-line-hover'; +const LABEL_ID = 'subcables-label'; + +export function useSubcablesLayer( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + reorderGlobeFeatureLayers: () => void, + opts: { + subcableGeo: SubcableGeoJson | null; + overlays: MapToggleState; + projection: MapProjectionId; + mapSyncEpoch: number; + hoveredCableId: string | null; + onHoverCable: (cableId: string | null) => void; + onClickCable: (cableId: string | null) => void; + }, +) { + const { subcableGeo, overlays, projection, mapSyncEpoch, hoveredCableId, onHoverCable, onClickCable } = opts; + + const onHoverRef = useRef(onHoverCable); + const onClickRef = useRef(onClickCable); + onHoverRef.current = onHoverCable; + onClickRef.current = onClickCable; + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const ensure = () => { + if (projectionBusyRef.current) return; + + const visibility = overlays.subcables ? 'visible' : 'none'; + setLayerVisibility(map, LINE_ID, overlays.subcables); + setLayerVisibility(map, LINE_HOVER_ID, overlays.subcables); + setLayerVisibility(map, LABEL_ID, overlays.subcables); + + if (!subcableGeo) return; + if (!map.isStyleLoaded()) return; + + try { + ensureGeoJsonSource(map, SRC_ID, subcableGeo); + + const before = map.getLayer('zones-fill') + ? 'zones-fill' + : map.getLayer('deck-globe') + ? 'deck-globe' + : undefined; + + ensureLayer( + map, + { + id: LINE_ID, + type: 'line', + source: SRC_ID, + paint: { + 'line-color': ['get', 'color'], + 'line-opacity': ['interpolate', ['linear'], ['zoom'], 2, 0.4, 6, 0.55, 10, 0.7], + 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 0.8, 6, 1.2, 10, 1.8], + }, + layout: { visibility, 'line-cap': 'round', 'line-join': 'round' }, + } as unknown as LayerSpecification, + { before }, + ); + + ensureLayer( + map, + { + id: LINE_HOVER_ID, + type: 'line', + source: SRC_ID, + paint: { + 'line-color': ['get', 'color'], + 'line-opacity': 0, + 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 5, 10, 8], + }, + filter: ['==', ['get', 'id'], ''], + layout: { visibility, 'line-cap': 'round', 'line-join': 'round' }, + } as unknown as LayerSpecification, + { before }, + ); + + ensureLayer( + map, + { + id: LABEL_ID, + type: 'symbol', + source: SRC_ID, + layout: { + visibility, + 'symbol-placement': 'line', + 'text-field': ['get', 'name'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 4, 9, 8, 11, 12, 13], + 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], + 'text-allow-overlap': false, + 'text-padding': 8, + 'text-rotation-alignment': 'map', + }, + paint: { + 'text-color': 'rgba(210,225,240,0.78)', + 'text-halo-color': 'rgba(2,6,23,0.85)', + 'text-halo-width': 1.0, + 'text-halo-blur': 0.6, + 'text-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0, 5, 0.7, 8, 0.85], + }, + minzoom: 4, + } as unknown as LayerSpecification, + ); + + // Update hover highlight + if (hoveredCableId) { + if (map.getLayer(LINE_ID)) { + map.setPaintProperty(LINE_ID, 'line-opacity', [ + 'case', + ['==', ['get', 'id'], hoveredCableId], + 0.95, + ['interpolate', ['linear'], ['zoom'], 2, 0.25, 6, 0.35, 10, 0.45], + ] as never); + map.setPaintProperty(LINE_ID, 'line-width', [ + 'case', + ['==', ['get', 'id'], hoveredCableId], + ['interpolate', ['linear'], ['zoom'], 2, 2.0, 6, 2.8, 10, 3.5], + ['interpolate', ['linear'], ['zoom'], 2, 0.6, 6, 0.9, 10, 1.4], + ] as never); + } + if (map.getLayer(LINE_HOVER_ID)) { + map.setFilter(LINE_HOVER_ID, ['==', ['get', 'id'], hoveredCableId] as never); + map.setPaintProperty(LINE_HOVER_ID, 'line-opacity', 0.25); + } + } else { + if (map.getLayer(LINE_ID)) { + map.setPaintProperty( + LINE_ID, + 'line-opacity', + ['interpolate', ['linear'], ['zoom'], 2, 0.4, 6, 0.55, 10, 0.7] as never, + ); + map.setPaintProperty( + LINE_ID, + 'line-width', + ['interpolate', ['linear'], ['zoom'], 2, 0.8, 6, 1.2, 10, 1.8] as never, + ); + } + if (map.getLayer(LINE_HOVER_ID)) { + map.setFilter(LINE_HOVER_ID, ['==', ['get', 'id'], ''] as never); + map.setPaintProperty(LINE_HOVER_ID, 'line-opacity', 0); + } + } + } catch (e) { + console.warn('Subcables layer setup failed:', e); + } finally { + reorderGlobeFeatureLayers(); + kickRepaint(map); + } + }; + + const stop = onMapStyleReady(map, ensure); + return () => { + stop(); + }; + }, [subcableGeo, overlays.subcables, projection, mapSyncEpoch, hoveredCableId, reorderGlobeFeatureLayers]); + + // Mouse events + useEffect(() => { + const map = mapRef.current; + if (!map) return; + if (!overlays.subcables) return; + + const onMouseEnter = (e: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => { + const cableId = e.features?.[0]?.properties?.id; + if (typeof cableId === 'string' && cableId) { + map.getCanvas().style.cursor = 'pointer'; + onHoverRef.current(cableId); + } + }; + + const onMouseLeave = () => { + map.getCanvas().style.cursor = ''; + onHoverRef.current(null); + }; + + const onClick = (e: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => { + const cableId = e.features?.[0]?.properties?.id; + if (typeof cableId === 'string' && cableId) { + onClickRef.current(cableId); + } + }; + + const addEvents = () => { + if (!map.getLayer(LINE_ID)) return; + map.on('mouseenter', LINE_ID, onMouseEnter); + map.on('mouseleave', LINE_ID, onMouseLeave); + map.on('click', LINE_ID, onClick); + }; + + if (map.isStyleLoaded() && map.getLayer(LINE_ID)) { + addEvents(); + } else { + map.once('idle', addEvents); + } + + return () => { + try { + map.off('mouseenter', LINE_ID, onMouseEnter); + map.off('mouseleave', LINE_ID, onMouseLeave); + map.off('click', LINE_ID, onClick); + } catch { + // ignore + } + }; + }, [overlays.subcables, mapSyncEpoch]); + + // Cleanup on unmount + useEffect(() => { + const mapInstance = mapRef.current; + return () => { + if (!mapInstance) return; + cleanupLayers(mapInstance, [LABEL_ID, LINE_HOVER_ID, LINE_ID], [SRC_ID]); + }; + }, []); +} diff --git a/apps/web/src/widgets/map3d/lib/layerHelpers.ts b/apps/web/src/widgets/map3d/lib/layerHelpers.ts index ff62d63..0c335e5 100644 --- a/apps/web/src/widgets/map3d/lib/layerHelpers.ts +++ b/apps/web/src/widgets/map3d/lib/layerHelpers.ts @@ -37,6 +37,9 @@ const GLOBE_NATIVE_LAYER_IDS = [ 'fleet-circles-ml-fill', 'fleet-circles-ml', 'pair-range-ml', + 'subcables-line', + 'subcables-line-hover', + 'subcables-label', 'deck-globe', ]; @@ -48,6 +51,7 @@ const GLOBE_NATIVE_SOURCE_IDS = [ 'fleet-circles-ml-src', 'fleet-circles-ml-fill-src', 'pair-range-ml-src', + 'subcables-src', ]; export function clearGlobeNativeLayers(map: maplibregl.Map) { diff --git a/apps/web/src/widgets/map3d/types.ts b/apps/web/src/widgets/map3d/types.ts index b884e4a..98358f6 100644 --- a/apps/web/src/widgets/map3d/types.ts +++ b/apps/web/src/widgets/map3d/types.ts @@ -1,5 +1,6 @@ import type { AisTarget } from '../../entities/aisTarget/model/types'; import type { LegacyVesselInfo } from '../../entities/legacyVessel/model/types'; +import type { SubcableGeoJson } from '../../entities/subcable/model/types'; import type { ZonesGeoJson } from '../../entities/zone/api/useZones'; import type { MapToggleState } from '../../features/mapToggles/MapToggles'; import type { FcLink, FleetCircle, PairLink } from '../../features/legacyDashboard/model/types'; @@ -45,6 +46,10 @@ export interface Map3DProps { onClearMmsiHover?: () => void; onHoverPair?: (mmsiList: number[]) => void; onClearPairHover?: () => void; + subcableGeo?: SubcableGeoJson | null; + hoveredCableId?: string | null; + onHoverCable?: (cableId: string | null) => void; + onClickCable?: (cableId: string | null) => void; } export type DashSeg = { diff --git a/apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx b/apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx new file mode 100644 index 0000000..9bfec56 --- /dev/null +++ b/apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx @@ -0,0 +1,107 @@ +import type { SubcableDetail } from '../../entities/subcable/model/types'; + +interface Props { + detail: SubcableDetail; + color?: string; + onClose: () => void; +} + +export function SubcableInfoPanel({ detail, color, onClose }: Props) { + const landingCount = detail.landing_points.length; + const countries = [...new Set(detail.landing_points.map((lp) => lp.country).filter(Boolean))]; + + return ( +
+ + +
+
+ {color && ( +
+ )} +
{detail.name}
+
+
+ Submarine Cable{detail.is_planned ? ' (Planned)' : ''} +
+
+ +
+ 길이 + {detail.length || '-'} +
+
+ 개통 + {detail.rfs || '-'} +
+ {detail.owners && ( +
+ 운영사 + + {detail.owners} + +
+ )} + {detail.suppliers && ( +
+ 공급사 + {detail.suppliers} +
+ )} + + {landingCount > 0 && ( +
+
+ Landing Points ({landingCount}) · {countries.length} countries +
+
+ {detail.landing_points.map((lp) => ( +
+ {lp.country}{' '} + {lp.name} + {lp.is_tbd && TBD} +
+ ))} +
+
+ )} + + {detail.notes && ( +
+ {detail.notes} +
+ )} + + {detail.url && ( + + )} +
+ ); +}