feat(map): 해저케이블 레이어 및 정보 패널 구현
- subcable entity 생성 (타입 정의 + 데이터 로딩 hook) - MapLibre 레이어: 케이블 라인 + 호버 하이라이트 + 라벨 - 지도 표시 설정에 해저케이블 토글 추가 - 클릭 시 우측 정보 패널 (길이, 개통, 운영사, landing points) - Map3D + DashboardPage 통합 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
621a5037c2
커밋
ca5560aff2
52
apps/web/src/entities/subcable/api/useSubcables.ts
Normal file
52
apps/web/src/entities/subcable/api/useSubcables.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { SubcableGeoJson, SubcableDetailsIndex, SubcableDetail } from '../model/types';
|
||||
|
||||
interface SubcableData {
|
||||
geo: SubcableGeoJson;
|
||||
details: Map<string, SubcableDetail>;
|
||||
}
|
||||
|
||||
export function useSubcables(
|
||||
geoUrl = '/data/subcables/cable-geo.json',
|
||||
detailsUrl = '/data/subcables/cable-details.min.json',
|
||||
) {
|
||||
const [data, setData] = useState<SubcableData | null>(null);
|
||||
const [error, setError] = useState<string | null>(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<string, SubcableDetail>();
|
||||
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 };
|
||||
}
|
||||
39
apps/web/src/entities/subcable/model/types.ts
Normal file
39
apps/web/src/entities/subcable/model/types.ts
Normal file
@ -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<string, SubcableDetail>;
|
||||
}
|
||||
@ -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 (
|
||||
|
||||
@ -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<Bbox | null>(null);
|
||||
@ -117,6 +120,7 @@ export function DashboardPage() {
|
||||
fleetCircles: true,
|
||||
predictVectors: true,
|
||||
shipLabels: true,
|
||||
subcables: false,
|
||||
});
|
||||
const [fleetRelationSortMode, setFleetRelationSortMode] = useState<FleetRelationSortMode>("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<string | null>(null);
|
||||
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
|
||||
|
||||
const [settings, setSettings] = useState<Map3DSettings>({
|
||||
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))}
|
||||
/>
|
||||
<MapLegend />
|
||||
{selectedLegacyVessel ? (
|
||||
@ -718,6 +729,13 @@ export function DashboardPage() {
|
||||
) : selectedTarget ? (
|
||||
<AisInfoPanel target={selectedTarget} legacy={selectedLegacyInfo} onClose={() => setSelectedMmsi(null)} />
|
||||
) : null}
|
||||
{selectedCableId && subcableData?.details.get(selectedCableId) ? (
|
||||
<SubcableInfoPanel
|
||||
detail={subcableData.details.get(selectedCableId)!}
|
||||
color={subcableData.geo.features.find((f) => f.properties.id === selectedCableId)?.properties.color}
|
||||
onClose={() => setSelectedCableId(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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 },
|
||||
|
||||
229
apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts
Normal file
229
apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts
Normal file
@ -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<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
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]);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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 = {
|
||||
|
||||
107
apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx
Normal file
107
apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx
Normal file
@ -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 (
|
||||
<div className="map-info" style={{ maxWidth: 340 }}>
|
||||
<button className="close-btn" onClick={onClose} aria-label="close">
|
||||
✕
|
||||
</button>
|
||||
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{color && (
|
||||
<div
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 3,
|
||||
backgroundColor: color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div style={{ fontSize: 16, fontWeight: 900, color: 'var(--accent)' }}>{detail.name}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>
|
||||
Submarine Cable{detail.is_planned ? ' (Planned)' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ir">
|
||||
<span className="il">길이</span>
|
||||
<span className="iv">{detail.length || '-'}</span>
|
||||
</div>
|
||||
<div className="ir">
|
||||
<span className="il">개통</span>
|
||||
<span className="iv">{detail.rfs || '-'}</span>
|
||||
</div>
|
||||
{detail.owners && (
|
||||
<div className="ir" style={{ alignItems: 'flex-start' }}>
|
||||
<span className="il">운영사</span>
|
||||
<span className="iv" style={{ wordBreak: 'break-word' }}>
|
||||
{detail.owners}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{detail.suppliers && (
|
||||
<div className="ir">
|
||||
<span className="il">공급사</span>
|
||||
<span className="iv">{detail.suppliers}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{landingCount > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--muted)', marginBottom: 4 }}>
|
||||
Landing Points ({landingCount}) · {countries.length} countries
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 140,
|
||||
overflowY: 'auto',
|
||||
fontSize: 10,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
{detail.landing_points.map((lp) => (
|
||||
<div key={lp.id}>
|
||||
<span style={{ color: 'var(--muted)' }}>{lp.country}</span>{' '}
|
||||
<b>{lp.name}</b>
|
||||
{lp.is_tbd && <span style={{ color: '#F59E0B', marginLeft: 4 }}>TBD</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.notes && (
|
||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--muted)', fontStyle: 'italic' }}>
|
||||
{detail.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.url && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<a
|
||||
href={detail.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: 10, color: 'var(--accent)' }}
|
||||
>
|
||||
Official website ↗
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user