From b0d51a94901b36a05d32baaef320f0971defde06 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 12:11:39 +0900 Subject: [PATCH 01/58] fix(map): sync deck overlays with maplibre globe --- apps/web/src/widgets/map3d/Map3D.tsx | 311 +++++++++++++----- .../widgets/map3d/MaplibreDeckCustomLayer.ts | 130 ++++++++ 2 files changed, 364 insertions(+), 77 deletions(-) create mode 100644 apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 546e190..879ee26 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -1,8 +1,10 @@ import { HexagonLayer } from "@deck.gl/aggregation-layers"; -import { IconLayer, GeoJsonLayer, LineLayer, ScatterplotLayer } from "@deck.gl/layers"; +import { IconLayer, LineLayer, ScatterplotLayer } from "@deck.gl/layers"; import { MapboxOverlay } from "@deck.gl/mapbox"; -import { MapView, _GlobeView as GlobeView, type PickingInfo } from "@deck.gl/core"; +import { type PickingInfo } from "@deck.gl/core"; import maplibregl, { + type GeoJSONSource, + type GeoJSONSourceSpecification, type LayerSpecification, type RasterDEMSourceSpecification, type StyleSpecification, @@ -16,7 +18,7 @@ import type { ZoneId } from "../../entities/zone/model/meta"; import { ZONE_META } from "../../entities/zone/model/meta"; import type { MapToggleState } from "../../features/mapToggles/MapToggles"; import type { FcLink, FleetCircle, PairLink } from "../../features/legacyDashboard/model/types"; -import { hexToRgb } from "../../shared/lib/color/hexToRgb"; +import { MaplibreDeckCustomLayer } from "./MaplibreDeckCustomLayer"; export type Map3DSettings = { showSeamark: boolean; @@ -59,6 +61,18 @@ function isFiniteNumber(x: unknown): x is number { return typeof x === "number" && Number.isFinite(x); } +const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius` +const DEG2RAD = Math.PI / 180; + +function lngLatToUnitSphere(lon: number, lat: number, altitudeMeters = 0): [number, number, number] { + const lambda = lon * DEG2RAD; + const phi = lat * DEG2RAD; + const cosPhi = Math.cos(phi); + const s = 1 + altitudeMeters / EARTH_RADIUS_M; + // MapLibre globe space: x = east, y = north, z = lon=0 at equator. + return [Math.sin(lambda) * cosPhi * s, Math.sin(phi) * s, Math.cos(lambda) * cosPhi * s]; +} + const LEGACY_CODE_COLORS: Record = { PT: [30, 64, 175], // #1e40af "PT-S": [234, 88, 12], // #ea580c @@ -433,10 +447,6 @@ type PairRangeCircle = { const DECK_VIEW_ID = "mapbox"; -function getDeckView(projection: MapProjectionId) { - return projection === "globe" ? new GlobeView({ id: DECK_VIEW_ID }) : new MapView({ id: DECK_VIEW_ID }); -} - export function Map3D({ targets, zones, @@ -455,6 +465,7 @@ export function Map3D({ const containerRef = useRef(null); const mapRef = useRef(null); const overlayRef = useRef(null); + const globeDeckLayerRef = useRef(null); const showSeamarkRef = useRef(settings.showSeamark); const baseMapRef = useRef(baseMap); const projectionRef = useRef(projection); @@ -509,13 +520,22 @@ export function Map3D({ map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), "top-left"); map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: "metric" }), "bottom-left"); - // NOTE: `MapboxOverlayProps`'s TS typing pins `DeckProps` generics to `null`, - // which makes `views` type `null`. Runtime supports `views`; cast to keep TS happy. - overlay = new MapboxOverlay({ interleaved: true, layers: [], views: getDeckView(projectionRef.current) } as unknown as never); - map.addControl(overlay); - mapRef.current = map; - overlayRef.current = overlay; + + // Initial Deck integration: + // - mercator: MapboxOverlay interleaved (fast, feature-rich) + // - globe: MapLibre custom layer that feeds Deck the globe MVP matrix (keeps basemap+layers aligned) + if (projectionRef.current === "mercator") { + overlay = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never); + map.addControl(overlay); + overlayRef.current = overlay; + } else { + globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ + id: "deck-globe", + viewId: DECK_VIEW_ID, + deckProps: { layers: [] }, + }); + } function applyProjection() { if (!map) return; @@ -533,6 +553,14 @@ export function Map3D({ // Ensure the seamark raster overlay exists even when using MapTiler vector styles. map.on("style.load", () => { applyProjection(); + // Globe deck layer lives inside the style and must be re-added after any style swap. + if (projectionRef.current === "globe" && globeDeckLayerRef.current && !map!.getLayer(globeDeckLayerRef.current.id)) { + try { + map!.addLayer(globeDeckLayerRef.current); + } catch { + // ignore + } + } if (!showSeamarkRef.current) return; try { ensureSeamarkOverlay(map!, "bathymetry-lines"); @@ -587,6 +615,7 @@ export function Map3D({ } overlayRef.current = null; + globeDeckLayerRef.current = null; mapRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -598,23 +627,7 @@ export function Map3D({ if (!map) return; let cancelled = false; - // Recreate the Deck overlay so the Deck instance uses the correct View type - // (MapView vs GlobeView) and stays in sync with MapLibre. - try { - const old = overlayRef.current; - if (old) map.removeControl(old); - } catch { - // ignore - } - try { - const next = new MapboxOverlay({ interleaved: true, layers: [], views: getDeckView(projection) } as unknown as never); - map.addControl(next); - overlayRef.current = next; - } catch (e) { - console.warn("Deck overlay re-create failed:", e); - } - - const applyProjection = () => { + const syncProjectionAndDeck = () => { if (cancelled) return; try { map.setProjection({ type: projection }); @@ -622,15 +635,66 @@ export function Map3D({ } catch (e) { console.warn("Projection switch failed:", e); } + + if (projection === "globe") { + // Tear down MapboxOverlay (mercator) and use a MapLibre custom layer that renders Deck + // with MapLibre's globe MVP matrix. This avoids the Deck <-> MapLibre globe mismatch. + const old = overlayRef.current; + if (old) { + try { + old.finalize(); + } catch { + // ignore + } + overlayRef.current = null; + } + + if (!globeDeckLayerRef.current) { + globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ + id: "deck-globe", + viewId: DECK_VIEW_ID, + deckProps: { layers: [] }, + }); + } + + const layer = globeDeckLayerRef.current; + if (layer && map.isStyleLoaded() && !map.getLayer(layer.id)) { + try { + map.addLayer(layer); + } catch { + // ignore + } + } + } else { + // Tear down globe custom layer (if present), restore MapboxOverlay interleaved. + const globeLayer = globeDeckLayerRef.current; + if (globeLayer && map.getLayer(globeLayer.id)) { + try { + map.removeLayer(globeLayer.id); + } catch { + // ignore + } + } + + if (!overlayRef.current) { + try { + const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never); + map.addControl(next); + overlayRef.current = next; + } catch (e) { + console.warn("Deck overlay create failed:", e); + } + } + } }; - if (map.isStyleLoaded()) applyProjection(); - else map.once("style.load", applyProjection); + if (map.isStyleLoaded()) syncProjectionAndDeck(); + else map.once("style.load", syncProjectionAndDeck); return () => { cancelled = true; try { - map.off("style.load", applyProjection); + map.off("style.load", syncProjectionAndDeck); } catch { // ignore } @@ -691,10 +755,121 @@ export function Map3D({ } }, [settings.showSeamark]); + // Zones (MapLibre-native GeoJSON layers; works in both mercator + globe) + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = "zones-src"; + const fillId = "zones-fill"; + const lineId = "zones-line"; + + const zoneColorExpr: unknown[] = ["match", ["get", "zoneId"]]; + for (const k of Object.keys(ZONE_META) as ZoneId[]) { + zoneColorExpr.push(k, ZONE_META[k].color); + } + zoneColorExpr.push("#3B82F6"); + + const ensure = () => { + // Always update visibility if the layers exist. + const visibility = overlays.zones ? "visible" : "none"; + try { + if (map.getLayer(fillId)) map.setLayoutProperty(fillId, "visibility", visibility); + } catch { + // ignore + } + try { + if (map.getLayer(lineId)) map.setLayoutProperty(lineId, "visibility", visibility); + } catch { + // ignore + } + + if (!zones) return; + if (!map.isStyleLoaded()) return; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) { + existing.setData(zones); + } else { + map.addSource(srcId, { type: "geojson", data: zones } as GeoJSONSourceSpecification); + } + + // Keep zones below Deck layers (ships / deck-globe), and below seamarks if enabled. + const style = map.getStyle(); + const firstSymbol = (style.layers || []).find((l) => (l as { type?: string } | undefined)?.type === "symbol") as + | { id?: string } + | undefined; + const before = map.getLayer("deck-globe") + ? "deck-globe" + : map.getLayer("ships") + ? "ships" + : map.getLayer("seamark") + ? "seamark" + : firstSymbol?.id; + + if (!map.getLayer(fillId)) { + map.addLayer( + { + id: fillId, + type: "fill", + source: srcId, + paint: { + "fill-color": zoneColorExpr as never, + "fill-opacity": 0.12, + }, + layout: { visibility }, + } as unknown as LayerSpecification, + before, + ); + } + + if (!map.getLayer(lineId)) { + map.addLayer( + { + id: lineId, + type: "line", + source: srcId, + paint: { + "line-color": zoneColorExpr as never, + "line-opacity": 0.85, + "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1], + }, + layout: { visibility }, + } as unknown as LayerSpecification, + before, + ); + } + } catch (e) { + console.warn("Zones layer setup failed:", e); + } + }; + + if (map.isStyleLoaded()) ensure(); + map.on("style.load", ensure); + return () => { + try { + map.off("style.load", ensure); + } catch { + // ignore + } + }; + }, [zones, overlays.zones]); + const shipData = useMemo(() => { return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon)); }, [targets]); + const globePosByMmsi = useMemo(() => { + if (projection !== "globe") return null; + const m = new Map(); + for (const t of shipData) { + // Slightly above the sea surface to keep the icon readable and avoid depth-fighting. + m.set(t.mmsi, lngLatToUnitSphere(t.lon, t.lat, 12)); + } + return m; + }, [projection, shipData]); + const legacyTargets = useMemo(() => { if (!legacyHits) return []; return shipData.filter((t) => legacyHits.has(t.mmsi)); @@ -727,14 +902,15 @@ export function Map3D({ // Update Deck.gl layers useEffect(() => { - const overlay = overlayRef.current; const map = mapRef.current; - if (!overlay || !map) return; + if (!map) return; + const deckTarget = projection === "globe" ? globeDeckLayerRef.current : overlayRef.current; + if (!deckTarget) return; const overlayParams = projection === "globe" ? GLOBE_OVERLAY_PARAMS : DEPTH_DISABLED_PARAMS; const layers = []; - if (settings.showDensity) { + if (settings.showDensity && projection !== "globe") { layers.push( new HexagonLayer({ id: "density", @@ -750,42 +926,13 @@ export function Map3D({ ); } - if (overlays.zones && zones) { - layers.push( - new GeoJsonLayer({ - id: "zones", - data: zones, - pickable: true, - // Avoid z-fighting flicker with other layers in the shared MapLibre depth buffer. - parameters: overlayParams, - stroked: true, - filled: true, - getFillColor: (f) => { - const zoneId = (f.properties as { zoneId?: string } | undefined)?.zoneId as ZoneId | undefined; - const col = zoneId ? ZONE_META[zoneId]?.color : "#3B82F6"; - const [r, g, b] = hexToRgb(col); - return [r, g, b, 22]; - }, - getLineColor: (f) => { - const zoneId = (f.properties as { zoneId?: string } | undefined)?.zoneId as ZoneId | undefined; - const col = zoneId ? ZONE_META[zoneId]?.color : "#3B82F6"; - const [r, g, b] = hexToRgb(col); - return [r, g, b, 200]; - }, - lineWidthMinPixels: 1, - lineWidthMaxPixels: 2, - getLineWidth: 1, - }), - ); - } - - if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) { + if (overlays.fleetCircles && projection !== "globe" && (fleetCircles?.length ?? 0) > 0) { layers.push( new ScatterplotLayer({ id: "fleet-circles", data: fleetCircles, pickable: false, - billboard: projection === "globe", + billboard: false, parameters: overlayParams, filled: false, stroked: true, @@ -799,13 +946,13 @@ export function Map3D({ ); } - if (overlays.pairRange && pairRanges.length > 0) { + if (overlays.pairRange && projection !== "globe" && pairRanges.length > 0) { layers.push( new ScatterplotLayer({ id: "pair-range", data: pairRanges, pickable: false, - billboard: projection === "globe", + billboard: false, parameters: overlayParams, filled: false, stroked: true, @@ -827,8 +974,8 @@ export function Map3D({ data: pairLinks, pickable: false, parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, + getSourcePosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.from[0], d.from[1]) : d.from), + getTargetPosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.to[0], d.to[1]) : d.to), getColor: (d) => (d.warn ? [245, 158, 11, 220] : [59, 130, 246, 85]), getWidth: (d) => (d.warn ? 2.2 : 1.4), widthUnits: "pixels", @@ -843,8 +990,8 @@ export function Map3D({ data: fcDashed, pickable: false, parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, + getSourcePosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.from[0], d.from[1]) : d.from), + getTargetPosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.to[0], d.to[1]) : d.to), getColor: (d) => (d.suspicious ? [239, 68, 68, 220] : [217, 119, 6, 200]), getWidth: () => 1.3, widthUnits: "pixels", @@ -873,7 +1020,10 @@ export function Map3D({ if (!rgb) return [245, 158, 11, 200]; return [rgb[0], rgb[1], rgb[2], 200]; }, - getPosition: (d) => [d.lon, d.lat], + getPosition: (d) => + projection === "globe" + ? (globePosByMmsi?.get(d.mmsi) ?? lngLatToUnitSphere(d.lon, d.lat, 12)) + : ([d.lon, d.lat] as [number, number]), updateTriggers: { getRadius: [selectedMmsi], getLineColor: [legacyHits], @@ -895,7 +1045,10 @@ export function Map3D({ iconAtlas: "/assets/ship.svg", iconMapping: SHIP_ICON_MAPPING, getIcon: () => "ship", - getPosition: (d) => [d.lon, d.lat], + getPosition: (d) => + projection === "globe" + ? (globePosByMmsi?.get(d.mmsi) ?? lngLatToUnitSphere(d.lon, d.lat, 12)) + : ([d.lon, d.lat] as [number, number]), getAngle: (d) => (isFiniteNumber(d.cog) ? d.cog : 0), sizeUnits: "pixels", getSize: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 34 : 22), @@ -909,7 +1062,7 @@ export function Map3D({ ); } - overlay.setProps({ + const deckProps = { layers, getTooltip: (info: PickingInfo) => { if (!info.object) return null; @@ -962,7 +1115,10 @@ export function Map3D({ map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 }); } }, - }); + } as const; + + if (projection === "globe") globeDeckLayerRef.current?.setProps(deckProps); + else overlayRef.current?.setProps(deckProps as unknown as never); }, [ projection, shipData, @@ -982,6 +1138,7 @@ export function Map3D({ pairRanges, fcDashed, fleetCircles, + globePosByMmsi, ]); return
; diff --git a/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts b/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts new file mode 100644 index 0000000..c6baec1 --- /dev/null +++ b/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts @@ -0,0 +1,130 @@ +import { Deck, MapController, View, Viewport, type DeckProps } from "@deck.gl/core"; +import type maplibregl from "maplibre-gl"; + +type MatrixViewState = { + // MapLibre provides a full world->clip matrix as `modelViewProjectionMatrix`. + // We treat it as the viewport's projection matrix and keep viewMatrix identity. + projectionMatrix: number[]; + viewMatrix?: number[]; + // Deck's View state is constrained to include transition props. We only need one overlapping key + // to satisfy TS structural checks without pulling in internal deck.gl types. + transitionDuration?: number; +}; + +class MatrixView extends View { + getViewportType(viewState: MatrixViewState): typeof Viewport { + void viewState; + return Viewport; + } + + // Controller isn't used (Deck is created with `controller: false`) but View requires one. + protected get ControllerType(): typeof MapController { + return MapController; + } +} + +const IDENTITY_4x4: number[] = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + +function readMat4(m: ArrayLike): number[] { + const out = new Array(16); + for (let i = 0; i < 16; i++) out[i] = m[i] as number; + return out; +} + +function mat4Changed(a: number[] | undefined, b: ArrayLike): boolean { + if (!a || a.length !== 16) return true; + // The matrix values change on map move/rotate. A strict compare is fine. + for (let i = 0; i < 16; i++) { + if (a[i] !== (b[i] as number)) return true; + } + return false; +} + +export class MaplibreDeckCustomLayer implements maplibregl.CustomLayerInterface { + id: string; + type = "custom" as const; + renderingMode = "3d" as const; + + private _map: maplibregl.Map | null = null; + private _deck: Deck | null = null; + private _deckProps: Partial> = {}; + private _viewId: string; + private _lastMvp: number[] | undefined; + + constructor(opts: { id: string; viewId: string; deckProps?: Partial> }) { + this.id = opts.id; + this._viewId = opts.viewId; + this._deckProps = opts.deckProps ?? {}; + } + + get deck(): Deck | null { + return this._deck; + } + + setProps(next: Partial>) { + this._deckProps = { ...this._deckProps, ...next }; + if (this._deck) this._deck.setProps(this._deckProps as DeckProps); + this._map?.triggerRepaint(); + } + + onAdd(map: maplibregl.Map, gl: WebGLRenderingContext | WebGL2RenderingContext): void { + this._map = map; + + const deck = new Deck({ + ...this._deckProps, + // Share MapLibre's WebGL context + canvas (single context). + gl: gl as WebGL2RenderingContext, + canvas: map.getCanvas(), + width: null, + height: null, + // Let MapLibre own pointer/touch behaviors on the shared canvas. + touchAction: "none", + controller: false, + views: this._deckProps.views ?? [new MatrixView({ id: this._viewId })], + viewState: this._deckProps.viewState ?? { [this._viewId]: { projectionMatrix: IDENTITY_4x4 } }, + // Only request a repaint when Deck thinks it needs one. Drawing happens in `render()`. + _customRender: () => map.triggerRepaint(), + parameters: { + // Match @deck.gl/mapbox interleaved defaults (premultiplied blending). + depthWriteEnabled: true, + depthCompare: "less-equal", + depthBias: 0, + blend: true, + blendColorSrcFactor: "src-alpha", + blendColorDstFactor: "one-minus-src-alpha", + blendAlphaSrcFactor: "one", + blendAlphaDstFactor: "one-minus-src-alpha", + blendColorOperation: "add", + blendAlphaOperation: "add", + ...this._deckProps.parameters, + }, + }); + + this._deck = deck; + } + + onRemove(): void { + this._deck?.finalize(); + this._deck = null; + this._map = null; + this._lastMvp = undefined; + } + + render(_gl: WebGLRenderingContext | WebGL2RenderingContext, options: maplibregl.CustomRenderMethodInput): void { + const deck = this._deck; + if (!deck) return; + + // MapLibre gives us a world->clip matrix for the current projection (mercator/globe). + // For globe, this matrix expects unit-sphere world coordinates (see MapLibre's globe transform). + if (mat4Changed(this._lastMvp, options.modelViewProjectionMatrix)) { + const projectionMatrix = readMat4(options.modelViewProjectionMatrix); + this._lastMvp = projectionMatrix; + deck.setProps({ viewState: { [this._viewId]: { projectionMatrix, viewMatrix: IDENTITY_4x4 } } }); + } + + deck._drawLayers("maplibre-custom", { + clearCanvas: false, + clearStack: true, + }); + } +} From 0172ed61347330330d1fcf0dd67adb335aba50cd Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 12:36:25 +0900 Subject: [PATCH 02/58] fix(globe): keep deck instance across style reloads --- apps/web/src/widgets/map3d/Map3D.tsx | 52 ++++++++++++++++--- .../widgets/map3d/MaplibreDeckCustomLayer.ts | 46 ++++++++++++++-- 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 879ee26..8d9de7e 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -601,14 +601,21 @@ export function Map3D({ }); })(); - return () => { - cancelled = true; - controller.abort(); + return () => { + cancelled = true; + controller.abort(); - if (map) { - map.remove(); - map = null; - } + // If we are unmounting, ensure the globe Deck instance is finalized (style reload would keep it alive). + try { + globeDeckLayerRef.current?.requestFinalize(); + } catch { + // ignore + } + + if (map) { + map.remove(); + map = null; + } if (overlay) { overlay.finalize(); overlay = null; @@ -670,6 +677,7 @@ export function Map3D({ const globeLayer = globeDeckLayerRef.current; if (globeLayer && map.getLayer(globeLayer.id)) { try { + globeLayer.requestFinalize(); map.removeLayer(globeLayer.id); } catch { // ignore @@ -728,6 +736,36 @@ export function Map3D({ }; }, [baseMap]); + // Globe rendering + bathymetry tuning. + // Some terrain/hillshade/extrusion effects look unstable under globe and can occlude Deck overlays. + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const apply = () => { + if (!map.isStyleLoaded()) return; + const disableBathy3D = projection === "globe" && baseMap === "enhanced"; + const vis = disableBathy3D ? "none" : "visible"; + for (const id of ["bathymetry-extrusion", "bathymetry-hillshade"]) { + try { + if (map.getLayer(id)) map.setLayoutProperty(id, "visibility", vis); + } catch { + // ignore + } + } + }; + + if (map.isStyleLoaded()) apply(); + map.on("style.load", apply); + return () => { + try { + map.off("style.load", apply); + } catch { + // ignore + } + }; + }, [projection, baseMap]); + // seamark toggle useEffect(() => { const map = mapRef.current; diff --git a/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts b/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts index c6baec1..0fae915 100644 --- a/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts +++ b/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts @@ -50,6 +50,7 @@ export class MaplibreDeckCustomLayer implements maplibregl.CustomLayerInterface private _deckProps: Partial> = {}; private _viewId: string; private _lastMvp: number[] | undefined; + private _finalizeOnRemove: boolean = false; constructor(opts: { id: string; viewId: string; deckProps?: Partial> }) { this.id = opts.id; @@ -61,6 +62,10 @@ export class MaplibreDeckCustomLayer implements maplibregl.CustomLayerInterface return this._deck; } + requestFinalize() { + this._finalizeOnRemove = true; + } + setProps(next: Partial>) { this._deckProps = { ...this._deckProps, ...next }; if (this._deck) this._deck.setProps(this._deckProps as DeckProps); @@ -68,8 +73,22 @@ export class MaplibreDeckCustomLayer implements maplibregl.CustomLayerInterface } onAdd(map: maplibregl.Map, gl: WebGLRenderingContext | WebGL2RenderingContext): void { + this._finalizeOnRemove = false; this._map = map; + if (this._deck) { + // Re-attached after a style change; keep the existing Deck instance so we don't reuse + // finalized Layer objects (Deck does not allow that). + this._lastMvp = undefined; + this._deck.setProps({ + ...this._deckProps, + canvas: map.getCanvas(), + // Ensure any pending redraw requests trigger a map repaint again. + _customRender: () => map.triggerRepaint(), + } as DeckProps); + return; + } + const deck = new Deck({ ...this._deckProps, // Share MapLibre's WebGL context + canvas (single context). @@ -104,15 +123,36 @@ export class MaplibreDeckCustomLayer implements maplibregl.CustomLayerInterface } onRemove(): void { - this._deck?.finalize(); - this._deck = null; + const deck = this._deck; + const map = this._map; this._map = null; this._lastMvp = undefined; + + if (!deck) return; + + if (this._finalizeOnRemove) { + deck.finalize(); + this._deck = null; + return; + } + + // Likely a base style swap; keep Deck instance alive and re-attach in onAdd(). + // Disable repaint requests until we get re-attached. + try { + deck.setProps({ _customRender: () => void 0 } as Partial>); + } catch { + // ignore + } + try { + map?.triggerRepaint(); + } catch { + // ignore + } } render(_gl: WebGLRenderingContext | WebGL2RenderingContext, options: maplibregl.CustomRenderMethodInput): void { const deck = this._deck; - if (!deck) return; + if (!deck || !deck.isInitialized) return; // MapLibre gives us a world->clip matrix for the current projection (mercator/globe). // For globe, this matrix expects unit-sphere world coordinates (see MapLibre's globe transform). From d4859eb361e3cfe0a92f04654bc90dfe6593daf7 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 13:03:05 +0900 Subject: [PATCH 03/58] fix(globe): stabilize deck draw; billboard ships --- apps/web/src/widgets/map3d/Map3D.tsx | 324 +++++++++++++++++- .../widgets/map3d/MaplibreDeckCustomLayer.ts | 7 + 2 files changed, 316 insertions(+), 15 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 8d9de7e..47497e6 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -82,6 +82,15 @@ const LEGACY_CODE_COLORS: Record = { FC: [245, 158, 11], // #f59e0b }; +const LEGACY_CODE_HEX: Record = { + PT: "#1e40af", + "PT-S": "#ea580c", + GN: "#10b981", + OT: "#8b5cf6", + PS: "#ef4444", + FC: "#f59e0b", +}; + const DEPTH_DISABLED_PARAMS = { // In MapLibre+Deck (interleaved), the map often leaves a depth buffer populated. // For 2D overlays like zones/icons/halos we want stable painter's-order rendering @@ -466,6 +475,7 @@ export function Map3D({ const mapRef = useRef(null); const overlayRef = useRef(null); const globeDeckLayerRef = useRef(null); + const prevGlobeSelectedRef = useRef(null); const showSeamarkRef = useRef(settings.showSeamark); const baseMapRef = useRef(baseMap); const projectionRef = useRef(projection); @@ -894,6 +904,289 @@ export function Map3D({ }; }, [zones, overlays.zones]); + // Ships in globe mode: render with MapLibre symbol layers so icons can be aligned to the globe surface. + // Deck IconLayer billboards or uses a fixed plane, which looks like ships are pointing into the sky/ground on globe. + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const imgId = "ship-globe-icon"; + const srcId = "ships-globe-src"; + const haloId = "ships-globe-halo"; + const outlineId = "ships-globe-outline"; + const symbolId = "ships-globe"; + + const remove = () => { + for (const id of [symbolId, outlineId, haloId]) { + try { + if (map.getLayer(id)) map.removeLayer(id); + } catch { + // ignore + } + } + try { + if (map.getSource(srcId)) map.removeSource(srcId); + } catch { + // ignore + } + prevGlobeSelectedRef.current = null; + }; + + const ensureImage = () => { + if (map.hasImage(imgId)) return; + const size = 96; + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // Simple top-down ship silhouette, pointing north. + ctx.clearRect(0, 0, size, size); + ctx.fillStyle = "rgba(255,255,255,1)"; + ctx.beginPath(); + ctx.moveTo(size / 2, 6); + ctx.lineTo(size / 2 - 14, 24); + ctx.lineTo(size / 2 - 18, 58); + ctx.lineTo(size / 2 - 10, 88); + ctx.lineTo(size / 2 + 10, 88); + ctx.lineTo(size / 2 + 18, 58); + ctx.lineTo(size / 2 + 14, 24); + ctx.closePath(); + ctx.fill(); + + ctx.fillRect(size / 2 - 8, 34, 16, 18); + + const img = ctx.getImageData(0, 0, size, size); + map.addImage(imgId, img, { pixelRatio: 2 }); + }; + + const speedColorExpr: unknown[] = [ + "case", + [">=", ["to-number", ["get", "sog"]], 10], + "#3b82f6", + [">=", ["to-number", ["get", "sog"]], 1], + "#22c55e", + "#64748b", + ]; + + const codeColorExpr: unknown[] = ["match", ["get", "code"]]; + for (const [k, hex] of Object.entries(LEGACY_CODE_HEX)) codeColorExpr.push(k, hex); + codeColorExpr.push(speedColorExpr); + + const ensure = () => { + if (!map.isStyleLoaded()) return; + + if (projection !== "globe" || !settings.showShips) { + remove(); + return; + } + + try { + ensureImage(); + } catch (e) { + console.warn("Ship icon image setup failed:", e); + } + + const globeShipData = targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon)); + const geojson: GeoJSON.FeatureCollection = { + type: "FeatureCollection", + features: globeShipData.map((t) => { + const legacy = legacyHits?.get(t.mmsi) ?? null; + return { + type: "Feature", + id: t.mmsi, + geometry: { type: "Point", coordinates: [t.lon, t.lat] }, + properties: { + mmsi: t.mmsi, + name: t.name || "", + cog: isFiniteNumber(t.cog) ? t.cog : 0, + sog: isFiniteNumber(t.sog) ? t.sog : 0, + permitted: !!legacy, + code: legacy?.shipCode || "", + }, + }; + }), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(geojson); + else map.addSource(srcId, { type: "geojson", data: geojson } as GeoJSONSourceSpecification); + } catch (e) { + console.warn("Ship source setup failed:", e); + return; + } + + const visibility = settings.showShips ? "visible" : "none"; + const circleRadius = [ + "case", + ["boolean", ["feature-state", "selected"], false], + ["interpolate", ["linear"], ["zoom"], 3, 5, 7, 8, 10, 10, 14, 14], + ["interpolate", ["linear"], ["zoom"], 3, 4, 7, 6, 10, 8, 14, 11], + ] as unknown as number[]; + + // Put ships at the top so they're always visible (especially important under globe projection). + const before = undefined; + + if (!map.getLayer(haloId)) { + try { + map.addLayer( + { + id: haloId, + type: "circle", + source: srcId, + layout: { visibility }, + paint: { + "circle-radius": circleRadius as never, + "circle-color": codeColorExpr as never, + "circle-opacity": 0.22, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn("Ship halo layer add failed:", e); + } + } else { + try { + map.setLayoutProperty(haloId, "visibility", visibility); + } catch { + // ignore + } + } + + if (!map.getLayer(outlineId)) { + try { + map.addLayer( + { + id: outlineId, + type: "circle", + source: srcId, + layout: { visibility }, + paint: { + "circle-radius": circleRadius as never, + "circle-color": "rgba(0,0,0,0)", + "circle-stroke-color": codeColorExpr as never, + "circle-stroke-width": [ + "case", + ["boolean", ["get", "permitted"], false], + ["case", ["boolean", ["feature-state", "selected"], false], 2.5, 1.6], + ["case", ["boolean", ["feature-state", "selected"], false], 2.0, 0.0], + ] as unknown as number[], + "circle-stroke-opacity": 0.8, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn("Ship outline layer add failed:", e); + } + } else { + try { + map.setLayoutProperty(outlineId, "visibility", visibility); + } catch { + // ignore + } + } + + if (!map.getLayer(symbolId)) { + try { + map.addLayer( + { + id: symbolId, + type: "symbol", + source: srcId, + layout: { + visibility, + "icon-image": imgId, + "icon-size": ["interpolate", ["linear"], ["zoom"], 3, 0.32, 7, 0.42, 10, 0.52, 14, 0.72], + "icon-allow-overlap": true, + "icon-ignore-placement": true, + "icon-anchor": "center", + "icon-rotate": ["get", "cog"], + // Keep rotation relative to the map (true-north), but billboard to camera so it + // doesn't look like it's pointing into the sky/ground on globe. + "icon-rotation-alignment": "map", + "icon-pitch-alignment": "viewport", + }, + paint: { + "icon-opacity": ["case", ["boolean", ["feature-state", "selected"], false], 1.0, 0.92], + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn("Ship symbol layer add failed:", e); + } + } else { + try { + map.setLayoutProperty(symbolId, "visibility", visibility); + } catch { + // ignore + } + } + + // Apply selection state for highlight. + try { + const prev = prevGlobeSelectedRef.current; + if (prev && prev !== selectedMmsi) map.setFeatureState({ source: srcId, id: prev }, { selected: false }); + } catch { + // ignore + } + try { + if (selectedMmsi) map.setFeatureState({ source: srcId, id: selectedMmsi }, { selected: true }); + } catch { + // ignore + } + prevGlobeSelectedRef.current = selectedMmsi; + }; + + ensure(); + map.on("style.load", ensure); + return () => { + try { + map.off("style.load", ensure); + } catch { + // ignore + } + }; + }, [projection, settings.showShips, targets, legacyHits, selectedMmsi]); + + // Globe ship click selection (MapLibre-native ships layer) + useEffect(() => { + const map = mapRef.current; + if (!map) return; + if (projection !== "globe" || !settings.showShips) return; + + const symbolId = "ships-globe"; + + const onClick = (e: maplibregl.MapMouseEvent) => { + try { + const feats = map.queryRenderedFeatures(e.point, { layers: [symbolId] }); + const f = feats?.[0]; + const props = (f?.properties || {}) as Record; + const mmsi = Number(props.mmsi); + if (Number.isFinite(mmsi)) { + onSelectMmsi(mmsi); + return; + } + } catch { + // ignore + } + onSelectMmsi(null); + }; + + map.on("click", onClick); + return () => { + try { + map.off("click", onClick); + } catch { + // ignore + } + }; + }, [projection, settings.showShips, onSelectMmsi]); + const shipData = useMemo(() => { return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon)); }, [targets]); @@ -1037,13 +1330,13 @@ export function Map3D({ ); } - if (settings.showShips && legacyTargets.length > 0) { + if (settings.showShips && projection !== "globe" && legacyTargets.length > 0) { layers.push( new ScatterplotLayer({ id: "legacy-halo", data: legacyTargets, pickable: false, - billboard: projection === "globe", + billboard: false, // This ring is most prone to z-fighting, so force it into pure painter's-order rendering. parameters: overlayParams, filled: false, @@ -1059,9 +1352,7 @@ export function Map3D({ return [rgb[0], rgb[1], rgb[2], 200]; }, getPosition: (d) => - projection === "globe" - ? (globePosByMmsi?.get(d.mmsi) ?? lngLatToUnitSphere(d.lon, d.lat, 12)) - : ([d.lon, d.lat] as [number, number]), + [d.lon, d.lat] as [number, number], updateTriggers: { getRadius: [selectedMmsi], getLineColor: [legacyHits], @@ -1070,23 +1361,20 @@ export function Map3D({ ); } - if (settings.showShips) { + if (settings.showShips && projection !== "globe") { layers.push( new IconLayer({ id: "ships", data: shipData, pickable: true, - // Mercator: keep icons horizontal on the sea surface when view is pitched/rotated. - // Globe: billboard to keep the icon visible and glued to the globe. - billboard: projection === "globe", - parameters: projection === "globe" ? ({ ...overlayParams, cullMode: "none" } as const) : overlayParams, + // Keep icons horizontal on the sea surface when view is pitched/rotated. + billboard: false, + parameters: overlayParams, iconAtlas: "/assets/ship.svg", iconMapping: SHIP_ICON_MAPPING, getIcon: () => "ship", getPosition: (d) => - projection === "globe" - ? (globePosByMmsi?.get(d.mmsi) ?? lngLatToUnitSphere(d.lon, d.lat, 12)) - : ([d.lon, d.lat] as [number, number]), + [d.lon, d.lat] as [number, number], getAngle: (d) => (isFiniteNumber(d.cog) ? d.cog : 0), sizeUnits: "pixels", getSize: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 34 : 22), @@ -1102,7 +1390,10 @@ export function Map3D({ const deckProps = { layers, - getTooltip: (info: PickingInfo) => { + getTooltip: + projection === "globe" + ? undefined + : (info: PickingInfo) => { if (!info.object) return null; if (info.layer && info.layer.id === "density") { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1139,7 +1430,10 @@ export function Map3D({ if (label) return { text: label }; return null; }, - onClick: (info: PickingInfo) => { + onClick: + projection === "globe" + ? undefined + : (info: PickingInfo) => { if (!info.object) { onSelectMmsi(null); return; diff --git a/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts b/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts index 0fae915..d27e703 100644 --- a/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts +++ b/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts @@ -152,7 +152,14 @@ export class MaplibreDeckCustomLayer implements maplibregl.CustomLayerInterface render(_gl: WebGLRenderingContext | WebGL2RenderingContext, options: maplibregl.CustomRenderMethodInput): void { const deck = this._deck; + if (!this._map) return; if (!deck || !deck.isInitialized) return; + // Deck reports `isInitialized` once `viewManager` exists, but we still see rare cases during + // style/projection transitions where internal managers are temporarily null (or tearing down). + // Guard before calling the internal `_drawLayers` to avoid crashing the whole map render. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const internal = deck as any; + if (!internal.layerManager || !internal.viewManager) return; // MapLibre gives us a world->clip matrix for the current projection (mercator/globe). // For globe, this matrix expects unit-sphere world coordinates (see MapLibre's globe transform). From 84d602d25b5357892a613de8ce71e97380bfb3b1 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 13:14:03 +0900 Subject: [PATCH 04/58] fix(globe): avoid bathymetry overflow; fix ship halo expr --- apps/web/src/widgets/map3d/Map3D.tsx | 37 +++++++++++++++++++++------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 47497e6..fb7685a 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -754,11 +754,21 @@ export function Map3D({ const apply = () => { if (!map.isStyleLoaded()) return; - const disableBathy3D = projection === "globe" && baseMap === "enhanced"; - const vis = disableBathy3D ? "none" : "visible"; - for (const id of ["bathymetry-extrusion", "bathymetry-hillshade"]) { + const disableBathyHeavy = projection === "globe" && baseMap === "enhanced"; + const visHeavy = disableBathyHeavy ? "none" : "visible"; + + // Globe + our injected bathymetry fill polygons can exceed MapLibre's per-segment vertex limit + // (65535), causing broken ocean rendering. Keep globe mode stable by disabling the heavy fill. + const heavyIds = [ + "bathymetry-fill", + "bathymetry-borders", + "bathymetry-borders-major", + "bathymetry-extrusion", + "bathymetry-hillshade", + ]; + for (const id of heavyIds) { try { - if (map.getLayer(id)) map.setLayoutProperty(id, "visibility", vis); + if (map.getLayer(id)) map.setLayoutProperty(id, "visibility", visHeavy); } catch { // ignore } @@ -1019,12 +1029,21 @@ export function Map3D({ } const visibility = settings.showShips ? "visible" : "none"; + const isSelected = ["boolean", ["feature-state", "selected"], false] as const; + // Style-spec restriction: only one zoom-based step/interpolate is allowed in an expression. const circleRadius = [ - "case", - ["boolean", ["feature-state", "selected"], false], - ["interpolate", ["linear"], ["zoom"], 3, 5, 7, 8, 10, 10, 14, 14], - ["interpolate", ["linear"], ["zoom"], 3, 4, 7, 6, 10, 8, 14, 11], - ] as unknown as number[]; + "interpolate", + ["linear"], + ["zoom"], + 3, + ["case", isSelected, 5, 4], + 7, + ["case", isSelected, 8, 6], + 10, + ["case", isSelected, 10, 8], + 14, + ["case", isSelected, 14, 11], + ] as const; // Put ships at the top so they're always visible (especially important under globe projection). const before = undefined; From dc0729fc5fd408f989489c329dbb10156af67de4 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 13:15:41 +0900 Subject: [PATCH 05/58] fix(map): reduce bathymetry fill complexity at low zoom --- apps/web/src/widgets/map3d/Map3D.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index fb7685a..e4cebbf 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -218,6 +218,10 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str type: "fill", source: oceanSourceId, "source-layer": "contour", + // Very low zoom tiles can contain extremely complex polygons (coastline/detail), + // which may exceed MapLibre's per-segment 16-bit vertex limit and render incorrectly. + // We keep the fill starting at a more reasonable zoom. + minzoom: 4, paint: { // Dark-mode friendly palette (shallow = slightly brighter; deep = near-black). "fill-color": bathyFillColor, From 7f72ab651d7c88cc5b22d8fe574d723255a0d672 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 13:46:01 +0900 Subject: [PATCH 06/58] fix(globe): force repaints; maplibre overlays; disable bathy raster --- apps/web/src/widgets/map3d/Map3D.tsx | 526 ++++++++++++++++++++++++--- 1 file changed, 485 insertions(+), 41 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index e4cebbf..7088fb1 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -6,7 +6,6 @@ import maplibregl, { type GeoJSONSource, type GeoJSONSourceSpecification, type LayerSpecification, - type RasterDEMSourceSpecification, type StyleSpecification, type VectorSourceSpecification, } from "maplibre-gl"; @@ -61,6 +60,33 @@ function isFiniteNumber(x: unknown): x is number { return typeof x === "number" && Number.isFinite(x); } +function kickRepaint(map: maplibregl.Map | null) { + if (!map) return; + try { + map.triggerRepaint(); + } catch { + // ignore + } + try { + requestAnimationFrame(() => { + try { + map.triggerRepaint(); + } catch { + // ignore + } + }); + requestAnimationFrame(() => { + try { + map.triggerRepaint(); + } catch { + // ignore + } + }); + } catch { + // ignore (e.g., non-browser env) + } +} + const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius` const DEG2RAD = Math.PI / 180; @@ -142,8 +168,10 @@ function ensureSeamarkOverlay(map: maplibregl.Map, beforeLayerId?: string) { } function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) { + // NOTE: Vector-only bathymetry injection. + // Raster/DEM hillshade was intentionally removed for now because it caused ocean flicker + // and extra PNG tile traffic under globe projection in our setup. const oceanSourceId = "maptiler-ocean"; - const terrainSourceId = "maptiler-terrain"; if (!style.sources) style.sources = {} as StyleSpecification["sources"]; if (!style.layers) style.layers = []; @@ -155,15 +183,6 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str } satisfies VectorSourceSpecification as unknown as StyleSpecification["sources"][string]; } - if (!style.sources[terrainSourceId]) { - style.sources[terrainSourceId] = { - type: "raster-dem", - url: `https://api.maptiler.com/tiles/terrain-rgb/tiles.json?key=${encodeURIComponent(maptilerKey)}`, - tileSize: 512, - encoding: "mapbox", - } satisfies RasterDEMSourceSpecification as unknown as StyleSpecification["sources"][string]; - } - const depth = ["to-number", ["get", "depth"]] as unknown as number[]; const depthLabel = ["concat", ["to-string", ["*", depth, -1]], "m"] as unknown as string[]; @@ -197,22 +216,6 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str "#0b3a53", ] as const; - const bathyHillshade: LayerSpecification = { - id: "bathymetry-hillshade", - type: "hillshade", - source: terrainSourceId, - paint: { - "hillshade-illumination-anchor": "viewport", - "hillshade-illumination-direction": 315, - "hillshade-illumination-altitude": 45, - "hillshade-exaggeration": ["interpolate", ["linear"], ["zoom"], 0, 0.15, 6, 0.25, 10, 0.32], - // Dark-mode tuned shading. Alpha is baked into the colors. - "hillshade-shadow-color": "rgba(0,0,0,0.45)", - "hillshade-highlight-color": "rgba(255,255,255,0.18)", - "hillshade-accent-color": "rgba(255,255,255,0.06)", - }, - } as unknown as LayerSpecification; - const bathyFill: LayerSpecification = { id: "bathymetry-fill", type: "fill", @@ -383,7 +386,6 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str const toInsert = [ bathyFill, - bathyHillshade, bathyExtrusion, bathyBandBorders, bathyBandBordersMajor, @@ -452,6 +454,24 @@ function dashifyLine(from: [number, number], to: [number, number], suspicious: b return segs; } +function circleRingLngLat(center: [number, number], radiusMeters: number, steps = 72): [number, number][] { + const [lon0, lat0] = center; + const latRad = lat0 * DEG2RAD; + const cosLat = Math.max(1e-6, Math.cos(latRad)); + const r = Math.max(0, radiusMeters); + + const ring: [number, number][] = []; + for (let i = 0; i <= steps; i++) { + const a = (i / steps) * Math.PI * 2; + const dy = r * Math.sin(a); + const dx = r * Math.cos(a); + const dLat = (dy / EARTH_RADIUS_M) / DEG2RAD; + const dLon = (dx / (EARTH_RADIUS_M * cosLat)) / DEG2RAD; + ring.push([lon0 + dLon, lat0 + dLat]); + } + return ring; +} + type PairRangeCircle = { center: [number, number]; // [lon, lat] radiusNm: number; @@ -708,6 +728,10 @@ export function Map3D({ } } } + + // MapLibre may not schedule a frame immediately after projection swaps if the map is idle. + // Kick a few repaints so overlay sources (ships/zones) appear instantly. + kickRepaint(map); }; if (map.isStyleLoaded()) syncProjectionAndDeck(); @@ -738,6 +762,7 @@ export function Map3D({ // Disable style diff to avoid warnings with custom layers (Deck MapboxOverlay) and // to ensure a clean rebuild when switching between very different styles. map.setStyle(style, { diff: false }); + map.once("style.load", () => kickRepaint(map)); } catch (e) { if (cancelled) return; console.warn("Base map switch failed:", e); @@ -904,6 +929,8 @@ export function Map3D({ } } catch (e) { console.warn("Zones layer setup failed:", e); + } finally { + kickRepaint(map); } }; @@ -916,7 +943,7 @@ export function Map3D({ // ignore } }; - }, [zones, overlays.zones]); + }, [zones, overlays.zones, projection, baseMap]); // Ships in globe mode: render with MapLibre symbol layers so icons can be aligned to the globe surface. // Deck IconLayer billboards or uses a fixed plane, which looks like ships are pointing into the sky/ground on globe. @@ -944,6 +971,7 @@ export function Map3D({ // ignore } prevGlobeSelectedRef.current = null; + kickRepaint(map); }; const ensureImage = () => { @@ -1007,6 +1035,9 @@ export function Map3D({ type: "FeatureCollection", features: globeShipData.map((t) => { const legacy = legacyHits?.get(t.mmsi) ?? null; + const cog = isFiniteNumber(t.cog) ? t.cog : 0; + const cogNorm = ((cog % 360) + 360) % 360; + const cog4 = (Math.round(cogNorm / 90) % 4) * 90; return { type: "Feature", id: t.mmsi, @@ -1014,8 +1045,11 @@ export function Map3D({ properties: { mmsi: t.mmsi, name: t.name || "", - cog: isFiniteNumber(t.cog) ? t.cog : 0, + cog, + cog4, sog: isFiniteNumber(t.sog) ? t.sog : 0, + length: isFiniteNumber(t.length) ? t.length : 0, + width: isFiniteNumber(t.width) ? t.width : 0, permitted: !!legacy, code: legacy?.shipCode || "", }, @@ -1115,6 +1149,27 @@ export function Map3D({ if (!map.getLayer(symbolId)) { try { + const lengthExpr: unknown[] = ["to-number", ["get", "length"], 0]; + const widthExpr: unknown[] = ["to-number", ["get", "width"], 0]; + const hullExpr: unknown[] = ["clamp", ["+", lengthExpr, ["*", 3, widthExpr]], 0, 420]; + const sizeFactor: unknown[] = [ + "interpolate", + ["linear"], + hullExpr, + 0, + 0.85, + 40, + 0.95, + 80, + 1.0, + 160, + 1.25, + 260, + 1.55, + 350, + 1.85, + ]; + map.addLayer( { id: symbolId, @@ -1123,15 +1178,27 @@ export function Map3D({ layout: { visibility, "icon-image": imgId, - "icon-size": ["interpolate", ["linear"], ["zoom"], 3, 0.32, 7, 0.42, 10, 0.52, 14, 0.72], + "icon-size": [ + "interpolate", + ["linear"], + ["zoom"], + 3, + ["*", 0.32, sizeFactor], + 7, + ["*", 0.42, sizeFactor], + 10, + ["*", 0.52, sizeFactor], + 14, + ["*", 0.72, sizeFactor], + ] as unknown as number[], "icon-allow-overlap": true, "icon-ignore-placement": true, "icon-anchor": "center", - "icon-rotate": ["get", "cog"], - // Keep rotation relative to the map (true-north), but billboard to camera so it - // doesn't look like it's pointing into the sky/ground on globe. + // Debug-friendly: quantize heading to N/E/S/W while we validate globe alignment. + "icon-rotate": ["get", "cog4"], + // Keep the icon on the sea surface. "icon-rotation-alignment": "map", - "icon-pitch-alignment": "viewport", + "icon-pitch-alignment": "map", }, paint: { "icon-opacity": ["case", ["boolean", ["feature-state", "selected"], false], 1.0, 0.92], @@ -1163,6 +1230,7 @@ export function Map3D({ // ignore } prevGlobeSelectedRef.current = selectedMmsi; + kickRepaint(map); }; ensure(); @@ -1210,6 +1278,382 @@ export function Map3D({ }; }, [projection, settings.showShips, onSelectMmsi]); + // Globe overlays (pair links / FC links / ranges) rendered as MapLibre GeoJSON layers. + // Deck custom layers are more fragile under globe projection; MapLibre-native rendering stays aligned like zones. + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = "pair-lines-ml-src"; + const layerId = "pair-lines-ml"; + + const remove = () => { + try { + if (map.getLayer(layerId)) map.removeLayer(layerId); + } catch { + // ignore + } + try { + if (map.getSource(srcId)) map.removeSource(srcId); + } catch { + // ignore + } + }; + + const ensure = () => { + if (!map.isStyleLoaded()) return; + if (projection !== "globe" || !overlays.pairLines || (pairLinks?.length ?? 0) === 0) { + remove(); + return; + } + + const fc: GeoJSON.FeatureCollection = { + type: "FeatureCollection", + features: (pairLinks || []).map((p, idx) => ({ + type: "Feature", + id: `${p.aMmsi}-${p.bMmsi}-${idx}`, + geometry: { type: "LineString", coordinates: [p.from, p.to] }, + properties: { warn: p.warn }, + })), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: "geojson", data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn("Pair lines source setup failed:", e); + return; + } + + const before = map.getLayer("ships-globe-halo") ? "ships-globe-halo" : undefined; + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: "line", + source: srcId, + layout: { "line-cap": "round", "line-join": "round", visibility: "visible" }, + paint: { + "line-color": [ + "case", + ["boolean", ["get", "warn"], false], + "rgba(245,158,11,0.95)", + "rgba(59,130,246,0.55)", + ] as never, + "line-width": ["case", ["boolean", ["get", "warn"], false], 2.2, 1.4] as never, + "line-opacity": 0.9, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn("Pair lines layer add failed:", e); + } + } + + kickRepaint(map); + }; + + ensure(); + map.on("style.load", ensure); + return () => { + try { + map.off("style.load", ensure); + } catch { + // ignore + } + remove(); + }; + }, [projection, overlays.pairLines, pairLinks]); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = "fc-lines-ml-src"; + const layerId = "fc-lines-ml"; + + const remove = () => { + try { + if (map.getLayer(layerId)) map.removeLayer(layerId); + } catch { + // ignore + } + try { + if (map.getSource(srcId)) map.removeSource(srcId); + } catch { + // ignore + } + }; + + const ensure = () => { + if (!map.isStyleLoaded()) return; + if (projection !== "globe" || !overlays.fcLines) { + remove(); + return; + } + + const segs: DashSeg[] = []; + for (const l of fcLinks || []) segs.push(...dashifyLine(l.from, l.to, l.suspicious)); + if (segs.length === 0) { + remove(); + return; + } + + const fc: GeoJSON.FeatureCollection = { + type: "FeatureCollection", + features: segs.map((s, idx) => ({ + type: "Feature", + id: `fc-${idx}`, + geometry: { type: "LineString", coordinates: [s.from, s.to] }, + properties: { suspicious: s.suspicious }, + })), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: "geojson", data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn("FC lines source setup failed:", e); + return; + } + + const before = map.getLayer("ships-globe-halo") ? "ships-globe-halo" : undefined; + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: "line", + source: srcId, + layout: { "line-cap": "round", "line-join": "round", visibility: "visible" }, + paint: { + "line-color": [ + "case", + ["boolean", ["get", "suspicious"], false], + "rgba(239,68,68,0.95)", + "rgba(217,119,6,0.92)", + ] as never, + "line-width": 1.3, + "line-opacity": 0.9, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn("FC lines layer add failed:", e); + } + } + + kickRepaint(map); + }; + + ensure(); + map.on("style.load", ensure); + return () => { + try { + map.off("style.load", ensure); + } catch { + // ignore + } + remove(); + }; + }, [projection, overlays.fcLines, fcLinks]); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = "fleet-circles-ml-src"; + const layerId = "fleet-circles-ml"; + + const remove = () => { + try { + if (map.getLayer(layerId)) map.removeLayer(layerId); + } catch { + // ignore + } + try { + if (map.getSource(srcId)) map.removeSource(srcId); + } catch { + // ignore + } + }; + + const ensure = () => { + if (!map.isStyleLoaded()) return; + if (projection !== "globe" || !overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) { + remove(); + return; + } + + const fc: GeoJSON.FeatureCollection = { + type: "FeatureCollection", + features: (fleetCircles || []).map((c, idx) => { + const ring = circleRingLngLat(c.center, c.radiusNm * 1852); + return { + type: "Feature", + id: `fleet-${c.ownerKey}-${idx}`, + geometry: { type: "LineString", coordinates: ring }, + properties: { count: c.count }, + }; + }), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: "geojson", data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn("Fleet circles source setup failed:", e); + return; + } + + const before = map.getLayer("ships-globe-halo") ? "ships-globe-halo" : undefined; + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: "line", + source: srcId, + layout: { "line-cap": "round", "line-join": "round", visibility: "visible" }, + paint: { + "line-color": "rgba(245,158,11,0.65)", + "line-width": 1.1, + "line-opacity": 0.85, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn("Fleet circles layer add failed:", e); + } + } + + kickRepaint(map); + }; + + ensure(); + map.on("style.load", ensure); + return () => { + try { + map.off("style.load", ensure); + } catch { + // ignore + } + remove(); + }; + }, [projection, overlays.fleetCircles, fleetCircles]); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = "pair-range-ml-src"; + const layerId = "pair-range-ml"; + + const remove = () => { + try { + if (map.getLayer(layerId)) map.removeLayer(layerId); + } catch { + // ignore + } + try { + if (map.getSource(srcId)) map.removeSource(srcId); + } catch { + // ignore + } + }; + + const ensure = () => { + if (!map.isStyleLoaded()) return; + if (projection !== "globe" || !overlays.pairRange) { + remove(); + return; + } + + const ranges: PairRangeCircle[] = []; + for (const p of pairLinks || []) { + const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2]; + ranges.push({ center, radiusNm: Math.max(0.05, p.distanceNm / 2), warn: p.warn }); + } + if (ranges.length === 0) { + remove(); + return; + } + + const fc: GeoJSON.FeatureCollection = { + type: "FeatureCollection", + features: ranges.map((c, idx) => { + const ring = circleRingLngLat(c.center, c.radiusNm * 1852); + return { + type: "Feature", + id: `pair-range-${idx}`, + geometry: { type: "LineString", coordinates: ring }, + properties: { warn: c.warn }, + }; + }), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: "geojson", data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn("Pair range source setup failed:", e); + return; + } + + const before = map.getLayer("ships-globe-halo") ? "ships-globe-halo" : undefined; + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: "line", + source: srcId, + layout: { "line-cap": "round", "line-join": "round", visibility: "visible" }, + paint: { + "line-color": [ + "case", + ["boolean", ["get", "warn"], false], + "rgba(245,158,11,0.75)", + "rgba(59,130,246,0.45)", + ] as never, + "line-width": 1.0, + "line-opacity": 0.85, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn("Pair range layer add failed:", e); + } + } + + kickRepaint(map); + }; + + ensure(); + map.on("style.load", ensure); + return () => { + try { + map.off("style.load", ensure); + } catch { + // ignore + } + remove(); + }; + }, [projection, overlays.pairRange, pairLinks]); + const shipData = useMemo(() => { return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon)); }, [targets]); @@ -1321,15 +1765,15 @@ export function Map3D({ ); } - if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) { + if (overlays.pairLines && projection !== "globe" && (pairLinks?.length ?? 0) > 0) { layers.push( new LineLayer({ id: "pair-lines", data: pairLinks, pickable: false, parameters: overlayParams, - getSourcePosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.from[0], d.from[1]) : d.from), - getTargetPosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.to[0], d.to[1]) : d.to), + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? [245, 158, 11, 220] : [59, 130, 246, 85]), getWidth: (d) => (d.warn ? 2.2 : 1.4), widthUnits: "pixels", @@ -1337,15 +1781,15 @@ export function Map3D({ ); } - if (overlays.fcLines && fcDashed.length > 0) { + if (overlays.fcLines && projection !== "globe" && fcDashed.length > 0) { layers.push( new LineLayer({ id: "fc-lines", data: fcDashed, pickable: false, parameters: overlayParams, - getSourcePosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.from[0], d.from[1]) : d.from), - getTargetPosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.to[0], d.to[1]) : d.to), + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? [239, 68, 68, 220] : [217, 119, 6, 200]), getWidth: () => 1.3, widthUnits: "pixels", From bcd4a77f475f12c7f7eda8b1fcce7841127d945b Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 13:58:07 +0900 Subject: [PATCH 07/58] fix(api): add center/radius AIS query and stabilize globe ship icon render --- .../aisTarget/api/searchAisTargets.ts | 12 ++++ .../aisPolling/useAisTargetPolling.ts | 19 +++++- .../web/src/pages/dashboard/DashboardPage.tsx | 8 +++ apps/web/src/widgets/map3d/Map3D.tsx | 60 ++++++++++++++++--- 4 files changed, 89 insertions(+), 10 deletions(-) diff --git a/apps/web/src/entities/aisTarget/api/searchAisTargets.ts b/apps/web/src/entities/aisTarget/api/searchAisTargets.ts index b963ebc..b6bcdb3 100644 --- a/apps/web/src/entities/aisTarget/api/searchAisTargets.ts +++ b/apps/web/src/entities/aisTarget/api/searchAisTargets.ts @@ -3,6 +3,9 @@ import type { AisTargetSearchResponse } from "../model/types"; export type SearchAisTargetsParams = { minutes: number; bbox?: string; + centerLon?: number; + centerLat?: number; + radiusMeters?: number; }; export async function searchAisTargets(params: SearchAisTargetsParams, signal?: AbortSignal) { @@ -13,6 +16,15 @@ export async function searchAisTargets(params: SearchAisTargetsParams, signal?: const u = new URL(`${base}/api/ais-target/search`, window.location.origin); u.searchParams.set("minutes", String(params.minutes)); if (params.bbox) u.searchParams.set("bbox", params.bbox); + if (typeof params.centerLon === "number" && Number.isFinite(params.centerLon)) { + u.searchParams.set("centerLon", String(params.centerLon)); + } + if (typeof params.centerLat === "number" && Number.isFinite(params.centerLat)) { + u.searchParams.set("centerLat", String(params.centerLat)); + } + if (typeof params.radiusMeters === "number" && Number.isFinite(params.radiusMeters)) { + u.searchParams.set("radiusMeters", String(params.radiusMeters)); + } const res = await fetch(u, { signal, headers: { accept: "application/json" } }); const txt = await res.text(); diff --git a/apps/web/src/features/aisPolling/useAisTargetPolling.ts b/apps/web/src/features/aisPolling/useAisTargetPolling.ts index b7bc959..eec745e 100644 --- a/apps/web/src/features/aisPolling/useAisTargetPolling.ts +++ b/apps/web/src/features/aisPolling/useAisTargetPolling.ts @@ -22,6 +22,9 @@ export type AisPollingOptions = { intervalMs?: number; retentionMinutes?: number; bbox?: string; + centerLon?: number; + centerLat?: number; + radiusMeters?: number; enabled?: boolean; }; @@ -114,6 +117,9 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { const retentionMinutes = opts.retentionMinutes ?? initialMinutes; const enabled = opts.enabled ?? true; const bbox = opts.bbox; + const centerLon = opts.centerLon; + const centerLat = opts.centerLat; + const radiusMeters = opts.radiusMeters; const storeRef = useRef>(new Map()); const inFlightRef = useRef(false); @@ -143,7 +149,16 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { try { setSnapshot((s) => ({ ...s, status: s.status === "idle" ? "loading" : s.status, error: null })); - const res = await searchAisTargets({ minutes, bbox }, controller.signal); + const res = await searchAisTargets( + { + minutes, + bbox, + centerLon, + centerLat, + radiusMeters, + }, + controller.signal, + ); if (cancelled) return; const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data); @@ -198,7 +213,7 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { controller.abort(); window.clearInterval(id); }; - }, [initialMinutes, incrementalMinutes, intervalMs, retentionMinutes, bbox, enabled]); + }, [initialMinutes, incrementalMinutes, intervalMs, retentionMinutes, bbox, centerLon, centerLat, radiusMeters, enabled]); const targets = useMemo(() => { // `rev` is a version counter so we recompute the array snapshot when the store changes. diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 4736b2c..f0533fd 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -33,6 +33,11 @@ import { import { VESSEL_TYPE_ORDER } from "../../entities/vessel/model/meta"; const AIS_API_BASE = (import.meta.env.VITE_API_URL || "/snp-api").replace(/\/$/, ""); +const AIS_CENTER = { + lon: 126.95, + lat: 35.95, + radiusMeters: 2_000_000, +}; function fmtLocal(iso: string | null) { if (!iso) return "-"; @@ -75,6 +80,9 @@ export function DashboardPage() { intervalMs: 60_000, retentionMinutes: 90, bbox: useApiBbox ? apiBbox : undefined, + centerLon: useApiBbox ? undefined : AIS_CENTER.lon, + centerLat: useApiBbox ? undefined : AIS_CENTER.lat, + radiusMeters: useApiBbox ? undefined : AIS_CENTER.radiusMeters, }); const [selectedMmsi, setSelectedMmsi] = useState(null); diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 7088fb1..e3c7101 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -90,6 +90,10 @@ function kickRepaint(map: maplibregl.Map | null) { const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius` const DEG2RAD = Math.PI / 180; +function clampExpr(inputExpr: unknown, minValue: number, maxValue: number): unknown[] { + return ["min", ["max", inputExpr, minValue], maxValue]; +} + function lngLatToUnitSphere(lon: number, lat: number, altitudeMeters = 0): [number, number, number] { const lambda = lon * DEG2RAD; const phi = lat * DEG2RAD; @@ -299,14 +303,20 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str } as unknown as LayerSpecification; const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500]; + const bathyMajorDepthFilter: unknown[] = [ + "match", + ["to-number", ["get", "depth"]], + ...majorDepths.map((v) => [v, true]).flat(), + false, + ] as unknown[]; + const bathyLinesMajor: LayerSpecification = { id: "bathymetry-lines-major", type: "line", source: oceanSourceId, "source-layer": "contour_line", minzoom: 8, - // Use legacy filter syntax here (not expression "in"), so we can pass multiple values. - filter: ["in", "depth", ...majorDepths] as unknown as unknown[], + filter: bathyMajorDepthFilter as unknown as unknown[], paint: { "line-color": "rgba(255,255,255,0.16)", "line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0.22, 10, 0.28, 12, 0.34], @@ -321,8 +331,7 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str source: oceanSourceId, "source-layer": "contour", minzoom: 4, - // Use legacy filter syntax here (not expression "in"), so we can pass multiple values. - filter: ["in", "depth", ...majorDepths] as unknown as unknown[], + filter: bathyMajorDepthFilter as unknown as unknown[], paint: { "line-color": "rgba(255,255,255,0.14)", "line-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0.14, 8, 0.2, 12, 0.26], @@ -337,8 +346,7 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str source: oceanSourceId, "source-layer": "contour_line", minzoom: 10, - // Use legacy filter syntax here (not expression "in"), so we can pass multiple values. - filter: ["in", "depth", ...majorDepths] as unknown as unknown[], + filter: bathyMajorDepthFilter as unknown as unknown[], layout: { "symbol-placement": "line", "text-field": depthLabel, @@ -732,6 +740,11 @@ export function Map3D({ // MapLibre may not schedule a frame immediately after projection swaps if the map is idle. // Kick a few repaints so overlay sources (ships/zones) appear instantly. kickRepaint(map); + try { + map.resize(); + } catch { + // ignore + } }; if (map.isStyleLoaded()) syncProjectionAndDeck(); @@ -762,7 +775,10 @@ export function Map3D({ // Disable style diff to avoid warnings with custom layers (Deck MapboxOverlay) and // to ensure a clean rebuild when switching between very different styles. map.setStyle(style, { diff: false }); - map.once("style.load", () => kickRepaint(map)); + map.once("style.load", () => { + kickRepaint(map); + requestAnimationFrame(() => kickRepaint(map)); + }); } catch (e) { if (cancelled) return; console.warn("Base map switch failed:", e); @@ -785,6 +801,9 @@ export function Map3D({ if (!map.isStyleLoaded()) return; const disableBathyHeavy = projection === "globe" && baseMap === "enhanced"; const visHeavy = disableBathyHeavy ? "none" : "visible"; + const disableBaseMapSea = projection === "globe" && baseMap === "enhanced"; + const seaVisibility = disableBaseMapSea ? "none" : "visible"; + const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i; // Globe + our injected bathymetry fill polygons can exceed MapLibre's per-segment vertex limit // (65535), causing broken ocean rendering. Keep globe mode stable by disabling the heavy fill. @@ -802,6 +821,27 @@ export function Map3D({ // ignore } } + + // Vector basemap water-style layers can flicker on globe with dense symbols/fills in this stack. + // Hide them only in globe/enhanced mode and restore on return. + try { + for (const layer of map.getStyle().layers || []) { + const id = String(layer.id ?? ""); + if (!id) continue; + const sourceLayer = String((layer as Record)["source-layer"] ?? "").toLowerCase(); + const source = String((layer as { source?: unknown }).source ?? "").toLowerCase(); + const isSea = seaRegex.test(id) || seaRegex.test(sourceLayer) || seaRegex.test(source); + if (!isSea) continue; + if (!map.getLayer(id)) continue; + try { + map.setLayoutProperty(id, "visibility", seaVisibility); + } catch { + // ignore + } + } + } catch { + // ignore + } }; if (map.isStyleLoaded()) apply(); @@ -1151,7 +1191,7 @@ export function Map3D({ try { const lengthExpr: unknown[] = ["to-number", ["get", "length"], 0]; const widthExpr: unknown[] = ["to-number", ["get", "width"], 0]; - const hullExpr: unknown[] = ["clamp", ["+", lengthExpr, ["*", 3, widthExpr]], 0, 420]; + const hullExpr: unknown[] = clampExpr(["+", lengthExpr, ["*", 3, widthExpr]], 0, 420); const sizeFactor: unknown[] = [ "interpolate", ["linear"], @@ -1254,6 +1294,10 @@ export function Map3D({ const onClick = (e: maplibregl.MapMouseEvent) => { try { + if (!map.getLayer(symbolId)) { + onSelectMmsi(null); + return; + } const feats = map.queryRenderedFeatures(e.point, { layers: [symbolId] }); const f = feats?.[0]; const props = (f?.properties || {}) as Record; From b8ccef23cae174f0779a66ed4ea00b6ba99f07b2 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 14:04:37 +0900 Subject: [PATCH 08/58] fix(globe): stabilize ship symbols and deck rendering --- apps/web/src/widgets/map3d/Map3D.tsx | 265 +++++++++--------- .../widgets/map3d/MaplibreDeckCustomLayer.ts | 17 +- 2 files changed, 140 insertions(+), 142 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index e3c7101..fae9006 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -9,7 +9,7 @@ import maplibregl, { type StyleSpecification, type VectorSourceSpecification, } from "maplibre-gl"; -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { AisTarget } from "../../entities/aisTarget/model/types"; import type { LegacyVesselInfo } from "../../entities/legacyVessel/model/types"; import type { ZonesGeoJson } from "../../entities/zone/api/useZones"; @@ -87,21 +87,9 @@ function kickRepaint(map: maplibregl.Map | null) { } } -const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius` const DEG2RAD = Math.PI / 180; -function clampExpr(inputExpr: unknown, minValue: number, maxValue: number): unknown[] { - return ["min", ["max", inputExpr, minValue], maxValue]; -} - -function lngLatToUnitSphere(lon: number, lat: number, altitudeMeters = 0): [number, number, number] { - const lambda = lon * DEG2RAD; - const phi = lat * DEG2RAD; - const cosPhi = Math.cos(phi); - const s = 1 + altitudeMeters / EARTH_RADIUS_M; - // MapLibre globe space: x = east, y = north, z = lon=0 at equator. - return [Math.sin(lambda) * cosPhi * s, Math.sin(phi) * s, Math.cos(lambda) * cosPhi * s]; -} +const clampNumber = (value: number, minValue: number, maxValue: number) => Math.max(minValue, Math.min(maxValue, value)); const LEGACY_CODE_COLORS: Record = { PT: [30, 64, 175], // #1e40af @@ -228,7 +216,8 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str // Very low zoom tiles can contain extremely complex polygons (coastline/detail), // which may exceed MapLibre's per-segment 16-bit vertex limit and render incorrectly. // We keep the fill starting at a more reasonable zoom. - minzoom: 4, + minzoom: 6, + maxzoom: 12, paint: { // Dark-mode friendly palette (shallow = slightly brighter; deep = near-black). "fill-color": bathyFillColor, @@ -236,38 +225,15 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str }, } as unknown as LayerSpecification; - const bathyExtrusion: LayerSpecification = { - id: "bathymetry-extrusion", - type: "fill-extrusion", - source: oceanSourceId, - "source-layer": "contour", - minzoom: 6, - paint: { - "fill-extrusion-color": bathyFillColor, - // MapLibre fill-extrusion cannot go below 0m, so we exaggerate the "relative seabed height" - // (shallow areas higher, deep areas lower) to create a stepped relief. - "fill-extrusion-base": 0, - // NOTE: `zoom` can only appear as the input to a top-level `step`/`interpolate`. - "fill-extrusion-height": [ - "interpolate", - ["linear"], - ["zoom"], - 6, - ["*", ["+", depth, 12000], 0.002], // depth is negative; -> range [0..12000] - 10, - ["*", ["+", depth, 12000], 0.01], - ], - "fill-extrusion-opacity": ["interpolate", ["linear"], ["zoom"], 6, 0.0, 7, 0.25, 10, 0.55], - "fill-extrusion-vertical-gradient": true, - }, - } as unknown as LayerSpecification; + const bathyBandBorders: LayerSpecification = { id: "bathymetry-borders", type: "line", source: oceanSourceId, "source-layer": "contour", - minzoom: 4, + minzoom: 6, + maxzoom: 14, paint: { "line-color": "rgba(255,255,255,0.06)", "line-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0.12, 8, 0.18, 12, 0.22], @@ -304,10 +270,9 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500]; const bathyMajorDepthFilter: unknown[] = [ - "match", + "in", ["to-number", ["get", "depth"]], - ...majorDepths.map((v) => [v, true]).flat(), - false, + ["literal", majorDepths], ] as unknown[]; const bathyLinesMajor: LayerSpecification = { @@ -316,6 +281,7 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str source: oceanSourceId, "source-layer": "contour_line", minzoom: 8, + maxzoom: 14, filter: bathyMajorDepthFilter as unknown as unknown[], paint: { "line-color": "rgba(255,255,255,0.16)", @@ -331,6 +297,7 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str source: oceanSourceId, "source-layer": "contour", minzoom: 4, + maxzoom: 14, filter: bathyMajorDepthFilter as unknown as unknown[], paint: { "line-color": "rgba(255,255,255,0.14)", @@ -394,7 +361,6 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str const toInsert = [ bathyFill, - bathyExtrusion, bathyBandBorders, bathyBandBordersMajor, bathyLinesMinor, @@ -486,6 +452,7 @@ type PairRangeCircle = { warn: boolean; }; +const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius` const DECK_VIEW_ID = "mapbox"; export function Map3D({ @@ -507,10 +474,11 @@ export function Map3D({ const mapRef = useRef(null); const overlayRef = useRef(null); const globeDeckLayerRef = useRef(null); - const prevGlobeSelectedRef = useRef(null); + const globeShipsEpochRef = useRef(-1); const showSeamarkRef = useRef(settings.showSeamark); const baseMapRef = useRef(baseMap); const projectionRef = useRef(projection); + const [mapSyncEpoch, setMapSyncEpoch] = useState(0); useEffect(() => { showSeamarkRef.current = settings.showSeamark; @@ -675,27 +643,54 @@ export function Map3D({ const map = mapRef.current; if (!map) return; let cancelled = false; + let retries = 0; + const maxRetries = 6; const syncProjectionAndDeck = () => { if (cancelled) return; - try { - map.setProjection({ type: projection }); - map.setRenderWorldCopies(projection !== "globe"); + + if (!map.isStyleLoaded()) { + if (!cancelled && retries < maxRetries) { + retries += 1; + window.requestAnimationFrame(() => syncProjectionAndDeck()); + } + return; + } + + const next = projection; + try { + map.setProjection({ type: next }); + map.setRenderWorldCopies(next !== "globe"); } catch (e) { + if (!cancelled && retries < maxRetries) { + retries += 1; + window.requestAnimationFrame(() => syncProjectionAndDeck()); + return; + } console.warn("Projection switch failed:", e); } + const oldOverlay = overlayRef.current; + if (projection === "globe" && oldOverlay) { + // Globe mode uses custom MapLibre deck layers and should fully replace Mercator overlays. + try { + oldOverlay.finalize(); + } catch { + // ignore + } + overlayRef.current = null; + } + if (projection === "globe") { - // Tear down MapboxOverlay (mercator) and use a MapLibre custom layer that renders Deck - // with MapLibre's globe MVP matrix. This avoids the Deck <-> MapLibre globe mismatch. - const old = overlayRef.current; - if (old) { + // Ensure any stale layer from old mode is dropped then re-added on this projection. + if (globeDeckLayerRef.current) { try { - old.finalize(); + if (map.getLayer(globeDeckLayerRef.current.id)) { + map.removeLayer(globeDeckLayerRef.current.id); + } } catch { // ignore } - overlayRef.current = null; } if (!globeDeckLayerRef.current) { @@ -745,6 +740,8 @@ export function Map3D({ } catch { // ignore } + + setMapSyncEpoch((prev) => prev + 1); }; if (map.isStyleLoaded()) syncProjectionAndDeck(); @@ -778,6 +775,7 @@ export function Map3D({ map.once("style.load", () => { kickRepaint(map); requestAnimationFrame(() => kickRepaint(map)); + setMapSyncEpoch((prev) => prev + 1); }); } catch (e) { if (cancelled) return; @@ -801,7 +799,7 @@ export function Map3D({ if (!map.isStyleLoaded()) return; const disableBathyHeavy = projection === "globe" && baseMap === "enhanced"; const visHeavy = disableBathyHeavy ? "none" : "visible"; - const disableBaseMapSea = projection === "globe" && baseMap === "enhanced"; + const disableBaseMapSea = projection === "globe"; const seaVisibility = disableBaseMapSea ? "none" : "visible"; const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i; @@ -822,17 +820,20 @@ export function Map3D({ } } - // Vector basemap water-style layers can flicker on globe with dense symbols/fills in this stack. - // Hide them only in globe/enhanced mode and restore on return. + // Vector basemap water/raster layers can flicker on globe with dense symbols/fills in this stack. + // Hide them only in globe mode and restore on return. try { for (const layer of map.getStyle().layers || []) { const id = String(layer.id ?? ""); if (!id) continue; const sourceLayer = String((layer as Record)["source-layer"] ?? "").toLowerCase(); const source = String((layer as { source?: unknown }).source ?? "").toLowerCase(); + const type = String((layer as { type?: unknown }).type ?? "").toLowerCase(); const isSea = seaRegex.test(id) || seaRegex.test(sourceLayer) || seaRegex.test(source); - if (!isSea) continue; + const isRaster = type === "raster"; + if (!isSea && !isRaster) continue; if (!map.getLayer(id)) continue; + if (isRaster && id === "seamark") continue; try { map.setLayoutProperty(id, "visibility", seaVisibility); } catch { @@ -853,7 +854,7 @@ export function Map3D({ // ignore } }; - }, [projection, baseMap]); + }, [projection, baseMap, mapSyncEpoch]); // seamark toggle useEffect(() => { @@ -983,7 +984,7 @@ export function Map3D({ // ignore } }; - }, [zones, overlays.zones, projection, baseMap]); + }, [zones, overlays.zones, projection, baseMap, mapSyncEpoch]); // Ships in globe mode: render with MapLibre symbol layers so icons can be aligned to the globe surface. // Deck IconLayer billboards or uses a fixed plane, which looks like ships are pointing into the sky/ground on globe. @@ -1010,7 +1011,6 @@ export function Map3D({ } catch { // ignore } - prevGlobeSelectedRef.current = null; kickRepaint(map); }; @@ -1064,6 +1064,11 @@ export function Map3D({ return; } + if (globeShipsEpochRef.current !== mapSyncEpoch) { + remove(); + globeShipsEpochRef.current = mapSyncEpoch; + } + try { ensureImage(); } catch (e) { @@ -1077,7 +1082,14 @@ export function Map3D({ const legacy = legacyHits?.get(t.mmsi) ?? null; const cog = isFiniteNumber(t.cog) ? t.cog : 0; const cogNorm = ((cog % 360) + 360) % 360; - const cog4 = (Math.round(cogNorm / 90) % 4) * 90; + const hull = clampNumber((isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, 50, 420); + const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); + const selected = t.mmsi === selectedMmsi; + const selectedScale = selected ? 1.08 : 1; + const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3); + const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45); + const iconSize10 = clampNumber(0.56 * sizeScale * selectedScale, 0.35, 1.7); + const iconSize14 = clampNumber(0.72 * sizeScale * selectedScale, 0.45, 2.1); return { type: "Feature", id: t.mmsi, @@ -1085,11 +1097,14 @@ export function Map3D({ properties: { mmsi: t.mmsi, name: t.name || "", - cog, - cog4, + cog: cogNorm, sog: isFiniteNumber(t.sog) ? t.sog : 0, - length: isFiniteNumber(t.length) ? t.length : 0, - width: isFiniteNumber(t.width) ? t.width : 0, + iconSize3, + iconSize7, + iconSize10, + iconSize14, + sizeScale, + selected: selected ? 1 : 0, permitted: !!legacy, code: legacy?.shipCode || "", }, @@ -1107,20 +1122,18 @@ export function Map3D({ } const visibility = settings.showShips ? "visible" : "none"; - const isSelected = ["boolean", ["feature-state", "selected"], false] as const; - // Style-spec restriction: only one zoom-based step/interpolate is allowed in an expression. const circleRadius = [ "interpolate", ["linear"], ["zoom"], 3, - ["case", isSelected, 5, 4], + 4, 7, - ["case", isSelected, 8, 6], + 6, 10, - ["case", isSelected, 10, 8], + 8, 14, - ["case", isSelected, 14, 11], + 11, ] as const; // Put ships at the top so they're always visible (especially important under globe projection). @@ -1168,8 +1181,8 @@ export function Map3D({ "circle-stroke-width": [ "case", ["boolean", ["get", "permitted"], false], - ["case", ["boolean", ["feature-state", "selected"], false], 2.5, 1.6], - ["case", ["boolean", ["feature-state", "selected"], false], 2.0, 0.0], + ["case", ["==", ["get", "selected"], 1], 2.5, 1.6], + ["case", ["==", ["get", "selected"], 1], 2.0, 0.0], ] as unknown as number[], "circle-stroke-opacity": 0.8, }, @@ -1189,27 +1202,6 @@ export function Map3D({ if (!map.getLayer(symbolId)) { try { - const lengthExpr: unknown[] = ["to-number", ["get", "length"], 0]; - const widthExpr: unknown[] = ["to-number", ["get", "width"], 0]; - const hullExpr: unknown[] = clampExpr(["+", lengthExpr, ["*", 3, widthExpr]], 0, 420); - const sizeFactor: unknown[] = [ - "interpolate", - ["linear"], - hullExpr, - 0, - 0.85, - 40, - 0.95, - 80, - 1.0, - 160, - 1.25, - 260, - 1.55, - 350, - 1.85, - ]; - map.addLayer( { id: symbolId, @@ -1223,25 +1215,24 @@ export function Map3D({ ["linear"], ["zoom"], 3, - ["*", 0.32, sizeFactor], + ["to-number", ["get", "iconSize3"], 0.35], 7, - ["*", 0.42, sizeFactor], + ["to-number", ["get", "iconSize7"], 0.45], 10, - ["*", 0.52, sizeFactor], + ["to-number", ["get", "iconSize10"], 0.56], 14, - ["*", 0.72, sizeFactor], + ["to-number", ["get", "iconSize14"], 0.72], ] as unknown as number[], "icon-allow-overlap": true, "icon-ignore-placement": true, "icon-anchor": "center", - // Debug-friendly: quantize heading to N/E/S/W while we validate globe alignment. - "icon-rotate": ["get", "cog4"], + "icon-rotate": ["to-number", ["get", "cog"], 0], // Keep the icon on the sea surface. "icon-rotation-alignment": "map", "icon-pitch-alignment": "map", }, paint: { - "icon-opacity": ["case", ["boolean", ["feature-state", "selected"], false], 1.0, 0.92], + "icon-opacity": ["case", ["==", ["get", "selected"], 1], 1.0, 0.92], }, } as unknown as LayerSpecification, before, @@ -1257,19 +1248,7 @@ export function Map3D({ } } - // Apply selection state for highlight. - try { - const prev = prevGlobeSelectedRef.current; - if (prev && prev !== selectedMmsi) map.setFeatureState({ source: srcId, id: prev }, { selected: false }); - } catch { - // ignore - } - try { - if (selectedMmsi) map.setFeatureState({ source: srcId, id: selectedMmsi }, { selected: true }); - } catch { - // ignore - } - prevGlobeSelectedRef.current = selectedMmsi; + // Selection is now source-data driven (`selected` property), no per-feature state update needed. kickRepaint(map); }; @@ -1282,7 +1261,7 @@ export function Map3D({ // ignore } }; - }, [projection, settings.showShips, targets, legacyHits, selectedMmsi]); + }, [projection, settings.showShips, targets, legacyHits, selectedMmsi, mapSyncEpoch]); // Globe ship click selection (MapLibre-native ships layer) useEffect(() => { @@ -1291,14 +1270,14 @@ export function Map3D({ if (projection !== "globe" || !settings.showShips) return; const symbolId = "ships-globe"; + const haloId = "ships-globe-halo"; + const outlineId = "ships-globe-outline"; + const clickedRadiusDeg2 = Math.pow(0.08, 2); const onClick = (e: maplibregl.MapMouseEvent) => { try { - if (!map.getLayer(symbolId)) { - onSelectMmsi(null); - return; - } - const feats = map.queryRenderedFeatures(e.point, { layers: [symbolId] }); + const layerIds = [symbolId, haloId, outlineId].filter((id) => map.getLayer(id)); + const feats = layerIds.length > 0 ? map.queryRenderedFeatures(e.point, { layers: layerIds }) : []; const f = feats?.[0]; const props = (f?.properties || {}) as Record; const mmsi = Number(props.mmsi); @@ -1306,6 +1285,25 @@ export function Map3D({ onSelectMmsi(mmsi); return; } + + const clicked = { lat: e.lngLat.lat, lon: e.lngLat.lng }; + const cosLat = Math.cos(clicked.lat * DEG2RAD); + let bestMmsi: number | null = null; + let bestD2 = Number.POSITIVE_INFINITY; + for (const t of targets) { + if (!isFiniteNumber(t.lat) || !isFiniteNumber(t.lon)) continue; + const dLon = (clicked.lon - t.lon) * cosLat; + const dLat = clicked.lat - t.lat; + const d2 = dLon * dLon + dLat * dLat; + if (d2 <= clickedRadiusDeg2 && d2 < bestD2) { + bestD2 = d2; + bestMmsi = t.mmsi; + } + } + if (bestMmsi != null) { + onSelectMmsi(bestMmsi); + return; + } } catch { // ignore } @@ -1320,7 +1318,7 @@ export function Map3D({ // ignore } }; - }, [projection, settings.showShips, onSelectMmsi]); + }, [projection, settings.showShips, onSelectMmsi, mapSyncEpoch, targets]); // Globe overlays (pair links / FC links / ranges) rendered as MapLibre GeoJSON layers. // Deck custom layers are more fragile under globe projection; MapLibre-native rendering stays aligned like zones. @@ -1411,7 +1409,7 @@ export function Map3D({ } remove(); }; - }, [projection, overlays.pairLines, pairLinks]); + }, [projection, overlays.pairLines, pairLinks, mapSyncEpoch]); useEffect(() => { const map = mapRef.current; @@ -1507,7 +1505,7 @@ export function Map3D({ } remove(); }; - }, [projection, overlays.fcLines, fcLinks]); + }, [projection, overlays.fcLines, fcLinks, mapSyncEpoch]); useEffect(() => { const map = mapRef.current; @@ -1594,7 +1592,7 @@ export function Map3D({ } remove(); }; - }, [projection, overlays.fleetCircles, fleetCircles]); + }, [projection, overlays.fleetCircles, fleetCircles, mapSyncEpoch]); useEffect(() => { const map = mapRef.current; @@ -1696,22 +1694,12 @@ export function Map3D({ } remove(); }; - }, [projection, overlays.pairRange, pairLinks]); + }, [projection, overlays.pairRange, pairLinks, mapSyncEpoch]); const shipData = useMemo(() => { return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon)); }, [targets]); - const globePosByMmsi = useMemo(() => { - if (projection !== "globe") return null; - const m = new Map(); - for (const t of shipData) { - // Slightly above the sea surface to keep the icon readable and avoid depth-fighting. - m.set(t.mmsi, lngLatToUnitSphere(t.lon, t.lat, 12)); - } - return m; - }, [projection, shipData]); - const legacyTargets = useMemo(() => { if (!legacyHits) return []; return shipData.filter((t) => legacyHits.has(t.mmsi)); @@ -1965,6 +1953,7 @@ export function Map3D({ }, [ projection, shipData, + baseMap, zones, selectedMmsi, overlays.zones, @@ -1981,7 +1970,7 @@ export function Map3D({ pairRanges, fcDashed, fleetCircles, - globePosByMmsi, + mapSyncEpoch, ]); return
; diff --git a/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts b/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts index d27e703..f3bc99b 100644 --- a/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts +++ b/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts @@ -169,9 +169,18 @@ export class MaplibreDeckCustomLayer implements maplibregl.CustomLayerInterface deck.setProps({ viewState: { [this._viewId]: { projectionMatrix, viewMatrix: IDENTITY_4x4 } } }); } - deck._drawLayers("maplibre-custom", { - clearCanvas: false, - clearStack: true, - }); + try { + deck._drawLayers("maplibre-custom", { + clearCanvas: false, + clearStack: true, + }); + } catch (e) { + // Rendering can fail transiently during style/projection transitions. + // Keep the map responsive and request a clean pass on next frame. + console.warn("Deck render sync failed, skipping frame:", e); + requestAnimationFrame(() => { + this._map?.triggerRepaint(); + }); + } } } From c31d26124cc34ef8f9b9ffb28718b4acbd4b77e7 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 14:17:27 +0900 Subject: [PATCH 09/58] fix(ais,map): 2-stage bootstrap and globe overlay refresh --- .../aisPolling/useAisTargetPolling.ts | 46 ++-- .../web/src/pages/dashboard/DashboardPage.tsx | 1 + apps/web/src/widgets/map3d/Map3D.tsx | 237 +++++++++++------- 3 files changed, 174 insertions(+), 110 deletions(-) diff --git a/apps/web/src/features/aisPolling/useAisTargetPolling.ts b/apps/web/src/features/aisPolling/useAisTargetPolling.ts index eec745e..8524e65 100644 --- a/apps/web/src/features/aisPolling/useAisTargetPolling.ts +++ b/apps/web/src/features/aisPolling/useAisTargetPolling.ts @@ -18,6 +18,7 @@ export type AisPollingSnapshot = { export type AisPollingOptions = { initialMinutes?: number; + bootstrapMinutes?: number; incrementalMinutes?: number; intervalMs?: number; retentionMinutes?: number; @@ -43,10 +44,10 @@ function upsertByMmsi(store: Map, rows: AisTarget[]) { continue; } - // Prefer newer records if the upstream ever returns stale items. - const prevTs = prev.messageTimestamp ?? ""; - const nextTs = r.messageTimestamp ?? ""; - if (nextTs && prevTs && nextTs < prevTs) continue; + // Keep newer rows only. If backend returns same/older timestamp, skip. + const prevTs = Date.parse(prev.messageTimestamp || ""); + const nextTs = Date.parse(r.messageTimestamp || ""); + if (Number.isFinite(prevTs) && Number.isFinite(nextTs) && nextTs <= prevTs) continue; store.set(r.mmsi, r); upserted += 1; @@ -112,6 +113,7 @@ function pruneStore(store: Map, retentionMinutes: number, bbo export function useAisTargetPolling(opts: AisPollingOptions = {}) { const initialMinutes = opts.initialMinutes ?? 60; + const bootstrapMinutes = opts.bootstrapMinutes ?? initialMinutes; const incrementalMinutes = opts.incrementalMinutes ?? 1; const intervalMs = opts.intervalMs ?? 60_000; const retentionMinutes = opts.retentionMinutes ?? initialMinutes; @@ -122,7 +124,7 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { const radiusMeters = opts.radiusMeters; const storeRef = useRef>(new Map()); - const inFlightRef = useRef(false); + const generationRef = useRef(0); const [rev, setRev] = useState(0); const [snapshot, setSnapshot] = useState({ @@ -142,10 +144,9 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { let cancelled = false; const controller = new AbortController(); + const generation = ++generationRef.current; - async function run(minutes: number) { - if (inFlightRef.current) return; - inFlightRef.current = true; + async function run(minutes: number, context: "bootstrap" | "initial" | "incremental") { try { setSnapshot((s) => ({ ...s, status: s.status === "idle" ? "loading" : s.status, error: null })); @@ -159,7 +160,7 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { }, controller.signal, ); - if (cancelled) return; + if (cancelled || generation !== generationRef.current) return; const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data); const deleted = pruneStore(storeRef.current, retentionMinutes, bbox); @@ -179,14 +180,12 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { }); setRev((r) => r + 1); } catch (e) { - if (cancelled) return; + if (cancelled || generation !== generationRef.current) return; setSnapshot((s) => ({ ...s, - status: "error", + status: context === "incremental" ? s.status : "error", error: e instanceof Error ? e.message : String(e), })); - } finally { - inFlightRef.current = false; } } @@ -205,15 +204,30 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) { }); setRev((r) => r + 1); - void run(initialMinutes); - const id = window.setInterval(() => void run(incrementalMinutes), intervalMs); + void run(bootstrapMinutes, "bootstrap"); + if (bootstrapMinutes !== initialMinutes) { + void run(initialMinutes, "initial"); + } + + const id = window.setInterval(() => void run(incrementalMinutes, "incremental"), intervalMs); return () => { cancelled = true; controller.abort(); window.clearInterval(id); }; - }, [initialMinutes, incrementalMinutes, intervalMs, retentionMinutes, bbox, centerLon, centerLat, radiusMeters, enabled]); + }, [ + initialMinutes, + bootstrapMinutes, + incrementalMinutes, + intervalMs, + retentionMinutes, + bbox, + centerLon, + centerLat, + radiusMeters, + enabled, + ]); const targets = useMemo(() => { // `rev` is a version counter so we recompute the array snapshot when the store changes. diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index f0533fd..ee949ac 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -76,6 +76,7 @@ export function DashboardPage() { const { targets, snapshot } = useAisTargetPolling({ initialMinutes: 60, + bootstrapMinutes: 10, incrementalMinutes: 2, intervalMs: 60_000, retentionMinutes: 90, diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index fae9006..c8acd5b 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -87,10 +87,84 @@ function kickRepaint(map: maplibregl.Map | null) { } } +function onMapStyleReady(map: maplibregl.Map, callback: () => void) { + if (map.isStyleLoaded()) { + callback(); + return () => { + // noop + }; + } + + let fired = false; + const runOnce = () => { + if (fired || !map.isStyleLoaded()) return; + fired = true; + callback(); + try { + map.off("style.load", runOnce); + map.off("styledata", runOnce); + map.off("idle", runOnce); + } catch { + // ignore + } + }; + + map.on("style.load", runOnce); + map.on("styledata", runOnce); + map.on("idle", runOnce); + + return () => { + if (fired) return; + fired = true; + try { + map.off("style.load", runOnce); + map.off("styledata", runOnce); + map.off("idle", runOnce); + } catch { + // ignore + } + }; +} + const DEG2RAD = Math.PI / 180; const clampNumber = (value: number, minValue: number, maxValue: number) => Math.max(minValue, Math.min(maxValue, value)); +function rgbToHex(rgb: [number, number, number]) { + const toHex = (v: number) => { + const clamped = Math.max(0, Math.min(255, Math.round(v))); + return clamped.toString(16).padStart(2, "0"); + }; + + return `#${toHex(rgb[0])}${toHex(rgb[1])}${toHex(rgb[2])}`; +} + +function lightenColor(rgb: [number, number, number], ratio = 0.32) { + const out = rgb.map((v) => Math.round(v + (255 - v) * ratio) as number) as [number, number, number]; + return out; +} + +function getGlobeShipColor({ + selected, + legacy, + sog, +}: { + selected: boolean; + legacy: string | null; + sog: number | null; +}) { + if (selected) return "rgba(255,255,255,0.98)"; + if (legacy) { + const rgb = LEGACY_CODE_COLORS[legacy]; + if (rgb) return rgbToHex(lightenColor(rgb, 0.38)); + } + + if (!isFiniteNumber(sog)) return "rgba(100,116,139,0.75)"; + if (sog >= 10) return "#3b82f6"; + if (sog >= 1) return "#22c55e"; + return "rgba(100,116,139,0.75)"; +} + const LEGACY_CODE_COLORS: Record = { PT: [30, 64, 175], // #1e40af "PT-S": [234, 88, 12], // #ea580c @@ -100,15 +174,6 @@ const LEGACY_CODE_COLORS: Record = { FC: [245, 158, 11], // #f59e0b }; -const LEGACY_CODE_HEX: Record = { - PT: "#1e40af", - "PT-S": "#ea580c", - GN: "#10b981", - OT: "#8b5cf6", - PS: "#ef4444", - FC: "#f59e0b", -}; - const DEPTH_DISABLED_PARAMS = { // In MapLibre+Deck (interleaved), the map often leaves a depth buffer populated. // For 2D overlays like zones/icons/halos we want stable painter's-order rendering @@ -480,6 +545,14 @@ export function Map3D({ const projectionRef = useRef(projection); const [mapSyncEpoch, setMapSyncEpoch] = useState(0); + const pulseMapSync = () => { + setMapSyncEpoch((prev) => prev + 1); + requestAnimationFrame(() => { + kickRepaint(mapRef.current); + setMapSyncEpoch((prev) => prev + 1); + }); + }; + useEffect(() => { showSeamarkRef.current = settings.showSeamark; }, [settings.showSeamark]); @@ -561,7 +634,7 @@ export function Map3D({ } // Ensure the seamark raster overlay exists even when using MapTiler vector styles. - map.on("style.load", () => { + onMapStyleReady(map, () => { applyProjection(); // Globe deck layer lives inside the style and must be re-added after any style swap. if (projectionRef.current === "globe" && globeDeckLayerRef.current && !map!.getLayer(globeDeckLayerRef.current.id)) { @@ -599,16 +672,16 @@ export function Map3D({ } } - map.once("load", () => { - if (showSeamarkRef.current) { - try { - ensureSeamarkOverlay(map!, "bathymetry-lines"); - } catch { - // ignore - } - applySeamarkOpacity(); + map.once("load", () => { + if (showSeamarkRef.current) { + try { + ensureSeamarkOverlay(map!, "bathymetry-lines"); + } catch { + // ignore } - }); + applySeamarkOpacity(); + } + }); })(); return () => { @@ -658,7 +731,7 @@ export function Map3D({ } const next = projection; - try { + try { map.setProjection({ type: next }); map.setRenderWorldCopies(next !== "globe"); } catch (e) { @@ -740,20 +813,20 @@ export function Map3D({ } catch { // ignore } - - setMapSyncEpoch((prev) => prev + 1); + pulseMapSync(); }; if (map.isStyleLoaded()) syncProjectionAndDeck(); - else map.once("style.load", syncProjectionAndDeck); + else { + const stop = onMapStyleReady(map, syncProjectionAndDeck); + return () => { + cancelled = true; + stop(); + }; + } return () => { cancelled = true; - try { - map.off("style.load", syncProjectionAndDeck); - } catch { - // ignore - } }; }, [projection]); @@ -764,6 +837,7 @@ export function Map3D({ let cancelled = false; const controller = new AbortController(); + let stop: (() => void) | null = null; (async () => { try { @@ -772,10 +846,10 @@ export function Map3D({ // Disable style diff to avoid warnings with custom layers (Deck MapboxOverlay) and // to ensure a clean rebuild when switching between very different styles. map.setStyle(style, { diff: false }); - map.once("style.load", () => { + stop = onMapStyleReady(map, () => { kickRepaint(map); requestAnimationFrame(() => kickRepaint(map)); - setMapSyncEpoch((prev) => prev + 1); + pulseMapSync(); }); } catch (e) { if (cancelled) return; @@ -786,6 +860,7 @@ export function Map3D({ return () => { cancelled = true; controller.abort(); + stop?.(); }; }, [baseMap]); @@ -845,14 +920,9 @@ export function Map3D({ } }; - if (map.isStyleLoaded()) apply(); - map.on("style.load", apply); + const stop = onMapStyleReady(map, apply); return () => { - try { - map.off("style.load", apply); - } catch { - // ignore - } + stop(); }; }, [projection, baseMap, mapSyncEpoch]); @@ -975,14 +1045,9 @@ export function Map3D({ } }; - if (map.isStyleLoaded()) ensure(); - map.on("style.load", ensure); + const stop = onMapStyleReady(map, ensure); return () => { - try { - map.off("style.load", ensure); - } catch { - // ignore - } + stop(); }; }, [zones, overlays.zones, projection, baseMap, mapSyncEpoch]); @@ -1040,22 +1105,9 @@ export function Map3D({ ctx.fillRect(size / 2 - 8, 34, 16, 18); const img = ctx.getImageData(0, 0, size, size); - map.addImage(imgId, img, { pixelRatio: 2 }); + map.addImage(imgId, img, { pixelRatio: 2, sdf: true }); }; - const speedColorExpr: unknown[] = [ - "case", - [">=", ["to-number", ["get", "sog"]], 10], - "#3b82f6", - [">=", ["to-number", ["get", "sog"]], 1], - "#22c55e", - "#64748b", - ]; - - const codeColorExpr: unknown[] = ["match", ["get", "code"]]; - for (const [k, hex] of Object.entries(LEGACY_CODE_HEX)) codeColorExpr.push(k, hex); - codeColorExpr.push(speedColorExpr); - const ensure = () => { if (!map.isStyleLoaded()) return; @@ -1099,6 +1151,11 @@ export function Map3D({ name: t.name || "", cog: cogNorm, sog: isFiniteNumber(t.sog) ? t.sog : 0, + shipColor: getGlobeShipColor({ + selected, + legacy: legacy?.shipCode || null, + sog: isFiniteNumber(t.sog) ? t.sog : null, + }), iconSize3, iconSize7, iconSize10, @@ -1149,7 +1206,7 @@ export function Map3D({ layout: { visibility }, paint: { "circle-radius": circleRadius as never, - "circle-color": codeColorExpr as never, + "circle-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, "circle-opacity": 0.22, }, } as unknown as LayerSpecification, @@ -1177,7 +1234,7 @@ export function Map3D({ paint: { "circle-radius": circleRadius as never, "circle-color": "rgba(0,0,0,0)", - "circle-stroke-color": codeColorExpr as never, + "circle-stroke-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, "circle-stroke-width": [ "case", ["boolean", ["get", "permitted"], false], @@ -1229,10 +1286,13 @@ export function Map3D({ "icon-rotate": ["to-number", ["get", "cog"], 0], // Keep the icon on the sea surface. "icon-rotation-alignment": "map", - "icon-pitch-alignment": "map", + "icon-pitch-alignment": "viewport", }, paint: { + "icon-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, "icon-opacity": ["case", ["==", ["get", "selected"], 1], 1.0, 0.92], + "icon-halo-color": "rgba(15,23,42,0.25)", + "icon-halo-width": 1, }, } as unknown as LayerSpecification, before, @@ -1252,14 +1312,9 @@ export function Map3D({ kickRepaint(map); }; - ensure(); - map.on("style.load", ensure); + const stop = onMapStyleReady(map, ensure); return () => { - try { - map.off("style.load", ensure); - } catch { - // ignore - } + stop(); }; }, [projection, settings.showShips, targets, legacyHits, selectedMmsi, mapSyncEpoch]); @@ -1277,9 +1332,19 @@ export function Map3D({ const onClick = (e: maplibregl.MapMouseEvent) => { try { const layerIds = [symbolId, haloId, outlineId].filter((id) => map.getLayer(id)); - const feats = layerIds.length > 0 ? map.queryRenderedFeatures(e.point, { layers: layerIds }) : []; + let feats: unknown[] = []; + if (layerIds.length > 0) { + try { + feats = map.queryRenderedFeatures(e.point, { layers: layerIds }) as unknown[]; + } catch { + feats = []; + } + } const f = feats?.[0]; - const props = (f?.properties || {}) as Record; + const props = ((f as { properties?: Record } | undefined)?.properties || {}) as Record< + string, + unknown + >; const mmsi = Number(props.mmsi); if (Number.isFinite(mmsi)) { onSelectMmsi(mmsi); @@ -1399,14 +1464,10 @@ export function Map3D({ kickRepaint(map); }; + const stop = onMapStyleReady(map, ensure); ensure(); - map.on("style.load", ensure); return () => { - try { - map.off("style.load", ensure); - } catch { - // ignore - } + stop(); remove(); }; }, [projection, overlays.pairLines, pairLinks, mapSyncEpoch]); @@ -1495,14 +1556,10 @@ export function Map3D({ kickRepaint(map); }; + const stop = onMapStyleReady(map, ensure); ensure(); - map.on("style.load", ensure); return () => { - try { - map.off("style.load", ensure); - } catch { - // ignore - } + stop(); remove(); }; }, [projection, overlays.fcLines, fcLinks, mapSyncEpoch]); @@ -1582,14 +1639,10 @@ export function Map3D({ kickRepaint(map); }; + const stop = onMapStyleReady(map, ensure); ensure(); - map.on("style.load", ensure); return () => { - try { - map.off("style.load", ensure); - } catch { - // ignore - } + stop(); remove(); }; }, [projection, overlays.fleetCircles, fleetCircles, mapSyncEpoch]); @@ -1684,14 +1737,10 @@ export function Map3D({ kickRepaint(map); }; + const stop = onMapStyleReady(map, ensure); ensure(); - map.on("style.load", ensure); return () => { - try { - map.off("style.load", ensure); - } catch { - // ignore - } + stop(); remove(); }; }, [projection, overlays.pairRange, pairLinks, mapSyncEpoch]); From 15378ed7ffac2ad9187ab33da4e7fdfc356dbb74 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 14:24:00 +0900 Subject: [PATCH 10/58] fix(map): scale flat icons and prioritize relation layers --- apps/web/src/widgets/map3d/Map3D.tsx | 142 +++++++++++++++------------ 1 file changed, 80 insertions(+), 62 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index c8acd5b..fa1846c 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -182,6 +182,11 @@ const DEPTH_DISABLED_PARAMS = { depthWriteEnabled: false, } as const; +const FLAT_SHIP_ICON_SIZE = 19; +const FLAT_SHIP_ICON_SIZE_SELECTED = 28; +const FLAT_LEGACY_HALO_RADIUS = 14; +const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18; + const GLOBE_OVERLAY_PARAMS = { // In globe mode we want depth-testing against the globe so features on the far side don't draw through. // Still disable depth writes so our overlays don't interfere with each other. @@ -717,7 +722,12 @@ export function Map3D({ if (!map) return; let cancelled = false; let retries = 0; - const maxRetries = 6; + const maxRetries = 18; + + const getCurrentProjection = () => { + const projectionConfig = map.getProjection?.(); + return projectionConfig && "type" in projectionConfig ? (projectionConfig.type as MapProjectionId | undefined) : undefined; + }; const syncProjectionAndDeck = () => { if (cancelled) return; @@ -732,7 +742,9 @@ export function Map3D({ const next = projection; try { - map.setProjection({ type: next }); + if (getCurrentProjection() !== next) { + map.setProjection({ type: next }); + } map.setRenderWorldCopies(next !== "globe"); } catch (e) { if (!cancelled && retries < maxRetries) { @@ -781,6 +793,11 @@ export function Map3D({ } catch { // ignore } + if (!map.getLayer(layer.id) && !cancelled && retries < maxRetries) { + retries += 1; + window.requestAnimationFrame(() => syncProjectionAndDeck()); + return; + } } } else { // Tear down globe custom layer (if present), restore MapboxOverlay interleaved. @@ -1433,7 +1450,7 @@ export function Map3D({ return; } - const before = map.getLayer("ships-globe-halo") ? "ships-globe-halo" : undefined; + const before = undefined; if (!map.getLayer(layerId)) { try { @@ -1525,7 +1542,7 @@ export function Map3D({ return; } - const before = map.getLayer("ships-globe-halo") ? "ships-globe-halo" : undefined; + const before = undefined; if (!map.getLayer(layerId)) { try { @@ -1613,7 +1630,7 @@ export function Map3D({ return; } - const before = map.getLayer("ships-globe-halo") ? "ships-globe-halo" : undefined; + const before = undefined; if (!map.getLayer(layerId)) { try { @@ -1706,7 +1723,7 @@ export function Map3D({ return; } - const before = map.getLayer("ships-globe-halo") ? "ships-globe-halo" : undefined; + const before = undefined; if (!map.getLayer(layerId)) { try { @@ -1805,22 +1822,61 @@ export function Map3D({ ); } - if (overlays.fleetCircles && projection !== "globe" && (fleetCircles?.length ?? 0) > 0) { + if (settings.showShips && projection !== "globe") { layers.push( - new ScatterplotLayer({ - id: "fleet-circles", - data: fleetCircles, + new IconLayer({ + id: "ships", + data: shipData, + pickable: true, + // Keep icons horizontal on the sea surface when view is pitched/rotated. + billboard: false, + parameters: overlayParams, + iconAtlas: "/assets/ship.svg", + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => "ship", + getPosition: (d) => + [d.lon, d.lat] as [number, number], + getAngle: (d) => (isFiniteNumber(d.cog) ? d.cog : 0), + sizeUnits: "pixels", + getSize: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? FLAT_SHIP_ICON_SIZE_SELECTED : FLAT_SHIP_ICON_SIZE), + getColor: (d) => getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null), + alphaCutoff: 0.05, + updateTriggers: { + getSize: [selectedMmsi], + getColor: [selectedMmsi, legacyHits], + }, + }), + ); + } + + if (settings.showShips && projection !== "globe" && legacyTargets.length > 0) { + layers.push( + new ScatterplotLayer({ + id: "legacy-halo", + data: legacyTargets, pickable: false, billboard: false, + // This ring is most prone to z-fighting, so force it into pure painter's-order rendering. parameters: overlayParams, filled: false, stroked: true, - radiusUnits: "meters", - getRadius: (d) => d.radiusNm * 1852, + radiusUnits: "pixels", + getRadius: (d) => + (selectedMmsi && d.mmsi === selectedMmsi ? FLAT_LEGACY_HALO_RADIUS_SELECTED : FLAT_LEGACY_HALO_RADIUS), lineWidthUnits: "pixels", - getLineWidth: 1, - getLineColor: () => [245, 158, 11, 140], - getPosition: (d) => d.center, + getLineWidth: 2, + getLineColor: (d) => { + const l = legacyHits?.get(d.mmsi); + const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; + if (!rgb) return [245, 158, 11, 200]; + return [rgb[0], rgb[1], rgb[2], 200]; + }, + getPosition: (d) => + [d.lon, d.lat] as [number, number], + updateTriggers: { + getRadius: [selectedMmsi], + getLineColor: [legacyHits], + }, }), ); } @@ -1878,60 +1934,22 @@ export function Map3D({ ); } - if (settings.showShips && projection !== "globe" && legacyTargets.length > 0) { + if (overlays.fleetCircles && projection !== "globe" && (fleetCircles?.length ?? 0) > 0) { layers.push( - new ScatterplotLayer({ - id: "legacy-halo", - data: legacyTargets, + new ScatterplotLayer({ + id: "fleet-circles", + data: fleetCircles, pickable: false, billboard: false, - // This ring is most prone to z-fighting, so force it into pure painter's-order rendering. parameters: overlayParams, filled: false, stroked: true, - radiusUnits: "pixels", - getRadius: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 22 : 16), + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: "pixels", - getLineWidth: 2, - getLineColor: (d) => { - const l = legacyHits?.get(d.mmsi); - const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; - if (!rgb) return [245, 158, 11, 200]; - return [rgb[0], rgb[1], rgb[2], 200]; - }, - getPosition: (d) => - [d.lon, d.lat] as [number, number], - updateTriggers: { - getRadius: [selectedMmsi], - getLineColor: [legacyHits], - }, - }), - ); - } - - if (settings.showShips && projection !== "globe") { - layers.push( - new IconLayer({ - id: "ships", - data: shipData, - pickable: true, - // Keep icons horizontal on the sea surface when view is pitched/rotated. - billboard: false, - parameters: overlayParams, - iconAtlas: "/assets/ship.svg", - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => "ship", - getPosition: (d) => - [d.lon, d.lat] as [number, number], - getAngle: (d) => (isFiniteNumber(d.cog) ? d.cog : 0), - sizeUnits: "pixels", - getSize: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 34 : 22), - getColor: (d) => getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null), - alphaCutoff: 0.05, - updateTriggers: { - getSize: [selectedMmsi], - getColor: [selectedMmsi, legacyHits], - }, + getLineWidth: 1, + getLineColor: () => [245, 158, 11, 140], + getPosition: (d) => d.center, }), ); } From 0ffadb2e66f28712a33deadda2b8d36d4baeb81a Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 14:27:08 +0900 Subject: [PATCH 11/58] fix(map): harden globe projection switch and overlay teardown --- apps/web/src/widgets/map3d/Map3D.tsx | 86 ++++++++++++++++++---------- 1 file changed, 57 insertions(+), 29 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index fa1846c..625d0cc 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -126,6 +126,16 @@ function onMapStyleReady(map: maplibregl.Map, callback: () => void) { }; } +function extractProjectionType(map: maplibregl.Map): MapProjectionId | undefined { + const projection = map.getProjection?.(); + if (!projection || typeof projection !== "object") return undefined; + + const rawType = (projection as { type?: unknown; name?: unknown }).type ?? (projection as { type?: unknown; name?: unknown }).name; + if (rawType === "globe") return "globe"; + if (rawType === "mercator") return "mercator"; + return undefined; +} + const DEG2RAD = Math.PI / 180; const clampNumber = (value: number, minValue: number, maxValue: number) => Math.max(minValue, Math.min(maxValue, value)); @@ -724,9 +734,38 @@ export function Map3D({ let retries = 0; const maxRetries = 18; - const getCurrentProjection = () => { - const projectionConfig = map.getProjection?.(); - return projectionConfig && "type" in projectionConfig ? (projectionConfig.type as MapProjectionId | undefined) : undefined; + const disposeMercatorOverlay = () => { + const current = overlayRef.current; + if (!current) return; + try { + map.removeControl(current as never); + } catch { + // ignore + } + try { + current.finalize(); + } catch { + // ignore + } + overlayRef.current = null; + }; + + const disposeGlobeDeckLayer = () => { + const current = globeDeckLayerRef.current; + if (!current) return; + if (map.getLayer(current.id)) { + try { + map.removeLayer(current.id); + } catch { + // ignore + } + } + try { + current.requestFinalize(); + } catch { + // ignore + } + globeDeckLayerRef.current = null; }; const syncProjectionAndDeck = () => { @@ -741,11 +780,21 @@ export function Map3D({ } const next = projection; + const currentProjection = extractProjectionType(map); + const shouldSwitchProjection = currentProjection !== next; + try { - if (getCurrentProjection() !== next) { + if (shouldSwitchProjection) { map.setProjection({ type: next }); } map.setRenderWorldCopies(next !== "globe"); + if (shouldSwitchProjection) { + if (!cancelled && retries < maxRetries) { + retries += 1; + window.requestAnimationFrame(() => syncProjectionAndDeck()); + return; + } + } } catch (e) { if (!cancelled && retries < maxRetries) { retries += 1; @@ -758,25 +807,12 @@ export function Map3D({ const oldOverlay = overlayRef.current; if (projection === "globe" && oldOverlay) { // Globe mode uses custom MapLibre deck layers and should fully replace Mercator overlays. - try { - oldOverlay.finalize(); - } catch { - // ignore - } - overlayRef.current = null; + disposeMercatorOverlay(); } if (projection === "globe") { - // Ensure any stale layer from old mode is dropped then re-added on this projection. - if (globeDeckLayerRef.current) { - try { - if (map.getLayer(globeDeckLayerRef.current.id)) { - map.removeLayer(globeDeckLayerRef.current.id); - } - } catch { - // ignore - } - } + // Start with a clean globe Deck layer state to avoid partially torn-down renders. + disposeGlobeDeckLayer(); if (!globeDeckLayerRef.current) { globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ @@ -801,15 +837,7 @@ export function Map3D({ } } else { // Tear down globe custom layer (if present), restore MapboxOverlay interleaved. - const globeLayer = globeDeckLayerRef.current; - if (globeLayer && map.getLayer(globeLayer.id)) { - try { - globeLayer.requestFinalize(); - map.removeLayer(globeLayer.id); - } catch { - // ignore - } - } + disposeGlobeDeckLayer(); if (!overlayRef.current) { try { From 6f7a82af4c03935e52dcdfd62114c7dd79ddc413 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 14:29:19 +0900 Subject: [PATCH 12/58] fix(map): stop hiding raster base and reset decks on projection switch --- apps/web/src/widgets/map3d/Map3D.tsx | 36 +++++++++++++++------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 625d0cc..a2487b0 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -737,6 +737,11 @@ export function Map3D({ const disposeMercatorOverlay = () => { const current = overlayRef.current; if (!current) return; + try { + current.setProps({ layers: [] } as never); + } catch { + // ignore + } try { map.removeControl(current as never); } catch { @@ -783,17 +788,21 @@ export function Map3D({ const currentProjection = extractProjectionType(map); const shouldSwitchProjection = currentProjection !== next; + if (projection === "globe") { + disposeMercatorOverlay(); + } else { + disposeGlobeDeckLayer(); + } + try { if (shouldSwitchProjection) { map.setProjection({ type: next }); } map.setRenderWorldCopies(next !== "globe"); - if (shouldSwitchProjection) { - if (!cancelled && retries < maxRetries) { - retries += 1; - window.requestAnimationFrame(() => syncProjectionAndDeck()); - return; - } + if (shouldSwitchProjection && currentProjection !== next && !cancelled && retries < maxRetries) { + retries += 1; + window.requestAnimationFrame(() => syncProjectionAndDeck()); + return; } } catch (e) { if (!cancelled && retries < maxRetries) { @@ -804,12 +813,6 @@ export function Map3D({ console.warn("Projection switch failed:", e); } - const oldOverlay = overlayRef.current; - if (projection === "globe" && oldOverlay) { - // Globe mode uses custom MapLibre deck layers and should fully replace Mercator overlays. - disposeMercatorOverlay(); - } - if (projection === "globe") { // Start with a clean globe Deck layer state to avoid partially torn-down renders. disposeGlobeDeckLayer(); @@ -919,8 +922,7 @@ export function Map3D({ if (!map.isStyleLoaded()) return; const disableBathyHeavy = projection === "globe" && baseMap === "enhanced"; const visHeavy = disableBathyHeavy ? "none" : "visible"; - const disableBaseMapSea = projection === "globe"; - const seaVisibility = disableBaseMapSea ? "none" : "visible"; + const seaVisibility = "visible" as const; const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i; // Globe + our injected bathymetry fill polygons can exceed MapLibre's per-segment vertex limit @@ -940,8 +942,8 @@ export function Map3D({ } } - // Vector basemap water/raster layers can flicker on globe with dense symbols/fills in this stack. - // Hide them only in globe mode and restore on return. + // Vector basemap water layers can be tuned per-style. Keep visible by default, + // only toggling layers that match an explicit water/sea signature. try { for (const layer of map.getStyle().layers || []) { const id = String(layer.id ?? ""); @@ -951,7 +953,7 @@ export function Map3D({ const type = String((layer as { type?: unknown }).type ?? "").toLowerCase(); const isSea = seaRegex.test(id) || seaRegex.test(sourceLayer) || seaRegex.test(source); const isRaster = type === "raster"; - if (!isSea && !isRaster) continue; + if (!isSea) continue; if (!map.getLayer(id)) continue; if (isRaster && id === "seamark") continue; try { From 1225d5c54c97b301ea32b19b04a40ca022ba6c3b Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 14:33:50 +0900 Subject: [PATCH 13/58] fix(map3d): sync mercator restore on globe toggle --- apps/web/src/widgets/map3d/Map3D.tsx | 102 +++++++++++++++++++++------ 1 file changed, 82 insertions(+), 20 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index a2487b0..0afa504 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -9,7 +9,7 @@ import maplibregl, { type StyleSpecification, type VectorSourceSpecification, } from "maplibre-gl"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AisTarget } from "../../entities/aisTarget/model/types"; import type { LegacyVesselInfo } from "../../entities/legacyVessel/model/types"; import type { ZonesGeoJson } from "../../entities/zone/api/useZones"; @@ -580,6 +580,67 @@ export function Map3D({ projectionRef.current = projection; }, [projection]); + const removeLayerIfExists = useCallback((map: maplibregl.Map, layerId: string) => { + try { + if (map.getLayer(layerId)) { + map.removeLayer(layerId); + } + } catch { + // ignore + } + }, []); + + const removeSourceIfExists = useCallback((map: maplibregl.Map, sourceId: string) => { + try { + if (map.getSource(sourceId)) { + map.removeSource(sourceId); + } + } catch { + // ignore + } + }, []); + + const ensureMercatorOverlay = useCallback(() => { + const map = mapRef.current; + if (!map) return null; + if (overlayRef.current) return overlayRef.current; + + try { + const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never); + map.addControl(next); + overlayRef.current = next; + return next; + } catch (e) { + console.warn("Deck overlay create failed:", e); + return null; + } + }, []); + + const clearGlobeNativeLayers = useCallback(() => { + const map = mapRef.current; + if (!map) return; + + const layerIds = [ + "ships-globe-halo", + "ships-globe-outline", + "ships-globe", + "pair-lines-ml", + "fc-lines-ml", + "fleet-circles-ml", + "pair-range-ml", + "deck-globe", + ]; + + for (const id of layerIds) { + removeLayerIfExists(map, id); + } + + const sourceIds = ["ships-globe-src", "pair-lines-ml-src", "fc-lines-ml-src", "fleet-circles-ml-src", "pair-range-ml-src"]; + for (const id of sourceIds) { + removeSourceIfExists(map, id); + } + }, [removeLayerIfExists, removeSourceIfExists]); + // Init MapLibre + Deck.gl (single WebGL context via MapboxOverlay) useEffect(() => { if (!containerRef.current || mapRef.current) return; @@ -758,13 +819,7 @@ export function Map3D({ const disposeGlobeDeckLayer = () => { const current = globeDeckLayerRef.current; if (!current) return; - if (map.getLayer(current.id)) { - try { - map.removeLayer(current.id); - } catch { - // ignore - } - } + removeLayerIfExists(map, current.id); try { current.requestFinalize(); } catch { @@ -790,8 +845,10 @@ export function Map3D({ if (projection === "globe") { disposeMercatorOverlay(); + clearGlobeNativeLayers(); } else { disposeGlobeDeckLayer(); + clearGlobeNativeLayers(); } try { @@ -842,15 +899,7 @@ export function Map3D({ // Tear down globe custom layer (if present), restore MapboxOverlay interleaved. disposeGlobeDeckLayer(); - if (!overlayRef.current) { - try { - const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never); - map.addControl(next); - overlayRef.current = next; - } catch (e) { - console.warn("Deck overlay create failed:", e); - } - } + ensureMercatorOverlay(); } // MapLibre may not schedule a frame immediately after projection swaps if the map is idle. @@ -876,7 +925,7 @@ export function Map3D({ return () => { cancelled = true; }; - }, [projection]); + }, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, removeLayerIfExists]); // Base map toggle useEffect(() => { @@ -1333,7 +1382,7 @@ export function Map3D({ "icon-rotate": ["to-number", ["get", "cog"], 0], // Keep the icon on the sea surface. "icon-rotation-alignment": "map", - "icon-pitch-alignment": "viewport", + "icon-pitch-alignment": "map", }, paint: { "icon-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, @@ -1830,7 +1879,19 @@ export function Map3D({ useEffect(() => { const map = mapRef.current; if (!map) return; - const deckTarget = projection === "globe" ? globeDeckLayerRef.current : overlayRef.current; + let deckTarget = projection === "globe" ? globeDeckLayerRef.current : overlayRef.current; + + if (projection === "mercator") { + if (!deckTarget) deckTarget = ensureMercatorOverlay(); + if (!deckTarget) return; + try { + deckTarget.setProps({ layers: [] } as never); + } catch { + // ignore + } + } else if (!deckTarget && projection === "globe") { + return; + } if (!deckTarget) return; const overlayParams = projection === "globe" ? GLOBE_OVERLAY_PARAMS : DEPTH_DISABLED_PARAMS; @@ -2068,6 +2129,7 @@ export function Map3D({ fcDashed, fleetCircles, mapSyncEpoch, + ensureMercatorOverlay, ]); return
; From 2514591703b593765667ac91021f53e336937060 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 14:38:25 +0900 Subject: [PATCH 14/58] fix(map3d): align globe ship icon rendering and heading --- apps/web/src/widgets/map3d/Map3D.tsx | 123 +++++++++++++++++++++------ 1 file changed, 96 insertions(+), 27 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 0afa504..b5f7de9 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -137,9 +137,29 @@ function extractProjectionType(map: maplibregl.Map): MapProjectionId | undefined } const DEG2RAD = Math.PI / 180; +const GLOBE_ICON_HEADING_OFFSET_DEG = -90; const clampNumber = (value: number, minValue: number, maxValue: number) => Math.max(minValue, Math.min(maxValue, value)); +function normalizeAngleDeg(value: number, offset = 0): number { + const v = value + offset; + return ((v % 360) + 360) % 360; +} + +function getDisplayHeading({ + cog, + heading, + offset = 0, +}: { + cog: number | null | undefined; + heading: number | null | undefined; + offset?: number; +}) { + const raw = + isFiniteNumber(heading) && heading >= 0 && heading <= 360 && heading !== 511 ? heading : isFiniteNumber(cog) ? cog : 0; + return normalizeAngleDeg(raw, offset); +} + function rgbToHex(rgb: [number, number, number]) { const toHex = (v: number) => { const clamped = Math.max(0, Math.min(255, Math.round(v))); @@ -182,6 +202,7 @@ const LEGACY_CODE_COLORS: Record = { OT: [139, 92, 246], // #8b5cf6 PS: [239, 68, 68], // #ef4444 FC: [245, 158, 11], // #f59e0b + C21: [236, 72, 153], // #ec4899 }; const DEPTH_DISABLED_PARAMS = { @@ -558,6 +579,7 @@ export function Map3D({ const showSeamarkRef = useRef(settings.showSeamark); const baseMapRef = useRef(baseMap); const projectionRef = useRef(projection); + const globeShipIconLoadingRef = useRef(false); const [mapSyncEpoch, setMapSyncEpoch] = useState(0); const pulseMapSync = () => { @@ -1176,32 +1198,71 @@ export function Map3D({ }; const ensureImage = () => { + const addFallbackImage = () => { + const size = 96; + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // Simple top-down ship silhouette, pointing north. + ctx.clearRect(0, 0, size, size); + ctx.fillStyle = "rgba(255,255,255,1)"; + ctx.beginPath(); + ctx.moveTo(size / 2, 6); + ctx.lineTo(size / 2 - 14, 24); + ctx.lineTo(size / 2 - 18, 58); + ctx.lineTo(size / 2 - 10, 88); + ctx.lineTo(size / 2 + 10, 88); + ctx.lineTo(size / 2 + 18, 58); + ctx.lineTo(size / 2 + 14, 24); + ctx.closePath(); + ctx.fill(); + + ctx.fillRect(size / 2 - 8, 34, 16, 18); + + const img = ctx.getImageData(0, 0, size, size); + map.addImage(imgId, img, { pixelRatio: 2, sdf: true }); + kickRepaint(map); + }; + if (map.hasImage(imgId)) return; - const size = 96; - const canvas = document.createElement("canvas"); - canvas.width = size; - canvas.height = size; - const ctx = canvas.getContext("2d"); - if (!ctx) return; + if (globeShipIconLoadingRef.current) return; - // Simple top-down ship silhouette, pointing north. - ctx.clearRect(0, 0, size, size); - ctx.fillStyle = "rgba(255,255,255,1)"; - ctx.beginPath(); - ctx.moveTo(size / 2, 6); - ctx.lineTo(size / 2 - 14, 24); - ctx.lineTo(size / 2 - 18, 58); - ctx.lineTo(size / 2 - 10, 88); - ctx.lineTo(size / 2 + 10, 88); - ctx.lineTo(size / 2 + 18, 58); - ctx.lineTo(size / 2 + 14, 24); - ctx.closePath(); - ctx.fill(); + try { + globeShipIconLoadingRef.current = true; + void map + .loadImage("/assets/ship.svg") + .then((response) => { + globeShipIconLoadingRef.current = false; + if (map.hasImage(imgId)) return; - ctx.fillRect(size / 2 - 8, 34, 16, 18); + const loadedImage = (response as { data?: HTMLImageElement | ImageBitmap } | undefined)?.data; + if (!loadedImage) { + addFallbackImage(); + return; + } - const img = ctx.getImageData(0, 0, size, size); - map.addImage(imgId, img, { pixelRatio: 2, sdf: true }); + try { + map.addImage(imgId, loadedImage, { pixelRatio: 2, sdf: true }); + kickRepaint(map); + } catch (e) { + console.warn("Ship icon image add failed:", e); + } + }) + .catch(() => { + globeShipIconLoadingRef.current = false; + addFallbackImage(); + }); + } catch (e) { + globeShipIconLoadingRef.current = false; + try { + addFallbackImage(); + } catch (fallbackError) { + console.warn("Ship icon image setup failed:", e, fallbackError); + } + } }; const ensure = () => { @@ -1228,8 +1289,11 @@ export function Map3D({ type: "FeatureCollection", features: globeShipData.map((t) => { const legacy = legacyHits?.get(t.mmsi) ?? null; - const cog = isFiniteNumber(t.cog) ? t.cog : 0; - const cogNorm = ((cog % 360) + 360) % 360; + const heading = getDisplayHeading({ + cog: t.cog, + heading: t.heading, + offset: GLOBE_ICON_HEADING_OFFSET_DEG, + }); const hull = clampNumber((isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, 50, 420); const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); const selected = t.mmsi === selectedMmsi; @@ -1245,7 +1309,8 @@ export function Map3D({ properties: { mmsi: t.mmsi, name: t.name || "", - cog: cogNorm, + cog: heading, + heading, sog: isFiniteNumber(t.sog) ? t.sog : 0, shipColor: getGlobeShipColor({ selected, @@ -1379,7 +1444,7 @@ export function Map3D({ "icon-allow-overlap": true, "icon-ignore-placement": true, "icon-anchor": "center", - "icon-rotate": ["to-number", ["get", "cog"], 0], + "icon-rotate": ["to-number", ["get", "heading"], 0], // Keep the icon on the sea surface. "icon-rotation-alignment": "map", "icon-pitch-alignment": "map", @@ -1927,7 +1992,11 @@ export function Map3D({ getIcon: () => "ship", getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => (isFiniteNumber(d.cog) ? d.cog : 0), + getAngle: (d) => + getDisplayHeading({ + cog: d.cog, + heading: d.heading, + }), sizeUnits: "pixels", getSize: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? FLAT_SHIP_ICON_SIZE_SELECTED : FLAT_SHIP_ICON_SIZE), getColor: (d) => getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null), From f745bb16d7a2c11432391d4a82c61813e7e0ce61 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 14:42:07 +0900 Subject: [PATCH 15/58] feat(map3d): add projection mode transition loading overlay --- apps/web/src/app/styles.css | 74 +++++++++++++++++++ .../web/src/pages/dashboard/DashboardPage.tsx | 14 ++++ apps/web/src/widgets/map3d/Map3D.tsx | 71 +++++++++++++++++- 3 files changed, 158 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/styles.css b/apps/web/src/app/styles.css index bd6a490..de52f4f 100644 --- a/apps/web/src/app/styles.css +++ b/apps/web/src/app/styles.css @@ -596,6 +596,80 @@ body { font-weight: 600; } +.map-loader-overlay { + position: absolute; + inset: 0; + z-index: 950; + display: flex; + align-items: center; + justify-content: center; + background: rgba(2, 6, 23, 0.42); + pointer-events: auto; +} + +.map-loader-overlay__panel { + width: min(72vw, 320px); + background: rgba(15, 23, 42, 0.94); + border: 1px solid var(--border); + border-radius: 12px; + padding: 14px 16px; + display: grid; + gap: 10px; + justify-items: center; +} + +.map-loader-overlay__spinner { + width: 28px; + height: 28px; + border: 3px solid rgba(148, 163, 184, 0.28); + border-top-color: var(--accent); + border-radius: 50%; + animation: map-loader-spin 0.7s linear infinite; +} + +.map-loader-overlay__text { + font-size: 12px; + color: var(--text); + letter-spacing: 0.2px; +} + +.map-loader-overlay__bar { + width: 100%; + height: 6px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.2); + overflow: hidden; + position: relative; +} + +.map-loader-overlay__fill { + width: 28%; + height: 100%; + border-radius: inherit; + background: var(--accent); + animation: map-loader-fill 1.2s ease-in-out infinite; +} + +@keyframes map-loader-spin { + to { + transform: rotate(360deg); + } +} + +@keyframes map-loader-fill { + 0% { + transform: translateX(-40%); + } + + 50% { + transform: translateX(220%); + } + + 100% { + transform: translateX(-40%); + } +} + .close-btn { position: absolute; top: 6px; diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index ee949ac..c2f9453 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -115,6 +115,8 @@ export function DashboardPage() { showSeamark: false, }); + const [isProjectionLoading, setIsProjectionLoading] = useState(false); + const [clock, setClock] = useState(() => new Date().toLocaleString("ko-KR", { hour12: false })); useEffect(() => { const id = window.setInterval(() => setClock(new Date().toLocaleString("ko-KR", { hour12: false })), 1000); @@ -472,6 +474,17 @@ export function DashboardPage() {
+ {isProjectionLoading ? ( +
+
+
+
지도 모드 동기화 중...
+
+
+
+
+
+ ) : null} {selectedLegacyVessel ? ( diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index b5f7de9..241a0a7 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -42,6 +42,7 @@ type Props = { pairLinks?: PairLink[]; fcLinks?: FcLink[]; fleetCircles?: FleetCircle[]; + onProjectionLoadingChange?: (loading: boolean) => void; }; const SHIP_ICON_MAPPING = { @@ -570,6 +571,7 @@ export function Map3D({ pairLinks, fcLinks, fleetCircles, + onProjectionLoadingChange, }: Props) { const containerRef = useRef(null); const mapRef = useRef(null); @@ -580,8 +582,41 @@ export function Map3D({ const baseMapRef = useRef(baseMap); const projectionRef = useRef(projection); const globeShipIconLoadingRef = useRef(false); + const projectionBusyRef = useRef(false); + const projectionBusyTimerRef = useRef | null>(null); + const projectionPrevRef = useRef(projection); const [mapSyncEpoch, setMapSyncEpoch] = useState(0); + const clearProjectionBusyTimer = useCallback(() => { + if (projectionBusyTimerRef.current == null) return; + clearTimeout(projectionBusyTimerRef.current); + projectionBusyTimerRef.current = null; + }, []); + + const setProjectionLoading = useCallback( + (loading: boolean) => { + if (projectionBusyRef.current === loading) return; + projectionBusyRef.current = loading; + + if (loading) { + clearProjectionBusyTimer(); + projectionBusyTimerRef.current = setTimeout(() => { + if (projectionBusyRef.current) { + setProjectionLoading(false); + console.warn("Projection loading fallback timeout reached."); + } + }, 18000); + } else { + clearProjectionBusyTimer(); + } + + if (onProjectionLoadingChange) { + onProjectionLoadingChange(loading); + } + }, + [onProjectionLoadingChange, clearProjectionBusyTimer], + ); + const pulseMapSync = () => { setMapSyncEpoch((prev) => prev + 1); requestAnimationFrame(() => { @@ -590,6 +625,15 @@ export function Map3D({ }); }; + useEffect(() => { + return () => { + clearProjectionBusyTimer(); + if (projectionBusyRef.current) { + setProjectionLoading(false); + } + }; + }, [clearProjectionBusyTimer, setProjectionLoading]); + useEffect(() => { showSeamarkRef.current = settings.showSeamark; }, [settings.showSeamark]); @@ -816,6 +860,10 @@ export function Map3D({ let cancelled = false; let retries = 0; const maxRetries = 18; + const isTransition = projectionPrevRef.current !== projection; + projectionPrevRef.current = projection; + + if (isTransition) setProjectionLoading(true); const disposeMercatorOverlay = () => { const current = overlayRef.current; @@ -852,6 +900,9 @@ export function Map3D({ const syncProjectionAndDeck = () => { if (cancelled) return; + if (!isTransition) { + return; + } if (!map.isStyleLoaded()) { if (!cancelled && retries < maxRetries) { @@ -889,6 +940,7 @@ export function Map3D({ window.requestAnimationFrame(() => syncProjectionAndDeck()); return; } + if (isTransition) setProjectionLoading(false); console.warn("Projection switch failed:", e); } @@ -932,22 +984,39 @@ export function Map3D({ } catch { // ignore } + if (isTransition) { + const mercatorReady = projection === "mercator" && !!overlayRef.current; + const globeReady = projection === "globe" && !!map.getLayer("deck-globe"); + if (mercatorReady || globeReady) { + setProjectionLoading(false); + } else if (!cancelled && retries < maxRetries) { + retries += 1; + window.requestAnimationFrame(() => syncProjectionAndDeck()); + return; + } else { + setProjectionLoading(false); + } + } pulseMapSync(); }; + if (!isTransition) return; + if (map.isStyleLoaded()) syncProjectionAndDeck(); else { const stop = onMapStyleReady(map, syncProjectionAndDeck); return () => { cancelled = true; stop(); + if (isTransition) setProjectionLoading(false); }; } return () => { cancelled = true; + if (isTransition) setProjectionLoading(false); }; - }, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, removeLayerIfExists]); + }, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, removeLayerIfExists, setProjectionLoading]); // Base map toggle useEffect(() => { From 9a9f7302cb91b4deeabc1cfb46c039bdcb12b909 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 14:45:31 +0900 Subject: [PATCH 16/58] fix(map3d): simplify projection loading release condition --- apps/web/src/widgets/map3d/Map3D.tsx | 51 +++++++++++++++++++++------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 241a0a7..af4d3be 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -605,7 +605,7 @@ export function Map3D({ setProjectionLoading(false); console.warn("Projection loading fallback timeout reached."); } - }, 18000); + }, 3000); } else { clearProjectionBusyTimer(); } @@ -862,6 +862,41 @@ export function Map3D({ const maxRetries = 18; const isTransition = projectionPrevRef.current !== projection; projectionPrevRef.current = projection; + let settleScheduled = false; + let settleCleanup: (() => void) | null = null; + + const startProjectionSettle = () => { + if (!isTransition || settleScheduled) return; + settleScheduled = true; + + const finalize = () => { + if (!cancelled && isTransition) setProjectionLoading(false); + }; + + const finalizeSoon = () => { + if (cancelled || !isTransition || projectionBusyRef.current === false) return; + if (!map.isStyleLoaded()) { + requestAnimationFrame(finalizeSoon); + return; + } + requestAnimationFrame(() => requestAnimationFrame(finalize)); + }; + + const onIdle = () => finalizeSoon(); + try { + map.on("idle", onIdle); + const styleReadyCleanup = onMapStyleReady(map, finalizeSoon); + settleCleanup = () => { + map.off("idle", onIdle); + styleReadyCleanup(); + }; + } catch { + requestAnimationFrame(finalize); + settleCleanup = null; + } + + finalizeSoon(); + }; if (isTransition) setProjectionLoading(true); @@ -985,17 +1020,7 @@ export function Map3D({ // ignore } if (isTransition) { - const mercatorReady = projection === "mercator" && !!overlayRef.current; - const globeReady = projection === "globe" && !!map.getLayer("deck-globe"); - if (mercatorReady || globeReady) { - setProjectionLoading(false); - } else if (!cancelled && retries < maxRetries) { - retries += 1; - window.requestAnimationFrame(() => syncProjectionAndDeck()); - return; - } else { - setProjectionLoading(false); - } + startProjectionSettle(); } pulseMapSync(); }; @@ -1007,6 +1032,7 @@ export function Map3D({ const stop = onMapStyleReady(map, syncProjectionAndDeck); return () => { cancelled = true; + if (settleCleanup) settleCleanup(); stop(); if (isTransition) setProjectionLoading(false); }; @@ -1014,6 +1040,7 @@ export function Map3D({ return () => { cancelled = true; + if (settleCleanup) settleCleanup(); if (isTransition) setProjectionLoading(false); }; }, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, removeLayerIfExists, setProjectionLoading]); From ea51aee6b41fca48b0fa00d0d6f622c0cc868e10 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 14:52:57 +0900 Subject: [PATCH 17/58] Fix globe tooltip typing and overlay defaults --- .../web/src/pages/dashboard/DashboardPage.tsx | 2 +- apps/web/src/widgets/map3d/Map3D.tsx | 537 ++++++++++++++++-- 2 files changed, 504 insertions(+), 35 deletions(-) diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index c2f9453..001d320 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -103,7 +103,7 @@ export function DashboardPage() { const [overlays, setOverlays] = useState({ pairLines: true, - pairRange: false, + pairRange: true, fcLines: true, zones: true, fleetCircles: true, diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index af4d3be..085674d 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -161,6 +161,176 @@ function getDisplayHeading({ return normalizeAngleDeg(raw, offset); } +function toSafeNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + return null; +} + +function toIntMmsi(value: unknown): number | null { + const n = toSafeNumber(value); + if (n == null) return null; + return Math.trunc(n); +} + +function formatNm(value: number | null | undefined) { + if (!isFiniteNumber(value)) return "-"; + return `${value.toFixed(2)} NM`; +} + +function getLegacyTag(legacyHits: Map | null | undefined, mmsi: number) { + const legacy = legacyHits?.get(mmsi); + if (!legacy) return null; + return `${legacy.permitNo} (${legacy.shipCode})`; +} + +function getTargetName(mmsi: number, targetByMmsi: Map, legacyHits: Map | null | undefined) { + const legacy = legacyHits?.get(mmsi); + const target = targetByMmsi.get(mmsi); + return ( + (target?.name || "").trim() || legacy?.shipNameCn || legacy?.shipNameRoman || `MMSI ${mmsi}` + ); +} + +function getShipTooltipHtml({ + mmsi, + targetByMmsi, + legacyHits, +}: { + mmsi: number; + targetByMmsi: Map; + legacyHits: Map | null | undefined; +}) { + const legacy = legacyHits?.get(mmsi); + const t = targetByMmsi.get(mmsi); + const name = getTargetName(mmsi, targetByMmsi, legacyHits); + const sog = isFiniteNumber(t?.sog) ? t.sog : null; + const cog = isFiniteNumber(t?.cog) ? t.cog : null; + const msg = t?.messageTimestamp ?? null; + const vesselType = t?.vesselType || ""; + + const legacyHtml = legacy + ? `
+
CN Permit · ${legacy.shipCode} · ${legacy.permitNo}
+
유효범위: ${legacy.workSeaArea || "-"}
+
` + : ""; + + return { + html: `
+
${name}
+
MMSI: ${mmsi}${vesselType ? ` · ${vesselType}` : ""}
+
SOG: ${sog ?? "?"} kt · COG: ${cog ?? "?"}°
+ ${msg ? `
${msg}
` : ""} + ${legacyHtml} +
`, + }; +} + +function getPairLinkTooltipHtml({ + warn, + distanceNm, + aMmsi, + bMmsi, + legacyHits, + targetByMmsi, +}: { + warn: boolean; + distanceNm: number | null | undefined; + aMmsi: number; + bMmsi: number; + legacyHits: Map | null | undefined; + targetByMmsi: Map; +}) { + const d = formatNm(distanceNm); + const a = getTargetName(aMmsi, targetByMmsi, legacyHits); + const b = getTargetName(bMmsi, targetByMmsi, legacyHits); + const aTag = getLegacyTag(legacyHits, aMmsi); + const bTag = getLegacyTag(legacyHits, bMmsi); + return { + html: `
+
쌍 연결
+
${aTag ?? `MMSI ${aMmsi}`}
+
↔ ${bTag ?? `MMSI ${bMmsi}`}
+
거리: ${d} · 상태: ${warn ? "주의" : "정상"}
+
${a} / ${b}
+
`, + }; +} + +function getFcLinkTooltipHtml({ + suspicious, + distanceNm, + fcMmsi, + otherMmsi, + legacyHits, + targetByMmsi, +}: { + suspicious: boolean; + distanceNm: number | null | undefined; + fcMmsi: number; + otherMmsi: number; + legacyHits: Map | null | undefined; + targetByMmsi: Map; +}) { + const d = formatNm(distanceNm); + const a = getTargetName(fcMmsi, targetByMmsi, legacyHits); + const b = getTargetName(otherMmsi, targetByMmsi, legacyHits); + const aTag = getLegacyTag(legacyHits, fcMmsi); + const bTag = getLegacyTag(legacyHits, otherMmsi); + return { + html: `
+
환적 연결
+
${aTag ?? `MMSI ${fcMmsi}`}
+
→ ${bTag ?? `MMSI ${otherMmsi}`}
+
거리: ${d} · 상태: ${suspicious ? "의심" : "일반"}
+
${a} / ${b}
+
`, + }; +} + +function getRangeTooltipHtml({ + warn, + distanceNm, + aMmsi, + bMmsi, + legacyHits, +}: { + warn: boolean; + distanceNm: number | null | undefined; + aMmsi: number; + bMmsi: number; + legacyHits: Map | null | undefined; +}) { + const d = formatNm(distanceNm); + const aTag = getLegacyTag(legacyHits, aMmsi); + const bTag = getLegacyTag(legacyHits, bMmsi); + const radiusNm = toSafeNumber(distanceNm); + return { + html: `
+
쌍 연결범위
+
${aTag ?? `MMSI ${aMmsi}`}
+
↔ ${bTag ?? `MMSI ${bMmsi}`}
+
범위: ${d} · 반경: ${formatNm(radiusNm == null ? null : radiusNm / 2)} · 상태: ${warn ? "주의" : "정상"}
+
`, + }; +} + +function getFleetCircleTooltipHtml({ + ownerKey, + count, +}: { + ownerKey: string; + count: number; +}) { + return { + html: `
+
선단 범위
+
소유주: ${ownerKey || "-"}
+
선박 수: ${count}
+
`, + }; +} + function rgbToHex(rgb: [number, number, number]) { const toHex = (v: number) => { const clamped = Math.max(0, Math.min(255, Math.round(v))); @@ -511,9 +681,23 @@ function getShipColor( return [100, 116, 139, 160]; } -type DashSeg = { from: [number, number]; to: [number, number]; suspicious: boolean }; +type DashSeg = { + from: [number, number]; + to: [number, number]; + suspicious: boolean; + distanceNm?: number; + fromMmsi?: number; + toMmsi?: number; +}; -function dashifyLine(from: [number, number], to: [number, number], suspicious: boolean): DashSeg[] { +function dashifyLine( + from: [number, number], + to: [number, number], + suspicious: boolean, + distanceNm?: number, + fromMmsi?: number, + toMmsi?: number, +): DashSeg[] { // Simple dashed effect: split into segments and render every other one. const segs: DashSeg[] = []; const steps = 14; @@ -525,7 +709,14 @@ function dashifyLine(from: [number, number], to: [number, number], suspicious: b const lat0 = from[1] + (to[1] - from[1]) * a0; const lon1 = from[0] + (to[0] - from[0]) * a1; const lat1 = from[1] + (to[1] - from[1]) * a1; - segs.push({ from: [lon0, lat0], to: [lon1, lat1], suspicious }); + segs.push({ + from: [lon0, lat0], + to: [lon1, lat1], + suspicious, + distanceNm, + fromMmsi, + toMmsi, + }); } return segs; } @@ -552,6 +743,9 @@ type PairRangeCircle = { center: [number, number]; // [lon, lat] radiusNm: number; warn: boolean; + aMmsi: number; + bMmsi: number; + distanceNm: number; }; const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius` @@ -585,6 +779,7 @@ export function Map3D({ const projectionBusyRef = useRef(false); const projectionBusyTimerRef = useRef | null>(null); const projectionPrevRef = useRef(projection); + const mapTooltipRef = useRef(null); const [mapSyncEpoch, setMapSyncEpoch] = useState(0); const clearProjectionBusyTimer = useCallback(() => { @@ -1175,12 +1370,18 @@ export function Map3D({ const srcId = "zones-src"; const fillId = "zones-fill"; const lineId = "zones-line"; + const labelId = "zones-label"; const zoneColorExpr: unknown[] = ["match", ["get", "zoneId"]]; for (const k of Object.keys(ZONE_META) as ZoneId[]) { zoneColorExpr.push(k, ZONE_META[k].color); } zoneColorExpr.push("#3B82F6"); + const zoneLabelExpr: unknown[] = ["match", ["to-string", ["coalesce", ["get", "zoneId"], ""]]]; + for (const k of Object.keys(ZONE_META) as ZoneId[]) { + zoneLabelExpr.push(k, ZONE_META[k].name); + } + zoneLabelExpr.push(["coalesce", ["get", "zoneName"], ["get", "zoneLabel"], ["get", "NAME"], "수역"]); const ensure = () => { // Always update visibility if the layers exist. @@ -1195,6 +1396,11 @@ export function Map3D({ } catch { // ignore } + try { + if (map.getLayer(labelId)) map.setLayoutProperty(labelId, "visibility", visibility); + } catch { + // ignore + } if (!zones) return; if (!map.isStyleLoaded()) return; @@ -1252,6 +1458,34 @@ export function Map3D({ before, ); } + + if (!map.getLayer(labelId)) { + map.addLayer( + { + id: labelId, + type: "symbol", + source: srcId, + layout: { + visibility, + "symbol-placement": "point", + "text-field": zoneLabelExpr as never, + "text-size": 11, + "text-font": ["Noto Sans Regular", "Open Sans Regular"], + "text-anchor": "top", + "text-offset": [0, 0.35], + "text-allow-overlap": false, + "text-ignore-placement": false, + }, + paint: { + "text-color": "#dbeafe", + "text-halo-color": "rgba(2,6,23,0.85)", + "text-halo-width": 1.2, + "text-halo-blur": 0.8, + }, + } as unknown as LayerSpecification, + undefined, + ); + } } catch (e) { console.warn("Zones layer setup failed:", e); } finally { @@ -1642,6 +1876,7 @@ export function Map3D({ }; }, [projection, settings.showShips, onSelectMmsi, mapSyncEpoch, targets]); + // Globe overlays (pair links / FC links / ranges) rendered as MapLibre GeoJSON layers. // Deck custom layers are more fragile under globe projection; MapLibre-native rendering stays aligned like zones. useEffect(() => { @@ -1677,7 +1912,13 @@ export function Map3D({ type: "Feature", id: `${p.aMmsi}-${p.bMmsi}-${idx}`, geometry: { type: "LineString", coordinates: [p.from, p.to] }, - properties: { warn: p.warn }, + properties: { + type: "pair", + aMmsi: p.aMmsi, + bMmsi: p.bMmsi, + distanceNm: p.distanceNm, + warn: p.warn, + }, })), }; @@ -1757,7 +1998,9 @@ export function Map3D({ } const segs: DashSeg[] = []; - for (const l of fcLinks || []) segs.push(...dashifyLine(l.from, l.to, l.suspicious)); + for (const l of fcLinks || []) { + segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi)); + } if (segs.length === 0) { remove(); return; @@ -1769,7 +2012,13 @@ export function Map3D({ type: "Feature", id: `fc-${idx}`, geometry: { type: "LineString", coordinates: [s.from, s.to] }, - properties: { suspicious: s.suspicious }, + properties: { + type: "fc", + suspicious: s.suspicious, + distanceNm: s.distanceNm, + fcMmsi: s.fromMmsi ?? -1, + otherMmsi: s.toMmsi ?? -1, + }, })), }; @@ -1856,7 +2105,12 @@ export function Map3D({ type: "Feature", id: `fleet-${c.ownerKey}-${idx}`, geometry: { type: "LineString", coordinates: ring }, - properties: { count: c.count }, + properties: { + type: "fleet", + ownerKey: c.ownerKey, + count: c.count, + vesselMmsis: c.vesselMmsis.length, + }, }; }), }; @@ -1934,7 +2188,14 @@ export function Map3D({ const ranges: PairRangeCircle[] = []; for (const p of pairLinks || []) { const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2]; - ranges.push({ center, radiusNm: Math.max(0.05, p.distanceNm / 2), warn: p.warn }); + ranges.push({ + center, + radiusNm: Math.max(0.05, p.distanceNm / 2), + warn: p.warn, + aMmsi: p.aMmsi, + bMmsi: p.bMmsi, + distanceNm: p.distanceNm, + }); } if (ranges.length === 0) { remove(); @@ -1949,7 +2210,13 @@ export function Map3D({ type: "Feature", id: `pair-range-${idx}`, geometry: { type: "LineString", coordinates: ring }, - properties: { warn: c.warn }, + properties: { + type: "pair-range", + warn: c.warn, + aMmsi: c.aMmsi, + bMmsi: c.bMmsi, + distanceNm: c.distanceNm, + }, }; }), }; @@ -2006,6 +2273,168 @@ export function Map3D({ return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon)); }, [targets]); + const shipByMmsi = useMemo(() => { + const byMmsi = new Map(); + for (const t of shipData) byMmsi.set(t.mmsi, t); + return byMmsi; + }, [shipData]); + + const clearGlobeTooltip = useCallback(() => { + if (!mapTooltipRef.current) return; + try { + mapTooltipRef.current.remove(); + } catch { + // ignore + } + mapTooltipRef.current = null; + }, []); + + const buildGlobeFeatureTooltip = useCallback( + (feature: { properties?: Record | null; layer?: { id?: string } } | null | undefined) => { + if (!feature) return null; + const props = feature.properties || {}; + const layerId = feature.layer?.id; + + const maybeMmsi = toIntMmsi(props.mmsi); + if (maybeMmsi != null && maybeMmsi > 0) { + return getShipTooltipHtml({ mmsi: maybeMmsi, targetByMmsi: shipByMmsi, legacyHits }); + } + + if (layerId === "pair-lines-ml") { + const warn = props.warn === true; + const aMmsi = toIntMmsi(props.aMmsi); + const bMmsi = toIntMmsi(props.bMmsi); + if (aMmsi == null || bMmsi == null) return null; + return getPairLinkTooltipHtml({ + warn, + distanceNm: toSafeNumber(props.distanceNm), + aMmsi, + bMmsi, + legacyHits, + targetByMmsi: shipByMmsi, + }); + } + + if (layerId === "fc-lines-ml") { + const fcMmsi = toIntMmsi(props.fcMmsi); + const otherMmsi = toIntMmsi(props.otherMmsi); + if (fcMmsi == null || otherMmsi == null) return null; + return getFcLinkTooltipHtml({ + suspicious: props.suspicious === true, + distanceNm: toSafeNumber(props.distanceNm), + fcMmsi, + otherMmsi, + legacyHits, + targetByMmsi: shipByMmsi, + }); + } + + if (layerId === "pair-range-ml") { + const aMmsi = toIntMmsi(props.aMmsi); + const bMmsi = toIntMmsi(props.bMmsi); + if (aMmsi == null || bMmsi == null) return null; + return getRangeTooltipHtml({ + warn: props.warn === true, + distanceNm: toSafeNumber(props.distanceNm), + aMmsi, + bMmsi, + legacyHits, + }); + } + + if (layerId === "fleet-circles-ml") { + return getFleetCircleTooltipHtml({ + ownerKey: String(props.ownerKey ?? ""), + count: Number(props.count ?? 0), + }); + } + + const zoneLabel = String((props.zoneLabel ?? props.zoneName ?? "").toString()); + if (zoneLabel) { + const zoneName = zoneLabel || ZONE_META[(String(props.zoneId ?? "") as ZoneId)]?.name || "수역"; + return { html: `
${zoneName}
` }; + } + + return null; + }, + [legacyHits, shipByMmsi], + ); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const onMouseMove = (e: maplibregl.MapMouseEvent) => { + if (projection !== "globe") { + clearGlobeTooltip(); + return; + } + + const candidateLayerIds = [ + "ships-globe", + "ships-globe-halo", + "ships-globe-outline", + "pair-lines-ml", + "fc-lines-ml", + "fleet-circles-ml", + "pair-range-ml", + "zones-fill", + "zones-line", + "zones-label", + ].filter((id) => map.getLayer(id)); + + if (candidateLayerIds.length === 0) { + clearGlobeTooltip(); + return; + } + + let rendered: Array<{ properties?: Record | null; layer?: { id?: string } }> = []; + try { + rendered = map.queryRenderedFeatures(e.point, { layers: candidateLayerIds }) as unknown as Array<{ + properties?: Record | null; + layer?: { id?: string }; + }>; + } catch { + rendered = []; + } + + const first = rendered[0]; + const tooltip = buildGlobeFeatureTooltip(first); + if (!tooltip) { + clearGlobeTooltip(); + return; + } + + if (!mapTooltipRef.current) { + mapTooltipRef.current = new maplibregl.Popup({ + closeButton: false, + closeOnClick: false, + className: "maplibre-tooltip-popup", + }); + } + + const content = tooltip?.html ?? ""; + if (content) { + mapTooltipRef.current.setLngLat(e.lngLat).setHTML(content).addTo(map); + return; + } + clearGlobeTooltip(); + }; + + const onMouseOut = () => { + clearGlobeTooltip(); + }; + + map.on("mousemove", onMouseMove); + map.on("mouseout", onMouseOut); + + return () => { + map.off("mousemove", onMouseMove); + map.off("mouseout", onMouseOut); + clearGlobeTooltip(); + }; + }, [projection, buildGlobeFeatureTooltip, clearGlobeTooltip]); + const legacyTargets = useMemo(() => { if (!legacyHits) return []; return shipData.filter((t) => legacyHits.has(t.mmsi)); @@ -2013,7 +2442,9 @@ export function Map3D({ const fcDashed = useMemo(() => { const segs: DashSeg[] = []; - for (const l of fcLinks || []) segs.push(...dashifyLine(l.from, l.to, l.suspicious)); + for (const l of fcLinks || []) { + segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi)); + } return segs; }, [fcLinks]); @@ -2021,7 +2452,14 @@ export function Map3D({ const out: PairRangeCircle[] = []; for (const p of pairLinks || []) { const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2]; - out.push({ center, radiusNm: Math.max(0.05, p.distanceNm / 2), warn: p.warn }); + out.push({ + center, + radiusNm: Math.max(0.05, p.distanceNm / 2), + warn: p.warn, + aMmsi: p.aMmsi, + bMmsi: p.bMmsi, + distanceNm: p.distanceNm, + }); } return out; }, [pairLinks]); @@ -2142,7 +2580,7 @@ export function Map3D({ new ScatterplotLayer({ id: "pair-range", data: pairRanges, - pickable: false, + pickable: true, billboard: false, parameters: overlayParams, filled: false, @@ -2163,7 +2601,7 @@ export function Map3D({ new LineLayer({ id: "pair-lines", data: pairLinks, - pickable: false, + pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, @@ -2179,7 +2617,7 @@ export function Map3D({ new LineLayer({ id: "fc-lines", data: fcDashed, - pickable: false, + pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, @@ -2195,7 +2633,7 @@ export function Map3D({ new ScatterplotLayer({ id: "fleet-circles", data: fleetCircles, - pickable: false, + pickable: true, billboard: false, parameters: overlayParams, filled: false, @@ -2223,28 +2661,58 @@ export function Map3D({ const n = Array.isArray(o?.points) ? o.points.length : 0; return { text: `AIS density: ${n}` }; } - // zones // eslint-disable-next-line @typescript-eslint/no-explicit-any const obj: any = info.object; if (typeof obj.mmsi === "number") { - const t = obj as AisTarget; - const name = (t.name || "").trim() || "(no name)"; - const legacy = legacyHits?.get(t.mmsi); - const legacyHtml = legacy - ? `
-
CN Permit · ${legacy.shipCode} · ${legacy.permitNo}
-
` - : ""; - return { - html: `
-
${name}
-
MMSI: ${t.mmsi} · ${t.vesselType || "Unknown"}
-
SOG: ${t.sog ?? "?"} kt · COG: ${t.cog ?? "?"}°
-
${t.status || ""}
-
${t.messageTimestamp || ""}
- ${legacyHtml} -
`, - }; + return getShipTooltipHtml({ mmsi: obj.mmsi, targetByMmsi: shipByMmsi, legacyHits }); + } + + if (info.layer && info.layer.id === "pair-lines") { + const aMmsi = toSafeNumber(obj.aMmsi) ?? toSafeNumber(obj.fromMmsi); + const bMmsi = toSafeNumber(obj.bMmsi) ?? toSafeNumber(obj.toMmsi); + if (aMmsi == null || bMmsi == null) return null; + return getPairLinkTooltipHtml({ + warn: !!obj.warn, + distanceNm: toSafeNumber(obj.distanceNm), + aMmsi, + bMmsi, + legacyHits, + targetByMmsi: shipByMmsi, + }); + } + + if (info.layer && info.layer.id === "fc-lines") { + const fcMmsi = toSafeNumber(obj.fcMmsi) ?? toSafeNumber(obj.fromMmsi); + const otherMmsi = toSafeNumber(obj.otherMmsi) ?? toSafeNumber(obj.toMmsi); + if (fcMmsi == null || otherMmsi == null) return null; + return getFcLinkTooltipHtml({ + suspicious: !!obj.suspicious, + distanceNm: toSafeNumber(obj.distanceNm), + fcMmsi, + otherMmsi, + legacyHits, + targetByMmsi: shipByMmsi, + }); + } + + if (info.layer && info.layer.id === "pair-range") { + const aMmsi = toSafeNumber(obj.aMmsi) ?? toSafeNumber(obj.fromMmsi); + const bMmsi = toSafeNumber(obj.bMmsi) ?? toSafeNumber(obj.toMmsi); + if (aMmsi == null || bMmsi == null) return null; + return getRangeTooltipHtml({ + warn: !!obj.warn, + distanceNm: toSafeNumber(obj.distanceNm), + aMmsi, + bMmsi, + legacyHits, + }); + } + + if (info.layer && info.layer.id === "fleet-circles") { + return getFleetCircleTooltipHtml({ + ownerKey: String(obj.ownerKey ?? ""), + count: Number(obj.count ?? 0), + }); } const p = obj.properties as { zoneName?: string; zoneLabel?: string } | undefined; @@ -2293,6 +2761,7 @@ export function Map3D({ pairRanges, fcDashed, fleetCircles, + shipByMmsi, mapSyncEpoch, ensureMercatorOverlay, ]); From ed5b0da5f9f0d9afa452d02c0bd73e99c0472179 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 15:17:48 +0900 Subject: [PATCH 18/58] fix: prevent hover update loop and map style ready guard --- .../web/src/pages/dashboard/DashboardPage.tsx | 103 +- apps/web/src/widgets/map3d/Map3D.tsx | 1318 +++++++++++++++-- 2 files changed, 1265 insertions(+), 156 deletions(-) diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 001d320..61630b2 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -4,6 +4,7 @@ import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettings import type { MapToggleState } from "../../features/mapToggles/MapToggles"; import { MapToggles } from "../../features/mapToggles/MapToggles"; import { TypeFilterGrid } from "../../features/typeFilter/TypeFilterGrid"; +import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types"; import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels"; import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib"; import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib"; @@ -87,6 +88,11 @@ export function DashboardPage() { }); const [selectedMmsi, setSelectedMmsi] = useState(null); + const [highlightedMmsiSet, setHighlightedMmsiSet] = useState([]); + const [hoveredMmsiSet, setHoveredMmsiSet] = useState([]); + const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState([]); + const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState([]); + const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState(null); const [typeEnabled, setTypeEnabled] = useState>({ PT: true, "PT-S": true, @@ -109,6 +115,8 @@ export function DashboardPage() { fleetCircles: true, }); + const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined); + const [settings, setSettings] = useState({ showShips: true, showDensity: false, @@ -192,6 +200,52 @@ export function DashboardPage() { return legacyHits.get(selectedMmsi) ?? null; }, [selectedMmsi, legacyHits]); + const availableTargetMmsiSet = useMemo( + () => new Set(targetsInScope.map((t) => t.mmsi).filter((mmsi) => Number.isFinite(mmsi))), + [targetsInScope], + ); + const activeHighlightedMmsiSet = useMemo( + () => highlightedMmsiSet.filter((mmsi) => availableTargetMmsiSet.has(mmsi)), + [highlightedMmsiSet, availableTargetMmsiSet], + ); + + const setUniqueSorted = (items: number[]) => + Array.from(new Set(items.filter((item) => Number.isFinite(item)))).sort((a, b) => a - b); + + const setSortedIfChanged = (next: number[]) => { + const sorted = setUniqueSorted(next); + return (prev: number[]) => (prev.length === sorted.length && prev.every((v, i) => v === sorted[i]) ? prev : sorted); + }; + + const handleFleetContextMenu = (ownerKey: string, mmsis: number[]) => { + if (!mmsis.length) return; + const members = mmsis + .map((mmsi) => legacyVesselsFiltered.find((v): v is DerivedLegacyVessel => v.mmsi === mmsi)) + .filter( + (v): v is DerivedLegacyVessel & { lat: number; lon: number } => + v != null && typeof v.lat === "number" && typeof v.lon === "number" && Number.isFinite(v.lat) && Number.isFinite(v.lon), + ); + + if (members.length === 0) return; + const sumLon = members.reduce((acc, v) => acc + v.lon, 0); + const sumLat = members.reduce((acc, v) => acc + v.lat, 0); + const center: [number, number] = [sumLon / members.length, sumLat / members.length]; + setFleetFocus({ + id: `${ownerKey}-${Date.now()}`, + center, + zoom: 9, + }); + }; + + const toggleHighlightedMmsi = (mmsi: number) => { + setHighlightedMmsiSet((prev) => { + const next = new Set(prev); + if (next.has(mmsi)) next.delete(mmsi); + else next.add(mmsi); + return Array.from(next).sort((a, b) => a - b); + }); + }; + const fishingCount = legacyVesselsAll.filter((v) => v.state.isFishing).length; const transitCount = legacyVesselsAll.filter((v) => v.state.isTransit).length; @@ -299,11 +353,27 @@ export function DashboardPage() {
- setHoveredMmsiSet(setUniqueSorted(mmsis))} + onClearHover={() => setHoveredMmsiSet([])} + onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))} + onClearPairHover={() => setHoveredPairMmsiSet([])} + onHoverFleet={(ownerKey, fleetMmsis) => { + setHoveredFleetOwnerKey(ownerKey); + setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis)); + }} + onClearFleetHover={() => { + setHoveredFleetOwnerKey(null); + setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : [])); + }} + hoveredFleetOwnerKey={hoveredFleetOwnerKey} + hoveredFleetMmsiSet={hoveredFleetMmsiSet} + onContextMenuFleet={handleFleetContextMenu} />
@@ -315,7 +385,15 @@ export function DashboardPage() { ({legacyVesselsFiltered.length}척)
- + setHoveredMmsiSet([mmsi])} + onClearHover={() => setHoveredMmsiSet([])} + />
@@ -489,17 +567,32 @@ export function DashboardPage() { targets={targetsForMap} zones={zones} selectedMmsi={selectedMmsi} + highlightedMmsiSet={activeHighlightedMmsiSet} + hoveredMmsiSet={hoveredMmsiSet} + hoveredFleetMmsiSet={hoveredFleetMmsiSet} + hoveredPairMmsiSet={hoveredPairMmsiSet} + hoveredFleetOwnerKey={hoveredFleetOwnerKey} settings={settings} baseMap={baseMap} projection={projection} overlays={overlays} onSelectMmsi={setSelectedMmsi} + onToggleHighlightMmsi={toggleHighlightedMmsi} onViewBboxChange={setViewBbox} legacyHits={legacyHits} pairLinks={pairLinksForMap} fcLinks={fcLinksForMap} fleetCircles={fleetCirclesForMap} + fleetFocus={fleetFocus} onProjectionLoadingChange={setIsProjectionLoading} + onHoverFleet={(ownerKey, fleetMmsis) => { + setHoveredFleetOwnerKey(ownerKey); + setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis)); + }} + onClearFleetHover={() => { + setHoveredFleetOwnerKey(null); + setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : [])); + }} /> {selectedLegacyVessel ? ( diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 085674d..453a8e2 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -32,19 +32,57 @@ type Props = { targets: AisTarget[]; zones: ZonesGeoJson | null; selectedMmsi: number | null; + hoveredMmsiSet?: number[]; + hoveredFleetMmsiSet?: number[]; + hoveredPairMmsiSet?: number[]; + hoveredFleetOwnerKey?: string | null; + highlightedMmsiSet?: number[]; settings: Map3DSettings; baseMap: BaseMapId; projection: MapProjectionId; overlays: MapToggleState; onSelectMmsi: (mmsi: number | null) => void; + onToggleHighlightMmsi?: (mmsi: number) => void; onViewBboxChange?: (bbox: [number, number, number, number]) => void; legacyHits?: Map | null; pairLinks?: PairLink[]; fcLinks?: FcLink[]; fleetCircles?: FleetCircle[]; onProjectionLoadingChange?: (loading: boolean) => void; + fleetFocus?: { + id: string | number; + center: [number, number]; + zoom?: number; + }; + onHoverFleet?: (ownerKey: string | null, fleetMmsiSet: number[]) => void; + onClearFleetHover?: () => void; }; +function toNumberSet(values: number[] | undefined | null) { + const out = new Set(); + if (!values) return out; + for (const value of values) { + if (Number.isFinite(value)) { + out.add(value); + } + } + return out; +} + +function mergeNumberSets(...sets: Set[]) { + const out = new Set(); + for (const s of sets) { + for (const v of s) { + out.add(v); + } + } + return out; +} + +function makeSetSignature(values: Set) { + return Array.from(values).sort((a, b) => a - b).join(","); +} + const SHIP_ICON_MAPPING = { ship: { x: 0, @@ -88,7 +126,12 @@ function kickRepaint(map: maplibregl.Map | null) { } } -function onMapStyleReady(map: maplibregl.Map, callback: () => void) { +function onMapStyleReady(map: maplibregl.Map | null, callback: () => void) { + if (!map) { + return () => { + // noop + }; + } if (map.isStyleLoaded()) { callback(); return () => { @@ -98,7 +141,7 @@ function onMapStyleReady(map: maplibregl.Map, callback: () => void) { let fired = false; const runOnce = () => { - if (fired || !map.isStyleLoaded()) return; + if (!map || fired || !map.isStyleLoaded()) return; fired = true; callback(); try { @@ -118,6 +161,7 @@ function onMapStyleReady(map: maplibregl.Map, callback: () => void) { if (fired) return; fired = true; try { + if (!map) return; map.off("style.load", runOnce); map.off("styledata", runOnce); map.off("idle", runOnce); @@ -139,6 +183,9 @@ function extractProjectionType(map: maplibregl.Map): MapProjectionId | undefined const DEG2RAD = Math.PI / 180; const GLOBE_ICON_HEADING_OFFSET_DEG = -90; +const MAP_SELECTED_SHIP_RGB: [number, number, number] = [34, 211, 238]; +const MAP_HIGHLIGHT_SHIP_RGB: [number, number, number] = [245, 158, 11]; +const MAP_DEFAULT_SHIP_RGB: [number, number, number] = [100, 116, 139]; const clampNumber = (value: number, minValue: number, maxValue: number) => Math.max(minValue, Math.min(maxValue, value)); @@ -317,15 +364,18 @@ function getRangeTooltipHtml({ function getFleetCircleTooltipHtml({ ownerKey, + ownerLabel, count, }: { ownerKey: string; + ownerLabel?: string; count: number; }) { + const displayOwner = ownerLabel && ownerLabel.trim() ? ownerLabel : ownerKey; return { html: `
선단 범위
-
소유주: ${ownerKey || "-"}
+
소유주: ${displayOwner || "-"}
선박 수: ${count}
`, }; @@ -345,16 +395,13 @@ function lightenColor(rgb: [number, number, number], ratio = 0.32) { return out; } -function getGlobeShipColor({ - selected, +function getGlobeBaseShipColor({ legacy, sog, }: { - selected: boolean; legacy: string | null; sog: number | null; }) { - if (selected) return "rgba(255,255,255,0.98)"; if (legacy) { const rgb = LEGACY_CODE_COLORS[legacy]; if (rgb) return rgbToHex(lightenColor(rgb, 0.38)); @@ -386,8 +433,10 @@ const DEPTH_DISABLED_PARAMS = { const FLAT_SHIP_ICON_SIZE = 19; const FLAT_SHIP_ICON_SIZE_SELECTED = 28; +const FLAT_SHIP_ICON_SIZE_HIGHLIGHTED = 25; const FLAT_LEGACY_HALO_RADIUS = 14; const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18; +const FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED = 16; const GLOBE_OVERLAY_PARAMS = { // In globe mode we want depth-testing against the globe so features on the far side don't draw through. @@ -668,17 +717,24 @@ function getShipColor( t: AisTarget, selectedMmsi: number | null, legacyShipCode: string | null, + highlightedMmsis: Set, ): [number, number, number, number] { - if (selectedMmsi && t.mmsi === selectedMmsi) return [255, 255, 255, 255]; + if (selectedMmsi && t.mmsi === selectedMmsi) { + return [MAP_SELECTED_SHIP_RGB[0], MAP_SELECTED_SHIP_RGB[1], MAP_SELECTED_SHIP_RGB[2], 255]; + } + + if (highlightedMmsis.has(t.mmsi)) { + return [MAP_HIGHLIGHT_SHIP_RGB[0], MAP_HIGHLIGHT_SHIP_RGB[1], MAP_HIGHLIGHT_SHIP_RGB[2], 235]; + } if (legacyShipCode) { const rgb = LEGACY_CODE_COLORS[legacyShipCode]; if (rgb) return [rgb[0], rgb[1], rgb[2], 235]; return [245, 158, 11, 235]; } - if (!isFiniteNumber(t.sog)) return [100, 116, 139, 160]; + if (!isFiniteNumber(t.sog)) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 160]; if (t.sog >= 10) return [59, 130, 246, 220]; if (t.sog >= 1) return [34, 197, 94, 210]; - return [100, 116, 139, 160]; + return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 160]; } type DashSeg = { @@ -748,6 +804,16 @@ type PairRangeCircle = { distanceNm: number; }; +const makeUniqueSorted = (values: number[]) => Array.from(new Set(values.filter((v) => Number.isFinite(v)))).sort((a, b) => a - b); + +const equalNumberArrays = (a: number[], b: number[]) => { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +}; + const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius` const DECK_VIEW_ID = "mapbox"; @@ -755,17 +821,26 @@ export function Map3D({ targets, zones, selectedMmsi, + hoveredMmsiSet = [], + hoveredFleetMmsiSet = [], + hoveredPairMmsiSet = [], + hoveredFleetOwnerKey = null, + highlightedMmsiSet = [], settings, baseMap, projection, overlays, onSelectMmsi, + onToggleHighlightMmsi, onViewBboxChange, legacyHits, pairLinks, fcLinks, fleetCircles, onProjectionLoadingChange, + fleetFocus, + onHoverFleet, + onClearFleetHover, }: Props) { const containerRef = useRef(null); const mapRef = useRef(null); @@ -780,7 +855,168 @@ export function Map3D({ const projectionBusyTimerRef = useRef | null>(null); const projectionPrevRef = useRef(projection); const mapTooltipRef = useRef(null); + const [hoveredDeckMmsiSet, setHoveredDeckMmsiSet] = useState([]); + const [hoveredDeckPairMmsiSet, setHoveredDeckPairMmsiSet] = useState([]); + const [hoveredDeckFleetOwnerKey, setHoveredDeckFleetOwnerKey] = useState(null); + const [hoveredDeckFleetMmsiSet, setHoveredDeckFleetMmsiSet] = useState([]); + const [hoveredZoneId, setHoveredZoneId] = useState(null); + const hoveredMmsiSetRef = useMemo(() => toNumberSet(hoveredMmsiSet), [hoveredMmsiSet]); + const hoveredFleetMmsiSetRef = useMemo(() => toNumberSet(hoveredFleetMmsiSet), [hoveredFleetMmsiSet]); + const hoveredPairMmsiSetRef = useMemo(() => toNumberSet(hoveredPairMmsiSet), [hoveredPairMmsiSet]); + const externalHighlightedSetRef = useMemo(() => toNumberSet(highlightedMmsiSet), [highlightedMmsiSet]); + const hoveredDeckMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckMmsiSet), [hoveredDeckMmsiSet]); + const hoveredDeckPairMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckPairMmsiSet), [hoveredDeckPairMmsiSet]); + const hoveredDeckFleetMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckFleetMmsiSet), [hoveredDeckFleetMmsiSet]); + const hoveredFleetOwnerKeys = useMemo(() => { + const keys = new Set(); + if (hoveredFleetOwnerKey) keys.add(hoveredFleetOwnerKey); + if (hoveredDeckFleetOwnerKey) keys.add(hoveredDeckFleetOwnerKey); + return keys; + }, [hoveredFleetOwnerKey, hoveredDeckFleetOwnerKey]); + const effectiveHoveredPairMmsiSet = useMemo( + () => mergeNumberSets(hoveredPairMmsiSetRef, hoveredDeckPairMmsiSetRef), + [hoveredPairMmsiSetRef, hoveredDeckPairMmsiSetRef], + ); + const effectiveHoveredFleetMmsiSet = useMemo( + () => mergeNumberSets(hoveredFleetMmsiSetRef, hoveredDeckFleetMmsiSetRef), + [hoveredFleetMmsiSetRef, hoveredDeckFleetMmsiSetRef], + ); const [mapSyncEpoch, setMapSyncEpoch] = useState(0); + const highlightedMmsiSetCombined = useMemo( + () => + mergeNumberSets( + hoveredMmsiSetRef, + hoveredDeckMmsiSetRef, + externalHighlightedSetRef, + effectiveHoveredFleetMmsiSet, + effectiveHoveredPairMmsiSet, + ), + [ + hoveredMmsiSetRef, + hoveredDeckMmsiSetRef, + externalHighlightedSetRef, + effectiveHoveredFleetMmsiSet, + effectiveHoveredPairMmsiSet, + ], + ); + const hoveredShipSignature = useMemo( + () => + `${makeSetSignature(hoveredMmsiSetRef)}|${makeSetSignature(externalHighlightedSetRef)}|${makeSetSignature( + hoveredDeckMmsiSetRef, + )}|${makeSetSignature(effectiveHoveredFleetMmsiSet)}|${makeSetSignature(effectiveHoveredPairMmsiSet)}`, + [ + hoveredMmsiSetRef, + externalHighlightedSetRef, + hoveredDeckMmsiSetRef, + effectiveHoveredFleetMmsiSet, + effectiveHoveredPairMmsiSet, + ], + ); + const hoveredFleetSignature = useMemo( + () => `${makeSetSignature(effectiveHoveredFleetMmsiSet)}|${[...hoveredFleetOwnerKeys].sort().join(",")}`, + [effectiveHoveredFleetMmsiSet, hoveredFleetOwnerKeys], + ); + const hoveredPairSignature = useMemo(() => makeSetSignature(effectiveHoveredPairMmsiSet), [effectiveHoveredPairMmsiSet]); + + const isHighlightedMmsi = useCallback( + (mmsi: number) => highlightedMmsiSetCombined.has(mmsi), + [highlightedMmsiSetCombined], + ); + + const isHighlightedPair = useCallback( + (aMmsi: number, bMmsi: number) => + effectiveHoveredPairMmsiSet.size === 2 && + effectiveHoveredPairMmsiSet.has(aMmsi) && + effectiveHoveredPairMmsiSet.has(bMmsi), + [effectiveHoveredPairMmsiSet], + ); + + const isHighlightedFleet = useCallback( + (ownerKey: string, vesselMmsis: number[]) => { + if (hoveredFleetOwnerKeys.has(ownerKey)) return true; + return vesselMmsis.some((x) => isHighlightedMmsi(x)); + }, + [hoveredFleetOwnerKeys, isHighlightedMmsi], + ); + + const hasAuxiliarySelectModifier = (ev?: { + shiftKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + } | null): boolean => { + if (!ev) return false; + return !!(ev.shiftKey || ev.ctrlKey || ev.metaKey); + }; + + const setHoveredMmsiList = useCallback((next: number[]) => { + const normalized = makeUniqueSorted(next); + setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); + }, []); + + const setHoveredDeckMmsiSingle = useCallback((mmsi: number | null) => { + const normalized = mmsi == null ? [] : [mmsi]; + setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); + }, []); + + const setHoveredDeckPairs = useCallback((next: number[]) => { + const normalized = makeUniqueSorted(next); + setHoveredDeckPairMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); + }, []); + + const setHoveredDeckFleetMmsis = useCallback((next: number[]) => { + const normalized = makeUniqueSorted(next); + setHoveredDeckFleetMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); + }, []); + + const setHoveredDeckFleetOwner = useCallback((ownerKey: string | null) => { + setHoveredDeckFleetOwnerKey((prev) => (prev === ownerKey ? prev : ownerKey)); + }, []); + + const onHoverFleetRef = useRef(onHoverFleet); + const onClearFleetHoverRef = useRef(onClearFleetHover); + const mapFleetHoverStateRef = useRef<{ + ownerKey: string | null; + vesselMmsis: number[]; + }>({ ownerKey: null, vesselMmsis: [] }); + + useEffect(() => { + onHoverFleetRef.current = onHoverFleet; + onClearFleetHoverRef.current = onClearFleetHover; + }, [onHoverFleet, onClearFleetHover]); + + const setMapFleetHoverState = useCallback( + (ownerKey: string | null, vesselMmsis: number[]) => { + const normalized = makeUniqueSorted(vesselMmsis); + const prev = mapFleetHoverStateRef.current; + if (prev.ownerKey === ownerKey && equalNumberArrays(prev.vesselMmsis, normalized)) { + return; + } + setHoveredDeckFleetOwner(ownerKey); + setHoveredDeckFleetMmsis(normalized); + mapFleetHoverStateRef.current = { ownerKey, vesselMmsis: normalized }; + onHoverFleetRef.current?.(ownerKey, normalized); + }, + [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis], + ); + + const clearMapFleetHoverState = useCallback(() => { + const nextOwner = null; + const prev = mapFleetHoverStateRef.current; + const shouldNotify = prev.ownerKey !== null || prev.vesselMmsis.length !== 0; + mapFleetHoverStateRef.current = { ownerKey: nextOwner, vesselMmsis: [] }; + setHoveredDeckFleetOwner(null); + setHoveredDeckFleetMmsis([]); + if (shouldNotify) { + onClearFleetHoverRef.current?.(); + } + }, [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis]); + + useEffect(() => { + mapFleetHoverStateRef.current = { + ownerKey: hoveredFleetOwnerKey, + vesselMmsis: hoveredFleetMmsiSet, + }; + }, [hoveredFleetOwnerKey, hoveredFleetMmsiSet]); const clearProjectionBusyTimer = useCallback(() => { if (projectionBusyTimerRef.current == null) return; @@ -887,6 +1123,7 @@ export function Map3D({ "ships-globe", "pair-lines-ml", "fc-lines-ml", + "fleet-circles-ml-fill", "fleet-circles-ml", "pair-range-ml", "deck-globe", @@ -896,7 +1133,14 @@ export function Map3D({ removeLayerIfExists(map, id); } - const sourceIds = ["ships-globe-src", "pair-lines-ml-src", "fc-lines-ml-src", "fleet-circles-ml-src", "pair-range-ml-src"]; + const sourceIds = [ + "ships-globe-src", + "pair-lines-ml-src", + "fc-lines-ml-src", + "fleet-circles-ml-src", + "fleet-circles-ml-fill-src", + "pair-range-ml-src", + ]; for (const id of sourceIds) { removeSourceIfExists(map, id); } @@ -1307,7 +1551,9 @@ export function Map3D({ // Vector basemap water layers can be tuned per-style. Keep visible by default, // only toggling layers that match an explicit water/sea signature. try { - for (const layer of map.getStyle().layers || []) { + const style = map.getStyle(); + const styleLayers = style && Array.isArray(style.layers) ? style.layers : []; + for (const layer of styleLayers) { const id = String(layer.id ?? ""); if (!id) continue; const sourceLayer = String((layer as Record)["source-layer"] ?? "").toLowerCase(); @@ -1415,7 +1661,8 @@ export function Map3D({ // Keep zones below Deck layers (ships / deck-globe), and below seamarks if enabled. const style = map.getStyle(); - const firstSymbol = (style.layers || []).find((l) => (l as { type?: string } | undefined)?.type === "symbol") as + const styleLayers = style && Array.isArray(style.layers) ? style.layers : []; + const firstSymbol = styleLayers.find((l) => (l as { type?: string } | undefined)?.type === "symbol") as | { id?: string } | undefined; const before = map.getLayer("deck-globe") @@ -1426,6 +1673,58 @@ export function Map3D({ ? "seamark" : firstSymbol?.id; + const zoneMatchExpr = + hoveredZoneId !== null + ? (["==", ["to-string", ["coalesce", ["get", "zoneId"], ""]], hoveredZoneId] as unknown[]) + : false; + + if (map.getLayer(fillId)) { + try { + map.setPaintProperty( + fillId, + "fill-opacity", + hoveredZoneId ? (["case", zoneMatchExpr, 0.24, 0.1] as unknown as number) : 0.12, + ); + } catch { + // ignore + } + } + + if (map.getLayer(lineId)) { + try { + map.setPaintProperty( + lineId, + "line-color", + hoveredZoneId + ? (["case", zoneMatchExpr, "rgba(125,211,252,0.98)", zoneColorExpr as never] as never) + : (zoneColorExpr as never), + ); + } catch { + // ignore + } + try { + map.setPaintProperty(lineId, "line-opacity", hoveredZoneId ? (["case", zoneMatchExpr, 1, 0.85] as never) : 0.85); + } catch { + // ignore + } + try { + map.setPaintProperty( + lineId, + "line-width", + hoveredZoneId + ? ([ + "case", + zoneMatchExpr, + ["interpolate", ["linear"], ["zoom"], 4, 1.6, 10, 2.0, 14, 2.8], + ["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1], + ] as never) + : (["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1] as never), + ); + } catch { + // ignore + } + } + if (!map.getLayer(fillId)) { map.addLayer( { @@ -1434,7 +1733,14 @@ export function Map3D({ source: srcId, paint: { "fill-color": zoneColorExpr as never, - "fill-opacity": 0.12, + "fill-opacity": hoveredZoneId + ? ([ + "case", + zoneMatchExpr, + 0.24, + 0.1, + ] as unknown as number) + : 0.12, }, layout: { visibility }, } as unknown as LayerSpecification, @@ -1449,9 +1755,20 @@ export function Map3D({ type: "line", source: srcId, paint: { - "line-color": zoneColorExpr as never, - "line-opacity": 0.85, - "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1], + "line-color": hoveredZoneId + ? (["case", zoneMatchExpr, "rgba(125,211,252,0.98)", zoneColorExpr as never] as never) + : (zoneColorExpr as never), + "line-opacity": hoveredZoneId + ? (["case", zoneMatchExpr, 1, 0.85] as never) + : 0.85, + "line-width": hoveredZoneId + ? ([ + "case", + zoneMatchExpr, + ["interpolate", ["linear"], ["zoom"], 4, 1.6, 10, 2.0, 14, 2.8], + ["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1], + ] as unknown as number[]) + : (["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1] as never), }, layout: { visibility }, } as unknown as LayerSpecification, @@ -1497,7 +1814,7 @@ export function Map3D({ return () => { stop(); }; - }, [zones, overlays.zones, projection, baseMap, mapSyncEpoch]); + }, [zones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch]); // Ships in globe mode: render with MapLibre symbol layers so icons can be aligned to the globe surface. // Deck IconLayer billboards or uses a fixed plane, which looks like ships are pointing into the sky/ground on globe. @@ -1627,7 +1944,10 @@ export function Map3D({ const hull = clampNumber((isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, 50, 420); const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); const selected = t.mmsi === selectedMmsi; + const highlighted = isHighlightedMmsi(t.mmsi); const selectedScale = selected ? 1.08 : 1; + const highlightScale = highlighted ? 1.06 : 1; + const iconScale = selected ? selectedScale : highlightScale; const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3); const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45); const iconSize10 = clampNumber(0.56 * sizeScale * selectedScale, 0.35, 1.7); @@ -1642,17 +1962,17 @@ export function Map3D({ cog: heading, heading, sog: isFiniteNumber(t.sog) ? t.sog : 0, - shipColor: getGlobeShipColor({ - selected, + shipColor: getGlobeBaseShipColor({ legacy: legacy?.shipCode || null, sog: isFiniteNumber(t.sog) ? t.sog : null, }), - iconSize3, - iconSize7, - iconSize10, - iconSize14, + iconSize3: iconSize3 * iconScale, + iconSize7: iconSize7 * iconScale, + iconSize10: iconSize10 * iconScale, + iconSize14: iconSize14 * iconScale, sizeScale, selected: selected ? 1 : 0, + highlighted: highlighted ? 1 : 0, permitted: !!legacy, code: legacy?.shipCode || "", }, @@ -1670,7 +1990,7 @@ export function Map3D({ } const visibility = settings.showShips ? "visible" : "none"; - const circleRadius = [ + const baseCircleRadius: unknown[] = [ "interpolate", ["linear"], ["zoom"], @@ -1683,6 +2003,38 @@ export function Map3D({ 14, 11, ] as const; + const highlightedCircleRadius: unknown[] = [ + "case", + ["==", ["get", "selected"], 1], + [ + "interpolate", + ["linear"], + ["zoom"], + 3, + 4.6, + 7, + 6.8, + 10, + 9.0, + 14, + 11.8, + ] as unknown[], + ["==", ["get", "highlighted"], 1], + [ + "interpolate", + ["linear"], + ["zoom"], + 3, + 4.2, + 7, + 6.2, + 10, + 8.2, + 14, + 10.8, + ] as unknown[], + baseCircleRadius, + ]; // Put ships at the top so they're always visible (especially important under globe projection). const before = undefined; @@ -1694,11 +2046,28 @@ export function Map3D({ id: haloId, type: "circle", source: srcId, - layout: { visibility }, + layout: { + visibility, + "circle-sort-key": [ + "case", + ["==", ["get", "selected"], 1], + 30, + ["==", ["get", "highlighted"], 1], + 25, + 10, + ] as never, + }, paint: { - "circle-radius": circleRadius as never, + "circle-radius": highlightedCircleRadius as never, "circle-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, - "circle-opacity": 0.22, + "circle-opacity": [ + "case", + ["==", ["get", "selected"], 1], + 0.38, + ["==", ["get", "highlighted"], 1], + 0.34, + 0.16, + ] as never, }, } as unknown as LayerSpecification, before, @@ -1709,6 +2078,16 @@ export function Map3D({ } else { try { map.setLayoutProperty(haloId, "visibility", visibility); + map.setPaintProperty(haloId, "circle-color", ["case", ["==", ["get", "highlighted"], 1], ["coalesce", ["get", "shipColor"], "#64748b"], ["coalesce", ["get", "shipColor"], "#64748b"]] as never); + map.setPaintProperty(haloId, "circle-opacity", [ + "case", + ["==", ["get", "selected"], 1], + 0.38, + ["==", ["get", "highlighted"], 1], + 0.34, + 0.16, + ] as never); + map.setPaintProperty(haloId, "circle-radius", highlightedCircleRadius as never); } catch { // ignore } @@ -1721,18 +2100,37 @@ export function Map3D({ id: outlineId, type: "circle", source: srcId, - layout: { visibility }, paint: { - "circle-radius": circleRadius as never, + "circle-radius": highlightedCircleRadius as never, "circle-color": "rgba(0,0,0,0)", - "circle-stroke-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, + "circle-stroke-color": [ + "case", + ["==", ["get", "selected"], 1], + "rgba(14,234,255,0.95)", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,0.95)", + "rgba(59,130,246,0.75)", + ] as never, "circle-stroke-width": [ "case", - ["boolean", ["get", "permitted"], false], - ["case", ["==", ["get", "selected"], 1], 2.5, 1.6], - ["case", ["==", ["get", "selected"], 1], 2.0, 0.0], - ] as unknown as number[], - "circle-stroke-opacity": 0.8, + ["==", ["get", "selected"], 1], + 3.4, + ["==", ["get", "highlighted"], 1], + 2.7, + 0.0, + ] as never, + "circle-stroke-opacity": 0.85, + }, + layout: { + visibility, + "circle-sort-key": [ + "case", + ["==", ["get", "selected"], 1], + 40, + ["==", ["get", "highlighted"], 1], + 35, + 15, + ] as never, }, } as unknown as LayerSpecification, before, @@ -1743,6 +2141,30 @@ export function Map3D({ } else { try { map.setLayoutProperty(outlineId, "visibility", visibility); + map.setPaintProperty( + outlineId, + "circle-stroke-color", + [ + "case", + ["==", ["get", "selected"], 1], + "rgba(14,234,255,0.95)", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,0.95)", + "rgba(59,130,246,0.75)", + ] as never, + ); + map.setPaintProperty( + outlineId, + "circle-stroke-width", + [ + "case", + ["==", ["get", "selected"], 1], + 3.4, + ["==", ["get", "highlighted"], 1], + 2.7, + 0.0, + ] as never, + ); } catch { // ignore } @@ -1757,6 +2179,14 @@ export function Map3D({ source: srcId, layout: { visibility, + "symbol-sort-key": [ + "case", + ["==", ["get", "selected"], 1], + 50, + ["==", ["get", "highlighted"], 1], + 45, + 20, + ] as never, "icon-image": imgId, "icon-size": [ "interpolate", @@ -1780,10 +2210,38 @@ export function Map3D({ "icon-pitch-alignment": "map", }, paint: { - "icon-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, - "icon-opacity": ["case", ["==", ["get", "selected"], 1], 1.0, 0.92], - "icon-halo-color": "rgba(15,23,42,0.25)", - "icon-halo-width": 1, + "icon-color": [ + "case", + ["==", ["get", "selected"], 1], + "rgba(14,234,255,1)", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,1)", + "rgba(59,130,246,1)", + ] as never, + "icon-opacity": [ + "case", + ["==", ["get", "selected"], 1], + 1.0, + ["==", ["get", "highlighted"], 1], + 1, + 0.9, + ] as never, + "icon-halo-color": [ + "case", + ["==", ["get", "selected"], 1], + "rgba(14,234,255,0.68)", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,0.72)", + "rgba(15,23,42,0.25)", + ] as never, + "icon-halo-width": [ + "case", + ["==", ["get", "selected"], 1], + 2.2, + ["==", ["get", "highlighted"], 1], + 1.5, + 0, + ] as never, }, } as unknown as LayerSpecification, before, @@ -1794,12 +2252,60 @@ export function Map3D({ } else { try { map.setLayoutProperty(symbolId, "visibility", visibility); + map.setPaintProperty( + symbolId, + "icon-color", + [ + "case", + ["==", ["get", "selected"], 1], + "rgba(14,234,255,1)", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,1)", + ["coalesce", ["get", "shipColor"], "#64748b"], + ] as never, + ); + map.setPaintProperty( + symbolId, + "icon-opacity", + [ + "case", + ["==", ["get", "selected"], 1], + 1.0, + ["==", ["get", "highlighted"], 1], + 1, + 0.9, + ] as never, + ); + map.setPaintProperty( + symbolId, + "icon-halo-color", + [ + "case", + ["==", ["get", "selected"], 1], + "rgba(14,234,255,0.68)", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,0.72)", + "rgba(15,23,42,0.25)", + ] as never, + ); + map.setPaintProperty( + symbolId, + "icon-halo-width", + [ + "case", + ["==", ["get", "selected"], 1], + 2.2, + ["==", ["get", "highlighted"], 1], + 1.5, + 0, + ] as never, + ); } catch { // ignore } } - // Selection is now source-data driven (`selected` property), no per-feature state update needed. + // Selection and highlight are now source-data driven. kickRepaint(map); }; @@ -1807,7 +2313,7 @@ export function Map3D({ return () => { stop(); }; - }, [projection, settings.showShips, targets, legacyHits, selectedMmsi, mapSyncEpoch]); + }, [projection, settings.showShips, targets, legacyHits, selectedMmsi, hoveredMmsiSetRef, hoveredFleetMmsiSetRef, hoveredPairMmsiSetRef, isHighlightedMmsi, mapSyncEpoch]); // Globe ship click selection (MapLibre-native ships layer) useEffect(() => { @@ -1838,6 +2344,10 @@ export function Map3D({ >; const mmsi = Number(props.mmsi); if (Number.isFinite(mmsi)) { + if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) { + onToggleHighlightMmsi?.(mmsi); + return; + } onSelectMmsi(mmsi); return; } @@ -1857,6 +2367,10 @@ export function Map3D({ } } if (bestMmsi != null) { + if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) { + onToggleHighlightMmsi?.(bestMmsi); + return; + } onSelectMmsi(bestMmsi); return; } @@ -1874,7 +2388,7 @@ export function Map3D({ // ignore } }; - }, [projection, settings.showShips, onSelectMmsi, mapSyncEpoch, targets]); + }, [projection, settings.showShips, onSelectMmsi, onToggleHighlightMmsi, mapSyncEpoch, targets]); // Globe overlays (pair links / FC links / ranges) rendered as MapLibre GeoJSON layers. @@ -1918,6 +2432,7 @@ export function Map3D({ bMmsi: p.bMmsi, distanceNm: p.distanceNm, warn: p.warn, + highlighted: isHighlightedPair(p.aMmsi, p.bMmsi) ? 1 : 0, }, })), }; @@ -1944,11 +2459,20 @@ export function Map3D({ paint: { "line-color": [ "case", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,0.98)", ["boolean", ["get", "warn"], false], "rgba(245,158,11,0.95)", "rgba(59,130,246,0.55)", ] as never, - "line-width": ["case", ["boolean", ["get", "warn"], false], 2.2, 1.4] as never, + "line-width": [ + "case", + ["==", ["get", "highlighted"], 1], + 2.8, + ["boolean", ["get", "warn"], false], + 2.2, + 1.4, + ] as never, "line-opacity": 0.9, }, } as unknown as LayerSpecification, @@ -1968,7 +2492,7 @@ export function Map3D({ stop(); remove(); }; - }, [projection, overlays.pairLines, pairLinks, mapSyncEpoch]); + }, [projection, overlays.pairLines, pairLinks, mapSyncEpoch, hoveredShipSignature, hoveredPairSignature, isHighlightedPair]); useEffect(() => { const map = mapRef.current; @@ -2018,6 +2542,7 @@ export function Map3D({ distanceNm: s.distanceNm, fcMmsi: s.fromMmsi ?? -1, otherMmsi: s.toMmsi ?? -1, + highlighted: s.fromMmsi != null && isHighlightedMmsi(s.fromMmsi) ? 1 : 0, }, })), }; @@ -2044,11 +2569,13 @@ export function Map3D({ paint: { "line-color": [ "case", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,0.98)", ["boolean", ["get", "suspicious"], false], "rgba(239,68,68,0.95)", "rgba(217,119,6,0.92)", ] as never, - "line-width": 1.3, + "line-width": ["case", ["==", ["get", "highlighted"], 1], 2.0, 1.3] as never, "line-opacity": 0.9, }, } as unknown as LayerSpecification, @@ -2068,16 +2595,23 @@ export function Map3D({ stop(); remove(); }; - }, [projection, overlays.fcLines, fcLinks, mapSyncEpoch]); + }, [projection, overlays.fcLines, fcLinks, mapSyncEpoch, hoveredShipSignature, isHighlightedMmsi]); useEffect(() => { const map = mapRef.current; if (!map) return; const srcId = "fleet-circles-ml-src"; + const fillSrcId = "fleet-circles-ml-fill-src"; const layerId = "fleet-circles-ml"; + const fillLayerId = "fleet-circles-ml-fill"; const remove = () => { + try { + if (map.getLayer(fillLayerId)) map.removeLayer(fillLayerId); + } catch { + // ignore + } try { if (map.getLayer(layerId)) map.removeLayer(layerId); } catch { @@ -2088,6 +2622,11 @@ export function Map3D({ } catch { // ignore } + try { + if (map.getSource(fillSrcId)) map.removeSource(fillSrcId); + } catch { + // ignore + } }; const ensure = () => { @@ -2097,7 +2636,7 @@ export function Map3D({ return; } - const fc: GeoJSON.FeatureCollection = { + const fcLine: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: (fleetCircles || []).map((c, idx) => { const ring = circleRingLngLat(c.center, c.radiusNm * 1852); @@ -2108,8 +2647,36 @@ export function Map3D({ properties: { type: "fleet", ownerKey: c.ownerKey, + ownerLabel: c.ownerLabel, count: c.count, - vesselMmsis: c.vesselMmsis.length, + vesselMmsis: c.vesselMmsis, + highlighted: + isHighlightedFleet(c.ownerKey, c.vesselMmsis) || c.vesselMmsis.some((m) => isHighlightedMmsi(m)) + ? 1 + : 0, + }, + }; + }), + }; + + const fcFill: GeoJSON.FeatureCollection = { + type: "FeatureCollection", + features: (fleetCircles || []).map((c, idx) => { + const ring = circleRingLngLat(c.center, c.radiusNm * 1852); + return { + type: "Feature", + id: `fleet-fill-${c.ownerKey}-${idx}`, + geometry: { type: "Polygon", coordinates: [ring] }, + properties: { + type: "fleet-fill", + ownerKey: c.ownerKey, + ownerLabel: c.ownerLabel, + count: c.count, + vesselMmsis: c.vesselMmsis, + highlighted: + isHighlightedFleet(c.ownerKey, c.vesselMmsis) || c.vesselMmsis.some((m) => isHighlightedMmsi(m)) + ? 1 + : 0, }, }; }), @@ -2117,8 +2684,17 @@ export function Map3D({ try { const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(fc); - else map.addSource(srcId, { type: "geojson", data: fc } as GeoJSONSourceSpecification); + if (existing) existing.setData(fcLine); + else map.addSource(srcId, { type: "geojson", data: fcLine } as GeoJSONSourceSpecification); + } catch (e) { + console.warn("Fleet circles source setup failed:", e); + return; + } + + try { + const existingFill = map.getSource(fillSrcId) as GeoJSONSource | undefined; + if (existingFill) existingFill.setData(fcFill); + else map.addSource(fillSrcId, { type: "geojson", data: fcFill } as GeoJSONSourceSpecification); } catch (e) { console.warn("Fleet circles source setup failed:", e); return; @@ -2126,6 +2702,31 @@ export function Map3D({ const before = undefined; + if (!map.getLayer(fillLayerId)) { + try { + map.addLayer( + { + id: fillLayerId, + type: "fill", + source: fillSrcId, + layout: { visibility: "visible" }, + paint: { + "fill-color": [ + "case", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,0.16)", + "rgba(245,158,11,0.02)", + ] as never, + "fill-opacity": ["case", ["==", ["get", "highlighted"], 1], 0.7, 0.36] as never, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn("Fleet circles fill layer add failed:", e); + } + } + if (!map.getLayer(layerId)) { try { map.addLayer( @@ -2135,8 +2736,8 @@ export function Map3D({ source: srcId, layout: { "line-cap": "round", "line-join": "round", visibility: "visible" }, paint: { - "line-color": "rgba(245,158,11,0.65)", - "line-width": 1.1, + "line-color": ["case", ["==", ["get", "highlighted"], 1], "rgba(245,158,11,0.95)", "rgba(245,158,11,0.65)"] as never, + "line-width": ["case", ["==", ["get", "highlighted"], 1], 2, 1.1] as never, "line-opacity": 0.85, }, } as unknown as LayerSpecification, @@ -2156,7 +2757,17 @@ export function Map3D({ stop(); remove(); }; - }, [projection, overlays.fleetCircles, fleetCircles, mapSyncEpoch]); + }, [ + projection, + overlays.fleetCircles, + fleetCircles, + mapSyncEpoch, + hoveredShipSignature, + hoveredFleetSignature, + hoveredFleetOwnerKey, + isHighlightedFleet, + isHighlightedMmsi, + ]); useEffect(() => { const map = mapRef.current; @@ -2216,6 +2827,7 @@ export function Map3D({ aMmsi: c.aMmsi, bMmsi: c.bMmsi, distanceNm: c.distanceNm, + highlighted: isHighlightedPair(c.aMmsi, c.bMmsi) ? 1 : 0, }, }; }), @@ -2243,11 +2855,13 @@ export function Map3D({ paint: { "line-color": [ "case", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,0.92)", ["boolean", ["get", "warn"], false], "rgba(245,158,11,0.75)", "rgba(59,130,246,0.45)", ] as never, - "line-width": 1.0, + "line-width": ["case", ["==", ["get", "highlighted"], 1], 1.6, 1.0] as never, "line-opacity": 0.85, }, } as unknown as LayerSpecification, @@ -2267,7 +2881,7 @@ export function Map3D({ stop(); remove(); }; - }, [projection, overlays.pairRange, pairLinks, mapSyncEpoch]); + }, [projection, overlays.pairRange, pairLinks, mapSyncEpoch, hoveredShipSignature, hoveredPairSignature, hoveredFleetSignature, isHighlightedPair]); const shipData = useMemo(() => { return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon)); @@ -2289,6 +2903,25 @@ export function Map3D({ mapTooltipRef.current = null; }, []); + const setGlobeTooltip = useCallback((lngLat: maplibregl.LngLatLike, tooltipHtml: string) => { + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + if (!mapTooltipRef.current) { + mapTooltipRef.current = new maplibregl.Popup({ + closeButton: false, + closeOnClick: false, + maxWidth: "360px", + className: "maplibre-tooltip-popup", + }); + } + + const container = document.createElement("div"); + container.className = "maplibre-tooltip-popup__content"; + container.innerHTML = tooltipHtml; + + mapTooltipRef.current.setLngLat(lngLat).setDOMContent(container).addTo(map); + }, []); + const buildGlobeFeatureTooltip = useCallback( (feature: { properties?: Record | null; layer?: { id?: string } } | null | undefined) => { if (!feature) return null; @@ -2342,9 +2975,10 @@ export function Map3D({ }); } - if (layerId === "fleet-circles-ml") { + if (layerId === "fleet-circles-ml" || layerId === "fleet-circles-ml-fill") { return getFleetCircleTooltipHtml({ ownerKey: String(props.ownerKey ?? ""), + ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ""), count: Number(props.count ?? 0), }); } @@ -2364,26 +2998,63 @@ export function Map3D({ const map = mapRef.current; if (!map) return; + const clearDeckGlobeHoverState = () => { + setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); + setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + clearMapFleetHoverState(); + }; + + const resetGlobeHoverStates = () => { + setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); + setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + clearMapFleetHoverState(); + }; + + const normalizeMmsiList = (value: unknown): number[] => { + if (!Array.isArray(value)) return []; + const out: number[] = []; + for (const n of value) { + const m = toIntMmsi(n); + if (m != null) out.push(m); + } + return out; + }; + const onMouseMove = (e: maplibregl.MapMouseEvent) => { if (projection !== "globe") { + clearGlobeTooltip(); + resetGlobeHoverStates(); + return; + } + if (!map.isStyleLoaded()) { + clearDeckGlobeHoverState(); clearGlobeTooltip(); return; } - const candidateLayerIds = [ - "ships-globe", - "ships-globe-halo", - "ships-globe-outline", - "pair-lines-ml", - "fc-lines-ml", - "fleet-circles-ml", - "pair-range-ml", - "zones-fill", - "zones-line", - "zones-label", - ].filter((id) => map.getLayer(id)); + let candidateLayerIds: string[] = []; + try { + candidateLayerIds = [ + "ships-globe", + "ships-globe-halo", + "ships-globe-outline", + "pair-lines-ml", + "fc-lines-ml", + "fleet-circles-ml", + "fleet-circles-ml-fill", + "pair-range-ml", + "zones-fill", + "zones-line", + "zones-label", + ].filter((id) => map.getLayer(id)); + } catch { + candidateLayerIds = []; + } if (candidateLayerIds.length === 0) { + resetGlobeHoverStates(); clearGlobeTooltip(); return; } @@ -2398,30 +3069,95 @@ export function Map3D({ rendered = []; } - const first = rendered[0]; - const tooltip = buildGlobeFeatureTooltip(first); - if (!tooltip) { + const priority = [ + "ships-globe", + "ships-globe-halo", + "ships-globe-outline", + "pair-lines-ml", + "fc-lines-ml", + "pair-range-ml", + "fleet-circles-ml-fill", + "fleet-circles-ml", + "zones-fill", + "zones-line", + "zones-label", + ]; + + const first = priority.map((id) => rendered.find((r) => r.layer?.id === id)).find(Boolean) as + | { properties?: Record | null; layer?: { id?: string } } + | undefined; + + if (!first) { + resetGlobeHoverStates(); clearGlobeTooltip(); return; } - if (!mapTooltipRef.current) { - mapTooltipRef.current = new maplibregl.Popup({ - closeButton: false, - closeOnClick: false, - className: "maplibre-tooltip-popup", - }); + const layerId = first.layer?.id; + const props = first.properties || {}; + const isShipLayer = layerId === "ships-globe" || layerId === "ships-globe-halo" || layerId === "ships-globe-outline"; + const isPairLayer = layerId === "pair-lines-ml" || layerId === "pair-range-ml"; + const isFcLayer = layerId === "fc-lines-ml"; + const isFleetLayer = layerId === "fleet-circles-ml" || layerId === "fleet-circles-ml-fill"; + const isZoneLayer = layerId === "zones-fill" || layerId === "zones-line" || layerId === "zones-label"; + + if (isShipLayer) { + const mmsi = toIntMmsi(props.mmsi); + setHoveredDeckMmsiSingle(mmsi); + setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); + clearMapFleetHoverState(); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + } else if (isPairLayer) { + const aMmsi = toIntMmsi(props.aMmsi); + const bMmsi = toIntMmsi(props.bMmsi); + setHoveredDeckPairs([...(aMmsi == null ? [] : [aMmsi]), ...(bMmsi == null ? [] : [bMmsi])]); + setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); + clearMapFleetHoverState(); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + } else if (isFcLayer) { + const from = toIntMmsi(props.fcMmsi); + const to = toIntMmsi(props.otherMmsi); + const fromTo = [from, to].filter((v): v is number => v != null); + setHoveredDeckPairs(fromTo); + setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, fromTo) ? prev : fromTo)); + clearMapFleetHoverState(); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + } else if (isFleetLayer) { + const ownerKey = String(props.ownerKey ?? ""); + const list = normalizeMmsiList(props.vesselMmsis); + setMapFleetHoverState(ownerKey || null, list); + setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); + setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + } else if (isZoneLayer) { + clearMapFleetHoverState(); + setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); + setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); + const zoneId = String((props.zoneId ?? "").toString()); + setHoveredZoneId(zoneId || null); + } else { + resetGlobeHoverStates(); + } + + const tooltip = buildGlobeFeatureTooltip(first); + if (!tooltip) { + if (!isZoneLayer) { + resetGlobeHoverStates(); + } + clearGlobeTooltip(); + return; } const content = tooltip?.html ?? ""; if (content) { - mapTooltipRef.current.setLngLat(e.lngLat).setHTML(content).addTo(map); + setGlobeTooltip(e.lngLat, content); return; } clearGlobeTooltip(); }; const onMouseOut = () => { + resetGlobeHoverStates(); clearGlobeTooltip(); }; @@ -2433,7 +3169,16 @@ export function Map3D({ map.off("mouseout", onMouseOut); clearGlobeTooltip(); }; - }, [projection, buildGlobeFeatureTooltip, clearGlobeTooltip]); + }, [ + projection, + buildGlobeFeatureTooltip, + clearGlobeTooltip, + clearMapFleetHoverState, + setHoveredDeckPairs, + setHoveredDeckMmsiSingle, + setMapFleetHoverState, + setGlobeTooltip, + ]); const legacyTargets = useMemo(() => { if (!legacyHits) return []; @@ -2474,6 +3219,31 @@ export function Map3D({ map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 }); }, [selectedMmsi, shipData]); + useEffect(() => { + const map = mapRef.current; + if (!map || !fleetFocus) return; + const [lon, lat] = fleetFocus.center; + if (!Number.isFinite(lon) || !Number.isFinite(lat)) return; + + const apply = () => { + map.easeTo({ + center: [lon, lat], + zoom: fleetFocus.zoom ?? 10, + duration: 700, + }); + }; + + if (map.isStyleLoaded()) { + apply(); + return; + } + + const stop = onMapStyleReady(map, apply); + return () => { + stop(); + }; + }, [fleetFocus?.id, fleetFocus?.center?.[0], fleetFocus?.center?.[1], fleetFocus?.zoom]); + // Update Deck.gl layers useEffect(() => { const map = mapRef.current; @@ -2495,6 +3265,38 @@ export function Map3D({ const overlayParams = projection === "globe" ? GLOBE_OVERLAY_PARAMS : DEPTH_DISABLED_PARAMS; const layers = []; + const clearDeckHover = () => { + setHoveredMmsiList([]); + setHoveredDeckPairs([]); + clearMapFleetHoverState(); + }; + + const toFleetMmsiList = (value: unknown) => { + if (!Array.isArray(value)) return []; + const out: number[] = []; + for (const item of value) { + const v = toIntMmsi(item); + if (v != null) out.push(v); + } + return out; + }; + + const onDeckSelectOrHighlight = (info: unknown, allowMultiSelect = false) => { + const obj = info as { mmsi?: unknown; srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }; + const mmsi = toIntMmsi(obj.mmsi); + if (mmsi == null) return; + const evt = obj.srcEvent ?? null; + const isAux = hasAuxiliarySelectModifier(evt); + if (onToggleHighlightMmsi && isAux) { + onToggleHighlightMmsi(mmsi); + return; + } + if (!allowMultiSelect && selectedMmsi === mmsi) { + onSelectMmsi(null); + return; + } + onSelectMmsi(mmsi); + }; if (settings.showDensity && projection !== "globe") { layers.push( @@ -2512,69 +3314,6 @@ export function Map3D({ ); } - if (settings.showShips && projection !== "globe") { - layers.push( - new IconLayer({ - id: "ships", - data: shipData, - pickable: true, - // Keep icons horizontal on the sea surface when view is pitched/rotated. - billboard: false, - parameters: overlayParams, - iconAtlas: "/assets/ship.svg", - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => "ship", - getPosition: (d) => - [d.lon, d.lat] as [number, number], - getAngle: (d) => - getDisplayHeading({ - cog: d.cog, - heading: d.heading, - }), - sizeUnits: "pixels", - getSize: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? FLAT_SHIP_ICON_SIZE_SELECTED : FLAT_SHIP_ICON_SIZE), - getColor: (d) => getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null), - alphaCutoff: 0.05, - updateTriggers: { - getSize: [selectedMmsi], - getColor: [selectedMmsi, legacyHits], - }, - }), - ); - } - - if (settings.showShips && projection !== "globe" && legacyTargets.length > 0) { - layers.push( - new ScatterplotLayer({ - id: "legacy-halo", - data: legacyTargets, - pickable: false, - billboard: false, - // This ring is most prone to z-fighting, so force it into pure painter's-order rendering. - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "pixels", - getRadius: (d) => - (selectedMmsi && d.mmsi === selectedMmsi ? FLAT_LEGACY_HALO_RADIUS_SELECTED : FLAT_LEGACY_HALO_RADIUS), - lineWidthUnits: "pixels", - getLineWidth: 2, - getLineColor: (d) => { - const l = legacyHits?.get(d.mmsi); - const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; - if (!rgb) return [245, 158, 11, 200]; - return [rgb[0], rgb[1], rgb[2], 200]; - }, - getPosition: (d) => - [d.lon, d.lat] as [number, number], - updateTriggers: { - getRadius: [selectedMmsi], - getLineColor: [legacyHits], - }, - }), - ); - } - if (overlays.pairRange && projection !== "globe" && pairRanges.length > 0) { layers.push( new ScatterplotLayer({ @@ -2589,9 +3328,42 @@ export function Map3D({ getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: "pixels", - getLineWidth: () => 1, - getLineColor: (d) => (d.warn ? [245, 158, 11, 170] : [59, 130, 246, 110]), + getLineWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), + getLineColor: (d) => { + if (isHighlightedPair(d.aMmsi, d.bMmsi)) return [245, 158, 11, 220]; + return d.warn ? [245, 158, 11, 170] : [59, 130, 246, 110]; + }, getPosition: (d) => d.center, + onHover: (info) => { + if (!info.object) { + clearDeckHover(); + return; + } + const p = info.object as PairRangeCircle; + const aMmsi = p.aMmsi; + const bMmsi = p.bMmsi; + setHoveredDeckPairs([aMmsi, bMmsi]); + setHoveredMmsiList([aMmsi, bMmsi]); + clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) { + onSelectMmsi(null); + return; + } + const obj = info.object as PairRangeCircle; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + onToggleHighlightMmsi?.(obj.aMmsi); + onToggleHighlightMmsi?.(obj.bMmsi); + return; + } + onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); + }, + updateTriggers: { + getLineWidth: [hoveredPairSignature], + getLineColor: [hoveredPairSignature], + }, }), ); } @@ -2605,9 +3377,37 @@ export function Map3D({ parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, - getColor: (d) => (d.warn ? [245, 158, 11, 220] : [59, 130, 246, 85]), - getWidth: (d) => (d.warn ? 2.2 : 1.4), + getColor: (d) => { + if (isHighlightedPair(d.aMmsi, d.bMmsi)) return [245, 158, 11, 245]; + return d.warn ? [245, 158, 11, 220] : [59, 130, 246, 85]; + }, + getWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), widthUnits: "pixels", + onHover: (info) => { + if (!info.object) { + clearDeckHover(); + return; + } + const obj = info.object as PairLink; + setHoveredDeckPairs([obj.aMmsi, obj.bMmsi]); + setHoveredMmsiList([obj.aMmsi, obj.bMmsi]); + clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) return; + const obj = info.object as PairLink; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + onToggleHighlightMmsi?.(obj.aMmsi); + onToggleHighlightMmsi?.(obj.bMmsi); + return; + } + onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); + }, + updateTriggers: { + getColor: [hoveredPairSignature], + getWidth: [hoveredPairSignature], + }, }), ); } @@ -2621,9 +3421,50 @@ export function Map3D({ parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, - getColor: (d) => (d.suspicious ? [239, 68, 68, 220] : [217, 119, 6, 200]), - getWidth: () => 1.3, + getColor: (d) => { + const isHighlighted = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); + if (isHighlighted) return [245, 158, 11, 230]; + return d.suspicious ? [239, 68, 68, 220] : [217, 119, 6, 200]; + }, + getWidth: (d) => { + const isHighlighted = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); + return isHighlighted ? 1.9 : 1.3; + }, widthUnits: "pixels", + onHover: (info) => { + if (!info.object) { + clearDeckHover(); + return; + } + const obj = info.object as DashSeg; + const aMmsi = obj.fromMmsi; + const bMmsi = obj.toMmsi; + if (aMmsi == null || bMmsi == null) { + setHoveredMmsiList([]); + return; + } + setHoveredDeckPairs([aMmsi, bMmsi]); + setHoveredMmsiList([aMmsi, bMmsi]); + clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) return; + const obj = info.object as DashSeg; + if (obj.fromMmsi == null || obj.toMmsi == null) { + return; + } + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + onToggleHighlightMmsi?.(obj.fromMmsi); + onToggleHighlightMmsi?.(obj.toMmsi); + return; + } + onDeckSelectOrHighlight({ mmsi: obj.fromMmsi }); + }, + updateTriggers: { + getColor: [hoveredShipSignature], + getWidth: [hoveredShipSignature], + }, }), ); } @@ -2641,9 +3482,168 @@ export function Map3D({ radiusUnits: "meters", getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: "pixels", - getLineWidth: 1, - getLineColor: () => [245, 158, 11, 140], + getLineWidth: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), + getLineColor: (d) => { + const isHighlighted = isHighlightedFleet(d.ownerKey, d.vesselMmsis); + return isHighlighted ? [245, 158, 11, 220] : [245, 158, 11, 140]; + }, getPosition: (d) => d.center, + onHover: (info) => { + if (!info.object) { + clearDeckHover(); + return; + } + const obj = info.object as FleetCircle; + const list = toFleetMmsiList(obj.vesselMmsis); + setMapFleetHoverState(obj.ownerKey || null, list); + setHoveredMmsiList(list); + setHoveredDeckPairs([]); + }, + onClick: (info) => { + if (!info.object) return; + const obj = info.object as FleetCircle; + const list = toFleetMmsiList(obj.vesselMmsis); + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + for (const mmsi of list) onToggleHighlightMmsi?.(mmsi); + return; + } + const first = list[0]; + if (first != null) { + onDeckSelectOrHighlight({ mmsi: first }); + } + }, + updateTriggers: { + getLineWidth: [hoveredFleetSignature, hoveredFleetOwnerKey, hoveredShipSignature], + getLineColor: [hoveredFleetSignature, hoveredFleetOwnerKey, hoveredShipSignature], + }, + }), + ); + } + + if (overlays.fleetCircles && projection !== "globe" && (fleetCircles?.length ?? 0) > 0) { + layers.push( + new ScatterplotLayer({ + id: "fleet-circles-fill", + data: fleetCircles, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: true, + stroked: false, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + getFillColor: (d) => { + const isHighlighted = isHighlightedFleet(d.ownerKey, d.vesselMmsis); + return isHighlighted ? [245, 158, 11, 42] : [245, 158, 11, 6]; + }, + getPosition: (d) => d.center, + updateTriggers: { + getFillColor: [hoveredFleetSignature, hoveredFleetOwnerKey, hoveredShipSignature], + }, + }), + ); + } + + if (settings.showShips && projection !== "globe" && legacyTargets.length > 0) { + layers.push( + new ScatterplotLayer({ + id: "legacy-halo", + data: legacyTargets, + pickable: false, + billboard: false, + // This ring is most prone to z-fighting, so force it into pure painter's-order rendering. + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "pixels", + getRadius: (d) => { + if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; + if (isHighlightedMmsi(d.mmsi)) return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; + return FLAT_LEGACY_HALO_RADIUS; + }, + lineWidthUnits: "pixels", + getLineWidth: (d) => { + const isHighlighted = isHighlightedMmsi(d.mmsi); + return selectedMmsi && d.mmsi === selectedMmsi + ? 2.5 + : isHighlighted + ? 2.2 + : 2; + }, + getLineColor: (d) => { + if (selectedMmsi && d.mmsi === selectedMmsi) return [14, 234, 255, 230]; + if (isHighlightedMmsi(d.mmsi)) return [245, 158, 11, 210]; + const l = legacyHits?.get(d.mmsi); + const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; + if (!rgb) return [245, 158, 11, 200]; + return [rgb[0], rgb[1], rgb[2], 200]; + }, + getPosition: (d) => [d.lon, d.lat] as [number, number], + updateTriggers: { + getRadius: [selectedMmsi, hoveredShipSignature], + getLineColor: [selectedMmsi, legacyHits, hoveredShipSignature], + }, + }), + ); + } + + if (settings.showShips && projection !== "globe") { + layers.push( + new IconLayer({ + id: "ships", + data: shipData, + pickable: true, + // Keep icons horizontal on the sea surface when view is pitched/rotated. + billboard: false, + parameters: overlayParams, + iconAtlas: "/assets/ship.svg", + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => "ship", + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => + getDisplayHeading({ + cog: d.cog, + heading: d.heading, + }), + sizeUnits: "pixels", + getSize: (d) => { + if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; + if (isHighlightedMmsi(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; + return FLAT_SHIP_ICON_SIZE; + }, + getColor: (d) => + getShipColor( + d, + selectedMmsi, + legacyHits?.get(d.mmsi)?.shipCode ?? null, + highlightedMmsiSetCombined, + ), + onHover: (info) => { + if (!info.object) { + clearDeckHover(); + return; + } + const obj = info.object as AisTarget; + setHoveredMmsiList([obj.mmsi]); + setHoveredDeckPairs([]); + clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) { + onSelectMmsi(null); + return; + } + onDeckSelectOrHighlight({ + mmsi: info.object.mmsi, + srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent, + }, true); + }, + alphaCutoff: 0.05, + updateTriggers: { + getSize: [selectedMmsi, hoveredShipSignature], + getColor: [selectedMmsi, legacyHits, hoveredShipSignature], + }, }), ); } @@ -2711,6 +3711,7 @@ export function Map3D({ if (info.layer && info.layer.id === "fleet-circles") { return getFleetCircleTooltipHtml({ ownerKey: String(obj.ownerKey ?? ""), + ownerLabel: String(obj.ownerLabel ?? obj.ownerKey ?? ""), count: Number(obj.count ?? 0), }); } @@ -2733,6 +3734,11 @@ export function Map3D({ const obj: any = info.object; if (typeof obj.mmsi === "number") { const t = obj as AisTarget; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + onToggleHighlightMmsi?.(t.mmsi); + return; + } onSelectMmsi(t.mmsi); map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 }); } @@ -2763,6 +3769,16 @@ export function Map3D({ fleetCircles, shipByMmsi, mapSyncEpoch, + hoveredShipSignature, + hoveredFleetSignature, + hoveredPairSignature, + hoveredFleetOwnerKey, + highlightedMmsiSet, + isHighlightedMmsi, + isHighlightedFleet, + isHighlightedPair, + clearMapFleetHoverState, + setMapFleetHoverState, ensureMercatorOverlay, ]); From 03d728589f9fca2b8e2a640457554fad553448ce Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 15:22:23 +0900 Subject: [PATCH 19/58] Sync globe layer rebuild during projection transition --- apps/web/src/widgets/map3d/Map3D.tsx | 13 ++ .../src/widgets/relations/RelationsPanel.tsx | 142 ++++++++++++++---- 2 files changed, 126 insertions(+), 29 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 453a8e2..e194216 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -1630,6 +1630,7 @@ export function Map3D({ zoneLabelExpr.push(["coalesce", ["get", "zoneName"], ["get", "zoneLabel"], ["get", "NAME"], "수역"]); const ensure = () => { + if (projectionBusyRef.current) return; // Always update visibility if the layers exist. const visibility = overlays.zones ? "visible" : "none"; try { @@ -1913,6 +1914,7 @@ export function Map3D({ }; const ensure = () => { + if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; if (projection !== "globe" || !settings.showShips) { @@ -2414,6 +2416,7 @@ export function Map3D({ }; const ensure = () => { + if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; if (projection !== "globe" || !overlays.pairLines || (pairLinks?.length ?? 0) === 0) { remove(); @@ -2515,6 +2518,7 @@ export function Map3D({ }; const ensure = () => { + if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; if (projection !== "globe" || !overlays.fcLines) { remove(); @@ -2630,6 +2634,7 @@ export function Map3D({ }; const ensure = () => { + if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; if (projection !== "globe" || !overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) { remove(); @@ -2790,6 +2795,7 @@ export function Map3D({ }; const ensure = () => { + if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; if (projection !== "globe" || !overlays.pairRange) { remove(); @@ -3028,6 +3034,11 @@ export function Map3D({ resetGlobeHoverStates(); return; } + if (projectionBusyRef.current) { + resetGlobeHoverStates(); + clearGlobeTooltip(); + return; + } if (!map.isStyleLoaded()) { clearDeckGlobeHoverState(); clearGlobeTooltip(); @@ -3248,6 +3259,8 @@ export function Map3D({ useEffect(() => { const map = mapRef.current; if (!map) return; + if (projectionBusyRef.current) return; + let deckTarget = projection === "globe" ? globeDeckLayerRef.current : overlayRef.current; if (projection === "mercator") { diff --git a/apps/web/src/widgets/relations/RelationsPanel.tsx b/apps/web/src/widgets/relations/RelationsPanel.tsx index 4cbc3cc..1572940 100644 --- a/apps/web/src/widgets/relations/RelationsPanel.tsx +++ b/apps/web/src/widgets/relations/RelationsPanel.tsx @@ -1,3 +1,4 @@ +import { useMemo, type MouseEvent } from "react"; import { VESSEL_TYPES } from "../../entities/vessel/model/meta"; import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types"; import { haversineNm } from "../../shared/lib/geo/haversineNm"; @@ -7,9 +8,56 @@ type Props = { vessels: DerivedLegacyVessel[]; fleetVessels: DerivedLegacyVessel[]; onSelectMmsi: (mmsi: number) => void; + onToggleHighlightMmsi: (mmsi: number) => void; + onHoverMmsi: (mmsis: number[]) => void; + onClearHover: () => void; + onHoverPair: (mmsis: number[]) => void; + onClearPairHover: () => void; + onHoverFleet: (ownerKey: string | null, mmsis: number[]) => void; + onClearFleetHover: () => void; + hoveredFleetOwnerKey?: string | null; + hoveredFleetMmsiSet?: number[]; + onContextMenuFleet?: (ownerKey: string, mmsis: number[]) => void; }; -export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelectMmsi }: Props) { +export function RelationsPanel({ + selectedVessel, + vessels, + fleetVessels, + onSelectMmsi, + onToggleHighlightMmsi, + onHoverMmsi, + onClearHover, + onHoverPair, + onClearPairHover, + onHoverFleet, + onClearFleetHover, + hoveredFleetOwnerKey, + hoveredFleetMmsiSet, + onContextMenuFleet, +}: Props) { + const handlePrimaryAction = (e: MouseEvent, mmsi: number) => { + if (e.shiftKey || e.ctrlKey || e.metaKey) { + onToggleHighlightMmsi(mmsi); + return; + } + onSelectMmsi(mmsi); + }; + + const clearAllHovers = () => { + onClearHover(); + onClearPairHover(); + onClearFleetHover(); + }; + + const hoveredFleetMmsis = useMemo(() => new Set(hoveredFleetMmsiSet ?? []), [hoveredFleetMmsiSet]); + + const isFleetHighlightByOwner = (ownerKey: string | null) => + hoveredFleetOwnerKey != null && ownerKey != null && hoveredFleetOwnerKey === ownerKey; + + const isVesselHighlight = (mmsi: number, ownerKey: string | null) => + hoveredFleetMmsis.has(mmsi) || isFleetHighlightByOwner(ownerKey); + if (selectedVessel) { const v = selectedVessel; const meta = VESSEL_TYPES[v.shipCode]; @@ -19,7 +67,7 @@ export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelect const fcNearby = vessels.filter((fc) => fc.shipCode === "FC" && fc.mmsi !== v.mmsi && haversineNm(fc.lat, fc.lon, v.lat, v.lon) < 5); return ( -
+
{meta.icon} {v.permitNo} @@ -59,12 +107,22 @@ export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelect <>
- onSelectMmsi(v.mmsi)}> + onHoverPair([v.mmsi, pair.mmsi])} + onMouseLeave={onClearPairHover} + onClick={(e) => handlePrimaryAction(e, v.mmsi)} + > {v.permitNo}
{warn ? "⚠" : "⟷"}
- onSelectMmsi(pair.mmsi)}> + onHoverPair([v.mmsi, pair.mmsi])} + onMouseLeave={onClearPairHover} + onClick={(e) => handlePrimaryAction(e, pair.mmsi)} + > {pair.permitNo}
- onSelectMmsi(fc.mmsi)}> + onHoverPair([v.mmsi, fc.mmsi])} + onMouseLeave={onClearPairHover} + onClick={(e) => handlePrimaryAction(e, fc.mmsi)} + > {fc.permitNo} @@ -144,7 +207,14 @@ export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelect {sameOwner.slice(0, 8).map((sv) => { const m = VESSEL_TYPES[sv.shipCode]; return ( -
onSelectMmsi(sv.mmsi)} style={{ cursor: "pointer" }}> +
onHoverFleet(v.ownerKey, [v.mmsi, ...sameOwner.map((x) => x.mmsi), sv.mmsi])} + onMouseLeave={onClearFleetHover} + onClick={(e) => handlePrimaryAction(e, sv.mmsi)} + style={{ cursor: "pointer" }} + >
{sv.shipCode} {sv.permitNo} @@ -160,7 +230,6 @@ export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelect ); } - // No vessel selected: show top fleets if (fleetVessels.length === 0) { return
(현재 지도에 표시중인 대상 선박이 없습니다)
; } @@ -176,40 +245,51 @@ export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelect const topFleets = Array.from(group.entries()) .map(([ownerKey, vs]) => ({ ownerKey, vs })) .filter((x) => x.vs.length >= 3) - .sort((a, b) => b.vs.length - a.vs.length) - .slice(0, 5); + .sort((a, b) => b.vs.length - a.vs.length); if (topFleets.length === 0) { return
(표시 중인 선단(3척 이상) 없음)
; } return ( -
+
{topFleets.map(({ ownerKey, vs }) => { const displayOwner = vs.find((v) => v.ownerCn)?.ownerCn || vs.find((v) => v.ownerRoman)?.ownerRoman || ownerKey; const displayTitle = ownerKey && displayOwner !== ownerKey ? `${displayOwner} (${ownerKey})` : displayOwner; + const isHighlightedFleetRow = isFleetHighlightByOwner(ownerKey) || vs.some((v) => hoveredFleetMmsis.has(v.mmsi)); const codes: Record = {}; for (const v of vs) codes[v.shipCode] = (codes[v.shipCode] ?? 0) + 1; return ( -
-
+
{ + e.preventDefault(); + onContextMenuFleet?.(ownerKey, vs.map((x) => x.mmsi)); + }} + onMouseEnter={() => onHoverFleet(ownerKey, vs.map((v) => v.mmsi))} + onMouseLeave={onClearFleetHover} + > +
🏢{" "} - - {displayOwner} - {" "} - {vs.length}척 -
+ + {displayTitle} + {" "} + {vs.length}척 +
{Object.entries(codes).map(([c, n]) => { const meta = VESSEL_TYPES[c as keyof typeof VESSEL_TYPES]; @@ -234,11 +314,15 @@ export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelect
{vs.slice(0, 18).map((v) => { const m = VESSEL_TYPES[v.shipCode]; - const text = v.shipCode === "FC" ? "F" : v.shipCode === "PT" ? "M" : v.shipCode === "PT-S" ? "S" : v.shipCode[0]; + const text = + v.shipCode === "FC" ? "F" : v.shipCode === "PT" ? "M" : v.shipCode === "PT-S" ? "S" : v.shipCode[0]; return (
onSelectMmsi(v.mmsi)} + className={`fleet-dot ${isVesselHighlight(v.mmsi, ownerKey) ? "hl" : ""}`} + onMouseEnter={() => onHoverMmsi([v.mmsi])} + onMouseLeave={onClearHover} + onClick={(e) => handlePrimaryAction(e, v.mmsi)} style={{ cursor: "pointer", width: 16, From 96d8a03f93e7236f1bdf60899c72205ae87db45a Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 15:25:10 +0900 Subject: [PATCH 20/58] feat: add fleet relation sort toggle --- apps/web/src/app/styles.css | 102 ++++++++++++++++++ .../web/src/pages/dashboard/DashboardPage.tsx | 35 +++++- .../src/widgets/relations/RelationsPanel.tsx | 29 ++++- 3 files changed, 157 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/styles.css b/apps/web/src/app/styles.css index de52f4f..b5b2d94 100644 --- a/apps/web/src/app/styles.css +++ b/apps/web/src/app/styles.css @@ -121,6 +121,30 @@ body { margin-bottom: 6px; } +.sb-t-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.relation-sort { + display: flex; + align-items: center; + gap: 6px; + font-size: 8px; + color: var(--muted); + white-space: nowrap; +} + +.relation-sort__option { + display: inline-flex; + align-items: center; + gap: 3px; + cursor: pointer; + user-select: none; +} + /* Type grid */ .tg { display: grid; @@ -215,6 +239,21 @@ body { background: var(--card); } +.vi.sel { + background: rgba(14, 234, 255, 0.16); + border-color: rgba(14, 234, 255, 0.55); +} + +.vi.hl { + background: rgba(245, 158, 11, 0.16); + border: 1px solid rgba(245, 158, 11, 0.4); +} + +.vi.sel.hl { + background: linear-gradient(90deg, rgba(14, 234, 255, 0.16), rgba(245, 158, 11, 0.16)); + border-color: rgba(14, 234, 255, 0.7); +} + .vi .dot { width: 7px; height: 7px; @@ -488,6 +527,13 @@ body { margin-bottom: 4px; } +.fleet-card.hl, +.fleet-card:hover { + border-color: rgba(245, 158, 11, 0.75); + background: rgba(251, 191, 36, 0.09); + box-shadow: 0 0 0 1px rgba(245, 158, 11, 0.25) inset; +} + .fleet-owner { font-size: 10px; font-weight: 700; @@ -495,6 +541,10 @@ body { margin-bottom: 4px; } +.fleet-owner.hl { + color: rgba(245, 158, 11, 1); +} + .fleet-vessel { display: flex; align-items: center; @@ -503,6 +553,14 @@ body { padding: 1px 0; } +.fleet-vessel.hl { + color: rgba(245, 158, 11, 1); +} + +.fleet-dot.hl { + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.45); +} + /* Toggles */ .tog { display: flex; @@ -650,6 +708,50 @@ body { animation: map-loader-fill 1.2s ease-in-out infinite; } +.maplibre-tooltip-popup .maplibregl-popup-content { + color: #f8fafc !important; + background: rgba(2, 6, 23, 0.98) !important; + border: 1px solid rgba(148, 163, 184, 0.4) !important; + box-shadow: 0 8px 26px rgba(2, 6, 23, 0.55) !important; + border-radius: 8px !important; + font-size: 11px !important; + line-height: 1.35 !important; + padding: 7px 9px !important; + color: #f8fafc !important; + min-width: 180px; +} + +.maplibre-tooltip-popup .maplibregl-popup-tip { + border-top-color: rgba(2, 6, 23, 0.97) !important; +} + +.maplibre-tooltip-popup__content { + color: #f8fafc; + font-family: Pretendard, Inter, ui-sans-serif, -apple-system, Segoe UI, sans-serif; + font-size: 11px; + line-height: 1.35; +} + +.maplibre-tooltip-popup__content div, +.maplibre-tooltip-popup__content span, +.maplibre-tooltip-popup__content p { + color: inherit; +} + +.maplibre-tooltip-popup__content div { + word-break: break-word; +} + +.maplibre-tooltip-popup .maplibregl-popup-content div, +.maplibre-tooltip-popup .maplibregl-popup-content span, +.maplibre-tooltip-popup .maplibregl-popup-content p { + color: inherit !important; +} + +.maplibre-tooltip-popup .maplibregl-popup-close-button { + color: #94a3b8 !important; +} + @keyframes map-loader-spin { to { transform: rotate(360deg); diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 61630b2..0ae6c5c 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -48,6 +48,7 @@ function fmtLocal(iso: string | null) { } type Bbox = [number, number, number, number]; // [lonMin, latMin, lonMax, latMax] +type FleetRelationSortMode = "count" | "range"; function inBbox(lon: number, lat: number, bbox: Bbox) { const [lonMin, latMin, lonMax, latMax] = bbox; @@ -114,6 +115,7 @@ export function DashboardPage() { zones: true, fleetCircles: true, }); + const [fleetRelationSortMode, setFleetRelationSortMode] = useState("count"); const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined); @@ -346,11 +348,33 @@ export function DashboardPage() {
-
- 선단 연관관계{" "} - - {selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : "(선박 클릭 시 표시)"} - +
+
+ 선단 연관관계{" "} + + {selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : "(선박 클릭 시 표시)"} + +
+
+ + +
(prev.length === 0 ? prev : [])); }} + fleetSortMode={fleetRelationSortMode} hoveredFleetOwnerKey={hoveredFleetOwnerKey} hoveredFleetMmsiSet={hoveredFleetMmsiSet} onContextMenuFleet={handleFleetContextMenu} diff --git a/apps/web/src/widgets/relations/RelationsPanel.tsx b/apps/web/src/widgets/relations/RelationsPanel.tsx index 1572940..3b38527 100644 --- a/apps/web/src/widgets/relations/RelationsPanel.tsx +++ b/apps/web/src/widgets/relations/RelationsPanel.tsx @@ -3,6 +3,8 @@ import { VESSEL_TYPES } from "../../entities/vessel/model/meta"; import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types"; import { haversineNm } from "../../shared/lib/geo/haversineNm"; +type FleetSortMode = "count" | "range"; + type Props = { selectedVessel: DerivedLegacyVessel | null; vessels: DerivedLegacyVessel[]; @@ -18,6 +20,7 @@ type Props = { hoveredFleetOwnerKey?: string | null; hoveredFleetMmsiSet?: number[]; onContextMenuFleet?: (ownerKey: string, mmsis: number[]) => void; + fleetSortMode?: FleetSortMode; }; export function RelationsPanel({ @@ -35,6 +38,7 @@ export function RelationsPanel({ hoveredFleetOwnerKey, hoveredFleetMmsiSet, onContextMenuFleet, + fleetSortMode = "count", }: Props) { const handlePrimaryAction = (e: MouseEvent, mmsi: number) => { if (e.shiftKey || e.ctrlKey || e.metaKey) { @@ -242,10 +246,27 @@ export function RelationsPanel({ else group.set(v.ownerKey, [v]); } - const topFleets = Array.from(group.entries()) - .map(([ownerKey, vs]) => ({ ownerKey, vs })) - .filter((x) => x.vs.length >= 3) - .sort((a, b) => b.vs.length - a.vs.length); + const topFleets = useMemo(() => { + const toFleetMeta = Array.from(group.entries()) + .map(([ownerKey, vs]) => { + const lon = vs.reduce((sum, v) => sum + v.lon, 0) / vs.length; + const lat = vs.reduce((sum, v) => sum + v.lat, 0) / vs.length; + const radiusNm = vs.reduce((max, v) => { + const d = haversineNm(lat, lon, v.lat, v.lon); + return Math.max(max, d); + }, 0); + + return { ownerKey, vs, radiusNm }; + }) + .filter((x) => x.vs.length >= 3); + + return toFleetMeta.sort((a, b) => { + if (fleetSortMode === "range") { + return b.radiusNm - a.radiusNm || b.vs.length - a.vs.length; + } + return b.vs.length - a.vs.length || b.radiusNm - a.radiusNm; + }); + }, [fleetSortMode, group]); if (topFleets.length === 0) { return
(표시 중인 선단(3척 이상) 없음)
; From b883c4113b4ffa38aa63f1b94e54e32cf70babbe Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 15:27:57 +0900 Subject: [PATCH 21/58] fix: guard map style and ship layer ids during rendering --- apps/web/src/widgets/map3d/Map3D.tsx | 33 +++++++++++++++++++--------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index e194216..c082500 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -189,6 +189,12 @@ const MAP_DEFAULT_SHIP_RGB: [number, number, number] = [100, 116, 139]; const clampNumber = (value: number, minValue: number, maxValue: number) => Math.max(minValue, Math.min(maxValue, value)); +function getLayerId(value: unknown): string | null { + if (!value || typeof value !== "object") return null; + const candidate = (value as { id?: unknown }).id; + return typeof candidate === "string" ? candidate : null; +} + function normalizeAngleDeg(value: number, offset = 0): number { const v = value + offset; return ((v % 360) + 360) % 360; @@ -676,7 +682,11 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str } as unknown as LayerSpecification; // Insert before the first symbol layer (keep labels on top), otherwise append. - const layers = style.layers as LayerSpecification[]; + const rawLayers = Array.isArray(style.layers) ? style.layers : []; + const layers = rawLayers.filter((layer): layer is LayerSpecification => { + if (!layer || typeof layer !== "object") return false; + return typeof (layer as { id?: unknown }).id === "string"; + }); const symbolIndex = layers.findIndex((l) => l.type === "symbol"); const insertAt = symbolIndex >= 0 ? symbolIndex : layers.length; @@ -1077,7 +1087,8 @@ export function Map3D({ projectionRef.current = projection; }, [projection]); - const removeLayerIfExists = useCallback((map: maplibregl.Map, layerId: string) => { + const removeLayerIfExists = useCallback((map: maplibregl.Map, layerId: string | null | undefined) => { + if (!layerId) return; try { if (map.getLayer(layerId)) { map.removeLayer(layerId); @@ -1218,9 +1229,10 @@ export function Map3D({ onMapStyleReady(map, () => { applyProjection(); // Globe deck layer lives inside the style and must be re-added after any style swap. - if (projectionRef.current === "globe" && globeDeckLayerRef.current && !map!.getLayer(globeDeckLayerRef.current.id)) { + const deckLayer = globeDeckLayerRef.current; + if (projectionRef.current === "globe" && deckLayer && !map!.getLayer(deckLayer.id)) { try { - map!.addLayer(globeDeckLayerRef.current); + map!.addLayer(deckLayer); } catch { // ignore } @@ -1431,13 +1443,14 @@ export function Map3D({ } const layer = globeDeckLayerRef.current; - if (layer && map.isStyleLoaded() && !map.getLayer(layer.id)) { + const layerId = layer?.id; + if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) { try { map.addLayer(layer); } catch { // ignore } - if (!map.getLayer(layer.id) && !cancelled && retries < maxRetries) { + if (!map.getLayer(layerId) && !cancelled && retries < maxRetries) { retries += 1; window.requestAnimationFrame(() => syncProjectionAndDeck()); return; @@ -1554,7 +1567,7 @@ export function Map3D({ const style = map.getStyle(); const styleLayers = style && Array.isArray(style.layers) ? style.layers : []; for (const layer of styleLayers) { - const id = String(layer.id ?? ""); + const id = getLayerId(layer); if (!id) continue; const sourceLayer = String((layer as Record)["source-layer"] ?? "").toLowerCase(); const source = String((layer as { source?: unknown }).source ?? "").toLowerCase(); @@ -1933,7 +1946,7 @@ export function Map3D({ console.warn("Ship icon image setup failed:", e); } - const globeShipData = targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon)); + const globeShipData = shipData; const geojson: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: globeShipData.map((t) => { @@ -1956,7 +1969,7 @@ export function Map3D({ const iconSize14 = clampNumber(0.72 * sizeScale * selectedScale, 0.45, 2.1); return { type: "Feature", - id: t.mmsi, + ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), geometry: { type: "Point", coordinates: [t.lon, t.lat] }, properties: { mmsi: t.mmsi, @@ -2890,7 +2903,7 @@ export function Map3D({ }, [projection, overlays.pairRange, pairLinks, mapSyncEpoch, hoveredShipSignature, hoveredPairSignature, hoveredFleetSignature, isHighlightedPair]); const shipData = useMemo(() => { - return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon)); + return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi)); }, [targets]); const shipByMmsi = useMemo(() => { From ccf3f2361fe8871f38975eb88f67ab5c1ae85655 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 15:30:09 +0900 Subject: [PATCH 22/58] fix: guard deck layer arrays against null ids --- apps/web/src/widgets/map3d/Map3D.tsx | 67 ++++++++++++++++++- .../widgets/map3d/MaplibreDeckCustomLayer.ts | 30 ++++++++- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index c082500..f37f1e5 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -195,6 +195,33 @@ function getLayerId(value: unknown): string | null { return typeof candidate === "string" ? candidate : null; } +function sanitizeDeckLayerList(value: unknown): unknown[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const out: unknown[] = []; + let dropped = 0; + + for (const layer of value) { + const layerId = getLayerId(layer); + if (!layerId) { + dropped += 1; + continue; + } + if (seen.has(layerId)) { + dropped += 1; + continue; + } + seen.add(layerId); + out.push(layer); + } + + if (dropped > 0 && import.meta.env.DEV) { + console.warn(`Sanitized deck layer list: dropped ${dropped} invalid/duplicate entries`); + } + + return out; +} + function normalizeAngleDeg(value: number, offset = 0): number { const v = value + offset; return ((v % 360) + 360) % 360; @@ -3674,8 +3701,10 @@ export function Map3D({ ); } + const normalizedLayers = sanitizeDeckLayerList(layers); + const deckProps = { - layers, + layers: normalizedLayers, getTooltip: projection === "globe" ? undefined @@ -3771,8 +3800,40 @@ export function Map3D({ }, } as const; - if (projection === "globe") globeDeckLayerRef.current?.setProps(deckProps); - else overlayRef.current?.setProps(deckProps as unknown as never); + const safeDeckProps = { ...deckProps, layers: normalizedLayers }; + const fallbackDeckProps = { ...safeDeckProps, layers: [] as unknown[] }; + const applyDeckProps = () => { + if (projection === "globe") { + const target = globeDeckLayerRef.current; + if (!target) return; + try { + target.setProps(safeDeckProps as never); + } catch (e) { + console.error("Failed to apply deck props on globe overlay. Falling back to empty deck layer set.", e); + try { + target.setProps(fallbackDeckProps as never); + } catch { + // Ignore secondary failure; rendering will recover on next update. + } + } + return; + } + + const target = overlayRef.current; + if (!target) return; + try { + target.setProps(safeDeckProps as unknown as never); + } catch (e) { + console.error("Failed to apply deck props on mercator overlay. Falling back to empty deck layer set.", e); + try { + target.setProps(fallbackDeckProps as unknown as never); + } catch { + // Ignore secondary failure. + } + } + }; + + applyDeckProps(); }, [ projection, shipData, diff --git a/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts b/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts index f3bc99b..4998c26 100644 --- a/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts +++ b/apps/web/src/widgets/map3d/MaplibreDeckCustomLayer.ts @@ -24,6 +24,7 @@ class MatrixView extends View { } const IDENTITY_4x4: number[] = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; +type DeckLayerList = NonNullable["layers"]>; function readMat4(m: ArrayLike): number[] { const out = new Array(16); @@ -40,6 +41,25 @@ function mat4Changed(a: number[] | undefined, b: ArrayLike): boolean { return false; } +function getDeckLayerId(value: unknown): string | null { + if (!value || typeof value !== "object") return null; + const candidate = (value as { id?: unknown }).id; + return typeof candidate === "string" ? candidate : null; +} + +function sanitizeDeckLayers(value: unknown): DeckLayerList { + if (!Array.isArray(value)) return [] as DeckLayerList; + const out: DeckLayerList = []; + const seen = new Set(); + for (const item of value) { + const layerId = getDeckLayerId(item); + if (!layerId || seen.has(layerId)) continue; + seen.add(layerId); + out.push(item as DeckLayerList[number]); + } + return out; +} + export class MaplibreDeckCustomLayer implements maplibregl.CustomLayerInterface { id: string; type = "custom" as const; @@ -67,7 +87,8 @@ export class MaplibreDeckCustomLayer implements maplibregl.CustomLayerInterface } setProps(next: Partial>) { - this._deckProps = { ...this._deckProps, ...next }; + const normalized = next.layers ? { ...next, layers: sanitizeDeckLayers(next.layers) } : next; + this._deckProps = { ...this._deckProps, ...normalized }; if (this._deck) this._deck.setProps(this._deckProps as DeckProps); this._map?.triggerRepaint(); } @@ -80,17 +101,20 @@ export class MaplibreDeckCustomLayer implements maplibregl.CustomLayerInterface // Re-attached after a style change; keep the existing Deck instance so we don't reuse // finalized Layer objects (Deck does not allow that). this._lastMvp = undefined; - this._deck.setProps({ + const nextDeckProps = { ...this._deckProps, + layers: sanitizeDeckLayers(this._deckProps.layers), canvas: map.getCanvas(), // Ensure any pending redraw requests trigger a map repaint again. _customRender: () => map.triggerRepaint(), - } as DeckProps); + }; + this._deck.setProps(nextDeckProps as DeckProps); return; } const deck = new Deck({ ...this._deckProps, + layers: sanitizeDeckLayers(this._deckProps.layers), // Share MapLibre's WebGL context + canvas (single context). gl: gl as WebGL2RenderingContext, canvas: map.getCanvas(), From 84a3ec237406081a5fd1c78f05bec0eabf50ecb3 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 15:33:42 +0900 Subject: [PATCH 23/58] fix: stabilize hook order in RelationsPanel --- .../src/widgets/relations/RelationsPanel.tsx | 59 +++++++++---------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/apps/web/src/widgets/relations/RelationsPanel.tsx b/apps/web/src/widgets/relations/RelationsPanel.tsx index 3b38527..063328e 100644 --- a/apps/web/src/widgets/relations/RelationsPanel.tsx +++ b/apps/web/src/widgets/relations/RelationsPanel.tsx @@ -62,6 +62,35 @@ export function RelationsPanel({ const isVesselHighlight = (mmsi: number, ownerKey: string | null) => hoveredFleetMmsis.has(mmsi) || isFleetHighlightByOwner(ownerKey); + const topFleets = useMemo(() => { + const group = new Map(); + for (const v of fleetVessels) { + if (!v.ownerKey) continue; + const list = group.get(v.ownerKey); + if (list) list.push(v); + else group.set(v.ownerKey, [v]); + } + + return Array.from(group.entries()) + .map(([ownerKey, vs]) => { + const lon = vs.reduce((sum, v) => sum + v.lon, 0) / vs.length; + const lat = vs.reduce((sum, v) => sum + v.lat, 0) / vs.length; + const radiusNm = vs.reduce((max, v) => { + const d = haversineNm(lat, lon, v.lat, v.lon); + return Math.max(max, d); + }, 0); + + return { ownerKey, vs, radiusNm }; + }) + .filter((x) => x.vs.length >= 3) + .sort((a, b) => { + if (fleetSortMode === "range") { + return b.radiusNm - a.radiusNm || b.vs.length - a.vs.length; + } + return b.vs.length - a.vs.length || b.radiusNm - a.radiusNm; + }); + }, [fleetVessels, fleetSortMode]); + if (selectedVessel) { const v = selectedVessel; const meta = VESSEL_TYPES[v.shipCode]; @@ -238,36 +267,6 @@ export function RelationsPanel({ return
(현재 지도에 표시중인 대상 선박이 없습니다)
; } - const group = new Map(); - for (const v of fleetVessels) { - if (!v.ownerKey) continue; - const list = group.get(v.ownerKey); - if (list) list.push(v); - else group.set(v.ownerKey, [v]); - } - - const topFleets = useMemo(() => { - const toFleetMeta = Array.from(group.entries()) - .map(([ownerKey, vs]) => { - const lon = vs.reduce((sum, v) => sum + v.lon, 0) / vs.length; - const lat = vs.reduce((sum, v) => sum + v.lat, 0) / vs.length; - const radiusNm = vs.reduce((max, v) => { - const d = haversineNm(lat, lon, v.lat, v.lon); - return Math.max(max, d); - }, 0); - - return { ownerKey, vs, radiusNm }; - }) - .filter((x) => x.vs.length >= 3); - - return toFleetMeta.sort((a, b) => { - if (fleetSortMode === "range") { - return b.radiusNm - a.radiusNm || b.vs.length - a.vs.length; - } - return b.vs.length - a.vs.length || b.radiusNm - a.radiusNm; - }); - }, [fleetSortMode, group]); - if (topFleets.length === 0) { return
(표시 중인 선단(3척 이상) 없음)
; } From bb5fd793d8983ea66c771d11c6abdb6b6717bed0 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 15:36:29 +0900 Subject: [PATCH 24/58] fix: resolve globe ship circle-radius expression and ensure ship layers top --- apps/web/src/widgets/map3d/Map3D.tsx | 86 ++++++++++++---------------- 1 file changed, 38 insertions(+), 48 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index f37f1e5..ad62098 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -478,6 +478,29 @@ const GLOBE_OVERLAY_PARAMS = { depthWriteEnabled: false, } as const; +function makeGlobeCircleRadiusExpr() { + const base3 = 4; + const base7 = 6; + const base10 = 8; + const base14 = 11; + + return [ + "interpolate", + ["linear"], + ["zoom"], + 3, + ["case", ["==", ["get", "selected"], 1], 4.6, ["==", ["get", "highlighted"], 1], 4.2, base3], + 7, + ["case", ["==", ["get", "selected"], 1], 6.8, ["==", ["get", "highlighted"], 1], 6.2, base7], + 10, + ["case", ["==", ["get", "selected"], 1], 9.0, ["==", ["get", "highlighted"], 1], 8.2, base10], + 14, + ["case", ["==", ["get", "selected"], 1], 11.8, ["==", ["get", "highlighted"], 1], 10.8, base14], + ]; +} + +const GLOBE_SHIP_CIRCLE_RADIUS_EXPR = makeGlobeCircleRadiusExpr() as never; + function getMapTilerKey(): string | null { const k = import.meta.env.VITE_MAPTILER_KEY; if (typeof k !== "string") return null; @@ -2032,54 +2055,20 @@ export function Map3D({ } const visibility = settings.showShips ? "visible" : "none"; - const baseCircleRadius: unknown[] = [ - "interpolate", - ["linear"], - ["zoom"], - 3, - 4, - 7, - 6, - 10, - 8, - 14, - 11, - ] as const; - const highlightedCircleRadius: unknown[] = [ - "case", - ["==", ["get", "selected"], 1], - [ - "interpolate", - ["linear"], - ["zoom"], - 3, - 4.6, - 7, - 6.8, - 10, - 9.0, - 14, - 11.8, - ] as unknown[], - ["==", ["get", "highlighted"], 1], - [ - "interpolate", - ["linear"], - ["zoom"], - 3, - 4.2, - 7, - 6.2, - 10, - 8.2, - 14, - 10.8, - ] as unknown[], - baseCircleRadius, - ]; // Put ships at the top so they're always visible (especially important under globe projection). const before = undefined; + const bringShipLayersToFront = () => { + const ids = [haloId, outlineId, symbolId]; + for (const id of ids) { + if (!map.getLayer(id)) continue; + try { + map.moveLayer(id); + } catch { + // ignore + } + } + }; if (!map.getLayer(haloId)) { try { @@ -2100,7 +2089,7 @@ export function Map3D({ ] as never, }, paint: { - "circle-radius": highlightedCircleRadius as never, + "circle-radius": GLOBE_SHIP_CIRCLE_RADIUS_EXPR, "circle-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, "circle-opacity": [ "case", @@ -2129,7 +2118,7 @@ export function Map3D({ 0.34, 0.16, ] as never); - map.setPaintProperty(haloId, "circle-radius", highlightedCircleRadius as never); + map.setPaintProperty(haloId, "circle-radius", GLOBE_SHIP_CIRCLE_RADIUS_EXPR); } catch { // ignore } @@ -2143,7 +2132,7 @@ export function Map3D({ type: "circle", source: srcId, paint: { - "circle-radius": highlightedCircleRadius as never, + "circle-radius": GLOBE_SHIP_CIRCLE_RADIUS_EXPR, "circle-color": "rgba(0,0,0,0)", "circle-stroke-color": [ "case", @@ -2348,6 +2337,7 @@ export function Map3D({ } // Selection and highlight are now source-data driven. + bringShipLayersToFront(); kickRepaint(map); }; From e504dbebca5f017f7c67fc8acdc62940ba5051b9 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 15:43:36 +0900 Subject: [PATCH 25/58] Fix globe zones line-width expression and enforce map layer ordering --- apps/web/src/widgets/map3d/Map3D.tsx | 174 ++++++++++++++++++++++----- 1 file changed, 142 insertions(+), 32 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index ad62098..2c1a343 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -83,6 +83,44 @@ function makeSetSignature(values: Set) { return Array.from(values).sort((a, b) => a - b).join(","); } +function toTextValue(value: unknown): string { + if (value == null) return ""; + return String(value).trim(); +} + +function getZoneIdFromProps(props: Record | null | undefined): string { + const safeProps = props || {}; + const candidates = [ + "zoneId", + "zone_id", + "zoneIdNo", + "zoneKey", + "zoneCode", + "ZONE_ID", + "ZONECODE", + "id", + ]; + + for (const key of candidates) { + const value = toTextValue(safeProps[key]); + if (value) return value; + } + + return ""; +} + +function getZoneDisplayNameFromProps(props: Record | null | undefined): string { + const safeProps = props || {}; + const nameCandidates = ["zoneName", "zoneLabel", "NAME", "name", "ZONE_NM", "label"]; + for (const key of nameCandidates) { + const name = toTextValue(safeProps[key]); + if (name) return name; + } + const zoneId = getZoneIdFromProps(safeProps); + if (!zoneId) return "수역"; + return ZONE_META[(zoneId as ZoneId)]?.name || `수역 ${zoneId}`; +} + const SHIP_ICON_MAPPING = { ship: { x: 0, @@ -933,6 +971,37 @@ export function Map3D({ if (hoveredDeckFleetOwnerKey) keys.add(hoveredDeckFleetOwnerKey); return keys; }, [hoveredFleetOwnerKey, hoveredDeckFleetOwnerKey]); + + const reorderGlobeFeatureLayers = useCallback(() => { + const map = mapRef.current; + if (!map || projectionRef.current !== "globe") return; + if (projectionBusyRef.current) return; + + const ordering = [ + "pair-lines-ml", + "fc-lines-ml", + "pair-range-ml", + "fleet-circles-ml-fill", + "fleet-circles-ml", + "zones-fill", + "zones-line", + "zones-label", + "ships-globe-halo", + "ships-globe-outline", + "ships-globe", + ]; + + for (const layerId of ordering) { + try { + if (map.getLayer(layerId)) map.moveLayer(layerId); + } catch { + // ignore + } + } + + kickRepaint(map); + }, []); + const effectiveHoveredPairMmsiSet = useMemo( () => mergeNumberSets(hoveredPairMmsiSetRef, hoveredDeckPairMmsiSetRef), [hoveredPairMmsiSetRef, hoveredDeckPairMmsiSetRef], @@ -1515,6 +1584,7 @@ export function Map3D({ // MapLibre may not schedule a frame immediately after projection swaps if the map is idle. // Kick a few repaints so overlay sources (ships/zones) appear instantly. + reorderGlobeFeatureLayers(); kickRepaint(map); try { map.resize(); @@ -1545,7 +1615,7 @@ export function Map3D({ if (settleCleanup) settleCleanup(); if (isTransition) setProjectionLoading(false); }; - }, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, removeLayerIfExists, setProjectionLoading]); + }, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, removeLayerIfExists, reorderGlobeFeatureLayers, setProjectionLoading]); // Base map toggle useEffect(() => { @@ -1741,6 +1811,19 @@ export function Map3D({ hoveredZoneId !== null ? (["==", ["to-string", ["coalesce", ["get", "zoneId"], ""]], hoveredZoneId] as unknown[]) : false; + const zoneLineWidthExpr = hoveredZoneId + ? ([ + "interpolate", + ["linear"], + ["zoom"], + 4, + ["case", zoneMatchExpr, 1.6, 0.8], + 10, + ["case", zoneMatchExpr, 2.0, 1.4], + 14, + ["case", zoneMatchExpr, 2.8, 2.1], + ] as unknown as never) + : (["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1] as never); if (map.getLayer(fillId)) { try { @@ -1772,18 +1855,7 @@ export function Map3D({ // ignore } try { - map.setPaintProperty( - lineId, - "line-width", - hoveredZoneId - ? ([ - "case", - zoneMatchExpr, - ["interpolate", ["linear"], ["zoom"], 4, 1.6, 10, 2.0, 14, 2.8], - ["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1], - ] as never) - : (["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1] as never), - ); + map.setPaintProperty(lineId, "line-width", zoneLineWidthExpr); } catch { // ignore } @@ -1825,14 +1897,7 @@ export function Map3D({ "line-opacity": hoveredZoneId ? (["case", zoneMatchExpr, 1, 0.85] as never) : 0.85, - "line-width": hoveredZoneId - ? ([ - "case", - zoneMatchExpr, - ["interpolate", ["linear"], ["zoom"], 4, 1.6, 10, 2.0, 14, 2.8], - ["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1], - ] as unknown as number[]) - : (["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1] as never), + "line-width": zoneLineWidthExpr, }, layout: { visibility }, } as unknown as LayerSpecification, @@ -1870,6 +1935,7 @@ export function Map3D({ } catch (e) { console.warn("Zones layer setup failed:", e); } finally { + reorderGlobeFeatureLayers(); kickRepaint(map); } }; @@ -1878,7 +1944,7 @@ export function Map3D({ return () => { stop(); }; - }, [zones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch]); + }, [zones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]); // Ships in globe mode: render with MapLibre symbol layers so icons can be aligned to the globe surface. // Deck IconLayer billboards or uses a fixed plane, which looks like ships are pointing into the sky/ground on globe. @@ -1905,6 +1971,7 @@ export function Map3D({ } catch { // ignore } + reorderGlobeFeatureLayers(); kickRepaint(map); }; @@ -2338,6 +2405,7 @@ export function Map3D({ // Selection and highlight are now source-data driven. bringShipLayersToFront(); + reorderGlobeFeatureLayers(); kickRepaint(map); }; @@ -2345,7 +2413,19 @@ export function Map3D({ return () => { stop(); }; - }, [projection, settings.showShips, targets, legacyHits, selectedMmsi, hoveredMmsiSetRef, hoveredFleetMmsiSetRef, hoveredPairMmsiSetRef, isHighlightedMmsi, mapSyncEpoch]); + }, [ + projection, + settings.showShips, + targets, + legacyHits, + selectedMmsi, + hoveredMmsiSetRef, + hoveredFleetMmsiSetRef, + hoveredPairMmsiSetRef, + isHighlightedMmsi, + mapSyncEpoch, + reorderGlobeFeatureLayers, + ]); // Globe ship click selection (MapLibre-native ships layer) useEffect(() => { @@ -2516,6 +2596,7 @@ export function Map3D({ } } + reorderGlobeFeatureLayers(); kickRepaint(map); }; @@ -2525,7 +2606,16 @@ export function Map3D({ stop(); remove(); }; - }, [projection, overlays.pairLines, pairLinks, mapSyncEpoch, hoveredShipSignature, hoveredPairSignature, isHighlightedPair]); + }, [ + projection, + overlays.pairLines, + pairLinks, + mapSyncEpoch, + hoveredShipSignature, + hoveredPairSignature, + isHighlightedPair, + reorderGlobeFeatureLayers, + ]); useEffect(() => { const map = mapRef.current; @@ -2620,6 +2710,7 @@ export function Map3D({ } } + reorderGlobeFeatureLayers(); kickRepaint(map); }; @@ -2629,7 +2720,15 @@ export function Map3D({ stop(); remove(); }; - }, [projection, overlays.fcLines, fcLinks, mapSyncEpoch, hoveredShipSignature, isHighlightedMmsi]); + }, [ + projection, + overlays.fcLines, + fcLinks, + mapSyncEpoch, + hoveredShipSignature, + isHighlightedMmsi, + reorderGlobeFeatureLayers, + ]); useEffect(() => { const map = mapRef.current; @@ -2783,6 +2882,7 @@ export function Map3D({ } } + reorderGlobeFeatureLayers(); kickRepaint(map); }; @@ -2802,6 +2902,7 @@ export function Map3D({ hoveredFleetOwnerKey, isHighlightedFleet, isHighlightedMmsi, + reorderGlobeFeatureLayers, ]); useEffect(() => { @@ -2917,7 +3018,17 @@ export function Map3D({ stop(); remove(); }; - }, [projection, overlays.pairRange, pairLinks, mapSyncEpoch, hoveredShipSignature, hoveredPairSignature, hoveredFleetSignature, isHighlightedPair]); + }, [ + projection, + overlays.pairRange, + pairLinks, + mapSyncEpoch, + hoveredShipSignature, + hoveredPairSignature, + hoveredFleetSignature, + isHighlightedPair, + reorderGlobeFeatureLayers, + ]); const shipData = useMemo(() => { return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi)); @@ -3019,10 +3130,9 @@ export function Map3D({ }); } - const zoneLabel = String((props.zoneLabel ?? props.zoneName ?? "").toString()); + const zoneLabel = getZoneDisplayNameFromProps(props); if (zoneLabel) { - const zoneName = zoneLabel || ZONE_META[(String(props.zoneId ?? "") as ZoneId)]?.name || "수역"; - return { html: `
${zoneName}
` }; + return { html: `
${zoneLabel}
` }; } return null; @@ -3174,7 +3284,7 @@ export function Map3D({ clearMapFleetHoverState(); setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); - const zoneId = String((props.zoneId ?? "").toString()); + const zoneId = getZoneIdFromProps(props); setHoveredZoneId(zoneId || null); } else { resetGlobeHoverStates(); @@ -3761,8 +3871,8 @@ export function Map3D({ }); } - const p = obj.properties as { zoneName?: string; zoneLabel?: string } | undefined; - const label = p?.zoneName ?? p?.zoneLabel; + const p = obj.properties as Record | undefined; + const label = getZoneDisplayNameFromProps(p); if (label) return { text: label }; return null; }, From 70dc651230720ac03199b3b2a0ca6a5fb43f3c3d Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 15:48:49 +0900 Subject: [PATCH 26/58] Keep globe overlays stable and reuse globe layer IDs --- apps/web/src/widgets/map3d/Map3D.tsx | 133 +++++++++++++++++---------- 1 file changed, 83 insertions(+), 50 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 2c1a343..f1f431d 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -121,6 +121,26 @@ function getZoneDisplayNameFromProps(props: Record | null | und return ZONE_META[(zoneId as ZoneId)]?.name || `수역 ${zoneId}`; } +function makeOrderedPairKey(a: number, b: number) { + const left = Math.trunc(Math.min(a, b)); + const right = Math.trunc(Math.max(a, b)); + return `${left}-${right}`; +} + +function makePairLinkFeatureId(a: number, b: number, suffix?: string) { + const pair = makeOrderedPairKey(a, b); + return suffix ? `pair-${pair}-${suffix}` : `pair-${pair}`; +} + +function makeFcSegmentFeatureId(a: number, b: number, segmentIndex: number) { + const pair = makeOrderedPairKey(a, b); + return `fc-${pair}-${segmentIndex}`; +} + +function makeFleetCircleFeatureId(ownerKey: string) { + return `fleet-${ownerKey}`; +} + const SHIP_ICON_MAPPING = { ship: { x: 0, @@ -972,20 +992,21 @@ export function Map3D({ return keys; }, [hoveredFleetOwnerKey, hoveredDeckFleetOwnerKey]); - const reorderGlobeFeatureLayers = useCallback(() => { + const reorderGlobeFeatureLayers = useCallback((options?: { shipTop?: boolean }) => { const map = mapRef.current; if (!map || projectionRef.current !== "globe") return; if (projectionBusyRef.current) return; + const shipTop = options?.shipTop === true; const ordering = [ + "zones-fill", + "zones-line", + "zones-label", "pair-lines-ml", "fc-lines-ml", "pair-range-ml", "fleet-circles-ml-fill", "fleet-circles-ml", - "zones-fill", - "zones-line", - "zones-label", "ships-globe-halo", "ships-globe-outline", "ships-globe", @@ -999,6 +1020,17 @@ export function Map3D({ } } + if (!shipTop) return; + + const shipOrdering = ["ships-globe-halo", "ships-globe-outline", "ships-globe"]; + for (const layerId of shipOrdering) { + try { + if (map.getLayer(layerId)) map.moveLayer(layerId); + } catch { + // ignore + } + } + kickRepaint(map); }, []); @@ -2405,7 +2437,7 @@ export function Map3D({ // Selection and highlight are now source-data driven. bringShipLayersToFront(); - reorderGlobeFeatureLayers(); + reorderGlobeFeatureLayers({ shipTop: true }); kickRepaint(map); }; @@ -2514,12 +2546,7 @@ export function Map3D({ const remove = () => { try { - if (map.getLayer(layerId)) map.removeLayer(layerId); - } catch { - // ignore - } - try { - if (map.getSource(srcId)) map.removeSource(srcId); + if (map.getLayer(layerId)) map.setLayoutProperty(layerId, "visibility", "none"); } catch { // ignore } @@ -2535,9 +2562,9 @@ export function Map3D({ const fc: GeoJSON.FeatureCollection = { type: "FeatureCollection", - features: (pairLinks || []).map((p, idx) => ({ + features: (pairLinks || []).map((p) => ({ type: "Feature", - id: `${p.aMmsi}-${p.bMmsi}-${idx}`, + id: makePairLinkFeatureId(p.aMmsi, p.bMmsi), geometry: { type: "LineString", coordinates: [p.from, p.to] }, properties: { type: "pair", @@ -2588,12 +2615,18 @@ export function Map3D({ ] as never, "line-opacity": 0.9, }, - } as unknown as LayerSpecification, + } as unknown as LayerSpecification, before, ); } catch (e) { console.warn("Pair lines layer add failed:", e); } + } else { + try { + map.setLayoutProperty(layerId, "visibility", "visible"); + } catch { + // ignore + } } reorderGlobeFeatureLayers(); @@ -2604,7 +2637,6 @@ export function Map3D({ ensure(); return () => { stop(); - remove(); }; }, [ projection, @@ -2626,12 +2658,7 @@ export function Map3D({ const remove = () => { try { - if (map.getLayer(layerId)) map.removeLayer(layerId); - } catch { - // ignore - } - try { - if (map.getSource(srcId)) map.removeSource(srcId); + if (map.getLayer(layerId)) map.setLayoutProperty(layerId, "visibility", "none"); } catch { // ignore } @@ -2658,7 +2685,7 @@ export function Map3D({ type: "FeatureCollection", features: segs.map((s, idx) => ({ type: "Feature", - id: `fc-${idx}`, + id: makeFcSegmentFeatureId(s.fromMmsi ?? -1, s.toMmsi ?? -1, idx), geometry: { type: "LineString", coordinates: [s.from, s.to] }, properties: { type: "fc", @@ -2702,12 +2729,18 @@ export function Map3D({ "line-width": ["case", ["==", ["get", "highlighted"], 1], 2.0, 1.3] as never, "line-opacity": 0.9, }, - } as unknown as LayerSpecification, + } as unknown as LayerSpecification, before, ); } catch (e) { console.warn("FC lines layer add failed:", e); } + } else { + try { + map.setLayoutProperty(layerId, "visibility", "visible"); + } catch { + // ignore + } } reorderGlobeFeatureLayers(); @@ -2718,7 +2751,6 @@ export function Map3D({ ensure(); return () => { stop(); - remove(); }; }, [ projection, @@ -2741,22 +2773,12 @@ export function Map3D({ const remove = () => { try { - if (map.getLayer(fillLayerId)) map.removeLayer(fillLayerId); + if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, "visibility", "none"); } catch { // ignore } try { - if (map.getLayer(layerId)) map.removeLayer(layerId); - } catch { - // ignore - } - try { - if (map.getSource(srcId)) map.removeSource(srcId); - } catch { - // ignore - } - try { - if (map.getSource(fillSrcId)) map.removeSource(fillSrcId); + if (map.getLayer(layerId)) map.setLayoutProperty(layerId, "visibility", "none"); } catch { // ignore } @@ -2772,11 +2794,11 @@ export function Map3D({ const fcLine: GeoJSON.FeatureCollection = { type: "FeatureCollection", - features: (fleetCircles || []).map((c, idx) => { + features: (fleetCircles || []).map((c) => { const ring = circleRingLngLat(c.center, c.radiusNm * 1852); return { type: "Feature", - id: `fleet-${c.ownerKey}-${idx}`, + id: makeFleetCircleFeatureId(c.ownerKey), geometry: { type: "LineString", coordinates: ring }, properties: { type: "fleet", @@ -2795,11 +2817,11 @@ export function Map3D({ const fcFill: GeoJSON.FeatureCollection = { type: "FeatureCollection", - features: (fleetCircles || []).map((c, idx) => { + features: (fleetCircles || []).map((c) => { const ring = circleRingLngLat(c.center, c.radiusNm * 1852); return { type: "Feature", - id: `fleet-fill-${c.ownerKey}-${idx}`, + id: `${makeFleetCircleFeatureId(c.ownerKey)}-fill`, geometry: { type: "Polygon", coordinates: [ring] }, properties: { type: "fleet-fill", @@ -2859,6 +2881,12 @@ export function Map3D({ } catch (e) { console.warn("Fleet circles fill layer add failed:", e); } + } else { + try { + map.setLayoutProperty(fillLayerId, "visibility", "visible"); + } catch { + // ignore + } } if (!map.getLayer(layerId)) { @@ -2880,6 +2908,12 @@ export function Map3D({ } catch (e) { console.warn("Fleet circles layer add failed:", e); } + } else { + try { + map.setLayoutProperty(layerId, "visibility", "visible"); + } catch { + // ignore + } } reorderGlobeFeatureLayers(); @@ -2890,7 +2924,6 @@ export function Map3D({ ensure(); return () => { stop(); - remove(); }; }, [ projection, @@ -2914,12 +2947,7 @@ export function Map3D({ const remove = () => { try { - if (map.getLayer(layerId)) map.removeLayer(layerId); - } catch { - // ignore - } - try { - if (map.getSource(srcId)) map.removeSource(srcId); + if (map.getLayer(layerId)) map.setLayoutProperty(layerId, "visibility", "none"); } catch { // ignore } @@ -2952,11 +2980,11 @@ export function Map3D({ const fc: GeoJSON.FeatureCollection = { type: "FeatureCollection", - features: ranges.map((c, idx) => { + features: ranges.map((c) => { const ring = circleRingLngLat(c.center, c.radiusNm * 1852); return { type: "Feature", - id: `pair-range-${idx}`, + id: makePairLinkFeatureId(c.aMmsi, c.bMmsi), geometry: { type: "LineString", coordinates: ring }, properties: { type: "pair-range", @@ -3007,6 +3035,12 @@ export function Map3D({ } catch (e) { console.warn("Pair range layer add failed:", e); } + } else { + try { + map.setLayoutProperty(layerId, "visibility", "visible"); + } catch { + // ignore + } } kickRepaint(map); @@ -3016,7 +3050,6 @@ export function Map3D({ ensure(); return () => { stop(); - remove(); }; }, [ projection, From b944887430ffcc277b5b0b0e4fdc1a308483343f Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 15:49:01 +0900 Subject: [PATCH 27/58] Adjust globe layer stacking and keep overlay layers alive --- apps/web/src/widgets/map3d/Map3D.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index f1f431d..43119ee 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -1002,14 +1002,14 @@ export function Map3D({ "zones-fill", "zones-line", "zones-label", + "ships-globe-halo", + "ships-globe-outline", + "ships-globe", "pair-lines-ml", "fc-lines-ml", "pair-range-ml", "fleet-circles-ml-fill", "fleet-circles-ml", - "ships-globe-halo", - "ships-globe-outline", - "ships-globe", ]; for (const layerId of ordering) { From 05b0c6b881351f877a31c9ad0903cf091b3633b0 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 16:09:21 +0900 Subject: [PATCH 28/58] feat(map3d): stabilize globe overlays and hover-highlight sync --- .../features/legacyDashboard/model/derive.ts | 5 + .../features/legacyDashboard/model/types.ts | 2 +- .../web/src/pages/dashboard/DashboardPage.tsx | 4 + apps/web/src/widgets/map3d/Map3D.tsx | 634 +++++++++++++----- .../web/src/widgets/vesselList/VesselList.tsx | 34 +- 5 files changed, 516 insertions(+), 163 deletions(-) diff --git a/apps/web/src/features/legacyDashboard/model/derive.ts b/apps/web/src/features/legacyDashboard/model/derive.ts index 10f2778..cf2d569 100644 --- a/apps/web/src/features/legacyDashboard/model/derive.ts +++ b/apps/web/src/features/legacyDashboard/model/derive.ts @@ -188,12 +188,17 @@ export function computeFleetCircles(vessels: DerivedLegacyVessel[]): FleetCircle const out: FleetCircle[] = []; for (const [ownerKey, vs] of groups.entries()) { if (vs.length < 3) continue; + const ownerLabel = + vs.find((v) => v.ownerCn)?.ownerCn ?? + vs.find((v) => v.ownerRoman)?.ownerRoman ?? + ownerKey; const lon = vs.reduce((sum, v) => sum + v.lon, 0) / vs.length; const lat = vs.reduce((sum, v) => sum + v.lat, 0) / vs.length; let radiusNm = 0; for (const v of vs) radiusNm = Math.max(radiusNm, haversineNm(lat, lon, v.lat, v.lon)); out.push({ ownerKey, + ownerLabel, center: [lon, lat], radiusNm: Math.max(0.2, radiusNm), count: vs.length, diff --git a/apps/web/src/features/legacyDashboard/model/types.ts b/apps/web/src/features/legacyDashboard/model/types.ts index b2a3661..3ff2bf5 100644 --- a/apps/web/src/features/legacyDashboard/model/types.ts +++ b/apps/web/src/features/legacyDashboard/model/types.ts @@ -56,6 +56,7 @@ export type FcLink = { export type FleetCircle = { ownerKey: string; + ownerLabel: string; center: [number, number]; radiusNm: number; count: number; @@ -71,4 +72,3 @@ export type LegacyAlarm = { text: string; relatedMmsi: number[]; }; - diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 0ae6c5c..edd7764 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -610,6 +610,10 @@ export function DashboardPage() { fleetCircles={fleetCirclesForMap} fleetFocus={fleetFocus} onProjectionLoadingChange={setIsProjectionLoading} + onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))} + onClearMmsiHover={() => setHoveredMmsiSet((prev) => (prev.length === 0 ? prev : []))} + onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))} + onClearPairHover={() => setHoveredPairMmsiSet([])} onHoverFleet={(ownerKey, fleetMmsis) => { setHoveredFleetOwnerKey(ownerKey); setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis)); diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 43119ee..d341d8f 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -56,6 +56,10 @@ type Props = { }; onHoverFleet?: (ownerKey: string | null, fleetMmsiSet: number[]) => void; onClearFleetHover?: () => void; + onHoverMmsi?: (mmsiList: number[]) => void; + onClearMmsiHover?: () => void; + onHoverPair?: (mmsiList: number[]) => void; + onClearPairHover?: () => void; }; function toNumberSet(values: number[] | undefined | null) { @@ -141,6 +145,49 @@ function makeFleetCircleFeatureId(ownerKey: string) { return `fleet-${ownerKey}`; } +function makeMmsiPairHighlightExpr(aField: string, bField: string, hoveredMmsiList: number[]) { + if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length < 2) { + return false; + } + const inA = ["in", ["to-number", ["get", aField]], ["literal", hoveredMmsiList]] as unknown[]; + const inB = ["in", ["to-number", ["get", bField]], ["literal", hoveredMmsiList]] as unknown[]; + return ["all", inA, inB] as unknown[]; +} + +function makeMmsiAnyEndpointExpr(aField: string, bField: string, hoveredMmsiList: number[]) { + if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length === 0) { + return false; + } + const literal = ["literal", hoveredMmsiList] as unknown[]; + return [ + "any", + ["in", ["to-number", ["get", aField]], literal], + ["in", ["to-number", ["get", bField]], literal], + ] as unknown[]; +} + +function makeFleetOwnerMatchExpr(hoveredOwnerKeys: string[]) { + if (!Array.isArray(hoveredOwnerKeys) || hoveredOwnerKeys.length === 0) { + return false; + } + const expr = ["match", ["to-string", ["coalesce", ["get", "ownerKey"], ""]]] as unknown[]; + for (const ownerKey of hoveredOwnerKeys) { + expr.push(String(ownerKey), true); + } + expr.push(false); + return expr; +} + +function makeFleetMemberMatchExpr(hoveredFleetMmsiList: number[]) { + if (!Array.isArray(hoveredFleetMmsiList) || hoveredFleetMmsiList.length === 0) { + return false; + } + const clauses = hoveredFleetMmsiList.map((mmsi) => + ["in", mmsi, ["coalesce", ["get", "vesselMmsis"], []]] as unknown[], + ); + return ["any", ...clauses] as unknown[]; +} + const SHIP_ICON_MAPPING = { ship: { x: 0, @@ -922,7 +969,37 @@ type PairRangeCircle = { distanceNm: number; }; -const makeUniqueSorted = (values: number[]) => Array.from(new Set(values.filter((v) => Number.isFinite(v)))).sort((a, b) => a - b); +const toNumberArray = (values: unknown): number[] => { + if (values == null) return []; + if (Array.isArray(values)) { + return values as unknown as number[]; + } + if (typeof values === "number" && Number.isFinite(values)) { + return [values]; + } + if (typeof values === "string") { + const value = toSafeNumber(Number(values)); + return value == null ? [] : [value]; + } + if (typeof values === "object") { + if (typeof (values as { [Symbol.iterator]?: unknown })?.[Symbol.iterator] === "function") { + try { + return Array.from(values as Iterable) as number[]; + } catch { + return []; + } + } + } + return []; +}; + +const makeUniqueSorted = (values: unknown) => { + const maybeArray = toNumberArray(values); + const normalized = Array.isArray(maybeArray) ? maybeArray : []; + const unique = Array.from(new Set(normalized.filter((value) => Number.isFinite(value)))); + unique.sort((a, b) => a - b); + return unique; +}; const equalNumberArrays = (a: number[], b: number[]) => { if (a.length !== b.length) return false; @@ -959,6 +1036,10 @@ export function Map3D({ fleetFocus, onHoverFleet, onClearFleetHover, + onHoverMmsi, + onClearMmsiHover, + onHoverPair, + onClearPairHover, }: Props) { const containerRef = useRef(null); const mapRef = useRef(null); @@ -973,6 +1054,8 @@ export function Map3D({ const projectionBusyTimerRef = useRef | null>(null); const projectionPrevRef = useRef(projection); const mapTooltipRef = useRef(null); + const deckHoverRafRef = useRef(null); + const deckHoverHasHitRef = useRef(false); const [hoveredDeckMmsiSet, setHoveredDeckMmsiSet] = useState([]); const [hoveredDeckPairMmsiSet, setHoveredDeckPairMmsiSet] = useState([]); const [hoveredDeckFleetOwnerKey, setHoveredDeckFleetOwnerKey] = useState(null); @@ -991,13 +1074,16 @@ export function Map3D({ if (hoveredDeckFleetOwnerKey) keys.add(hoveredDeckFleetOwnerKey); return keys; }, [hoveredFleetOwnerKey, hoveredDeckFleetOwnerKey]); + const fleetFocusId = fleetFocus?.id; + const fleetFocusLon = fleetFocus?.center?.[0]; + const fleetFocusLat = fleetFocus?.center?.[1]; + const fleetFocusZoom = fleetFocus?.zoom; - const reorderGlobeFeatureLayers = useCallback((options?: { shipTop?: boolean }) => { + const reorderGlobeFeatureLayers = useCallback(() => { const map = mapRef.current; if (!map || projectionRef.current !== "globe") return; if (projectionBusyRef.current) return; - const shipTop = options?.shipTop === true; const ordering = [ "zones-fill", "zones-line", @@ -1020,17 +1106,6 @@ export function Map3D({ } } - if (!shipTop) return; - - const shipOrdering = ["ships-globe-halo", "ships-globe-outline", "ships-globe"]; - for (const layerId of shipOrdering) { - try { - if (map.getLayer(layerId)) map.moveLayer(layerId); - } catch { - // ignore - } - } - kickRepaint(map); }, []); @@ -1078,6 +1153,9 @@ export function Map3D({ [effectiveHoveredFleetMmsiSet, hoveredFleetOwnerKeys], ); const hoveredPairSignature = useMemo(() => makeSetSignature(effectiveHoveredPairMmsiSet), [effectiveHoveredPairMmsiSet]); + const hoveredPairMmsiList = useMemo(() => makeUniqueSorted(Array.from(effectiveHoveredPairMmsiSet)), [effectiveHoveredPairMmsiSet]); + const hoveredFleetOwnerKeyList = useMemo(() => Array.from(hoveredFleetOwnerKeys).sort(), [hoveredFleetOwnerKeys]); + const hoveredFleetMmsiList = useMemo(() => makeUniqueSorted(Array.from(effectiveHoveredFleetMmsiSet)), [effectiveHoveredFleetMmsiSet]); const isHighlightedMmsi = useCallback( (mmsi: number) => highlightedMmsiSetCombined.has(mmsi), @@ -1100,6 +1178,16 @@ export function Map3D({ [hoveredFleetOwnerKeys, isHighlightedMmsi], ); + const shipData = useMemo(() => { + return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi)); + }, [targets]); + + const shipByMmsi = useMemo(() => { + const byMmsi = new Map(); + for (const t of shipData) byMmsi.set(t.mmsi, t); + return byMmsi; + }, [shipData]); + const hasAuxiliarySelectModifier = (ev?: { shiftKey?: boolean; ctrlKey?: boolean; @@ -1109,21 +1197,6 @@ export function Map3D({ return !!(ev.shiftKey || ev.ctrlKey || ev.metaKey); }; - const setHoveredMmsiList = useCallback((next: number[]) => { - const normalized = makeUniqueSorted(next); - setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); - }, []); - - const setHoveredDeckMmsiSingle = useCallback((mmsi: number | null) => { - const normalized = mmsi == null ? [] : [mmsi]; - setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); - }, []); - - const setHoveredDeckPairs = useCallback((next: number[]) => { - const normalized = makeUniqueSorted(next); - setHoveredDeckPairMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); - }, []); - const setHoveredDeckFleetMmsis = useCallback((next: number[]) => { const normalized = makeUniqueSorted(next); setHoveredDeckFleetMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); @@ -1135,6 +1208,12 @@ export function Map3D({ const onHoverFleetRef = useRef(onHoverFleet); const onClearFleetHoverRef = useRef(onClearFleetHover); + const onHoverMmsiRef = useRef(onHoverMmsi); + const onClearMmsiHoverRef = useRef(onClearMmsiHover); + const onHoverPairRef = useRef(onHoverPair); + const onClearPairHoverRef = useRef(onClearPairHover); + const mapDeckMmsiHoverRef = useRef([]); + const mapDeckPairHoverRef = useRef([]); const mapFleetHoverStateRef = useRef<{ ownerKey: string | null; vesselMmsis: number[]; @@ -1143,22 +1222,11 @@ export function Map3D({ useEffect(() => { onHoverFleetRef.current = onHoverFleet; onClearFleetHoverRef.current = onClearFleetHover; - }, [onHoverFleet, onClearFleetHover]); - - const setMapFleetHoverState = useCallback( - (ownerKey: string | null, vesselMmsis: number[]) => { - const normalized = makeUniqueSorted(vesselMmsis); - const prev = mapFleetHoverStateRef.current; - if (prev.ownerKey === ownerKey && equalNumberArrays(prev.vesselMmsis, normalized)) { - return; - } - setHoveredDeckFleetOwner(ownerKey); - setHoveredDeckFleetMmsis(normalized); - mapFleetHoverStateRef.current = { ownerKey, vesselMmsis: normalized }; - onHoverFleetRef.current?.(ownerKey, normalized); - }, - [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis], - ); + onHoverMmsiRef.current = onHoverMmsi; + onClearMmsiHoverRef.current = onClearMmsiHover; + onHoverPairRef.current = onHoverPair; + onClearPairHoverRef.current = onClearPairHover; + }, [onHoverFleet, onClearFleetHover, onHoverMmsi, onClearMmsiHover, onHoverPair, onClearPairHover]); const clearMapFleetHoverState = useCallback(() => { const nextOwner = null; @@ -1172,6 +1240,97 @@ export function Map3D({ } }, [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis]); + const clearDeckHoverPairs = useCallback(() => { + const prev = mapDeckPairHoverRef.current; + mapDeckPairHoverRef.current = []; + setHoveredDeckPairMmsiSet((prevState) => (prevState.length === 0 ? prevState : [])); + if (prev.length > 0) { + onClearPairHoverRef.current?.(); + } + }, [setHoveredDeckPairMmsiSet]); + + const clearDeckHoverMmsi = useCallback(() => { + const prev = mapDeckMmsiHoverRef.current; + mapDeckMmsiHoverRef.current = []; + setHoveredDeckMmsiSet((prevState) => (prevState.length === 0 ? prevState : [])); + if (prev.length > 0) { + onClearMmsiHoverRef.current?.(); + } + }, [setHoveredDeckMmsiSet]); + + const scheduleDeckHoverResolve = useCallback(() => { + if (deckHoverRafRef.current != null) return; + deckHoverRafRef.current = window.requestAnimationFrame(() => { + deckHoverRafRef.current = null; + if (!deckHoverHasHitRef.current) { + clearDeckHoverMmsi(); + clearDeckHoverPairs(); + clearMapFleetHoverState(); + } + deckHoverHasHitRef.current = false; + }); + }, [clearDeckHoverMmsi, clearDeckHoverPairs, clearMapFleetHoverState]); + + const touchDeckHoverState = useCallback( + (isHover: boolean) => { + if (isHover) deckHoverHasHitRef.current = true; + scheduleDeckHoverResolve(); + }, + [scheduleDeckHoverResolve], + ); + + const setDeckHoverMmsi = useCallback( + (next: number[]) => { + const normalized = makeUniqueSorted(next); + touchDeckHoverState(normalized.length > 0); + setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); + if (!equalNumberArrays(mapDeckMmsiHoverRef.current, normalized)) { + mapDeckMmsiHoverRef.current = normalized; + onHoverMmsiRef.current?.(normalized); + } + }, + [setHoveredDeckMmsiSet, touchDeckHoverState], + ); + + const setDeckHoverPairs = useCallback( + (next: number[]) => { + const normalized = makeUniqueSorted(next); + touchDeckHoverState(normalized.length > 0); + setHoveredDeckPairMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); + if (!equalNumberArrays(mapDeckPairHoverRef.current, normalized)) { + mapDeckPairHoverRef.current = normalized; + onHoverPairRef.current?.(normalized); + } + }, + [setHoveredDeckPairMmsiSet, touchDeckHoverState], + ); + + const setMapFleetHoverState = useCallback( + (ownerKey: string | null, vesselMmsis: number[]) => { + const normalized = makeUniqueSorted(vesselMmsis); + const prev = mapFleetHoverStateRef.current; + if (prev.ownerKey === ownerKey && equalNumberArrays(prev.vesselMmsis, normalized)) { + return; + } + touchDeckHoverState(!!ownerKey || normalized.length > 0); + setHoveredDeckFleetOwner(ownerKey); + setHoveredDeckFleetMmsis(normalized); + mapFleetHoverStateRef.current = { ownerKey, vesselMmsis: normalized }; + onHoverFleetRef.current?.(ownerKey, normalized); + }, + [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis, touchDeckHoverState], + ); + + useEffect(() => { + return () => { + if (deckHoverRafRef.current != null) { + window.cancelAnimationFrame(deckHoverRafRef.current); + deckHoverRafRef.current = null; + } + deckHoverHasHitRef.current = false; + }; + }, []); + useEffect(() => { mapFleetHoverStateRef.current = { ownerKey: hoveredFleetOwnerKey, @@ -2155,19 +2314,7 @@ export function Map3D({ const visibility = settings.showShips ? "visible" : "none"; - // Put ships at the top so they're always visible (especially important under globe projection). const before = undefined; - const bringShipLayersToFront = () => { - const ids = [haloId, outlineId, symbolId]; - for (const id of ids) { - if (!map.getLayer(id)) continue; - try { - map.moveLayer(id); - } catch { - // ignore - } - } - }; if (!map.getLayer(haloId)) { try { @@ -2181,10 +2328,12 @@ export function Map3D({ "circle-sort-key": [ "case", ["==", ["get", "selected"], 1], - 30, + 90, ["==", ["get", "highlighted"], 1], - 25, - 10, + 80, + ["==", ["get", "permitted"], 1], + 60, + 20, ] as never, }, paint: { @@ -2208,7 +2357,20 @@ export function Map3D({ } else { try { map.setLayoutProperty(haloId, "visibility", visibility); - map.setPaintProperty(haloId, "circle-color", ["case", ["==", ["get", "highlighted"], 1], ["coalesce", ["get", "shipColor"], "#64748b"], ["coalesce", ["get", "shipColor"], "#64748b"]] as never); + map.setPaintProperty( + haloId, + "circle-color", + [ + "case", + ["==", ["get", "selected"], 1], + "rgba(14,234,255,1)", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,1)", + ["==", ["get", "permitted"], 1], + "rgba(125,211,252,0.95)", + "rgba(59,130,246,1)", + ] as never, + ); map.setPaintProperty(haloId, "circle-opacity", [ "case", ["==", ["get", "selected"], 1], @@ -2239,6 +2401,8 @@ export function Map3D({ "rgba(14,234,255,0.95)", ["==", ["get", "highlighted"], 1], "rgba(245,158,11,0.95)", + ["==", ["get", "permitted"], 1], + "rgba(125,211,252,0.95)", "rgba(59,130,246,0.75)", ] as never, "circle-stroke-width": [ @@ -2247,6 +2411,8 @@ export function Map3D({ 3.4, ["==", ["get", "highlighted"], 1], 2.7, + ["==", ["get", "permitted"], 1], + 1.8, 0.0, ] as never, "circle-stroke-opacity": 0.85, @@ -2256,10 +2422,12 @@ export function Map3D({ "circle-sort-key": [ "case", ["==", ["get", "selected"], 1], - 40, + 100, ["==", ["get", "highlighted"], 1], - 35, - 15, + 90, + ["==", ["get", "permitted"], 1], + 70, + 30, ] as never, }, } as unknown as LayerSpecification, @@ -2280,6 +2448,8 @@ export function Map3D({ "rgba(14,234,255,0.95)", ["==", ["get", "highlighted"], 1], "rgba(245,158,11,0.95)", + ["==", ["get", "permitted"], 1], + "rgba(125,211,252,0.95)", "rgba(59,130,246,0.75)", ] as never, ); @@ -2292,6 +2462,8 @@ export function Map3D({ 3.4, ["==", ["get", "highlighted"], 1], 2.7, + ["==", ["get", "permitted"], 1], + 1.8, 0.0, ] as never, ); @@ -2312,10 +2484,12 @@ export function Map3D({ "symbol-sort-key": [ "case", ["==", ["get", "selected"], 1], - 50, + 95, ["==", ["get", "highlighted"], 1], + 85, + ["==", ["get", "permitted"], 1], + 65, 45, - 20, ] as never, "icon-image": imgId, "icon-size": [ @@ -2346,6 +2520,8 @@ export function Map3D({ "rgba(14,234,255,1)", ["==", ["get", "highlighted"], 1], "rgba(245,158,11,1)", + ["==", ["get", "permitted"], 1], + "rgba(125,211,252,1)", "rgba(59,130,246,1)", ] as never, "icon-opacity": [ @@ -2391,6 +2567,8 @@ export function Map3D({ "rgba(14,234,255,1)", ["==", ["get", "highlighted"], 1], "rgba(245,158,11,1)", + ["==", ["get", "permitted"], 1], + "rgba(125,211,252,1)", ["coalesce", ["get", "shipColor"], "#64748b"], ] as never, ); @@ -2415,6 +2593,8 @@ export function Map3D({ "rgba(14,234,255,0.68)", ["==", ["get", "highlighted"], 1], "rgba(245,158,11,0.72)", + ["==", ["get", "permitted"], 1], + "rgba(125,211,252,0.58)", "rgba(15,23,42,0.25)", ] as never, ); @@ -2436,8 +2616,7 @@ export function Map3D({ } // Selection and highlight are now source-data driven. - bringShipLayersToFront(); - reorderGlobeFeatureLayers({ shipTop: true }); + reorderGlobeFeatureLayers(); kickRepaint(map); }; @@ -2448,7 +2627,7 @@ export function Map3D({ }, [ projection, settings.showShips, - targets, + shipData, legacyHits, selectedMmsi, hoveredMmsiSetRef, @@ -2566,14 +2745,13 @@ export function Map3D({ type: "Feature", id: makePairLinkFeatureId(p.aMmsi, p.bMmsi), geometry: { type: "LineString", coordinates: [p.from, p.to] }, - properties: { - type: "pair", - aMmsi: p.aMmsi, - bMmsi: p.bMmsi, - distanceNm: p.distanceNm, - warn: p.warn, - highlighted: isHighlightedPair(p.aMmsi, p.bMmsi) ? 1 : 0, - }, + properties: { + type: "pair", + aMmsi: p.aMmsi, + bMmsi: p.bMmsi, + distanceNm: p.distanceNm, + warn: p.warn, + }, })), }; @@ -2643,9 +2821,6 @@ export function Map3D({ overlays.pairLines, pairLinks, mapSyncEpoch, - hoveredShipSignature, - hoveredPairSignature, - isHighlightedPair, reorderGlobeFeatureLayers, ]); @@ -2687,14 +2862,13 @@ export function Map3D({ type: "Feature", id: makeFcSegmentFeatureId(s.fromMmsi ?? -1, s.toMmsi ?? -1, idx), geometry: { type: "LineString", coordinates: [s.from, s.to] }, - properties: { - type: "fc", - suspicious: s.suspicious, - distanceNm: s.distanceNm, - fcMmsi: s.fromMmsi ?? -1, - otherMmsi: s.toMmsi ?? -1, - highlighted: s.fromMmsi != null && isHighlightedMmsi(s.fromMmsi) ? 1 : 0, - }, + properties: { + type: "fc", + suspicious: s.suspicious, + distanceNm: s.distanceNm, + fcMmsi: s.fromMmsi ?? -1, + otherMmsi: s.toMmsi ?? -1, + }, })), }; @@ -2757,8 +2931,6 @@ export function Map3D({ overlays.fcLines, fcLinks, mapSyncEpoch, - hoveredShipSignature, - isHighlightedMmsi, reorderGlobeFeatureLayers, ]); @@ -2806,10 +2978,10 @@ export function Map3D({ ownerLabel: c.ownerLabel, count: c.count, vesselMmsis: c.vesselMmsis, - highlighted: - isHighlightedFleet(c.ownerKey, c.vesselMmsis) || c.vesselMmsis.some((m) => isHighlightedMmsi(m)) - ? 1 - : 0, + // Kept for backward compatibility with existing paint expressions. + // Actual hover-state highlighting is now handled in + // updateGlobeOverlayPaintStates. + highlighted: 0, }, }; }), @@ -2829,10 +3001,10 @@ export function Map3D({ ownerLabel: c.ownerLabel, count: c.count, vesselMmsis: c.vesselMmsis, - highlighted: - isHighlightedFleet(c.ownerKey, c.vesselMmsis) || c.vesselMmsis.some((m) => isHighlightedMmsi(m)) - ? 1 - : 0, + // Kept for backward compatibility with existing paint expressions. + // Actual hover-state highlighting is now handled in + // updateGlobeOverlayPaintStates. + highlighted: 0, }, }; }), @@ -2930,11 +3102,6 @@ export function Map3D({ overlays.fleetCircles, fleetCircles, mapSyncEpoch, - hoveredShipSignature, - hoveredFleetSignature, - hoveredFleetOwnerKey, - isHighlightedFleet, - isHighlightedMmsi, reorderGlobeFeatureLayers, ]); @@ -2992,7 +3159,10 @@ export function Map3D({ aMmsi: c.aMmsi, bMmsi: c.bMmsi, distanceNm: c.distanceNm, - highlighted: isHighlightedPair(c.aMmsi, c.bMmsi) ? 1 : 0, + // Kept for backward compatibility with existing paint expressions. + // Actual hover-state highlighting is now handled in + // updateGlobeOverlayPaintStates. + highlighted: 0, }, }; }), @@ -3056,22 +3226,148 @@ export function Map3D({ overlays.pairRange, pairLinks, mapSyncEpoch, - hoveredShipSignature, - hoveredPairSignature, - hoveredFleetSignature, - isHighlightedPair, reorderGlobeFeatureLayers, ]); - const shipData = useMemo(() => { - return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi)); - }, [targets]); + const updateGlobeOverlayPaintStates = useCallback(() => { + if (projection !== "globe" || projectionBusyRef.current) return; - const shipByMmsi = useMemo(() => { - const byMmsi = new Map(); - for (const t of shipData) byMmsi.set(t.mmsi, t); - return byMmsi; - }, [shipData]); + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + + const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]); + + const pairHighlightExpr = hoveredPairMmsiList.length >= 2 + ? makeMmsiPairHighlightExpr("aMmsi", "bMmsi", hoveredPairMmsiList) + : false; + + const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0 + ? makeMmsiAnyEndpointExpr("fcMmsi", "otherMmsi", fleetAwarePairMmsiList) + : false; + + const fleetOwnerMatchExpr = + hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false; + const fleetMemberExpr = + hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false; + const fleetHighlightExpr = + hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0 + ? (["any", fleetOwnerMatchExpr, fleetMemberExpr] as never) + : false; + + try { + if (map.getLayer("pair-lines-ml")) { + map.setPaintProperty( + "pair-lines-ml", + "line-color", + ["case", pairHighlightExpr, "rgba(245,158,11,0.98)", ["boolean", ["get", "warn"], false], "rgba(59,130,246,0.55)", "rgba(59,130,246,0.55)"] as never, + ); + map.setPaintProperty( + "pair-lines-ml", + "line-width", + ["case", pairHighlightExpr, 2.8, ["boolean", ["get", "warn"], false], 2.2, 1.4] as never, + ); + } + } catch { + // ignore + } + + try { + if (map.getLayer("fc-lines-ml")) { + map.setPaintProperty( + "fc-lines-ml", + "line-color", + ["case", fcEndpointHighlightExpr, "rgba(245,158,11,0.98)", ["boolean", ["get", "suspicious"], false], "rgba(239,68,68,0.95)", "rgba(217,119,6,0.92)"] as never, + ); + map.setPaintProperty( + "fc-lines-ml", + "line-width", + ["case", fcEndpointHighlightExpr, 2.0, 1.3] as never, + ); + } + } catch { + // ignore + } + + try { + if (map.getLayer("pair-range-ml")) { + map.setPaintProperty( + "pair-range-ml", + "line-color", + [ + "case", + pairHighlightExpr, + "rgba(245,158,11,0.92)", + ["boolean", ["get", "warn"], false], + "rgba(245,158,11,0.75)", + "rgba(59,130,246,0.45)", + ] as never, + ); + map.setPaintProperty( + "pair-range-ml", + "line-width", + ["case", pairHighlightExpr, 1.6, 1.0] as never, + ); + } + } catch { + // ignore + } + + try { + if (map.getLayer("fleet-circles-ml-fill")) { + map.setPaintProperty( + "fleet-circles-ml-fill", + "fill-color", + [ + "case", + fleetHighlightExpr, + "rgba(245,158,11,0.24)", + "rgba(245,158,11,0.02)", + ] as never, + ); + map.setPaintProperty( + "fleet-circles-ml-fill", + "fill-opacity", + ["case", fleetHighlightExpr, 0.7, 0.28] as never, + ); + } + if (map.getLayer("fleet-circles-ml")) { + map.setPaintProperty( + "fleet-circles-ml", + "line-color", + ["case", fleetHighlightExpr, "rgba(245,158,11,0.95)", "rgba(245,158,11,0.65)"] as never, + ); + map.setPaintProperty( + "fleet-circles-ml", + "line-width", + ["case", fleetHighlightExpr, 2, 1.1] as never, + ); + } + } catch { + // ignore + } + }, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList]); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + const stop = onMapStyleReady(map, updateGlobeOverlayPaintStates); + updateGlobeOverlayPaintStates(); + return () => { + stop(); + }; + }, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateGlobeOverlayPaintStates]); + + const shipLayerData = useMemo(() => { + if (shipData.length === 0) return shipData; + const layer = [...shipData]; + layer.sort((a, b) => { + const aPriority = a.mmsi === selectedMmsi ? 3 : isHighlightedMmsi(a.mmsi) ? 2 : 0; + const bPriority = b.mmsi === selectedMmsi ? 3 : isHighlightedMmsi(b.mmsi) ? 2 : 0; + if (aPriority !== bPriority) return aPriority - bPriority; + return a.mmsi - b.mmsi; + }); + return layer; + }, [shipData, isHighlightedMmsi, selectedMmsi]); const clearGlobeTooltip = useCallback(() => { if (!mapTooltipRef.current) return; @@ -3178,15 +3474,15 @@ export function Map3D({ if (!map) return; const clearDeckGlobeHoverState = () => { - setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); - setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); + clearDeckHoverMmsi(); + clearDeckHoverPairs(); setHoveredZoneId((prev) => (prev === null ? prev : null)); clearMapFleetHoverState(); }; const resetGlobeHoverStates = () => { - setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); - setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); + clearDeckHoverMmsi(); + clearDeckHoverPairs(); setHoveredZoneId((prev) => (prev === null ? prev : null)); clearMapFleetHoverState(); }; @@ -3287,36 +3583,36 @@ export function Map3D({ if (isShipLayer) { const mmsi = toIntMmsi(props.mmsi); - setHoveredDeckMmsiSingle(mmsi); - setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); + setDeckHoverMmsi(mmsi == null ? [] : [mmsi]); + clearDeckHoverPairs(); clearMapFleetHoverState(); setHoveredZoneId((prev) => (prev === null ? prev : null)); } else if (isPairLayer) { const aMmsi = toIntMmsi(props.aMmsi); const bMmsi = toIntMmsi(props.bMmsi); - setHoveredDeckPairs([...(aMmsi == null ? [] : [aMmsi]), ...(bMmsi == null ? [] : [bMmsi])]); - setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); + setDeckHoverPairs([...(aMmsi == null ? [] : [aMmsi]), ...(bMmsi == null ? [] : [bMmsi])]); + clearDeckHoverMmsi(); clearMapFleetHoverState(); setHoveredZoneId((prev) => (prev === null ? prev : null)); } else if (isFcLayer) { const from = toIntMmsi(props.fcMmsi); const to = toIntMmsi(props.otherMmsi); const fromTo = [from, to].filter((v): v is number => v != null); - setHoveredDeckPairs(fromTo); - setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, fromTo) ? prev : fromTo)); + setDeckHoverPairs(fromTo); + setDeckHoverMmsi(fromTo); clearMapFleetHoverState(); setHoveredZoneId((prev) => (prev === null ? prev : null)); } else if (isFleetLayer) { const ownerKey = String(props.ownerKey ?? ""); const list = normalizeMmsiList(props.vesselMmsis); setMapFleetHoverState(ownerKey || null, list); - setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); - setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); + clearDeckHoverMmsi(); + clearDeckHoverPairs(); setHoveredZoneId((prev) => (prev === null ? prev : null)); } else if (isZoneLayer) { clearMapFleetHoverState(); - setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); - setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); + clearDeckHoverMmsi(); + clearDeckHoverPairs(); const zoneId = getZoneIdFromProps(props); setHoveredZoneId(zoneId || null); } else { @@ -3358,8 +3654,10 @@ export function Map3D({ buildGlobeFeatureTooltip, clearGlobeTooltip, clearMapFleetHoverState, - setHoveredDeckPairs, - setHoveredDeckMmsiSingle, + clearDeckHoverPairs, + clearDeckHoverMmsi, + setDeckHoverPairs, + setDeckHoverMmsi, setMapFleetHoverState, setGlobeTooltip, ]); @@ -3369,6 +3667,18 @@ export function Map3D({ return shipData.filter((t) => legacyHits.has(t.mmsi)); }, [shipData, legacyHits]); + const legacyTargetsOrdered = useMemo(() => { + if (legacyTargets.length === 0) return legacyTargets; + const layer = [...legacyTargets]; + layer.sort((a, b) => { + const aPriority = a.mmsi === selectedMmsi ? 3 : isHighlightedMmsi(a.mmsi) ? 2 : 0; + const bPriority = b.mmsi === selectedMmsi ? 3 : isHighlightedMmsi(b.mmsi) ? 2 : 0; + if (aPriority !== bPriority) return aPriority - bPriority; + return a.mmsi - b.mmsi; + }); + return layer; + }, [legacyTargets, isHighlightedMmsi, selectedMmsi]); + const fcDashed = useMemo(() => { const segs: DashSeg[] = []; for (const l of fcLinks || []) { @@ -3405,14 +3715,16 @@ export function Map3D({ useEffect(() => { const map = mapRef.current; - if (!map || !fleetFocus) return; - const [lon, lat] = fleetFocus.center; - if (!Number.isFinite(lon) || !Number.isFinite(lat)) return; + if (!map || fleetFocusLon == null || fleetFocusLat == null || !Number.isFinite(fleetFocusLon) || !Number.isFinite(fleetFocusLat)) + return; + const lon = fleetFocusLon; + const lat = fleetFocusLat; + const zoom = fleetFocusZoom ?? 10; const apply = () => { map.easeTo({ center: [lon, lat], - zoom: fleetFocus.zoom ?? 10, + zoom, duration: 700, }); }; @@ -3426,7 +3738,7 @@ export function Map3D({ return () => { stop(); }; - }, [fleetFocus?.id, fleetFocus?.center?.[0], fleetFocus?.center?.[1], fleetFocus?.zoom]); + }, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]); // Update Deck.gl layers useEffect(() => { @@ -3452,9 +3764,7 @@ export function Map3D({ const overlayParams = projection === "globe" ? GLOBE_OVERLAY_PARAMS : DEPTH_DISABLED_PARAMS; const layers = []; const clearDeckHover = () => { - setHoveredMmsiList([]); - setHoveredDeckPairs([]); - clearMapFleetHoverState(); + touchDeckHoverState(false); }; const toFleetMmsiList = (value: unknown) => { @@ -3488,7 +3798,7 @@ export function Map3D({ layers.push( new HexagonLayer({ id: "density", - data: shipData, + data: shipLayerData, pickable: true, extruded: true, radius: 2500, @@ -3525,11 +3835,12 @@ export function Map3D({ clearDeckHover(); return; } + touchDeckHoverState(true); const p = info.object as PairRangeCircle; const aMmsi = p.aMmsi; const bMmsi = p.bMmsi; - setHoveredDeckPairs([aMmsi, bMmsi]); - setHoveredMmsiList([aMmsi, bMmsi]); + setDeckHoverPairs([aMmsi, bMmsi]); + setDeckHoverMmsi([aMmsi, bMmsi]); clearMapFleetHoverState(); }, onClick: (info) => { @@ -3574,9 +3885,10 @@ export function Map3D({ clearDeckHover(); return; } + touchDeckHoverState(true); const obj = info.object as PairLink; - setHoveredDeckPairs([obj.aMmsi, obj.bMmsi]); - setHoveredMmsiList([obj.aMmsi, obj.bMmsi]); + setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); + setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); clearMapFleetHoverState(); }, onClick: (info) => { @@ -3622,15 +3934,16 @@ export function Map3D({ clearDeckHover(); return; } + touchDeckHoverState(true); const obj = info.object as DashSeg; const aMmsi = obj.fromMmsi; const bMmsi = obj.toMmsi; if (aMmsi == null || bMmsi == null) { - setHoveredMmsiList([]); + clearDeckHover(); return; } - setHoveredDeckPairs([aMmsi, bMmsi]); - setHoveredMmsiList([aMmsi, bMmsi]); + setDeckHoverPairs([aMmsi, bMmsi]); + setDeckHoverMmsi([aMmsi, bMmsi]); clearMapFleetHoverState(); }, onClick: (info) => { @@ -3679,11 +3992,12 @@ export function Map3D({ clearDeckHover(); return; } + touchDeckHoverState(true); const obj = info.object as FleetCircle; const list = toFleetMmsiList(obj.vesselMmsis); setMapFleetHoverState(obj.ownerKey || null, list); - setHoveredMmsiList(list); - setHoveredDeckPairs([]); + setDeckHoverMmsi(list); + clearDeckHoverPairs(); }, onClick: (info) => { if (!info.object) return; @@ -3735,7 +4049,7 @@ export function Map3D({ layers.push( new ScatterplotLayer({ id: "legacy-halo", - data: legacyTargets, + data: legacyTargetsOrdered, pickable: false, billboard: false, // This ring is most prone to z-fighting, so force it into pure painter's-order rendering. @@ -3778,7 +4092,7 @@ export function Map3D({ layers.push( new IconLayer({ id: "ships", - data: shipData, + data: shipLayerData, pickable: true, // Keep icons horizontal on the sea surface when view is pitched/rotated. billboard: false, @@ -3810,9 +4124,10 @@ export function Map3D({ clearDeckHover(); return; } + touchDeckHoverState(true); const obj = info.object as AisTarget; - setHoveredMmsiList([obj.mmsi]); - setHoveredDeckPairs([]); + setDeckHoverMmsi([obj.mmsi]); + clearDeckHoverPairs(); clearMapFleetHoverState(); }, onClick: (info) => { @@ -3969,7 +4284,8 @@ export function Map3D({ applyDeckProps(); }, [ projection, - shipData, + shipLayerData, + legacyTargetsOrdered, baseMap, zones, selectedMmsi, @@ -3993,12 +4309,18 @@ export function Map3D({ hoveredFleetSignature, hoveredPairSignature, hoveredFleetOwnerKey, - highlightedMmsiSet, + highlightedMmsiSetCombined, + onToggleHighlightMmsi, isHighlightedMmsi, isHighlightedFleet, isHighlightedPair, + setDeckHoverMmsi, + clearDeckHoverMmsi, + setDeckHoverPairs, + clearDeckHoverPairs, clearMapFleetHoverState, setMapFleetHoverState, + touchDeckHoverState, ensureMercatorOverlay, ]); diff --git a/apps/web/src/widgets/vesselList/VesselList.tsx b/apps/web/src/widgets/vesselList/VesselList.tsx index dbbe1b1..112b7ba 100644 --- a/apps/web/src/widgets/vesselList/VesselList.tsx +++ b/apps/web/src/widgets/vesselList/VesselList.tsx @@ -1,17 +1,38 @@ import { VESSEL_TYPES } from "../../entities/vessel/model/meta"; import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types"; +import type { MouseEvent } from "react"; type Props = { vessels: DerivedLegacyVessel[]; selectedMmsi: number | null; + highlightedMmsiSet?: number[]; + onToggleHighlightMmsi: (mmsi: number) => void; onSelectMmsi: (mmsi: number) => void; + onHoverMmsi?: (mmsi: number) => void; + onClearHover?: () => void; }; -function isFiniteNumber(x: unknown): x is number { +export function VesselList({ + vessels, + selectedMmsi, + highlightedMmsiSet = [], + onToggleHighlightMmsi, + onSelectMmsi, + onHoverMmsi, + onClearHover, +}: Props) { + const handlePrimaryAction = (e: MouseEvent, mmsi: number) => { + if (e.shiftKey || e.ctrlKey || e.metaKey) { + onToggleHighlightMmsi(mmsi); + return; + } + onSelectMmsi(mmsi); + }; + + function isFiniteNumber(x: unknown): x is number { return typeof x === "number" && Number.isFinite(x); } -export function VesselList({ vessels, selectedMmsi, onSelectMmsi }: Props) { const sorted = vessels .slice() .sort((a, b) => (isFiniteNumber(b.sog) ? b.sog : -1) - (isFiniteNumber(a.sog) ? a.sog : -1)) @@ -29,13 +50,15 @@ export function VesselList({ vessels, selectedMmsi, onSelectMmsi }: Props) { const speedColor = inRange ? "#22C55E" : (v.sog ?? 0) > 5 ? "#3B82F6" : "var(--muted)"; const hasPair = v.pairPermitNo ? "⛓" : ""; const sel = selectedMmsi === v.mmsi; + const hl = highlightedMmsiSet.includes(v.mmsi); return (
onSelectMmsi(v.mmsi)} - style={sel ? { background: "rgba(59,130,246,.12)", border: "1px solid rgba(59,130,246,.45)" } : undefined} + className={`vi ${sel ? "sel" : ""} ${hl ? "hl" : ""}`} + onClick={(e) => handlePrimaryAction(e, v.mmsi)} + onMouseEnter={() => onHoverMmsi?.(v.mmsi)} + onMouseLeave={() => onClearHover?.()} title={v.name} >
@@ -56,4 +79,3 @@ export function VesselList({ vessels, selectedMmsi, onSelectMmsi }: Props) {
); } - From 5b7d1c4331b04f8414fa0d7f63db4765817d069f Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 16:12:10 +0900 Subject: [PATCH 29/58] fix: stabilize globe projection loading and globe ship icon fallback --- apps/web/src/widgets/map3d/Map3D.tsx | 139 ++++++++++++++++++--------- 1 file changed, 92 insertions(+), 47 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index d341d8f..0e3fe85 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -606,6 +606,44 @@ function makeGlobeCircleRadiusExpr() { const GLOBE_SHIP_CIRCLE_RADIUS_EXPR = makeGlobeCircleRadiusExpr() as never; +function buildFallbackGlobeShipIcon() { + const size = 96; + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + + ctx.clearRect(0, 0, size, size); + ctx.fillStyle = "rgba(255,255,255,1)"; + ctx.beginPath(); + ctx.moveTo(size / 2, 6); + ctx.lineTo(size / 2 - 14, 24); + ctx.lineTo(size / 2 - 18, 58); + ctx.lineTo(size / 2 - 10, 88); + ctx.lineTo(size / 2 + 10, 88); + ctx.lineTo(size / 2 + 18, 58); + ctx.lineTo(size / 2 + 14, 24); + ctx.closePath(); + ctx.fill(); + + ctx.fillRect(size / 2 - 8, 34, 16, 18); + + return ctx.getImageData(0, 0, size, size); +} + +function ensureFallbackShipImage(map: maplibregl.Map, imageId: string) { + if (!map || map.hasImage(imageId)) return; + const image = buildFallbackGlobeShipIcon(); + if (!image) return; + + try { + map.addImage(imageId, image, { pixelRatio: 2, sdf: true }); + } catch { + // ignore + } +} + function getMapTilerKey(): string | null { const k = import.meta.env.VITE_MAPTILER_KEY; if (typeof k !== "string") return null; @@ -1051,6 +1089,7 @@ export function Map3D({ const projectionRef = useRef(projection); const globeShipIconLoadingRef = useRef(false); const projectionBusyRef = useRef(false); + const projectionBusyTokenRef = useRef(0); const projectionBusyTimerRef = useRef | null>(null); const projectionPrevRef = useRef(projection); const mapTooltipRef = useRef(null); @@ -1344,28 +1383,37 @@ export function Map3D({ projectionBusyTimerRef.current = null; }, []); + const endProjectionLoading = useCallback(() => { + if (!projectionBusyRef.current) return; + projectionBusyRef.current = false; + clearProjectionBusyTimer(); + if (onProjectionLoadingChange) { + onProjectionLoadingChange(false); + } + }, [clearProjectionBusyTimer, onProjectionLoadingChange]); + const setProjectionLoading = useCallback( (loading: boolean) => { if (projectionBusyRef.current === loading) return; - projectionBusyRef.current = loading; - - if (loading) { - clearProjectionBusyTimer(); - projectionBusyTimerRef.current = setTimeout(() => { - if (projectionBusyRef.current) { - setProjectionLoading(false); - console.warn("Projection loading fallback timeout reached."); - } - }, 3000); - } else { - clearProjectionBusyTimer(); + if (!loading) { + endProjectionLoading(); + return; } + clearProjectionBusyTimer(); + projectionBusyRef.current = true; + const token = ++projectionBusyTokenRef.current; if (onProjectionLoadingChange) { - onProjectionLoadingChange(loading); + onProjectionLoadingChange(true); } + + projectionBusyTimerRef.current = setTimeout(() => { + if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return; + console.debug("Projection loading fallback timeout reached."); + endProjectionLoading(); + }, 4000); }, - [onProjectionLoadingChange, clearProjectionBusyTimer], + [clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange], ); const pulseMapSync = () => { @@ -1379,11 +1427,9 @@ export function Map3D({ useEffect(() => { return () => { clearProjectionBusyTimer(); - if (projectionBusyRef.current) { - setProjectionLoading(false); - } + endProjectionLoading(); }; - }, [clearProjectionBusyTimer, setProjectionLoading]); + }, [clearProjectionBusyTimer, endProjectionLoading]); useEffect(() => { showSeamarkRef.current = settings.showSeamark; @@ -2167,45 +2213,29 @@ export function Map3D({ }; const ensureImage = () => { + ensureFallbackShipImage(map, imgId); + if (globeShipIconLoadingRef.current) return; + if (map.hasImage(imgId)) return; + const addFallbackImage = () => { - const size = 96; - const canvas = document.createElement("canvas"); - canvas.width = size; - canvas.height = size; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - // Simple top-down ship silhouette, pointing north. - ctx.clearRect(0, 0, size, size); - ctx.fillStyle = "rgba(255,255,255,1)"; - ctx.beginPath(); - ctx.moveTo(size / 2, 6); - ctx.lineTo(size / 2 - 14, 24); - ctx.lineTo(size / 2 - 18, 58); - ctx.lineTo(size / 2 - 10, 88); - ctx.lineTo(size / 2 + 10, 88); - ctx.lineTo(size / 2 + 18, 58); - ctx.lineTo(size / 2 + 14, 24); - ctx.closePath(); - ctx.fill(); - - ctx.fillRect(size / 2 - 8, 34, 16, 18); - - const img = ctx.getImageData(0, 0, size, size); - map.addImage(imgId, img, { pixelRatio: 2, sdf: true }); + ensureFallbackShipImage(map, imgId); kickRepaint(map); }; - if (map.hasImage(imgId)) return; - if (globeShipIconLoadingRef.current) return; - + let fallbackTimer: ReturnType | null = null; try { globeShipIconLoadingRef.current = true; + fallbackTimer = window.setTimeout(() => { + addFallbackImage(); + }, 80); void map .loadImage("/assets/ship.svg") .then((response) => { globeShipIconLoadingRef.current = false; - if (map.hasImage(imgId)) return; + if (fallbackTimer != null) { + clearTimeout(fallbackTimer); + fallbackTimer = null; + } const loadedImage = (response as { data?: HTMLImageElement | ImageBitmap } | undefined)?.data; if (!loadedImage) { @@ -2214,6 +2244,13 @@ export function Map3D({ } try { + if (map.hasImage(imgId)) { + try { + map.removeImage(imgId); + } catch { + // ignore + } + } map.addImage(imgId, loadedImage, { pixelRatio: 2, sdf: true }); kickRepaint(map); } catch (e) { @@ -2222,10 +2259,18 @@ export function Map3D({ }) .catch(() => { globeShipIconLoadingRef.current = false; + if (fallbackTimer != null) { + clearTimeout(fallbackTimer); + fallbackTimer = null; + } addFallbackImage(); }); } catch (e) { globeShipIconLoadingRef.current = false; + if (fallbackTimer != null) { + clearTimeout(fallbackTimer); + fallbackTimer = null; + } try { addFallbackImage(); } catch (fallbackError) { From 86d36d25e36a6ef7360e63b069256bb3b92b206d Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 16:12:36 +0900 Subject: [PATCH 30/58] fix: reduce globe symbol paint variability to avoid bucket mismatch --- apps/web/src/widgets/map3d/Map3D.tsx | 90 ++-------------------------- 1 file changed, 4 insertions(+), 86 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 0e3fe85..aa2bb74 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -2559,40 +2559,10 @@ export function Map3D({ "icon-pitch-alignment": "map", }, paint: { - "icon-color": [ - "case", - ["==", ["get", "selected"], 1], - "rgba(14,234,255,1)", - ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,1)", - ["==", ["get", "permitted"], 1], - "rgba(125,211,252,1)", - "rgba(59,130,246,1)", - ] as never, - "icon-opacity": [ - "case", - ["==", ["get", "selected"], 1], - 1.0, - ["==", ["get", "highlighted"], 1], - 1, - 0.9, - ] as never, - "icon-halo-color": [ - "case", - ["==", ["get", "selected"], 1], - "rgba(14,234,255,0.68)", - ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,0.72)", - "rgba(15,23,42,0.25)", - ] as never, - "icon-halo-width": [ - "case", - ["==", ["get", "selected"], 1], - 2.2, - ["==", ["get", "highlighted"], 1], - 1.5, - 0, - ] as never, + "icon-color": "#ffffff", + "icon-opacity": 1, + "icon-halo-color": "rgba(15,23,42,0.22)", + "icon-halo-width": 0, }, } as unknown as LayerSpecification, before, @@ -2603,58 +2573,6 @@ export function Map3D({ } else { try { map.setLayoutProperty(symbolId, "visibility", visibility); - map.setPaintProperty( - symbolId, - "icon-color", - [ - "case", - ["==", ["get", "selected"], 1], - "rgba(14,234,255,1)", - ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,1)", - ["==", ["get", "permitted"], 1], - "rgba(125,211,252,1)", - ["coalesce", ["get", "shipColor"], "#64748b"], - ] as never, - ); - map.setPaintProperty( - symbolId, - "icon-opacity", - [ - "case", - ["==", ["get", "selected"], 1], - 1.0, - ["==", ["get", "highlighted"], 1], - 1, - 0.9, - ] as never, - ); - map.setPaintProperty( - symbolId, - "icon-halo-color", - [ - "case", - ["==", ["get", "selected"], 1], - "rgba(14,234,255,0.68)", - ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,0.72)", - ["==", ["get", "permitted"], 1], - "rgba(125,211,252,0.58)", - "rgba(15,23,42,0.25)", - ] as never, - ); - map.setPaintProperty( - symbolId, - "icon-halo-width", - [ - "case", - ["==", ["get", "selected"], 1], - 2.2, - ["==", ["get", "highlighted"], 1], - 1.5, - 0, - ] as never, - ); } catch { // ignore } From 6ff5ae383f4c520283087b284ecdeaac088767a4 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 16:14:03 +0900 Subject: [PATCH 31/58] fix: restore globe ship icon color while keeping symbol layer stable --- apps/web/src/widgets/map3d/Map3D.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index aa2bb74..87ee2b0 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -2559,10 +2559,8 @@ export function Map3D({ "icon-pitch-alignment": "map", }, paint: { - "icon-color": "#ffffff", + "icon-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, "icon-opacity": 1, - "icon-halo-color": "rgba(15,23,42,0.22)", - "icon-halo-width": 0, }, } as unknown as LayerSpecification, before, From f36c63d639b0f98c53678d5cb039e149f68ff37b Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 16:23:04 +0900 Subject: [PATCH 32/58] chore: checkpoint before deck.gl optimization work From 54d33a8670ee7c029120ac3b3bd3af13babc70b3 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 16:28:04 +0900 Subject: [PATCH 33/58] fix(map3d): restore mercator static/overlay split and stabilize globe deck rendering --- apps/web/src/widgets/map3d/Map3D.tsx | 1713 ++++++++++++++++---------- 1 file changed, 1069 insertions(+), 644 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 87ee2b0..2050554 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -1082,6 +1082,7 @@ export function Map3D({ const containerRef = useRef(null); const mapRef = useRef(null); const overlayRef = useRef(null); + const overlayInteractionRef = useRef(null); const globeDeckLayerRef = useRef(null); const globeShipsEpochRef = useRef(-1); const showSeamarkRef = useRef(settings.showSeamark); @@ -1187,11 +1188,6 @@ export function Map3D({ effectiveHoveredPairMmsiSet, ], ); - const hoveredFleetSignature = useMemo( - () => `${makeSetSignature(effectiveHoveredFleetMmsiSet)}|${[...hoveredFleetOwnerKeys].sort().join(",")}`, - [effectiveHoveredFleetMmsiSet, hoveredFleetOwnerKeys], - ); - const hoveredPairSignature = useMemo(() => makeSetSignature(effectiveHoveredPairMmsiSet), [effectiveHoveredPairMmsiSet]); const hoveredPairMmsiList = useMemo(() => makeUniqueSorted(Array.from(effectiveHoveredPairMmsiSet)), [effectiveHoveredPairMmsiSet]); const hoveredFleetOwnerKeyList = useMemo(() => Array.from(hoveredFleetOwnerKeys).sort(), [hoveredFleetOwnerKeys]); const hoveredFleetMmsiList = useMemo(() => makeUniqueSorted(Array.from(effectiveHoveredFleetMmsiSet)), [effectiveHoveredFleetMmsiSet]); @@ -1236,6 +1232,39 @@ export function Map3D({ return !!(ev.shiftKey || ev.ctrlKey || ev.metaKey); }; + const toFleetMmsiList = useCallback((value: unknown) => { + if (!Array.isArray(value)) return []; + const out: number[] = []; + for (const item of value) { + const v = toIntMmsi(item); + if (v != null) out.push(v); + } + return out; + }, []); + + const onDeckSelectOrHighlight = useCallback( + (info: unknown, allowMultiSelect = false) => { + const obj = info as { + mmsi?: unknown; + srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null; + }; + const mmsi = toIntMmsi(obj.mmsi); + if (mmsi == null) return; + const evt = obj.srcEvent ?? null; + const isAux = hasAuxiliarySelectModifier(evt); + if (onToggleHighlightMmsi && isAux) { + onToggleHighlightMmsi(mmsi); + return; + } + if (!allowMultiSelect && selectedMmsi === mmsi) { + onSelectMmsi(null); + return; + } + onSelectMmsi(mmsi); + }, + [hasAuxiliarySelectModifier, onSelectMmsi, onToggleHighlightMmsi, selectedMmsi], + ); + const setHoveredDeckFleetMmsis = useCallback((next: number[]) => { const normalized = makeUniqueSorted(next); setHoveredDeckFleetMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); @@ -1464,20 +1493,30 @@ export function Map3D({ } }, []); - const ensureMercatorOverlay = useCallback(() => { + const ensureMercatorOverlays = useCallback(() => { const map = mapRef.current; if (!map) return null; - if (overlayRef.current) return overlayRef.current; - try { - const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never); - map.addControl(next); - overlayRef.current = next; - return next; - } catch (e) { - console.warn("Deck overlay create failed:", e); - return null; - } + const ensureLayer = (ref: { current: MapboxOverlay | null }) => { + if (ref.current) return ref.current; + try { + const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never); + map.addControl(next); + ref.current = next; + return next; + } catch (e) { + console.warn("Deck overlay create failed:", e); + return null; + } + }; + + const base = ensureLayer(overlayRef); + if (!base) return null; + + const interaction = ensureLayer(overlayInteractionRef); + if (!interaction) return null; + + return { base, interaction }; }, []); const clearGlobeNativeLayers = useCallback(() => { @@ -1518,7 +1557,6 @@ export function Map3D({ if (!containerRef.current || mapRef.current) return; let map: maplibregl.Map | null = null; - let overlay: MapboxOverlay | null = null; let cancelled = false; const controller = new AbortController(); @@ -1557,9 +1595,9 @@ export function Map3D({ // - mercator: MapboxOverlay interleaved (fast, feature-rich) // - globe: MapLibre custom layer that feeds Deck the globe MVP matrix (keeps basemap+layers aligned) if (projectionRef.current === "mercator") { - overlay = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never); - map.addControl(overlay); - overlayRef.current = overlay; + const overlays = ensureMercatorOverlays(); + if (!overlays) return; + overlayRef.current = overlays.base; } else { globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ id: "deck-globe", @@ -1648,12 +1686,14 @@ export function Map3D({ map.remove(); map = null; } - if (overlay) { - overlay.finalize(); - overlay = null; + if (overlayRef.current) { + overlayRef.current.finalize(); + overlayRef.current = null; + } + if (overlayInteractionRef.current) { + overlayInteractionRef.current.finalize(); + overlayInteractionRef.current = null; } - - overlayRef.current = null; globeDeckLayerRef.current = null; mapRef.current = null; }; @@ -1707,25 +1747,33 @@ export function Map3D({ if (isTransition) setProjectionLoading(true); - const disposeMercatorOverlay = () => { - const current = overlayRef.current; - if (!current) return; - try { - current.setProps({ layers: [] } as never); - } catch { - // ignore - } - try { - map.removeControl(current as never); - } catch { - // ignore - } - try { - current.finalize(); - } catch { - // ignore - } - overlayRef.current = null; + const disposeMercatorOverlays = () => { + const disposeOne = (target: MapboxOverlay | null, toNull: "base" | "interaction") => { + if (!target) return; + try { + target.setProps({ layers: [] } as never); + } catch { + // ignore + } + try { + map.removeControl(target as never); + } catch { + // ignore + } + try { + target.finalize(); + } catch { + // ignore + } + if (toNull === "base") { + overlayRef.current = null; + } else { + overlayInteractionRef.current = null; + } + }; + + disposeOne(overlayRef.current, "base"); + disposeOne(overlayInteractionRef.current, "interaction"); }; const disposeGlobeDeckLayer = () => { @@ -1759,7 +1807,7 @@ export function Map3D({ const shouldSwitchProjection = currentProjection !== next; if (projection === "globe") { - disposeMercatorOverlay(); + disposeMercatorOverlays(); clearGlobeNativeLayers(); } else { disposeGlobeDeckLayer(); @@ -1816,7 +1864,7 @@ export function Map3D({ // Tear down globe custom layer (if present), restore MapboxOverlay interleaved. disposeGlobeDeckLayer(); - ensureMercatorOverlay(); + ensureMercatorOverlays(); } // MapLibre may not schedule a frame immediately after projection swaps if the map is idle. @@ -1852,7 +1900,7 @@ export function Map3D({ if (settleCleanup) settleCleanup(); if (isTransition) setProjectionLoading(false); }; - }, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, removeLayerIfExists, reorderGlobeFeatureLayers, setProjectionLoading]); + }, [projection, clearGlobeNativeLayers, ensureMercatorOverlays, removeLayerIfExists, reorderGlobeFeatureLayers, setProjectionLoading]); // Base map toggle useEffect(() => { @@ -3321,15 +3369,21 @@ export function Map3D({ const shipLayerData = useMemo(() => { if (shipData.length === 0) return shipData; const layer = [...shipData]; - layer.sort((a, b) => { - const aPriority = a.mmsi === selectedMmsi ? 3 : isHighlightedMmsi(a.mmsi) ? 2 : 0; - const bPriority = b.mmsi === selectedMmsi ? 3 : isHighlightedMmsi(b.mmsi) ? 2 : 0; - if (aPriority !== bPriority) return aPriority - bPriority; - return a.mmsi - b.mmsi; - }); + layer.sort((a, b) => a.mmsi - b.mmsi); return layer; }, [shipData, isHighlightedMmsi, selectedMmsi]); + const shipHighlightSet = useMemo(() => { + const out = new Set(highlightedMmsiSetCombined); + if (selectedMmsi) out.add(selectedMmsi); + return out; + }, [highlightedMmsiSetCombined, selectedMmsi]); + + const shipOverlayLayerData = useMemo(() => { + if (shipHighlightSet.size === 0) return []; + return shipLayerData.filter((target) => shipHighlightSet.has(target.mmsi)); + }, [shipLayerData, shipHighlightSet]); + const clearGlobeTooltip = useCallback(() => { if (!mapTooltipRef.current) return; try { @@ -3631,14 +3685,14 @@ export function Map3D({ const legacyTargetsOrdered = useMemo(() => { if (legacyTargets.length === 0) return legacyTargets; const layer = [...legacyTargets]; - layer.sort((a, b) => { - const aPriority = a.mmsi === selectedMmsi ? 3 : isHighlightedMmsi(a.mmsi) ? 2 : 0; - const bPriority = b.mmsi === selectedMmsi ? 3 : isHighlightedMmsi(b.mmsi) ? 2 : 0; - if (aPriority !== bPriority) return aPriority - bPriority; - return a.mmsi - b.mmsi; - }); + layer.sort((a, b) => a.mmsi - b.mmsi); return layer; - }, [legacyTargets, isHighlightedMmsi, selectedMmsi]); + }, [legacyTargets]); + + const legacyOverlayTargets = useMemo(() => { + if (shipHighlightSet.size === 0) return []; + return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi)); + }, [legacyTargets, shipHighlightSet]); const fcDashed = useMemo(() => { const segs: DashSeg[] = []; @@ -3664,6 +3718,961 @@ export function Map3D({ return out; }, [pairLinks]); + const pairLinksInteractive = useMemo(() => { + if (!overlays.pairLines || (pairLinks?.length ?? 0) === 0) return []; + if (hoveredPairMmsiSetRef.size < 2) return []; + const links = pairLinks || []; + return links.filter((link) => + hoveredPairMmsiSetRef.has(link.aMmsi) && hoveredPairMmsiSetRef.has(link.bMmsi), + ); + }, [pairLinks, hoveredPairMmsiSetRef, overlays.pairLines]); + + const pairRangesInteractive = useMemo(() => { + if (!overlays.pairRange || pairRanges.length === 0) return []; + if (hoveredPairMmsiSetRef.size < 2) return []; + return pairRanges.filter((range) => + hoveredPairMmsiSetRef.has(range.aMmsi) && hoveredPairMmsiSetRef.has(range.bMmsi), + ); + }, [pairRanges, hoveredPairMmsiSetRef, overlays.pairRange]); + + const fcLinesInteractive = useMemo(() => { + if (!overlays.fcLines || fcDashed.length === 0) return []; + if (shipHighlightSet.size === 0) return []; + return fcDashed.filter( + (line) => [line.fromMmsi, line.toMmsi].some((mmsi) => (mmsi == null ? false : shipHighlightSet.has(mmsi))), + ); + }, [fcDashed, hoveredShipSignature, overlays.fcLines, shipHighlightSet]); + + const fleetCirclesInteractive = useMemo(() => { + if (!overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) return []; + if (hoveredFleetOwnerKeys.size === 0 && shipHighlightSet.size === 0) return []; + const circles = fleetCircles || []; + return circles.filter((item) => isHighlightedFleet(item.ownerKey, item.vesselMmsis)); + }, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, shipHighlightSet]); + + // Static deck layers for mercator (positions + base states). Interaction overlays are handled separately. + useEffect(() => { + const map = mapRef.current; + if (!map) return; + if (projection !== "mercator" || projectionBusyRef.current) { + if (projection !== "mercator") { + try { + if (overlayRef.current) overlayRef.current.setProps({ layers: [] } as never); + } catch { + // ignore + } + } + return; + } + + const refs = ensureMercatorOverlays(); + const deckTarget = refs?.base; + if (!deckTarget) return; + + const layers: unknown[] = []; + const overlayParams = DEPTH_DISABLED_PARAMS; + const clearDeckHover = () => { + touchDeckHoverState(false); + }; + + if (settings.showDensity) { + layers.push( + new HexagonLayer({ + id: "density", + data: shipLayerData, + pickable: true, + extruded: true, + radius: 2500, + elevationScale: 35, + coverage: 0.92, + opacity: 0.35, + getPosition: (d) => [d.lon, d.lat], + }), + ); + } + + if (overlays.pairRange && pairRanges.length > 0) { + layers.push( + new ScatterplotLayer({ + id: "pair-range", + data: pairRanges, + pickable: true, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + radiusMinPixels: 10, + lineWidthUnits: "pixels", + getLineWidth: () => 1, + getLineColor: (d) => (d.warn ? [245, 158, 11, 170] : [59, 130, 246, 110]), + getPosition: (d) => d.center, + onHover: (info) => { + if (!info.object) { + clearDeckHover(); + return; + } + touchDeckHoverState(true); + const p = info.object as PairRangeCircle; + setDeckHoverPairs([p.aMmsi, p.bMmsi]); + setDeckHoverMmsi([p.aMmsi, p.bMmsi]); + clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) { + onSelectMmsi(null); + return; + } + const obj = info.object as PairRangeCircle; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + onToggleHighlightMmsi?.(obj.aMmsi); + onToggleHighlightMmsi?.(obj.bMmsi); + return; + } + onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); + }, + }), + ); + } + + if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) { + layers.push( + new LineLayer({ + id: "pair-lines", + data: pairLinks, + pickable: true, + parameters: overlayParams, + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, + getColor: (d) => (d.warn ? [245, 158, 11, 220] : [59, 130, 246, 85]), + getWidth: (d) => (d.warn ? 2.2 : 1.4), + widthUnits: "pixels", + onHover: (info) => { + if (!info.object) { + clearDeckHover(); + return; + } + touchDeckHoverState(true); + const obj = info.object as PairLink; + setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); + setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); + clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) return; + const obj = info.object as PairLink; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + onToggleHighlightMmsi?.(obj.aMmsi); + onToggleHighlightMmsi?.(obj.bMmsi); + return; + } + onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); + }, + }), + ); + } + + if (overlays.fcLines && fcDashed.length > 0) { + layers.push( + new LineLayer({ + id: "fc-lines", + data: fcDashed, + pickable: true, + parameters: overlayParams, + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, + getColor: (d) => (d.suspicious ? [239, 68, 68, 220] : [217, 119, 6, 200]), + getWidth: () => 1.3, + widthUnits: "pixels", + onHover: (info) => { + if (!info.object) { + clearDeckHover(); + return; + } + touchDeckHoverState(true); + const obj = info.object as DashSeg; + if (obj.fromMmsi == null || obj.toMmsi == null) { + clearDeckHover(); + return; + } + setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); + setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); + clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) return; + const obj = info.object as DashSeg; + if (obj.fromMmsi == null || obj.toMmsi == null) return; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + onToggleHighlightMmsi?.(obj.fromMmsi); + onToggleHighlightMmsi?.(obj.toMmsi); + return; + } + onDeckSelectOrHighlight({ mmsi: obj.fromMmsi }); + }, + }), + ); + } + + if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) { + layers.push( + new ScatterplotLayer({ + id: "fleet-circles", + data: fleetCircles, + pickable: true, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + lineWidthUnits: "pixels", + getLineWidth: () => 1.1, + getLineColor: () => [245, 158, 11, 140], + getPosition: (d) => d.center, + onHover: (info) => { + if (!info.object) { + clearDeckHover(); + return; + } + touchDeckHoverState(true); + const obj = info.object as FleetCircle; + const list = toFleetMmsiList(obj.vesselMmsis); + setMapFleetHoverState(obj.ownerKey || null, list); + setDeckHoverMmsi(list); + clearDeckHoverPairs(); + }, + onClick: (info) => { + if (!info.object) return; + const obj = info.object as FleetCircle; + const list = toFleetMmsiList(obj.vesselMmsis); + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + for (const mmsi of list) onToggleHighlightMmsi?.(mmsi); + return; + } + const first = list[0]; + if (first != null) onDeckSelectOrHighlight({ mmsi: first }); + }, + }), + ); + } + + if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) { + layers.push( + new ScatterplotLayer({ + id: "fleet-circles-fill", + data: fleetCircles, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: true, + stroked: false, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + getFillColor: () => [245, 158, 11, 6], + getPosition: (d) => d.center, + }), + ); + } + + if (settings.showShips && legacyTargetsOrdered.length > 0) { + layers.push( + new ScatterplotLayer({ + id: "legacy-halo", + data: legacyTargetsOrdered, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "pixels", + getRadius: () => FLAT_LEGACY_HALO_RADIUS, + lineWidthUnits: "pixels", + getLineWidth: () => 2, + getLineColor: (d) => { + const l = legacyHits?.get(d.mmsi); + const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; + if (!rgb) return [245, 158, 11, 200]; + return [rgb[0], rgb[1], rgb[2], 200]; + }, + getPosition: (d) => [d.lon, d.lat] as [number, number], + }), + ); + } + + if (settings.showShips) { + layers.push( + new IconLayer({ + id: "ships", + data: shipLayerData, + pickable: true, + billboard: false, + parameters: overlayParams, + iconAtlas: "/assets/ship.svg", + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => "ship", + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => + getDisplayHeading({ + cog: d.cog, + heading: d.heading, + }), + sizeUnits: "pixels", + getSize: () => FLAT_SHIP_ICON_SIZE, + getColor: (d) => + getShipColor( + d, + null, + legacyHits?.get(d.mmsi)?.shipCode ?? null, + new Set(), + ), + onHover: (info) => { + if (!info.object) { + clearDeckHover(); + return; + } + touchDeckHoverState(true); + const obj = info.object as AisTarget; + setDeckHoverMmsi([obj.mmsi]); + clearDeckHoverPairs(); + clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) { + onSelectMmsi(null); + return; + } + onDeckSelectOrHighlight({ + mmsi: info.object.mmsi, + srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent, + }, true); + }, + alphaCutoff: 0.05, + }), + ); + } + + const normalizedLayers = sanitizeDeckLayerList(layers); + const deckProps = { + layers: normalizedLayers, + getTooltip: (info: PickingInfo) => { + if (!info.object) return null; + if (info.layer && info.layer.id === "density") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const o: any = info.object; + const n = Array.isArray(o?.points) ? o.points.length : 0; + return { text: `AIS density: ${n}` }; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const obj: any = info.object; + if (typeof obj.mmsi === "number") { + return getShipTooltipHtml({ mmsi: obj.mmsi, targetByMmsi: shipByMmsi, legacyHits }); + } + if (info.layer && info.layer.id === "pair-lines") { + const aMmsi = toSafeNumber(obj.aMmsi); + const bMmsi = toSafeNumber(obj.bMmsi); + if (aMmsi == null || bMmsi == null) return null; + return getPairLinkTooltipHtml({ + warn: !!obj.warn, + distanceNm: toSafeNumber(obj.distanceNm), + aMmsi, + bMmsi, + legacyHits, + targetByMmsi: shipByMmsi, + }); + } + if (info.layer && info.layer.id === "fc-lines") { + const fcMmsi = toSafeNumber(obj.fcMmsi); + const otherMmsi = toSafeNumber(obj.otherMmsi); + if (fcMmsi == null || otherMmsi == null) return null; + return getFcLinkTooltipHtml({ + suspicious: !!obj.suspicious, + distanceNm: toSafeNumber(obj.distanceNm), + fcMmsi, + otherMmsi, + legacyHits, + targetByMmsi: shipByMmsi, + }); + } + if (info.layer && info.layer.id === "pair-range") { + const aMmsi = toSafeNumber(obj.aMmsi); + const bMmsi = toSafeNumber(obj.bMmsi); + if (aMmsi == null || bMmsi == null) return null; + return getRangeTooltipHtml({ + warn: !!obj.warn, + distanceNm: toSafeNumber(obj.distanceNm), + aMmsi, + bMmsi, + legacyHits, + }); + } + if (info.layer && info.layer.id === "fleet-circles") { + return getFleetCircleTooltipHtml({ + ownerKey: String(obj.ownerKey ?? ""), + ownerLabel: String(obj.ownerKey ?? ""), + count: Number(obj.count ?? 0), + }); + } + return null; + }, + onClick: (info: PickingInfo) => { + if (!info.object) { + onSelectMmsi(null); + return; + } + if (info.layer && info.layer.id === "density") return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const obj: any = info.object; + if (typeof obj.mmsi === "number") { + const t = obj as AisTarget; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + onToggleHighlightMmsi?.(t.mmsi); + return; + } + onSelectMmsi(t.mmsi); + map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 }); + } + }, + }; + + try { + deckTarget.setProps(deckProps as never); + } catch (e) { + console.error("Failed to apply base mercator deck props. Falling back to empty layer set.", e); + try { + deckTarget.setProps({ ...deckProps, layers: [] as unknown[] } as never); + } catch { + // Ignore secondary failure. + } + } + }, [ + ensureMercatorOverlays, + projection, + overlayRef, + projectionBusyRef, + shipLayerData, + shipByMmsi, + pairRanges, + pairLinks, + fcDashed, + fleetCircles, + legacyTargetsOrdered, + legacyHits, + overlays.pairRange, + overlays.pairLines, + overlays.fcLines, + overlays.fleetCircles, + settings.showDensity, + settings.showShips, + onDeckSelectOrHighlight, + onSelectMmsi, + onToggleHighlightMmsi, + setDeckHoverPairs, + clearMapFleetHoverState, + setDeckHoverMmsi, + clearDeckHoverMmsi, + toFleetMmsiList, + touchDeckHoverState, + hasAuxiliarySelectModifier, + ]); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + if (projectionBusyRef.current) return; + + if (projection !== "mercator") { + try { + if (overlayInteractionRef.current) overlayInteractionRef.current.setProps({ layers: [] } as never); + } catch { + // ignore + } + return; + } + + const refs = ensureMercatorOverlays(); + const deckTarget = refs?.interaction; + if (!deckTarget) return; + + const overlayParams = DEPTH_DISABLED_PARAMS; + const overlayLayers: unknown[] = []; + + if (overlays.pairRange && pairRangesInteractive.length > 0) { + overlayLayers.push( + new ScatterplotLayer({ + id: "pair-range-overlay", + data: pairRangesInteractive, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + radiusMinPixels: 10, + lineWidthUnits: "pixels", + getLineWidth: () => 2.2, + getLineColor: (d) => (d.warn ? [245, 158, 11, 220] : [245, 158, 11, 170]), + getPosition: (d) => d.center, + }), + ); + } + + if (overlays.pairLines && pairLinksInteractive.length > 0) { + overlayLayers.push( + new LineLayer({ + id: "pair-lines-overlay", + data: pairLinksInteractive, + pickable: false, + parameters: overlayParams, + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, + getColor: () => [245, 158, 11, 245], + getWidth: () => 2.6, + widthUnits: "pixels", + }), + ); + } + + if (overlays.fcLines && fcLinesInteractive.length > 0) { + overlayLayers.push( + new LineLayer({ + id: "fc-lines-overlay", + data: fcLinesInteractive, + pickable: false, + parameters: overlayParams, + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, + getColor: () => [245, 158, 11, 230], + getWidth: () => 1.9, + widthUnits: "pixels", + }), + ); + } + + if (overlays.fleetCircles && fleetCirclesInteractive.length > 0) { + overlayLayers.push( + new ScatterplotLayer({ + id: "fleet-circles-overlay-fill", + data: fleetCirclesInteractive, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: true, + stroked: false, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + getFillColor: () => [245, 158, 11, 42], + }), + ); + overlayLayers.push( + new ScatterplotLayer({ + id: "fleet-circles-overlay", + data: fleetCirclesInteractive, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + lineWidthUnits: "pixels", + getLineWidth: () => 1.8, + getLineColor: () => [245, 158, 11, 220], + getPosition: (d) => d.center, + }), + ); + } + + if (settings.showShips && legacyOverlayTargets.length > 0) { + overlayLayers.push( + new ScatterplotLayer({ + id: "legacy-halo-overlay", + data: legacyOverlayTargets, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "pixels", + getRadius: (d) => { + if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; + return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; + }, + lineWidthUnits: "pixels", + getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2.2), + getLineColor: (d) => { + if (selectedMmsi && d.mmsi === selectedMmsi) return [14, 234, 255, 230]; + const l = legacyHits?.get(d.mmsi); + const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; + if (!rgb) return [245, 158, 11, 210]; + return [rgb[0], rgb[1], rgb[2], 210]; + }, + getPosition: (d) => [d.lon, d.lat] as [number, number], + }), + ); + } + + if (settings.showShips && shipOverlayLayerData.length > 0) { + overlayLayers.push( + new IconLayer({ + id: "ships-overlay", + data: shipOverlayLayerData, + pickable: false, + billboard: false, + parameters: overlayParams, + iconAtlas: "/assets/ship.svg", + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => "ship", + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => + getDisplayHeading({ + cog: d.cog, + heading: d.heading, + }), + sizeUnits: "pixels", + getSize: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? FLAT_SHIP_ICON_SIZE_SELECTED : FLAT_SHIP_ICON_SIZE_HIGHLIGHTED), + getColor: (d) => + getShipColor( + d, + selectedMmsi, + legacyHits?.get(d.mmsi)?.shipCode ?? null, + shipHighlightSet, + ), + }), + ); + } + + const normalizedLayers = sanitizeDeckLayerList(overlayLayers); + const overlayDeckProps = { + layers: normalizedLayers, + getTooltip: undefined, + onClick: undefined, + }; + + try { + deckTarget.setProps(overlayDeckProps as never); + } catch (e) { + console.error("Failed to apply interaction mercator deck props. Falling back to empty layer set.", e); + try { + deckTarget.setProps({ ...overlayDeckProps, layers: [] as unknown[] } as never); + } catch { + // Ignore secondary failure. + } + } + }, [ + ensureMercatorOverlays, + projection, + projectionBusyRef, + shipOverlayLayerData, + legacyOverlayTargets, + pairRangesInteractive, + pairLinksInteractive, + fcLinesInteractive, + fleetCirclesInteractive, + overlays.pairRange, + overlays.pairLines, + overlays.fcLines, + overlays.fleetCircles, + settings.showShips, + selectedMmsi, + shipHighlightSet, + legacyHits, + ]); + + // Globe deck (3D) layer updates. Keep rendering logic deterministic and avoid per-frame churn. + useEffect(() => { + const map = mapRef.current; + if (!map || projection !== "globe" || projectionBusyRef.current) return; + const deckTarget = globeDeckLayerRef.current; + if (!deckTarget) return; + + const overlayParams = GLOBE_OVERLAY_PARAMS; + const globeLayers: unknown[] = []; + + if (overlays.pairRange && pairRanges.length > 0) { + globeLayers.push( + new ScatterplotLayer({ + id: "pair-range-globe", + data: pairRanges, + pickable: true, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + radiusMinPixels: 10, + lineWidthUnits: "pixels", + getLineWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), + getLineColor: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? [245, 158, 11, 220] : [59, 130, 246, 110]), + getPosition: (d) => d.center, + onHover: (info) => { + if (!info.object) { + clearDeckHoverPairs(); + clearDeckHoverMmsi(); + return; + } + touchDeckHoverState(true); + const p = info.object as PairRangeCircle; + setDeckHoverPairs([p.aMmsi, p.bMmsi]); + setDeckHoverMmsi([p.aMmsi, p.bMmsi]); + clearMapFleetHoverState(); + }, + }), + ); + } + + if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) { + const links = pairLinks || []; + globeLayers.push( + new LineLayer({ + id: "pair-lines-globe", + data: links, + pickable: true, + parameters: overlayParams, + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, + getColor: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? [245, 158, 11, 245] : [59, 130, 246, 85]), + getWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), + widthUnits: "pixels", + onHover: (info) => { + if (!info.object) { + clearDeckHoverPairs(); + clearDeckHoverMmsi(); + return; + } + touchDeckHoverState(true); + const obj = info.object as PairLink; + setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); + setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); + clearMapFleetHoverState(); + }, + }), + ); + } + + if (overlays.fcLines && fcDashed.length > 0) { + globeLayers.push( + new LineLayer({ + id: "fc-lines-globe", + data: fcDashed, + pickable: true, + parameters: overlayParams, + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, + getColor: (d) => { + const isHighlighted = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); + return isHighlighted ? [245, 158, 11, 230] : [217, 119, 6, 200]; + }, + getWidth: (d) => { + const isHighlighted = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); + return isHighlighted ? 1.9 : 1.3; + }, + widthUnits: "pixels", + onHover: (info) => { + if (!info.object) { + clearDeckHoverPairs(); + clearDeckHoverMmsi(); + return; + } + touchDeckHoverState(true); + const obj = info.object as DashSeg; + const aMmsi = obj.fromMmsi; + const bMmsi = obj.toMmsi; + if (aMmsi == null || bMmsi == null) { + clearDeckHoverPairs(); + clearDeckHoverMmsi(); + return; + } + setDeckHoverPairs([aMmsi, bMmsi]); + setDeckHoverMmsi([aMmsi, bMmsi]); + clearMapFleetHoverState(); + }, + }), + ); + } + + if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) { + const circles = fleetCircles || []; + globeLayers.push( + new ScatterplotLayer({ + id: "fleet-circles-globe", + data: circles, + pickable: true, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + lineWidthUnits: "pixels", + getLineWidth: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), + getLineColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? [245, 158, 11, 220] : [245, 158, 11, 140]), + getPosition: (d) => d.center, + onHover: (info) => { + if (!info.object) { + clearDeckHoverPairs(); + clearDeckHoverMmsi(); + clearMapFleetHoverState(); + return; + } + touchDeckHoverState(true); + const obj = info.object as FleetCircle; + const list = toFleetMmsiList(obj.vesselMmsis); + setMapFleetHoverState(obj.ownerKey || null, list); + setDeckHoverMmsi(list); + clearDeckHoverPairs(); + }, + }), + ); + globeLayers.push( + new ScatterplotLayer({ + id: "fleet-circles-fill-globe", + data: circles, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: true, + stroked: false, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + getFillColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? [245, 158, 11, 42] : [245, 158, 11, 6]), + getPosition: (d) => d.center, + }), + ); + } + + if (settings.showShips && legacyTargetsOrdered.length > 0) { + globeLayers.push( + new ScatterplotLayer({ + id: "legacy-halo-globe", + data: legacyTargetsOrdered, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "pixels", + getRadius: (d) => { + if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; + if (isHighlightedMmsi(d.mmsi)) return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; + return FLAT_LEGACY_HALO_RADIUS; + }, + lineWidthUnits: "pixels", + getLineWidth: (d) => { + const isHighlighted = isHighlightedMmsi(d.mmsi); + return selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : isHighlighted ? 2.2 : 2; + }, + getLineColor: (d) => { + if (selectedMmsi && d.mmsi === selectedMmsi) return [14, 234, 255, 230]; + if (isHighlightedMmsi(d.mmsi)) return [245, 158, 11, 210]; + const l = legacyHits?.get(d.mmsi); + const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; + if (!rgb) return [245, 158, 11, 200]; + return [rgb[0], rgb[1], rgb[2], 200]; + }, + getPosition: (d) => [d.lon, d.lat] as [number, number], + }), + ); + } + + if (settings.showShips) { + globeLayers.push( + new IconLayer({ + id: "ships-globe", + data: shipLayerData, + pickable: true, + billboard: false, + parameters: overlayParams, + iconAtlas: "/assets/ship.svg", + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => "ship", + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => + getDisplayHeading({ + cog: d.cog, + heading: d.heading, + }), + sizeUnits: "pixels", + getSize: (d) => { + if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; + if (isHighlightedMmsi(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; + return FLAT_SHIP_ICON_SIZE; + }, + getColor: (d) => + getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, highlightedMmsiSetCombined), + onHover: (info) => { + if (!info.object) { + clearDeckHoverPairs(); + clearDeckHoverMmsi(); + clearMapFleetHoverState(); + return; + } + touchDeckHoverState(true); + const obj = info.object as AisTarget; + setDeckHoverMmsi([obj.mmsi]); + clearDeckHoverPairs(); + clearMapFleetHoverState(); + }, + alphaCutoff: 0.05, + }), + ); + } + + const normalizedLayers = sanitizeDeckLayerList(globeLayers); + const globeDeckProps = { + layers: normalizedLayers, + getTooltip: undefined, + onClick: undefined, + }; + + try { + deckTarget.setProps(globeDeckProps as never); + } catch (e) { + console.error("Failed to apply globe deck props. Falling back to empty deck layer set.", e); + try { + deckTarget.setProps({ ...globeDeckProps, layers: [] as unknown[] } as never); + } catch { + // Ignore secondary failure. + } + } + }, [ + projection, + projectionBusyRef, + pairRanges, + pairLinks, + fcDashed, + fleetCircles, + legacyTargetsOrdered, + shipLayerData, + overlays.pairRange, + overlays.pairLines, + overlays.fcLines, + overlays.fleetCircles, + settings.showShips, + selectedMmsi, + isHighlightedMmsi, + isHighlightedFleet, + isHighlightedPair, + clearDeckHoverPairs, + clearDeckHoverMmsi, + clearMapFleetHoverState, + setDeckHoverPairs, + setDeckHoverMmsi, + setMapFleetHoverState, + toFleetMmsiList, + touchDeckHoverState, + legacyHits, + highlightedMmsiSetCombined, + ]); + // When the selected MMSI changes due to external UI (e.g., list click), fly to it. useEffect(() => { const map = mapRef.current; @@ -3701,589 +4710,5 @@ export function Map3D({ }; }, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]); - // Update Deck.gl layers - useEffect(() => { - const map = mapRef.current; - if (!map) return; - if (projectionBusyRef.current) return; - - let deckTarget = projection === "globe" ? globeDeckLayerRef.current : overlayRef.current; - - if (projection === "mercator") { - if (!deckTarget) deckTarget = ensureMercatorOverlay(); - if (!deckTarget) return; - try { - deckTarget.setProps({ layers: [] } as never); - } catch { - // ignore - } - } else if (!deckTarget && projection === "globe") { - return; - } - if (!deckTarget) return; - - const overlayParams = projection === "globe" ? GLOBE_OVERLAY_PARAMS : DEPTH_DISABLED_PARAMS; - const layers = []; - const clearDeckHover = () => { - touchDeckHoverState(false); - }; - - const toFleetMmsiList = (value: unknown) => { - if (!Array.isArray(value)) return []; - const out: number[] = []; - for (const item of value) { - const v = toIntMmsi(item); - if (v != null) out.push(v); - } - return out; - }; - - const onDeckSelectOrHighlight = (info: unknown, allowMultiSelect = false) => { - const obj = info as { mmsi?: unknown; srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }; - const mmsi = toIntMmsi(obj.mmsi); - if (mmsi == null) return; - const evt = obj.srcEvent ?? null; - const isAux = hasAuxiliarySelectModifier(evt); - if (onToggleHighlightMmsi && isAux) { - onToggleHighlightMmsi(mmsi); - return; - } - if (!allowMultiSelect && selectedMmsi === mmsi) { - onSelectMmsi(null); - return; - } - onSelectMmsi(mmsi); - }; - - if (settings.showDensity && projection !== "globe") { - layers.push( - new HexagonLayer({ - id: "density", - data: shipLayerData, - pickable: true, - extruded: true, - radius: 2500, - elevationScale: 35, - coverage: 0.92, - opacity: 0.35, - getPosition: (d) => [d.lon, d.lat], - }), - ); - } - - if (overlays.pairRange && projection !== "globe" && pairRanges.length > 0) { - layers.push( - new ScatterplotLayer({ - id: "pair-range", - data: pairRanges, - pickable: true, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "meters", - getRadius: (d) => d.radiusNm * 1852, - radiusMinPixels: 10, - lineWidthUnits: "pixels", - getLineWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), - getLineColor: (d) => { - if (isHighlightedPair(d.aMmsi, d.bMmsi)) return [245, 158, 11, 220]; - return d.warn ? [245, 158, 11, 170] : [59, 130, 246, 110]; - }, - getPosition: (d) => d.center, - onHover: (info) => { - if (!info.object) { - clearDeckHover(); - return; - } - touchDeckHoverState(true); - const p = info.object as PairRangeCircle; - const aMmsi = p.aMmsi; - const bMmsi = p.bMmsi; - setDeckHoverPairs([aMmsi, bMmsi]); - setDeckHoverMmsi([aMmsi, bMmsi]); - clearMapFleetHoverState(); - }, - onClick: (info) => { - if (!info.object) { - onSelectMmsi(null); - return; - } - const obj = info.object as PairRangeCircle; - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - onToggleHighlightMmsi?.(obj.aMmsi); - onToggleHighlightMmsi?.(obj.bMmsi); - return; - } - onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); - }, - updateTriggers: { - getLineWidth: [hoveredPairSignature], - getLineColor: [hoveredPairSignature], - }, - }), - ); - } - - if (overlays.pairLines && projection !== "globe" && (pairLinks?.length ?? 0) > 0) { - layers.push( - new LineLayer({ - id: "pair-lines", - data: pairLinks, - pickable: true, - parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, - getColor: (d) => { - if (isHighlightedPair(d.aMmsi, d.bMmsi)) return [245, 158, 11, 245]; - return d.warn ? [245, 158, 11, 220] : [59, 130, 246, 85]; - }, - getWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), - widthUnits: "pixels", - onHover: (info) => { - if (!info.object) { - clearDeckHover(); - return; - } - touchDeckHoverState(true); - const obj = info.object as PairLink; - setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); - setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); - clearMapFleetHoverState(); - }, - onClick: (info) => { - if (!info.object) return; - const obj = info.object as PairLink; - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - onToggleHighlightMmsi?.(obj.aMmsi); - onToggleHighlightMmsi?.(obj.bMmsi); - return; - } - onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); - }, - updateTriggers: { - getColor: [hoveredPairSignature], - getWidth: [hoveredPairSignature], - }, - }), - ); - } - - if (overlays.fcLines && projection !== "globe" && fcDashed.length > 0) { - layers.push( - new LineLayer({ - id: "fc-lines", - data: fcDashed, - pickable: true, - parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, - getColor: (d) => { - const isHighlighted = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); - if (isHighlighted) return [245, 158, 11, 230]; - return d.suspicious ? [239, 68, 68, 220] : [217, 119, 6, 200]; - }, - getWidth: (d) => { - const isHighlighted = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); - return isHighlighted ? 1.9 : 1.3; - }, - widthUnits: "pixels", - onHover: (info) => { - if (!info.object) { - clearDeckHover(); - return; - } - touchDeckHoverState(true); - const obj = info.object as DashSeg; - const aMmsi = obj.fromMmsi; - const bMmsi = obj.toMmsi; - if (aMmsi == null || bMmsi == null) { - clearDeckHover(); - return; - } - setDeckHoverPairs([aMmsi, bMmsi]); - setDeckHoverMmsi([aMmsi, bMmsi]); - clearMapFleetHoverState(); - }, - onClick: (info) => { - if (!info.object) return; - const obj = info.object as DashSeg; - if (obj.fromMmsi == null || obj.toMmsi == null) { - return; - } - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - onToggleHighlightMmsi?.(obj.fromMmsi); - onToggleHighlightMmsi?.(obj.toMmsi); - return; - } - onDeckSelectOrHighlight({ mmsi: obj.fromMmsi }); - }, - updateTriggers: { - getColor: [hoveredShipSignature], - getWidth: [hoveredShipSignature], - }, - }), - ); - } - - if (overlays.fleetCircles && projection !== "globe" && (fleetCircles?.length ?? 0) > 0) { - layers.push( - new ScatterplotLayer({ - id: "fleet-circles", - data: fleetCircles, - pickable: true, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "meters", - getRadius: (d) => d.radiusNm * 1852, - lineWidthUnits: "pixels", - getLineWidth: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), - getLineColor: (d) => { - const isHighlighted = isHighlightedFleet(d.ownerKey, d.vesselMmsis); - return isHighlighted ? [245, 158, 11, 220] : [245, 158, 11, 140]; - }, - getPosition: (d) => d.center, - onHover: (info) => { - if (!info.object) { - clearDeckHover(); - return; - } - touchDeckHoverState(true); - const obj = info.object as FleetCircle; - const list = toFleetMmsiList(obj.vesselMmsis); - setMapFleetHoverState(obj.ownerKey || null, list); - setDeckHoverMmsi(list); - clearDeckHoverPairs(); - }, - onClick: (info) => { - if (!info.object) return; - const obj = info.object as FleetCircle; - const list = toFleetMmsiList(obj.vesselMmsis); - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - for (const mmsi of list) onToggleHighlightMmsi?.(mmsi); - return; - } - const first = list[0]; - if (first != null) { - onDeckSelectOrHighlight({ mmsi: first }); - } - }, - updateTriggers: { - getLineWidth: [hoveredFleetSignature, hoveredFleetOwnerKey, hoveredShipSignature], - getLineColor: [hoveredFleetSignature, hoveredFleetOwnerKey, hoveredShipSignature], - }, - }), - ); - } - - if (overlays.fleetCircles && projection !== "globe" && (fleetCircles?.length ?? 0) > 0) { - layers.push( - new ScatterplotLayer({ - id: "fleet-circles-fill", - data: fleetCircles, - pickable: false, - billboard: false, - parameters: overlayParams, - filled: true, - stroked: false, - radiusUnits: "meters", - getRadius: (d) => d.radiusNm * 1852, - getFillColor: (d) => { - const isHighlighted = isHighlightedFleet(d.ownerKey, d.vesselMmsis); - return isHighlighted ? [245, 158, 11, 42] : [245, 158, 11, 6]; - }, - getPosition: (d) => d.center, - updateTriggers: { - getFillColor: [hoveredFleetSignature, hoveredFleetOwnerKey, hoveredShipSignature], - }, - }), - ); - } - - if (settings.showShips && projection !== "globe" && legacyTargets.length > 0) { - layers.push( - new ScatterplotLayer({ - id: "legacy-halo", - data: legacyTargetsOrdered, - pickable: false, - billboard: false, - // This ring is most prone to z-fighting, so force it into pure painter's-order rendering. - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "pixels", - getRadius: (d) => { - if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; - if (isHighlightedMmsi(d.mmsi)) return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; - return FLAT_LEGACY_HALO_RADIUS; - }, - lineWidthUnits: "pixels", - getLineWidth: (d) => { - const isHighlighted = isHighlightedMmsi(d.mmsi); - return selectedMmsi && d.mmsi === selectedMmsi - ? 2.5 - : isHighlighted - ? 2.2 - : 2; - }, - getLineColor: (d) => { - if (selectedMmsi && d.mmsi === selectedMmsi) return [14, 234, 255, 230]; - if (isHighlightedMmsi(d.mmsi)) return [245, 158, 11, 210]; - const l = legacyHits?.get(d.mmsi); - const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; - if (!rgb) return [245, 158, 11, 200]; - return [rgb[0], rgb[1], rgb[2], 200]; - }, - getPosition: (d) => [d.lon, d.lat] as [number, number], - updateTriggers: { - getRadius: [selectedMmsi, hoveredShipSignature], - getLineColor: [selectedMmsi, legacyHits, hoveredShipSignature], - }, - }), - ); - } - - if (settings.showShips && projection !== "globe") { - layers.push( - new IconLayer({ - id: "ships", - data: shipLayerData, - pickable: true, - // Keep icons horizontal on the sea surface when view is pitched/rotated. - billboard: false, - parameters: overlayParams, - iconAtlas: "/assets/ship.svg", - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => "ship", - getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => - getDisplayHeading({ - cog: d.cog, - heading: d.heading, - }), - sizeUnits: "pixels", - getSize: (d) => { - if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; - if (isHighlightedMmsi(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; - return FLAT_SHIP_ICON_SIZE; - }, - getColor: (d) => - getShipColor( - d, - selectedMmsi, - legacyHits?.get(d.mmsi)?.shipCode ?? null, - highlightedMmsiSetCombined, - ), - onHover: (info) => { - if (!info.object) { - clearDeckHover(); - return; - } - touchDeckHoverState(true); - const obj = info.object as AisTarget; - setDeckHoverMmsi([obj.mmsi]); - clearDeckHoverPairs(); - clearMapFleetHoverState(); - }, - onClick: (info) => { - if (!info.object) { - onSelectMmsi(null); - return; - } - onDeckSelectOrHighlight({ - mmsi: info.object.mmsi, - srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent, - }, true); - }, - alphaCutoff: 0.05, - updateTriggers: { - getSize: [selectedMmsi, hoveredShipSignature], - getColor: [selectedMmsi, legacyHits, hoveredShipSignature], - }, - }), - ); - } - - const normalizedLayers = sanitizeDeckLayerList(layers); - - const deckProps = { - layers: normalizedLayers, - getTooltip: - projection === "globe" - ? undefined - : (info: PickingInfo) => { - if (!info.object) return null; - if (info.layer && info.layer.id === "density") { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const o: any = info.object; - const n = Array.isArray(o?.points) ? o.points.length : 0; - return { text: `AIS density: ${n}` }; - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const obj: any = info.object; - if (typeof obj.mmsi === "number") { - return getShipTooltipHtml({ mmsi: obj.mmsi, targetByMmsi: shipByMmsi, legacyHits }); - } - - if (info.layer && info.layer.id === "pair-lines") { - const aMmsi = toSafeNumber(obj.aMmsi) ?? toSafeNumber(obj.fromMmsi); - const bMmsi = toSafeNumber(obj.bMmsi) ?? toSafeNumber(obj.toMmsi); - if (aMmsi == null || bMmsi == null) return null; - return getPairLinkTooltipHtml({ - warn: !!obj.warn, - distanceNm: toSafeNumber(obj.distanceNm), - aMmsi, - bMmsi, - legacyHits, - targetByMmsi: shipByMmsi, - }); - } - - if (info.layer && info.layer.id === "fc-lines") { - const fcMmsi = toSafeNumber(obj.fcMmsi) ?? toSafeNumber(obj.fromMmsi); - const otherMmsi = toSafeNumber(obj.otherMmsi) ?? toSafeNumber(obj.toMmsi); - if (fcMmsi == null || otherMmsi == null) return null; - return getFcLinkTooltipHtml({ - suspicious: !!obj.suspicious, - distanceNm: toSafeNumber(obj.distanceNm), - fcMmsi, - otherMmsi, - legacyHits, - targetByMmsi: shipByMmsi, - }); - } - - if (info.layer && info.layer.id === "pair-range") { - const aMmsi = toSafeNumber(obj.aMmsi) ?? toSafeNumber(obj.fromMmsi); - const bMmsi = toSafeNumber(obj.bMmsi) ?? toSafeNumber(obj.toMmsi); - if (aMmsi == null || bMmsi == null) return null; - return getRangeTooltipHtml({ - warn: !!obj.warn, - distanceNm: toSafeNumber(obj.distanceNm), - aMmsi, - bMmsi, - legacyHits, - }); - } - - if (info.layer && info.layer.id === "fleet-circles") { - return getFleetCircleTooltipHtml({ - ownerKey: String(obj.ownerKey ?? ""), - ownerLabel: String(obj.ownerLabel ?? obj.ownerKey ?? ""), - count: Number(obj.count ?? 0), - }); - } - - const p = obj.properties as Record | undefined; - const label = getZoneDisplayNameFromProps(p); - if (label) return { text: label }; - return null; - }, - onClick: - projection === "globe" - ? undefined - : (info: PickingInfo) => { - if (!info.object) { - onSelectMmsi(null); - return; - } - if (info.layer && info.layer.id === "density") return; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const obj: any = info.object; - if (typeof obj.mmsi === "number") { - const t = obj as AisTarget; - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - onToggleHighlightMmsi?.(t.mmsi); - return; - } - onSelectMmsi(t.mmsi); - map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 }); - } - }, - } as const; - - const safeDeckProps = { ...deckProps, layers: normalizedLayers }; - const fallbackDeckProps = { ...safeDeckProps, layers: [] as unknown[] }; - const applyDeckProps = () => { - if (projection === "globe") { - const target = globeDeckLayerRef.current; - if (!target) return; - try { - target.setProps(safeDeckProps as never); - } catch (e) { - console.error("Failed to apply deck props on globe overlay. Falling back to empty deck layer set.", e); - try { - target.setProps(fallbackDeckProps as never); - } catch { - // Ignore secondary failure; rendering will recover on next update. - } - } - return; - } - - const target = overlayRef.current; - if (!target) return; - try { - target.setProps(safeDeckProps as unknown as never); - } catch (e) { - console.error("Failed to apply deck props on mercator overlay. Falling back to empty deck layer set.", e); - try { - target.setProps(fallbackDeckProps as unknown as never); - } catch { - // Ignore secondary failure. - } - } - }; - - applyDeckProps(); - }, [ - projection, - shipLayerData, - legacyTargetsOrdered, - baseMap, - zones, - selectedMmsi, - overlays.zones, - settings.showShips, - settings.showDensity, - onSelectMmsi, - legacyHits, - legacyTargets, - overlays.pairLines, - overlays.pairRange, - overlays.fcLines, - overlays.fleetCircles, - pairLinks, - pairRanges, - fcDashed, - fleetCircles, - shipByMmsi, - mapSyncEpoch, - hoveredShipSignature, - hoveredFleetSignature, - hoveredPairSignature, - hoveredFleetOwnerKey, - highlightedMmsiSetCombined, - onToggleHighlightMmsi, - isHighlightedMmsi, - isHighlightedFleet, - isHighlightedPair, - setDeckHoverMmsi, - clearDeckHoverMmsi, - setDeckHoverPairs, - clearDeckHoverPairs, - clearMapFleetHoverState, - setMapFleetHoverState, - touchDeckHoverState, - ensureMercatorOverlay, - ]); - return
; } From 30e6e584ee553d3ba063d0a333833780176f871b Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 16:35:05 +0900 Subject: [PATCH 34/58] refactor(map3d): isolate ship hover overlay for icon flicker reduction --- apps/web/src/widgets/map3d/Map3D.tsx | 142 ++++++++++++++++----------- 1 file changed, 84 insertions(+), 58 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 2050554..2468837 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -575,6 +575,7 @@ const FLAT_SHIP_ICON_SIZE_HIGHLIGHTED = 25; const FLAT_LEGACY_HALO_RADIUS = 14; const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18; const FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED = 16; +const EMPTY_MMSI_SET = new Set(); const GLOBE_OVERLAY_PARAMS = { // In globe mode we want depth-testing against the globe so features on the far side don't draw through. @@ -1079,6 +1080,13 @@ export function Map3D({ onHoverPair, onClearPairHover, }: Props) { + void onHoverFleet; + void onClearFleetHover; + void onHoverMmsi; + void onClearMmsiHover; + void onHoverPair; + void onClearPairHover; + const containerRef = useRef(null); const mapRef = useRef(null); const overlayRef = useRef(null); @@ -1196,6 +1204,18 @@ export function Map3D({ (mmsi: number) => highlightedMmsiSetCombined.has(mmsi), [highlightedMmsiSetCombined], ); + const baseHighlightedMmsiSet = useMemo(() => { + const out = new Set(); + if (selectedMmsi != null) out.add(selectedMmsi); + externalHighlightedSetRef.forEach((value) => { + out.add(value); + }); + return out; + }, [selectedMmsi, externalHighlightedSetRef]); + const isBaseHighlightedMmsi = useCallback( + (mmsi: number) => baseHighlightedMmsiSet.has(mmsi), + [baseHighlightedMmsiSet], + ); const isHighlightedPair = useCallback( (aMmsi: number, bMmsi: number) => @@ -1274,12 +1294,6 @@ export function Map3D({ setHoveredDeckFleetOwnerKey((prev) => (prev === ownerKey ? prev : ownerKey)); }, []); - const onHoverFleetRef = useRef(onHoverFleet); - const onClearFleetHoverRef = useRef(onClearFleetHover); - const onHoverMmsiRef = useRef(onHoverMmsi); - const onClearMmsiHoverRef = useRef(onClearMmsiHover); - const onHoverPairRef = useRef(onHoverPair); - const onClearPairHoverRef = useRef(onClearPairHover); const mapDeckMmsiHoverRef = useRef([]); const mapDeckPairHoverRef = useRef([]); const mapFleetHoverStateRef = useRef<{ @@ -1287,43 +1301,20 @@ export function Map3D({ vesselMmsis: number[]; }>({ ownerKey: null, vesselMmsis: [] }); - useEffect(() => { - onHoverFleetRef.current = onHoverFleet; - onClearFleetHoverRef.current = onClearFleetHover; - onHoverMmsiRef.current = onHoverMmsi; - onClearMmsiHoverRef.current = onClearMmsiHover; - onHoverPairRef.current = onHoverPair; - onClearPairHoverRef.current = onClearPairHover; - }, [onHoverFleet, onClearFleetHover, onHoverMmsi, onClearMmsiHover, onHoverPair, onClearPairHover]); - const clearMapFleetHoverState = useCallback(() => { - const nextOwner = null; - const prev = mapFleetHoverStateRef.current; - const shouldNotify = prev.ownerKey !== null || prev.vesselMmsis.length !== 0; - mapFleetHoverStateRef.current = { ownerKey: nextOwner, vesselMmsis: [] }; + mapFleetHoverStateRef.current = { ownerKey: null, vesselMmsis: [] }; setHoveredDeckFleetOwner(null); setHoveredDeckFleetMmsis([]); - if (shouldNotify) { - onClearFleetHoverRef.current?.(); - } }, [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis]); const clearDeckHoverPairs = useCallback(() => { - const prev = mapDeckPairHoverRef.current; mapDeckPairHoverRef.current = []; setHoveredDeckPairMmsiSet((prevState) => (prevState.length === 0 ? prevState : [])); - if (prev.length > 0) { - onClearPairHoverRef.current?.(); - } }, [setHoveredDeckPairMmsiSet]); const clearDeckHoverMmsi = useCallback(() => { - const prev = mapDeckMmsiHoverRef.current; mapDeckMmsiHoverRef.current = []; setHoveredDeckMmsiSet((prevState) => (prevState.length === 0 ? prevState : [])); - if (prev.length > 0) { - onClearMmsiHoverRef.current?.(); - } }, [setHoveredDeckMmsiSet]); const scheduleDeckHoverResolve = useCallback(() => { @@ -1352,10 +1343,7 @@ export function Map3D({ const normalized = makeUniqueSorted(next); touchDeckHoverState(normalized.length > 0); setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); - if (!equalNumberArrays(mapDeckMmsiHoverRef.current, normalized)) { - mapDeckMmsiHoverRef.current = normalized; - onHoverMmsiRef.current?.(normalized); - } + mapDeckMmsiHoverRef.current = normalized; }, [setHoveredDeckMmsiSet, touchDeckHoverState], ); @@ -1365,10 +1353,7 @@ export function Map3D({ const normalized = makeUniqueSorted(next); touchDeckHoverState(normalized.length > 0); setHoveredDeckPairMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); - if (!equalNumberArrays(mapDeckPairHoverRef.current, normalized)) { - mapDeckPairHoverRef.current = normalized; - onHoverPairRef.current?.(normalized); - } + mapDeckPairHoverRef.current = normalized; }, [setHoveredDeckPairMmsiSet, touchDeckHoverState], ); @@ -1384,7 +1369,6 @@ export function Map3D({ setHoveredDeckFleetOwner(ownerKey); setHoveredDeckFleetMmsis(normalized); mapFleetHoverStateRef.current = { ownerKey, vesselMmsis: normalized }; - onHoverFleetRef.current?.(ownerKey, normalized); }, [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis, touchDeckHoverState], ); @@ -2360,7 +2344,7 @@ export function Map3D({ const hull = clampNumber((isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, 50, 420); const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); const selected = t.mmsi === selectedMmsi; - const highlighted = isHighlightedMmsi(t.mmsi); + const highlighted = isBaseHighlightedMmsi(t.mmsi); const selectedScale = selected ? 1.08 : 1; const highlightScale = highlighted ? 1.06 : 1; const iconScale = selected ? selectedScale : highlightScale; @@ -2639,10 +2623,7 @@ export function Map3D({ shipData, legacyHits, selectedMmsi, - hoveredMmsiSetRef, - hoveredFleetMmsiSetRef, - hoveredPairMmsiSetRef, - isHighlightedMmsi, + isBaseHighlightedMmsi, mapSyncEpoch, reorderGlobeFeatureLayers, ]); @@ -3368,10 +3349,8 @@ export function Map3D({ const shipLayerData = useMemo(() => { if (shipData.length === 0) return shipData; - const layer = [...shipData]; - layer.sort((a, b) => a.mmsi - b.mmsi); - return layer; - }, [shipData, isHighlightedMmsi, selectedMmsi]); + return [...shipData]; + }, [shipData]); const shipHighlightSet = useMemo(() => { const out = new Set(highlightedMmsiSetCombined); @@ -3380,9 +3359,9 @@ export function Map3D({ }, [highlightedMmsiSetCombined, selectedMmsi]); const shipOverlayLayerData = useMemo(() => { - if (shipHighlightSet.size === 0) return []; - return shipLayerData.filter((target) => shipHighlightSet.has(target.mmsi)); - }, [shipLayerData, shipHighlightSet]); + if (shipLayerData.length === 0) return shipLayerData; + return shipLayerData; + }, [shipLayerData]); const clearGlobeTooltip = useCallback(() => { if (!mapTooltipRef.current) return; @@ -4029,7 +4008,7 @@ export function Map3D({ d, null, legacyHits?.get(d.mmsi)?.shipCode ?? null, - new Set(), + EMPTY_MMSI_SET, ), onHover: (info) => { if (!info.object) { @@ -4337,14 +4316,20 @@ export function Map3D({ heading: d.heading, }), sizeUnits: "pixels", - getSize: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? FLAT_SHIP_ICON_SIZE_SELECTED : FLAT_SHIP_ICON_SIZE_HIGHLIGHTED), - getColor: (d) => - getShipColor( + getSize: (d) => { + if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; + if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; + return 0; + }, + getColor: (d) => { + if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; + return getShipColor( d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet, - ), + ); + }, }), ); } @@ -4603,11 +4588,15 @@ export function Map3D({ sizeUnits: "pixels", getSize: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; - if (isHighlightedMmsi(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return FLAT_SHIP_ICON_SIZE; }, getColor: (d) => - getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, highlightedMmsiSetCombined), + getShipColor( + d, + selectedMmsi, + legacyHits?.get(d.mmsi)?.shipCode ?? null, + EMPTY_MMSI_SET, + ), onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); @@ -4626,6 +4615,43 @@ export function Map3D({ ); } + if (settings.showShips) { + globeLayers.push( + new IconLayer({ + id: "ships-globe-hover", + data: shipLayerData, + pickable: false, + billboard: false, + parameters: overlayParams, + iconAtlas: "/assets/ship.svg", + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => "ship", + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => + getDisplayHeading({ + cog: d.cog, + heading: d.heading, + }), + sizeUnits: "pixels", + getSize: (d) => { + if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; + if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; + return 0; + }, + getColor: (d) => { + if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; + return getShipColor( + d, + selectedMmsi, + legacyHits?.get(d.mmsi)?.shipCode ?? null, + shipHighlightSet, + ); + }, + alphaCutoff: 0.05, + }), + ); + } + const normalizedLayers = sanitizeDeckLayerList(globeLayers); const globeDeckProps = { layers: normalizedLayers, From 3497b8c7e2ae424cc2a22f4d42131378a5e51773 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 18:42:49 +0900 Subject: [PATCH 35/58] feat(dashboard): alarms filter + legend/palette sync + map polish --- apps/web/index.html | 4 +- apps/web/public/favicon.svg | 21 + apps/web/src/app/styles.css | 69 + .../features/legacyDashboard/model/derive.ts | 33 +- .../features/legacyDashboard/model/types.ts | 16 + .../web/src/pages/dashboard/DashboardPage.tsx | 97 +- apps/web/src/shared/lib/map/palette.ts | 58 + apps/web/src/widgets/alarms/AlarmsPanel.tsx | 8 +- apps/web/src/widgets/info/VesselInfoPanel.tsx | 3 +- apps/web/src/widgets/legend/MapLegend.tsx | 45 +- apps/web/src/widgets/map3d/Map3D.tsx | 1410 +++++++++++------ .../src/widgets/relations/RelationsPanel.tsx | 7 +- 12 files changed, 1255 insertions(+), 516 deletions(-) create mode 100644 apps/web/public/favicon.svg create mode 100644 apps/web/src/shared/lib/map/palette.ts diff --git a/apps/web/index.html b/apps/web/index.html index 7277cb1..8dd863c 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,9 +2,9 @@ - + - 906척 실시간 조업 감시 — 선단 연관관계 + WING 조업감시 데모
diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..8490944 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/apps/web/src/app/styles.css b/apps/web/src/app/styles.css index b5b2d94..36528cf 100644 --- a/apps/web/src/app/styles.css +++ b/apps/web/src/app/styles.css @@ -465,6 +465,75 @@ body { white-space: nowrap; } +/* Alarm filter (dropdown) */ +.alarm-filter { + position: relative; +} + +.alarm-filter__summary { + list-style: none; + cursor: pointer; + padding: 2px 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: rgba(30, 41, 59, 0.55); + color: var(--text); + font-size: 8px; + font-weight: 700; + letter-spacing: 0.4px; + user-select: none; + white-space: nowrap; +} + +.alarm-filter__summary::-webkit-details-marker { + display: none; +} + +.alarm-filter__menu { + position: absolute; + right: 0; + top: 22px; + z-index: 2000; + min-width: 170px; + padding: 6px; + border-radius: 10px; + border: 1px solid var(--border); + background: rgba(15, 23, 42, 0.98); + box-shadow: 0 16px 50px rgba(0, 0, 0, 0.55); +} + +.alarm-filter__row { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; + border-radius: 6px; + cursor: pointer; + font-size: 10px; + color: var(--text); + user-select: none; +} + +.alarm-filter__row:hover { + background: rgba(59, 130, 246, 0.08); +} + +.alarm-filter__row input { + cursor: pointer; +} + +.alarm-filter__cnt { + margin-left: auto; + font-size: 9px; + color: var(--muted); +} + +.alarm-filter__sep { + height: 1px; + background: rgba(30, 58, 95, 0.85); + margin: 4px 0; +} + /* Relation panel */ .rel-panel { background: var(--card); diff --git a/apps/web/src/features/legacyDashboard/model/derive.ts b/apps/web/src/features/legacyDashboard/model/derive.ts index cf2d569..01f839a 100644 --- a/apps/web/src/features/legacyDashboard/model/derive.ts +++ b/apps/web/src/features/legacyDashboard/model/derive.ts @@ -301,14 +301,33 @@ export function computeLegacyAlarms(args: { }); } - // Most recent first by timeLabel (approx), then by severity. - const sevScore = (s: "cr" | "hi") => (s === "cr" ? 2 : 1); + // Fixed category priority (independent of severity): + // 1) 수역 이탈 2) 쌍 이격 경고 3) 환적 의심 4) 휴어기 조업 의심 5) AIS 지연 + // Within each category: most recent first (smaller N in "-N분" is more recent). + const kindPriority: Record = { + zone_violation: 0, + pair_separation: 1, + transshipment: 2, + closed_season: 3, + ais_stale: 4, + }; + + const parseAgeMin = (label: string) => { + if (label === "방금") return 0; + const m = /-(\\d+)분/.exec(label); + if (m) return Number(m[1]); + return Number.POSITIVE_INFINITY; + }; + alarms.sort((a, b) => { - const av = sevScore(b.severity) - sevScore(a.severity); - if (av !== 0) return av; - const at = Number(a.timeLabel.replace(/[^0-9]/g, "")) || 0; - const bt = Number(b.timeLabel.replace(/[^0-9]/g, "")) || 0; - return at - bt; + const ak = kindPriority[a.kind] ?? 999; + const bk = kindPriority[b.kind] ?? 999; + if (ak !== bk) return ak - bk; + const am = parseAgeMin(a.timeLabel); + const bm = parseAgeMin(b.timeLabel); + if (am !== bm) return am - bm; + // Stable tie-break. + return a.text.localeCompare(b.text); }); return alarms; diff --git a/apps/web/src/features/legacyDashboard/model/types.ts b/apps/web/src/features/legacyDashboard/model/types.ts index 3ff2bf5..c2c4b12 100644 --- a/apps/web/src/features/legacyDashboard/model/types.ts +++ b/apps/web/src/features/legacyDashboard/model/types.ts @@ -72,3 +72,19 @@ export type LegacyAlarm = { text: string; relatedMmsi: number[]; }; + +export const LEGACY_ALARM_KINDS: LegacyAlarmKind[] = [ + "pair_separation", + "transshipment", + "closed_season", + "ais_stale", + "zone_violation", +]; + +export const LEGACY_ALARM_KIND_LABEL: Record = { + pair_separation: "쌍 이격 경고", + transshipment: "환적 의심", + closed_season: "휴어기 조업 의심", + ais_stale: "AIS 지연", + zone_violation: "수역 이탈", +}; diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index edd7764..50dea15 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -4,7 +4,8 @@ import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettings import type { MapToggleState } from "../../features/mapToggles/MapToggles"; import { MapToggles } from "../../features/mapToggles/MapToggles"; import { TypeFilterGrid } from "../../features/typeFilter/TypeFilterGrid"; -import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types"; +import type { DerivedLegacyVessel, LegacyAlarmKind } from "../../features/legacyDashboard/model/types"; +import { LEGACY_ALARM_KIND_LABEL, LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types"; import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels"; import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib"; import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib"; @@ -117,6 +118,10 @@ export function DashboardPage() { }); const [fleetRelationSortMode, setFleetRelationSortMode] = useState("count"); + const [alarmKindEnabled, setAlarmKindEnabled] = useState>(() => { + return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record; + }); + const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined); const [settings, setSettings] = useState({ @@ -183,6 +188,26 @@ export function DashboardPage() { const fcLinksAll = useMemo(() => computeFcLinks(legacyVesselsAll), [legacyVesselsAll]); const alarms = useMemo(() => computeLegacyAlarms({ vessels: legacyVesselsAll, pairLinks: pairLinksAll, fcLinks: fcLinksAll }), [legacyVesselsAll, pairLinksAll, fcLinksAll]); + const alarmKindCounts = useMemo(() => { + const base = Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, 0])) as Record; + for (const a of alarms) { + base[a.kind] = (base[a.kind] ?? 0) + 1; + } + return base; + }, [alarms]); + + const enabledAlarmKinds = useMemo(() => { + return LEGACY_ALARM_KINDS.filter((k) => alarmKindEnabled[k]); + }, [alarmKindEnabled]); + + const allAlarmKindsEnabled = enabledAlarmKinds.length === LEGACY_ALARM_KINDS.length; + + const filteredAlarms = useMemo(() => { + if (allAlarmKindsEnabled) return alarms; + const enabled = new Set(enabledAlarmKinds); + return alarms.filter((a) => enabled.has(a.kind)); + }, [alarms, enabledAlarmKinds, allAlarmKindsEnabled]); + const pairLinksForMap = useMemo(() => computePairLinks(legacyVesselsFiltered), [legacyVesselsFiltered]); const fcLinksForMap = useMemo(() => computeFcLinks(legacyVesselsFiltered), [legacyVesselsFiltered]); const fleetCirclesForMap = useMemo(() => computeFleetCircles(legacyVesselsFiltered), [legacyVesselsFiltered]); @@ -253,6 +278,8 @@ export function DashboardPage() { const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]); const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode; + const enabledAlarmKindCount = enabledAlarmKinds.length; + const alarmFilterSummary = allAlarmKindsEnabled ? "전체" : `${enabledAlarmKindCount}/${LEGACY_ALARM_KINDS.length}`; return (
@@ -339,7 +366,7 @@ export function DashboardPage() { 지구본
-
지도 우하단 Attribution(라이센스) 표기 유지
+ {/* Attribution (license) stays visible in the map UI; no need to repeat it here. */}
@@ -421,9 +448,69 @@ export function DashboardPage() { />
-
-
실시간 경고
- +
+
+
+ 실시간 경고{" "} + + ({filteredAlarms.length}/{alarms.length}) + +
+ + {LEGACY_ALARM_KINDS.length <= 3 ? ( +
+ {LEGACY_ALARM_KINDS.map((k) => ( + + ))} +
+ ) : ( +
+ + {alarmFilterSummary} + +
+ +
+ {LEGACY_ALARM_KINDS.map((k) => ( + + ))} +
+
+ )} +
+ +
+ +
{adminMode ? ( diff --git a/apps/web/src/shared/lib/map/palette.ts b/apps/web/src/shared/lib/map/palette.ts new file mode 100644 index 0000000..9dc1c56 --- /dev/null +++ b/apps/web/src/shared/lib/map/palette.ts @@ -0,0 +1,58 @@ +export type Rgb = [number, number, number]; +export type DeckRgba = [number, number, number, number]; + +export function rgbToHex(rgb: Rgb): string { + const toHex = (v: number) => { + const clamped = Math.max(0, Math.min(255, Math.round(v))); + return clamped.toString(16).padStart(2, "0"); + }; + return `#${toHex(rgb[0])}${toHex(rgb[1])}${toHex(rgb[2])}`; +} + +export function rgba(rgb: Rgb, alpha01: number): string { + const a = Math.max(0, Math.min(1, alpha01)); + return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`; +} + +export function deckRgba(rgb: Rgb, alpha255: number): DeckRgba { + const a = Math.max(0, Math.min(255, Math.round(alpha255))); + return [rgb[0], rgb[1], rgb[2], a]; +} + +// CN-permit (legacy) ship category colors. Used by both map layers and legend. +export const LEGACY_CODE_COLORS_RGB: Record = { + PT: [30, 64, 175], // #1e40af + "PT-S": [234, 88, 12], // #ea580c + GN: [16, 185, 129], // #10b981 + OT: [139, 92, 246], // #8b5cf6 + PS: [239, 68, 68], // #ef4444 + FC: [245, 158, 11], // #f59e0b + C21: [236, 72, 153], // #ec4899 +}; + +export const LEGACY_CODE_COLORS_HEX: Record = Object.fromEntries( + Object.entries(LEGACY_CODE_COLORS_RGB).map(([k, rgb]) => [k, rgbToHex(rgb)]), +) as Record; + +// Non-target AIS ships should be visible but muted (speed encoded mainly via brightness). +export const OTHER_AIS_SPEED_RGB = { + fast: [148, 163, 184] as Rgb, // SOG >= 10 + moving: [100, 116, 139] as Rgb, // 1 <= SOG < 10 + stopped: [71, 85, 105] as Rgb, // SOG < 1 +}; + +export const OTHER_AIS_SPEED_HEX = { + fast: rgbToHex(OTHER_AIS_SPEED_RGB.fast), + moving: rgbToHex(OTHER_AIS_SPEED_RGB.moving), + stopped: rgbToHex(OTHER_AIS_SPEED_RGB.stopped), +}; + +// Overlay palette: keep a cohesive "warm alert" family, but ensure each overlay type is distinguishable. +export const OVERLAY_RGB = { + pairNormal: [59, 130, 246] as Rgb, // blue-500 + pairWarn: [251, 113, 133] as Rgb, // rose-400 (쌍 이격 경고) + fcTransfer: [249, 115, 22] as Rgb, // orange-500 (환적 연결) + fleetRange: [250, 204, 21] as Rgb, // yellow-400 (선단 범위) + suspicious: [239, 68, 68] as Rgb, // red-500 +}; + diff --git a/apps/web/src/widgets/alarms/AlarmsPanel.tsx b/apps/web/src/widgets/alarms/AlarmsPanel.tsx index 215676b..35f79d8 100644 --- a/apps/web/src/widgets/alarms/AlarmsPanel.tsx +++ b/apps/web/src/widgets/alarms/AlarmsPanel.tsx @@ -1,13 +1,15 @@ import type { LegacyAlarm } from "../../features/legacyDashboard/model/types"; export function AlarmsPanel({ alarms, onSelectMmsi }: { alarms: LegacyAlarm[]; onSelectMmsi?: (mmsi: number) => void }) { - const shown = alarms.slice(0, 6); + if (alarms.length === 0) { + return
(현재 경고 없음)
; + } return (
- {shown.map((a, idx) => ( + {alarms.map((a, idx) => (
{ if (!onSelectMmsi) return; diff --git a/apps/web/src/widgets/info/VesselInfoPanel.tsx b/apps/web/src/widgets/info/VesselInfoPanel.tsx index b0c4a1f..845640e 100644 --- a/apps/web/src/widgets/info/VesselInfoPanel.tsx +++ b/apps/web/src/widgets/info/VesselInfoPanel.tsx @@ -2,6 +2,7 @@ import { ZONE_META } from "../../entities/zone/model/meta"; import { SPEED_MAX, VESSEL_TYPES } from "../../entities/vessel/model/meta"; import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types"; import { haversineNm } from "../../shared/lib/geo/haversineNm"; +import { OVERLAY_RGB, rgbToHex } from "../../shared/lib/map/palette"; type Props = { vessel: DerivedLegacyVessel; @@ -93,7 +94,7 @@ export function VesselInfoPanel({ vessel: v, allVessels, onClose, onSelectMmsi }
쌍 이격 - 3 ? "#F59E0B" : "#22C55E" }}> + 3 ? rgbToHex(OVERLAY_RGB.pairWarn) : "#22C55E" }}> {pairDist.toFixed(2)}NM {pairDist > 3 ? "⚠" : "✓"}
diff --git a/apps/web/src/widgets/legend/MapLegend.tsx b/apps/web/src/widgets/legend/MapLegend.tsx index 5682992..3377175 100644 --- a/apps/web/src/widgets/legend/MapLegend.tsx +++ b/apps/web/src/widgets/legend/MapLegend.tsx @@ -1,4 +1,5 @@ import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta"; +import { LEGACY_CODE_COLORS_HEX, OTHER_AIS_SPEED_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette"; export function MapLegend() { return ( @@ -12,48 +13,56 @@ export function MapLegend() { ))}
- AIS 선박(속도) + 기타 AIS 선박(속도)
-
+
SOG ≥ 10 kt
-
+
1 ≤ SOG < 10 kt
-
- SOG < 1 kt (or unknown) +
+ SOG < 1 kt +
+
+
+ SOG unknown
CN Permit(업종)
-
+
PT 본선 (ring + 색상)
-
+
PT-S 부속선
-
+
GN 유망
-
+
OT 1척식
-
+
PS 위망
-
+
FC 운반선
+
+
+ C21 +
밀도(3D) @@ -66,25 +75,29 @@ export function MapLegend() { 연결선
-
+
PT↔PT-S 쌍 (정상)
-
+
쌍 연결범위
-
+
쌍 이격 경고 (>3NM)
-
+
FC 환적 연결 (dashed)
-
+
선단 범위
+
+
+ FC 환적 연결 (의심) +
); } diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 2468837..d3da34b 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -17,6 +17,7 @@ import type { ZoneId } from "../../entities/zone/model/meta"; import { ZONE_META } from "../../entities/zone/model/meta"; import type { MapToggleState } from "../../features/mapToggles/MapToggles"; import type { FcLink, FleetCircle, PairLink } from "../../features/legacyDashboard/model/types"; +import { LEGACY_CODE_COLORS_RGB, OVERLAY_RGB, rgba as rgbaCss } from "../../shared/lib/map/palette"; import { MaplibreDeckCustomLayer } from "./MaplibreDeckCustomLayer"; export type Map3DSettings = { @@ -183,7 +184,7 @@ function makeFleetMemberMatchExpr(hoveredFleetMmsiList: number[]) { return false; } const clauses = hoveredFleetMmsiList.map((mmsi) => - ["in", mmsi, ["coalesce", ["get", "vesselMmsis"], []]] as unknown[], + ["in", mmsi, ["coalesce", ["get", "vesselMmsis"], ["literal", []]]] as unknown[], ); return ["any", ...clauses] as unknown[]; } @@ -545,21 +546,141 @@ function getGlobeBaseShipColor({ if (rgb) return rgbToHex(lightenColor(rgb, 0.38)); } - if (!isFiniteNumber(sog)) return "rgba(100,116,139,0.75)"; - if (sog >= 10) return "#3b82f6"; - if (sog >= 1) return "#22c55e"; - return "rgba(100,116,139,0.75)"; + // Non-target AIS should be visible but muted so target vessels stand out. + // Encode speed mostly via brightness (not hue) to avoid clashing with target category colors. + if (!isFiniteNumber(sog)) return "rgba(100,116,139,0.55)"; + if (sog >= 10) return "rgba(148,163,184,0.78)"; + if (sog >= 1) return "rgba(100,116,139,0.74)"; + return "rgba(71,85,105,0.68)"; } -const LEGACY_CODE_COLORS: Record = { - PT: [30, 64, 175], // #1e40af - "PT-S": [234, 88, 12], // #ea580c - GN: [16, 185, 129], // #10b981 - OT: [139, 92, 246], // #8b5cf6 - PS: [239, 68, 68], // #ef4444 - FC: [245, 158, 11], // #f59e0b - C21: [236, 72, 153], // #ec4899 -}; +const LEGACY_CODE_COLORS = LEGACY_CODE_COLORS_RGB; + +const OVERLAY_PAIR_NORMAL_RGB = OVERLAY_RGB.pairNormal; +const OVERLAY_PAIR_WARN_RGB = OVERLAY_RGB.pairWarn; +const OVERLAY_FC_TRANSFER_RGB = OVERLAY_RGB.fcTransfer; +const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange; +const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious; + +// Deck.gl color constants (avoid per-object allocations inside accessors). +const PAIR_RANGE_NORMAL_DECK: [number, number, number, number] = [ + OVERLAY_PAIR_NORMAL_RGB[0], + OVERLAY_PAIR_NORMAL_RGB[1], + OVERLAY_PAIR_NORMAL_RGB[2], + 110, +]; +const PAIR_RANGE_WARN_DECK: [number, number, number, number] = [ + OVERLAY_PAIR_WARN_RGB[0], + OVERLAY_PAIR_WARN_RGB[1], + OVERLAY_PAIR_WARN_RGB[2], + 170, +]; +const PAIR_LINE_NORMAL_DECK: [number, number, number, number] = [ + OVERLAY_PAIR_NORMAL_RGB[0], + OVERLAY_PAIR_NORMAL_RGB[1], + OVERLAY_PAIR_NORMAL_RGB[2], + 85, +]; +const PAIR_LINE_WARN_DECK: [number, number, number, number] = [ + OVERLAY_PAIR_WARN_RGB[0], + OVERLAY_PAIR_WARN_RGB[1], + OVERLAY_PAIR_WARN_RGB[2], + 220, +]; +const FC_LINE_NORMAL_DECK: [number, number, number, number] = [ + OVERLAY_FC_TRANSFER_RGB[0], + OVERLAY_FC_TRANSFER_RGB[1], + OVERLAY_FC_TRANSFER_RGB[2], + 200, +]; +const FC_LINE_SUSPICIOUS_DECK: [number, number, number, number] = [ + OVERLAY_SUSPICIOUS_RGB[0], + OVERLAY_SUSPICIOUS_RGB[1], + OVERLAY_SUSPICIOUS_RGB[2], + 220, +]; +const FLEET_RANGE_LINE_DECK: [number, number, number, number] = [ + OVERLAY_FLEET_RANGE_RGB[0], + OVERLAY_FLEET_RANGE_RGB[1], + OVERLAY_FLEET_RANGE_RGB[2], + 140, +]; +const FLEET_RANGE_FILL_DECK: [number, number, number, number] = [ + OVERLAY_FLEET_RANGE_RGB[0], + OVERLAY_FLEET_RANGE_RGB[1], + OVERLAY_FLEET_RANGE_RGB[2], + 6, +]; + +const PAIR_RANGE_NORMAL_DECK_HL: [number, number, number, number] = [ + OVERLAY_PAIR_NORMAL_RGB[0], + OVERLAY_PAIR_NORMAL_RGB[1], + OVERLAY_PAIR_NORMAL_RGB[2], + 200, +]; +const PAIR_RANGE_WARN_DECK_HL: [number, number, number, number] = [ + OVERLAY_PAIR_WARN_RGB[0], + OVERLAY_PAIR_WARN_RGB[1], + OVERLAY_PAIR_WARN_RGB[2], + 240, +]; +const PAIR_LINE_NORMAL_DECK_HL: [number, number, number, number] = [ + OVERLAY_PAIR_NORMAL_RGB[0], + OVERLAY_PAIR_NORMAL_RGB[1], + OVERLAY_PAIR_NORMAL_RGB[2], + 245, +]; +const PAIR_LINE_WARN_DECK_HL: [number, number, number, number] = [ + OVERLAY_PAIR_WARN_RGB[0], + OVERLAY_PAIR_WARN_RGB[1], + OVERLAY_PAIR_WARN_RGB[2], + 245, +]; +const FC_LINE_NORMAL_DECK_HL: [number, number, number, number] = [ + OVERLAY_FC_TRANSFER_RGB[0], + OVERLAY_FC_TRANSFER_RGB[1], + OVERLAY_FC_TRANSFER_RGB[2], + 235, +]; +const FC_LINE_SUSPICIOUS_DECK_HL: [number, number, number, number] = [ + OVERLAY_SUSPICIOUS_RGB[0], + OVERLAY_SUSPICIOUS_RGB[1], + OVERLAY_SUSPICIOUS_RGB[2], + 245, +]; +const FLEET_RANGE_LINE_DECK_HL: [number, number, number, number] = [ + OVERLAY_FLEET_RANGE_RGB[0], + OVERLAY_FLEET_RANGE_RGB[1], + OVERLAY_FLEET_RANGE_RGB[2], + 220, +]; +const FLEET_RANGE_FILL_DECK_HL: [number, number, number, number] = [ + OVERLAY_FLEET_RANGE_RGB[0], + OVERLAY_FLEET_RANGE_RGB[1], + OVERLAY_FLEET_RANGE_RGB[2], + 42, +]; + +// MapLibre overlay colors. +const PAIR_LINE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.55); +const PAIR_LINE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.95); +const PAIR_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.95); +const PAIR_LINE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.98); + +const PAIR_RANGE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.45); +const PAIR_RANGE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.75); +const PAIR_RANGE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.92); +const PAIR_RANGE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.92); + +const FC_LINE_NORMAL_ML = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.92); +const FC_LINE_SUSPICIOUS_ML = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.95); +const FC_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.98); +const FC_LINE_SUSPICIOUS_ML_HL = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.98); + +const FLEET_FILL_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.02); +const FLEET_FILL_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.16); +const FLEET_LINE_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.65); +const FLEET_LINE_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95); const DEPTH_DISABLED_PARAMS = { // In MapLibre+Deck (interleaved), the map often leaves a depth buffer populated. @@ -935,10 +1056,11 @@ function getShipColor( if (rgb) return [rgb[0], rgb[1], rgb[2], 235]; return [245, 158, 11, 235]; } - if (!isFiniteNumber(t.sog)) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 160]; - if (t.sog >= 10) return [59, 130, 246, 220]; - if (t.sog >= 1) return [34, 197, 94, 210]; - return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 160]; + // Non-target AIS: muted gray scale (avoid clashing with target category colors like PT/GN/etc). + if (!isFiniteNumber(t.sog)) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 130]; + if (t.sog >= 10) return [148, 163, 184, 185]; // slate-400 + if (t.sog >= 1) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 175]; // slate-500 + return [71, 85, 105, 165]; // slate-600 } type DashSeg = { @@ -1139,6 +1261,9 @@ export function Map3D({ "ships-globe-halo", "ships-globe-outline", "ships-globe", + "ships-globe-hover-halo", + "ships-globe-hover-outline", + "ships-globe-hover", "pair-lines-ml", "fc-lines-ml", "pair-range-ml", @@ -1183,6 +1308,10 @@ export function Map3D({ effectiveHoveredPairMmsiSet, ], ); + const highlightedMmsiSetForShips = useMemo( + () => (projection === "globe" ? mergeNumberSets(hoveredMmsiSetRef, externalHighlightedSetRef) : highlightedMmsiSetCombined), + [projection, hoveredMmsiSetRef, externalHighlightedSetRef, highlightedMmsiSetCombined], + ); const hoveredShipSignature = useMemo( () => `${makeSetSignature(hoveredMmsiSetRef)}|${makeSetSignature(externalHighlightedSetRef)}|${makeSetSignature( @@ -1243,14 +1372,43 @@ export function Map3D({ return byMmsi; }, [shipData]); - const hasAuxiliarySelectModifier = (ev?: { - shiftKey?: boolean; - ctrlKey?: boolean; - metaKey?: boolean; - } | null): boolean => { - if (!ev) return false; - return !!(ev.shiftKey || ev.ctrlKey || ev.metaKey); - }; + const shipLayerData = useMemo(() => { + if (shipData.length === 0) return shipData; + return [...shipData]; + }, [shipData]); + + const shipHighlightSet = useMemo(() => { + const out = new Set(highlightedMmsiSetForShips); + if (selectedMmsi) out.add(selectedMmsi); + return out; + }, [highlightedMmsiSetForShips, selectedMmsi]); + + const shipHoverOverlaySet = useMemo( + () => + projection === "globe" + ? mergeNumberSets(highlightedMmsiSetCombined, shipHighlightSet) + : shipHighlightSet, + [projection, highlightedMmsiSetCombined, shipHighlightSet], + ); + + const shipOverlayLayerData = useMemo(() => { + if (shipLayerData.length === 0) return []; + if (shipHighlightSet.size === 0) return []; + + return shipLayerData.filter((target) => shipHighlightSet.has(target.mmsi)); + }, [shipHighlightSet, shipLayerData]); + + const hasAuxiliarySelectModifier = useCallback( + (ev?: { + shiftKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + } | null): boolean => { + if (!ev) return false; + return !!(ev.shiftKey || ev.ctrlKey || ev.metaKey); + }, + [], + ); const toFleetMmsiList = useCallback((value: unknown) => { if (!Array.isArray(value)) return []; @@ -1300,6 +1458,7 @@ export function Map3D({ ownerKey: string | null; vesselMmsis: number[]; }>({ ownerKey: null, vesselMmsis: [] }); + const globeHoverShipSignatureRef = useRef(""); const clearMapFleetHoverState = useCallback(() => { mapFleetHoverStateRef.current = { ownerKey: null, vesselMmsis: [] }; @@ -1403,6 +1562,14 @@ export function Map3D({ if (onProjectionLoadingChange) { onProjectionLoadingChange(false); } + // Many layer "ensure" functions bail out while projectionBusyRef is true. + // Trigger a sync pulse when loading ends so globe/mercator layers appear immediately + // without requiring a user toggle (e.g., industry filter). + setMapSyncEpoch((prev) => prev + 1); + requestAnimationFrame(() => { + kickRepaint(mapRef.current); + setMapSyncEpoch((prev) => prev + 1); + }); }, [clearProjectionBusyTimer, onProjectionLoadingChange]); const setProjectionLoading = useCallback( @@ -1477,30 +1644,19 @@ export function Map3D({ } }, []); - const ensureMercatorOverlays = useCallback(() => { + const ensureMercatorOverlay = useCallback(() => { const map = mapRef.current; if (!map) return null; - - const ensureLayer = (ref: { current: MapboxOverlay | null }) => { - if (ref.current) return ref.current; - try { - const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never); - map.addControl(next); - ref.current = next; - return next; - } catch (e) { - console.warn("Deck overlay create failed:", e); - return null; - } - }; - - const base = ensureLayer(overlayRef); - if (!base) return null; - - const interaction = ensureLayer(overlayInteractionRef); - if (!interaction) return null; - - return { base, interaction }; + if (overlayRef.current) return overlayRef.current; + try { + const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never); + map.addControl(next); + overlayRef.current = next; + return next; + } catch (e) { + console.warn("Deck overlay create failed:", e); + return null; + } }, []); const clearGlobeNativeLayers = useCallback(() => { @@ -1511,6 +1667,9 @@ export function Map3D({ "ships-globe-halo", "ships-globe-outline", "ships-globe", + "ships-globe-hover-halo", + "ships-globe-hover-outline", + "ships-globe-hover", "pair-lines-ml", "fc-lines-ml", "fleet-circles-ml-fill", @@ -1525,6 +1684,7 @@ export function Map3D({ const sourceIds = [ "ships-globe-src", + "ships-globe-hover-src", "pair-lines-ml-src", "fc-lines-ml-src", "fleet-circles-ml-src", @@ -1579,9 +1739,9 @@ export function Map3D({ // - mercator: MapboxOverlay interleaved (fast, feature-rich) // - globe: MapLibre custom layer that feeds Deck the globe MVP matrix (keeps basemap+layers aligned) if (projectionRef.current === "mercator") { - const overlays = ensureMercatorOverlays(); - if (!overlays) return; - overlayRef.current = overlays.base; + const overlay = ensureMercatorOverlay(); + if (!overlay) return; + overlayRef.current = overlay; } else { globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ id: "deck-globe", @@ -1848,7 +2008,7 @@ export function Map3D({ // Tear down globe custom layer (if present), restore MapboxOverlay interleaved. disposeGlobeDeckLayer(); - ensureMercatorOverlays(); + ensureMercatorOverlay(); } // MapLibre may not schedule a frame immediately after projection swaps if the map is idle. @@ -1884,7 +2044,7 @@ export function Map3D({ if (settleCleanup) settleCleanup(); if (isTransition) setProjectionLoading(false); }; - }, [projection, clearGlobeNativeLayers, ensureMercatorOverlays, removeLayerIfExists, reorderGlobeFeatureLayers, setProjectionLoading]); + }, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, removeLayerIfExists, reorderGlobeFeatureLayers, setProjectionLoading]); // Base map toggle useEffect(() => { @@ -2240,6 +2400,7 @@ export function Map3D({ } catch { // ignore } + globeHoverShipSignatureRef.current = ""; reorderGlobeFeatureLayers(); kickRepaint(map); }; @@ -2404,12 +2565,16 @@ export function Map3D({ visibility, "circle-sort-key": [ "case", - ["==", ["get", "selected"], 1], - 90, - ["==", ["get", "highlighted"], 1], - 80, + ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], + 120, + ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], + 115, ["==", ["get", "permitted"], 1], + 110, + ["==", ["get", "selected"], 1], 60, + ["==", ["get", "highlighted"], 1], + 55, 20, ] as never, }, @@ -2434,6 +2599,24 @@ export function Map3D({ } else { try { map.setLayoutProperty(haloId, "visibility", visibility); + map.setLayoutProperty( + haloId, + "circle-sort-key", + [ + "case", + ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], + 120, + ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], + 115, + ["==", ["get", "permitted"], 1], + 110, + ["==", ["get", "selected"], 1], + 60, + ["==", ["get", "highlighted"], 1], + 55, + 20, + ] as never, + ); map.setPaintProperty( haloId, "circle-color", @@ -2443,9 +2626,7 @@ export function Map3D({ "rgba(14,234,255,1)", ["==", ["get", "highlighted"], 1], "rgba(245,158,11,1)", - ["==", ["get", "permitted"], 1], - "rgba(125,211,252,0.95)", - "rgba(59,130,246,1)", + ["coalesce", ["get", "shipColor"], "#64748b"], ] as never, ); map.setPaintProperty(haloId, "circle-opacity", [ @@ -2478,9 +2659,7 @@ export function Map3D({ "rgba(14,234,255,0.95)", ["==", ["get", "highlighted"], 1], "rgba(245,158,11,0.95)", - ["==", ["get", "permitted"], 1], - "rgba(125,211,252,0.95)", - "rgba(59,130,246,0.75)", + ["coalesce", ["get", "shipColor"], "#64748b"], ] as never, "circle-stroke-width": [ "case", @@ -2498,12 +2677,16 @@ export function Map3D({ visibility, "circle-sort-key": [ "case", - ["==", ["get", "selected"], 1], - 100, - ["==", ["get", "highlighted"], 1], - 90, + ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], + 130, + ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], + 125, ["==", ["get", "permitted"], 1], + 120, + ["==", ["get", "selected"], 1], 70, + ["==", ["get", "highlighted"], 1], + 65, 30, ] as never, }, @@ -2516,6 +2699,24 @@ export function Map3D({ } else { try { map.setLayoutProperty(outlineId, "visibility", visibility); + map.setLayoutProperty( + outlineId, + "circle-sort-key", + [ + "case", + ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], + 130, + ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], + 125, + ["==", ["get", "permitted"], 1], + 120, + ["==", ["get", "selected"], 1], + 70, + ["==", ["get", "highlighted"], 1], + 65, + 30, + ] as never, + ); map.setPaintProperty( outlineId, "circle-stroke-color", @@ -2525,9 +2726,7 @@ export function Map3D({ "rgba(14,234,255,0.95)", ["==", ["get", "highlighted"], 1], "rgba(245,158,11,0.95)", - ["==", ["get", "permitted"], 1], - "rgba(125,211,252,0.95)", - "rgba(59,130,246,0.75)", + ["coalesce", ["get", "shipColor"], "#64748b"], ] as never, ); map.setPaintProperty( @@ -2560,12 +2759,16 @@ export function Map3D({ visibility, "symbol-sort-key": [ "case", - ["==", ["get", "selected"], 1], - 95, - ["==", ["get", "highlighted"], 1], - 85, + ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], + 140, + ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], + 135, ["==", ["get", "permitted"], 1], - 65, + 130, + ["==", ["get", "selected"], 1], + 80, + ["==", ["get", "highlighted"], 1], + 75, 45, ] as never, "icon-image": imgId, @@ -2590,6 +2793,323 @@ export function Map3D({ "icon-rotation-alignment": "map", "icon-pitch-alignment": "map", }, + paint: { + "icon-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, + "icon-opacity": [ + "case", + ["==", ["get", "permitted"], 1], + 1, + ["==", ["get", "selected"], 1], + 0.86, + ["==", ["get", "highlighted"], 1], + 0.82, + 0.66, + ] as never, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn("Ship symbol layer add failed:", e); + } + } else { + try { + map.setLayoutProperty(symbolId, "visibility", visibility); + map.setLayoutProperty( + symbolId, + "symbol-sort-key", + [ + "case", + ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], + 140, + ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], + 135, + ["==", ["get", "permitted"], 1], + 130, + ["==", ["get", "selected"], 1], + 80, + ["==", ["get", "highlighted"], 1], + 75, + 45, + ] as never, + ); + map.setPaintProperty( + symbolId, + "icon-opacity", + [ + "case", + ["==", ["get", "permitted"], 1], + 1, + ["==", ["get", "selected"], 1], + 0.86, + ["==", ["get", "highlighted"], 1], + 0.82, + 0.66, + ] as never, + ); + } catch { + // ignore + } + } + + // Selection and highlight are now source-data driven. + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + return () => { + stop(); + }; + }, [ + projection, + settings.showShips, + shipData, + legacyHits, + selectedMmsi, + mapSyncEpoch, + reorderGlobeFeatureLayers, + ]); + + // Globe hover overlay ships: update only hovered/selected targets to avoid rebuilding full ship layer on every hover. + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const imgId = "ship-globe-icon"; + const srcId = "ships-globe-hover-src"; + const haloId = "ships-globe-hover-halo"; + const outlineId = "ships-globe-hover-outline"; + const symbolId = "ships-globe-hover"; + + const remove = () => { + for (const id of [symbolId, outlineId, haloId]) { + try { + if (map.getLayer(id)) map.removeLayer(id); + } catch { + // ignore + } + } + try { + if (map.getSource(srcId)) map.removeSource(srcId); + } catch { + // ignore + } + globeHoverShipSignatureRef.current = ""; + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + + if (projection !== "globe" || !settings.showShips || shipHoverOverlaySet.size === 0) { + remove(); + return; + } + + if (globeShipsEpochRef.current !== mapSyncEpoch) { + remove(); + globeShipsEpochRef.current = mapSyncEpoch; + } + + ensureFallbackShipImage(map, imgId); + if (!map.hasImage(imgId)) { + return; + } + + const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi)); + if (hovered.length === 0) { + remove(); + return; + } + const hoverSignature = hovered + .map((t) => `${t.mmsi}:${t.lon.toFixed(6)}:${t.lat.toFixed(6)}:${t.heading ?? 0}`) + .join("|"); + const hasHoverSource = map.getSource(srcId) != null; + const hasHoverLayers = [symbolId, outlineId, haloId].every((id) => map.getLayer(id)); + if (hoverSignature === globeHoverShipSignatureRef.current && hasHoverSource && hasHoverLayers) { + return; + } + globeHoverShipSignatureRef.current = hoverSignature; + const needReorder = !hasHoverSource || !hasHoverLayers; + + const hoverGeojson: GeoJSON.FeatureCollection = { + type: "FeatureCollection", + features: hovered.map((t) => { + const legacy = legacyHits?.get(t.mmsi) ?? null; + const heading = getDisplayHeading({ + cog: t.cog, + heading: t.heading, + offset: GLOBE_ICON_HEADING_OFFSET_DEG, + }); + const hull = clampNumber( + (isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, + 50, + 420, + ); + const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); + const selected = t.mmsi === selectedMmsi; + const scale = selected ? 1.16 : 1.1; + return { + type: "Feature", + ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), + geometry: { type: "Point", coordinates: [t.lon, t.lat] }, + properties: { + mmsi: t.mmsi, + name: t.name || "", + cog: heading, + heading, + sog: isFiniteNumber(t.sog) ? t.sog : 0, + shipColor: getGlobeBaseShipColor({ + legacy: legacy?.shipCode || null, + sog: isFiniteNumber(t.sog) ? t.sog : null, + }), + iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45), + iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7), + iconSize10: clampNumber(0.56 * sizeScale * scale, 0.35, 2.0), + iconSize14: clampNumber(0.72 * sizeScale * scale, 0.45, 2.4), + selected: selected ? 1 : 0, + permitted: !!legacy, + }, + }; + }), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(hoverGeojson); + else map.addSource(srcId, { type: "geojson", data: hoverGeojson } as GeoJSONSourceSpecification); + } catch (e) { + console.warn("Ship hover source setup failed:", e); + return; + } + + const before = undefined; + + if (!map.getLayer(haloId)) { + try { + map.addLayer( + { + id: haloId, + type: "circle", + source: srcId, + layout: { + visibility: "visible", + "circle-sort-key": [ + "case", + ["==", ["get", "selected"], 1], + 120, + ["==", ["get", "permitted"], 1], + 115, + 110, + ] as never, + }, + paint: { + "circle-radius": GLOBE_SHIP_CIRCLE_RADIUS_EXPR, + "circle-color": [ + "case", + ["==", ["get", "selected"], 1], + "rgba(14,234,255,1)", + "rgba(245,158,11,1)", + ] as never, + "circle-opacity": 0.42, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn("Ship hover halo layer add failed:", e); + } + } else { + map.setLayoutProperty(haloId, "visibility", "visible"); + } + + if (!map.getLayer(outlineId)) { + try { + map.addLayer( + { + id: outlineId, + type: "circle", + source: srcId, + paint: { + "circle-radius": GLOBE_SHIP_CIRCLE_RADIUS_EXPR, + "circle-color": "rgba(0,0,0,0)", + "circle-stroke-color": [ + "case", + ["==", ["get", "selected"], 1], + "rgba(14,234,255,0.95)", + "rgba(245,158,11,0.95)", + ] as never, + "circle-stroke-width": [ + "case", + ["==", ["get", "selected"], 1], + 3.8, + 2.2, + ] as never, + "circle-stroke-opacity": 0.9, + }, + layout: { + visibility: "visible", + "circle-sort-key": [ + "case", + ["==", ["get", "selected"], 1], + 121, + ["==", ["get", "permitted"], 1], + 116, + 111, + ] as never, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn("Ship hover outline layer add failed:", e); + } + } else { + map.setLayoutProperty(outlineId, "visibility", "visible"); + } + + if (!map.getLayer(symbolId)) { + try { + map.addLayer( + { + id: symbolId, + type: "symbol", + source: srcId, + layout: { + visibility: "visible", + "symbol-sort-key": [ + "case", + ["==", ["get", "selected"], 1], + 122, + ["==", ["get", "permitted"], 1], + 117, + 112, + ] as never, + "icon-image": imgId, + "icon-size": [ + "interpolate", + ["linear"], + ["zoom"], + 3, + ["to-number", ["get", "iconSize3"], 0.35], + 7, + ["to-number", ["get", "iconSize7"], 0.45], + 10, + ["to-number", ["get", "iconSize10"], 0.56], + 14, + ["to-number", ["get", "iconSize14"], 0.72], + ] as unknown as number[], + "icon-allow-overlap": true, + "icon-ignore-placement": true, + "icon-anchor": "center", + "icon-rotate": ["to-number", ["get", "heading"], 0], + // Keep the icon on the sea surface. + "icon-rotation-alignment": "map", + "icon-pitch-alignment": "map", + }, paint: { "icon-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, "icon-opacity": 1, @@ -2598,18 +3118,15 @@ export function Map3D({ before, ); } catch (e) { - console.warn("Ship symbol layer add failed:", e); + console.warn("Ship hover symbol layer add failed:", e); } } else { - try { - map.setLayoutProperty(symbolId, "visibility", visibility); - } catch { - // ignore - } + map.setLayoutProperty(symbolId, "visibility", "visible"); } - // Selection and highlight are now source-data driven. - reorderGlobeFeatureLayers(); + if (needReorder) { + reorderGlobeFeatureLayers(); + } kickRepaint(map); }; @@ -2620,10 +3137,10 @@ export function Map3D({ }, [ projection, settings.showShips, - shipData, + shipLayerData, legacyHits, + shipHoverOverlaySet, selectedMmsi, - isBaseHighlightedMmsi, mapSyncEpoch, reorderGlobeFeatureLayers, ]); @@ -2768,10 +3285,10 @@ export function Map3D({ "line-color": [ "case", ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,0.98)", + ["case", ["boolean", ["get", "warn"], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], ["boolean", ["get", "warn"], false], - "rgba(245,158,11,0.95)", - "rgba(59,130,246,0.55)", + PAIR_LINE_WARN_ML, + PAIR_LINE_NORMAL_ML, ] as never, "line-width": [ "case", @@ -2885,10 +3402,10 @@ export function Map3D({ "line-color": [ "case", ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,0.98)", + ["case", ["boolean", ["get", "suspicious"], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], ["boolean", ["get", "suspicious"], false], - "rgba(239,68,68,0.95)", - "rgba(217,119,6,0.92)", + FC_LINE_SUSPICIOUS_ML, + FC_LINE_NORMAL_ML, ] as never, "line-width": ["case", ["==", ["get", "highlighted"], 1], 2.0, 1.3] as never, "line-opacity": 0.9, @@ -3032,8 +3549,8 @@ export function Map3D({ "fill-color": [ "case", ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,0.16)", - "rgba(245,158,11,0.02)", + FLEET_FILL_ML_HL, + FLEET_FILL_ML, ] as never, "fill-opacity": ["case", ["==", ["get", "highlighted"], 1], 0.7, 0.36] as never, }, @@ -3060,7 +3577,7 @@ export function Map3D({ source: srcId, layout: { "line-cap": "round", "line-join": "round", visibility: "visible" }, paint: { - "line-color": ["case", ["==", ["get", "highlighted"], 1], "rgba(245,158,11,0.95)", "rgba(245,158,11,0.65)"] as never, + "line-color": ["case", ["==", ["get", "highlighted"], 1], FLEET_LINE_ML_HL, FLEET_LINE_ML] as never, "line-width": ["case", ["==", ["get", "highlighted"], 1], 2, 1.1] as never, "line-opacity": 0.85, }, @@ -3181,10 +3698,10 @@ export function Map3D({ "line-color": [ "case", ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,0.92)", + ["case", ["boolean", ["get", "warn"], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ["boolean", ["get", "warn"], false], - "rgba(245,158,11,0.75)", - "rgba(59,130,246,0.45)", + PAIR_RANGE_WARN_ML, + PAIR_RANGE_NORMAL_ML, ] as never, "line-width": ["case", ["==", ["get", "highlighted"], 1], 1.6, 1.0] as never, "line-opacity": 0.85, @@ -3249,7 +3766,14 @@ export function Map3D({ map.setPaintProperty( "pair-lines-ml", "line-color", - ["case", pairHighlightExpr, "rgba(245,158,11,0.98)", ["boolean", ["get", "warn"], false], "rgba(59,130,246,0.55)", "rgba(59,130,246,0.55)"] as never, + [ + "case", + pairHighlightExpr, + ["case", ["boolean", ["get", "warn"], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], + ["boolean", ["get", "warn"], false], + PAIR_LINE_WARN_ML, + PAIR_LINE_NORMAL_ML, + ] as never, ); map.setPaintProperty( "pair-lines-ml", @@ -3266,7 +3790,14 @@ export function Map3D({ map.setPaintProperty( "fc-lines-ml", "line-color", - ["case", fcEndpointHighlightExpr, "rgba(245,158,11,0.98)", ["boolean", ["get", "suspicious"], false], "rgba(239,68,68,0.95)", "rgba(217,119,6,0.92)"] as never, + [ + "case", + fcEndpointHighlightExpr, + ["case", ["boolean", ["get", "suspicious"], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], + ["boolean", ["get", "suspicious"], false], + FC_LINE_SUSPICIOUS_ML, + FC_LINE_NORMAL_ML, + ] as never, ); map.setPaintProperty( "fc-lines-ml", @@ -3286,10 +3817,10 @@ export function Map3D({ [ "case", pairHighlightExpr, - "rgba(245,158,11,0.92)", + ["case", ["boolean", ["get", "warn"], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ["boolean", ["get", "warn"], false], - "rgba(245,158,11,0.75)", - "rgba(59,130,246,0.45)", + PAIR_RANGE_WARN_ML, + PAIR_RANGE_NORMAL_ML, ] as never, ); map.setPaintProperty( @@ -3310,8 +3841,8 @@ export function Map3D({ [ "case", fleetHighlightExpr, - "rgba(245,158,11,0.24)", - "rgba(245,158,11,0.02)", + FLEET_FILL_ML_HL, + FLEET_FILL_ML, ] as never, ); map.setPaintProperty( @@ -3324,7 +3855,7 @@ export function Map3D({ map.setPaintProperty( "fleet-circles-ml", "line-color", - ["case", fleetHighlightExpr, "rgba(245,158,11,0.95)", "rgba(245,158,11,0.65)"] as never, + ["case", fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never, ); map.setPaintProperty( "fleet-circles-ml", @@ -3347,22 +3878,6 @@ export function Map3D({ }; }, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateGlobeOverlayPaintStates]); - const shipLayerData = useMemo(() => { - if (shipData.length === 0) return shipData; - return [...shipData]; - }, [shipData]); - - const shipHighlightSet = useMemo(() => { - const out = new Set(highlightedMmsiSetCombined); - if (selectedMmsi) out.add(selectedMmsi); - return out; - }, [highlightedMmsiSetCombined, selectedMmsi]); - - const shipOverlayLayerData = useMemo(() => { - if (shipLayerData.length === 0) return shipLayerData; - return shipLayerData; - }, [shipLayerData]); - const clearGlobeTooltip = useCallback(() => { if (!mapTooltipRef.current) return; try { @@ -3716,18 +4231,19 @@ export function Map3D({ const fcLinesInteractive = useMemo(() => { if (!overlays.fcLines || fcDashed.length === 0) return []; - if (shipHighlightSet.size === 0) return []; + if (highlightedMmsiSetCombined.size === 0) return []; return fcDashed.filter( - (line) => [line.fromMmsi, line.toMmsi].some((mmsi) => (mmsi == null ? false : shipHighlightSet.has(mmsi))), + (line) => + [line.fromMmsi, line.toMmsi].some((mmsi) => (mmsi == null ? false : highlightedMmsiSetCombined.has(mmsi))), ); - }, [fcDashed, hoveredShipSignature, overlays.fcLines, shipHighlightSet]); + }, [fcDashed, hoveredShipSignature, overlays.fcLines, highlightedMmsiSetCombined]); const fleetCirclesInteractive = useMemo(() => { if (!overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) return []; - if (hoveredFleetOwnerKeys.size === 0 && shipHighlightSet.size === 0) return []; + if (hoveredFleetOwnerKeys.size === 0 && highlightedMmsiSetCombined.size === 0) return []; const circles = fleetCircles || []; return circles.filter((item) => isHighlightedFleet(item.ownerKey, item.vesselMmsis)); - }, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, shipHighlightSet]); + }, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, highlightedMmsiSetCombined]); // Static deck layers for mercator (positions + base states). Interaction overlays are handled separately. useEffect(() => { @@ -3744,8 +4260,7 @@ export function Map3D({ return; } - const refs = ensureMercatorOverlays(); - const deckTarget = refs?.base; + const deckTarget = ensureMercatorOverlay(); if (!deckTarget) return; const layers: unknown[] = []; @@ -3753,6 +4268,19 @@ export function Map3D({ const clearDeckHover = () => { touchDeckHoverState(false); }; + const isTargetShip = (mmsi: number) => (legacyHits ? legacyHits.has(mmsi) : false); + const shipOtherData: AisTarget[] = []; + const shipTargetData: AisTarget[] = []; + for (const t of shipLayerData) { + if (isTargetShip(t.mmsi)) shipTargetData.push(t); + else shipOtherData.push(t); + } + const shipOverlayOtherData: AisTarget[] = []; + const shipOverlayTargetData: AisTarget[] = []; + for (const t of shipOverlayLayerData) { + if (isTargetShip(t.mmsi)) shipOverlayTargetData.push(t); + else shipOverlayOtherData.push(t); + } if (settings.showDensity) { layers.push( @@ -3785,7 +4313,7 @@ export function Map3D({ radiusMinPixels: 10, lineWidthUnits: "pixels", getLineWidth: () => 1, - getLineColor: (d) => (d.warn ? [245, 158, 11, 170] : [59, 130, 246, 110]), + getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK), getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { @@ -3825,7 +4353,7 @@ export function Map3D({ parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, - getColor: (d) => (d.warn ? [245, 158, 11, 220] : [59, 130, 246, 85]), + getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK), getWidth: (d) => (d.warn ? 2.2 : 1.4), widthUnits: "pixels", onHover: (info) => { @@ -3863,7 +4391,7 @@ export function Map3D({ parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, - getColor: (d) => (d.suspicious ? [239, 68, 68, 220] : [217, 119, 6, 200]), + getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK), getWidth: () => 1.3, widthUnits: "pixels", onHover: (info) => { @@ -3911,7 +4439,7 @@ export function Map3D({ getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: "pixels", getLineWidth: () => 1.1, - getLineColor: () => [245, 158, 11, 140], + getLineColor: () => FLEET_RANGE_LINE_DECK, getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { @@ -3953,43 +4481,273 @@ export function Map3D({ stroked: false, radiusUnits: "meters", getRadius: (d) => d.radiusNm * 1852, - getFillColor: () => [245, 158, 11, 6], + getFillColor: () => FLEET_RANGE_FILL_DECK, getPosition: (d) => d.center, }), ); } - if (settings.showShips && legacyTargetsOrdered.length > 0) { + if (settings.showShips) { + // Always render non-target ships below target ships. + const shipOnHover = (info: PickingInfo) => { + if (!info.object) { + clearDeckHover(); + return; + } + touchDeckHoverState(true); + const obj = info.object as AisTarget; + setDeckHoverMmsi([obj.mmsi]); + clearDeckHoverPairs(); + clearMapFleetHoverState(); + }; + const shipOnClick = (info: PickingInfo) => { + if (!info.object) { + onSelectMmsi(null); + return; + } + onDeckSelectOrHighlight( + { + mmsi: (info.object as AisTarget).mmsi, + srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent, + }, + true, + ); + }; + + if (shipOtherData.length > 0) { + layers.push( + new IconLayer({ + id: "ships-other", + data: shipOtherData, + pickable: true, + billboard: false, + parameters: overlayParams, + iconAtlas: "/assets/ship.svg", + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => "ship", + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => + getDisplayHeading({ + cog: d.cog, + heading: d.heading, + }), + sizeUnits: "pixels", + getSize: () => FLAT_SHIP_ICON_SIZE, + getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET), + onHover: shipOnHover, + onClick: shipOnClick, + alphaCutoff: 0.05, + }), + ); + } + + // Hover/selection overlay for non-target ships stays below all target ships. + if (shipOverlayOtherData.length > 0) { + layers.push( + new IconLayer({ + id: "ships-overlay-other", + data: shipOverlayOtherData, + pickable: false, + billboard: false, + parameters: overlayParams, + iconAtlas: "/assets/ship.svg", + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => "ship", + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => + getDisplayHeading({ + cog: d.cog, + heading: d.heading, + }), + sizeUnits: "pixels", + getSize: (d) => { + if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; + if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; + return 0; + }, + getColor: (d) => getShipColor(d, selectedMmsi, null, shipHighlightSet), + alphaCutoff: 0.05, + }), + ); + } + + // Target ship halos and icons render above non-target ships. + if (legacyTargetsOrdered.length > 0) { + layers.push( + new ScatterplotLayer({ + id: "legacy-halo", + data: legacyTargetsOrdered, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "pixels", + getRadius: () => FLAT_LEGACY_HALO_RADIUS, + lineWidthUnits: "pixels", + getLineWidth: () => 2, + getLineColor: (d) => { + const l = legacyHits?.get(d.mmsi); + const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; + if (!rgb) return [245, 158, 11, 200]; + return [rgb[0], rgb[1], rgb[2], 200]; + }, + getPosition: (d) => [d.lon, d.lat] as [number, number], + }), + ); + } + + if (shipTargetData.length > 0) { + layers.push( + new IconLayer({ + id: "ships-target", + data: shipTargetData, + pickable: true, + billboard: false, + parameters: overlayParams, + iconAtlas: "/assets/ship.svg", + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => "ship", + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => + getDisplayHeading({ + cog: d.cog, + heading: d.heading, + }), + sizeUnits: "pixels", + getSize: () => FLAT_SHIP_ICON_SIZE, + getColor: (d) => getShipColor(d, null, legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET), + onHover: shipOnHover, + onClick: shipOnClick, + alphaCutoff: 0.05, + }), + ); + } + } + + // Interaction overlays (hover/selection highlights) are appended so they always render above base layers. + if (overlays.pairRange && pairRangesInteractive.length > 0) { + layers.push( + new ScatterplotLayer({ + id: "pair-range-overlay", + data: pairRangesInteractive, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + radiusMinPixels: 10, + lineWidthUnits: "pixels", + getLineWidth: () => 2.2, + getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), + getPosition: (d) => d.center, + }), + ); + } + + if (overlays.pairLines && pairLinksInteractive.length > 0) { + layers.push( + new LineLayer({ + id: "pair-lines-overlay", + data: pairLinksInteractive, + pickable: false, + parameters: overlayParams, + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, + getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), + getWidth: () => 2.6, + widthUnits: "pixels", + }), + ); + } + + if (overlays.fcLines && fcLinesInteractive.length > 0) { + layers.push( + new LineLayer({ + id: "fc-lines-overlay", + data: fcLinesInteractive, + pickable: false, + parameters: overlayParams, + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, + getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), + getWidth: () => 1.9, + widthUnits: "pixels", + }), + ); + } + + if (overlays.fleetCircles && fleetCirclesInteractive.length > 0) { + layers.push( + new ScatterplotLayer({ + id: "fleet-circles-overlay-fill", + data: fleetCirclesInteractive, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: true, + stroked: false, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + getFillColor: () => FLEET_RANGE_FILL_DECK_HL, + }), + ); + layers.push( + new ScatterplotLayer({ + id: "fleet-circles-overlay", + data: fleetCirclesInteractive, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + lineWidthUnits: "pixels", + getLineWidth: () => 1.8, + getLineColor: () => FLEET_RANGE_LINE_DECK_HL, + getPosition: (d) => d.center, + }), + ); + } + + if (settings.showShips && legacyOverlayTargets.length > 0) { layers.push( new ScatterplotLayer({ - id: "legacy-halo", - data: legacyTargetsOrdered, + id: "legacy-halo-overlay", + data: legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: "pixels", - getRadius: () => FLAT_LEGACY_HALO_RADIUS, + getRadius: (d) => { + if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; + return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; + }, lineWidthUnits: "pixels", - getLineWidth: () => 2, + getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { + if (selectedMmsi && d.mmsi === selectedMmsi) return [14, 234, 255, 230]; const l = legacyHits?.get(d.mmsi); const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; - if (!rgb) return [245, 158, 11, 200]; - return [rgb[0], rgb[1], rgb[2], 200]; + if (!rgb) return [245, 158, 11, 210]; + return [rgb[0], rgb[1], rgb[2], 210]; }, getPosition: (d) => [d.lon, d.lat] as [number, number], }), ); } - if (settings.showShips) { + if (settings.showShips && shipOverlayTargetData.length > 0) { layers.push( new IconLayer({ - id: "ships", - data: shipLayerData, - pickable: true, + id: "ships-overlay-target", + data: shipOverlayTargetData, + pickable: false, billboard: false, parameters: overlayParams, iconAtlas: "/assets/ship.svg", @@ -4002,36 +4760,15 @@ export function Map3D({ heading: d.heading, }), sizeUnits: "pixels", - getSize: () => FLAT_SHIP_ICON_SIZE, - getColor: (d) => - getShipColor( - d, - null, - legacyHits?.get(d.mmsi)?.shipCode ?? null, - EMPTY_MMSI_SET, - ), - onHover: (info) => { - if (!info.object) { - clearDeckHover(); - return; - } - touchDeckHoverState(true); - const obj = info.object as AisTarget; - setDeckHoverMmsi([obj.mmsi]); - clearDeckHoverPairs(); - clearMapFleetHoverState(); + getSize: (d) => { + if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; + if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; + return 0; }, - onClick: (info) => { - if (!info.object) { - onSelectMmsi(null); - return; - } - onDeckSelectOrHighlight({ - mmsi: info.object.mmsi, - srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent, - }, true); + getColor: (d) => { + if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; + return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); }, - alphaCutoff: 0.05, }), ); } @@ -4131,7 +4868,7 @@ export function Map3D({ } } }, [ - ensureMercatorOverlays, + ensureMercatorOverlay, projection, overlayRef, projectionBusyRef, @@ -4143,6 +4880,12 @@ export function Map3D({ fleetCircles, legacyTargetsOrdered, legacyHits, + legacyOverlayTargets, + shipOverlayLayerData, + pairRangesInteractive, + pairLinksInteractive, + fcLinesInteractive, + fleetCirclesInteractive, overlays.pairRange, overlays.pairLines, overlays.fcLines, @@ -4161,216 +4904,6 @@ export function Map3D({ hasAuxiliarySelectModifier, ]); - useEffect(() => { - const map = mapRef.current; - if (!map) return; - if (projectionBusyRef.current) return; - - if (projection !== "mercator") { - try { - if (overlayInteractionRef.current) overlayInteractionRef.current.setProps({ layers: [] } as never); - } catch { - // ignore - } - return; - } - - const refs = ensureMercatorOverlays(); - const deckTarget = refs?.interaction; - if (!deckTarget) return; - - const overlayParams = DEPTH_DISABLED_PARAMS; - const overlayLayers: unknown[] = []; - - if (overlays.pairRange && pairRangesInteractive.length > 0) { - overlayLayers.push( - new ScatterplotLayer({ - id: "pair-range-overlay", - data: pairRangesInteractive, - pickable: false, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "meters", - getRadius: (d) => d.radiusNm * 1852, - radiusMinPixels: 10, - lineWidthUnits: "pixels", - getLineWidth: () => 2.2, - getLineColor: (d) => (d.warn ? [245, 158, 11, 220] : [245, 158, 11, 170]), - getPosition: (d) => d.center, - }), - ); - } - - if (overlays.pairLines && pairLinksInteractive.length > 0) { - overlayLayers.push( - new LineLayer({ - id: "pair-lines-overlay", - data: pairLinksInteractive, - pickable: false, - parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, - getColor: () => [245, 158, 11, 245], - getWidth: () => 2.6, - widthUnits: "pixels", - }), - ); - } - - if (overlays.fcLines && fcLinesInteractive.length > 0) { - overlayLayers.push( - new LineLayer({ - id: "fc-lines-overlay", - data: fcLinesInteractive, - pickable: false, - parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, - getColor: () => [245, 158, 11, 230], - getWidth: () => 1.9, - widthUnits: "pixels", - }), - ); - } - - if (overlays.fleetCircles && fleetCirclesInteractive.length > 0) { - overlayLayers.push( - new ScatterplotLayer({ - id: "fleet-circles-overlay-fill", - data: fleetCirclesInteractive, - pickable: false, - billboard: false, - parameters: overlayParams, - filled: true, - stroked: false, - radiusUnits: "meters", - getRadius: (d) => d.radiusNm * 1852, - getFillColor: () => [245, 158, 11, 42], - }), - ); - overlayLayers.push( - new ScatterplotLayer({ - id: "fleet-circles-overlay", - data: fleetCirclesInteractive, - pickable: false, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "meters", - getRadius: (d) => d.radiusNm * 1852, - lineWidthUnits: "pixels", - getLineWidth: () => 1.8, - getLineColor: () => [245, 158, 11, 220], - getPosition: (d) => d.center, - }), - ); - } - - if (settings.showShips && legacyOverlayTargets.length > 0) { - overlayLayers.push( - new ScatterplotLayer({ - id: "legacy-halo-overlay", - data: legacyOverlayTargets, - pickable: false, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "pixels", - getRadius: (d) => { - if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; - return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; - }, - lineWidthUnits: "pixels", - getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2.2), - getLineColor: (d) => { - if (selectedMmsi && d.mmsi === selectedMmsi) return [14, 234, 255, 230]; - const l = legacyHits?.get(d.mmsi); - const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; - if (!rgb) return [245, 158, 11, 210]; - return [rgb[0], rgb[1], rgb[2], 210]; - }, - getPosition: (d) => [d.lon, d.lat] as [number, number], - }), - ); - } - - if (settings.showShips && shipOverlayLayerData.length > 0) { - overlayLayers.push( - new IconLayer({ - id: "ships-overlay", - data: shipOverlayLayerData, - pickable: false, - billboard: false, - parameters: overlayParams, - iconAtlas: "/assets/ship.svg", - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => "ship", - getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => - getDisplayHeading({ - cog: d.cog, - heading: d.heading, - }), - sizeUnits: "pixels", - getSize: (d) => { - if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; - if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; - return 0; - }, - getColor: (d) => { - if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; - return getShipColor( - d, - selectedMmsi, - legacyHits?.get(d.mmsi)?.shipCode ?? null, - shipHighlightSet, - ); - }, - }), - ); - } - - const normalizedLayers = sanitizeDeckLayerList(overlayLayers); - const overlayDeckProps = { - layers: normalizedLayers, - getTooltip: undefined, - onClick: undefined, - }; - - try { - deckTarget.setProps(overlayDeckProps as never); - } catch (e) { - console.error("Failed to apply interaction mercator deck props. Falling back to empty layer set.", e); - try { - deckTarget.setProps({ ...overlayDeckProps, layers: [] as unknown[] } as never); - } catch { - // Ignore secondary failure. - } - } - }, [ - ensureMercatorOverlays, - projection, - projectionBusyRef, - shipOverlayLayerData, - legacyOverlayTargets, - pairRangesInteractive, - pairLinksInteractive, - fcLinesInteractive, - fleetCirclesInteractive, - overlays.pairRange, - overlays.pairLines, - overlays.fcLines, - overlays.fleetCircles, - settings.showShips, - selectedMmsi, - shipHighlightSet, - legacyHits, - ]); - // Globe deck (3D) layer updates. Keep rendering logic deterministic and avoid per-frame churn. useEffect(() => { const map = mapRef.current; @@ -4396,7 +4929,11 @@ export function Map3D({ radiusMinPixels: 10, lineWidthUnits: "pixels", getLineWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), - getLineColor: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? [245, 158, 11, 220] : [59, 130, 246, 110]), + getLineColor: (d) => { + const hl = isHighlightedPair(d.aMmsi, d.bMmsi); + if (hl) return d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL; + return d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK; + }, getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { @@ -4424,7 +4961,11 @@ export function Map3D({ parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, - getColor: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? [245, 158, 11, 245] : [59, 130, 246, 85]), + getColor: (d) => { + const hl = isHighlightedPair(d.aMmsi, d.bMmsi); + if (hl) return d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL; + return d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK; + }, getWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), widthUnits: "pixels", onHover: (info) => { @@ -4454,7 +4995,8 @@ export function Map3D({ getTargetPosition: (d) => d.to, getColor: (d) => { const isHighlighted = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); - return isHighlighted ? [245, 158, 11, 230] : [217, 119, 6, 200]; + if (isHighlighted) return d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL; + return d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK; }, getWidth: (d) => { const isHighlighted = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); @@ -4499,7 +5041,7 @@ export function Map3D({ getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: "pixels", getLineWidth: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), - getLineColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? [245, 158, 11, 220] : [245, 158, 11, 140]), + getLineColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_LINE_DECK_HL : FLEET_RANGE_LINE_DECK), getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { @@ -4528,7 +5070,7 @@ export function Map3D({ stroked: false, radiusUnits: "meters", getRadius: (d) => d.radiusNm * 1852, - getFillColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? [245, 158, 11, 42] : [245, 158, 11, 6]), + getFillColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_FILL_DECK_HL : FLEET_RANGE_FILL_DECK), getPosition: (d) => d.center, }), ); @@ -4547,17 +5089,14 @@ export function Map3D({ radiusUnits: "pixels", getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; - if (isHighlightedMmsi(d.mmsi)) return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; return FLAT_LEGACY_HALO_RADIUS; }, lineWidthUnits: "pixels", getLineWidth: (d) => { - const isHighlighted = isHighlightedMmsi(d.mmsi); - return selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : isHighlighted ? 2.2 : 2; + return selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2; }, getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return [14, 234, 255, 230]; - if (isHighlightedMmsi(d.mmsi)) return [245, 158, 11, 210]; const l = legacyHits?.get(d.mmsi); const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; if (!rgb) return [245, 158, 11, 200]; @@ -4568,90 +5107,6 @@ export function Map3D({ ); } - if (settings.showShips) { - globeLayers.push( - new IconLayer({ - id: "ships-globe", - data: shipLayerData, - pickable: true, - billboard: false, - parameters: overlayParams, - iconAtlas: "/assets/ship.svg", - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => "ship", - getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => - getDisplayHeading({ - cog: d.cog, - heading: d.heading, - }), - sizeUnits: "pixels", - getSize: (d) => { - if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; - return FLAT_SHIP_ICON_SIZE; - }, - getColor: (d) => - getShipColor( - d, - selectedMmsi, - legacyHits?.get(d.mmsi)?.shipCode ?? null, - EMPTY_MMSI_SET, - ), - onHover: (info) => { - if (!info.object) { - clearDeckHoverPairs(); - clearDeckHoverMmsi(); - clearMapFleetHoverState(); - return; - } - touchDeckHoverState(true); - const obj = info.object as AisTarget; - setDeckHoverMmsi([obj.mmsi]); - clearDeckHoverPairs(); - clearMapFleetHoverState(); - }, - alphaCutoff: 0.05, - }), - ); - } - - if (settings.showShips) { - globeLayers.push( - new IconLayer({ - id: "ships-globe-hover", - data: shipLayerData, - pickable: false, - billboard: false, - parameters: overlayParams, - iconAtlas: "/assets/ship.svg", - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => "ship", - getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => - getDisplayHeading({ - cog: d.cog, - heading: d.heading, - }), - sizeUnits: "pixels", - getSize: (d) => { - if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; - if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; - return 0; - }, - getColor: (d) => { - if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; - return getShipColor( - d, - selectedMmsi, - legacyHits?.get(d.mmsi)?.shipCode ?? null, - shipHighlightSet, - ); - }, - alphaCutoff: 0.05, - }), - ); - } - const normalizedLayers = sanitizeDeckLayerList(globeLayers); const globeDeckProps = { layers: normalizedLayers, @@ -4677,14 +5132,12 @@ export function Map3D({ fcDashed, fleetCircles, legacyTargetsOrdered, - shipLayerData, overlays.pairRange, overlays.pairLines, overlays.fcLines, overlays.fleetCircles, settings.showShips, selectedMmsi, - isHighlightedMmsi, isHighlightedFleet, isHighlightedPair, clearDeckHoverPairs, @@ -4696,7 +5149,6 @@ export function Map3D({ toFleetMmsiList, touchDeckHoverState, legacyHits, - highlightedMmsiSetCombined, ]); // When the selected MMSI changes due to external UI (e.g., list click), fly to it. diff --git a/apps/web/src/widgets/relations/RelationsPanel.tsx b/apps/web/src/widgets/relations/RelationsPanel.tsx index 063328e..2024522 100644 --- a/apps/web/src/widgets/relations/RelationsPanel.tsx +++ b/apps/web/src/widgets/relations/RelationsPanel.tsx @@ -2,6 +2,7 @@ import { useMemo, type MouseEvent } from "react"; import { VESSEL_TYPES } from "../../entities/vessel/model/meta"; import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types"; import { haversineNm } from "../../shared/lib/geo/haversineNm"; +import { OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette"; type FleetSortMode = "count" | "range"; @@ -161,8 +162,8 @@ export function RelationsPanel({ {dist.toFixed(2)}NM @@ -197,7 +198,7 @@ export function RelationsPanel({ > {fc.permitNo} - + {dist.toFixed(1)}NM {isSameOwner ? ( From 15d5d5ad23de774e9c3618ee7cfead69507ed2e1 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 18:47:52 +0900 Subject: [PATCH 36/58] fix(globe): gate bathymetry fill by zoom to avoid ocean tearing --- apps/web/src/widgets/map3d/Map3D.tsx | 85 +++++++++++++++++++--------- 1 file changed, 58 insertions(+), 27 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index d3da34b..13ca7ae 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -997,14 +997,20 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str } as unknown as LayerSpecification; // Insert before the first symbol layer (keep labels on top), otherwise append. - const rawLayers = Array.isArray(style.layers) ? style.layers : []; - const layers = rawLayers.filter((layer): layer is LayerSpecification => { - if (!layer || typeof layer !== "object") return false; - return typeof (layer as { id?: unknown }).id === "string"; - }); - const symbolIndex = layers.findIndex((l) => l.type === "symbol"); + const layers = Array.isArray(style.layers) ? (style.layers as unknown as LayerSpecification[]) : []; + if (!Array.isArray(style.layers)) { + style.layers = layers as unknown as StyleSpecification["layers"]; + } + + const symbolIndex = layers.findIndex((l) => (l as { type?: unknown } | null)?.type === "symbol"); const insertAt = symbolIndex >= 0 ? symbolIndex : layers.length; + const existingIds = new Set(); + for (const layer of layers) { + const id = getLayerId(layer); + if (id) existingIds.add(id); + } + const toInsert = [ bathyFill, bathyBandBorders, @@ -1013,12 +1019,44 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str bathyLinesMajor, bathyLabels, landformLabels, - ].filter( - (l) => !layers.some((x) => x.id === l.id), - ); + ].filter((l) => !existingIds.has(l.id)); if (toInsert.length > 0) layers.splice(insertAt, 0, ...toInsert); } +type BathyZoomRange = { + id: string; + mercator: [number, number]; + globe: [number, number]; +}; + +const BATHY_ZOOM_RANGES: BathyZoomRange[] = [ + { id: "bathymetry-fill", mercator: [6, 12], globe: [8, 12] }, + { id: "bathymetry-borders", mercator: [6, 14], globe: [8, 14] }, + { id: "bathymetry-borders-major", mercator: [4, 14], globe: [8, 14] }, +]; + +function applyBathymetryZoomProfile(map: maplibregl.Map, baseMap: BaseMapId, projection: MapProjectionId) { + if (!map || !map.isStyleLoaded()) return; + if (baseMap !== "enhanced") return; + const isGlobe = projection === "globe"; + + for (const range of BATHY_ZOOM_RANGES) { + if (!map.getLayer(range.id)) continue; + const [minzoom, maxzoom] = isGlobe ? range.globe : range.mercator; + try { + // Safety: ensure heavy layers aren't stuck hidden from a previous session. + map.setLayoutProperty(range.id, "visibility", "visible"); + } catch { + // ignore + } + try { + map.setLayerZoomRange(range.id, minzoom, maxzoom); + } catch { + // ignore + } + } +} + async function resolveInitialMapStyle(signal: AbortSignal): Promise { const key = getMapTilerKey(); if (!key) return "/map/styles/osm-seamark.json"; @@ -1223,6 +1261,7 @@ export function Map3D({ const projectionBusyTokenRef = useRef(0); const projectionBusyTimerRef = useRef | null>(null); const projectionPrevRef = useRef(projection); + const bathyZoomProfileKeyRef = useRef(""); const mapTooltipRef = useRef(null); const deckHoverRafRef = useRef(null); const deckHoverHasHitRef = useRef(false); @@ -2081,33 +2120,25 @@ export function Map3D({ }, [baseMap]); // Globe rendering + bathymetry tuning. - // Some terrain/hillshade/extrusion effects look unstable under globe and can occlude Deck overlays. + // Under globe projection, low-zoom bathymetry polygons can exceed MapLibre's per-segment 16-bit vertex + // limit (65535) due to projection subdivision. Keep globe stable by gating heavy bathymetry fills/borders + // to higher zoom levels rather than toggling them on every frame. useEffect(() => { const map = mapRef.current; if (!map) return; const apply = () => { if (!map.isStyleLoaded()) return; - const disableBathyHeavy = projection === "globe" && baseMap === "enhanced"; - const visHeavy = disableBathyHeavy ? "none" : "visible"; const seaVisibility = "visible" as const; const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i; - // Globe + our injected bathymetry fill polygons can exceed MapLibre's per-segment vertex limit - // (65535), causing broken ocean rendering. Keep globe mode stable by disabling the heavy fill. - const heavyIds = [ - "bathymetry-fill", - "bathymetry-borders", - "bathymetry-borders-major", - "bathymetry-extrusion", - "bathymetry-hillshade", - ]; - for (const id of heavyIds) { - try { - if (map.getLayer(id)) map.setLayoutProperty(id, "visibility", visHeavy); - } catch { - // ignore - } + // Apply zoom gating for heavy bathymetry layers once per (baseMap, projection) combination. + // This avoids repeatedly mutating layer zoom ranges on hover/mapSyncEpoch pulses. + const nextProfileKey = `bathyZoomV1|${baseMap}|${projection}`; + if (bathyZoomProfileKeyRef.current !== nextProfileKey) { + applyBathymetryZoomProfile(map, baseMap, projection); + bathyZoomProfileKeyRef.current = nextProfileKey; + kickRepaint(map); } // Vector basemap water layers can be tuned per-style. Keep visible by default, From dc702728bed6b6ca6ee4b28789c86fc1fe00d098 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 18:51:29 +0900 Subject: [PATCH 37/58] tweak(map): increase bathymetry depth label size --- apps/web/src/widgets/map3d/Map3D.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 13ca7ae..8024f03 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -960,7 +960,8 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str "symbol-placement": "line", "text-field": depthLabel, "text-font": ["Noto Sans Regular", "Open Sans Regular"], - "text-size": ["interpolate", ["linear"], ["zoom"], 10, 10, 12, 12], + // Make depth labels more legible on both mercator + globe. + "text-size": ["interpolate", ["linear"], ["zoom"], 10, 12, 12, 14, 14, 15], "text-allow-overlap": false, "text-padding": 2, "text-rotation-alignment": "map", From 0899223c75cdac182994468313a82f87a541bee5 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 18:55:57 +0900 Subject: [PATCH 38/58] fix(map): keep bathymetry visible when overzooming --- apps/web/src/widgets/map3d/Map3D.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 8024f03..dce2dd8 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -859,7 +859,8 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str // which may exceed MapLibre's per-segment 16-bit vertex limit and render incorrectly. // We keep the fill starting at a more reasonable zoom. minzoom: 6, - maxzoom: 12, + // Source maxzoom is 12, but we allow overzoom so the bathymetry doesn't disappear when zooming in. + maxzoom: 24, paint: { // Dark-mode friendly palette (shallow = slightly brighter; deep = near-black). "fill-color": bathyFillColor, @@ -875,7 +876,7 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str source: oceanSourceId, "source-layer": "contour", minzoom: 6, - maxzoom: 14, + maxzoom: 24, paint: { "line-color": "rgba(255,255,255,0.06)", "line-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0.12, 8, 0.18, 12, 0.22], @@ -923,7 +924,7 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str source: oceanSourceId, "source-layer": "contour_line", minzoom: 8, - maxzoom: 14, + maxzoom: 24, filter: bathyMajorDepthFilter as unknown as unknown[], paint: { "line-color": "rgba(255,255,255,0.16)", @@ -939,7 +940,7 @@ function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: str source: oceanSourceId, "source-layer": "contour", minzoom: 4, - maxzoom: 14, + maxzoom: 24, filter: bathyMajorDepthFilter as unknown as unknown[], paint: { "line-color": "rgba(255,255,255,0.14)", @@ -1031,9 +1032,11 @@ type BathyZoomRange = { }; const BATHY_ZOOM_RANGES: BathyZoomRange[] = [ - { id: "bathymetry-fill", mercator: [6, 12], globe: [8, 12] }, - { id: "bathymetry-borders", mercator: [6, 14], globe: [8, 14] }, - { id: "bathymetry-borders-major", mercator: [4, 14], globe: [8, 14] }, + // MapTiler Ocean tiles maxzoom=12; beyond that we overzoom the z12 geometry. + // Keep rendering at high zoom so the sea doesn't revert to the basemap's flat water color. + { id: "bathymetry-fill", mercator: [6, 24], globe: [8, 24] }, + { id: "bathymetry-borders", mercator: [6, 24], globe: [8, 24] }, + { id: "bathymetry-borders-major", mercator: [4, 24], globe: [8, 24] }, ]; function applyBathymetryZoomProfile(map: maplibregl.Map, baseMap: BaseMapId, projection: MapProjectionId) { From 11aff67a0466b7c220398f780b680833d282754a Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 19:15:20 +0900 Subject: [PATCH 39/58] feat(map): add prediction vectors and ship labels toggles --- apps/web/src/app/styles.css | 13 + .../src/features/mapToggles/MapToggles.tsx | 8 +- .../web/src/pages/dashboard/DashboardPage.tsx | 2 + apps/web/src/widgets/legend/MapLegend.tsx | 11 + apps/web/src/widgets/map3d/Map3D.tsx | 542 +++++++++++++++--- 5 files changed, 499 insertions(+), 77 deletions(-) diff --git a/apps/web/src/app/styles.css b/apps/web/src/app/styles.css index 36528cf..a959fd6 100644 --- a/apps/web/src/app/styles.css +++ b/apps/web/src/app/styles.css @@ -638,6 +638,19 @@ body { margin-bottom: 6px; } +.tog.tog-map { + /* Keep "지도 표시 설정" buttons in a predictable 2-row layout (4 columns). */ + gap: 4px; +} + +.tog.tog-map .tog-btn { + flex: 1 1 calc(25% - 4px); + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .tog-btn { font-size: 8px; padding: 2px 6px; diff --git a/apps/web/src/features/mapToggles/MapToggles.tsx b/apps/web/src/features/mapToggles/MapToggles.tsx index babb959..cf2722a 100644 --- a/apps/web/src/features/mapToggles/MapToggles.tsx +++ b/apps/web/src/features/mapToggles/MapToggles.tsx @@ -4,6 +4,8 @@ export type MapToggleState = { fcLines: boolean; zones: boolean; fleetCircles: boolean; + predictVectors: boolean; + shipLabels: boolean; }; type Props = { @@ -16,12 +18,14 @@ export function MapToggles({ value, onToggle }: Props) { { id: "pairLines", label: "쌍 연결선" }, { id: "pairRange", label: "쌍 연결범위" }, { id: "fcLines", label: "환적 연결선" }, - { id: "zones", label: "수역 표시" }, { id: "fleetCircles", label: "선단 범위" }, + { id: "zones", label: "수역 표시" }, + { id: "predictVectors", label: "예측 벡터" }, + { id: "shipLabels", label: "선박명 표시" }, ]; return ( -
+
{items.map((t) => (
onToggle(t.id)}> {t.label} diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 50dea15..4c1ab37 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -115,6 +115,8 @@ export function DashboardPage() { fcLines: true, zones: true, fleetCircles: true, + predictVectors: false, + shipLabels: false, }); const [fleetRelationSortMode, setFleetRelationSortMode] = useState("count"); diff --git a/apps/web/src/widgets/legend/MapLegend.tsx b/apps/web/src/widgets/legend/MapLegend.tsx index 3377175..1d7a7d3 100644 --- a/apps/web/src/widgets/legend/MapLegend.tsx +++ b/apps/web/src/widgets/legend/MapLegend.tsx @@ -98,6 +98,17 @@ export function MapLegend() {
FC 환적 연결 (의심)
+
+
+ 예측 벡터 (15분) +
); } diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index dce2dd8..16a537a 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -17,7 +17,7 @@ import type { ZoneId } from "../../entities/zone/model/meta"; import { ZONE_META } from "../../entities/zone/model/meta"; import type { MapToggleState } from "../../features/mapToggles/MapToggles"; import type { FcLink, FleetCircle, PairLink } from "../../features/legacyDashboard/model/types"; -import { LEGACY_CODE_COLORS_RGB, OVERLAY_RGB, rgba as rgbaCss } from "../../shared/lib/map/palette"; +import { LEGACY_CODE_COLORS_RGB, OTHER_AIS_SPEED_RGB, OVERLAY_RGB, rgba as rgbaCss } from "../../shared/lib/map/palette"; import { MaplibreDeckCustomLayer } from "./MaplibreDeckCustomLayer"; export type Map3DSettings = { @@ -288,6 +288,7 @@ function extractProjectionType(map: maplibregl.Map): MapProjectionId | undefined } const DEG2RAD = Math.PI / 180; +const RAD2DEG = 180 / Math.PI; const GLOBE_ICON_HEADING_OFFSET_DEG = -90; const MAP_SELECTED_SHIP_RGB: [number, number, number] = [34, 211, 238]; const MAP_HIGHLIGHT_SHIP_RGB: [number, number, number] = [245, 158, 11]; @@ -301,6 +302,42 @@ function getLayerId(value: unknown): string | null { return typeof candidate === "string" ? candidate : null; } +function wrapLonDeg(lon: number) { + // Normalize longitude into [-180, 180). + const v = ((lon + 180) % 360 + 360) % 360; + return v - 180; +} + +function destinationPointLngLat( + from: [number, number], // [lon, lat] + bearingDeg: number, + distanceMeters: number, +): [number, number] { + const [lonDeg, latDeg] = from; + const lat1 = latDeg * DEG2RAD; + const lon1 = lonDeg * DEG2RAD; + const brng = bearingDeg * DEG2RAD; + const dr = Math.max(0, distanceMeters) / EARTH_RADIUS_M; + if (!Number.isFinite(dr) || dr === 0) return [lonDeg, latDeg]; + + const sinLat1 = Math.sin(lat1); + const cosLat1 = Math.cos(lat1); + const sinDr = Math.sin(dr); + const cosDr = Math.cos(dr); + + const lat2 = Math.asin(sinLat1 * cosDr + cosLat1 * sinDr * Math.cos(brng)); + const lon2 = + lon1 + + Math.atan2( + Math.sin(brng) * sinDr * cosLat1, + cosDr - sinLat1 * Math.sin(lat2), + ); + + const outLon = wrapLonDeg(lon2 * RAD2DEG); + const outLat = clampNumber(lat2 * RAD2DEG, -85.0, 85.0); + return [outLon, outLat]; +} + function sanitizeDeckLayerList(value: unknown): unknown[] { if (!Array.isArray(value)) return []; const seen = new Set(); @@ -1297,18 +1334,21 @@ export function Map3D({ if (!map || projectionRef.current !== "globe") return; if (projectionBusyRef.current) return; - const ordering = [ - "zones-fill", - "zones-line", - "zones-label", - "ships-globe-halo", - "ships-globe-outline", - "ships-globe", - "ships-globe-hover-halo", - "ships-globe-hover-outline", - "ships-globe-hover", - "pair-lines-ml", - "fc-lines-ml", + const ordering = [ + "zones-fill", + "zones-line", + "zones-label", + "predict-vectors", + "predict-vectors-hl", + "ships-globe-halo", + "ships-globe-outline", + "ships-globe", + "ships-globe-label", + "ships-globe-hover-halo", + "ships-globe-hover-outline", + "ships-globe-hover", + "pair-lines-ml", + "fc-lines-ml", "pair-range-ml", "fleet-circles-ml-fill", "fleet-circles-ml", @@ -1706,15 +1746,16 @@ export function Map3D({ const map = mapRef.current; if (!map) return; - const layerIds = [ - "ships-globe-halo", - "ships-globe-outline", - "ships-globe", - "ships-globe-hover-halo", - "ships-globe-hover-outline", - "ships-globe-hover", - "pair-lines-ml", - "fc-lines-ml", + const layerIds = [ + "ships-globe-halo", + "ships-globe-outline", + "ships-globe", + "ships-globe-label", + "ships-globe-hover-halo", + "ships-globe-hover-outline", + "ships-globe-hover", + "pair-lines-ml", + "fc-lines-ml", "fleet-circles-ml-fill", "fleet-circles-ml", "pair-range-ml", @@ -2410,25 +2451,307 @@ export function Map3D({ }; }, [zones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]); + // Prediction vectors: MapLibre-native GeoJSON line layer so it stays stable in both mercator + globe. + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = "predict-vectors-src"; + const lineId = "predict-vectors"; + const hlId = "predict-vectors-hl"; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + + const visibility = overlays.predictVectors ? "visible" : "none"; + + const horizonMinutes = 15; + const horizonSeconds = horizonMinutes * 60; + const metersPerSecondPerKnot = 0.514444; + + const features: GeoJSON.Feature[] = []; + if (overlays.predictVectors && settings.showShips && shipData.length > 0) { + for (const t of shipData) { + const legacy = legacyHits?.get(t.mmsi) ?? null; + const isTarget = !!legacy; + const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi; + const isPinnedHighlight = externalHighlightedSetRef.has(t.mmsi); + if (!isTarget && !isSelected && !isPinnedHighlight) continue; + + const sog = isFiniteNumber(t.sog) ? t.sog : null; + const cog = + isFiniteNumber(t.cog) ? t.cog : isFiniteNumber(t.heading) ? t.heading : null; + if (sog == null || cog == null) continue; + if (sog < 0.2) continue; + + const distM = sog * metersPerSecondPerKnot * horizonSeconds; + if (!Number.isFinite(distM) || distM <= 0) continue; + + const to = destinationPointLngLat([t.lon, t.lat], cog, distM); + + const rgb = isTarget + ? LEGACY_CODE_COLORS_RGB[legacy?.shipCode ?? ""] ?? OTHER_AIS_SPEED_RGB.moving + : OTHER_AIS_SPEED_RGB.moving; + const alpha = isTarget ? 0.48 : 0.28; + const hl = isSelected || isPinnedHighlight ? 1 : 0; + + features.push({ + type: "Feature", + id: `pred-${t.mmsi}`, + geometry: { type: "LineString", coordinates: [[t.lon, t.lat], to] }, + properties: { + mmsi: t.mmsi, + minutes: horizonMinutes, + sog, + cog, + target: isTarget ? 1 : 0, + hl, + color: rgbaCss(rgb, alpha), + }, + }); + } + } + + const fc: GeoJSON.FeatureCollection = { type: "FeatureCollection", features }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: "geojson", data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn("Prediction vector source setup failed:", e); + return; + } + + const ensureLayer = (id: string, paint: LayerSpecification["paint"], filter?: unknown[]) => { + if (!map.getLayer(id)) { + try { + map.addLayer( + { + id, + type: "line", + source: srcId, + ...(filter ? { filter: filter as never } : {}), + layout: { + visibility, + "line-cap": "round", + "line-join": "round", + }, + paint, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn("Prediction vector layer add failed:", e); + } + } else { + try { + map.setLayoutProperty(id, "visibility", visibility); + } catch { + // ignore + } + } + }; + + ensureLayer( + lineId, + { + "line-color": ["coalesce", ["get", "color"], "rgba(148,163,184,0.3)"] as never, + "line-width": 1.2, + "line-opacity": 1, + "line-dasharray": [1.2, 1.8] as never, + } as never, + ); + ensureLayer( + hlId, + { + "line-color": ["coalesce", ["get", "color"], "rgba(226,232,240,0.7)"] as never, + "line-width": 2.2, + "line-opacity": 1, + "line-dasharray": [1.2, 1.8] as never, + } as never, + ["==", ["to-number", ["get", "hl"], 0], 1] as unknown as unknown[], + ); + + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + return () => { + stop(); + }; + }, [ + overlays.predictVectors, + settings.showShips, + shipData, + legacyHits, + selectedMmsi, + externalHighlightedSetRef, + projection, + baseMap, + mapSyncEpoch, + reorderGlobeFeatureLayers, + ]); + + // Ship name labels in mercator: MapLibre-native symbol layer so collision/placement is handled automatically. + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = "ship-labels-src"; + const layerId = "ship-labels"; + + const remove = () => { + try { + if (map.getLayer(layerId)) map.removeLayer(layerId); + } catch { + // ignore + } + try { + if (map.getSource(srcId)) map.removeSource(srcId); + } catch { + // ignore + } + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + + if (projection !== "mercator" || !settings.showShips) { + remove(); + return; + } + + const visibility = overlays.shipLabels ? "visible" : "none"; + + const features: GeoJSON.Feature[] = []; + for (const t of shipData) { + const legacy = legacyHits?.get(t.mmsi) ?? null; + const isTarget = !!legacy; + const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi; + const isPinnedHighlight = externalHighlightedSetRef.has(t.mmsi); + if (!isTarget && !isSelected && !isPinnedHighlight) continue; + + const labelName = (legacy?.shipNameCn || legacy?.shipNameRoman || t.name || "").trim(); + if (!labelName) continue; + + features.push({ + type: "Feature", + id: `ship-label-${t.mmsi}`, + geometry: { type: "Point", coordinates: [t.lon, t.lat] }, + properties: { + mmsi: t.mmsi, + labelName, + selected: isSelected ? 1 : 0, + highlighted: isPinnedHighlight ? 1 : 0, + permitted: isTarget ? 1 : 0, + }, + }); + } + + const fc: GeoJSON.FeatureCollection = { type: "FeatureCollection", features }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: "geojson", data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn("Ship label source setup failed:", e); + return; + } + + const filter = ["!=", ["to-string", ["coalesce", ["get", "labelName"], ""]], ""] as unknown as unknown[]; + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: "symbol", + source: srcId, + minzoom: 7, + filter: filter as never, + layout: { + visibility, + "symbol-placement": "point", + "text-field": ["get", "labelName"] as never, + "text-font": ["Noto Sans Regular", "Open Sans Regular"], + "text-size": ["interpolate", ["linear"], ["zoom"], 7, 10, 10, 11, 12, 12, 14, 13] as never, + "text-anchor": "top", + "text-offset": [0, 1.1], + "text-padding": 2, + "text-allow-overlap": false, + "text-ignore-placement": false, + }, + paint: { + "text-color": [ + "case", + ["==", ["get", "selected"], 1], + "rgba(14,234,255,0.95)", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,0.95)", + "rgba(226,232,240,0.92)", + ] as never, + "text-halo-color": "rgba(2,6,23,0.85)", + "text-halo-width": 1.2, + "text-halo-blur": 0.8, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn("Ship label layer add failed:", e); + } + } else { + try { + map.setLayoutProperty(layerId, "visibility", visibility); + } catch { + // ignore + } + } + + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + return () => { + stop(); + }; + }, [ + projection, + settings.showShips, + overlays.shipLabels, + shipData, + legacyHits, + selectedMmsi, + externalHighlightedSetRef, + baseMap, + mapSyncEpoch, + ]); + // Ships in globe mode: render with MapLibre symbol layers so icons can be aligned to the globe surface. // Deck IconLayer billboards or uses a fixed plane, which looks like ships are pointing into the sky/ground on globe. useEffect(() => { const map = mapRef.current; if (!map) return; - const imgId = "ship-globe-icon"; - const srcId = "ships-globe-src"; - const haloId = "ships-globe-halo"; - const outlineId = "ships-globe-outline"; - const symbolId = "ships-globe"; + const imgId = "ship-globe-icon"; + const srcId = "ships-globe-src"; + const haloId = "ships-globe-halo"; + const outlineId = "ships-globe-outline"; + const symbolId = "ships-globe"; + const labelId = "ships-globe-label"; - const remove = () => { - for (const id of [symbolId, outlineId, haloId]) { - try { - if (map.getLayer(id)) map.removeLayer(id); - } catch { - // ignore - } + const remove = () => { + for (const id of [labelId, symbolId, outlineId, haloId]) { + try { + if (map.getLayer(id)) map.removeLayer(id); + } catch { + // ignore + } } try { if (map.getSource(srcId)) map.removeSource(srcId); @@ -2527,16 +2850,21 @@ export function Map3D({ console.warn("Ship icon image setup failed:", e); } - const globeShipData = shipData; - const geojson: GeoJSON.FeatureCollection = { - type: "FeatureCollection", - features: globeShipData.map((t) => { - const legacy = legacyHits?.get(t.mmsi) ?? null; - const heading = getDisplayHeading({ - cog: t.cog, - heading: t.heading, - offset: GLOBE_ICON_HEADING_OFFSET_DEG, - }); + const globeShipData = shipData; + const geojson: GeoJSON.FeatureCollection = { + type: "FeatureCollection", + features: globeShipData.map((t) => { + const legacy = legacyHits?.get(t.mmsi) ?? null; + const labelName = + legacy?.shipNameCn || + legacy?.shipNameRoman || + t.name || + ""; + const heading = getDisplayHeading({ + cog: t.cog, + heading: t.heading, + offset: GLOBE_ICON_HEADING_OFFSET_DEG, + }); const hull = clampNumber((isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, 50, 420); const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); const selected = t.mmsi === selectedMmsi; @@ -2552,14 +2880,15 @@ export function Map3D({ type: "Feature", ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), geometry: { type: "Point", coordinates: [t.lon, t.lat] }, - properties: { - mmsi: t.mmsi, - name: t.name || "", - cog: heading, - heading, - sog: isFiniteNumber(t.sog) ? t.sog : 0, - shipColor: getGlobeBaseShipColor({ - legacy: legacy?.shipCode || null, + properties: { + mmsi: t.mmsi, + name: t.name || "", + labelName, + cog: heading, + heading, + sog: isFiniteNumber(t.sog) ? t.sog : 0, + shipColor: getGlobeBaseShipColor({ + legacy: legacy?.shipCode || null, sog: isFiniteNumber(t.sog) ? t.sog : null, }), iconSize3: iconSize3 * iconScale, @@ -2783,10 +3112,10 @@ export function Map3D({ } } - if (!map.getLayer(symbolId)) { - try { - map.addLayer( - { + if (!map.getLayer(symbolId)) { + try { + map.addLayer( + { id: symbolId, type: "symbol", source: srcId, @@ -2847,10 +3176,10 @@ export function Map3D({ } catch (e) { console.warn("Ship symbol layer add failed:", e); } - } else { - try { - map.setLayoutProperty(symbolId, "visibility", visibility); - map.setLayoutProperty( + } else { + try { + map.setLayoutProperty(symbolId, "visibility", visibility); + map.setLayoutProperty( symbolId, "symbol-sort-key", [ @@ -2884,27 +3213,90 @@ export function Map3D({ ); } catch { // ignore - } - } + } + } - // Selection and highlight are now source-data driven. - reorderGlobeFeatureLayers(); - kickRepaint(map); - }; + // Optional ship name labels (toggle). Keep labels readable and avoid clutter. + const labelVisibility = overlays.shipLabels ? "visible" : "none"; + const labelFilter = [ + "all", + ["!=", ["to-string", ["coalesce", ["get", "labelName"], ""]], ""], + [ + "any", + ["==", ["get", "permitted"], 1], + ["==", ["get", "selected"], 1], + ["==", ["get", "highlighted"], 1], + ], + ] as unknown as unknown[]; + + if (!map.getLayer(labelId)) { + try { + map.addLayer( + { + id: labelId, + type: "symbol", + source: srcId, + minzoom: 7, + filter: labelFilter as never, + layout: { + visibility: labelVisibility, + "symbol-placement": "point", + "text-field": ["get", "labelName"] as never, + "text-font": ["Noto Sans Regular", "Open Sans Regular"], + "text-size": ["interpolate", ["linear"], ["zoom"], 7, 10, 10, 11, 12, 12, 14, 13] as never, + "text-anchor": "top", + "text-offset": [0, 1.1], + "text-padding": 2, + "text-allow-overlap": false, + "text-ignore-placement": false, + }, + paint: { + "text-color": [ + "case", + ["==", ["get", "selected"], 1], + "rgba(14,234,255,0.95)", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,0.95)", + "rgba(226,232,240,0.92)", + ] as never, + "text-halo-color": "rgba(2,6,23,0.85)", + "text-halo-width": 1.2, + "text-halo-blur": 0.8, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn("Ship label layer add failed:", e); + } + } else { + try { + map.setLayoutProperty(labelId, "visibility", labelVisibility); + } catch { + // ignore + } + } + + // Selection and highlight are now source-data driven. + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; const stop = onMapStyleReady(map, ensure); return () => { stop(); }; - }, [ - projection, - settings.showShips, - shipData, - legacyHits, - selectedMmsi, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); + }, [ + projection, + settings.showShips, + overlays.shipLabels, + shipData, + legacyHits, + selectedMmsi, + isBaseHighlightedMmsi, + mapSyncEpoch, + reorderGlobeFeatureLayers, + ]); // Globe hover overlay ships: update only hovered/selected targets to avoid rebuilding full ship layer on every hover. useEffect(() => { From a8aa916076fb0424d70b326cd9d1672343f72520 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 19:41:15 +0900 Subject: [PATCH 40/58] fix(map): align prediction vectors with ship course + improve contrast --- apps/web/src/widgets/map3d/Map3D.tsx | 88 +++++++++++++++++++++------- 1 file changed, 68 insertions(+), 20 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 16a537a..95f18a6 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -370,6 +370,15 @@ function normalizeAngleDeg(value: number, offset = 0): number { return ((v % 360) + 360) % 360; } +function toValidBearingDeg(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + // AIS heading uses 511 as "not available". Some feeds may also use 360 as "not available". + if (value === 511) return null; + if (value < 0) return null; + if (value >= 360) return null; + return value; +} + function getDisplayHeading({ cog, heading, @@ -379,8 +388,8 @@ function getDisplayHeading({ heading: number | null | undefined; offset?: number; }) { - const raw = - isFiniteNumber(heading) && heading >= 0 && heading <= 360 && heading !== 511 ? heading : isFiniteNumber(cog) ? cog : 0; + // Use COG (0=N, 90=E...) as the primary bearing so ship icons + prediction vectors stay aligned. + const raw = toValidBearingDeg(cog) ?? toValidBearingDeg(heading) ?? 0; return normalizeAngleDeg(raw, offset); } @@ -1329,16 +1338,18 @@ export function Map3D({ const fleetFocusLat = fleetFocus?.center?.[1]; const fleetFocusZoom = fleetFocus?.zoom; - const reorderGlobeFeatureLayers = useCallback(() => { - const map = mapRef.current; - if (!map || projectionRef.current !== "globe") return; - if (projectionBusyRef.current) return; + const reorderGlobeFeatureLayers = useCallback(() => { + const map = mapRef.current; + if (!map || projectionRef.current !== "globe") return; + if (projectionBusyRef.current) return; const ordering = [ "zones-fill", "zones-line", "zones-label", + "predict-vectors-outline", "predict-vectors", + "predict-vectors-hl-outline", "predict-vectors-hl", "ships-globe-halo", "ships-globe-outline", @@ -2457,7 +2468,9 @@ export function Map3D({ if (!map) return; const srcId = "predict-vectors-src"; + const outlineId = "predict-vectors-outline"; const lineId = "predict-vectors"; + const hlOutlineId = "predict-vectors-hl-outline"; const hlId = "predict-vectors-hl"; const ensure = () => { @@ -2480,20 +2493,21 @@ export function Map3D({ if (!isTarget && !isSelected && !isPinnedHighlight) continue; const sog = isFiniteNumber(t.sog) ? t.sog : null; - const cog = - isFiniteNumber(t.cog) ? t.cog : isFiniteNumber(t.heading) ? t.heading : null; - if (sog == null || cog == null) continue; + const bearing = toValidBearingDeg(t.cog) ?? toValidBearingDeg(t.heading); + if (sog == null || bearing == null) continue; if (sog < 0.2) continue; const distM = sog * metersPerSecondPerKnot * horizonSeconds; if (!Number.isFinite(distM) || distM <= 0) continue; - const to = destinationPointLngLat([t.lon, t.lat], cog, distM); + const to = destinationPointLngLat([t.lon, t.lat], bearing, distM); - const rgb = isTarget + const baseRgb = isTarget ? LEGACY_CODE_COLORS_RGB[legacy?.shipCode ?? ""] ?? OTHER_AIS_SPEED_RGB.moving : OTHER_AIS_SPEED_RGB.moving; - const alpha = isTarget ? 0.48 : 0.28; + const rgb = lightenColor(baseRgb, isTarget ? 0.55 : 0.62); + const alpha = isTarget ? 0.72 : 0.52; + const alphaHl = isTarget ? 0.92 : 0.84; const hl = isSelected || isPinnedHighlight ? 1 : 0; features.push({ @@ -2504,10 +2518,11 @@ export function Map3D({ mmsi: t.mmsi, minutes: horizonMinutes, sog, - cog, + cog: bearing, target: isTarget ? 1 : 0, hl, color: rgbaCss(rgb, alpha), + colorHl: rgbaCss(rgb, alphaHl), }, }); } @@ -2524,7 +2539,7 @@ export function Map3D({ return; } - const ensureLayer = (id: string, paint: LayerSpecification["paint"], filter?: unknown[]) => { + const ensureLayer = (id: string, paint: LayerSpecification["paint"], filter: unknown[]) => { if (!map.getLayer(id)) { try { map.addLayer( @@ -2532,7 +2547,7 @@ export function Map3D({ id, type: "line", source: srcId, - ...(filter ? { filter: filter as never } : {}), + filter: filter as never, layout: { visibility, "line-cap": "round", @@ -2548,30 +2563,63 @@ export function Map3D({ } else { try { map.setLayoutProperty(id, "visibility", visibility); + map.setFilter(id, filter as never); + if (paint && typeof paint === "object") { + for (const [key, value] of Object.entries(paint)) { + map.setPaintProperty(id, key as never, value as never); + } + } } catch { // ignore } } }; + const baseFilter = ["==", ["to-number", ["get", "hl"], 0], 0] as unknown as unknown[]; + const hlFilter = ["==", ["to-number", ["get", "hl"], 0], 1] as unknown as unknown[]; + + // Outline (halo) for readability over bathymetry + seamark textures. + ensureLayer( + outlineId, + { + "line-color": "rgba(2,6,23,0.86)", + "line-width": 4.8, + "line-opacity": 1, + "line-blur": 0.2, + "line-dasharray": [1.2, 1.8] as never, + } as never, + baseFilter, + ); ensureLayer( lineId, { - "line-color": ["coalesce", ["get", "color"], "rgba(148,163,184,0.3)"] as never, - "line-width": 1.2, + "line-color": ["coalesce", ["get", "color"], "rgba(226,232,240,0.62)"] as never, + "line-width": 2.4, "line-opacity": 1, "line-dasharray": [1.2, 1.8] as never, } as never, + baseFilter, + ); + ensureLayer( + hlOutlineId, + { + "line-color": "rgba(2,6,23,0.92)", + "line-width": 6.4, + "line-opacity": 1, + "line-blur": 0.25, + "line-dasharray": [1.2, 1.8] as never, + } as never, + hlFilter, ); ensureLayer( hlId, { - "line-color": ["coalesce", ["get", "color"], "rgba(226,232,240,0.7)"] as never, - "line-width": 2.2, + "line-color": ["coalesce", ["get", "colorHl"], ["get", "color"], "rgba(241,245,249,0.92)"] as never, + "line-width": 3.6, "line-opacity": 1, "line-dasharray": [1.2, 1.8] as never, } as never, - ["==", ["to-number", ["get", "hl"], 0], 1] as unknown as unknown[], + hlFilter, ); reorderGlobeFeatureLayers(); From d01240a737ce4f94696c54f3ad2a4599e863f412 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 21:52:04 +0900 Subject: [PATCH 41/58] fix(map): align ship icon headings for COG convention --- apps/web/src/widgets/map3d/Map3D.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 95f18a6..cd4c710 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -289,7 +289,9 @@ function extractProjectionType(map: maplibregl.Map): MapProjectionId | undefined const DEG2RAD = Math.PI / 180; const RAD2DEG = 180 / Math.PI; -const GLOBE_ICON_HEADING_OFFSET_DEG = -90; +// ship.svg's native "up" direction is north (0deg), +// so map icon rotation can use COG directly. +const GLOBE_ICON_HEADING_OFFSET_DEG = 0; const MAP_SELECTED_SHIP_RGB: [number, number, number] = [34, 211, 238]; const MAP_HIGHLIGHT_SHIP_RGB: [number, number, number] = [245, 158, 11]; const MAP_DEFAULT_SHIP_RGB: [number, number, number] = [100, 116, 139]; @@ -5002,7 +5004,7 @@ export function Map3D({ getIcon: () => "ship", getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => - getDisplayHeading({ + -getDisplayHeading({ cog: d.cog, heading: d.heading, }), @@ -5030,7 +5032,7 @@ export function Map3D({ getIcon: () => "ship", getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => - getDisplayHeading({ + -getDisplayHeading({ cog: d.cog, heading: d.heading, }), @@ -5085,7 +5087,7 @@ export function Map3D({ getIcon: () => "ship", getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => - getDisplayHeading({ + -getDisplayHeading({ cog: d.cog, heading: d.heading, }), @@ -5230,7 +5232,7 @@ export function Map3D({ getIcon: () => "ship", getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => - getDisplayHeading({ + -getDisplayHeading({ cog: d.cog, heading: d.heading, }), From 918b80e06a17c6ae8f861467ce188a0a544fc29e Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 22:18:40 +0900 Subject: [PATCH 42/58] =?UTF-8?q?chore:=20=ED=8C=80=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pnpm → npm 전환 (워크스페이스 유지) - .claude/ 팀 규칙(5), 스킬(4), 설정, hooks 스크립트(3) 추가 - .githooks/ commit-msg, post-checkout, pre-commit 추가 - Nexus npm 프록시 설정 (.npmrc — URL만, 인증 제외) - .editorconfig, .prettierrc, .node-version(24) 추가 - CLAUDE.md 프로젝트 설명서 생성 - Map3D.tsx 미사용 함수 제거 (getDeckShipAngle) Co-Authored-By: Claude Opus 4.6 --- .claude/rules/code-style.md | 69 + .claude/rules/git-workflow.md | 84 + .claude/rules/naming.md | 53 + .claude/rules/team-policy.md | 34 + .claude/rules/testing.md | 64 + .claude/scripts/on-commit.sh | 14 + .claude/scripts/on-post-compact.sh | 23 + .claude/scripts/on-pre-compact.sh | 8 + .claude/settings.json | 85 + .claude/skills/create-mr/SKILL.md | 65 + .claude/skills/fix-issue/SKILL.md | 49 + .claude/skills/init-project/SKILL.md | 246 + .claude/skills/sync-team-workflow/SKILL.md | 98 + .claude/workflow-version.json | 6 + .editorconfig | 33 + .githooks/commit-msg | 60 + .githooks/post-checkout | 25 + .githooks/pre-commit | 61 + .gitignore | 34 +- .node-version | 1 + .npmrc | 2 + .prettierrc | 11 + CLAUDE.md | 77 + apps/web/src/widgets/map3d/Map3D.tsx | 137 +- package-lock.json | 4710 ++++++++++++++++++++ package.json | 13 +- pnpm-lock.yaml | 3001 ------------- pnpm-workspace.yaml | 4 - 28 files changed, 6025 insertions(+), 3042 deletions(-) create mode 100644 .claude/rules/code-style.md create mode 100644 .claude/rules/git-workflow.md create mode 100644 .claude/rules/naming.md create mode 100644 .claude/rules/team-policy.md create mode 100644 .claude/rules/testing.md create mode 100755 .claude/scripts/on-commit.sh create mode 100755 .claude/scripts/on-post-compact.sh create mode 100755 .claude/scripts/on-pre-compact.sh create mode 100644 .claude/settings.json create mode 100644 .claude/skills/create-mr/SKILL.md create mode 100644 .claude/skills/fix-issue/SKILL.md create mode 100644 .claude/skills/init-project/SKILL.md create mode 100644 .claude/skills/sync-team-workflow/SKILL.md create mode 100644 .claude/workflow-version.json create mode 100644 .editorconfig create mode 100755 .githooks/commit-msg create mode 100755 .githooks/post-checkout create mode 100755 .githooks/pre-commit create mode 100644 .node-version create mode 100644 .npmrc create mode 100644 .prettierrc create mode 100644 CLAUDE.md create mode 100644 package-lock.json delete mode 100644 pnpm-lock.yaml delete mode 100644 pnpm-workspace.yaml diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md new file mode 100644 index 0000000..facaabf --- /dev/null +++ b/.claude/rules/code-style.md @@ -0,0 +1,69 @@ +# TypeScript/React 코드 스타일 규칙 + +## TypeScript 일반 +- strict 모드 필수 (`tsconfig.json`) +- `any` 사용 금지 (불가피한 경우 주석으로 사유 명시) +- 타입 정의: `interface` 우선 (type은 유니온/인터섹션에만) +- 들여쓰기: 2 spaces +- 세미콜론: 사용 +- 따옴표: single quote +- trailing comma: 사용 + +## React 규칙 + +### 컴포넌트 +- 함수형 컴포넌트 + hooks 패턴만 사용 +- 클래스 컴포넌트 사용 금지 +- 컴포넌트 파일 당 하나의 export default 컴포넌트 +- Props 타입은 interface로 정의 (ComponentNameProps) + +```tsx +interface UserCardProps { + name: string; + email: string; + onEdit?: () => void; +} + +const UserCard = ({ name, email, onEdit }: UserCardProps) => { + return ( +
+

{name}

+

{email}

+ {onEdit && } +
+ ); +}; + +export default UserCard; +``` + +### Hooks +- 커스텀 훅은 `use` 접두사 (예: `useAuth`, `useFetch`) +- 훅은 `src/hooks/` 디렉토리에 분리 +- 복잡한 상태 로직은 커스텀 훅으로 추출 + +### 상태 관리 +- 컴포넌트 로컬 상태: `useState` +- 공유 상태: Context API 또는 Zustand +- 서버 상태: React Query (TanStack Query) 권장 + +### 이벤트 핸들러 +- `handle` 접두사: `handleClick`, `handleSubmit` +- Props로 전달 시 `on` 접두사: `onClick`, `onSubmit` + +## 스타일링 +- CSS Modules 또는 Tailwind CSS (프로젝트 설정에 따름) +- 인라인 스타일 지양 +- !important 사용 금지 + +## API 호출 +- API 호출 로직은 `src/services/`에 분리 +- Axios 또는 fetch wrapper 사용 +- 에러 처리: try-catch + 사용자 친화적 에러 메시지 +- 환경별 API URL은 `.env`에서 관리 + +## 기타 +- console.log 커밋 금지 (디버깅 후 제거) +- 매직 넘버/문자열 → 상수 파일로 추출 +- 사용하지 않는 import, 변수 제거 (ESLint로 검증) +- 이미지/아이콘은 `src/assets/`에 관리 diff --git a/.claude/rules/git-workflow.md b/.claude/rules/git-workflow.md new file mode 100644 index 0000000..4fee618 --- /dev/null +++ b/.claude/rules/git-workflow.md @@ -0,0 +1,84 @@ +# Git 워크플로우 규칙 + +## 브랜치 전략 + +### 브랜치 구조 +``` +main ← 배포 가능한 안정 브랜치 (보호됨) + └── develop ← 개발 통합 브랜치 + ├── feature/ISSUE-123-기능설명 + ├── bugfix/ISSUE-456-버그설명 + └── hotfix/ISSUE-789-긴급수정 +``` + +### 브랜치 네이밍 +- feature 브랜치: `feature/ISSUE-번호-간단설명` (예: `feature/ISSUE-42-user-login`) +- bugfix 브랜치: `bugfix/ISSUE-번호-간단설명` +- hotfix 브랜치: `hotfix/ISSUE-번호-간단설명` +- 이슈 번호가 없는 경우: `feature/간단설명` (예: `feature/add-swagger-docs`) + +### 브랜치 규칙 +- main, develop 브랜치에 직접 커밋/푸시 금지 +- feature 브랜치는 develop에서 분기 +- hotfix 브랜치는 main에서 분기 +- 머지는 반드시 MR(Merge Request)을 통해 수행 + +## 커밋 메시지 규칙 + +### Conventional Commits 형식 +``` +type(scope): subject + +body (선택) + +footer (선택) +``` + +### type (필수) +| type | 설명 | +|------|------| +| feat | 새로운 기능 추가 | +| fix | 버그 수정 | +| docs | 문서 변경 | +| style | 코드 포맷팅 (기능 변경 없음) | +| refactor | 리팩토링 (기능 변경 없음) | +| test | 테스트 추가/수정 | +| chore | 빌드, 설정 변경 | +| ci | CI/CD 설정 변경 | +| perf | 성능 개선 | + +### scope (선택) +- 변경 범위를 나타내는 짧은 단어 +- 한국어, 영어 모두 허용 (예: `feat(인증): 로그인 기능`, `fix(auth): token refresh`) + +### subject (필수) +- 변경 내용을 간결하게 설명 +- 한국어, 영어 모두 허용 +- 72자 이내 +- 마침표(.) 없이 끝냄 + +### 예시 +``` +feat(auth): JWT 기반 로그인 구현 +fix(배치): 야간 배치 타임아웃 수정 +docs: README에 빌드 방법 추가 +refactor(user-service): 중복 로직 추출 +test(결제): 환불 로직 단위 테스트 추가 +chore: Gradle 의존성 버전 업데이트 +``` + +## MR(Merge Request) 규칙 + +### MR 생성 +- 제목: 커밋 메시지와 동일한 Conventional Commits 형식 +- 본문: 변경 내용 요약, 테스트 방법, 관련 이슈 번호 +- 라벨: 적절한 라벨 부착 (feature, bugfix, hotfix 등) + +### MR 리뷰 +- 최소 1명의 리뷰어 승인 필수 +- CI 검증 통과 필수 (설정된 경우) +- 리뷰 코멘트 모두 해결 후 머지 + +### MR 머지 +- Squash Merge 권장 (깔끔한 히스토리) +- 머지 후 소스 브랜치 삭제 diff --git a/.claude/rules/naming.md b/.claude/rules/naming.md new file mode 100644 index 0000000..4b3c0b1 --- /dev/null +++ b/.claude/rules/naming.md @@ -0,0 +1,53 @@ +# TypeScript/React 네이밍 규칙 + +## 파일명 + +| 항목 | 규칙 | 예시 | +|------|------|------| +| 컴포넌트 | PascalCase | `UserCard.tsx`, `LoginForm.tsx` | +| 페이지 | PascalCase | `Dashboard.tsx`, `UserList.tsx` | +| 훅 | camelCase + use 접두사 | `useAuth.ts`, `useFetch.ts` | +| 서비스 | camelCase | `userService.ts`, `authApi.ts` | +| 유틸리티 | camelCase | `formatDate.ts`, `validation.ts` | +| 타입 정의 | camelCase | `user.types.ts`, `api.types.ts` | +| 상수 | camelCase | `routes.ts`, `constants.ts` | +| 스타일 | 컴포넌트명 + .module | `UserCard.module.css` | +| 테스트 | 대상 + .test | `UserCard.test.tsx` | + +## 변수/함수 + +| 항목 | 규칙 | 예시 | +|------|------|------| +| 변수 | camelCase | `userName`, `isLoading` | +| 함수 | camelCase | `getUserList`, `formatDate` | +| 상수 | UPPER_SNAKE_CASE | `MAX_RETRY`, `API_BASE_URL` | +| boolean 변수 | is/has/can/should 접두사 | `isActive`, `hasPermission` | +| 이벤트 핸들러 | handle 접두사 | `handleClick`, `handleSubmit` | +| 이벤트 Props | on 접두사 | `onClick`, `onSubmit` | + +## 타입/인터페이스 + +| 항목 | 규칙 | 예시 | +|------|------|------| +| interface | PascalCase | `UserProfile`, `ApiResponse` | +| Props | 컴포넌트명 + Props | `UserCardProps`, `ButtonProps` | +| 응답 타입 | 도메인 + Response | `UserResponse`, `LoginResponse` | +| 요청 타입 | 동작 + Request | `CreateUserRequest` | +| Enum | PascalCase | `UserStatus`, `HttpMethod` | +| Enum 값 | UPPER_SNAKE_CASE | `ACTIVE`, `PENDING` | +| Generic | 단일 대문자 | `T`, `K`, `V` | + +## 디렉토리 + +- 모두 kebab-case 또는 camelCase (프로젝트 통일) +- 예: `src/components/common/`, `src/hooks/`, `src/services/` + +## 컴포넌트 구조 예시 + +``` +src/components/user-card/ +├── UserCard.tsx # 컴포넌트 +├── UserCard.module.css # 스타일 +├── UserCard.test.tsx # 테스트 +└── index.ts # re-export +``` diff --git a/.claude/rules/team-policy.md b/.claude/rules/team-policy.md new file mode 100644 index 0000000..16d7553 --- /dev/null +++ b/.claude/rules/team-policy.md @@ -0,0 +1,34 @@ +# 팀 정책 (Team Policy) + +이 규칙은 조직 전체에 적용되는 필수 정책입니다. +프로젝트별 `.claude/rules/`에 추가 규칙을 정의할 수 있으나, 이 정책을 위반할 수 없습니다. + +## 보안 정책 + +### 금지 행위 +- `.env`, `.env.*`, `secrets/` 파일 읽기 및 내용 출력 금지 +- 비밀번호, API 키, 토큰 등 민감 정보를 코드에 하드코딩 금지 +- `git push --force`, `git reset --hard`, `git clean -fd` 실행 금지 +- `rm -rf /`, `rm -rf ~`, `rm -rf .git` 등 파괴적 명령 실행 금지 +- main/develop 브랜치에 직접 push 금지 (MR을 통해서만 머지) + +### 인증 정보 관리 +- 환경변수 또는 외부 설정 파일(`.env`, `application-local.yml`)로 관리 +- 설정 파일은 `.gitignore`에 반드시 포함 +- 예시 파일(`.env.example`, `application.yml.example`)만 커밋 + +## 코드 품질 정책 + +### 필수 검증 +- 커밋 전 빌드(컴파일) 성공 확인 +- 린트 경고 0개 유지 (CI에서도 검증) +- 테스트 코드가 있는 프로젝트는 테스트 통과 필수 + +### 코드 리뷰 +- main 브랜치 머지 시 최소 1명 리뷰 필수 +- 리뷰어 승인 없이 머지 불가 + +## 문서화 정책 +- 공개 API(controller endpoint)에는 반드시 설명 주석 작성 +- 복잡한 비즈니스 로직에는 의도를 설명하는 주석 작성 +- README.md에 프로젝트 빌드/실행 방법 유지 diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 0000000..5b41b54 --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,64 @@ +# TypeScript/React 테스트 규칙 + +## 테스트 프레임워크 +- Vitest (Vite 프로젝트) 또는 Jest +- React Testing Library (컴포넌트 테스트) +- MSW (Mock Service Worker, API 모킹) + +## 테스트 구조 + +### 단위 테스트 +- 유틸리티 함수, 커스텀 훅 테스트 +- 외부 의존성 없이 순수 로직 검증 + +```typescript +describe('formatDate', () => { + it('날짜를 YYYY-MM-DD 형식으로 변환한다', () => { + const result = formatDate(new Date('2026-02-14')); + expect(result).toBe('2026-02-14'); + }); + + it('유효하지 않은 날짜는 빈 문자열을 반환한다', () => { + const result = formatDate(new Date('invalid')); + expect(result).toBe(''); + }); +}); +``` + +### 컴포넌트 테스트 +- React Testing Library 사용 +- 사용자 관점에서 테스트 (구현 세부사항이 아닌 동작 테스트) +- `getByRole`, `getByText` 등 접근성 기반 쿼리 우선 + +```tsx +describe('UserCard', () => { + it('사용자 이름과 이메일을 표시한다', () => { + render(); + expect(screen.getByText('홍길동')).toBeInTheDocument(); + expect(screen.getByText('hong@test.com')).toBeInTheDocument(); + }); + + it('편집 버튼 클릭 시 onEdit 콜백을 호출한다', async () => { + const onEdit = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: '편집' })); + expect(onEdit).toHaveBeenCalledOnce(); + }); +}); +``` + +### 테스트 패턴 +- **Arrange-Act-Assert** 구조 +- 테스트 설명은 한국어로 작성 (`it('사용자 이름을 표시한다')`) +- 하나의 테스트에 하나의 검증 + +## 테스트 커버리지 +- 새로 작성하는 유틸리티 함수: 테스트 필수 +- 컴포넌트: 주요 상호작용 테스트 권장 +- API 호출: MSW로 모킹하여 에러/성공 시나리오 테스트 + +## 금지 사항 +- 구현 세부사항 테스트 금지 (state 값 직접 확인 등) +- `getByTestId` 남용 금지 (접근성 쿼리 우선) +- 스냅샷 테스트 남용 금지 (변경에 취약) +- `setTimeout`으로 비동기 대기 금지 → `waitFor`, `findBy` 사용 diff --git a/.claude/scripts/on-commit.sh b/.claude/scripts/on-commit.sh new file mode 100755 index 0000000..f473403 --- /dev/null +++ b/.claude/scripts/on-commit.sh @@ -0,0 +1,14 @@ +#!/bin/bash +INPUT=$(cat) +COMMAND=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null || echo "") +if echo "$COMMAND" | grep -qE 'git commit'; then + cat </dev/null || echo "") +if [ -z "$CWD" ]; then + CWD=$(pwd) +fi +PROJECT_HASH=$(echo "$CWD" | sed 's|/|-|g') +MEMORY_DIR="$HOME/.claude/projects/$PROJECT_HASH/memory" +CONTEXT="" +if [ -f "$MEMORY_DIR/MEMORY.md" ]; then + SUMMARY=$(head -100 "$MEMORY_DIR/MEMORY.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null) + CONTEXT="컨텍스트가 압축되었습니다.\\n\\n[세션 요약]\\n${SUMMARY}" +fi +if [ -f "$MEMORY_DIR/project-snapshot.md" ]; then + SNAP=$(head -50 "$MEMORY_DIR/project-snapshot.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null) + CONTEXT="${CONTEXT}\\n\\n[프로젝트 최신 상태]\\n${SNAP}" +fi +if [ -n "$CONTEXT" ]; then + CONTEXT="${CONTEXT}\\n\\n위 내용을 참고하여 작업을 이어가세요. 상세 내용은 memory/ 디렉토리의 각 파일을 참조하세요." + echo "{\"hookSpecificOutput\":{\"additionalContext\":\"${CONTEXT}\"}}" +else + echo "{\"hookSpecificOutput\":{\"additionalContext\":\"컨텍스트가 압축되었습니다. memory 파일이 없으므로 사용자에게 이전 작업 내용을 확인하세요.\"}}" +fi diff --git a/.claude/scripts/on-pre-compact.sh b/.claude/scripts/on-pre-compact.sh new file mode 100755 index 0000000..3f52f09 --- /dev/null +++ b/.claude/scripts/on-pre-compact.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# PreCompact hook: systemMessage만 지원 (hookSpecificOutput 사용 불가) +INPUT=$(cat) +cat <" +--- + +Gitea 이슈 #$ARGUMENTS 를 분석하고 수정 작업을 시작합니다. + +## 수행 단계 + +### 1. 이슈 조회 +```bash +curl -s "GITEA_URL/api/v1/repos/{owner}/{repo}/issues/$ARGUMENTS" \ + -H "Authorization: token ${GITEA_TOKEN}" +``` +- 이슈 제목, 본문, 라벨, 담당자 정보 확인 +- 이슈 내용을 사용자에게 요약하여 보여줌 + +### 2. 브랜치 생성 +이슈 라벨에 따라 브랜치 타입 결정: +- `bug` 라벨 → `bugfix/ISSUE-번호-설명` +- 그 외 → `feature/ISSUE-번호-설명` +- 긴급 → `hotfix/ISSUE-번호-설명` + +```bash +git checkout develop +git pull origin develop +git checkout -b {type}/ISSUE-{number}-{slug} +``` + +### 3. 이슈 분석 +이슈 내용을 바탕으로: +- 관련 파일 탐색 (Grep, Glob 활용) +- 영향 범위 파악 +- 수정 방향 제안 + +### 4. 수정 계획 제시 +사용자에게 수정 계획을 보여주고 승인을 받은 후 작업 진행: +- 수정할 파일 목록 +- 변경 내용 요약 +- 예상 영향 + +### 5. 작업 완료 후 +- 변경 사항 요약 +- `/create-mr` 실행 안내 + +## 필요 환경변수 +- `GITEA_TOKEN`: Gitea API 접근 토큰 diff --git a/.claude/skills/init-project/SKILL.md b/.claude/skills/init-project/SKILL.md new file mode 100644 index 0000000..3f5d388 --- /dev/null +++ b/.claude/skills/init-project/SKILL.md @@ -0,0 +1,246 @@ +--- +name: init-project +description: 팀 표준 워크플로우로 프로젝트를 초기화합니다 +allowed-tools: "Bash, Read, Write, Edit, Glob, Grep" +argument-hint: "[project-type: java-maven|java-gradle|react-ts|auto]" +--- + +팀 표준 워크플로우에 따라 프로젝트를 초기화합니다. +프로젝트 타입: $ARGUMENTS (기본: auto — 자동 감지) + +## 프로젝트 타입 자동 감지 + +$ARGUMENTS가 "auto"이거나 비어있으면 다음 순서로 감지: +1. `pom.xml` 존재 → **java-maven** +2. `build.gradle` 또는 `build.gradle.kts` 존재 → **java-gradle** +3. `package.json` + `tsconfig.json` 존재 → **react-ts** +4. 감지 실패 → 사용자에게 타입 선택 요청 + +## 수행 단계 + +### 1. 프로젝트 분석 +- 빌드 파일, 설정 파일, 디렉토리 구조 파악 +- 사용 중인 프레임워크, 라이브러리 감지 +- 기존 `.claude/` 디렉토리 존재 여부 확인 +- eslint, prettier, checkstyle, spotless 등 lint 도구 설치 여부 확인 + +### 2. CLAUDE.md 생성 +프로젝트 루트에 CLAUDE.md를 생성하고 다음 내용 포함: +- 프로젝트 개요 (이름, 타입, 주요 기술 스택) +- 빌드/실행 명령어 (감지된 빌드 도구 기반) +- 테스트 실행 명령어 +- lint 실행 명령어 (감지된 도구 기반) +- 프로젝트 디렉토리 구조 요약 +- 팀 컨벤션 참조 (`.claude/rules/` 안내) + +### Gitea 파일 다운로드 URL 패턴 +⚠️ Gitea raw 파일은 반드시 **web raw URL**을 사용해야 합니다 (`/api/v1/` 경로 사용 불가): +```bash +GITEA_URL="${GITEA_URL:-https://gitea.gc-si.dev}" +# common 파일: ${GITEA_URL}/gc/template-common/raw/branch/develop/<파일경로> +# 타입별 파일: ${GITEA_URL}/gc/template-<타입>/raw/branch/develop/<파일경로> +# 예시: +curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md" +curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig" +``` + +### 3. .claude/ 디렉토리 구성 +이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우 위의 URL 패턴으로 Gitea에서 다운로드: +- `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정 + hooks 섹션 (4단계 참조) +- `.claude/rules/` — 팀 규칙 파일 (team-policy, git-workflow, code-style, naming, testing) +- `.claude/skills/` — 팀 스킬 (create-mr, fix-issue, sync-team-workflow, init-project) + +### 4. Hook 스크립트 생성 +`.claude/scripts/` 디렉토리를 생성하고 다음 스크립트 파일 생성 (chmod +x): + +- `.claude/scripts/on-pre-compact.sh`: + +```bash +#!/bin/bash +# PreCompact hook: systemMessage만 지원 (hookSpecificOutput 사용 불가) +INPUT=$(cat) +cat </dev/null || echo "") +if [ -z "$CWD" ]; then + CWD=$(pwd) +fi +PROJECT_HASH=$(echo "$CWD" | sed 's|/|-|g') +MEMORY_DIR="$HOME/.claude/projects/$PROJECT_HASH/memory" +CONTEXT="" +if [ -f "$MEMORY_DIR/MEMORY.md" ]; then + SUMMARY=$(head -100 "$MEMORY_DIR/MEMORY.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null) + CONTEXT="컨텍스트가 압축되었습니다.\\n\\n[세션 요약]\\n${SUMMARY}" +fi +if [ -f "$MEMORY_DIR/project-snapshot.md" ]; then + SNAP=$(head -50 "$MEMORY_DIR/project-snapshot.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null) + CONTEXT="${CONTEXT}\\n\\n[프로젝트 최신 상태]\\n${SNAP}" +fi +if [ -n "$CONTEXT" ]; then + CONTEXT="${CONTEXT}\\n\\n위 내용을 참고하여 작업을 이어가세요. 상세 내용은 memory/ 디렉토리의 각 파일을 참조하세요." + echo "{\"hookSpecificOutput\":{\"additionalContext\":\"${CONTEXT}\"}}" +else + echo "{\"hookSpecificOutput\":{\"additionalContext\":\"컨텍스트가 압축되었습니다. memory 파일이 없으므로 사용자에게 이전 작업 내용을 확인하세요.\"}}" +fi +``` + +- `.claude/scripts/on-commit.sh`: + +```bash +#!/bin/bash +INPUT=$(cat) +COMMAND=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null || echo "") +if echo "$COMMAND" | grep -qE 'git commit'; then + cat </memory/`) 다음 파일들을 생성: + +- `memory/MEMORY.md` — 프로젝트 분석 결과 기반 핵심 요약 (200줄 이내) + - 현재 상태, 프로젝트 개요, 기술 스택, 주요 패키지 구조, 상세 참조 링크 +- `memory/project-snapshot.md` — 디렉토리 구조, 패키지 구성, 주요 의존성, API 엔드포인트 +- `memory/project-history.md` — "초기 팀 워크플로우 구성" 항목으로 시작 +- `memory/api-types.md` — 주요 인터페이스/DTO/Entity 타입 요약 +- `memory/decisions.md` — 빈 템플릿 (# 의사결정 기록) +- `memory/debugging.md` — 빈 템플릿 (# 디버깅 경험 & 패턴) + +### 10. Lint 도구 확인 +- TypeScript: eslint, prettier 설치 여부 확인. 미설치 시 사용자에게 설치 제안 +- Java: checkstyle, spotless 등 설정 확인 +- CLAUDE.md에 lint 실행 명령어가 이미 기록되었는지 확인 + +### 11. workflow-version.json 생성 +Gitea API로 최신 팀 워크플로우 버전을 조회: +```bash +curl -sf --max-time 5 "https://gitea.gc-si.dev/gc/template-common/raw/branch/develop/workflow-version.json" +``` +조회 성공 시 해당 `version` 값 사용, 실패 시 "1.0.0" 기본값 사용. + +`.claude/workflow-version.json` 파일 생성: +```json +{ + "applied_global_version": "<조회된 버전>", + "applied_date": "<현재날짜>", + "project_type": "<감지된타입>", + "gitea_url": "https://gitea.gc-si.dev" +} +``` + +### 12. 검증 및 요약 +- 생성/수정된 파일 목록 출력 +- `git config core.hooksPath` 확인 +- 빌드 명령 실행 가능 확인 +- Hook 스크립트 실행 권한 확인 +- 다음 단계 안내: + - 개발 시작, 첫 커밋 방법 + - 범용 스킬: `/api-registry`, `/changelog`, `/swagger-spec` diff --git a/.claude/skills/sync-team-workflow/SKILL.md b/.claude/skills/sync-team-workflow/SKILL.md new file mode 100644 index 0000000..930d04d --- /dev/null +++ b/.claude/skills/sync-team-workflow/SKILL.md @@ -0,0 +1,98 @@ +--- +name: sync-team-workflow +description: 팀 글로벌 워크플로우를 현재 프로젝트에 동기화합니다 +allowed-tools: "Bash, Read, Write, Edit, Glob, Grep" +--- + +팀 글로벌 워크플로우의 최신 버전을 현재 프로젝트에 적용합니다. + +## 수행 절차 + +### 1. 글로벌 버전 조회 +Gitea API로 template-common 리포의 workflow-version.json 조회: +```bash +GITEA_URL=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('gitea_url', 'https://gitea.gc-si.dev'))" 2>/dev/null || echo "https://gitea.gc-si.dev") + +curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/workflow-version.json" +``` + +### 2. 버전 비교 +로컬 `.claude/workflow-version.json`의 `applied_global_version` 필드와 비교: +- 버전 일치 → "최신 버전입니다" 안내 후 종료 +- 버전 불일치 → 미적용 변경 항목 추출하여 표시 + +### 3. 프로젝트 타입 감지 +자동 감지 순서: +1. `.claude/workflow-version.json`의 `project_type` 필드 확인 +2. 없으면: `pom.xml` → java-maven, `build.gradle` → java-gradle, `package.json` → react-ts + +### Gitea 파일 다운로드 URL 패턴 +⚠️ Gitea raw 파일은 반드시 **web raw URL**을 사용해야 합니다 (`/api/v1/` 경로 사용 불가): +```bash +GITEA_URL="${GITEA_URL:-https://gitea.gc-si.dev}" +# common 파일: ${GITEA_URL}/gc/template-common/raw/branch/develop/<파일경로> +# 타입별 파일: ${GITEA_URL}/gc/template-<타입>/raw/branch/develop/<파일경로> +# 예시: +curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md" +curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig" +``` + +### 4. 파일 다운로드 및 적용 +위의 URL 패턴으로 해당 타입 + common 템플릿 파일 다운로드: + +#### 4-1. 규칙 파일 (덮어쓰기) +팀 규칙은 로컬 수정 불가 — 항상 글로벌 최신으로 교체: +``` +.claude/rules/team-policy.md +.claude/rules/git-workflow.md +.claude/rules/code-style.md (타입별) +.claude/rules/naming.md (타입별) +.claude/rules/testing.md (타입별) +``` + +#### 4-2. settings.json (부분 갱신) +- `deny` 목록: 글로벌 최신으로 교체 +- `allow` 목록: 기존 사용자 커스텀 유지 + 글로벌 기본값 병합 +- `hooks`: init-project SKILL.md의 hooks JSON 블록을 참조하여 교체 (없으면 추가) + - SessionStart(compact) → on-post-compact.sh + - PreCompact → on-pre-compact.sh + - PostToolUse(Bash) → on-commit.sh + +#### 4-3. 스킬 파일 (덮어쓰기) +``` +.claude/skills/create-mr/SKILL.md +.claude/skills/fix-issue/SKILL.md +.claude/skills/sync-team-workflow/SKILL.md +.claude/skills/init-project/SKILL.md +``` + +#### 4-4. Git Hooks (덮어쓰기 + 실행 권한) +```bash +chmod +x .githooks/* +``` + +#### 4-5. Hook 스크립트 갱신 +init-project SKILL.md의 코드 블록에서 최신 스크립트를 추출하여 덮어쓰기: +``` +.claude/scripts/on-pre-compact.sh +.claude/scripts/on-post-compact.sh +.claude/scripts/on-commit.sh +``` +실행 권한 부여: `chmod +x .claude/scripts/*.sh` + +### 5. 로컬 버전 업데이트 +`.claude/workflow-version.json` 갱신: +```json +{ + "applied_global_version": "새버전", + "applied_date": "오늘날짜", + "project_type": "감지된타입", + "gitea_url": "https://gitea.gc-si.dev" +} +``` + +### 6. 변경 보고 +- `git diff`로 변경 내역 확인 +- 업데이트된 파일 목록 출력 +- 변경 로그(글로벌 workflow-version.json의 changes) 표시 +- 필요한 추가 조치 안내 (빌드 확인, 의존성 업데이트 등) diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json new file mode 100644 index 0000000..4fb0c4e --- /dev/null +++ b/.claude/workflow-version.json @@ -0,0 +1,6 @@ +{ + "applied_global_version": "1.2.0", + "applied_date": "2026-02-15", + "project_type": "react-ts", + "gitea_url": "https://gitea.gc-si.dev" +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6f831b5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,33 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{java,kt}] +indent_style = space +indent_size = 4 + +[*.{js,jsx,ts,tsx,json,yml,yaml,css,scss,html}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.{sh,bash}] +indent_style = space +indent_size = 4 + +[Makefile] +indent_style = tab + +[*.{gradle,groovy}] +indent_style = space +indent_size = 4 + +[*.xml] +indent_style = space +indent_size = 4 diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..93bb350 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,60 @@ +#!/bin/bash +#============================================================================== +# commit-msg hook +# Conventional Commits 형식 검증 (한/영 혼용 지원) +#============================================================================== + +COMMIT_MSG_FILE="$1" +COMMIT_MSG=$(cat "$COMMIT_MSG_FILE") + +# Merge 커밋은 검증 건너뜀 +if echo "$COMMIT_MSG" | head -1 | grep -qE "^Merge "; then + exit 0 +fi + +# Revert 커밋은 검증 건너뜀 +if echo "$COMMIT_MSG" | head -1 | grep -qE "^Revert "; then + exit 0 +fi + +# Conventional Commits 정규식 +# type(scope): subject +# - type: feat|fix|docs|style|refactor|test|chore|ci|perf (필수) +# - scope: 영문, 숫자, 한글, 점, 밑줄, 하이픈 허용 (선택) +# - subject: 1~72자, 한/영 혼용 허용 (필수) +PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([a-zA-Z0-9가-힣._-]+\))?: .{1,72}$' + +FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE") + +if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then + echo "" + echo "╔══════════════════════════════════════════════════════════════╗" + echo "║ 커밋 메시지가 Conventional Commits 형식에 맞지 않습니다 ║" + echo "╚══════════════════════════════════════════════════════════════╝" + echo "" + echo " 올바른 형식: type(scope): subject" + echo "" + echo " type (필수):" + echo " feat — 새로운 기능" + echo " fix — 버그 수정" + echo " docs — 문서 변경" + echo " style — 코드 포맷팅" + echo " refactor — 리팩토링" + echo " test — 테스트" + echo " chore — 빌드/설정 변경" + echo " ci — CI/CD 변경" + echo " perf — 성능 개선" + echo "" + echo " scope (선택): 한/영 모두 가능" + echo " subject (필수): 1~72자, 한/영 모두 가능" + echo "" + echo " 예시:" + echo " feat(auth): JWT 기반 로그인 구현" + echo " fix(배치): 야간 배치 타임아웃 수정" + echo " docs: README 업데이트" + echo " chore: Gradle 의존성 업데이트" + echo "" + echo " 현재 메시지: $FIRST_LINE" + echo "" + exit 1 +fi diff --git a/.githooks/post-checkout b/.githooks/post-checkout new file mode 100755 index 0000000..bae360f --- /dev/null +++ b/.githooks/post-checkout @@ -0,0 +1,25 @@ +#!/bin/bash +#============================================================================== +# post-checkout hook +# 브랜치 체크아웃 시 core.hooksPath 자동 설정 +# clone/checkout 후 .githooks 디렉토리가 있으면 자동으로 hooksPath 설정 +#============================================================================== + +# post-checkout 파라미터: prev_HEAD, new_HEAD, branch_flag +# branch_flag=1: 브랜치 체크아웃, 0: 파일 체크아웃 +BRANCH_FLAG="$3" + +# 파일 체크아웃은 건너뜀 +if [ "$BRANCH_FLAG" = "0" ]; then + exit 0 +fi + +# .githooks 디렉토리 존재 확인 +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +if [ -d "${REPO_ROOT}/.githooks" ]; then + CURRENT_HOOKS_PATH=$(git config core.hooksPath 2>/dev/null || echo "") + if [ "$CURRENT_HOOKS_PATH" != ".githooks" ]; then + git config core.hooksPath .githooks + chmod +x "${REPO_ROOT}/.githooks/"* 2>/dev/null + fi +fi diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..0705e10 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,61 @@ +#!/bin/bash +#============================================================================== +# pre-commit hook (모노레포 React TypeScript) +# apps/web TypeScript 컴파일 + 린트 검증 — 실패 시 커밋 차단 +#============================================================================== + +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) + +echo "pre-commit: TypeScript 타입 체크 중..." + +# npm 확인 +if ! command -v npx &>/dev/null; then + echo "경고: npx가 설치되지 않았습니다. 검증을 건너뜁니다." + exit 0 +fi + +# node_modules 확인 +if [ ! -d "${REPO_ROOT}/node_modules" ]; then + echo "경고: node_modules가 없습니다. 'npm install' 실행 후 다시 시도하세요." + exit 1 +fi + +# apps/web TypeScript 타입 체크 +if [ -d "${REPO_ROOT}/apps/web" ]; then + cd "${REPO_ROOT}/apps/web" + npx tsc --noEmit --pretty 2>&1 + TSC_RESULT=$? + + if [ $TSC_RESULT -ne 0 ]; then + echo "" + echo "╔══════════════════════════════════════════════════════════╗" + echo "║ TypeScript 타입 에러! 커밋이 차단되었습니다. ║" + echo "║ 타입 에러를 수정한 후 다시 커밋해주세요. ║" + echo "╚══════════════════════════════════════════════════════════╝" + echo "" + exit 1 + fi + + echo "pre-commit: apps/web 타입 체크 성공" + + # ESLint 검증 (설정 파일이 있는 경우만) + if [ -f "eslint.config.js" ] || [ -f "eslint.config.mjs" ] || [ -f ".eslintrc.js" ] || [ -f ".eslintrc.json" ] || [ -f ".eslintrc.cjs" ]; then + echo "pre-commit: ESLint 검증 중..." + npx eslint src/ --quiet 2>&1 + LINT_RESULT=$? + + if [ $LINT_RESULT -ne 0 ]; then + echo "" + echo "╔══════════════════════════════════════════════════════════╗" + echo "║ ESLint 에러! 커밋이 차단되었습니다. ║" + echo "║ 'npm run lint -- --fix'로 자동 수정을 시도해보세요. ║" + echo "╚══════════════════════════════════════════════════════════╝" + echo "" + exit 1 + fi + + echo "pre-commit: ESLint 통과" + fi +fi + +cd "${REPO_ROOT}" diff --git a/.gitignore b/.gitignore index 892f20b..02e1170 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,41 @@ -node_modules +# === Dependencies === +node_modules/ **/node_modules -dist +# === Build === +dist/ **/dist +build/ -.idea -.vscode +# === IDE === +.idea/ +.vscode/ +*.swp +*.swo +# === OS === .DS_Store +Thumbs.db +# === Environment === .env .env.* +!.env.example +secrets/ +# === Test === +coverage/ + +# === Cache === +.eslintcache +.prettiercache +*.tsbuildinfo + +# === Logs === *.log -*.tsbuildinfo +# === Claude Code === +# 글로벌 gitignore에서 .claude/ 전체를 무시하므로 팀 파일을 명시적으로 포함 +!.claude/ +.claude/settings.local.json +.claude/CLAUDE.local.md diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..22da051 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +registry=https://nexus.gc-si.dev/repository/npm-public/ +always-auth=true diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..73576ff --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..164c874 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,77 @@ +# Wing Fleet Dashboard (gc-wing) + +## 프로젝트 개요 + +- **타입**: React + TypeScript + Vite (모노레포) +- **Node.js**: `.node-version` 참조 (v24) +- **패키지 매니저**: npm (workspaces) +- **구조**: apps/web (프론트엔드) + apps/api (백엔드 API) + +## 빌드 및 실행 + +```bash +# 의존성 설치 +npm install + +# 전체 개발 서버 +npm run dev + +# 개별 개발 서버 +npm run dev:web # 프론트엔드 (Vite) +npm run dev:api # 백엔드 (Fastify + tsx watch) + +# 빌드 +npm run build # 전체 빌드 (web + api) +npm run build:web # 프론트엔드만 +npm run build:api # 백엔드만 + +# 린트 +npm run lint # apps/web ESLint + +# 데이터 준비 +npm run prepare:data +``` + +## 프로젝트 구조 + +``` +gc-wing-dev/ +├── apps/ +│ ├── web/ # React 19 + Vite 7 + MapLibre + Deck.gl +│ │ └── src/ +│ │ ├── app/ # App.tsx, styles +│ │ ├── entities/ # 도메인 모델 (vessel, zone, aisTarget, legacyVessel) +│ │ ├── features/ # 기능 단위 (mapToggles, typeFilter, aisPolling 등) +│ │ ├── pages/ # 페이지 (DashboardPage) +│ │ ├── shared/ # 공통 유틸 (lib/geo, lib/color, lib/map) +│ │ └── widgets/ # UI 위젯 (map3d, vesselList, info, alarms 등) +│ └── api/ # Fastify 5 + TypeScript +│ └── src/ +│ └── index.ts +├── data/ # 정적 데이터 +├── scripts/ # 빌드 스크립트 (prepare-zones, prepare-legacy) +└── legacy/ # 레거시 데이터 +``` + +## 기술 스택 + +| 영역 | 기술 | +|------|------| +| 프론트엔드 | React 19, Vite 7, TypeScript 5.9 | +| 지도 | MapLibre GL JS 5, Deck.gl 9 | +| 백엔드 | Fastify 5, TypeScript | +| 린트 | ESLint 9, Prettier | + +## 팀 규칙 + +- 코드 스타일: `.claude/rules/code-style.md` +- 네이밍 규칙: `.claude/rules/naming.md` +- 테스트 규칙: `.claude/rules/testing.md` +- Git 워크플로우: `.claude/rules/git-workflow.md` +- 팀 정책: `.claude/rules/team-policy.md` + +## 의존성 관리 + +- Nexus 프록시 레포지토리를 통해 npm 패키지 관리 (`.npmrc`) +- 새 의존성 추가: `npm -w @wing/web install 패키지명` +- devDependency: `npm -w @wing/web install -D 패키지명` diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index cd4c710..6ca1b8d 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -201,10 +201,27 @@ const SHIP_ICON_MAPPING = { }, } as const; +const ANCHOR_SPEED_THRESHOLD_KN = 1; +const ANCHORED_SHIP_ICON_ID = "ship-globe-anchored-icon"; + function isFiniteNumber(x: unknown): x is number { return typeof x === "number" && Number.isFinite(x); } +function isAnchoredShip({ + sog, + cog, + heading, +}: { + sog: number | null | undefined; + cog: number | null | undefined; + heading: number | null | undefined; +}): boolean { + if (!isFiniteNumber(sog)) return true; + if (sog <= ANCHOR_SPEED_THRESHOLD_KN) return true; + return toValidBearingDeg(cog) == null && toValidBearingDeg(heading) == null; +} + function kickRepaint(map: maplibregl.Map | null) { if (!map) return; try { @@ -802,9 +819,47 @@ function buildFallbackGlobeShipIcon() { return ctx.getImageData(0, 0, size, size); } -function ensureFallbackShipImage(map: maplibregl.Map, imageId: string) { +function buildFallbackGlobeAnchoredShipIcon() { + const baseImage = buildFallbackGlobeShipIcon(); + if (!baseImage) return null; + + const size = baseImage.width; + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + + ctx.putImageData(baseImage, 0, 0); + + // Add a small anchor glyph below the ship body for anchored-state distinction. + ctx.strokeStyle = "rgba(248,250,252,1)"; + ctx.lineWidth = 5; + ctx.lineCap = "round"; + ctx.beginPath(); + const cx = size / 2; + ctx.moveTo(cx - 18, 76); + ctx.lineTo(cx + 18, 76); + ctx.moveTo(cx, 66); + ctx.lineTo(cx, 82); + ctx.moveTo(cx, 82); + ctx.arc(cx, 82, 7, 0, Math.PI * 2); + ctx.moveTo(cx, 82); + ctx.lineTo(cx, 88); + ctx.moveTo(cx - 9, 88); + ctx.lineTo(cx + 9, 88); + ctx.stroke(); + + return ctx.getImageData(0, 0, size, size); +} + +function ensureFallbackShipImage( + map: maplibregl.Map, + imageId: string, + fallbackBuilder: () => ImageData | null = buildFallbackGlobeShipIcon, +) { if (!map || map.hasImage(imageId)) return; - const image = buildFallbackGlobeShipIcon(); + const image = fallbackBuilder(); if (!image) return; try { @@ -2788,12 +2843,13 @@ export function Map3D({ const map = mapRef.current; if (!map) return; - const imgId = "ship-globe-icon"; - const srcId = "ships-globe-src"; - const haloId = "ships-globe-halo"; - const outlineId = "ships-globe-outline"; - const symbolId = "ships-globe"; - const labelId = "ships-globe-label"; + const imgId = "ship-globe-icon"; + const anchoredImgId = ANCHORED_SHIP_ICON_ID; + const srcId = "ships-globe-src"; + const haloId = "ships-globe-halo"; + const outlineId = "ships-globe-outline"; + const symbolId = "ships-globe"; + const labelId = "ships-globe-label"; const remove = () => { for (const id of [labelId, symbolId, outlineId, haloId]) { @@ -2815,11 +2871,13 @@ export function Map3D({ const ensureImage = () => { ensureFallbackShipImage(map, imgId); + ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); if (globeShipIconLoadingRef.current) return; - if (map.hasImage(imgId)) return; + if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return; const addFallbackImage = () => { ensureFallbackShipImage(map, imgId); + ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); kickRepaint(map); }; @@ -2852,7 +2910,15 @@ export function Map3D({ // ignore } } + if (map.hasImage(anchoredImgId)) { + try { + map.removeImage(anchoredImgId); + } catch { + // ignore + } + } map.addImage(imgId, loadedImage, { pixelRatio: 2, sdf: true }); + map.addImage(anchoredImgId, loadedImage, { pixelRatio: 2, sdf: true }); kickRepaint(map); } catch (e) { console.warn("Ship icon image add failed:", e); @@ -2910,11 +2976,17 @@ export function Map3D({ legacy?.shipNameRoman || t.name || ""; - const heading = getDisplayHeading({ - cog: t.cog, - heading: t.heading, - offset: GLOBE_ICON_HEADING_OFFSET_DEG, - }); + const heading = getDisplayHeading({ + cog: t.cog, + heading: t.heading, + offset: GLOBE_ICON_HEADING_OFFSET_DEG, + }); + const isAnchored = isAnchoredShip({ + sog: t.sog, + cog: t.cog, + heading: t.heading, + }); + const shipHeading = isAnchored ? 0 : heading; const hull = clampNumber((isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, 50, 420); const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); const selected = t.mmsi === selectedMmsi; @@ -2930,17 +3002,18 @@ export function Map3D({ type: "Feature", ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), geometry: { type: "Point", coordinates: [t.lon, t.lat] }, - properties: { - mmsi: t.mmsi, - name: t.name || "", - labelName, - cog: heading, - heading, - sog: isFiniteNumber(t.sog) ? t.sog : 0, - shipColor: getGlobeBaseShipColor({ - legacy: legacy?.shipCode || null, - sog: isFiniteNumber(t.sog) ? t.sog : null, - }), + properties: { + mmsi: t.mmsi, + name: t.name || "", + labelName, + cog: shipHeading, + heading: shipHeading, + sog: isFiniteNumber(t.sog) ? t.sog : 0, + isAnchored: isAnchored ? 1 : 0, + shipColor: getGlobeBaseShipColor({ + legacy: legacy?.shipCode || null, + sog: isFiniteNumber(t.sog) ? t.sog : null, + }), iconSize3: iconSize3 * iconScale, iconSize7: iconSize7 * iconScale, iconSize10: iconSize10 * iconScale, @@ -3185,7 +3258,12 @@ export function Map3D({ 75, 45, ] as never, - "icon-image": imgId, + "icon-image": [ + "case", + ["==", ["to-number", ["get", "isAnchored"], 0], 1], + anchoredImgId, + imgId, + ] as never, "icon-size": [ "interpolate", ["linear"], @@ -3202,7 +3280,12 @@ export function Map3D({ "icon-allow-overlap": true, "icon-ignore-placement": true, "icon-anchor": "center", - "icon-rotate": ["to-number", ["get", "heading"], 0], + "icon-rotate": [ + "case", + ["==", ["to-number", ["get", "isAnchored"], 0], 1], + 0, + ["to-number", ["get", "heading"], 0], + ] as never, // Keep the icon on the sea surface. "icon-rotation-alignment": "map", "icon-pitch-alignment": "map", diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5a8ef66 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4710 @@ +{ + "name": "wing-fleet-dashboard", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wing-fleet-dashboard", + "workspaces": [ + "apps/*" + ], + "devDependencies": { + "xlsx": "^0.18.5" + } + }, + "apps/api": { + "name": "@wing/api", + "version": "0.0.0", + "dependencies": { + "@fastify/cors": "^11.1.0", + "fastify": "^5.6.1" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "tsx": "^4.20.5", + "typescript": "~5.9.3" + } + }, + "apps/web": { + "name": "@wing/web", + "version": "0.0.0", + "dependencies": { + "@deck.gl/aggregation-layers": "^9.2.7", + "@deck.gl/core": "^9.2.7", + "@deck.gl/layers": "^9.2.7", + "@deck.gl/mapbox": "^9.2.7", + "maplibre-gl": "^5.18.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@deck.gl/aggregation-layers": { + "version": "9.2.7", + "resolved": "https://registry.npmjs.org/@deck.gl/aggregation-layers/-/aggregation-layers-9.2.7.tgz", + "integrity": "sha512-dHBHMb8gitQVDNdgHlkPZ0YtxgU/4Ftbuelb6pE+1GiwXA/ie3tw+WWOQIxYiidmKJ5rVO2kr/cct75RhFcT2Q==", + "license": "MIT", + "dependencies": { + "@luma.gl/constants": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6", + "@math.gl/core": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "d3-hexbin": "^0.2.1" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@deck.gl/layers": "~9.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" + } + }, + "node_modules/@deck.gl/core": { + "version": "9.2.7", + "resolved": "https://registry.npmjs.org/@deck.gl/core/-/core-9.2.7.tgz", + "integrity": "sha512-mltYFDC2dMtAZPkAaVIadccbM3iy9jjnhLa5obFnWzPtXyc1UBr7OW50Cjy0IlQmAsgDI2BATzcw5a/p4zU8zw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@loaders.gl/core": "^4.3.4", + "@loaders.gl/images": "^4.3.4", + "@luma.gl/constants": "^9.2.6", + "@luma.gl/core": "^9.2.6", + "@luma.gl/engine": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6", + "@luma.gl/webgl": "^9.2.6", + "@math.gl/core": "^4.1.0", + "@math.gl/sun": "^4.1.0", + "@math.gl/types": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@probe.gl/env": "^4.1.0", + "@probe.gl/log": "^4.1.0", + "@probe.gl/stats": "^4.1.0", + "@types/offscreencanvas": "^2019.6.4", + "gl-matrix": "^3.0.0", + "mjolnir.js": "^3.0.0" + } + }, + "node_modules/@deck.gl/layers": { + "version": "9.2.7", + "resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-9.2.7.tgz", + "integrity": "sha512-oGRv3s+i+Rq4qQFTfdCBx2S650K4p0gGS/bPYpURCXW0a0LQBHqN8AkKbhdos7b7Lawp1iMwkIggBZSZaNkyXg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@loaders.gl/images": "^4.3.4", + "@loaders.gl/schema": "^4.3.4", + "@luma.gl/shadertools": "^9.2.6", + "@mapbox/tiny-sdf": "^2.0.5", + "@math.gl/core": "^4.1.0", + "@math.gl/polygon": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "earcut": "^2.2.4" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@loaders.gl/core": "^4.3.4", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" + } + }, + "node_modules/@deck.gl/mapbox": { + "version": "9.2.7", + "resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.2.7.tgz", + "integrity": "sha512-kcTMavoM9RqGbDXg78U/DGlR3dCQMR5+9ctc83qy0aNP57zQ62okomnq9DVCfxvcQjYb1uMqAt3HaBespInRcA==", + "license": "MIT", + "dependencies": { + "@luma.gl/constants": "^9.2.6", + "@math.gl/web-mercator": "^4.1.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@luma.gl/constants": "~9.2.6", + "@luma.gl/core": "~9.2.6", + "@math.gl/web-mercator": "^4.1.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@fastify/cors": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", + "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@loaders.gl/core": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.3.4.tgz", + "integrity": "sha512-cG0C5fMZ1jyW6WCsf4LoHGvaIAJCEVA/ioqKoYRwoSfXkOf+17KupK1OUQyUCw5XoRn+oWA1FulJQOYlXnb9Gw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@probe.gl/log": "^4.0.2" + } + }, + "node_modules/@loaders.gl/images": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/images/-/images-4.3.4.tgz", + "integrity": "sha512-qgc33BaNsqN9cWa/xvcGvQ50wGDONgQQdzHCKDDKhV2w/uptZoR5iofJfuG8UUV2vUMMd82Uk9zbopRx2rS4Ag==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/loader-utils": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/loader-utils/-/loader-utils-4.3.4.tgz", + "integrity": "sha512-tjMZvlKQSaMl2qmYTAxg+ySR6zd6hQn5n3XaU8+Ehp90TD3WzxvDKOMNDqOa72fFmIV+KgPhcmIJTpq4lAdC4Q==", + "license": "MIT", + "dependencies": { + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@probe.gl/log": "^4.0.2", + "@probe.gl/stats": "^4.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/schema": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/schema/-/schema-4.3.4.tgz", + "integrity": "sha512-1YTYoatgzr/6JTxqBLwDiD3AVGwQZheYiQwAimWdRBVB0JAzych7s1yBuE0CVEzj4JDPKOzVAz8KnU1TiBvJGw==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.7" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/worker-utils": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/worker-utils/-/worker-utils-4.3.4.tgz", + "integrity": "sha512-EbsszrASgT85GH3B7jkx7YXfQyIYo/rlobwMx6V3ewETapPUwdSAInv+89flnk5n2eu2Lpdeh+2zS6PvqbL2RA==", + "license": "MIT", + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@luma.gl/constants": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.2.6.tgz", + "integrity": "sha512-rvFFrJuSm5JIWbDHFuR4Q2s4eudO3Ggsv0TsGKn9eqvO7bBiPm/ANugHredvh3KviEyYuMZZxtfJvBdr3kzldg==", + "license": "MIT" + }, + "node_modules/@luma.gl/core": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/core/-/core-9.2.6.tgz", + "integrity": "sha512-d8KcH8ZZcjDAodSN/G2nueA9YE2X8kMz7Q0OxDGpCww6to1MZXM3Ydate/Jqsb5DDKVgUF6yD6RL8P5jOki9Yw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@math.gl/types": "^4.1.0", + "@probe.gl/env": "^4.0.8", + "@probe.gl/log": "^4.0.8", + "@probe.gl/stats": "^4.0.8", + "@types/offscreencanvas": "^2019.6.4" + } + }, + "node_modules/@luma.gl/engine": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/engine/-/engine-9.2.6.tgz", + "integrity": "sha512-1AEDs2AUqOWh7Wl4onOhXmQF+Rz1zNdPXF+Kxm4aWl92RQ42Sh2CmTvRt2BJku83VQ91KFIEm/v3qd3Urzf+Uw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@math.gl/core": "^4.1.0", + "@math.gl/types": "^4.1.0", + "@probe.gl/log": "^4.0.8", + "@probe.gl/stats": "^4.0.8" + }, + "peerDependencies": { + "@luma.gl/core": "~9.2.0", + "@luma.gl/shadertools": "~9.2.0" + } + }, + "node_modules/@luma.gl/shadertools": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/shadertools/-/shadertools-9.2.6.tgz", + "integrity": "sha512-4+uUbynqPUra9d/z1nQChyHmhLgmKfSMjS7kOwLB6exSnhKnpHL3+Hu9fv55qyaX50nGH3oHawhGtJ6RRvu65w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@math.gl/core": "^4.1.0", + "@math.gl/types": "^4.1.0", + "wgsl_reflect": "^1.2.0" + }, + "peerDependencies": { + "@luma.gl/core": "~9.2.0" + } + }, + "node_modules/@luma.gl/webgl": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/webgl/-/webgl-9.2.6.tgz", + "integrity": "sha512-NGBTdxJMk7j8Ygr1zuTyAvr1Tw+EpupMIQo7RelFjEsZXg6pujFqiDMM+rgxex8voCeuhWBJc7Rs+MoSqd46UQ==", + "license": "MIT", + "dependencies": { + "@luma.gl/constants": "9.2.6", + "@math.gl/types": "^4.1.0", + "@probe.gl/env": "^4.0.8" + }, + "peerDependencies": { + "@luma.gl/core": "~9.2.0" + } + }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", + "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", + "license": "ISC" + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.4.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.4.1.tgz", + "integrity": "sha512-UKhA4qv1h30XT768ccSv5NjNCX+dgfoq2qlLVmKejspPcSQTYD4SrVucgqegmYcKcmwf06wcNAa/kRd0NHWbUg==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.6.tgz", + "integrity": "sha512-rgtY3x65lrrfXycLf6/T22ZnjTg5WgIOsptOIoCaMZy4O4UAKTyZlYY0h6v8le721pTptF94U65yMDQkug+URw==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.2.1.tgz", + "integrity": "sha512-IxZBGq/+9cqf2qdWlFuQ+ZfoMhWpxDUGQZ/poPHOJBvwMUT1GuxLo6HgYTou+xxtsOsjfbcjI8PZaPCtmt97rA==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/vector-tile": "^2.0.4", + "@maplibre/geojson-vt": "^5.0.4", + "@types/geojson": "^7946.0.16", + "@types/supercluster": "^7.1.3", + "pbf": "^4.0.1", + "supercluster": "^8.0.1" + } + }, + "node_modules/@math.gl/core": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/core/-/core-4.1.0.tgz", + "integrity": "sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==", + "license": "MIT", + "dependencies": { + "@math.gl/types": "4.1.0" + } + }, + "node_modules/@math.gl/polygon": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/polygon/-/polygon-4.1.0.tgz", + "integrity": "sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0" + } + }, + "node_modules/@math.gl/sun": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/sun/-/sun-4.1.0.tgz", + "integrity": "sha512-i3q6OCBLSZ5wgZVhXg+X7gsjY/TUtuFW/2KBiq/U1ypLso3S4sEykoU/MGjxUv1xiiGtr+v8TeMbO1OBIh/HmA==", + "license": "MIT" + }, + "node_modules/@math.gl/types": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/types/-/types-4.1.0.tgz", + "integrity": "sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==", + "license": "MIT" + }, + "node_modules/@math.gl/web-mercator": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/web-mercator/-/web-mercator-4.1.0.tgz", + "integrity": "sha512-HZo3vO5GCMkXJThxRJ5/QYUYRr3XumfT8CzNNCwoJfinxy5NtKUd7dusNTXn7yJ40UoB8FMIwkVwNlqaiRZZAw==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@probe.gl/env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@probe.gl/env/-/env-4.1.0.tgz", + "integrity": "sha512-5ac2Jm2K72VCs4eSMsM7ykVRrV47w32xOGMvcgqn8vQdEMF9PRXyBGYEV9YbqRKWNKpNKmQJVi4AHM/fkCxs9w==", + "license": "MIT" + }, + "node_modules/@probe.gl/log": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@probe.gl/log/-/log-4.1.0.tgz", + "integrity": "sha512-r4gRReNY6f+OZEMgfWEXrAE2qJEt8rX0HsDJQXUBMoc+5H47bdB7f/5HBHAmapK8UydwPKL9wCDoS22rJ0yq7Q==", + "license": "MIT", + "dependencies": { + "@probe.gl/env": "4.1.0" + } + }, + "node_modules/@probe.gl/stats": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@probe.gl/stats/-/stats-4.1.0.tgz", + "integrity": "sha512-EI413MkWKBDVNIfLdqbeNSJTs7ToBz/KVGkwi3D+dQrSIkRI2IYbWGAU3xX+D6+CI4ls8ehxMhNpUVMaZggDvQ==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", + "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/type-utils": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.55.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", + "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", + "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", + "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", + "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz", + "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", + "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.55.0", + "@typescript-eslint/tsconfig-utils": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", + "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@wing/api": { + "resolved": "apps/api", + "link": true + }, + "node_modules/@wing/web": { + "resolved": "apps/web", + "link": true + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/d3-hexbin": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/d3-hexbin/-/d3-hexbin-0.2.2.tgz", + "integrity": "sha512-KS3fUT2ReD4RlGCjvCEm1RgMtp2NFZumdMu4DBzQK8AZv3fXRM6Xm8I4fSU07UXvH4xxg03NwWKWdvxfS/yc4w==", + "license": "BSD-3-Clause" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", + "integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastify/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-my-way": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.4.0.tgz", + "integrity": "sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/maplibre-gl": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.18.0.tgz", + "integrity": "sha512-UtWxPBpHuFvEkM+5FVfcFG9ZKEWZQI6+PZkvLErr8Zs5ux+O7/KQ3JjSUvAfOlMeMgd/77qlHpOw0yHL7JU5cw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.0.7", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^5.0.4", + "@maplibre/maplibre-gl-style-spec": "^24.4.1", + "@maplibre/mlt": "^1.1.6", + "@maplibre/vt-pbf": "^4.2.1", + "@types/geojson": "^7946.0.16", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/maplibre-gl/node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mjolnir.js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mjolnir.js/-/mjolnir.js-3.0.0.tgz", + "integrity": "sha512-siX3YCG7N2HnmN1xMH3cK4JkUZJhbkhRFJL+G5N1vH0mh1t5088rJknIoqDFWDIU6NPGvRRgLnYW3ZHjSMEBLA==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safe-regex2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", + "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz", + "integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.55.0", + "@typescript-eslint/parser": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/wgsl_reflect": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/wgsl_reflect/-/wgsl_reflect-1.2.3.tgz", + "integrity": "sha512-BQWBIsOn411M+ffBxmA6QRLvAOVbuz3Uk4NusxnqC1H7aeQcVLhdA3k2k/EFFFtqVjhz3z7JOOZF1a9hj2tv4Q==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/package.json b/package.json index 904fc7f..1337443 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,15 @@ { "name": "wing-fleet-dashboard", "private": true, - "packageManager": "pnpm@10.29.3", + "workspaces": ["apps/*"], "scripts": { - "dev": "pnpm -r --parallel dev", - "dev:web": "pnpm --filter @wing/web dev", - "dev:api": "pnpm --filter @wing/api dev", - "build": "pnpm -r build", + "dev": "npm run dev:web & npm run dev:api", + "dev:web": "npm -w @wing/web run dev", + "dev:api": "npm -w @wing/api run dev", + "build": "npm -w @wing/web run build && npm -w @wing/api run build", + "build:web": "npm -w @wing/web run build", + "build:api": "npm -w @wing/api run build", + "lint": "npm -w @wing/web run lint", "prepare:data": "node scripts/prepare-zones.mjs && node scripts/prepare-legacy.mjs" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 9e976be..0000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,3001 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - devDependencies: - xlsx: - specifier: ^0.18.5 - version: 0.18.5 - - apps/api: - dependencies: - '@fastify/cors': - specifier: ^11.1.0 - version: 11.2.0 - fastify: - specifier: ^5.6.1 - version: 5.7.4 - devDependencies: - '@types/node': - specifier: ^24.10.1 - version: 24.10.13 - tsx: - specifier: ^4.20.5 - version: 4.21.0 - typescript: - specifier: ~5.9.3 - version: 5.9.3 - - apps/web: - dependencies: - '@deck.gl/aggregation-layers': - specifier: ^9.2.7 - version: 9.2.7(@deck.gl/core@9.2.7)(@deck.gl/layers@9.2.7(@deck.gl/core@9.2.7)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))))(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))) - '@deck.gl/core': - specifier: ^9.2.7 - version: 9.2.7 - '@deck.gl/layers': - specifier: ^9.2.7 - version: 9.2.7(@deck.gl/core@9.2.7)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))) - '@deck.gl/mapbox': - specifier: ^9.2.7 - version: 9.2.7(@deck.gl/core@9.2.7)(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@math.gl/web-mercator@4.1.0) - maplibre-gl: - specifier: ^5.18.0 - version: 5.18.0 - react: - specifier: ^19.2.0 - version: 19.2.4 - react-dom: - specifier: ^19.2.0 - version: 19.2.4(react@19.2.4) - devDependencies: - '@eslint/js': - specifier: ^9.39.1 - version: 9.39.2 - '@types/node': - specifier: ^24.10.1 - version: 24.10.13 - '@types/react': - specifier: ^19.2.7 - version: 19.2.14 - '@types/react-dom': - specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.14) - '@vitejs/plugin-react': - specifier: ^5.1.1 - version: 5.1.4(vite@7.3.1(@types/node@24.10.13)(tsx@4.21.0)) - eslint: - specifier: ^9.39.1 - version: 9.39.2 - eslint-plugin-react-hooks: - specifier: ^7.0.1 - version: 7.0.1(eslint@9.39.2) - eslint-plugin-react-refresh: - specifier: ^0.4.24 - version: 0.4.26(eslint@9.39.2) - globals: - specifier: ^16.5.0 - version: 16.5.0 - typescript: - specifier: ~5.9.3 - version: 5.9.3 - typescript-eslint: - specifier: ^8.48.0 - version: 8.55.0(eslint@9.39.2)(typescript@5.9.3) - vite: - specifier: ^7.3.1 - version: 7.3.1(@types/node@24.10.13)(tsx@4.21.0) - -packages: - - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.28.6': - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.28.6': - resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.0': - resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-transform-react-jsx-self@7.27.1': - resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.27.1': - resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - - '@deck.gl/aggregation-layers@9.2.7': - resolution: {integrity: sha512-dHBHMb8gitQVDNdgHlkPZ0YtxgU/4Ftbuelb6pE+1GiwXA/ie3tw+WWOQIxYiidmKJ5rVO2kr/cct75RhFcT2Q==} - peerDependencies: - '@deck.gl/core': ~9.2.0 - '@deck.gl/layers': ~9.2.0 - '@luma.gl/core': ~9.2.6 - '@luma.gl/engine': ~9.2.6 - - '@deck.gl/core@9.2.7': - resolution: {integrity: sha512-mltYFDC2dMtAZPkAaVIadccbM3iy9jjnhLa5obFnWzPtXyc1UBr7OW50Cjy0IlQmAsgDI2BATzcw5a/p4zU8zw==} - - '@deck.gl/layers@9.2.7': - resolution: {integrity: sha512-oGRv3s+i+Rq4qQFTfdCBx2S650K4p0gGS/bPYpURCXW0a0LQBHqN8AkKbhdos7b7Lawp1iMwkIggBZSZaNkyXg==} - peerDependencies: - '@deck.gl/core': ~9.2.0 - '@loaders.gl/core': ^4.3.4 - '@luma.gl/core': ~9.2.6 - '@luma.gl/engine': ~9.2.6 - - '@deck.gl/mapbox@9.2.7': - resolution: {integrity: sha512-kcTMavoM9RqGbDXg78U/DGlR3dCQMR5+9ctc83qy0aNP57zQ62okomnq9DVCfxvcQjYb1uMqAt3HaBespInRcA==} - peerDependencies: - '@deck.gl/core': ~9.2.0 - '@luma.gl/constants': ~9.2.6 - '@luma.gl/core': ~9.2.6 - '@math.gl/web-mercator': ^4.1.0 - - '@esbuild/aix-ppc64@0.27.3': - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.27.3': - resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.27.3': - resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.27.3': - resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.27.3': - resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.3': - resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.27.3': - resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.3': - resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.27.3': - resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.27.3': - resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.27.3': - resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.27.3': - resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.27.3': - resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.27.3': - resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.3': - resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.27.3': - resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.27.3': - resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.3': - resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.3': - resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.3': - resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.3': - resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.3': - resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.3': - resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.27.3': - resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.27.3': - resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.27.3': - resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/config-array@0.21.1': - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/config-helpers@0.4.2': - resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/core@0.17.0': - resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/eslintrc@3.3.3': - resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/js@9.39.2': - resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/object-schema@2.1.7': - resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/plugin-kit@0.4.1': - resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@fastify/ajv-compiler@4.0.5': - resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} - - '@fastify/cors@11.2.0': - resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} - - '@fastify/error@4.2.0': - resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} - - '@fastify/fast-json-stringify-compiler@5.0.3': - resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} - - '@fastify/forwarded@3.0.1': - resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} - - '@fastify/merge-json-schemas@0.2.1': - resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} - - '@fastify/proxy-addr@5.1.0': - resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} - - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@loaders.gl/core@4.3.4': - resolution: {integrity: sha512-cG0C5fMZ1jyW6WCsf4LoHGvaIAJCEVA/ioqKoYRwoSfXkOf+17KupK1OUQyUCw5XoRn+oWA1FulJQOYlXnb9Gw==} - - '@loaders.gl/images@4.3.4': - resolution: {integrity: sha512-qgc33BaNsqN9cWa/xvcGvQ50wGDONgQQdzHCKDDKhV2w/uptZoR5iofJfuG8UUV2vUMMd82Uk9zbopRx2rS4Ag==} - peerDependencies: - '@loaders.gl/core': ^4.3.0 - - '@loaders.gl/loader-utils@4.3.4': - resolution: {integrity: sha512-tjMZvlKQSaMl2qmYTAxg+ySR6zd6hQn5n3XaU8+Ehp90TD3WzxvDKOMNDqOa72fFmIV+KgPhcmIJTpq4lAdC4Q==} - peerDependencies: - '@loaders.gl/core': ^4.3.0 - - '@loaders.gl/schema@4.3.4': - resolution: {integrity: sha512-1YTYoatgzr/6JTxqBLwDiD3AVGwQZheYiQwAimWdRBVB0JAzych7s1yBuE0CVEzj4JDPKOzVAz8KnU1TiBvJGw==} - peerDependencies: - '@loaders.gl/core': ^4.3.0 - - '@loaders.gl/worker-utils@4.3.4': - resolution: {integrity: sha512-EbsszrASgT85GH3B7jkx7YXfQyIYo/rlobwMx6V3ewETapPUwdSAInv+89flnk5n2eu2Lpdeh+2zS6PvqbL2RA==} - peerDependencies: - '@loaders.gl/core': ^4.3.0 - - '@luma.gl/constants@9.2.6': - resolution: {integrity: sha512-rvFFrJuSm5JIWbDHFuR4Q2s4eudO3Ggsv0TsGKn9eqvO7bBiPm/ANugHredvh3KviEyYuMZZxtfJvBdr3kzldg==} - - '@luma.gl/core@9.2.6': - resolution: {integrity: sha512-d8KcH8ZZcjDAodSN/G2nueA9YE2X8kMz7Q0OxDGpCww6to1MZXM3Ydate/Jqsb5DDKVgUF6yD6RL8P5jOki9Yw==} - - '@luma.gl/engine@9.2.6': - resolution: {integrity: sha512-1AEDs2AUqOWh7Wl4onOhXmQF+Rz1zNdPXF+Kxm4aWl92RQ42Sh2CmTvRt2BJku83VQ91KFIEm/v3qd3Urzf+Uw==} - peerDependencies: - '@luma.gl/core': ~9.2.0 - '@luma.gl/shadertools': ~9.2.0 - - '@luma.gl/shadertools@9.2.6': - resolution: {integrity: sha512-4+uUbynqPUra9d/z1nQChyHmhLgmKfSMjS7kOwLB6exSnhKnpHL3+Hu9fv55qyaX50nGH3oHawhGtJ6RRvu65w==} - peerDependencies: - '@luma.gl/core': ~9.2.0 - - '@luma.gl/webgl@9.2.6': - resolution: {integrity: sha512-NGBTdxJMk7j8Ygr1zuTyAvr1Tw+EpupMIQo7RelFjEsZXg6pujFqiDMM+rgxex8voCeuhWBJc7Rs+MoSqd46UQ==} - peerDependencies: - '@luma.gl/core': ~9.2.0 - - '@mapbox/geojson-rewind@0.5.2': - resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==} - hasBin: true - - '@mapbox/jsonlint-lines-primitives@2.0.2': - resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==} - engines: {node: '>= 0.6'} - - '@mapbox/point-geometry@1.1.0': - resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==} - - '@mapbox/tiny-sdf@2.0.7': - resolution: {integrity: sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==} - - '@mapbox/unitbezier@0.0.1': - resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==} - - '@mapbox/vector-tile@2.0.4': - resolution: {integrity: sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==} - - '@mapbox/whoots-js@3.1.0': - resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==} - engines: {node: '>=6.0.0'} - - '@maplibre/geojson-vt@5.0.4': - resolution: {integrity: sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==} - - '@maplibre/maplibre-gl-style-spec@24.4.1': - resolution: {integrity: sha512-UKhA4qv1h30XT768ccSv5NjNCX+dgfoq2qlLVmKejspPcSQTYD4SrVucgqegmYcKcmwf06wcNAa/kRd0NHWbUg==} - hasBin: true - - '@maplibre/mlt@1.1.6': - resolution: {integrity: sha512-rgtY3x65lrrfXycLf6/T22ZnjTg5WgIOsptOIoCaMZy4O4UAKTyZlYY0h6v8le721pTptF94U65yMDQkug+URw==} - - '@maplibre/vt-pbf@4.2.1': - resolution: {integrity: sha512-IxZBGq/+9cqf2qdWlFuQ+ZfoMhWpxDUGQZ/poPHOJBvwMUT1GuxLo6HgYTou+xxtsOsjfbcjI8PZaPCtmt97rA==} - - '@math.gl/core@4.1.0': - resolution: {integrity: sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==} - - '@math.gl/polygon@4.1.0': - resolution: {integrity: sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA==} - - '@math.gl/sun@4.1.0': - resolution: {integrity: sha512-i3q6OCBLSZ5wgZVhXg+X7gsjY/TUtuFW/2KBiq/U1ypLso3S4sEykoU/MGjxUv1xiiGtr+v8TeMbO1OBIh/HmA==} - - '@math.gl/types@4.1.0': - resolution: {integrity: sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==} - - '@math.gl/web-mercator@4.1.0': - resolution: {integrity: sha512-HZo3vO5GCMkXJThxRJ5/QYUYRr3XumfT8CzNNCwoJfinxy5NtKUd7dusNTXn7yJ40UoB8FMIwkVwNlqaiRZZAw==} - - '@pinojs/redact@0.4.0': - resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - - '@probe.gl/env@4.1.0': - resolution: {integrity: sha512-5ac2Jm2K72VCs4eSMsM7ykVRrV47w32xOGMvcgqn8vQdEMF9PRXyBGYEV9YbqRKWNKpNKmQJVi4AHM/fkCxs9w==} - - '@probe.gl/log@4.1.0': - resolution: {integrity: sha512-r4gRReNY6f+OZEMgfWEXrAE2qJEt8rX0HsDJQXUBMoc+5H47bdB7f/5HBHAmapK8UydwPKL9wCDoS22rJ0yq7Q==} - - '@probe.gl/stats@4.1.0': - resolution: {integrity: sha512-EI413MkWKBDVNIfLdqbeNSJTs7ToBz/KVGkwi3D+dQrSIkRI2IYbWGAU3xX+D6+CI4ls8ehxMhNpUVMaZggDvQ==} - - '@rolldown/pluginutils@1.0.0-rc.3': - resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - - '@rollup/rollup-android-arm-eabi@4.57.1': - resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.57.1': - resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.57.1': - resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.57.1': - resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.57.1': - resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.57.1': - resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.57.1': - resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.57.1': - resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.57.1': - resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.57.1': - resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.57.1': - resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.57.1': - resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-ppc64-gnu@4.57.1': - resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-musl@4.57.1': - resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} - cpu: [ppc64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-riscv64-gnu@4.57.1': - resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.57.1': - resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.57.1': - resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.57.1': - resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.57.1': - resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rollup/rollup-openbsd-x64@4.57.1': - resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.57.1': - resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.57.1': - resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.57.1': - resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.57.1': - resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.57.1': - resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} - cpu: [x64] - os: [win32] - - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/geojson@7946.0.16': - resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/node@24.10.13': - resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==} - - '@types/offscreencanvas@2019.7.3': - resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} - - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} - peerDependencies: - '@types/react': ^19.2.0 - - '@types/react@19.2.14': - resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} - - '@types/supercluster@7.1.3': - resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==} - - '@typescript-eslint/eslint-plugin@8.55.0': - resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.55.0 - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/parser@8.55.0': - resolution: {integrity: sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/project-service@8.55.0': - resolution: {integrity: sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/scope-manager@8.55.0': - resolution: {integrity: sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.55.0': - resolution: {integrity: sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/type-utils@8.55.0': - resolution: {integrity: sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/types@8.55.0': - resolution: {integrity: sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.55.0': - resolution: {integrity: sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/utils@8.55.0': - resolution: {integrity: sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/visitor-keys@8.55.0': - resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@vitejs/plugin-react@5.1.4': - resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==} - engines: {node: ^20.19.0 || >=22.12.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - - abstract-logging@2.0.1: - resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true - - adler-32@1.3.1: - resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} - engines: {node: '>=0.8'} - - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - atomic-sleep@1.0.0: - resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} - engines: {node: '>=8.0.0'} - - avvio@9.2.0: - resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - baseline-browser-mapping@2.9.19: - resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} - hasBin: true - - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - caniuse-lite@1.0.30001769: - resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} - - cfb@1.2.2: - resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} - engines: {node: '>=0.8'} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - codepage@1.15.0: - resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} - engines: {node: '>=0.8'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cookie@1.1.1: - resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} - engines: {node: '>=18'} - - crc-32@1.2.2: - resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} - engines: {node: '>=0.8'} - hasBin: true - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - d3-hexbin@0.2.2: - resolution: {integrity: sha512-KS3fUT2ReD4RlGCjvCEm1RgMtp2NFZumdMu4DBzQK8AZv3fXRM6Xm8I4fSU07UXvH4xxg03NwWKWdvxfS/yc4w==} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - - earcut@2.2.4: - resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} - - earcut@3.0.2: - resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} - - electron-to-chromium@1.5.286: - resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} - - esbuild@0.27.3: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} - engines: {node: '>=18'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-plugin-react-hooks@7.0.1: - resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} - engines: {node: '>=18'} - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - - eslint-plugin-react-refresh@0.4.26: - resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==} - peerDependencies: - eslint: '>=8.40' - - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint@9.39.2: - resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - fast-decode-uri-component@1.0.1: - resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-json-stringify@6.3.0: - resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fast-querystring@1.1.2: - resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} - - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - - fastify-plugin@5.1.0: - resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} - - fastify@5.7.4: - resolution: {integrity: sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==} - - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - - find-my-way@9.4.0: - resolution: {integrity: sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==} - engines: {node: '>=20'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - - frac@1.1.2: - resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} - engines: {node: '>=0.8'} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - - get-tsconfig@4.13.6: - resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} - - gl-matrix@3.4.4: - resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - - globals@16.5.0: - resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} - engines: {node: '>=18'} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - hermes-estree@0.25.1: - resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} - - hermes-parser@0.25.1: - resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} - - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - ipaddr.js@2.3.0: - resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} - engines: {node: '>= 10'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-schema-ref-resolver@3.0.0: - resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - json-stringify-pretty-compact@4.0.0: - resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - kdbush@4.0.2: - resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==} - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - - light-my-request@6.6.0: - resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - maplibre-gl@5.18.0: - resolution: {integrity: sha512-UtWxPBpHuFvEkM+5FVfcFG9ZKEWZQI6+PZkvLErr8Zs5ux+O7/KQ3JjSUvAfOlMeMgd/77qlHpOw0yHL7JU5cw==} - engines: {node: '>=16.14.0', npm: '>=8.1.0'} - - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - mjolnir.js@3.0.0: - resolution: {integrity: sha512-siX3YCG7N2HnmN1xMH3cK4JkUZJhbkhRFJL+G5N1vH0mh1t5088rJknIoqDFWDIU6NPGvRRgLnYW3ZHjSMEBLA==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - murmurhash-js@1.0.0: - resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - - on-exit-leak-free@2.1.2: - resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} - engines: {node: '>=14.0.0'} - - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - pbf@4.0.1: - resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==} - hasBin: true - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - - pino-abstract-transport@3.0.0: - resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} - - pino-std-serializers@7.1.0: - resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} - - pino@10.3.1: - resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} - hasBin: true - - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} - engines: {node: ^10 || ^12 || >=14} - - potpack@2.1.0: - resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==} - - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - process-warning@4.0.1: - resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} - - process-warning@5.0.0: - resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} - - protocol-buffers-schema@3.6.0: - resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - quick-format-unescaped@4.0.4: - resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - - quickselect@3.0.0: - resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} - - react-dom@19.2.4: - resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} - peerDependencies: - react: ^19.2.4 - - react-refresh@0.18.0: - resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} - engines: {node: '>=0.10.0'} - - react@19.2.4: - resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} - engines: {node: '>=0.10.0'} - - real-require@0.2.0: - resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} - engines: {node: '>= 12.13.0'} - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - - resolve-protobuf-schema@2.1.0: - resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} - - ret@0.5.0: - resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} - engines: {node: '>=10'} - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - - rollup@4.57.1: - resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - rw@1.3.3: - resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} - - safe-regex2@5.0.0: - resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} - - safe-stable-stringify@2.5.0: - resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} - engines: {node: '>=10'} - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - secure-json-parse@4.1.0: - resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - - set-cookie-parser@2.7.2: - resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - sonic-boom@4.2.1: - resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - split2@4.2.0: - resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} - engines: {node: '>= 10.x'} - - ssf@0.11.2: - resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} - engines: {node: '>=0.8'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - supercluster@8.0.1: - resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - thread-stream@4.0.0: - resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} - engines: {node: '>=20'} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - - tinyqueue@3.0.0: - resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} - - toad-cache@3.7.0: - resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} - engines: {node: '>=12'} - - ts-api-utils@2.4.0: - resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - - tsx@4.21.0: - resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} - engines: {node: '>=18.0.0'} - hasBin: true - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - typescript-eslint@8.55.0: - resolution: {integrity: sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - vite@7.3.1: - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - wgsl_reflect@1.2.3: - resolution: {integrity: sha512-BQWBIsOn411M+ffBxmA6QRLvAOVbuz3Uk4NusxnqC1H7aeQcVLhdA3k2k/EFFFtqVjhz3z7JOOZF1a9hj2tv4Q==} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - wmf@1.0.2: - resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} - engines: {node: '>=0.8'} - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - word@0.3.0: - resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} - engines: {node: '>=0.8'} - - xlsx@0.18.5: - resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} - engines: {node: '>=0.8'} - hasBin: true - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - - zod-validation-error@4.0.2: - resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} - engines: {node: '>=18.0.0'} - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - -snapshots: - - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.29.0': {} - - '@babel/core@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.28.6 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.28.6': - dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.28.0': {} - - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.28.6': {} - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} - - '@babel/helper-validator-option@7.27.1': {} - - '@babel/helpers@7.28.6': - dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - - '@babel/parser@7.29.0': - dependencies: - '@babel/types': 7.29.0 - - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@deck.gl/aggregation-layers@9.2.7(@deck.gl/core@9.2.7)(@deck.gl/layers@9.2.7(@deck.gl/core@9.2.7)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))))(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))': - dependencies: - '@deck.gl/core': 9.2.7 - '@deck.gl/layers': 9.2.7(@deck.gl/core@9.2.7)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))) - '@luma.gl/constants': 9.2.6 - '@luma.gl/core': 9.2.6 - '@luma.gl/engine': 9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)) - '@luma.gl/shadertools': 9.2.6(@luma.gl/core@9.2.6) - '@math.gl/core': 4.1.0 - '@math.gl/web-mercator': 4.1.0 - d3-hexbin: 0.2.2 - - '@deck.gl/core@9.2.7': - dependencies: - '@loaders.gl/core': 4.3.4 - '@loaders.gl/images': 4.3.4(@loaders.gl/core@4.3.4) - '@luma.gl/constants': 9.2.6 - '@luma.gl/core': 9.2.6 - '@luma.gl/engine': 9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)) - '@luma.gl/shadertools': 9.2.6(@luma.gl/core@9.2.6) - '@luma.gl/webgl': 9.2.6(@luma.gl/core@9.2.6) - '@math.gl/core': 4.1.0 - '@math.gl/sun': 4.1.0 - '@math.gl/types': 4.1.0 - '@math.gl/web-mercator': 4.1.0 - '@probe.gl/env': 4.1.0 - '@probe.gl/log': 4.1.0 - '@probe.gl/stats': 4.1.0 - '@types/offscreencanvas': 2019.7.3 - gl-matrix: 3.4.4 - mjolnir.js: 3.0.0 - - '@deck.gl/layers@9.2.7(@deck.gl/core@9.2.7)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))': - dependencies: - '@deck.gl/core': 9.2.7 - '@loaders.gl/core': 4.3.4 - '@loaders.gl/images': 4.3.4(@loaders.gl/core@4.3.4) - '@loaders.gl/schema': 4.3.4(@loaders.gl/core@4.3.4) - '@luma.gl/core': 9.2.6 - '@luma.gl/engine': 9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)) - '@luma.gl/shadertools': 9.2.6(@luma.gl/core@9.2.6) - '@mapbox/tiny-sdf': 2.0.7 - '@math.gl/core': 4.1.0 - '@math.gl/polygon': 4.1.0 - '@math.gl/web-mercator': 4.1.0 - earcut: 2.2.4 - - '@deck.gl/mapbox@9.2.7(@deck.gl/core@9.2.7)(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@math.gl/web-mercator@4.1.0)': - dependencies: - '@deck.gl/core': 9.2.7 - '@luma.gl/constants': 9.2.6 - '@luma.gl/core': 9.2.6 - '@math.gl/web-mercator': 4.1.0 - - '@esbuild/aix-ppc64@0.27.3': - optional: true - - '@esbuild/android-arm64@0.27.3': - optional: true - - '@esbuild/android-arm@0.27.3': - optional: true - - '@esbuild/android-x64@0.27.3': - optional: true - - '@esbuild/darwin-arm64@0.27.3': - optional: true - - '@esbuild/darwin-x64@0.27.3': - optional: true - - '@esbuild/freebsd-arm64@0.27.3': - optional: true - - '@esbuild/freebsd-x64@0.27.3': - optional: true - - '@esbuild/linux-arm64@0.27.3': - optional: true - - '@esbuild/linux-arm@0.27.3': - optional: true - - '@esbuild/linux-ia32@0.27.3': - optional: true - - '@esbuild/linux-loong64@0.27.3': - optional: true - - '@esbuild/linux-mips64el@0.27.3': - optional: true - - '@esbuild/linux-ppc64@0.27.3': - optional: true - - '@esbuild/linux-riscv64@0.27.3': - optional: true - - '@esbuild/linux-s390x@0.27.3': - optional: true - - '@esbuild/linux-x64@0.27.3': - optional: true - - '@esbuild/netbsd-arm64@0.27.3': - optional: true - - '@esbuild/netbsd-x64@0.27.3': - optional: true - - '@esbuild/openbsd-arm64@0.27.3': - optional: true - - '@esbuild/openbsd-x64@0.27.3': - optional: true - - '@esbuild/openharmony-arm64@0.27.3': - optional: true - - '@esbuild/sunos-x64@0.27.3': - optional: true - - '@esbuild/win32-arm64@0.27.3': - optional: true - - '@esbuild/win32-ia32@0.27.3': - optional: true - - '@esbuild/win32-x64@0.27.3': - optional: true - - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2)': - dependencies: - eslint: 9.39.2 - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/config-array@0.21.1': - dependencies: - '@eslint/object-schema': 2.1.7 - debug: 4.4.3 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.4.2': - dependencies: - '@eslint/core': 0.17.0 - - '@eslint/core@0.17.0': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/eslintrc@3.3.3': - dependencies: - ajv: 6.12.6 - debug: 4.4.3 - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@9.39.2': {} - - '@eslint/object-schema@2.1.7': {} - - '@eslint/plugin-kit@0.4.1': - dependencies: - '@eslint/core': 0.17.0 - levn: 0.4.1 - - '@fastify/ajv-compiler@4.0.5': - dependencies: - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - fast-uri: 3.1.0 - - '@fastify/cors@11.2.0': - dependencies: - fastify-plugin: 5.1.0 - toad-cache: 3.7.0 - - '@fastify/error@4.2.0': {} - - '@fastify/fast-json-stringify-compiler@5.0.3': - dependencies: - fast-json-stringify: 6.3.0 - - '@fastify/forwarded@3.0.1': {} - - '@fastify/merge-json-schemas@0.2.1': - dependencies: - dequal: 2.0.3 - - '@fastify/proxy-addr@5.1.0': - dependencies: - '@fastify/forwarded': 3.0.1 - ipaddr.js: 2.3.0 - - '@humanfs/core@0.19.1': {} - - '@humanfs/node@0.16.7': - dependencies: - '@humanfs/core': 0.19.1 - '@humanwhocodes/retry': 0.4.3 - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@loaders.gl/core@4.3.4': - dependencies: - '@loaders.gl/loader-utils': 4.3.4(@loaders.gl/core@4.3.4) - '@loaders.gl/schema': 4.3.4(@loaders.gl/core@4.3.4) - '@loaders.gl/worker-utils': 4.3.4(@loaders.gl/core@4.3.4) - '@probe.gl/log': 4.1.0 - - '@loaders.gl/images@4.3.4(@loaders.gl/core@4.3.4)': - dependencies: - '@loaders.gl/core': 4.3.4 - '@loaders.gl/loader-utils': 4.3.4(@loaders.gl/core@4.3.4) - - '@loaders.gl/loader-utils@4.3.4(@loaders.gl/core@4.3.4)': - dependencies: - '@loaders.gl/core': 4.3.4 - '@loaders.gl/schema': 4.3.4(@loaders.gl/core@4.3.4) - '@loaders.gl/worker-utils': 4.3.4(@loaders.gl/core@4.3.4) - '@probe.gl/log': 4.1.0 - '@probe.gl/stats': 4.1.0 - - '@loaders.gl/schema@4.3.4(@loaders.gl/core@4.3.4)': - dependencies: - '@loaders.gl/core': 4.3.4 - '@types/geojson': 7946.0.16 - - '@loaders.gl/worker-utils@4.3.4(@loaders.gl/core@4.3.4)': - dependencies: - '@loaders.gl/core': 4.3.4 - - '@luma.gl/constants@9.2.6': {} - - '@luma.gl/core@9.2.6': - dependencies: - '@math.gl/types': 4.1.0 - '@probe.gl/env': 4.1.0 - '@probe.gl/log': 4.1.0 - '@probe.gl/stats': 4.1.0 - '@types/offscreencanvas': 2019.7.3 - - '@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))': - dependencies: - '@luma.gl/core': 9.2.6 - '@luma.gl/shadertools': 9.2.6(@luma.gl/core@9.2.6) - '@math.gl/core': 4.1.0 - '@math.gl/types': 4.1.0 - '@probe.gl/log': 4.1.0 - '@probe.gl/stats': 4.1.0 - - '@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)': - dependencies: - '@luma.gl/core': 9.2.6 - '@math.gl/core': 4.1.0 - '@math.gl/types': 4.1.0 - wgsl_reflect: 1.2.3 - - '@luma.gl/webgl@9.2.6(@luma.gl/core@9.2.6)': - dependencies: - '@luma.gl/constants': 9.2.6 - '@luma.gl/core': 9.2.6 - '@math.gl/types': 4.1.0 - '@probe.gl/env': 4.1.0 - - '@mapbox/geojson-rewind@0.5.2': - dependencies: - get-stream: 6.0.1 - minimist: 1.2.8 - - '@mapbox/jsonlint-lines-primitives@2.0.2': {} - - '@mapbox/point-geometry@1.1.0': {} - - '@mapbox/tiny-sdf@2.0.7': {} - - '@mapbox/unitbezier@0.0.1': {} - - '@mapbox/vector-tile@2.0.4': - dependencies: - '@mapbox/point-geometry': 1.1.0 - '@types/geojson': 7946.0.16 - pbf: 4.0.1 - - '@mapbox/whoots-js@3.1.0': {} - - '@maplibre/geojson-vt@5.0.4': {} - - '@maplibre/maplibre-gl-style-spec@24.4.1': - dependencies: - '@mapbox/jsonlint-lines-primitives': 2.0.2 - '@mapbox/unitbezier': 0.0.1 - json-stringify-pretty-compact: 4.0.0 - minimist: 1.2.8 - quickselect: 3.0.0 - rw: 1.3.3 - tinyqueue: 3.0.0 - - '@maplibre/mlt@1.1.6': - dependencies: - '@mapbox/point-geometry': 1.1.0 - - '@maplibre/vt-pbf@4.2.1': - dependencies: - '@mapbox/point-geometry': 1.1.0 - '@mapbox/vector-tile': 2.0.4 - '@maplibre/geojson-vt': 5.0.4 - '@types/geojson': 7946.0.16 - '@types/supercluster': 7.1.3 - pbf: 4.0.1 - supercluster: 8.0.1 - - '@math.gl/core@4.1.0': - dependencies: - '@math.gl/types': 4.1.0 - - '@math.gl/polygon@4.1.0': - dependencies: - '@math.gl/core': 4.1.0 - - '@math.gl/sun@4.1.0': {} - - '@math.gl/types@4.1.0': {} - - '@math.gl/web-mercator@4.1.0': - dependencies: - '@math.gl/core': 4.1.0 - - '@pinojs/redact@0.4.0': {} - - '@probe.gl/env@4.1.0': {} - - '@probe.gl/log@4.1.0': - dependencies: - '@probe.gl/env': 4.1.0 - - '@probe.gl/stats@4.1.0': {} - - '@rolldown/pluginutils@1.0.0-rc.3': {} - - '@rollup/rollup-android-arm-eabi@4.57.1': - optional: true - - '@rollup/rollup-android-arm64@4.57.1': - optional: true - - '@rollup/rollup-darwin-arm64@4.57.1': - optional: true - - '@rollup/rollup-darwin-x64@4.57.1': - optional: true - - '@rollup/rollup-freebsd-arm64@4.57.1': - optional: true - - '@rollup/rollup-freebsd-x64@4.57.1': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.57.1': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.57.1': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.57.1': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.57.1': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.57.1': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.57.1': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-x64-musl@4.57.1': - optional: true - - '@rollup/rollup-openbsd-x64@4.57.1': - optional: true - - '@rollup/rollup-openharmony-arm64@4.57.1': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.57.1': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.57.1': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.57.1': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.57.1': - optional: true - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/estree@1.0.8': {} - - '@types/geojson@7946.0.16': {} - - '@types/json-schema@7.0.15': {} - - '@types/node@24.10.13': - dependencies: - undici-types: 7.16.0 - - '@types/offscreencanvas@2019.7.3': {} - - '@types/react-dom@19.2.3(@types/react@19.2.14)': - dependencies: - '@types/react': 19.2.14 - - '@types/react@19.2.14': - dependencies: - csstype: 3.2.3 - - '@types/supercluster@7.1.3': - dependencies: - '@types/geojson': 7946.0.16 - - '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.55.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/utils': 8.55.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.55.0 - eslint: 9.39.2 - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.55.0(eslint@9.39.2)(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.55.0 - debug: 4.4.3 - eslint: 9.39.2 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.55.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) - '@typescript-eslint/types': 8.55.0 - debug: 4.4.3 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.55.0': - dependencies: - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/visitor-keys': 8.55.0 - - '@typescript-eslint/tsconfig-utils@8.55.0(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - - '@typescript-eslint/type-utils@8.55.0(eslint@9.39.2)(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.55.0(eslint@9.39.2)(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.39.2 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@8.55.0': {} - - '@typescript-eslint/typescript-estree@8.55.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.55.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/visitor-keys': 8.55.0 - debug: 4.4.3 - minimatch: 9.0.5 - semver: 7.7.4 - tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.55.0(eslint@9.39.2)(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) - eslint: 9.39.2 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.55.0': - dependencies: - '@typescript-eslint/types': 8.55.0 - eslint-visitor-keys: 4.2.1 - - '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.10.13)(tsx@4.21.0))': - dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) - '@rolldown/pluginutils': 1.0.0-rc.3 - '@types/babel__core': 7.20.5 - react-refresh: 0.18.0 - vite: 7.3.1(@types/node@24.10.13)(tsx@4.21.0) - transitivePeerDependencies: - - supports-color - - abstract-logging@2.0.1: {} - - acorn-jsx@5.3.2(acorn@8.15.0): - dependencies: - acorn: 8.15.0 - - acorn@8.15.0: {} - - adler-32@1.3.1: {} - - ajv-formats@3.0.1(ajv@8.17.1): - optionalDependencies: - ajv: 8.17.1 - - ajv@6.12.6: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - ajv@8.17.1: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - argparse@2.0.1: {} - - atomic-sleep@1.0.0: {} - - avvio@9.2.0: - dependencies: - '@fastify/error': 4.2.0 - fastq: 1.20.1 - - balanced-match@1.0.2: {} - - baseline-browser-mapping@2.9.19: {} - - brace-expansion@1.1.12: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.0.2: - dependencies: - balanced-match: 1.0.2 - - browserslist@4.28.1: - dependencies: - baseline-browser-mapping: 2.9.19 - caniuse-lite: 1.0.30001769 - electron-to-chromium: 1.5.286 - node-releases: 2.0.27 - update-browserslist-db: 1.2.3(browserslist@4.28.1) - - callsites@3.1.0: {} - - caniuse-lite@1.0.30001769: {} - - cfb@1.2.2: - dependencies: - adler-32: 1.3.1 - crc-32: 1.2.2 - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - codepage@1.15.0: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - concat-map@0.0.1: {} - - convert-source-map@2.0.0: {} - - cookie@1.1.1: {} - - crc-32@1.2.2: {} - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - csstype@3.2.3: {} - - d3-hexbin@0.2.2: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - deep-is@0.1.4: {} - - dequal@2.0.3: {} - - earcut@2.2.4: {} - - earcut@3.0.2: {} - - electron-to-chromium@1.5.286: {} - - esbuild@0.27.3: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.3 - '@esbuild/android-arm': 0.27.3 - '@esbuild/android-arm64': 0.27.3 - '@esbuild/android-x64': 0.27.3 - '@esbuild/darwin-arm64': 0.27.3 - '@esbuild/darwin-x64': 0.27.3 - '@esbuild/freebsd-arm64': 0.27.3 - '@esbuild/freebsd-x64': 0.27.3 - '@esbuild/linux-arm': 0.27.3 - '@esbuild/linux-arm64': 0.27.3 - '@esbuild/linux-ia32': 0.27.3 - '@esbuild/linux-loong64': 0.27.3 - '@esbuild/linux-mips64el': 0.27.3 - '@esbuild/linux-ppc64': 0.27.3 - '@esbuild/linux-riscv64': 0.27.3 - '@esbuild/linux-s390x': 0.27.3 - '@esbuild/linux-x64': 0.27.3 - '@esbuild/netbsd-arm64': 0.27.3 - '@esbuild/netbsd-x64': 0.27.3 - '@esbuild/openbsd-arm64': 0.27.3 - '@esbuild/openbsd-x64': 0.27.3 - '@esbuild/openharmony-arm64': 0.27.3 - '@esbuild/sunos-x64': 0.27.3 - '@esbuild/win32-arm64': 0.27.3 - '@esbuild/win32-ia32': 0.27.3 - '@esbuild/win32-x64': 0.27.3 - - escalade@3.2.0: {} - - escape-string-regexp@4.0.0: {} - - eslint-plugin-react-hooks@7.0.1(eslint@9.39.2): - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.0 - eslint: 9.39.2 - hermes-parser: 0.25.1 - zod: 4.3.6 - zod-validation-error: 4.0.2(zod@4.3.6) - transitivePeerDependencies: - - supports-color - - eslint-plugin-react-refresh@0.4.26(eslint@9.39.2): - dependencies: - eslint: 9.39.2 - - eslint-scope@8.4.0: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.2.1: {} - - eslint@9.39.2: - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.3 - '@eslint/js': 9.39.2 - '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.7 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.12.6 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.2 - natural-compare: 1.4.0 - optionator: 0.9.4 - transitivePeerDependencies: - - supports-color - - espree@10.4.0: - dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) - eslint-visitor-keys: 4.2.1 - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - - esutils@2.0.3: {} - - fast-decode-uri-component@1.0.1: {} - - fast-deep-equal@3.1.3: {} - - fast-json-stable-stringify@2.1.0: {} - - fast-json-stringify@6.3.0: - dependencies: - '@fastify/merge-json-schemas': 0.2.1 - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - fast-uri: 3.1.0 - json-schema-ref-resolver: 3.0.0 - rfdc: 1.4.1 - - fast-levenshtein@2.0.6: {} - - fast-querystring@1.1.2: - dependencies: - fast-decode-uri-component: 1.0.1 - - fast-uri@3.1.0: {} - - fastify-plugin@5.1.0: {} - - fastify@5.7.4: - dependencies: - '@fastify/ajv-compiler': 4.0.5 - '@fastify/error': 4.2.0 - '@fastify/fast-json-stringify-compiler': 5.0.3 - '@fastify/proxy-addr': 5.1.0 - abstract-logging: 2.0.1 - avvio: 9.2.0 - fast-json-stringify: 6.3.0 - find-my-way: 9.4.0 - light-my-request: 6.6.0 - pino: 10.3.1 - process-warning: 5.0.0 - rfdc: 1.4.1 - secure-json-parse: 4.1.0 - semver: 7.7.4 - toad-cache: 3.7.0 - - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - - find-my-way@9.4.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-querystring: 1.1.2 - safe-regex2: 5.0.0 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@4.0.1: - dependencies: - flatted: 3.3.3 - keyv: 4.5.4 - - flatted@3.3.3: {} - - frac@1.1.2: {} - - fsevents@2.3.3: - optional: true - - gensync@1.0.0-beta.2: {} - - get-stream@6.0.1: {} - - get-tsconfig@4.13.6: - dependencies: - resolve-pkg-maps: 1.0.0 - - gl-matrix@3.4.4: {} - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - globals@14.0.0: {} - - globals@16.5.0: {} - - has-flag@4.0.0: {} - - hermes-estree@0.25.1: {} - - hermes-parser@0.25.1: - dependencies: - hermes-estree: 0.25.1 - - ignore@5.3.2: {} - - ignore@7.0.5: {} - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - imurmurhash@0.1.4: {} - - ipaddr.js@2.3.0: {} - - is-extglob@2.1.1: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - isexe@2.0.0: {} - - js-tokens@4.0.0: {} - - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - - jsesc@3.1.0: {} - - json-buffer@3.0.1: {} - - json-schema-ref-resolver@3.0.0: - dependencies: - dequal: 2.0.3 - - json-schema-traverse@0.4.1: {} - - json-schema-traverse@1.0.0: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - json-stringify-pretty-compact@4.0.0: {} - - json5@2.2.3: {} - - kdbush@4.0.2: {} - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - light-my-request@6.6.0: - dependencies: - cookie: 1.1.1 - process-warning: 4.0.1 - set-cookie-parser: 2.7.2 - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lodash.merge@4.6.2: {} - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - maplibre-gl@5.18.0: - dependencies: - '@mapbox/geojson-rewind': 0.5.2 - '@mapbox/jsonlint-lines-primitives': 2.0.2 - '@mapbox/point-geometry': 1.1.0 - '@mapbox/tiny-sdf': 2.0.7 - '@mapbox/unitbezier': 0.0.1 - '@mapbox/vector-tile': 2.0.4 - '@mapbox/whoots-js': 3.1.0 - '@maplibre/geojson-vt': 5.0.4 - '@maplibre/maplibre-gl-style-spec': 24.4.1 - '@maplibre/mlt': 1.1.6 - '@maplibre/vt-pbf': 4.2.1 - '@types/geojson': 7946.0.16 - '@types/supercluster': 7.1.3 - earcut: 3.0.2 - gl-matrix: 3.4.4 - kdbush: 4.0.2 - murmurhash-js: 1.0.0 - pbf: 4.0.1 - potpack: 2.1.0 - quickselect: 3.0.0 - supercluster: 8.0.1 - tinyqueue: 3.0.0 - - minimatch@3.1.2: - dependencies: - brace-expansion: 1.1.12 - - minimatch@9.0.5: - dependencies: - brace-expansion: 2.0.2 - - minimist@1.2.8: {} - - mjolnir.js@3.0.0: {} - - ms@2.1.3: {} - - murmurhash-js@1.0.0: {} - - nanoid@3.3.11: {} - - natural-compare@1.4.0: {} - - node-releases@2.0.27: {} - - on-exit-leak-free@2.1.2: {} - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - - path-exists@4.0.0: {} - - path-key@3.1.1: {} - - pbf@4.0.1: - dependencies: - resolve-protobuf-schema: 2.1.0 - - picocolors@1.1.1: {} - - picomatch@4.0.3: {} - - pino-abstract-transport@3.0.0: - dependencies: - split2: 4.2.0 - - pino-std-serializers@7.1.0: {} - - pino@10.3.1: - dependencies: - '@pinojs/redact': 0.4.0 - atomic-sleep: 1.0.0 - on-exit-leak-free: 2.1.2 - pino-abstract-transport: 3.0.0 - pino-std-serializers: 7.1.0 - process-warning: 5.0.0 - quick-format-unescaped: 4.0.4 - real-require: 0.2.0 - safe-stable-stringify: 2.5.0 - sonic-boom: 4.2.1 - thread-stream: 4.0.0 - - postcss@8.5.6: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - potpack@2.1.0: {} - - prelude-ls@1.2.1: {} - - process-warning@4.0.1: {} - - process-warning@5.0.0: {} - - protocol-buffers-schema@3.6.0: {} - - punycode@2.3.1: {} - - quick-format-unescaped@4.0.4: {} - - quickselect@3.0.0: {} - - react-dom@19.2.4(react@19.2.4): - dependencies: - react: 19.2.4 - scheduler: 0.27.0 - - react-refresh@0.18.0: {} - - react@19.2.4: {} - - real-require@0.2.0: {} - - require-from-string@2.0.2: {} - - resolve-from@4.0.0: {} - - resolve-pkg-maps@1.0.0: {} - - resolve-protobuf-schema@2.1.0: - dependencies: - protocol-buffers-schema: 3.6.0 - - ret@0.5.0: {} - - reusify@1.1.0: {} - - rfdc@1.4.1: {} - - rollup@4.57.1: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.57.1 - '@rollup/rollup-android-arm64': 4.57.1 - '@rollup/rollup-darwin-arm64': 4.57.1 - '@rollup/rollup-darwin-x64': 4.57.1 - '@rollup/rollup-freebsd-arm64': 4.57.1 - '@rollup/rollup-freebsd-x64': 4.57.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 - '@rollup/rollup-linux-arm-musleabihf': 4.57.1 - '@rollup/rollup-linux-arm64-gnu': 4.57.1 - '@rollup/rollup-linux-arm64-musl': 4.57.1 - '@rollup/rollup-linux-loong64-gnu': 4.57.1 - '@rollup/rollup-linux-loong64-musl': 4.57.1 - '@rollup/rollup-linux-ppc64-gnu': 4.57.1 - '@rollup/rollup-linux-ppc64-musl': 4.57.1 - '@rollup/rollup-linux-riscv64-gnu': 4.57.1 - '@rollup/rollup-linux-riscv64-musl': 4.57.1 - '@rollup/rollup-linux-s390x-gnu': 4.57.1 - '@rollup/rollup-linux-x64-gnu': 4.57.1 - '@rollup/rollup-linux-x64-musl': 4.57.1 - '@rollup/rollup-openbsd-x64': 4.57.1 - '@rollup/rollup-openharmony-arm64': 4.57.1 - '@rollup/rollup-win32-arm64-msvc': 4.57.1 - '@rollup/rollup-win32-ia32-msvc': 4.57.1 - '@rollup/rollup-win32-x64-gnu': 4.57.1 - '@rollup/rollup-win32-x64-msvc': 4.57.1 - fsevents: 2.3.3 - - rw@1.3.3: {} - - safe-regex2@5.0.0: - dependencies: - ret: 0.5.0 - - safe-stable-stringify@2.5.0: {} - - scheduler@0.27.0: {} - - secure-json-parse@4.1.0: {} - - semver@6.3.1: {} - - semver@7.7.4: {} - - set-cookie-parser@2.7.2: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - sonic-boom@4.2.1: - dependencies: - atomic-sleep: 1.0.0 - - source-map-js@1.2.1: {} - - split2@4.2.0: {} - - ssf@0.11.2: - dependencies: - frac: 1.1.2 - - strip-json-comments@3.1.1: {} - - supercluster@8.0.1: - dependencies: - kdbush: 4.0.2 - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - thread-stream@4.0.0: - dependencies: - real-require: 0.2.0 - - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - - tinyqueue@3.0.0: {} - - toad-cache@3.7.0: {} - - ts-api-utils@2.4.0(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - - tsx@4.21.0: - dependencies: - esbuild: 0.27.3 - get-tsconfig: 4.13.6 - optionalDependencies: - fsevents: 2.3.3 - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - typescript-eslint@8.55.0(eslint@9.39.2)(typescript@5.9.3): - dependencies: - '@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/parser': 8.55.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.55.0(eslint@9.39.2)(typescript@5.9.3) - eslint: 9.39.2 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - typescript@5.9.3: {} - - undici-types@7.16.0: {} - - update-browserslist-db@1.2.3(browserslist@4.28.1): - dependencies: - browserslist: 4.28.1 - escalade: 3.2.0 - picocolors: 1.1.1 - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - vite@7.3.1(@types/node@24.10.13)(tsx@4.21.0): - dependencies: - esbuild: 0.27.3 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.57.1 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 24.10.13 - fsevents: 2.3.3 - tsx: 4.21.0 - - wgsl_reflect@1.2.3: {} - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - wmf@1.0.2: {} - - word-wrap@1.2.5: {} - - word@0.3.0: {} - - xlsx@0.18.5: - dependencies: - adler-32: 1.3.1 - cfb: 1.2.2 - codepage: 1.15.0 - crc-32: 1.2.2 - ssf: 0.11.2 - wmf: 1.0.2 - word: 0.3.0 - - yallist@3.1.1: {} - - yocto-queue@0.1.0: {} - - zod-validation-error@4.0.2(zod@4.3.6): - dependencies: - zod: 4.3.6 - - zod@4.3.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml deleted file mode 100644 index c263bee..0000000 --- a/pnpm-workspace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -packages: - - "apps/*" - - "packages/*" - From 324c6267f0c9a9c7c301f18f5285d769ea54151a Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 23:57:38 +0900 Subject: [PATCH 43/58] =?UTF-8?q?refactor(map):=20Map3D=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EB=B2=84=EA=B7=B8?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/public/map/styles/carto-dark.json | 1 + apps/web/public/map/styles/osm-seamark.json | 1 + apps/web/src/widgets/map3d/Map3D.tsx | 1480 ++--------------- apps/web/src/widgets/map3d/constants.ts | 158 ++ .../src/widgets/map3d/hooks/useHoverState.ts | 66 + .../src/widgets/map3d/layers/bathymetry.ts | 292 ++++ apps/web/src/widgets/map3d/layers/seamark.ts | 27 + apps/web/src/widgets/map3d/lib/dashifyLine.ts | 31 + apps/web/src/widgets/map3d/lib/featureIds.ts | 19 + apps/web/src/widgets/map3d/lib/geometry.ts | 62 + .../src/widgets/map3d/lib/globeShipIcon.ts | 76 + .../web/src/widgets/map3d/lib/layerHelpers.ts | 62 + apps/web/src/widgets/map3d/lib/mapCore.ts | 124 ++ .../src/widgets/map3d/lib/mlExpressions.ts | 65 + apps/web/src/widgets/map3d/lib/setUtils.ts | 79 + apps/web/src/widgets/map3d/lib/shipUtils.ts | 117 ++ apps/web/src/widgets/map3d/lib/tooltips.ts | 169 ++ apps/web/src/widgets/map3d/lib/zoneUtils.ts | 40 + apps/web/src/widgets/map3d/types.ts | 72 + 19 files changed, 1604 insertions(+), 1337 deletions(-) create mode 100644 apps/web/src/widgets/map3d/constants.ts create mode 100644 apps/web/src/widgets/map3d/hooks/useHoverState.ts create mode 100644 apps/web/src/widgets/map3d/layers/bathymetry.ts create mode 100644 apps/web/src/widgets/map3d/layers/seamark.ts create mode 100644 apps/web/src/widgets/map3d/lib/dashifyLine.ts create mode 100644 apps/web/src/widgets/map3d/lib/featureIds.ts create mode 100644 apps/web/src/widgets/map3d/lib/geometry.ts create mode 100644 apps/web/src/widgets/map3d/lib/globeShipIcon.ts create mode 100644 apps/web/src/widgets/map3d/lib/layerHelpers.ts create mode 100644 apps/web/src/widgets/map3d/lib/mapCore.ts create mode 100644 apps/web/src/widgets/map3d/lib/mlExpressions.ts create mode 100644 apps/web/src/widgets/map3d/lib/setUtils.ts create mode 100644 apps/web/src/widgets/map3d/lib/shipUtils.ts create mode 100644 apps/web/src/widgets/map3d/lib/tooltips.ts create mode 100644 apps/web/src/widgets/map3d/lib/zoneUtils.ts create mode 100644 apps/web/src/widgets/map3d/types.ts diff --git a/apps/web/public/map/styles/carto-dark.json b/apps/web/public/map/styles/carto-dark.json index f93fcb0..51aa7ba 100644 --- a/apps/web/public/map/styles/carto-dark.json +++ b/apps/web/public/map/styles/carto-dark.json @@ -1,6 +1,7 @@ { "version": 8, "name": "CARTO Dark (Legacy)", + "glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf", "sources": { "carto-dark": { "type": "raster", diff --git a/apps/web/public/map/styles/osm-seamark.json b/apps/web/public/map/styles/osm-seamark.json index c6764e8..eacb8d8 100644 --- a/apps/web/public/map/styles/osm-seamark.json +++ b/apps/web/public/map/styles/osm-seamark.json @@ -1,6 +1,7 @@ { "version": 8, "name": "OSM Raster + OpenSeaMap", + "glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf", "sources": { "osm": { "type": "raster", diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 6ca1b8d..1064450 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -7,1316 +7,117 @@ import maplibregl, { type GeoJSONSourceSpecification, type LayerSpecification, type StyleSpecification, - type VectorSourceSpecification, } from "maplibre-gl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AisTarget } from "../../entities/aisTarget/model/types"; -import type { LegacyVesselInfo } from "../../entities/legacyVessel/model/types"; -import type { ZonesGeoJson } from "../../entities/zone/api/useZones"; import type { ZoneId } from "../../entities/zone/model/meta"; import { ZONE_META } from "../../entities/zone/model/meta"; -import type { MapToggleState } from "../../features/mapToggles/MapToggles"; -import type { FcLink, FleetCircle, PairLink } from "../../features/legacyDashboard/model/types"; -import { LEGACY_CODE_COLORS_RGB, OTHER_AIS_SPEED_RGB, OVERLAY_RGB, rgba as rgbaCss } from "../../shared/lib/map/palette"; +import type { FleetCircle, PairLink } from "../../features/legacyDashboard/model/types"; +import { LEGACY_CODE_COLORS_RGB, OTHER_AIS_SPEED_RGB, rgba as rgbaCss } from "../../shared/lib/map/palette"; import { MaplibreDeckCustomLayer } from "./MaplibreDeckCustomLayer"; +import type { BaseMapId, Map3DProps, MapProjectionId } from "./types"; +import type { DashSeg, PairRangeCircle } from "./types"; +import { + SHIP_ICON_MAPPING, + ANCHORED_SHIP_ICON_ID, + DEG2RAD, + GLOBE_ICON_HEADING_OFFSET_DEG, + FLAT_SHIP_ICON_SIZE, + FLAT_SHIP_ICON_SIZE_SELECTED, + FLAT_SHIP_ICON_SIZE_HIGHLIGHTED, + FLAT_LEGACY_HALO_RADIUS, + FLAT_LEGACY_HALO_RADIUS_SELECTED, + FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED, + EMPTY_MMSI_SET, + DECK_VIEW_ID, + DEPTH_DISABLED_PARAMS, + GLOBE_OVERLAY_PARAMS, + LEGACY_CODE_COLORS, + PAIR_RANGE_NORMAL_DECK, + PAIR_RANGE_WARN_DECK, + PAIR_LINE_NORMAL_DECK, + PAIR_LINE_WARN_DECK, + FC_LINE_NORMAL_DECK, + FC_LINE_SUSPICIOUS_DECK, + FLEET_RANGE_LINE_DECK, + FLEET_RANGE_FILL_DECK, + PAIR_RANGE_NORMAL_DECK_HL, + PAIR_RANGE_WARN_DECK_HL, + PAIR_LINE_NORMAL_DECK_HL, + PAIR_LINE_WARN_DECK_HL, + FC_LINE_NORMAL_DECK_HL, + FC_LINE_SUSPICIOUS_DECK_HL, + FLEET_RANGE_LINE_DECK_HL, + FLEET_RANGE_FILL_DECK_HL, + PAIR_LINE_NORMAL_ML, + PAIR_LINE_WARN_ML, + PAIR_LINE_NORMAL_ML_HL, + PAIR_LINE_WARN_ML_HL, + PAIR_RANGE_NORMAL_ML, + PAIR_RANGE_WARN_ML, + PAIR_RANGE_NORMAL_ML_HL, + PAIR_RANGE_WARN_ML_HL, + FC_LINE_NORMAL_ML, + FC_LINE_SUSPICIOUS_ML, + FC_LINE_NORMAL_ML_HL, + FC_LINE_SUSPICIOUS_ML_HL, + FLEET_FILL_ML, + FLEET_FILL_ML_HL, + FLEET_LINE_ML, + FLEET_LINE_ML_HL, +} from "./constants"; +import { + mergeNumberSets, + makeSetSignature, + isFiniteNumber, + toSafeNumber, + toIntMmsi, + makeUniqueSorted, + equalNumberArrays, +} from "./lib/setUtils"; +import { getZoneIdFromProps, getZoneDisplayNameFromProps } from "./lib/zoneUtils"; +import { + makePairLinkFeatureId, + makeFcSegmentFeatureId, + makeFleetCircleFeatureId, +} from "./lib/featureIds"; +import { + makeMmsiPairHighlightExpr, + makeMmsiAnyEndpointExpr, + makeFleetOwnerMatchExpr, + makeFleetMemberMatchExpr, + GLOBE_SHIP_CIRCLE_RADIUS_EXPR, +} from "./lib/mlExpressions"; +import { + toValidBearingDeg, + isAnchoredShip, + getDisplayHeading, + lightenColor, + getGlobeBaseShipColor, + getShipColor, +} from "./lib/shipUtils"; +import { + getShipTooltipHtml, + getPairLinkTooltipHtml, + getFcLinkTooltipHtml, + getRangeTooltipHtml, + getFleetCircleTooltipHtml, +} from "./lib/tooltips"; +import { + buildFallbackGlobeAnchoredShipIcon, + ensureFallbackShipImage, +} from "./lib/globeShipIcon"; +import { kickRepaint, onMapStyleReady, extractProjectionType, getLayerId, sanitizeDeckLayerList } from "./lib/mapCore"; +import { destinationPointLngLat, circleRingLngLat, clampNumber } from "./lib/geometry"; +import { dashifyLine } from "./lib/dashifyLine"; +import { ensureSeamarkOverlay } from "./layers/seamark"; +import { applyBathymetryZoomProfile, resolveMapStyle } from "./layers/bathymetry"; +import { useHoverState } from "./hooks/useHoverState"; + +export type { Map3DSettings, BaseMapId, MapProjectionId } from "./types"; + +type Props = Map3DProps; -export type Map3DSettings = { - showSeamark: boolean; - showShips: boolean; - showDensity: boolean; -}; - -export type BaseMapId = "enhanced" | "legacy"; -export type MapProjectionId = "mercator" | "globe"; - -type Props = { - targets: AisTarget[]; - zones: ZonesGeoJson | null; - selectedMmsi: number | null; - hoveredMmsiSet?: number[]; - hoveredFleetMmsiSet?: number[]; - hoveredPairMmsiSet?: number[]; - hoveredFleetOwnerKey?: string | null; - highlightedMmsiSet?: number[]; - settings: Map3DSettings; - baseMap: BaseMapId; - projection: MapProjectionId; - overlays: MapToggleState; - onSelectMmsi: (mmsi: number | null) => void; - onToggleHighlightMmsi?: (mmsi: number) => void; - onViewBboxChange?: (bbox: [number, number, number, number]) => void; - legacyHits?: Map | null; - pairLinks?: PairLink[]; - fcLinks?: FcLink[]; - fleetCircles?: FleetCircle[]; - onProjectionLoadingChange?: (loading: boolean) => void; - fleetFocus?: { - id: string | number; - center: [number, number]; - zoom?: number; - }; - onHoverFleet?: (ownerKey: string | null, fleetMmsiSet: number[]) => void; - onClearFleetHover?: () => void; - onHoverMmsi?: (mmsiList: number[]) => void; - onClearMmsiHover?: () => void; - onHoverPair?: (mmsiList: number[]) => void; - onClearPairHover?: () => void; -}; - -function toNumberSet(values: number[] | undefined | null) { - const out = new Set(); - if (!values) return out; - for (const value of values) { - if (Number.isFinite(value)) { - out.add(value); - } - } - return out; -} - -function mergeNumberSets(...sets: Set[]) { - const out = new Set(); - for (const s of sets) { - for (const v of s) { - out.add(v); - } - } - return out; -} - -function makeSetSignature(values: Set) { - return Array.from(values).sort((a, b) => a - b).join(","); -} - -function toTextValue(value: unknown): string { - if (value == null) return ""; - return String(value).trim(); -} - -function getZoneIdFromProps(props: Record | null | undefined): string { - const safeProps = props || {}; - const candidates = [ - "zoneId", - "zone_id", - "zoneIdNo", - "zoneKey", - "zoneCode", - "ZONE_ID", - "ZONECODE", - "id", - ]; - - for (const key of candidates) { - const value = toTextValue(safeProps[key]); - if (value) return value; - } - - return ""; -} - -function getZoneDisplayNameFromProps(props: Record | null | undefined): string { - const safeProps = props || {}; - const nameCandidates = ["zoneName", "zoneLabel", "NAME", "name", "ZONE_NM", "label"]; - for (const key of nameCandidates) { - const name = toTextValue(safeProps[key]); - if (name) return name; - } - const zoneId = getZoneIdFromProps(safeProps); - if (!zoneId) return "수역"; - return ZONE_META[(zoneId as ZoneId)]?.name || `수역 ${zoneId}`; -} - -function makeOrderedPairKey(a: number, b: number) { - const left = Math.trunc(Math.min(a, b)); - const right = Math.trunc(Math.max(a, b)); - return `${left}-${right}`; -} - -function makePairLinkFeatureId(a: number, b: number, suffix?: string) { - const pair = makeOrderedPairKey(a, b); - return suffix ? `pair-${pair}-${suffix}` : `pair-${pair}`; -} - -function makeFcSegmentFeatureId(a: number, b: number, segmentIndex: number) { - const pair = makeOrderedPairKey(a, b); - return `fc-${pair}-${segmentIndex}`; -} - -function makeFleetCircleFeatureId(ownerKey: string) { - return `fleet-${ownerKey}`; -} - -function makeMmsiPairHighlightExpr(aField: string, bField: string, hoveredMmsiList: number[]) { - if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length < 2) { - return false; - } - const inA = ["in", ["to-number", ["get", aField]], ["literal", hoveredMmsiList]] as unknown[]; - const inB = ["in", ["to-number", ["get", bField]], ["literal", hoveredMmsiList]] as unknown[]; - return ["all", inA, inB] as unknown[]; -} - -function makeMmsiAnyEndpointExpr(aField: string, bField: string, hoveredMmsiList: number[]) { - if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length === 0) { - return false; - } - const literal = ["literal", hoveredMmsiList] as unknown[]; - return [ - "any", - ["in", ["to-number", ["get", aField]], literal], - ["in", ["to-number", ["get", bField]], literal], - ] as unknown[]; -} - -function makeFleetOwnerMatchExpr(hoveredOwnerKeys: string[]) { - if (!Array.isArray(hoveredOwnerKeys) || hoveredOwnerKeys.length === 0) { - return false; - } - const expr = ["match", ["to-string", ["coalesce", ["get", "ownerKey"], ""]]] as unknown[]; - for (const ownerKey of hoveredOwnerKeys) { - expr.push(String(ownerKey), true); - } - expr.push(false); - return expr; -} - -function makeFleetMemberMatchExpr(hoveredFleetMmsiList: number[]) { - if (!Array.isArray(hoveredFleetMmsiList) || hoveredFleetMmsiList.length === 0) { - return false; - } - const clauses = hoveredFleetMmsiList.map((mmsi) => - ["in", mmsi, ["coalesce", ["get", "vesselMmsis"], ["literal", []]]] as unknown[], - ); - return ["any", ...clauses] as unknown[]; -} - -const SHIP_ICON_MAPPING = { - ship: { - x: 0, - y: 0, - width: 128, - height: 128, - anchorX: 64, - anchorY: 64, - mask: true, - }, -} as const; - -const ANCHOR_SPEED_THRESHOLD_KN = 1; -const ANCHORED_SHIP_ICON_ID = "ship-globe-anchored-icon"; - -function isFiniteNumber(x: unknown): x is number { - return typeof x === "number" && Number.isFinite(x); -} - -function isAnchoredShip({ - sog, - cog, - heading, -}: { - sog: number | null | undefined; - cog: number | null | undefined; - heading: number | null | undefined; -}): boolean { - if (!isFiniteNumber(sog)) return true; - if (sog <= ANCHOR_SPEED_THRESHOLD_KN) return true; - return toValidBearingDeg(cog) == null && toValidBearingDeg(heading) == null; -} - -function kickRepaint(map: maplibregl.Map | null) { - if (!map) return; - try { - map.triggerRepaint(); - } catch { - // ignore - } - try { - requestAnimationFrame(() => { - try { - map.triggerRepaint(); - } catch { - // ignore - } - }); - requestAnimationFrame(() => { - try { - map.triggerRepaint(); - } catch { - // ignore - } - }); - } catch { - // ignore (e.g., non-browser env) - } -} - -function onMapStyleReady(map: maplibregl.Map | null, callback: () => void) { - if (!map) { - return () => { - // noop - }; - } - if (map.isStyleLoaded()) { - callback(); - return () => { - // noop - }; - } - - let fired = false; - const runOnce = () => { - if (!map || fired || !map.isStyleLoaded()) return; - fired = true; - callback(); - try { - map.off("style.load", runOnce); - map.off("styledata", runOnce); - map.off("idle", runOnce); - } catch { - // ignore - } - }; - - map.on("style.load", runOnce); - map.on("styledata", runOnce); - map.on("idle", runOnce); - - return () => { - if (fired) return; - fired = true; - try { - if (!map) return; - map.off("style.load", runOnce); - map.off("styledata", runOnce); - map.off("idle", runOnce); - } catch { - // ignore - } - }; -} - -function extractProjectionType(map: maplibregl.Map): MapProjectionId | undefined { - const projection = map.getProjection?.(); - if (!projection || typeof projection !== "object") return undefined; - - const rawType = (projection as { type?: unknown; name?: unknown }).type ?? (projection as { type?: unknown; name?: unknown }).name; - if (rawType === "globe") return "globe"; - if (rawType === "mercator") return "mercator"; - return undefined; -} - -const DEG2RAD = Math.PI / 180; -const RAD2DEG = 180 / Math.PI; -// ship.svg's native "up" direction is north (0deg), -// so map icon rotation can use COG directly. -const GLOBE_ICON_HEADING_OFFSET_DEG = 0; -const MAP_SELECTED_SHIP_RGB: [number, number, number] = [34, 211, 238]; -const MAP_HIGHLIGHT_SHIP_RGB: [number, number, number] = [245, 158, 11]; -const MAP_DEFAULT_SHIP_RGB: [number, number, number] = [100, 116, 139]; - -const clampNumber = (value: number, minValue: number, maxValue: number) => Math.max(minValue, Math.min(maxValue, value)); - -function getLayerId(value: unknown): string | null { - if (!value || typeof value !== "object") return null; - const candidate = (value as { id?: unknown }).id; - return typeof candidate === "string" ? candidate : null; -} - -function wrapLonDeg(lon: number) { - // Normalize longitude into [-180, 180). - const v = ((lon + 180) % 360 + 360) % 360; - return v - 180; -} - -function destinationPointLngLat( - from: [number, number], // [lon, lat] - bearingDeg: number, - distanceMeters: number, -): [number, number] { - const [lonDeg, latDeg] = from; - const lat1 = latDeg * DEG2RAD; - const lon1 = lonDeg * DEG2RAD; - const brng = bearingDeg * DEG2RAD; - const dr = Math.max(0, distanceMeters) / EARTH_RADIUS_M; - if (!Number.isFinite(dr) || dr === 0) return [lonDeg, latDeg]; - - const sinLat1 = Math.sin(lat1); - const cosLat1 = Math.cos(lat1); - const sinDr = Math.sin(dr); - const cosDr = Math.cos(dr); - - const lat2 = Math.asin(sinLat1 * cosDr + cosLat1 * sinDr * Math.cos(brng)); - const lon2 = - lon1 + - Math.atan2( - Math.sin(brng) * sinDr * cosLat1, - cosDr - sinLat1 * Math.sin(lat2), - ); - - const outLon = wrapLonDeg(lon2 * RAD2DEG); - const outLat = clampNumber(lat2 * RAD2DEG, -85.0, 85.0); - return [outLon, outLat]; -} - -function sanitizeDeckLayerList(value: unknown): unknown[] { - if (!Array.isArray(value)) return []; - const seen = new Set(); - const out: unknown[] = []; - let dropped = 0; - - for (const layer of value) { - const layerId = getLayerId(layer); - if (!layerId) { - dropped += 1; - continue; - } - if (seen.has(layerId)) { - dropped += 1; - continue; - } - seen.add(layerId); - out.push(layer); - } - - if (dropped > 0 && import.meta.env.DEV) { - console.warn(`Sanitized deck layer list: dropped ${dropped} invalid/duplicate entries`); - } - - return out; -} - -function normalizeAngleDeg(value: number, offset = 0): number { - const v = value + offset; - return ((v % 360) + 360) % 360; -} - -function toValidBearingDeg(value: unknown): number | null { - if (typeof value !== "number" || !Number.isFinite(value)) return null; - // AIS heading uses 511 as "not available". Some feeds may also use 360 as "not available". - if (value === 511) return null; - if (value < 0) return null; - if (value >= 360) return null; - return value; -} - -function getDisplayHeading({ - cog, - heading, - offset = 0, -}: { - cog: number | null | undefined; - heading: number | null | undefined; - offset?: number; -}) { - // Use COG (0=N, 90=E...) as the primary bearing so ship icons + prediction vectors stay aligned. - const raw = toValidBearingDeg(cog) ?? toValidBearingDeg(heading) ?? 0; - return normalizeAngleDeg(raw, offset); -} - -function toSafeNumber(value: unknown): number | null { - if (typeof value === "number" && Number.isFinite(value)) return value; - return null; -} - -function toIntMmsi(value: unknown): number | null { - const n = toSafeNumber(value); - if (n == null) return null; - return Math.trunc(n); -} - -function formatNm(value: number | null | undefined) { - if (!isFiniteNumber(value)) return "-"; - return `${value.toFixed(2)} NM`; -} - -function getLegacyTag(legacyHits: Map | null | undefined, mmsi: number) { - const legacy = legacyHits?.get(mmsi); - if (!legacy) return null; - return `${legacy.permitNo} (${legacy.shipCode})`; -} - -function getTargetName(mmsi: number, targetByMmsi: Map, legacyHits: Map | null | undefined) { - const legacy = legacyHits?.get(mmsi); - const target = targetByMmsi.get(mmsi); - return ( - (target?.name || "").trim() || legacy?.shipNameCn || legacy?.shipNameRoman || `MMSI ${mmsi}` - ); -} - -function getShipTooltipHtml({ - mmsi, - targetByMmsi, - legacyHits, -}: { - mmsi: number; - targetByMmsi: Map; - legacyHits: Map | null | undefined; -}) { - const legacy = legacyHits?.get(mmsi); - const t = targetByMmsi.get(mmsi); - const name = getTargetName(mmsi, targetByMmsi, legacyHits); - const sog = isFiniteNumber(t?.sog) ? t.sog : null; - const cog = isFiniteNumber(t?.cog) ? t.cog : null; - const msg = t?.messageTimestamp ?? null; - const vesselType = t?.vesselType || ""; - - const legacyHtml = legacy - ? `
-
CN Permit · ${legacy.shipCode} · ${legacy.permitNo}
-
유효범위: ${legacy.workSeaArea || "-"}
-
` - : ""; - - return { - html: `
-
${name}
-
MMSI: ${mmsi}${vesselType ? ` · ${vesselType}` : ""}
-
SOG: ${sog ?? "?"} kt · COG: ${cog ?? "?"}°
- ${msg ? `
${msg}
` : ""} - ${legacyHtml} -
`, - }; -} - -function getPairLinkTooltipHtml({ - warn, - distanceNm, - aMmsi, - bMmsi, - legacyHits, - targetByMmsi, -}: { - warn: boolean; - distanceNm: number | null | undefined; - aMmsi: number; - bMmsi: number; - legacyHits: Map | null | undefined; - targetByMmsi: Map; -}) { - const d = formatNm(distanceNm); - const a = getTargetName(aMmsi, targetByMmsi, legacyHits); - const b = getTargetName(bMmsi, targetByMmsi, legacyHits); - const aTag = getLegacyTag(legacyHits, aMmsi); - const bTag = getLegacyTag(legacyHits, bMmsi); - return { - html: `
-
쌍 연결
-
${aTag ?? `MMSI ${aMmsi}`}
-
↔ ${bTag ?? `MMSI ${bMmsi}`}
-
거리: ${d} · 상태: ${warn ? "주의" : "정상"}
-
${a} / ${b}
-
`, - }; -} - -function getFcLinkTooltipHtml({ - suspicious, - distanceNm, - fcMmsi, - otherMmsi, - legacyHits, - targetByMmsi, -}: { - suspicious: boolean; - distanceNm: number | null | undefined; - fcMmsi: number; - otherMmsi: number; - legacyHits: Map | null | undefined; - targetByMmsi: Map; -}) { - const d = formatNm(distanceNm); - const a = getTargetName(fcMmsi, targetByMmsi, legacyHits); - const b = getTargetName(otherMmsi, targetByMmsi, legacyHits); - const aTag = getLegacyTag(legacyHits, fcMmsi); - const bTag = getLegacyTag(legacyHits, otherMmsi); - return { - html: `
-
환적 연결
-
${aTag ?? `MMSI ${fcMmsi}`}
-
→ ${bTag ?? `MMSI ${otherMmsi}`}
-
거리: ${d} · 상태: ${suspicious ? "의심" : "일반"}
-
${a} / ${b}
-
`, - }; -} - -function getRangeTooltipHtml({ - warn, - distanceNm, - aMmsi, - bMmsi, - legacyHits, -}: { - warn: boolean; - distanceNm: number | null | undefined; - aMmsi: number; - bMmsi: number; - legacyHits: Map | null | undefined; -}) { - const d = formatNm(distanceNm); - const aTag = getLegacyTag(legacyHits, aMmsi); - const bTag = getLegacyTag(legacyHits, bMmsi); - const radiusNm = toSafeNumber(distanceNm); - return { - html: `
-
쌍 연결범위
-
${aTag ?? `MMSI ${aMmsi}`}
-
↔ ${bTag ?? `MMSI ${bMmsi}`}
-
범위: ${d} · 반경: ${formatNm(radiusNm == null ? null : radiusNm / 2)} · 상태: ${warn ? "주의" : "정상"}
-
`, - }; -} - -function getFleetCircleTooltipHtml({ - ownerKey, - ownerLabel, - count, -}: { - ownerKey: string; - ownerLabel?: string; - count: number; -}) { - const displayOwner = ownerLabel && ownerLabel.trim() ? ownerLabel : ownerKey; - return { - html: `
-
선단 범위
-
소유주: ${displayOwner || "-"}
-
선박 수: ${count}
-
`, - }; -} - -function rgbToHex(rgb: [number, number, number]) { - const toHex = (v: number) => { - const clamped = Math.max(0, Math.min(255, Math.round(v))); - return clamped.toString(16).padStart(2, "0"); - }; - - return `#${toHex(rgb[0])}${toHex(rgb[1])}${toHex(rgb[2])}`; -} - -function lightenColor(rgb: [number, number, number], ratio = 0.32) { - const out = rgb.map((v) => Math.round(v + (255 - v) * ratio) as number) as [number, number, number]; - return out; -} - -function getGlobeBaseShipColor({ - legacy, - sog, -}: { - legacy: string | null; - sog: number | null; -}) { - if (legacy) { - const rgb = LEGACY_CODE_COLORS[legacy]; - if (rgb) return rgbToHex(lightenColor(rgb, 0.38)); - } - - // Non-target AIS should be visible but muted so target vessels stand out. - // Encode speed mostly via brightness (not hue) to avoid clashing with target category colors. - if (!isFiniteNumber(sog)) return "rgba(100,116,139,0.55)"; - if (sog >= 10) return "rgba(148,163,184,0.78)"; - if (sog >= 1) return "rgba(100,116,139,0.74)"; - return "rgba(71,85,105,0.68)"; -} - -const LEGACY_CODE_COLORS = LEGACY_CODE_COLORS_RGB; - -const OVERLAY_PAIR_NORMAL_RGB = OVERLAY_RGB.pairNormal; -const OVERLAY_PAIR_WARN_RGB = OVERLAY_RGB.pairWarn; -const OVERLAY_FC_TRANSFER_RGB = OVERLAY_RGB.fcTransfer; -const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange; -const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious; - -// Deck.gl color constants (avoid per-object allocations inside accessors). -const PAIR_RANGE_NORMAL_DECK: [number, number, number, number] = [ - OVERLAY_PAIR_NORMAL_RGB[0], - OVERLAY_PAIR_NORMAL_RGB[1], - OVERLAY_PAIR_NORMAL_RGB[2], - 110, -]; -const PAIR_RANGE_WARN_DECK: [number, number, number, number] = [ - OVERLAY_PAIR_WARN_RGB[0], - OVERLAY_PAIR_WARN_RGB[1], - OVERLAY_PAIR_WARN_RGB[2], - 170, -]; -const PAIR_LINE_NORMAL_DECK: [number, number, number, number] = [ - OVERLAY_PAIR_NORMAL_RGB[0], - OVERLAY_PAIR_NORMAL_RGB[1], - OVERLAY_PAIR_NORMAL_RGB[2], - 85, -]; -const PAIR_LINE_WARN_DECK: [number, number, number, number] = [ - OVERLAY_PAIR_WARN_RGB[0], - OVERLAY_PAIR_WARN_RGB[1], - OVERLAY_PAIR_WARN_RGB[2], - 220, -]; -const FC_LINE_NORMAL_DECK: [number, number, number, number] = [ - OVERLAY_FC_TRANSFER_RGB[0], - OVERLAY_FC_TRANSFER_RGB[1], - OVERLAY_FC_TRANSFER_RGB[2], - 200, -]; -const FC_LINE_SUSPICIOUS_DECK: [number, number, number, number] = [ - OVERLAY_SUSPICIOUS_RGB[0], - OVERLAY_SUSPICIOUS_RGB[1], - OVERLAY_SUSPICIOUS_RGB[2], - 220, -]; -const FLEET_RANGE_LINE_DECK: [number, number, number, number] = [ - OVERLAY_FLEET_RANGE_RGB[0], - OVERLAY_FLEET_RANGE_RGB[1], - OVERLAY_FLEET_RANGE_RGB[2], - 140, -]; -const FLEET_RANGE_FILL_DECK: [number, number, number, number] = [ - OVERLAY_FLEET_RANGE_RGB[0], - OVERLAY_FLEET_RANGE_RGB[1], - OVERLAY_FLEET_RANGE_RGB[2], - 6, -]; - -const PAIR_RANGE_NORMAL_DECK_HL: [number, number, number, number] = [ - OVERLAY_PAIR_NORMAL_RGB[0], - OVERLAY_PAIR_NORMAL_RGB[1], - OVERLAY_PAIR_NORMAL_RGB[2], - 200, -]; -const PAIR_RANGE_WARN_DECK_HL: [number, number, number, number] = [ - OVERLAY_PAIR_WARN_RGB[0], - OVERLAY_PAIR_WARN_RGB[1], - OVERLAY_PAIR_WARN_RGB[2], - 240, -]; -const PAIR_LINE_NORMAL_DECK_HL: [number, number, number, number] = [ - OVERLAY_PAIR_NORMAL_RGB[0], - OVERLAY_PAIR_NORMAL_RGB[1], - OVERLAY_PAIR_NORMAL_RGB[2], - 245, -]; -const PAIR_LINE_WARN_DECK_HL: [number, number, number, number] = [ - OVERLAY_PAIR_WARN_RGB[0], - OVERLAY_PAIR_WARN_RGB[1], - OVERLAY_PAIR_WARN_RGB[2], - 245, -]; -const FC_LINE_NORMAL_DECK_HL: [number, number, number, number] = [ - OVERLAY_FC_TRANSFER_RGB[0], - OVERLAY_FC_TRANSFER_RGB[1], - OVERLAY_FC_TRANSFER_RGB[2], - 235, -]; -const FC_LINE_SUSPICIOUS_DECK_HL: [number, number, number, number] = [ - OVERLAY_SUSPICIOUS_RGB[0], - OVERLAY_SUSPICIOUS_RGB[1], - OVERLAY_SUSPICIOUS_RGB[2], - 245, -]; -const FLEET_RANGE_LINE_DECK_HL: [number, number, number, number] = [ - OVERLAY_FLEET_RANGE_RGB[0], - OVERLAY_FLEET_RANGE_RGB[1], - OVERLAY_FLEET_RANGE_RGB[2], - 220, -]; -const FLEET_RANGE_FILL_DECK_HL: [number, number, number, number] = [ - OVERLAY_FLEET_RANGE_RGB[0], - OVERLAY_FLEET_RANGE_RGB[1], - OVERLAY_FLEET_RANGE_RGB[2], - 42, -]; - -// MapLibre overlay colors. -const PAIR_LINE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.55); -const PAIR_LINE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.95); -const PAIR_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.95); -const PAIR_LINE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.98); - -const PAIR_RANGE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.45); -const PAIR_RANGE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.75); -const PAIR_RANGE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.92); -const PAIR_RANGE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.92); - -const FC_LINE_NORMAL_ML = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.92); -const FC_LINE_SUSPICIOUS_ML = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.95); -const FC_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.98); -const FC_LINE_SUSPICIOUS_ML_HL = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.98); - -const FLEET_FILL_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.02); -const FLEET_FILL_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.16); -const FLEET_LINE_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.65); -const FLEET_LINE_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95); - -const DEPTH_DISABLED_PARAMS = { - // In MapLibre+Deck (interleaved), the map often leaves a depth buffer populated. - // For 2D overlays like zones/icons/halos we want stable painter's-order rendering - // to avoid z-fighting flicker when layers overlap at (or near) the same z. - depthCompare: "always", - depthWriteEnabled: false, -} as const; - -const FLAT_SHIP_ICON_SIZE = 19; -const FLAT_SHIP_ICON_SIZE_SELECTED = 28; -const FLAT_SHIP_ICON_SIZE_HIGHLIGHTED = 25; -const FLAT_LEGACY_HALO_RADIUS = 14; -const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18; -const FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED = 16; -const EMPTY_MMSI_SET = new Set(); - -const GLOBE_OVERLAY_PARAMS = { - // In globe mode we want depth-testing against the globe so features on the far side don't draw through. - // Still disable depth writes so our overlays don't interfere with each other. - depthCompare: "less-equal", - depthWriteEnabled: false, -} as const; - -function makeGlobeCircleRadiusExpr() { - const base3 = 4; - const base7 = 6; - const base10 = 8; - const base14 = 11; - - return [ - "interpolate", - ["linear"], - ["zoom"], - 3, - ["case", ["==", ["get", "selected"], 1], 4.6, ["==", ["get", "highlighted"], 1], 4.2, base3], - 7, - ["case", ["==", ["get", "selected"], 1], 6.8, ["==", ["get", "highlighted"], 1], 6.2, base7], - 10, - ["case", ["==", ["get", "selected"], 1], 9.0, ["==", ["get", "highlighted"], 1], 8.2, base10], - 14, - ["case", ["==", ["get", "selected"], 1], 11.8, ["==", ["get", "highlighted"], 1], 10.8, base14], - ]; -} - -const GLOBE_SHIP_CIRCLE_RADIUS_EXPR = makeGlobeCircleRadiusExpr() as never; - -function buildFallbackGlobeShipIcon() { - const size = 96; - const canvas = document.createElement("canvas"); - canvas.width = size; - canvas.height = size; - const ctx = canvas.getContext("2d"); - if (!ctx) return null; - - ctx.clearRect(0, 0, size, size); - ctx.fillStyle = "rgba(255,255,255,1)"; - ctx.beginPath(); - ctx.moveTo(size / 2, 6); - ctx.lineTo(size / 2 - 14, 24); - ctx.lineTo(size / 2 - 18, 58); - ctx.lineTo(size / 2 - 10, 88); - ctx.lineTo(size / 2 + 10, 88); - ctx.lineTo(size / 2 + 18, 58); - ctx.lineTo(size / 2 + 14, 24); - ctx.closePath(); - ctx.fill(); - - ctx.fillRect(size / 2 - 8, 34, 16, 18); - - return ctx.getImageData(0, 0, size, size); -} - -function buildFallbackGlobeAnchoredShipIcon() { - const baseImage = buildFallbackGlobeShipIcon(); - if (!baseImage) return null; - - const size = baseImage.width; - const canvas = document.createElement("canvas"); - canvas.width = size; - canvas.height = size; - const ctx = canvas.getContext("2d"); - if (!ctx) return null; - - ctx.putImageData(baseImage, 0, 0); - - // Add a small anchor glyph below the ship body for anchored-state distinction. - ctx.strokeStyle = "rgba(248,250,252,1)"; - ctx.lineWidth = 5; - ctx.lineCap = "round"; - ctx.beginPath(); - const cx = size / 2; - ctx.moveTo(cx - 18, 76); - ctx.lineTo(cx + 18, 76); - ctx.moveTo(cx, 66); - ctx.lineTo(cx, 82); - ctx.moveTo(cx, 82); - ctx.arc(cx, 82, 7, 0, Math.PI * 2); - ctx.moveTo(cx, 82); - ctx.lineTo(cx, 88); - ctx.moveTo(cx - 9, 88); - ctx.lineTo(cx + 9, 88); - ctx.stroke(); - - return ctx.getImageData(0, 0, size, size); -} - -function ensureFallbackShipImage( - map: maplibregl.Map, - imageId: string, - fallbackBuilder: () => ImageData | null = buildFallbackGlobeShipIcon, -) { - if (!map || map.hasImage(imageId)) return; - const image = fallbackBuilder(); - if (!image) return; - - try { - map.addImage(imageId, image, { pixelRatio: 2, sdf: true }); - } catch { - // ignore - } -} - -function getMapTilerKey(): string | null { - const k = import.meta.env.VITE_MAPTILER_KEY; - if (typeof k !== "string") return null; - const v = k.trim(); - return v ? v : null; -} - -function ensureSeamarkOverlay(map: maplibregl.Map, beforeLayerId?: string) { - const srcId = "seamark"; - const layerId = "seamark"; - - if (!map.getSource(srcId)) { - map.addSource(srcId, { - type: "raster", - tiles: ["https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png"], - tileSize: 256, - attribution: "© OpenSeaMap contributors", - }); - } - - if (!map.getLayer(layerId)) { - const layer: LayerSpecification = { - id: layerId, - type: "raster", - source: srcId, - paint: { "raster-opacity": 0.85 }, - } as unknown as LayerSpecification; - - // By default, MapLibre adds new layers to the top. - // For readability we want seamarks above bathymetry fill, but below bathymetry lines/labels. - const before = beforeLayerId && map.getLayer(beforeLayerId) ? beforeLayerId : undefined; - map.addLayer(layer, before); - } -} - -function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) { - // NOTE: Vector-only bathymetry injection. - // Raster/DEM hillshade was intentionally removed for now because it caused ocean flicker - // and extra PNG tile traffic under globe projection in our setup. - const oceanSourceId = "maptiler-ocean"; - - if (!style.sources) style.sources = {} as StyleSpecification["sources"]; - if (!style.layers) style.layers = []; - - if (!style.sources[oceanSourceId]) { - style.sources[oceanSourceId] = { - type: "vector", - url: `https://api.maptiler.com/tiles/ocean/tiles.json?key=${encodeURIComponent(maptilerKey)}`, - } satisfies VectorSourceSpecification as unknown as StyleSpecification["sources"][string]; - } - - const depth = ["to-number", ["get", "depth"]] as unknown as number[]; - const depthLabel = ["concat", ["to-string", ["*", depth, -1]], "m"] as unknown as string[]; - - const bathyFillColor = [ - "interpolate", - ["linear"], - depth, - -11000, - "#00040b", - -8000, - "#010610", - -6000, - "#020816", - -4000, - "#030c1c", - -2000, - "#041022", - -1000, - "#051529", - -500, - "#061a30", - -200, - "#071f36", - -100, - "#08263d", - -50, - "#092c44", - -20, - "#0a334b", - 0, - "#0b3a53", - ] as const; - - const bathyFill: LayerSpecification = { - id: "bathymetry-fill", - type: "fill", - source: oceanSourceId, - "source-layer": "contour", - // Very low zoom tiles can contain extremely complex polygons (coastline/detail), - // which may exceed MapLibre's per-segment 16-bit vertex limit and render incorrectly. - // We keep the fill starting at a more reasonable zoom. - minzoom: 6, - // Source maxzoom is 12, but we allow overzoom so the bathymetry doesn't disappear when zooming in. - maxzoom: 24, - paint: { - // Dark-mode friendly palette (shallow = slightly brighter; deep = near-black). - "fill-color": bathyFillColor, - "fill-opacity": ["interpolate", ["linear"], ["zoom"], 0, 0.9, 6, 0.86, 10, 0.78], - }, - } as unknown as LayerSpecification; - - - - const bathyBandBorders: LayerSpecification = { - id: "bathymetry-borders", - type: "line", - source: oceanSourceId, - "source-layer": "contour", - minzoom: 6, - maxzoom: 24, - paint: { - "line-color": "rgba(255,255,255,0.06)", - "line-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0.12, 8, 0.18, 12, 0.22], - "line-blur": ["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 0.2], - "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.2, 8, 0.35, 12, 0.6], - }, - } as unknown as LayerSpecification; - - const bathyLinesMinor: LayerSpecification = { - id: "bathymetry-lines", - type: "line", - source: oceanSourceId, - "source-layer": "contour_line", - minzoom: 8, - paint: { - "line-color": [ - "interpolate", - ["linear"], - depth, - -11000, - "rgba(255,255,255,0.04)", - -6000, - "rgba(255,255,255,0.05)", - -2000, - "rgba(255,255,255,0.07)", - 0, - "rgba(255,255,255,0.10)", - ], - "line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0.18, 10, 0.22, 12, 0.28], - "line-blur": ["interpolate", ["linear"], ["zoom"], 8, 0.8, 11, 0.3], - "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.35, 10, 0.55, 12, 0.85], - }, - } as unknown as LayerSpecification; - - const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500]; - const bathyMajorDepthFilter: unknown[] = [ - "in", - ["to-number", ["get", "depth"]], - ["literal", majorDepths], - ] as unknown[]; - - const bathyLinesMajor: LayerSpecification = { - id: "bathymetry-lines-major", - type: "line", - source: oceanSourceId, - "source-layer": "contour_line", - minzoom: 8, - maxzoom: 24, - filter: bathyMajorDepthFilter as unknown as unknown[], - paint: { - "line-color": "rgba(255,255,255,0.16)", - "line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0.22, 10, 0.28, 12, 0.34], - "line-blur": ["interpolate", ["linear"], ["zoom"], 8, 0.4, 11, 0.2], - "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.6, 10, 0.95, 12, 1.3], - }, - } as unknown as LayerSpecification; - - const bathyBandBordersMajor: LayerSpecification = { - id: "bathymetry-borders-major", - type: "line", - source: oceanSourceId, - "source-layer": "contour", - minzoom: 4, - maxzoom: 24, - filter: bathyMajorDepthFilter as unknown as unknown[], - paint: { - "line-color": "rgba(255,255,255,0.14)", - "line-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0.14, 8, 0.2, 12, 0.26], - "line-blur": ["interpolate", ["linear"], ["zoom"], 4, 0.3, 10, 0.15], - "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.35, 8, 0.55, 12, 0.85], - }, - } as unknown as LayerSpecification; - - const bathyLabels: LayerSpecification = { - id: "bathymetry-labels", - type: "symbol", - source: oceanSourceId, - "source-layer": "contour_line", - minzoom: 10, - filter: bathyMajorDepthFilter as unknown as unknown[], - layout: { - "symbol-placement": "line", - "text-field": depthLabel, - "text-font": ["Noto Sans Regular", "Open Sans Regular"], - // Make depth labels more legible on both mercator + globe. - "text-size": ["interpolate", ["linear"], ["zoom"], 10, 12, 12, 14, 14, 15], - "text-allow-overlap": false, - "text-padding": 2, - "text-rotation-alignment": "map", - }, - paint: { - "text-color": "rgba(226,232,240,0.72)", - "text-halo-color": "rgba(2,6,23,0.82)", - "text-halo-width": 1.0, - "text-halo-blur": 0.6, - }, - } as unknown as LayerSpecification; - - const landformLabels: LayerSpecification = { - id: "bathymetry-landforms", - type: "symbol", - source: oceanSourceId, - "source-layer": "landform", - minzoom: 8, - filter: ["has", "name"] as unknown as unknown[], - layout: { - "text-field": ["get", "name"] as unknown as unknown[], - "text-font": ["Noto Sans Italic", "Noto Sans Regular", "Open Sans Italic", "Open Sans Regular"], - "text-size": ["interpolate", ["linear"], ["zoom"], 8, 11, 10, 12, 12, 13], - "text-allow-overlap": false, - "text-anchor": "center", - "text-offset": [0, 0.0], - }, - paint: { - "text-color": "rgba(148,163,184,0.70)", - "text-halo-color": "rgba(2,6,23,0.85)", - "text-halo-width": 1.0, - "text-halo-blur": 0.7, - }, - } as unknown as LayerSpecification; - - // Insert before the first symbol layer (keep labels on top), otherwise append. - const layers = Array.isArray(style.layers) ? (style.layers as unknown as LayerSpecification[]) : []; - if (!Array.isArray(style.layers)) { - style.layers = layers as unknown as StyleSpecification["layers"]; - } - - const symbolIndex = layers.findIndex((l) => (l as { type?: unknown } | null)?.type === "symbol"); - const insertAt = symbolIndex >= 0 ? symbolIndex : layers.length; - - const existingIds = new Set(); - for (const layer of layers) { - const id = getLayerId(layer); - if (id) existingIds.add(id); - } - - const toInsert = [ - bathyFill, - bathyBandBorders, - bathyBandBordersMajor, - bathyLinesMinor, - bathyLinesMajor, - bathyLabels, - landformLabels, - ].filter((l) => !existingIds.has(l.id)); - if (toInsert.length > 0) layers.splice(insertAt, 0, ...toInsert); -} - -type BathyZoomRange = { - id: string; - mercator: [number, number]; - globe: [number, number]; -}; - -const BATHY_ZOOM_RANGES: BathyZoomRange[] = [ - // MapTiler Ocean tiles maxzoom=12; beyond that we overzoom the z12 geometry. - // Keep rendering at high zoom so the sea doesn't revert to the basemap's flat water color. - { id: "bathymetry-fill", mercator: [6, 24], globe: [8, 24] }, - { id: "bathymetry-borders", mercator: [6, 24], globe: [8, 24] }, - { id: "bathymetry-borders-major", mercator: [4, 24], globe: [8, 24] }, -]; - -function applyBathymetryZoomProfile(map: maplibregl.Map, baseMap: BaseMapId, projection: MapProjectionId) { - if (!map || !map.isStyleLoaded()) return; - if (baseMap !== "enhanced") return; - const isGlobe = projection === "globe"; - - for (const range of BATHY_ZOOM_RANGES) { - if (!map.getLayer(range.id)) continue; - const [minzoom, maxzoom] = isGlobe ? range.globe : range.mercator; - try { - // Safety: ensure heavy layers aren't stuck hidden from a previous session. - map.setLayoutProperty(range.id, "visibility", "visible"); - } catch { - // ignore - } - try { - map.setLayerZoomRange(range.id, minzoom, maxzoom); - } catch { - // ignore - } - } -} - -async function resolveInitialMapStyle(signal: AbortSignal): Promise { - const key = getMapTilerKey(); - if (!key) return "/map/styles/osm-seamark.json"; - - const baseMapId = (import.meta.env.VITE_MAPTILER_BASE_MAP_ID || "dataviz-dark").trim(); - const styleUrl = `https://api.maptiler.com/maps/${encodeURIComponent(baseMapId)}/style.json?key=${encodeURIComponent(key)}`; - - const res = await fetch(styleUrl, { signal, headers: { accept: "application/json" } }); - if (!res.ok) throw new Error(`MapTiler style fetch failed: ${res.status} ${res.statusText}`); - const json = (await res.json()) as StyleSpecification; - injectOceanBathymetryLayers(json, key); - return json; -} - -async function resolveMapStyle(baseMap: BaseMapId, signal: AbortSignal): Promise { - if (baseMap === "legacy") return "/map/styles/carto-dark.json"; - return resolveInitialMapStyle(signal); -} - -function getShipColor( - t: AisTarget, - selectedMmsi: number | null, - legacyShipCode: string | null, - highlightedMmsis: Set, -): [number, number, number, number] { - if (selectedMmsi && t.mmsi === selectedMmsi) { - return [MAP_SELECTED_SHIP_RGB[0], MAP_SELECTED_SHIP_RGB[1], MAP_SELECTED_SHIP_RGB[2], 255]; - } - - if (highlightedMmsis.has(t.mmsi)) { - return [MAP_HIGHLIGHT_SHIP_RGB[0], MAP_HIGHLIGHT_SHIP_RGB[1], MAP_HIGHLIGHT_SHIP_RGB[2], 235]; - } - if (legacyShipCode) { - const rgb = LEGACY_CODE_COLORS[legacyShipCode]; - if (rgb) return [rgb[0], rgb[1], rgb[2], 235]; - return [245, 158, 11, 235]; - } - // Non-target AIS: muted gray scale (avoid clashing with target category colors like PT/GN/etc). - if (!isFiniteNumber(t.sog)) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 130]; - if (t.sog >= 10) return [148, 163, 184, 185]; // slate-400 - if (t.sog >= 1) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 175]; // slate-500 - return [71, 85, 105, 165]; // slate-600 -} - -type DashSeg = { - from: [number, number]; - to: [number, number]; - suspicious: boolean; - distanceNm?: number; - fromMmsi?: number; - toMmsi?: number; -}; - -function dashifyLine( - from: [number, number], - to: [number, number], - suspicious: boolean, - distanceNm?: number, - fromMmsi?: number, - toMmsi?: number, -): DashSeg[] { - // Simple dashed effect: split into segments and render every other one. - const segs: DashSeg[] = []; - const steps = 14; - for (let i = 0; i < steps; i++) { - if (i % 2 === 1) continue; - const a0 = i / steps; - const a1 = (i + 1) / steps; - const lon0 = from[0] + (to[0] - from[0]) * a0; - const lat0 = from[1] + (to[1] - from[1]) * a0; - const lon1 = from[0] + (to[0] - from[0]) * a1; - const lat1 = from[1] + (to[1] - from[1]) * a1; - segs.push({ - from: [lon0, lat0], - to: [lon1, lat1], - suspicious, - distanceNm, - fromMmsi, - toMmsi, - }); - } - return segs; -} - -function circleRingLngLat(center: [number, number], radiusMeters: number, steps = 72): [number, number][] { - const [lon0, lat0] = center; - const latRad = lat0 * DEG2RAD; - const cosLat = Math.max(1e-6, Math.cos(latRad)); - const r = Math.max(0, radiusMeters); - - const ring: [number, number][] = []; - for (let i = 0; i <= steps; i++) { - const a = (i / steps) * Math.PI * 2; - const dy = r * Math.sin(a); - const dx = r * Math.cos(a); - const dLat = (dy / EARTH_RADIUS_M) / DEG2RAD; - const dLon = (dx / (EARTH_RADIUS_M * cosLat)) / DEG2RAD; - ring.push([lon0 + dLon, lat0 + dLat]); - } - return ring; -} - -type PairRangeCircle = { - center: [number, number]; // [lon, lat] - radiusNm: number; - warn: boolean; - aMmsi: number; - bMmsi: number; - distanceNm: number; -}; - -const toNumberArray = (values: unknown): number[] => { - if (values == null) return []; - if (Array.isArray(values)) { - return values as unknown as number[]; - } - if (typeof values === "number" && Number.isFinite(values)) { - return [values]; - } - if (typeof values === "string") { - const value = toSafeNumber(Number(values)); - return value == null ? [] : [value]; - } - if (typeof values === "object") { - if (typeof (values as { [Symbol.iterator]?: unknown })?.[Symbol.iterator] === "function") { - try { - return Array.from(values as Iterable) as number[]; - } catch { - return []; - } - } - } - return []; -}; - -const makeUniqueSorted = (values: unknown) => { - const maybeArray = toNumberArray(values); - const normalized = Array.isArray(maybeArray) ? maybeArray : []; - const unique = Array.from(new Set(normalized.filter((value) => Number.isFinite(value)))); - unique.sort((a, b) => a - b); - return unique; -}; - -const equalNumberArrays = (a: number[], b: number[]) => { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i += 1) { - if (a[i] !== b[i]) return false; - } - return true; -}; - -const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius` -const DECK_VIEW_ID = "mapbox"; export function Map3D({ targets, @@ -1372,24 +173,20 @@ export function Map3D({ const mapTooltipRef = useRef(null); const deckHoverRafRef = useRef(null); const deckHoverHasHitRef = useRef(false); - const [hoveredDeckMmsiSet, setHoveredDeckMmsiSet] = useState([]); - const [hoveredDeckPairMmsiSet, setHoveredDeckPairMmsiSet] = useState([]); - const [hoveredDeckFleetOwnerKey, setHoveredDeckFleetOwnerKey] = useState(null); - const [hoveredDeckFleetMmsiSet, setHoveredDeckFleetMmsiSet] = useState([]); - const [hoveredZoneId, setHoveredZoneId] = useState(null); - const hoveredMmsiSetRef = useMemo(() => toNumberSet(hoveredMmsiSet), [hoveredMmsiSet]); - const hoveredFleetMmsiSetRef = useMemo(() => toNumberSet(hoveredFleetMmsiSet), [hoveredFleetMmsiSet]); - const hoveredPairMmsiSetRef = useMemo(() => toNumberSet(hoveredPairMmsiSet), [hoveredPairMmsiSet]); - const externalHighlightedSetRef = useMemo(() => toNumberSet(highlightedMmsiSet), [highlightedMmsiSet]); - const hoveredDeckMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckMmsiSet), [hoveredDeckMmsiSet]); - const hoveredDeckPairMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckPairMmsiSet), [hoveredDeckPairMmsiSet]); - const hoveredDeckFleetMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckFleetMmsiSet), [hoveredDeckFleetMmsiSet]); - const hoveredFleetOwnerKeys = useMemo(() => { - const keys = new Set(); - if (hoveredFleetOwnerKey) keys.add(hoveredFleetOwnerKey); - if (hoveredDeckFleetOwnerKey) keys.add(hoveredDeckFleetOwnerKey); - return keys; - }, [hoveredFleetOwnerKey, hoveredDeckFleetOwnerKey]); + const { + setHoveredDeckMmsiSet, + setHoveredDeckPairMmsiSet, + setHoveredDeckFleetOwnerKey, + setHoveredDeckFleetMmsiSet, + hoveredZoneId, setHoveredZoneId, + hoveredMmsiSetRef, hoveredFleetMmsiSetRef, hoveredPairMmsiSetRef, + externalHighlightedSetRef, + hoveredDeckMmsiSetRef, hoveredDeckPairMmsiSetRef, hoveredDeckFleetMmsiSetRef, + hoveredFleetOwnerKeys, + } = useHoverState({ + hoveredMmsiSet, hoveredFleetMmsiSet, hoveredPairMmsiSet, + hoveredFleetOwnerKey, highlightedMmsiSet, + }); const fleetFocusId = fleetFocus?.id; const fleetFocusLon = fleetFocus?.center?.[0]; const fleetFocusLat = fleetFocus?.center?.[1]; @@ -1399,6 +196,7 @@ export function Map3D({ const map = mapRef.current; if (!map || projectionRef.current !== "globe") return; if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; const ordering = [ "zones-fill", @@ -1717,10 +515,7 @@ export function Map3D({ // Trigger a sync pulse when loading ends so globe/mercator layers appear immediately // without requiring a user toggle (e.g., industry filter). setMapSyncEpoch((prev) => prev + 1); - requestAnimationFrame(() => { - kickRepaint(mapRef.current); - setMapSyncEpoch((prev) => prev + 1); - }); + kickRepaint(mapRef.current); }, [clearProjectionBusyTimer, onProjectionLoadingChange]); const setProjectionLoading = useCallback( @@ -2022,7 +817,7 @@ export function Map3D({ requestAnimationFrame(finalizeSoon); return; } - requestAnimationFrame(() => requestAnimationFrame(finalize)); + requestAnimationFrame(finalize); }; const onIdle = () => finalizeSoon(); @@ -2956,7 +1751,6 @@ export function Map3D({ } if (globeShipsEpochRef.current !== mapSyncEpoch) { - remove(); globeShipsEpochRef.current = mapSyncEpoch; } @@ -3021,7 +1815,7 @@ export function Map3D({ sizeScale, selected: selected ? 1 : 0, highlighted: highlighted ? 1 : 0, - permitted: !!legacy, + permitted: legacy ? 1 : 0, code: legacy?.shipCode || "", }, }; @@ -3405,6 +2199,8 @@ export function Map3D({ } else { try { map.setLayoutProperty(labelId, "visibility", labelVisibility); + map.setFilter(labelId, labelFilter as never); + map.setLayoutProperty(labelId, "text-field", ["get", "labelName"] as never); } catch { // ignore } @@ -3470,7 +2266,6 @@ export function Map3D({ } if (globeShipsEpochRef.current !== mapSyncEpoch) { - remove(); globeShipsEpochRef.current = mapSyncEpoch; } @@ -3531,7 +2326,7 @@ export function Map3D({ iconSize10: clampNumber(0.56 * sizeScale * scale, 0.35, 2.0), iconSize14: clampNumber(0.72 * sizeScale * scale, 0.45, 2.4), selected: selected ? 1 : 0, - permitted: !!legacy, + permitted: legacy ? 1 : 0, }, }; }), @@ -5412,7 +4207,12 @@ export function Map3D({ return; } onSelectMmsi(t.mmsi); - map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 }); + const clickOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 }; + if (projectionRef.current === "globe") { + map.flyTo(clickOpts); + } else { + map.easeTo(clickOpts); + } } }, }; @@ -5718,7 +4518,12 @@ export function Map3D({ if (!selectedMmsi) return; const t = shipData.find((x) => x.mmsi === selectedMmsi); if (!t) return; - map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 }); + const opts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 }; + if (projectionRef.current === "globe") { + map.flyTo(opts); + } else { + map.easeTo(opts); + } }, [selectedMmsi, shipData]); useEffect(() => { @@ -5730,11 +4535,12 @@ export function Map3D({ const zoom = fleetFocusZoom ?? 10; const apply = () => { - map.easeTo({ - center: [lon, lat], - zoom, - duration: 700, - }); + const opts = { center: [lon, lat] as [number, number], zoom, duration: 700 }; + if (projectionRef.current === "globe") { + map.flyTo(opts); + } else { + map.easeTo(opts); + } }; if (map.isStyleLoaded()) { diff --git a/apps/web/src/widgets/map3d/constants.ts b/apps/web/src/widgets/map3d/constants.ts new file mode 100644 index 0000000..05a2940 --- /dev/null +++ b/apps/web/src/widgets/map3d/constants.ts @@ -0,0 +1,158 @@ +import { + LEGACY_CODE_COLORS_RGB, + OVERLAY_RGB, + rgba as rgbaCss, +} from '../../shared/lib/map/palette'; +import type { BathyZoomRange } from './types'; + +// ── Re-export palette aliases used throughout Map3D ── + +export const LEGACY_CODE_COLORS = LEGACY_CODE_COLORS_RGB; + +const OVERLAY_PAIR_NORMAL_RGB = OVERLAY_RGB.pairNormal; +const OVERLAY_PAIR_WARN_RGB = OVERLAY_RGB.pairWarn; +const OVERLAY_FC_TRANSFER_RGB = OVERLAY_RGB.fcTransfer; +const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange; +const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious; + +// ── Ship icon mapping (Deck.gl IconLayer) ── + +export const SHIP_ICON_MAPPING = { + ship: { + x: 0, + y: 0, + width: 128, + height: 128, + anchorX: 64, + anchorY: 64, + mask: true, + }, +} as const; + +// ── Ship constants ── + +export const ANCHOR_SPEED_THRESHOLD_KN = 1; +export const ANCHORED_SHIP_ICON_ID = 'ship-globe-anchored-icon'; + +// ── Geometry constants ── + +export const DEG2RAD = Math.PI / 180; +export const RAD2DEG = 180 / Math.PI; +export const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius` +export const GLOBE_ICON_HEADING_OFFSET_DEG = 0; + +// ── Ship color constants ── + +export const MAP_SELECTED_SHIP_RGB: [number, number, number] = [34, 211, 238]; +export const MAP_HIGHLIGHT_SHIP_RGB: [number, number, number] = [245, 158, 11]; +export const MAP_DEFAULT_SHIP_RGB: [number, number, number] = [100, 116, 139]; + +// ── Flat map icon sizes ── + +export const FLAT_SHIP_ICON_SIZE = 19; +export const FLAT_SHIP_ICON_SIZE_SELECTED = 28; +export const FLAT_SHIP_ICON_SIZE_HIGHLIGHTED = 25; +export const FLAT_LEGACY_HALO_RADIUS = 14; +export const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18; +export const FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED = 16; +export const EMPTY_MMSI_SET = new Set(); + +// ── Deck.gl view ID ── + +export const DECK_VIEW_ID = 'mapbox'; + +// ── Depth params ── + +export const DEPTH_DISABLED_PARAMS = { + depthCompare: 'always', + depthWriteEnabled: false, +} as const; + +export const GLOBE_OVERLAY_PARAMS = { + depthCompare: 'less-equal', + depthWriteEnabled: false, +} as const; + +// ── Deck.gl color constants (avoid per-object allocations inside accessors) ── + +export const PAIR_RANGE_NORMAL_DECK: [number, number, number, number] = [ + OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 110, +]; +export const PAIR_RANGE_WARN_DECK: [number, number, number, number] = [ + OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 170, +]; +export const PAIR_LINE_NORMAL_DECK: [number, number, number, number] = [ + OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 85, +]; +export const PAIR_LINE_WARN_DECK: [number, number, number, number] = [ + OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 220, +]; +export const FC_LINE_NORMAL_DECK: [number, number, number, number] = [ + OVERLAY_FC_TRANSFER_RGB[0], OVERLAY_FC_TRANSFER_RGB[1], OVERLAY_FC_TRANSFER_RGB[2], 200, +]; +export const FC_LINE_SUSPICIOUS_DECK: [number, number, number, number] = [ + OVERLAY_SUSPICIOUS_RGB[0], OVERLAY_SUSPICIOUS_RGB[1], OVERLAY_SUSPICIOUS_RGB[2], 220, +]; +export const FLEET_RANGE_LINE_DECK: [number, number, number, number] = [ + OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 140, +]; +export const FLEET_RANGE_FILL_DECK: [number, number, number, number] = [ + OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 6, +]; + +// ── Highlighted variants ── + +export const PAIR_RANGE_NORMAL_DECK_HL: [number, number, number, number] = [ + OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 200, +]; +export const PAIR_RANGE_WARN_DECK_HL: [number, number, number, number] = [ + OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 240, +]; +export const PAIR_LINE_NORMAL_DECK_HL: [number, number, number, number] = [ + OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 245, +]; +export const PAIR_LINE_WARN_DECK_HL: [number, number, number, number] = [ + OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 245, +]; +export const FC_LINE_NORMAL_DECK_HL: [number, number, number, number] = [ + OVERLAY_FC_TRANSFER_RGB[0], OVERLAY_FC_TRANSFER_RGB[1], OVERLAY_FC_TRANSFER_RGB[2], 235, +]; +export const FC_LINE_SUSPICIOUS_DECK_HL: [number, number, number, number] = [ + OVERLAY_SUSPICIOUS_RGB[0], OVERLAY_SUSPICIOUS_RGB[1], OVERLAY_SUSPICIOUS_RGB[2], 245, +]; +export const FLEET_RANGE_LINE_DECK_HL: [number, number, number, number] = [ + OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 220, +]; +export const FLEET_RANGE_FILL_DECK_HL: [number, number, number, number] = [ + OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 42, +]; + +// ── MapLibre overlay colors ── + +export const PAIR_LINE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.55); +export const PAIR_LINE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.95); +export const PAIR_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.95); +export const PAIR_LINE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.98); + +export const PAIR_RANGE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.45); +export const PAIR_RANGE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.75); +export const PAIR_RANGE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.92); +export const PAIR_RANGE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.92); + +export const FC_LINE_NORMAL_ML = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.92); +export const FC_LINE_SUSPICIOUS_ML = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.95); +export const FC_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.98); +export const FC_LINE_SUSPICIOUS_ML_HL = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.98); + +export const FLEET_FILL_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.02); +export const FLEET_FILL_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.16); +export const FLEET_LINE_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.65); +export const FLEET_LINE_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95); + +// ── Bathymetry zoom ranges ── + +export const BATHY_ZOOM_RANGES: BathyZoomRange[] = [ + { id: 'bathymetry-fill', mercator: [6, 24], globe: [8, 24] }, + { id: 'bathymetry-borders', mercator: [6, 24], globe: [8, 24] }, + { id: 'bathymetry-borders-major', mercator: [4, 24], globe: [8, 24] }, +]; diff --git a/apps/web/src/widgets/map3d/hooks/useHoverState.ts b/apps/web/src/widgets/map3d/hooks/useHoverState.ts new file mode 100644 index 0000000..a1cf7a0 --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useHoverState.ts @@ -0,0 +1,66 @@ +import { useMemo, useState } from 'react'; +import { toNumberSet } from '../lib/setUtils'; + +export interface HoverStateInput { + hoveredMmsiSet: number[]; + hoveredFleetMmsiSet: number[]; + hoveredPairMmsiSet: number[]; + hoveredFleetOwnerKey: string | null; + highlightedMmsiSet: number[]; +} + +export function useHoverState(input: HoverStateInput) { + const { + hoveredMmsiSet, + hoveredFleetMmsiSet, + hoveredPairMmsiSet, + hoveredFleetOwnerKey, + highlightedMmsiSet, + } = input; + + // Internal deck hover states + const [hoveredDeckMmsiSet, setHoveredDeckMmsiSet] = useState([]); + const [hoveredDeckPairMmsiSet, setHoveredDeckPairMmsiSet] = useState([]); + const [hoveredDeckFleetOwnerKey, setHoveredDeckFleetOwnerKey] = useState(null); + const [hoveredDeckFleetMmsiSet, setHoveredDeckFleetMmsiSet] = useState([]); + const [hoveredZoneId, setHoveredZoneId] = useState(null); + + // Derived sets + const hoveredMmsiSetRef = useMemo(() => toNumberSet(hoveredMmsiSet), [hoveredMmsiSet]); + const hoveredFleetMmsiSetRef = useMemo(() => toNumberSet(hoveredFleetMmsiSet), [hoveredFleetMmsiSet]); + const hoveredPairMmsiSetRef = useMemo(() => toNumberSet(hoveredPairMmsiSet), [hoveredPairMmsiSet]); + const externalHighlightedSetRef = useMemo(() => toNumberSet(highlightedMmsiSet), [highlightedMmsiSet]); + const hoveredDeckMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckMmsiSet), [hoveredDeckMmsiSet]); + const hoveredDeckPairMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckPairMmsiSet), [hoveredDeckPairMmsiSet]); + const hoveredDeckFleetMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckFleetMmsiSet), [hoveredDeckFleetMmsiSet]); + + const hoveredFleetOwnerKeys = useMemo(() => { + const keys = new Set(); + if (hoveredFleetOwnerKey) keys.add(hoveredFleetOwnerKey); + if (hoveredDeckFleetOwnerKey) keys.add(hoveredDeckFleetOwnerKey); + return keys; + }, [hoveredFleetOwnerKey, hoveredDeckFleetOwnerKey]); + + return { + // Internal states + setters + hoveredDeckMmsiSet, + setHoveredDeckMmsiSet, + hoveredDeckPairMmsiSet, + setHoveredDeckPairMmsiSet, + hoveredDeckFleetOwnerKey, + setHoveredDeckFleetOwnerKey, + hoveredDeckFleetMmsiSet, + setHoveredDeckFleetMmsiSet, + hoveredZoneId, + setHoveredZoneId, + // Derived sets + hoveredMmsiSetRef, + hoveredFleetMmsiSetRef, + hoveredPairMmsiSetRef, + externalHighlightedSetRef, + hoveredDeckMmsiSetRef, + hoveredDeckPairMmsiSetRef, + hoveredDeckFleetMmsiSetRef, + hoveredFleetOwnerKeys, + }; +} diff --git a/apps/web/src/widgets/map3d/layers/bathymetry.ts b/apps/web/src/widgets/map3d/layers/bathymetry.ts new file mode 100644 index 0000000..e0f7004 --- /dev/null +++ b/apps/web/src/widgets/map3d/layers/bathymetry.ts @@ -0,0 +1,292 @@ +import maplibregl, { + type LayerSpecification, + type StyleSpecification, + type VectorSourceSpecification, +} from 'maplibre-gl'; +import type { BaseMapId, BathyZoomRange, MapProjectionId } from '../types'; +import { getLayerId, getMapTilerKey } from '../lib/mapCore'; + +const BATHY_ZOOM_RANGES: BathyZoomRange[] = [ + { id: 'bathymetry-fill', mercator: [6, 24], globe: [8, 24] }, + { id: 'bathymetry-borders', mercator: [6, 24], globe: [8, 24] }, + { id: 'bathymetry-borders-major', mercator: [4, 24], globe: [8, 24] }, +]; + +export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) { + const oceanSourceId = 'maptiler-ocean'; + + if (!style.sources) style.sources = {} as StyleSpecification['sources']; + if (!style.layers) style.layers = []; + + if (!style.sources[oceanSourceId]) { + style.sources[oceanSourceId] = { + type: 'vector', + url: `https://api.maptiler.com/tiles/ocean/tiles.json?key=${encodeURIComponent(maptilerKey)}`, + } satisfies VectorSourceSpecification as unknown as StyleSpecification['sources'][string]; + } + + const depth = ['to-number', ['get', 'depth']] as unknown as number[]; + const depthLabel = ['concat', ['to-string', ['*', depth, -1]], 'm'] as unknown as string[]; + + // Bug #3 fix: shallow depths now use brighter teal tones to distinguish from deep ocean + const bathyFillColor = [ + 'interpolate', + ['linear'], + depth, + -11000, + '#00040b', + -8000, + '#010610', + -6000, + '#020816', + -4000, + '#030c1c', + -2000, + '#041022', + -1000, + '#051529', + -500, + '#061a30', + -200, + '#071f36', + -100, + '#08263d', + -50, + '#0e3d5e', + -20, + '#145578', + -10, + '#1a6e8e', + 0, + '#2097a6', + ] as const; + + const bathyFill: LayerSpecification = { + id: 'bathymetry-fill', + type: 'fill', + source: oceanSourceId, + 'source-layer': 'contour', + minzoom: 6, + maxzoom: 24, + paint: { + 'fill-color': bathyFillColor, + 'fill-opacity': ['interpolate', ['linear'], ['zoom'], 0, 0.9, 6, 0.86, 10, 0.78], + }, + } as unknown as LayerSpecification; + + const bathyBandBorders: LayerSpecification = { + id: 'bathymetry-borders', + type: 'line', + source: oceanSourceId, + 'source-layer': 'contour', + minzoom: 6, + maxzoom: 24, + paint: { + 'line-color': 'rgba(255,255,255,0.06)', + 'line-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0.12, 8, 0.18, 12, 0.22], + 'line-blur': ['interpolate', ['linear'], ['zoom'], 4, 0.8, 10, 0.2], + 'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.2, 8, 0.35, 12, 0.6], + }, + } as unknown as LayerSpecification; + + const bathyLinesMinor: LayerSpecification = { + id: 'bathymetry-lines', + type: 'line', + source: oceanSourceId, + 'source-layer': 'contour_line', + minzoom: 8, + paint: { + 'line-color': [ + 'interpolate', + ['linear'], + depth, + -11000, + 'rgba(255,255,255,0.04)', + -6000, + 'rgba(255,255,255,0.05)', + -2000, + 'rgba(255,255,255,0.07)', + 0, + 'rgba(255,255,255,0.10)', + ], + 'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.18, 10, 0.22, 12, 0.28], + 'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.8, 11, 0.3], + 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.35, 10, 0.55, 12, 0.85], + }, + } as unknown as LayerSpecification; + + const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500]; + const bathyMajorDepthFilter: unknown[] = [ + 'in', + ['to-number', ['get', 'depth']], + ['literal', majorDepths], + ] as unknown[]; + + const bathyLinesMajor: LayerSpecification = { + id: 'bathymetry-lines-major', + type: 'line', + source: oceanSourceId, + 'source-layer': 'contour_line', + minzoom: 8, + maxzoom: 24, + filter: bathyMajorDepthFilter as unknown as unknown[], + paint: { + 'line-color': 'rgba(255,255,255,0.16)', + 'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.22, 10, 0.28, 12, 0.34], + 'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.4, 11, 0.2], + 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.6, 10, 0.95, 12, 1.3], + }, + } as unknown as LayerSpecification; + + const bathyBandBordersMajor: LayerSpecification = { + id: 'bathymetry-borders-major', + type: 'line', + source: oceanSourceId, + 'source-layer': 'contour', + minzoom: 4, + maxzoom: 24, + filter: bathyMajorDepthFilter as unknown as unknown[], + paint: { + 'line-color': 'rgba(255,255,255,0.14)', + 'line-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0.14, 8, 0.2, 12, 0.26], + 'line-blur': ['interpolate', ['linear'], ['zoom'], 4, 0.3, 10, 0.15], + 'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.35, 8, 0.55, 12, 0.85], + }, + } as unknown as LayerSpecification; + + const bathyLabels: LayerSpecification = { + id: 'bathymetry-labels', + type: 'symbol', + source: oceanSourceId, + 'source-layer': 'contour_line', + minzoom: 10, + filter: bathyMajorDepthFilter as unknown as unknown[], + layout: { + 'symbol-placement': 'line', + 'text-field': depthLabel, + 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 10, 12, 12, 14, 14, 15], + 'text-allow-overlap': false, + 'text-padding': 2, + 'text-rotation-alignment': 'map', + }, + paint: { + 'text-color': 'rgba(226,232,240,0.72)', + 'text-halo-color': 'rgba(2,6,23,0.82)', + 'text-halo-width': 1.0, + 'text-halo-blur': 0.6, + }, + } as unknown as LayerSpecification; + + const landformLabels: LayerSpecification = { + id: 'bathymetry-landforms', + type: 'symbol', + source: oceanSourceId, + 'source-layer': 'landform', + minzoom: 8, + filter: ['has', 'name'] as unknown as unknown[], + layout: { + 'text-field': ['get', 'name'] as unknown as unknown[], + 'text-font': ['Noto Sans Italic', 'Noto Sans Regular', 'Open Sans Italic', 'Open Sans Regular'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 8, 11, 10, 12, 12, 13], + 'text-allow-overlap': false, + 'text-anchor': 'center', + 'text-offset': [0, 0.0], + }, + paint: { + 'text-color': 'rgba(148,163,184,0.70)', + 'text-halo-color': 'rgba(2,6,23,0.85)', + 'text-halo-width': 1.0, + 'text-halo-blur': 0.7, + }, + } as unknown as LayerSpecification; + + const layers = Array.isArray(style.layers) ? (style.layers as unknown as LayerSpecification[]) : []; + if (!Array.isArray(style.layers)) { + style.layers = layers as unknown as StyleSpecification['layers']; + } + + // Brighten base-map water fills so shallow coasts, rivers & lakes blend naturally + // with the bathymetry gradient instead of appearing as near-black voids. + const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i; + const SHALLOW_WATER_FILL = '#14606e'; + const SHALLOW_WATER_LINE = '#114f5c'; + for (const layer of layers) { + const id = getLayerId(layer); + if (!id) continue; + const spec = layer as Record; + const sourceLayer = String(spec['source-layer'] ?? '').toLowerCase(); + const layerType = String(spec.type ?? ''); + const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer); + if (!isWater) continue; + + const paint = (spec.paint ?? {}) as Record; + if (layerType === 'fill') { + paint['fill-color'] = SHALLOW_WATER_FILL; + spec.paint = paint; + } else if (layerType === 'line') { + paint['line-color'] = SHALLOW_WATER_LINE; + spec.paint = paint; + } + } + + const symbolIndex = layers.findIndex((l) => (l as { type?: unknown } | null)?.type === 'symbol'); + const insertAt = symbolIndex >= 0 ? symbolIndex : layers.length; + + const existingIds = new Set(); + for (const layer of layers) { + const id = getLayerId(layer); + if (id) existingIds.add(id); + } + + const toInsert = [ + bathyFill, + bathyBandBorders, + bathyBandBordersMajor, + bathyLinesMinor, + bathyLinesMajor, + bathyLabels, + landformLabels, + ].filter((l) => !existingIds.has(l.id)); + if (toInsert.length > 0) layers.splice(insertAt, 0, ...toInsert); +} + +export function applyBathymetryZoomProfile(map: maplibregl.Map, baseMap: BaseMapId, projection: MapProjectionId) { + if (!map || !map.isStyleLoaded()) return; + if (baseMap !== 'enhanced') return; + const isGlobe = projection === 'globe'; + + for (const range of BATHY_ZOOM_RANGES) { + if (!map.getLayer(range.id)) continue; + const [minzoom, maxzoom] = isGlobe ? range.globe : range.mercator; + try { + map.setLayoutProperty(range.id, 'visibility', 'visible'); + } catch { + // ignore + } + try { + map.setLayerZoomRange(range.id, minzoom, maxzoom); + } catch { + // ignore + } + } +} + +export async function resolveInitialMapStyle(signal: AbortSignal): Promise { + const key = getMapTilerKey(); + if (!key) return '/map/styles/osm-seamark.json'; + + const baseMapId = (import.meta.env.VITE_MAPTILER_BASE_MAP_ID || 'dataviz-dark').trim(); + const styleUrl = `https://api.maptiler.com/maps/${encodeURIComponent(baseMapId)}/style.json?key=${encodeURIComponent(key)}`; + + const res = await fetch(styleUrl, { signal, headers: { accept: 'application/json' } }); + if (!res.ok) throw new Error(`MapTiler style fetch failed: ${res.status} ${res.statusText}`); + const json = (await res.json()) as StyleSpecification; + injectOceanBathymetryLayers(json, key); + return json; +} + +export async function resolveMapStyle(baseMap: BaseMapId, signal: AbortSignal): Promise { + if (baseMap === 'legacy') return '/map/styles/carto-dark.json'; + return resolveInitialMapStyle(signal); +} diff --git a/apps/web/src/widgets/map3d/layers/seamark.ts b/apps/web/src/widgets/map3d/layers/seamark.ts new file mode 100644 index 0000000..d5aa8e5 --- /dev/null +++ b/apps/web/src/widgets/map3d/layers/seamark.ts @@ -0,0 +1,27 @@ +import maplibregl, { type LayerSpecification } from 'maplibre-gl'; + +export function ensureSeamarkOverlay(map: maplibregl.Map, beforeLayerId?: string) { + const srcId = 'seamark'; + const layerId = 'seamark'; + + if (!map.getSource(srcId)) { + map.addSource(srcId, { + type: 'raster', + tiles: ['https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png'], + tileSize: 256, + attribution: '© OpenSeaMap contributors', + }); + } + + if (!map.getLayer(layerId)) { + const layer: LayerSpecification = { + id: layerId, + type: 'raster', + source: srcId, + paint: { 'raster-opacity': 0.85 }, + } as unknown as LayerSpecification; + + const before = beforeLayerId && map.getLayer(beforeLayerId) ? beforeLayerId : undefined; + map.addLayer(layer, before); + } +} diff --git a/apps/web/src/widgets/map3d/lib/dashifyLine.ts b/apps/web/src/widgets/map3d/lib/dashifyLine.ts new file mode 100644 index 0000000..8f9099c --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/dashifyLine.ts @@ -0,0 +1,31 @@ +import type { DashSeg } from '../types'; + +export function dashifyLine( + from: [number, number], + to: [number, number], + suspicious: boolean, + distanceNm?: number, + fromMmsi?: number, + toMmsi?: number, +): DashSeg[] { + const segs: DashSeg[] = []; + const steps = 14; + for (let i = 0; i < steps; i++) { + if (i % 2 === 1) continue; + const a0 = i / steps; + const a1 = (i + 1) / steps; + const lon0 = from[0] + (to[0] - from[0]) * a0; + const lat0 = from[1] + (to[1] - from[1]) * a0; + const lon1 = from[0] + (to[0] - from[0]) * a1; + const lat1 = from[1] + (to[1] - from[1]) * a1; + segs.push({ + from: [lon0, lat0], + to: [lon1, lat1], + suspicious, + distanceNm, + fromMmsi, + toMmsi, + }); + } + return segs; +} diff --git a/apps/web/src/widgets/map3d/lib/featureIds.ts b/apps/web/src/widgets/map3d/lib/featureIds.ts new file mode 100644 index 0000000..54e8173 --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/featureIds.ts @@ -0,0 +1,19 @@ +export function makeOrderedPairKey(a: number, b: number) { + const left = Math.trunc(Math.min(a, b)); + const right = Math.trunc(Math.max(a, b)); + return `${left}-${right}`; +} + +export function makePairLinkFeatureId(a: number, b: number, suffix?: string) { + const pair = makeOrderedPairKey(a, b); + return suffix ? `pair-${pair}-${suffix}` : `pair-${pair}`; +} + +export function makeFcSegmentFeatureId(a: number, b: number, segmentIndex: number) { + const pair = makeOrderedPairKey(a, b); + return `fc-${pair}-${segmentIndex}`; +} + +export function makeFleetCircleFeatureId(ownerKey: string) { + return `fleet-${ownerKey}`; +} diff --git a/apps/web/src/widgets/map3d/lib/geometry.ts b/apps/web/src/widgets/map3d/lib/geometry.ts new file mode 100644 index 0000000..6e8f5eb --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/geometry.ts @@ -0,0 +1,62 @@ +import { DEG2RAD, RAD2DEG, EARTH_RADIUS_M } from '../constants'; + +export const clampNumber = (value: number, minValue: number, maxValue: number) => + Math.max(minValue, Math.min(maxValue, value)); + +export function wrapLonDeg(lon: number) { + const v = ((lon + 180) % 360 + 360) % 360; + return v - 180; +} + +export function destinationPointLngLat( + from: [number, number], + bearingDeg: number, + distanceMeters: number, +): [number, number] { + const [lonDeg, latDeg] = from; + const lat1 = latDeg * DEG2RAD; + const lon1 = lonDeg * DEG2RAD; + const brng = bearingDeg * DEG2RAD; + const dr = Math.max(0, distanceMeters) / EARTH_RADIUS_M; + if (!Number.isFinite(dr) || dr === 0) return [lonDeg, latDeg]; + + const sinLat1 = Math.sin(lat1); + const cosLat1 = Math.cos(lat1); + const sinDr = Math.sin(dr); + const cosDr = Math.cos(dr); + + const lat2 = Math.asin(sinLat1 * cosDr + cosLat1 * sinDr * Math.cos(brng)); + const lon2 = + lon1 + + Math.atan2( + Math.sin(brng) * sinDr * cosLat1, + cosDr - sinLat1 * Math.sin(lat2), + ); + + const outLon = wrapLonDeg(lon2 * RAD2DEG); + const outLat = clampNumber(lat2 * RAD2DEG, -85.0, 85.0); + return [outLon, outLat]; +} + +export function circleRingLngLat(center: [number, number], radiusMeters: number, steps = 72): [number, number][] { + const [lon0, lat0] = center; + const latRad = lat0 * DEG2RAD; + const cosLat = Math.max(1e-6, Math.cos(latRad)); + const r = Math.max(0, radiusMeters); + + const ring: [number, number][] = []; + for (let i = 0; i <= steps; i++) { + const a = (i / steps) * Math.PI * 2; + const dy = r * Math.sin(a); + const dx = r * Math.cos(a); + const dLat = (dy / EARTH_RADIUS_M) / DEG2RAD; + const dLon = (dx / (EARTH_RADIUS_M * cosLat)) / DEG2RAD; + ring.push([lon0 + dLon, lat0 + dLat]); + } + return ring; +} + +export function normalizeAngleDeg(value: number, offset = 0): number { + const v = value + offset; + return ((v % 360) + 360) % 360; +} diff --git a/apps/web/src/widgets/map3d/lib/globeShipIcon.ts b/apps/web/src/widgets/map3d/lib/globeShipIcon.ts new file mode 100644 index 0000000..718e4fb --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/globeShipIcon.ts @@ -0,0 +1,76 @@ +import maplibregl from 'maplibre-gl'; + +export function buildFallbackGlobeShipIcon() { + const size = 96; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + + ctx.clearRect(0, 0, size, size); + ctx.fillStyle = 'rgba(255,255,255,1)'; + ctx.beginPath(); + ctx.moveTo(size / 2, 6); + ctx.lineTo(size / 2 - 14, 24); + ctx.lineTo(size / 2 - 18, 58); + ctx.lineTo(size / 2 - 10, 88); + ctx.lineTo(size / 2 + 10, 88); + ctx.lineTo(size / 2 + 18, 58); + ctx.lineTo(size / 2 + 14, 24); + ctx.closePath(); + ctx.fill(); + + ctx.fillRect(size / 2 - 8, 34, 16, 18); + + return ctx.getImageData(0, 0, size, size); +} + +export function buildFallbackGlobeAnchoredShipIcon() { + const baseImage = buildFallbackGlobeShipIcon(); + if (!baseImage) return null; + + const size = baseImage.width; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + + ctx.putImageData(baseImage, 0, 0); + + ctx.strokeStyle = 'rgba(248,250,252,1)'; + ctx.lineWidth = 5; + ctx.lineCap = 'round'; + ctx.beginPath(); + const cx = size / 2; + ctx.moveTo(cx - 18, 76); + ctx.lineTo(cx + 18, 76); + ctx.moveTo(cx, 66); + ctx.lineTo(cx, 82); + ctx.moveTo(cx, 82); + ctx.arc(cx, 82, 7, 0, Math.PI * 2); + ctx.moveTo(cx, 82); + ctx.lineTo(cx, 88); + ctx.moveTo(cx - 9, 88); + ctx.lineTo(cx + 9, 88); + ctx.stroke(); + + return ctx.getImageData(0, 0, size, size); +} + +export function ensureFallbackShipImage( + map: maplibregl.Map, + imageId: string, + fallbackBuilder: () => ImageData | null = buildFallbackGlobeShipIcon, +) { + if (!map || map.hasImage(imageId)) return; + const image = fallbackBuilder(); + if (!image) return; + + try { + map.addImage(imageId, image, { pixelRatio: 2, sdf: true }); + } catch { + // ignore + } +} diff --git a/apps/web/src/widgets/map3d/lib/layerHelpers.ts b/apps/web/src/widgets/map3d/lib/layerHelpers.ts new file mode 100644 index 0000000..7eecc45 --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/layerHelpers.ts @@ -0,0 +1,62 @@ +import maplibregl, { + type GeoJSONSourceSpecification, + type LayerSpecification, +} from 'maplibre-gl'; + +export function ensureGeoJsonSource( + map: maplibregl.Map, + sourceId: string, + data: GeoJSON.GeoJSON, +) { + const existing = map.getSource(sourceId); + if (existing) { + (existing as maplibregl.GeoJSONSource).setData(data); + } else { + map.addSource(sourceId, { + type: 'geojson', + data, + } satisfies GeoJSONSourceSpecification); + } +} + +export function ensureLayer( + map: maplibregl.Map, + spec: LayerSpecification, + options?: { before?: string }, +) { + if (map.getLayer(spec.id)) return; + const before = options?.before && map.getLayer(options.before) ? options.before : undefined; + map.addLayer(spec, before); +} + +export function setLayerVisibility(map: maplibregl.Map, layerId: string, visible: boolean) { + if (!map.getLayer(layerId)) return; + try { + map.setLayoutProperty(layerId, 'visibility', visible ? 'visible' : 'none'); + } catch { + // ignore + } +} + +export function cleanupLayers( + map: maplibregl.Map, + layerIds: string[], + sourceIds: string[], +) { + requestAnimationFrame(() => { + for (const id of layerIds) { + try { + if (map.getLayer(id)) map.removeLayer(id); + } catch { + // ignore + } + } + for (const id of sourceIds) { + try { + if (map.getSource(id)) map.removeSource(id); + } catch { + // ignore + } + } + }); +} diff --git a/apps/web/src/widgets/map3d/lib/mapCore.ts b/apps/web/src/widgets/map3d/lib/mapCore.ts new file mode 100644 index 0000000..9dca30e --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/mapCore.ts @@ -0,0 +1,124 @@ +import maplibregl from 'maplibre-gl'; +import type { MapProjectionId } from '../types'; + +export function kickRepaint(map: maplibregl.Map | null) { + if (!map) return; + try { + if (map.isStyleLoaded()) map.triggerRepaint(); + } catch { + // ignore + } + try { + requestAnimationFrame(() => { + try { + if (map.isStyleLoaded()) map.triggerRepaint(); + } catch { + // ignore + } + }); + requestAnimationFrame(() => { + try { + if (map.isStyleLoaded()) map.triggerRepaint(); + } catch { + // ignore + } + }); + } catch { + // ignore (e.g., non-browser env) + } +} + +export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void) { + if (!map) { + return () => { + // noop + }; + } + if (map.isStyleLoaded()) { + callback(); + return () => { + // noop + }; + } + + let fired = false; + const runOnce = () => { + if (!map || fired || !map.isStyleLoaded()) return; + fired = true; + callback(); + try { + map.off('style.load', runOnce); + map.off('styledata', runOnce); + map.off('idle', runOnce); + } catch { + // ignore + } + }; + + map.on('style.load', runOnce); + map.on('styledata', runOnce); + map.on('idle', runOnce); + + return () => { + if (fired) return; + fired = true; + try { + if (!map) return; + map.off('style.load', runOnce); + map.off('styledata', runOnce); + map.off('idle', runOnce); + } catch { + // ignore + } + }; +} + +export function extractProjectionType(map: maplibregl.Map): MapProjectionId | undefined { + const projection = map.getProjection?.(); + if (!projection || typeof projection !== 'object') return undefined; + + const rawType = (projection as { type?: unknown; name?: unknown }).type ?? (projection as { type?: unknown; name?: unknown }).name; + if (rawType === 'globe') return 'globe'; + if (rawType === 'mercator') return 'mercator'; + return undefined; +} + +export function getMapTilerKey(): string | null { + const k = import.meta.env.VITE_MAPTILER_KEY; + if (typeof k !== 'string') return null; + const v = k.trim(); + return v ? v : null; +} + +export function getLayerId(value: unknown): string | null { + if (!value || typeof value !== 'object') return null; + const candidate = (value as { id?: unknown }).id; + return typeof candidate === 'string' ? candidate : null; +} + +export function sanitizeDeckLayerList(value: unknown): unknown[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const out: unknown[] = []; + let dropped = 0; + + for (const layer of value) { + const layerId = getLayerId(layer); + if (!layerId) { + dropped += 1; + continue; + } + if (seen.has(layerId)) { + dropped += 1; + continue; + } + seen.add(layerId); + out.push(layer); + } + + if (dropped > 0 && import.meta.env.DEV) { + console.warn(`Sanitized deck layer list: dropped ${dropped} invalid/duplicate entries`); + } + + return out; +} diff --git a/apps/web/src/widgets/map3d/lib/mlExpressions.ts b/apps/web/src/widgets/map3d/lib/mlExpressions.ts new file mode 100644 index 0000000..7d9ede2 --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/mlExpressions.ts @@ -0,0 +1,65 @@ +export function makeMmsiPairHighlightExpr(aField: string, bField: string, hoveredMmsiList: number[]) { + if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length < 2) { + return false; + } + const inA = ['in', ['to-number', ['get', aField]], ['literal', hoveredMmsiList]] as unknown[]; + const inB = ['in', ['to-number', ['get', bField]], ['literal', hoveredMmsiList]] as unknown[]; + return ['all', inA, inB] as unknown[]; +} + +export function makeMmsiAnyEndpointExpr(aField: string, bField: string, hoveredMmsiList: number[]) { + if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length === 0) { + return false; + } + const literal = ['literal', hoveredMmsiList] as unknown[]; + return [ + 'any', + ['in', ['to-number', ['get', aField]], literal], + ['in', ['to-number', ['get', bField]], literal], + ] as unknown[]; +} + +export function makeFleetOwnerMatchExpr(hoveredOwnerKeys: string[]) { + if (!Array.isArray(hoveredOwnerKeys) || hoveredOwnerKeys.length === 0) { + return false; + } + const expr = ['match', ['to-string', ['coalesce', ['get', 'ownerKey'], '']]] as unknown[]; + for (const ownerKey of hoveredOwnerKeys) { + expr.push(String(ownerKey), true); + } + expr.push(false); + return expr; +} + +export function makeFleetMemberMatchExpr(hoveredFleetMmsiList: number[]) { + if (!Array.isArray(hoveredFleetMmsiList) || hoveredFleetMmsiList.length === 0) { + return false; + } + const clauses = hoveredFleetMmsiList.map((mmsi) => + ['in', mmsi, ['coalesce', ['get', 'vesselMmsis'], ['literal', []]]] as unknown[], + ); + return ['any', ...clauses] as unknown[]; +} + +export function makeGlobeCircleRadiusExpr() { + const base3 = 4; + const base7 = 6; + const base10 = 8; + const base14 = 11; + + return [ + 'interpolate', + ['linear'], + ['zoom'], + 3, + ['case', ['==', ['get', 'selected'], 1], 4.6, ['==', ['get', 'highlighted'], 1], 4.2, base3], + 7, + ['case', ['==', ['get', 'selected'], 1], 6.8, ['==', ['get', 'highlighted'], 1], 6.2, base7], + 10, + ['case', ['==', ['get', 'selected'], 1], 9.0, ['==', ['get', 'highlighted'], 1], 8.2, base10], + 14, + ['case', ['==', ['get', 'selected'], 1], 11.8, ['==', ['get', 'highlighted'], 1], 10.8, base14], + ]; +} + +export const GLOBE_SHIP_CIRCLE_RADIUS_EXPR = makeGlobeCircleRadiusExpr() as never; diff --git a/apps/web/src/widgets/map3d/lib/setUtils.ts b/apps/web/src/widgets/map3d/lib/setUtils.ts new file mode 100644 index 0000000..fa9b79d --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/setUtils.ts @@ -0,0 +1,79 @@ +export function toNumberSet(values: number[] | undefined | null) { + const out = new Set(); + if (!values) return out; + for (const value of values) { + if (Number.isFinite(value)) { + out.add(value); + } + } + return out; +} + +export function mergeNumberSets(...sets: Set[]) { + const out = new Set(); + for (const s of sets) { + for (const v of s) { + out.add(v); + } + } + return out; +} + +export function makeSetSignature(values: Set) { + return Array.from(values).sort((a, b) => a - b).join(','); +} + +export function toSafeNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + return null; +} + +export function toIntMmsi(value: unknown): number | null { + const n = toSafeNumber(value); + if (n == null) return null; + return Math.trunc(n); +} + +export function isFiniteNumber(x: unknown): x is number { + return typeof x === 'number' && Number.isFinite(x); +} + +export const toNumberArray = (values: unknown): number[] => { + if (values == null) return []; + if (Array.isArray(values)) { + return values as unknown as number[]; + } + if (typeof values === 'number' && Number.isFinite(values)) { + return [values]; + } + if (typeof values === 'string') { + const value = toSafeNumber(Number(values)); + return value == null ? [] : [value]; + } + if (typeof values === 'object') { + if (typeof (values as { [Symbol.iterator]?: unknown })?.[Symbol.iterator] === 'function') { + try { + return Array.from(values as Iterable) as number[]; + } catch { + return []; + } + } + } + return []; +}; + +export const makeUniqueSorted = (values: unknown) => { + const maybeArray = toNumberArray(values); + const normalized = Array.isArray(maybeArray) ? maybeArray : []; + const unique = Array.from(new Set(normalized.filter((value) => Number.isFinite(value)))); + unique.sort((a, b) => a - b); + return unique; +}; + +export const equalNumberArrays = (a: number[], b: number[]) => { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +}; diff --git a/apps/web/src/widgets/map3d/lib/shipUtils.ts b/apps/web/src/widgets/map3d/lib/shipUtils.ts new file mode 100644 index 0000000..7e4fc59 --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/shipUtils.ts @@ -0,0 +1,117 @@ +import type { AisTarget } from '../../../entities/aisTarget/model/types'; +import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; +import { rgbToHex } from '../../../shared/lib/map/palette'; +import { + ANCHOR_SPEED_THRESHOLD_KN, + LEGACY_CODE_COLORS, + MAP_SELECTED_SHIP_RGB, + MAP_HIGHLIGHT_SHIP_RGB, + MAP_DEFAULT_SHIP_RGB, +} from '../constants'; +import { isFiniteNumber } from './setUtils'; +import { normalizeAngleDeg } from './geometry'; + +export function toValidBearingDeg(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + if (value === 511) return null; + if (value < 0) return null; + if (value >= 360) return null; + return value; +} + +export function isAnchoredShip({ + sog, + cog, + heading, +}: { + sog: number | null | undefined; + cog: number | null | undefined; + heading: number | null | undefined; +}): boolean { + if (!isFiniteNumber(sog)) return true; + if (sog <= ANCHOR_SPEED_THRESHOLD_KN) return true; + return toValidBearingDeg(cog) == null && toValidBearingDeg(heading) == null; +} + +export function getDisplayHeading({ + cog, + heading, + offset = 0, +}: { + cog: number | null | undefined; + heading: number | null | undefined; + offset?: number; +}) { + const raw = toValidBearingDeg(cog) ?? toValidBearingDeg(heading) ?? 0; + return normalizeAngleDeg(raw, offset); +} + +export function lightenColor(rgb: [number, number, number], ratio = 0.32) { + const out = rgb.map((v) => Math.round(v + (255 - v) * ratio) as number) as [number, number, number]; + return out; +} + +export function getGlobeBaseShipColor({ + legacy, + sog, +}: { + legacy: string | null; + sog: number | null; +}) { + if (legacy) { + const rgb = LEGACY_CODE_COLORS[legacy]; + if (rgb) return rgbToHex(lightenColor(rgb, 0.38)); + } + + if (!isFiniteNumber(sog)) return 'rgba(100,116,139,0.55)'; + if (sog >= 10) return 'rgba(148,163,184,0.78)'; + if (sog >= 1) return 'rgba(100,116,139,0.74)'; + return 'rgba(71,85,105,0.68)'; +} + +export function getShipColor( + t: AisTarget, + selectedMmsi: number | null, + legacyShipCode: string | null, + highlightedMmsis: Set, +): [number, number, number, number] { + if (selectedMmsi && t.mmsi === selectedMmsi) { + return [MAP_SELECTED_SHIP_RGB[0], MAP_SELECTED_SHIP_RGB[1], MAP_SELECTED_SHIP_RGB[2], 255]; + } + if (highlightedMmsis.has(t.mmsi)) { + return [MAP_HIGHLIGHT_SHIP_RGB[0], MAP_HIGHLIGHT_SHIP_RGB[1], MAP_HIGHLIGHT_SHIP_RGB[2], 235]; + } + if (legacyShipCode) { + const rgb = LEGACY_CODE_COLORS[legacyShipCode]; + if (rgb) return [rgb[0], rgb[1], rgb[2], 235]; + return [245, 158, 11, 235]; + } + if (!isFiniteNumber(t.sog)) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 130]; + if (t.sog >= 10) return [148, 163, 184, 185]; + if (t.sog >= 1) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 175]; + return [71, 85, 105, 165]; +} + +export function buildGlobeShipFeature( + t: AisTarget, + legacy: LegacyVesselInfo | undefined, + selectedMmsi: number | null, + highlightedMmsis: Set, + offset: number, +) { + const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi ? 1 : 0; + const isHighlighted = highlightedMmsis.has(t.mmsi) ? 1 : 0; + const anchored = isAnchoredShip(t); + + return { + mmsi: t.mmsi, + heading: getDisplayHeading({ cog: t.cog, heading: t.heading, offset }), + anchored, + color: getGlobeBaseShipColor({ legacy: legacy?.shipCode ?? null, sog: t.sog ?? null }), + selected: isSelected, + highlighted: isHighlighted, + permitted: legacy ? 1 : 0, + labelName: (t.name || '').trim() || legacy?.shipNameCn || legacy?.shipNameRoman || '', + legacyTag: legacy ? `${legacy.permitNo} (${legacy.shipCode})` : '', + }; +} diff --git a/apps/web/src/widgets/map3d/lib/tooltips.ts b/apps/web/src/widgets/map3d/lib/tooltips.ts new file mode 100644 index 0000000..fb06a29 --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/tooltips.ts @@ -0,0 +1,169 @@ +import type { AisTarget } from '../../../entities/aisTarget/model/types'; +import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; +import { isFiniteNumber, toSafeNumber } from './setUtils'; + +export function formatNm(value: number | null | undefined) { + if (!isFiniteNumber(value)) return '-'; + return `${value.toFixed(2)} NM`; +} + +export function getLegacyTag(legacyHits: Map | null | undefined, mmsi: number) { + const legacy = legacyHits?.get(mmsi); + if (!legacy) return null; + return `${legacy.permitNo} (${legacy.shipCode})`; +} + +export function getTargetName( + mmsi: number, + targetByMmsi: Map, + legacyHits: Map | null | undefined, +) { + const legacy = legacyHits?.get(mmsi); + const target = targetByMmsi.get(mmsi); + return ( + (target?.name || '').trim() || legacy?.shipNameCn || legacy?.shipNameRoman || `MMSI ${mmsi}` + ); +} + +export function getShipTooltipHtml({ + mmsi, + targetByMmsi, + legacyHits, +}: { + mmsi: number; + targetByMmsi: Map; + legacyHits: Map | null | undefined; +}) { + const legacy = legacyHits?.get(mmsi); + const t = targetByMmsi.get(mmsi); + const name = getTargetName(mmsi, targetByMmsi, legacyHits); + const sog = isFiniteNumber(t?.sog) ? t.sog : null; + const cog = isFiniteNumber(t?.cog) ? t.cog : null; + const msg = t?.messageTimestamp ?? null; + const vesselType = t?.vesselType || ''; + + const legacyHtml = legacy + ? `
+
CN Permit · ${legacy.shipCode} · ${legacy.permitNo}
+
유효범위: ${legacy.workSeaArea || '-'}
+
` + : ''; + + return { + html: `
+
${name}
+
MMSI: ${mmsi}${vesselType ? ` · ${vesselType}` : ''}
+
SOG: ${sog ?? '?'} kt · COG: ${cog ?? '?'}°
+ ${msg ? `
${msg}
` : ''} + ${legacyHtml} +
`, + }; +} + +export function getPairLinkTooltipHtml({ + warn, + distanceNm, + aMmsi, + bMmsi, + legacyHits, + targetByMmsi, +}: { + warn: boolean; + distanceNm: number | null | undefined; + aMmsi: number; + bMmsi: number; + legacyHits: Map | null | undefined; + targetByMmsi: Map; +}) { + const d = formatNm(distanceNm); + const a = getTargetName(aMmsi, targetByMmsi, legacyHits); + const b = getTargetName(bMmsi, targetByMmsi, legacyHits); + const aTag = getLegacyTag(legacyHits, aMmsi); + const bTag = getLegacyTag(legacyHits, bMmsi); + return { + html: `
+
쌍 연결
+
${aTag ?? `MMSI ${aMmsi}`}
+
↔ ${bTag ?? `MMSI ${bMmsi}`}
+
거리: ${d} · 상태: ${warn ? '주의' : '정상'}
+
${a} / ${b}
+
`, + }; +} + +export function getFcLinkTooltipHtml({ + suspicious, + distanceNm, + fcMmsi, + otherMmsi, + legacyHits, + targetByMmsi, +}: { + suspicious: boolean; + distanceNm: number | null | undefined; + fcMmsi: number; + otherMmsi: number; + legacyHits: Map | null | undefined; + targetByMmsi: Map; +}) { + const d = formatNm(distanceNm); + const a = getTargetName(fcMmsi, targetByMmsi, legacyHits); + const b = getTargetName(otherMmsi, targetByMmsi, legacyHits); + const aTag = getLegacyTag(legacyHits, fcMmsi); + const bTag = getLegacyTag(legacyHits, otherMmsi); + return { + html: `
+
환적 연결
+
${aTag ?? `MMSI ${fcMmsi}`}
+
→ ${bTag ?? `MMSI ${otherMmsi}`}
+
거리: ${d} · 상태: ${suspicious ? '의심' : '일반'}
+
${a} / ${b}
+
`, + }; +} + +export function getRangeTooltipHtml({ + warn, + distanceNm, + aMmsi, + bMmsi, + legacyHits, +}: { + warn: boolean; + distanceNm: number | null | undefined; + aMmsi: number; + bMmsi: number; + legacyHits: Map | null | undefined; +}) { + const d = formatNm(distanceNm); + const aTag = getLegacyTag(legacyHits, aMmsi); + const bTag = getLegacyTag(legacyHits, bMmsi); + const radiusNm = toSafeNumber(distanceNm); + return { + html: `
+
쌍 연결범위
+
${aTag ?? `MMSI ${aMmsi}`}
+
↔ ${bTag ?? `MMSI ${bMmsi}`}
+
범위: ${d} · 반경: ${formatNm(radiusNm == null ? null : radiusNm / 2)} · 상태: ${warn ? '주의' : '정상'}
+
`, + }; +} + +export function getFleetCircleTooltipHtml({ + ownerKey, + ownerLabel, + count, +}: { + ownerKey: string; + ownerLabel?: string; + count: number; +}) { + const displayOwner = ownerLabel && ownerLabel.trim() ? ownerLabel : ownerKey; + return { + html: `
+
선단 범위
+
소유주: ${displayOwner || '-'}
+
선박 수: ${count}
+
`, + }; +} diff --git a/apps/web/src/widgets/map3d/lib/zoneUtils.ts b/apps/web/src/widgets/map3d/lib/zoneUtils.ts new file mode 100644 index 0000000..086147c --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/zoneUtils.ts @@ -0,0 +1,40 @@ +import type { ZoneId } from '../../../entities/zone/model/meta'; +import { ZONE_META } from '../../../entities/zone/model/meta'; + +function toTextValue(value: unknown): string { + if (value == null) return ''; + return String(value).trim(); +} + +export function getZoneIdFromProps(props: Record | null | undefined): string { + const safeProps = props || {}; + const candidates = [ + 'zoneId', + 'zone_id', + 'zoneIdNo', + 'zoneKey', + 'zoneCode', + 'ZONE_ID', + 'ZONECODE', + 'id', + ]; + + for (const key of candidates) { + const value = toTextValue(safeProps[key]); + if (value) return value; + } + + return ''; +} + +export function getZoneDisplayNameFromProps(props: Record | null | undefined): string { + const safeProps = props || {}; + const nameCandidates = ['zoneName', 'zoneLabel', 'NAME', 'name', 'ZONE_NM', 'label']; + for (const key of nameCandidates) { + const name = toTextValue(safeProps[key]); + if (name) return name; + } + const zoneId = getZoneIdFromProps(safeProps); + if (!zoneId) return '수역'; + return ZONE_META[(zoneId as ZoneId)]?.name || `수역 ${zoneId}`; +} diff --git a/apps/web/src/widgets/map3d/types.ts b/apps/web/src/widgets/map3d/types.ts new file mode 100644 index 0000000..b884e4a --- /dev/null +++ b/apps/web/src/widgets/map3d/types.ts @@ -0,0 +1,72 @@ +import type { AisTarget } from '../../entities/aisTarget/model/types'; +import type { LegacyVesselInfo } from '../../entities/legacyVessel/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'; + +export type Map3DSettings = { + showSeamark: boolean; + showShips: boolean; + showDensity: boolean; +}; + +export type BaseMapId = 'enhanced' | 'legacy'; +export type MapProjectionId = 'mercator' | 'globe'; + +export interface Map3DProps { + targets: AisTarget[]; + zones: ZonesGeoJson | null; + selectedMmsi: number | null; + hoveredMmsiSet?: number[]; + hoveredFleetMmsiSet?: number[]; + hoveredPairMmsiSet?: number[]; + hoveredFleetOwnerKey?: string | null; + highlightedMmsiSet?: number[]; + settings: Map3DSettings; + baseMap: BaseMapId; + projection: MapProjectionId; + overlays: MapToggleState; + onSelectMmsi: (mmsi: number | null) => void; + onToggleHighlightMmsi?: (mmsi: number) => void; + onViewBboxChange?: (bbox: [number, number, number, number]) => void; + legacyHits?: Map | null; + pairLinks?: PairLink[]; + fcLinks?: FcLink[]; + fleetCircles?: FleetCircle[]; + onProjectionLoadingChange?: (loading: boolean) => void; + fleetFocus?: { + id: string | number; + center: [number, number]; + zoom?: number; + }; + onHoverFleet?: (ownerKey: string | null, fleetMmsiSet: number[]) => void; + onClearFleetHover?: () => void; + onHoverMmsi?: (mmsiList: number[]) => void; + onClearMmsiHover?: () => void; + onHoverPair?: (mmsiList: number[]) => void; + onClearPairHover?: () => void; +} + +export type DashSeg = { + from: [number, number]; + to: [number, number]; + suspicious: boolean; + distanceNm?: number; + fromMmsi?: number; + toMmsi?: number; +}; + +export type PairRangeCircle = { + center: [number, number]; // [lon, lat] + radiusNm: number; + warn: boolean; + aMmsi: number; + bMmsi: number; + distanceNm: number; +}; + +export type BathyZoomRange = { + id: string; + mercator: [number, number]; + globe: [number, number]; +}; From 864fc44d0ed06d8cecf564272432c96f5ffbc66c Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 00:41:11 +0900 Subject: [PATCH 44/58] =?UTF-8?q?refactor(map):=20Map3D.tsx=20hooks=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=EC=99=84=EB=A3=8C=20(4558=EC=A4=84=20?= =?UTF-8?q?=E2=86=92=20510=EC=A4=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/widgets/map3d/Map3D.tsx | 4281 +---------------- .../widgets/map3d/hooks/useBaseMapToggle.ts | 132 + .../src/widgets/map3d/hooks/useDeckLayers.ts | 665 +++ apps/web/src/widgets/map3d/hooks/useFlyTo.ts | 61 + .../map3d/hooks/useGlobeInteraction.ts | 318 ++ .../widgets/map3d/hooks/useGlobeOverlays.ts | 618 +++ .../src/widgets/map3d/hooks/useGlobeShips.ts | 1029 ++++ .../web/src/widgets/map3d/hooks/useMapInit.ts | 196 + .../map3d/hooks/usePredictionVectors.ts | 210 + .../map3d/hooks/useProjectionToggle.ts | 324 ++ .../src/widgets/map3d/hooks/useZonesLayer.ts | 230 + .../web/src/widgets/map3d/lib/layerHelpers.ts | 56 + 12 files changed, 3955 insertions(+), 4165 deletions(-) create mode 100644 apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts create mode 100644 apps/web/src/widgets/map3d/hooks/useDeckLayers.ts create mode 100644 apps/web/src/widgets/map3d/hooks/useFlyTo.ts create mode 100644 apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts create mode 100644 apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts create mode 100644 apps/web/src/widgets/map3d/hooks/useGlobeShips.ts create mode 100644 apps/web/src/widgets/map3d/hooks/useMapInit.ts create mode 100644 apps/web/src/widgets/map3d/hooks/usePredictionVectors.ts create mode 100644 apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts create mode 100644 apps/web/src/widgets/map3d/hooks/useZonesLayer.ts diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 1064450..1d519c8 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -1,120 +1,32 @@ -import { HexagonLayer } from "@deck.gl/aggregation-layers"; -import { IconLayer, LineLayer, ScatterplotLayer } from "@deck.gl/layers"; -import { MapboxOverlay } from "@deck.gl/mapbox"; -import { type PickingInfo } from "@deck.gl/core"; -import maplibregl, { - type GeoJSONSource, - type GeoJSONSourceSpecification, - type LayerSpecification, - type StyleSpecification, -} from "maplibre-gl"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { AisTarget } from "../../entities/aisTarget/model/types"; -import type { ZoneId } from "../../entities/zone/model/meta"; -import { ZONE_META } from "../../entities/zone/model/meta"; -import type { FleetCircle, PairLink } from "../../features/legacyDashboard/model/types"; -import { LEGACY_CODE_COLORS_RGB, OTHER_AIS_SPEED_RGB, rgba as rgbaCss } from "../../shared/lib/map/palette"; -import { MaplibreDeckCustomLayer } from "./MaplibreDeckCustomLayer"; -import type { BaseMapId, Map3DProps, MapProjectionId } from "./types"; -import type { DashSeg, PairRangeCircle } from "./types"; -import { - SHIP_ICON_MAPPING, - ANCHORED_SHIP_ICON_ID, - DEG2RAD, - GLOBE_ICON_HEADING_OFFSET_DEG, - FLAT_SHIP_ICON_SIZE, - FLAT_SHIP_ICON_SIZE_SELECTED, - FLAT_SHIP_ICON_SIZE_HIGHLIGHTED, - FLAT_LEGACY_HALO_RADIUS, - FLAT_LEGACY_HALO_RADIUS_SELECTED, - FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED, - EMPTY_MMSI_SET, - DECK_VIEW_ID, - DEPTH_DISABLED_PARAMS, - GLOBE_OVERLAY_PARAMS, - LEGACY_CODE_COLORS, - PAIR_RANGE_NORMAL_DECK, - PAIR_RANGE_WARN_DECK, - PAIR_LINE_NORMAL_DECK, - PAIR_LINE_WARN_DECK, - FC_LINE_NORMAL_DECK, - FC_LINE_SUSPICIOUS_DECK, - FLEET_RANGE_LINE_DECK, - FLEET_RANGE_FILL_DECK, - PAIR_RANGE_NORMAL_DECK_HL, - PAIR_RANGE_WARN_DECK_HL, - PAIR_LINE_NORMAL_DECK_HL, - PAIR_LINE_WARN_DECK_HL, - FC_LINE_NORMAL_DECK_HL, - FC_LINE_SUSPICIOUS_DECK_HL, - FLEET_RANGE_LINE_DECK_HL, - FLEET_RANGE_FILL_DECK_HL, - PAIR_LINE_NORMAL_ML, - PAIR_LINE_WARN_ML, - PAIR_LINE_NORMAL_ML_HL, - PAIR_LINE_WARN_ML_HL, - PAIR_RANGE_NORMAL_ML, - PAIR_RANGE_WARN_ML, - PAIR_RANGE_NORMAL_ML_HL, - PAIR_RANGE_WARN_ML_HL, - FC_LINE_NORMAL_ML, - FC_LINE_SUSPICIOUS_ML, - FC_LINE_NORMAL_ML_HL, - FC_LINE_SUSPICIOUS_ML_HL, - FLEET_FILL_ML, - FLEET_FILL_ML_HL, - FLEET_LINE_ML, - FLEET_LINE_ML_HL, -} from "./constants"; +import { MapboxOverlay } from '@deck.gl/mapbox'; +import maplibregl from 'maplibre-gl'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { AisTarget } from '../../entities/aisTarget/model/types'; +import { MaplibreDeckCustomLayer } from './MaplibreDeckCustomLayer'; +import type { BaseMapId, Map3DProps, MapProjectionId } from './types'; +import type { DashSeg, PairRangeCircle } from './types'; import { mergeNumberSets, makeSetSignature, isFiniteNumber, - toSafeNumber, toIntMmsi, makeUniqueSorted, equalNumberArrays, -} from "./lib/setUtils"; -import { getZoneIdFromProps, getZoneDisplayNameFromProps } from "./lib/zoneUtils"; -import { - makePairLinkFeatureId, - makeFcSegmentFeatureId, - makeFleetCircleFeatureId, -} from "./lib/featureIds"; -import { - makeMmsiPairHighlightExpr, - makeMmsiAnyEndpointExpr, - makeFleetOwnerMatchExpr, - makeFleetMemberMatchExpr, - GLOBE_SHIP_CIRCLE_RADIUS_EXPR, -} from "./lib/mlExpressions"; -import { - toValidBearingDeg, - isAnchoredShip, - getDisplayHeading, - lightenColor, - getGlobeBaseShipColor, - getShipColor, -} from "./lib/shipUtils"; -import { - getShipTooltipHtml, - getPairLinkTooltipHtml, - getFcLinkTooltipHtml, - getRangeTooltipHtml, - getFleetCircleTooltipHtml, -} from "./lib/tooltips"; -import { - buildFallbackGlobeAnchoredShipIcon, - ensureFallbackShipImage, -} from "./lib/globeShipIcon"; -import { kickRepaint, onMapStyleReady, extractProjectionType, getLayerId, sanitizeDeckLayerList } from "./lib/mapCore"; -import { destinationPointLngLat, circleRingLngLat, clampNumber } from "./lib/geometry"; -import { dashifyLine } from "./lib/dashifyLine"; -import { ensureSeamarkOverlay } from "./layers/seamark"; -import { applyBathymetryZoomProfile, resolveMapStyle } from "./layers/bathymetry"; -import { useHoverState } from "./hooks/useHoverState"; +} from './lib/setUtils'; +import { dashifyLine } from './lib/dashifyLine'; +import { useHoverState } from './hooks/useHoverState'; +import { useMapInit } from './hooks/useMapInit'; +import { useProjectionToggle } from './hooks/useProjectionToggle'; +import { useBaseMapToggle } from './hooks/useBaseMapToggle'; +import { useFlyTo } from './hooks/useFlyTo'; +import { useZonesLayer } from './hooks/useZonesLayer'; +import { usePredictionVectors } from './hooks/usePredictionVectors'; +import { useGlobeShips } from './hooks/useGlobeShips'; +import { useGlobeOverlays } from './hooks/useGlobeOverlays'; +import { useGlobeInteraction } from './hooks/useGlobeInteraction'; +import { useDeckLayers } from './hooks/useDeckLayers'; -export type { Map3DSettings, BaseMapId, MapProjectionId } from "./types"; +export type { Map3DSettings, BaseMapId, MapProjectionId } from './types'; type Props = Map3DProps; @@ -155,24 +67,22 @@ export function Map3D({ void onHoverPair; void onClearPairHover; + // ── Shared refs ────────────────────────────────────────────────────── const containerRef = useRef(null); const mapRef = useRef(null); const overlayRef = useRef(null); const overlayInteractionRef = useRef(null); const globeDeckLayerRef = useRef(null); - const globeShipsEpochRef = useRef(-1); - const showSeamarkRef = useRef(settings.showSeamark); const baseMapRef = useRef(baseMap); const projectionRef = useRef(projection); - const globeShipIconLoadingRef = useRef(false); const projectionBusyRef = useRef(false); - const projectionBusyTokenRef = useRef(0); - const projectionBusyTimerRef = useRef | null>(null); - const projectionPrevRef = useRef(projection); - const bathyZoomProfileKeyRef = useRef(""); - const mapTooltipRef = useRef(null); const deckHoverRafRef = useRef(null); const deckHoverHasHitRef = useRef(false); + + useEffect(() => { baseMapRef.current = baseMap; }, [baseMap]); + useEffect(() => { projectionRef.current = projection; }, [projection]); + + // ── Hover state ────────────────────────────────────────────────────── const { setHoveredDeckMmsiSet, setHoveredDeckPairMmsiSet, @@ -187,50 +97,13 @@ export function Map3D({ hoveredMmsiSet, hoveredFleetMmsiSet, hoveredPairMmsiSet, hoveredFleetOwnerKey, highlightedMmsiSet, }); + const fleetFocusId = fleetFocus?.id; const fleetFocusLon = fleetFocus?.center?.[0]; const fleetFocusLat = fleetFocus?.center?.[1]; const fleetFocusZoom = fleetFocus?.zoom; - const reorderGlobeFeatureLayers = useCallback(() => { - const map = mapRef.current; - if (!map || projectionRef.current !== "globe") return; - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - - const ordering = [ - "zones-fill", - "zones-line", - "zones-label", - "predict-vectors-outline", - "predict-vectors", - "predict-vectors-hl-outline", - "predict-vectors-hl", - "ships-globe-halo", - "ships-globe-outline", - "ships-globe", - "ships-globe-label", - "ships-globe-hover-halo", - "ships-globe-hover-outline", - "ships-globe-hover", - "pair-lines-ml", - "fc-lines-ml", - "pair-range-ml", - "fleet-circles-ml-fill", - "fleet-circles-ml", - ]; - - for (const layerId of ordering) { - try { - if (map.getLayer(layerId)) map.moveLayer(layerId); - } catch { - // ignore - } - } - - kickRepaint(map); - }, []); - + // ── Highlight memos ────────────────────────────────────────────────── const effectiveHoveredPairMmsiSet = useMemo( () => mergeNumberSets(hoveredPairMmsiSetRef, hoveredDeckPairMmsiSetRef), [hoveredPairMmsiSetRef, hoveredDeckPairMmsiSetRef], @@ -258,7 +131,7 @@ export function Map3D({ ], ); const highlightedMmsiSetForShips = useMemo( - () => (projection === "globe" ? mergeNumberSets(hoveredMmsiSetRef, externalHighlightedSetRef) : highlightedMmsiSetCombined), + () => (projection === 'globe' ? mergeNumberSets(hoveredMmsiSetRef, externalHighlightedSetRef) : highlightedMmsiSetCombined), [projection, hoveredMmsiSetRef, externalHighlightedSetRef, highlightedMmsiSetCombined], ); const hoveredShipSignature = useMemo( @@ -274,6 +147,7 @@ export function Map3D({ effectiveHoveredPairMmsiSet, ], ); + void hoveredShipSignature; const hoveredPairMmsiList = useMemo(() => makeUniqueSorted(Array.from(effectiveHoveredPairMmsiSet)), [effectiveHoveredPairMmsiSet]); const hoveredFleetOwnerKeyList = useMemo(() => Array.from(hoveredFleetOwnerKeys).sort(), [hoveredFleetOwnerKeys]); const hoveredFleetMmsiList = useMemo(() => makeUniqueSorted(Array.from(effectiveHoveredFleetMmsiSet)), [effectiveHoveredFleetMmsiSet]); @@ -285,16 +159,13 @@ export function Map3D({ const baseHighlightedMmsiSet = useMemo(() => { const out = new Set(); if (selectedMmsi != null) out.add(selectedMmsi); - externalHighlightedSetRef.forEach((value) => { - out.add(value); - }); + externalHighlightedSetRef.forEach((value) => { out.add(value); }); return out; }, [selectedMmsi, externalHighlightedSetRef]); const isBaseHighlightedMmsi = useCallback( (mmsi: number) => baseHighlightedMmsiSet.has(mmsi), [baseHighlightedMmsiSet], ); - const isHighlightedPair = useCallback( (aMmsi: number, bMmsi: number) => effectiveHoveredPairMmsiSet.size === 2 && @@ -302,7 +173,6 @@ export function Map3D({ effectiveHoveredPairMmsiSet.has(bMmsi), [effectiveHoveredPairMmsiSet], ); - const isHighlightedFleet = useCallback( (ownerKey: string, vesselMmsis: number[]) => { if (hoveredFleetOwnerKeys.has(ownerKey)) return true; @@ -311,6 +181,7 @@ export function Map3D({ [hoveredFleetOwnerKeys, isHighlightedMmsi], ); + // ── Ship data memos ────────────────────────────────────────────────── const shipData = useMemo(() => { return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi)); }, [targets]); @@ -334,7 +205,7 @@ export function Map3D({ const shipHoverOverlaySet = useMemo( () => - projection === "globe" + projection === 'globe' ? mergeNumberSets(highlightedMmsiSetCombined, shipHighlightSet) : shipHighlightSet, [projection, highlightedMmsiSetCombined, shipHighlightSet], @@ -343,16 +214,12 @@ export function Map3D({ const shipOverlayLayerData = useMemo(() => { if (shipLayerData.length === 0) return []; if (shipHighlightSet.size === 0) return []; - return shipLayerData.filter((target) => shipHighlightSet.has(target.mmsi)); }, [shipHighlightSet, shipLayerData]); + // ── Deck hover management ──────────────────────────────────────────── const hasAuxiliarySelectModifier = useCallback( - (ev?: { - shiftKey?: boolean; - ctrlKey?: boolean; - metaKey?: boolean; - } | null): boolean => { + (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null): boolean => { if (!ev) return false; return !!(ev.shiftKey || ev.ctrlKey || ev.metaKey); }, @@ -392,22 +259,23 @@ export function Map3D({ [hasAuxiliarySelectModifier, onSelectMmsi, onToggleHighlightMmsi, selectedMmsi], ); + // eslint-disable-next-line react-hooks/preserve-manual-memoization const setHoveredDeckFleetMmsis = useCallback((next: number[]) => { const normalized = makeUniqueSorted(next); setHoveredDeckFleetMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); }, []); + // eslint-disable-next-line react-hooks/preserve-manual-memoization const setHoveredDeckFleetOwner = useCallback((ownerKey: string | null) => { setHoveredDeckFleetOwnerKey((prev) => (prev === ownerKey ? prev : ownerKey)); }, []); const mapDeckMmsiHoverRef = useRef([]); const mapDeckPairHoverRef = useRef([]); - const mapFleetHoverStateRef = useRef<{ - ownerKey: string | null; - vesselMmsis: number[]; - }>({ ownerKey: null, vesselMmsis: [] }); - const globeHoverShipSignatureRef = useRef(""); + const mapFleetHoverStateRef = useRef<{ ownerKey: string | null; vesselMmsis: number[] }>({ + ownerKey: null, + vesselMmsis: [], + }); const clearMapFleetHoverState = useCallback(() => { mapFleetHoverStateRef.current = { ownerKey: null, vesselMmsis: [] }; @@ -481,6 +349,7 @@ export function Map3D({ [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis, touchDeckHoverState], ); + // hover RAF cleanup useEffect(() => { return () => { if (deckHoverRafRef.current != null) { @@ -491,6 +360,7 @@ export function Map3D({ }; }, []); + // sync external fleet hover state useEffect(() => { mapFleetHoverStateRef.current = { ownerKey: hoveredFleetOwnerKey, @@ -498,3051 +368,7 @@ export function Map3D({ }; }, [hoveredFleetOwnerKey, hoveredFleetMmsiSet]); - const clearProjectionBusyTimer = useCallback(() => { - if (projectionBusyTimerRef.current == null) return; - clearTimeout(projectionBusyTimerRef.current); - projectionBusyTimerRef.current = null; - }, []); - - const endProjectionLoading = useCallback(() => { - if (!projectionBusyRef.current) return; - projectionBusyRef.current = false; - clearProjectionBusyTimer(); - if (onProjectionLoadingChange) { - onProjectionLoadingChange(false); - } - // Many layer "ensure" functions bail out while projectionBusyRef is true. - // Trigger a sync pulse when loading ends so globe/mercator layers appear immediately - // without requiring a user toggle (e.g., industry filter). - setMapSyncEpoch((prev) => prev + 1); - kickRepaint(mapRef.current); - }, [clearProjectionBusyTimer, onProjectionLoadingChange]); - - const setProjectionLoading = useCallback( - (loading: boolean) => { - if (projectionBusyRef.current === loading) return; - if (!loading) { - endProjectionLoading(); - return; - } - - clearProjectionBusyTimer(); - projectionBusyRef.current = true; - const token = ++projectionBusyTokenRef.current; - if (onProjectionLoadingChange) { - onProjectionLoadingChange(true); - } - - projectionBusyTimerRef.current = setTimeout(() => { - if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return; - console.debug("Projection loading fallback timeout reached."); - endProjectionLoading(); - }, 4000); - }, - [clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange], - ); - - const pulseMapSync = () => { - setMapSyncEpoch((prev) => prev + 1); - requestAnimationFrame(() => { - kickRepaint(mapRef.current); - setMapSyncEpoch((prev) => prev + 1); - }); - }; - - useEffect(() => { - return () => { - clearProjectionBusyTimer(); - endProjectionLoading(); - }; - }, [clearProjectionBusyTimer, endProjectionLoading]); - - useEffect(() => { - showSeamarkRef.current = settings.showSeamark; - }, [settings.showSeamark]); - - useEffect(() => { - baseMapRef.current = baseMap; - }, [baseMap]); - - useEffect(() => { - projectionRef.current = projection; - }, [projection]); - - const removeLayerIfExists = useCallback((map: maplibregl.Map, layerId: string | null | undefined) => { - if (!layerId) return; - try { - if (map.getLayer(layerId)) { - map.removeLayer(layerId); - } - } catch { - // ignore - } - }, []); - - const removeSourceIfExists = useCallback((map: maplibregl.Map, sourceId: string) => { - try { - if (map.getSource(sourceId)) { - map.removeSource(sourceId); - } - } catch { - // ignore - } - }, []); - - const ensureMercatorOverlay = useCallback(() => { - const map = mapRef.current; - if (!map) return null; - if (overlayRef.current) return overlayRef.current; - try { - const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never); - map.addControl(next); - overlayRef.current = next; - return next; - } catch (e) { - console.warn("Deck overlay create failed:", e); - return null; - } - }, []); - - const clearGlobeNativeLayers = useCallback(() => { - const map = mapRef.current; - if (!map) return; - - const layerIds = [ - "ships-globe-halo", - "ships-globe-outline", - "ships-globe", - "ships-globe-label", - "ships-globe-hover-halo", - "ships-globe-hover-outline", - "ships-globe-hover", - "pair-lines-ml", - "fc-lines-ml", - "fleet-circles-ml-fill", - "fleet-circles-ml", - "pair-range-ml", - "deck-globe", - ]; - - for (const id of layerIds) { - removeLayerIfExists(map, id); - } - - const sourceIds = [ - "ships-globe-src", - "ships-globe-hover-src", - "pair-lines-ml-src", - "fc-lines-ml-src", - "fleet-circles-ml-src", - "fleet-circles-ml-fill-src", - "pair-range-ml-src", - ]; - for (const id of sourceIds) { - removeSourceIfExists(map, id); - } - }, [removeLayerIfExists, removeSourceIfExists]); - - // Init MapLibre + Deck.gl (single WebGL context via MapboxOverlay) - useEffect(() => { - if (!containerRef.current || mapRef.current) return; - - let map: maplibregl.Map | null = null; - let cancelled = false; - const controller = new AbortController(); - - (async () => { - let style: string | StyleSpecification = "/map/styles/osm-seamark.json"; - try { - style = await resolveMapStyle(baseMapRef.current, controller.signal); - } catch (e) { - // Don't block the app if MapTiler isn't configured yet. - // This is expected in early dev environments without `VITE_MAPTILER_KEY`. - console.warn("Map style init failed, falling back to local raster style:", e); - style = "/map/styles/osm-seamark.json"; - } - if (cancelled || !containerRef.current) return; - - map = new maplibregl.Map({ - container: containerRef.current, - style, - center: [126.5, 34.2], - zoom: 7, - pitch: 45, - bearing: 0, - maxPitch: 85, - dragRotate: true, - pitchWithRotate: true, - touchPitch: true, - scrollZoom: { around: "center" }, - }); - - map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), "top-left"); - map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: "metric" }), "bottom-left"); - - mapRef.current = map; - - // Initial Deck integration: - // - mercator: MapboxOverlay interleaved (fast, feature-rich) - // - globe: MapLibre custom layer that feeds Deck the globe MVP matrix (keeps basemap+layers aligned) - if (projectionRef.current === "mercator") { - const overlay = ensureMercatorOverlay(); - if (!overlay) return; - overlayRef.current = overlay; - } else { - globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ - id: "deck-globe", - viewId: DECK_VIEW_ID, - deckProps: { layers: [] }, - }); - } - - function applyProjection() { - if (!map) return; - const next = projectionRef.current; - if (next === "mercator") return; - try { - map.setProjection({ type: next }); - // Globe mode renders a single world; copies can look odd and aren't needed for KR region. - map.setRenderWorldCopies(next !== "globe"); - } catch (e) { - console.warn("Projection apply failed:", e); - } - } - - // Ensure the seamark raster overlay exists even when using MapTiler vector styles. - onMapStyleReady(map, () => { - applyProjection(); - // Globe deck layer lives inside the style and must be re-added after any style swap. - const deckLayer = globeDeckLayerRef.current; - if (projectionRef.current === "globe" && deckLayer && !map!.getLayer(deckLayer.id)) { - try { - map!.addLayer(deckLayer); - } catch { - // ignore - } - } - if (!showSeamarkRef.current) return; - try { - ensureSeamarkOverlay(map!, "bathymetry-lines"); - } catch { - // ignore (style not ready / already has it) - } - }); - - // Send initial bbox and update on move end (useful for lists / debug) - const emitBbox = () => { - const cb = onViewBboxChange; - if (!cb || !map) return; - const b = map.getBounds(); - cb([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]); - }; - map.on("load", emitBbox); - map.on("moveend", emitBbox); - - function applySeamarkOpacity() { - if (!map) return; - const opacity = settings.showSeamark ? 0.85 : 0; - try { - map.setPaintProperty("seamark", "raster-opacity", opacity); - } catch { - // style not ready yet - } - } - - map.once("load", () => { - if (showSeamarkRef.current) { - try { - ensureSeamarkOverlay(map!, "bathymetry-lines"); - } catch { - // ignore - } - applySeamarkOpacity(); - } - }); - })(); - - return () => { - cancelled = true; - controller.abort(); - - // If we are unmounting, ensure the globe Deck instance is finalized (style reload would keep it alive). - try { - globeDeckLayerRef.current?.requestFinalize(); - } catch { - // ignore - } - - if (map) { - map.remove(); - map = null; - } - if (overlayRef.current) { - overlayRef.current.finalize(); - overlayRef.current = null; - } - if (overlayInteractionRef.current) { - overlayInteractionRef.current.finalize(); - overlayInteractionRef.current = null; - } - globeDeckLayerRef.current = null; - mapRef.current = null; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Projection toggle (mercator <-> globe) - useEffect(() => { - const map = mapRef.current; - if (!map) return; - let cancelled = false; - let retries = 0; - const maxRetries = 18; - const isTransition = projectionPrevRef.current !== projection; - projectionPrevRef.current = projection; - let settleScheduled = false; - let settleCleanup: (() => void) | null = null; - - const startProjectionSettle = () => { - if (!isTransition || settleScheduled) return; - settleScheduled = true; - - const finalize = () => { - if (!cancelled && isTransition) setProjectionLoading(false); - }; - - const finalizeSoon = () => { - if (cancelled || !isTransition || projectionBusyRef.current === false) return; - if (!map.isStyleLoaded()) { - requestAnimationFrame(finalizeSoon); - return; - } - requestAnimationFrame(finalize); - }; - - const onIdle = () => finalizeSoon(); - try { - map.on("idle", onIdle); - const styleReadyCleanup = onMapStyleReady(map, finalizeSoon); - settleCleanup = () => { - map.off("idle", onIdle); - styleReadyCleanup(); - }; - } catch { - requestAnimationFrame(finalize); - settleCleanup = null; - } - - finalizeSoon(); - }; - - if (isTransition) setProjectionLoading(true); - - const disposeMercatorOverlays = () => { - const disposeOne = (target: MapboxOverlay | null, toNull: "base" | "interaction") => { - if (!target) return; - try { - target.setProps({ layers: [] } as never); - } catch { - // ignore - } - try { - map.removeControl(target as never); - } catch { - // ignore - } - try { - target.finalize(); - } catch { - // ignore - } - if (toNull === "base") { - overlayRef.current = null; - } else { - overlayInteractionRef.current = null; - } - }; - - disposeOne(overlayRef.current, "base"); - disposeOne(overlayInteractionRef.current, "interaction"); - }; - - const disposeGlobeDeckLayer = () => { - const current = globeDeckLayerRef.current; - if (!current) return; - removeLayerIfExists(map, current.id); - try { - current.requestFinalize(); - } catch { - // ignore - } - globeDeckLayerRef.current = null; - }; - - const syncProjectionAndDeck = () => { - if (cancelled) return; - if (!isTransition) { - return; - } - - if (!map.isStyleLoaded()) { - if (!cancelled && retries < maxRetries) { - retries += 1; - window.requestAnimationFrame(() => syncProjectionAndDeck()); - } - return; - } - - const next = projection; - const currentProjection = extractProjectionType(map); - const shouldSwitchProjection = currentProjection !== next; - - if (projection === "globe") { - disposeMercatorOverlays(); - clearGlobeNativeLayers(); - } else { - disposeGlobeDeckLayer(); - clearGlobeNativeLayers(); - } - - try { - if (shouldSwitchProjection) { - map.setProjection({ type: next }); - } - map.setRenderWorldCopies(next !== "globe"); - if (shouldSwitchProjection && currentProjection !== next && !cancelled && retries < maxRetries) { - retries += 1; - window.requestAnimationFrame(() => syncProjectionAndDeck()); - return; - } - } catch (e) { - if (!cancelled && retries < maxRetries) { - retries += 1; - window.requestAnimationFrame(() => syncProjectionAndDeck()); - return; - } - if (isTransition) setProjectionLoading(false); - console.warn("Projection switch failed:", e); - } - - if (projection === "globe") { - // Start with a clean globe Deck layer state to avoid partially torn-down renders. - disposeGlobeDeckLayer(); - - if (!globeDeckLayerRef.current) { - globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ - id: "deck-globe", - viewId: DECK_VIEW_ID, - deckProps: { layers: [] }, - }); - } - - const layer = globeDeckLayerRef.current; - const layerId = layer?.id; - if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) { - try { - map.addLayer(layer); - } catch { - // ignore - } - if (!map.getLayer(layerId) && !cancelled && retries < maxRetries) { - retries += 1; - window.requestAnimationFrame(() => syncProjectionAndDeck()); - return; - } - } - } else { - // Tear down globe custom layer (if present), restore MapboxOverlay interleaved. - disposeGlobeDeckLayer(); - - ensureMercatorOverlay(); - } - - // MapLibre may not schedule a frame immediately after projection swaps if the map is idle. - // Kick a few repaints so overlay sources (ships/zones) appear instantly. - reorderGlobeFeatureLayers(); - kickRepaint(map); - try { - map.resize(); - } catch { - // ignore - } - if (isTransition) { - startProjectionSettle(); - } - pulseMapSync(); - }; - - if (!isTransition) return; - - if (map.isStyleLoaded()) syncProjectionAndDeck(); - else { - const stop = onMapStyleReady(map, syncProjectionAndDeck); - return () => { - cancelled = true; - if (settleCleanup) settleCleanup(); - stop(); - if (isTransition) setProjectionLoading(false); - }; - } - - return () => { - cancelled = true; - if (settleCleanup) settleCleanup(); - if (isTransition) setProjectionLoading(false); - }; - }, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, removeLayerIfExists, reorderGlobeFeatureLayers, setProjectionLoading]); - - // Base map toggle - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - let cancelled = false; - const controller = new AbortController(); - let stop: (() => void) | null = null; - - (async () => { - try { - const style = await resolveMapStyle(baseMap, controller.signal); - if (cancelled) return; - // Disable style diff to avoid warnings with custom layers (Deck MapboxOverlay) and - // to ensure a clean rebuild when switching between very different styles. - map.setStyle(style, { diff: false }); - stop = onMapStyleReady(map, () => { - kickRepaint(map); - requestAnimationFrame(() => kickRepaint(map)); - pulseMapSync(); - }); - } catch (e) { - if (cancelled) return; - console.warn("Base map switch failed:", e); - } - })(); - - return () => { - cancelled = true; - controller.abort(); - stop?.(); - }; - }, [baseMap]); - - // Globe rendering + bathymetry tuning. - // Under globe projection, low-zoom bathymetry polygons can exceed MapLibre's per-segment 16-bit vertex - // limit (65535) due to projection subdivision. Keep globe stable by gating heavy bathymetry fills/borders - // to higher zoom levels rather than toggling them on every frame. - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const apply = () => { - if (!map.isStyleLoaded()) return; - const seaVisibility = "visible" as const; - const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i; - - // Apply zoom gating for heavy bathymetry layers once per (baseMap, projection) combination. - // This avoids repeatedly mutating layer zoom ranges on hover/mapSyncEpoch pulses. - const nextProfileKey = `bathyZoomV1|${baseMap}|${projection}`; - if (bathyZoomProfileKeyRef.current !== nextProfileKey) { - applyBathymetryZoomProfile(map, baseMap, projection); - bathyZoomProfileKeyRef.current = nextProfileKey; - kickRepaint(map); - } - - // Vector basemap water layers can be tuned per-style. Keep visible by default, - // only toggling layers that match an explicit water/sea signature. - try { - const style = map.getStyle(); - const styleLayers = style && Array.isArray(style.layers) ? style.layers : []; - for (const layer of styleLayers) { - const id = getLayerId(layer); - if (!id) continue; - const sourceLayer = String((layer as Record)["source-layer"] ?? "").toLowerCase(); - const source = String((layer as { source?: unknown }).source ?? "").toLowerCase(); - const type = String((layer as { type?: unknown }).type ?? "").toLowerCase(); - const isSea = seaRegex.test(id) || seaRegex.test(sourceLayer) || seaRegex.test(source); - const isRaster = type === "raster"; - if (!isSea) continue; - if (!map.getLayer(id)) continue; - if (isRaster && id === "seamark") continue; - try { - map.setLayoutProperty(id, "visibility", seaVisibility); - } catch { - // ignore - } - } - } catch { - // ignore - } - }; - - const stop = onMapStyleReady(map, apply); - return () => { - stop(); - }; - }, [projection, baseMap, mapSyncEpoch]); - - // seamark toggle - useEffect(() => { - const map = mapRef.current; - if (!map) return; - if (settings.showSeamark) { - try { - ensureSeamarkOverlay(map, "bathymetry-lines"); - map.setPaintProperty("seamark", "raster-opacity", 0.85); - } catch { - // ignore until style is ready - } - return; - } - - // If seamark is off, remove the layer+source to avoid unnecessary network tile requests. - try { - if (map.getLayer("seamark")) map.removeLayer("seamark"); - } catch { - // ignore - } - try { - if (map.getSource("seamark")) map.removeSource("seamark"); - } catch { - // ignore - } - }, [settings.showSeamark]); - - // Zones (MapLibre-native GeoJSON layers; works in both mercator + globe) - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = "zones-src"; - const fillId = "zones-fill"; - const lineId = "zones-line"; - const labelId = "zones-label"; - - const zoneColorExpr: unknown[] = ["match", ["get", "zoneId"]]; - for (const k of Object.keys(ZONE_META) as ZoneId[]) { - zoneColorExpr.push(k, ZONE_META[k].color); - } - zoneColorExpr.push("#3B82F6"); - const zoneLabelExpr: unknown[] = ["match", ["to-string", ["coalesce", ["get", "zoneId"], ""]]]; - for (const k of Object.keys(ZONE_META) as ZoneId[]) { - zoneLabelExpr.push(k, ZONE_META[k].name); - } - zoneLabelExpr.push(["coalesce", ["get", "zoneName"], ["get", "zoneLabel"], ["get", "NAME"], "수역"]); - - const ensure = () => { - if (projectionBusyRef.current) return; - // Always update visibility if the layers exist. - const visibility = overlays.zones ? "visible" : "none"; - try { - if (map.getLayer(fillId)) map.setLayoutProperty(fillId, "visibility", visibility); - } catch { - // ignore - } - try { - if (map.getLayer(lineId)) map.setLayoutProperty(lineId, "visibility", visibility); - } catch { - // ignore - } - try { - if (map.getLayer(labelId)) map.setLayoutProperty(labelId, "visibility", visibility); - } catch { - // ignore - } - - if (!zones) return; - if (!map.isStyleLoaded()) return; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) { - existing.setData(zones); - } else { - map.addSource(srcId, { type: "geojson", data: zones } as GeoJSONSourceSpecification); - } - - // Keep zones below Deck layers (ships / deck-globe), and below seamarks if enabled. - const style = map.getStyle(); - const styleLayers = style && Array.isArray(style.layers) ? style.layers : []; - const firstSymbol = styleLayers.find((l) => (l as { type?: string } | undefined)?.type === "symbol") as - | { id?: string } - | undefined; - const before = map.getLayer("deck-globe") - ? "deck-globe" - : map.getLayer("ships") - ? "ships" - : map.getLayer("seamark") - ? "seamark" - : firstSymbol?.id; - - const zoneMatchExpr = - hoveredZoneId !== null - ? (["==", ["to-string", ["coalesce", ["get", "zoneId"], ""]], hoveredZoneId] as unknown[]) - : false; - const zoneLineWidthExpr = hoveredZoneId - ? ([ - "interpolate", - ["linear"], - ["zoom"], - 4, - ["case", zoneMatchExpr, 1.6, 0.8], - 10, - ["case", zoneMatchExpr, 2.0, 1.4], - 14, - ["case", zoneMatchExpr, 2.8, 2.1], - ] as unknown as never) - : (["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1] as never); - - if (map.getLayer(fillId)) { - try { - map.setPaintProperty( - fillId, - "fill-opacity", - hoveredZoneId ? (["case", zoneMatchExpr, 0.24, 0.1] as unknown as number) : 0.12, - ); - } catch { - // ignore - } - } - - if (map.getLayer(lineId)) { - try { - map.setPaintProperty( - lineId, - "line-color", - hoveredZoneId - ? (["case", zoneMatchExpr, "rgba(125,211,252,0.98)", zoneColorExpr as never] as never) - : (zoneColorExpr as never), - ); - } catch { - // ignore - } - try { - map.setPaintProperty(lineId, "line-opacity", hoveredZoneId ? (["case", zoneMatchExpr, 1, 0.85] as never) : 0.85); - } catch { - // ignore - } - try { - map.setPaintProperty(lineId, "line-width", zoneLineWidthExpr); - } catch { - // ignore - } - } - - if (!map.getLayer(fillId)) { - map.addLayer( - { - id: fillId, - type: "fill", - source: srcId, - paint: { - "fill-color": zoneColorExpr as never, - "fill-opacity": hoveredZoneId - ? ([ - "case", - zoneMatchExpr, - 0.24, - 0.1, - ] as unknown as number) - : 0.12, - }, - layout: { visibility }, - } as unknown as LayerSpecification, - before, - ); - } - - if (!map.getLayer(lineId)) { - map.addLayer( - { - id: lineId, - type: "line", - source: srcId, - paint: { - "line-color": hoveredZoneId - ? (["case", zoneMatchExpr, "rgba(125,211,252,0.98)", zoneColorExpr as never] as never) - : (zoneColorExpr as never), - "line-opacity": hoveredZoneId - ? (["case", zoneMatchExpr, 1, 0.85] as never) - : 0.85, - "line-width": zoneLineWidthExpr, - }, - layout: { visibility }, - } as unknown as LayerSpecification, - before, - ); - } - - if (!map.getLayer(labelId)) { - map.addLayer( - { - id: labelId, - type: "symbol", - source: srcId, - layout: { - visibility, - "symbol-placement": "point", - "text-field": zoneLabelExpr as never, - "text-size": 11, - "text-font": ["Noto Sans Regular", "Open Sans Regular"], - "text-anchor": "top", - "text-offset": [0, 0.35], - "text-allow-overlap": false, - "text-ignore-placement": false, - }, - paint: { - "text-color": "#dbeafe", - "text-halo-color": "rgba(2,6,23,0.85)", - "text-halo-width": 1.2, - "text-halo-blur": 0.8, - }, - } as unknown as LayerSpecification, - undefined, - ); - } - } catch (e) { - console.warn("Zones layer setup failed:", e); - } finally { - reorderGlobeFeatureLayers(); - kickRepaint(map); - } - }; - - const stop = onMapStyleReady(map, ensure); - return () => { - stop(); - }; - }, [zones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]); - - // Prediction vectors: MapLibre-native GeoJSON line layer so it stays stable in both mercator + globe. - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = "predict-vectors-src"; - const outlineId = "predict-vectors-outline"; - const lineId = "predict-vectors"; - const hlOutlineId = "predict-vectors-hl-outline"; - const hlId = "predict-vectors-hl"; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - - const visibility = overlays.predictVectors ? "visible" : "none"; - - const horizonMinutes = 15; - const horizonSeconds = horizonMinutes * 60; - const metersPerSecondPerKnot = 0.514444; - - const features: GeoJSON.Feature[] = []; - if (overlays.predictVectors && settings.showShips && shipData.length > 0) { - for (const t of shipData) { - const legacy = legacyHits?.get(t.mmsi) ?? null; - const isTarget = !!legacy; - const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi; - const isPinnedHighlight = externalHighlightedSetRef.has(t.mmsi); - if (!isTarget && !isSelected && !isPinnedHighlight) continue; - - const sog = isFiniteNumber(t.sog) ? t.sog : null; - const bearing = toValidBearingDeg(t.cog) ?? toValidBearingDeg(t.heading); - if (sog == null || bearing == null) continue; - if (sog < 0.2) continue; - - const distM = sog * metersPerSecondPerKnot * horizonSeconds; - if (!Number.isFinite(distM) || distM <= 0) continue; - - const to = destinationPointLngLat([t.lon, t.lat], bearing, distM); - - const baseRgb = isTarget - ? LEGACY_CODE_COLORS_RGB[legacy?.shipCode ?? ""] ?? OTHER_AIS_SPEED_RGB.moving - : OTHER_AIS_SPEED_RGB.moving; - const rgb = lightenColor(baseRgb, isTarget ? 0.55 : 0.62); - const alpha = isTarget ? 0.72 : 0.52; - const alphaHl = isTarget ? 0.92 : 0.84; - const hl = isSelected || isPinnedHighlight ? 1 : 0; - - features.push({ - type: "Feature", - id: `pred-${t.mmsi}`, - geometry: { type: "LineString", coordinates: [[t.lon, t.lat], to] }, - properties: { - mmsi: t.mmsi, - minutes: horizonMinutes, - sog, - cog: bearing, - target: isTarget ? 1 : 0, - hl, - color: rgbaCss(rgb, alpha), - colorHl: rgbaCss(rgb, alphaHl), - }, - }); - } - } - - const fc: GeoJSON.FeatureCollection = { type: "FeatureCollection", features }; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(fc); - else map.addSource(srcId, { type: "geojson", data: fc } as GeoJSONSourceSpecification); - } catch (e) { - console.warn("Prediction vector source setup failed:", e); - return; - } - - const ensureLayer = (id: string, paint: LayerSpecification["paint"], filter: unknown[]) => { - if (!map.getLayer(id)) { - try { - map.addLayer( - { - id, - type: "line", - source: srcId, - filter: filter as never, - layout: { - visibility, - "line-cap": "round", - "line-join": "round", - }, - paint, - } as unknown as LayerSpecification, - undefined, - ); - } catch (e) { - console.warn("Prediction vector layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(id, "visibility", visibility); - map.setFilter(id, filter as never); - if (paint && typeof paint === "object") { - for (const [key, value] of Object.entries(paint)) { - map.setPaintProperty(id, key as never, value as never); - } - } - } catch { - // ignore - } - } - }; - - const baseFilter = ["==", ["to-number", ["get", "hl"], 0], 0] as unknown as unknown[]; - const hlFilter = ["==", ["to-number", ["get", "hl"], 0], 1] as unknown as unknown[]; - - // Outline (halo) for readability over bathymetry + seamark textures. - ensureLayer( - outlineId, - { - "line-color": "rgba(2,6,23,0.86)", - "line-width": 4.8, - "line-opacity": 1, - "line-blur": 0.2, - "line-dasharray": [1.2, 1.8] as never, - } as never, - baseFilter, - ); - ensureLayer( - lineId, - { - "line-color": ["coalesce", ["get", "color"], "rgba(226,232,240,0.62)"] as never, - "line-width": 2.4, - "line-opacity": 1, - "line-dasharray": [1.2, 1.8] as never, - } as never, - baseFilter, - ); - ensureLayer( - hlOutlineId, - { - "line-color": "rgba(2,6,23,0.92)", - "line-width": 6.4, - "line-opacity": 1, - "line-blur": 0.25, - "line-dasharray": [1.2, 1.8] as never, - } as never, - hlFilter, - ); - ensureLayer( - hlId, - { - "line-color": ["coalesce", ["get", "colorHl"], ["get", "color"], "rgba(241,245,249,0.92)"] as never, - "line-width": 3.6, - "line-opacity": 1, - "line-dasharray": [1.2, 1.8] as never, - } as never, - hlFilter, - ); - - reorderGlobeFeatureLayers(); - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - return () => { - stop(); - }; - }, [ - overlays.predictVectors, - settings.showShips, - shipData, - legacyHits, - selectedMmsi, - externalHighlightedSetRef, - projection, - baseMap, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); - - // Ship name labels in mercator: MapLibre-native symbol layer so collision/placement is handled automatically. - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = "ship-labels-src"; - const layerId = "ship-labels"; - - const remove = () => { - try { - if (map.getLayer(layerId)) map.removeLayer(layerId); - } catch { - // ignore - } - try { - if (map.getSource(srcId)) map.removeSource(srcId); - } catch { - // ignore - } - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - - if (projection !== "mercator" || !settings.showShips) { - remove(); - return; - } - - const visibility = overlays.shipLabels ? "visible" : "none"; - - const features: GeoJSON.Feature[] = []; - for (const t of shipData) { - const legacy = legacyHits?.get(t.mmsi) ?? null; - const isTarget = !!legacy; - const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi; - const isPinnedHighlight = externalHighlightedSetRef.has(t.mmsi); - if (!isTarget && !isSelected && !isPinnedHighlight) continue; - - const labelName = (legacy?.shipNameCn || legacy?.shipNameRoman || t.name || "").trim(); - if (!labelName) continue; - - features.push({ - type: "Feature", - id: `ship-label-${t.mmsi}`, - geometry: { type: "Point", coordinates: [t.lon, t.lat] }, - properties: { - mmsi: t.mmsi, - labelName, - selected: isSelected ? 1 : 0, - highlighted: isPinnedHighlight ? 1 : 0, - permitted: isTarget ? 1 : 0, - }, - }); - } - - const fc: GeoJSON.FeatureCollection = { type: "FeatureCollection", features }; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(fc); - else map.addSource(srcId, { type: "geojson", data: fc } as GeoJSONSourceSpecification); - } catch (e) { - console.warn("Ship label source setup failed:", e); - return; - } - - const filter = ["!=", ["to-string", ["coalesce", ["get", "labelName"], ""]], ""] as unknown as unknown[]; - - if (!map.getLayer(layerId)) { - try { - map.addLayer( - { - id: layerId, - type: "symbol", - source: srcId, - minzoom: 7, - filter: filter as never, - layout: { - visibility, - "symbol-placement": "point", - "text-field": ["get", "labelName"] as never, - "text-font": ["Noto Sans Regular", "Open Sans Regular"], - "text-size": ["interpolate", ["linear"], ["zoom"], 7, 10, 10, 11, 12, 12, 14, 13] as never, - "text-anchor": "top", - "text-offset": [0, 1.1], - "text-padding": 2, - "text-allow-overlap": false, - "text-ignore-placement": false, - }, - paint: { - "text-color": [ - "case", - ["==", ["get", "selected"], 1], - "rgba(14,234,255,0.95)", - ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,0.95)", - "rgba(226,232,240,0.92)", - ] as never, - "text-halo-color": "rgba(2,6,23,0.85)", - "text-halo-width": 1.2, - "text-halo-blur": 0.8, - }, - } as unknown as LayerSpecification, - undefined, - ); - } catch (e) { - console.warn("Ship label layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(layerId, "visibility", visibility); - } catch { - // ignore - } - } - - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - return () => { - stop(); - }; - }, [ - projection, - settings.showShips, - overlays.shipLabels, - shipData, - legacyHits, - selectedMmsi, - externalHighlightedSetRef, - baseMap, - mapSyncEpoch, - ]); - - // Ships in globe mode: render with MapLibre symbol layers so icons can be aligned to the globe surface. - // Deck IconLayer billboards or uses a fixed plane, which looks like ships are pointing into the sky/ground on globe. - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const imgId = "ship-globe-icon"; - const anchoredImgId = ANCHORED_SHIP_ICON_ID; - const srcId = "ships-globe-src"; - const haloId = "ships-globe-halo"; - const outlineId = "ships-globe-outline"; - const symbolId = "ships-globe"; - const labelId = "ships-globe-label"; - - const remove = () => { - for (const id of [labelId, symbolId, outlineId, haloId]) { - try { - if (map.getLayer(id)) map.removeLayer(id); - } catch { - // ignore - } - } - try { - if (map.getSource(srcId)) map.removeSource(srcId); - } catch { - // ignore - } - globeHoverShipSignatureRef.current = ""; - reorderGlobeFeatureLayers(); - kickRepaint(map); - }; - - const ensureImage = () => { - ensureFallbackShipImage(map, imgId); - ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); - if (globeShipIconLoadingRef.current) return; - if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return; - - const addFallbackImage = () => { - ensureFallbackShipImage(map, imgId); - ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); - kickRepaint(map); - }; - - let fallbackTimer: ReturnType | null = null; - try { - globeShipIconLoadingRef.current = true; - fallbackTimer = window.setTimeout(() => { - addFallbackImage(); - }, 80); - void map - .loadImage("/assets/ship.svg") - .then((response) => { - globeShipIconLoadingRef.current = false; - if (fallbackTimer != null) { - clearTimeout(fallbackTimer); - fallbackTimer = null; - } - - const loadedImage = (response as { data?: HTMLImageElement | ImageBitmap } | undefined)?.data; - if (!loadedImage) { - addFallbackImage(); - return; - } - - try { - if (map.hasImage(imgId)) { - try { - map.removeImage(imgId); - } catch { - // ignore - } - } - if (map.hasImage(anchoredImgId)) { - try { - map.removeImage(anchoredImgId); - } catch { - // ignore - } - } - map.addImage(imgId, loadedImage, { pixelRatio: 2, sdf: true }); - map.addImage(anchoredImgId, loadedImage, { pixelRatio: 2, sdf: true }); - kickRepaint(map); - } catch (e) { - console.warn("Ship icon image add failed:", e); - } - }) - .catch(() => { - globeShipIconLoadingRef.current = false; - if (fallbackTimer != null) { - clearTimeout(fallbackTimer); - fallbackTimer = null; - } - addFallbackImage(); - }); - } catch (e) { - globeShipIconLoadingRef.current = false; - if (fallbackTimer != null) { - clearTimeout(fallbackTimer); - fallbackTimer = null; - } - try { - addFallbackImage(); - } catch (fallbackError) { - console.warn("Ship icon image setup failed:", e, fallbackError); - } - } - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - - if (projection !== "globe" || !settings.showShips) { - remove(); - return; - } - - if (globeShipsEpochRef.current !== mapSyncEpoch) { - globeShipsEpochRef.current = mapSyncEpoch; - } - - try { - ensureImage(); - } catch (e) { - console.warn("Ship icon image setup failed:", e); - } - - const globeShipData = shipData; - const geojson: GeoJSON.FeatureCollection = { - type: "FeatureCollection", - features: globeShipData.map((t) => { - const legacy = legacyHits?.get(t.mmsi) ?? null; - const labelName = - legacy?.shipNameCn || - legacy?.shipNameRoman || - t.name || - ""; - const heading = getDisplayHeading({ - cog: t.cog, - heading: t.heading, - offset: GLOBE_ICON_HEADING_OFFSET_DEG, - }); - const isAnchored = isAnchoredShip({ - sog: t.sog, - cog: t.cog, - heading: t.heading, - }); - const shipHeading = isAnchored ? 0 : heading; - const hull = clampNumber((isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, 50, 420); - const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); - const selected = t.mmsi === selectedMmsi; - const highlighted = isBaseHighlightedMmsi(t.mmsi); - const selectedScale = selected ? 1.08 : 1; - const highlightScale = highlighted ? 1.06 : 1; - const iconScale = selected ? selectedScale : highlightScale; - const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3); - const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45); - const iconSize10 = clampNumber(0.56 * sizeScale * selectedScale, 0.35, 1.7); - const iconSize14 = clampNumber(0.72 * sizeScale * selectedScale, 0.45, 2.1); - return { - type: "Feature", - ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), - geometry: { type: "Point", coordinates: [t.lon, t.lat] }, - properties: { - mmsi: t.mmsi, - name: t.name || "", - labelName, - cog: shipHeading, - heading: shipHeading, - sog: isFiniteNumber(t.sog) ? t.sog : 0, - isAnchored: isAnchored ? 1 : 0, - shipColor: getGlobeBaseShipColor({ - legacy: legacy?.shipCode || null, - sog: isFiniteNumber(t.sog) ? t.sog : null, - }), - iconSize3: iconSize3 * iconScale, - iconSize7: iconSize7 * iconScale, - iconSize10: iconSize10 * iconScale, - iconSize14: iconSize14 * iconScale, - sizeScale, - selected: selected ? 1 : 0, - highlighted: highlighted ? 1 : 0, - permitted: legacy ? 1 : 0, - code: legacy?.shipCode || "", - }, - }; - }), - }; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(geojson); - else map.addSource(srcId, { type: "geojson", data: geojson } as GeoJSONSourceSpecification); - } catch (e) { - console.warn("Ship source setup failed:", e); - return; - } - - const visibility = settings.showShips ? "visible" : "none"; - - const before = undefined; - - if (!map.getLayer(haloId)) { - try { - map.addLayer( - { - id: haloId, - type: "circle", - source: srcId, - layout: { - visibility, - "circle-sort-key": [ - "case", - ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], - 120, - ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], - 115, - ["==", ["get", "permitted"], 1], - 110, - ["==", ["get", "selected"], 1], - 60, - ["==", ["get", "highlighted"], 1], - 55, - 20, - ] as never, - }, - paint: { - "circle-radius": GLOBE_SHIP_CIRCLE_RADIUS_EXPR, - "circle-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, - "circle-opacity": [ - "case", - ["==", ["get", "selected"], 1], - 0.38, - ["==", ["get", "highlighted"], 1], - 0.34, - 0.16, - ] as never, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn("Ship halo layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(haloId, "visibility", visibility); - map.setLayoutProperty( - haloId, - "circle-sort-key", - [ - "case", - ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], - 120, - ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], - 115, - ["==", ["get", "permitted"], 1], - 110, - ["==", ["get", "selected"], 1], - 60, - ["==", ["get", "highlighted"], 1], - 55, - 20, - ] as never, - ); - map.setPaintProperty( - haloId, - "circle-color", - [ - "case", - ["==", ["get", "selected"], 1], - "rgba(14,234,255,1)", - ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,1)", - ["coalesce", ["get", "shipColor"], "#64748b"], - ] as never, - ); - map.setPaintProperty(haloId, "circle-opacity", [ - "case", - ["==", ["get", "selected"], 1], - 0.38, - ["==", ["get", "highlighted"], 1], - 0.34, - 0.16, - ] as never); - map.setPaintProperty(haloId, "circle-radius", GLOBE_SHIP_CIRCLE_RADIUS_EXPR); - } catch { - // ignore - } - } - - if (!map.getLayer(outlineId)) { - try { - map.addLayer( - { - id: outlineId, - type: "circle", - source: srcId, - paint: { - "circle-radius": GLOBE_SHIP_CIRCLE_RADIUS_EXPR, - "circle-color": "rgba(0,0,0,0)", - "circle-stroke-color": [ - "case", - ["==", ["get", "selected"], 1], - "rgba(14,234,255,0.95)", - ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,0.95)", - ["coalesce", ["get", "shipColor"], "#64748b"], - ] as never, - "circle-stroke-width": [ - "case", - ["==", ["get", "selected"], 1], - 3.4, - ["==", ["get", "highlighted"], 1], - 2.7, - ["==", ["get", "permitted"], 1], - 1.8, - 0.0, - ] as never, - "circle-stroke-opacity": 0.85, - }, - layout: { - visibility, - "circle-sort-key": [ - "case", - ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], - 130, - ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], - 125, - ["==", ["get", "permitted"], 1], - 120, - ["==", ["get", "selected"], 1], - 70, - ["==", ["get", "highlighted"], 1], - 65, - 30, - ] as never, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn("Ship outline layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(outlineId, "visibility", visibility); - map.setLayoutProperty( - outlineId, - "circle-sort-key", - [ - "case", - ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], - 130, - ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], - 125, - ["==", ["get", "permitted"], 1], - 120, - ["==", ["get", "selected"], 1], - 70, - ["==", ["get", "highlighted"], 1], - 65, - 30, - ] as never, - ); - map.setPaintProperty( - outlineId, - "circle-stroke-color", - [ - "case", - ["==", ["get", "selected"], 1], - "rgba(14,234,255,0.95)", - ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,0.95)", - ["coalesce", ["get", "shipColor"], "#64748b"], - ] as never, - ); - map.setPaintProperty( - outlineId, - "circle-stroke-width", - [ - "case", - ["==", ["get", "selected"], 1], - 3.4, - ["==", ["get", "highlighted"], 1], - 2.7, - ["==", ["get", "permitted"], 1], - 1.8, - 0.0, - ] as never, - ); - } catch { - // ignore - } - } - - if (!map.getLayer(symbolId)) { - try { - map.addLayer( - { - id: symbolId, - type: "symbol", - source: srcId, - layout: { - visibility, - "symbol-sort-key": [ - "case", - ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], - 140, - ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], - 135, - ["==", ["get", "permitted"], 1], - 130, - ["==", ["get", "selected"], 1], - 80, - ["==", ["get", "highlighted"], 1], - 75, - 45, - ] as never, - "icon-image": [ - "case", - ["==", ["to-number", ["get", "isAnchored"], 0], 1], - anchoredImgId, - imgId, - ] as never, - "icon-size": [ - "interpolate", - ["linear"], - ["zoom"], - 3, - ["to-number", ["get", "iconSize3"], 0.35], - 7, - ["to-number", ["get", "iconSize7"], 0.45], - 10, - ["to-number", ["get", "iconSize10"], 0.56], - 14, - ["to-number", ["get", "iconSize14"], 0.72], - ] as unknown as number[], - "icon-allow-overlap": true, - "icon-ignore-placement": true, - "icon-anchor": "center", - "icon-rotate": [ - "case", - ["==", ["to-number", ["get", "isAnchored"], 0], 1], - 0, - ["to-number", ["get", "heading"], 0], - ] as never, - // Keep the icon on the sea surface. - "icon-rotation-alignment": "map", - "icon-pitch-alignment": "map", - }, - paint: { - "icon-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, - "icon-opacity": [ - "case", - ["==", ["get", "permitted"], 1], - 1, - ["==", ["get", "selected"], 1], - 0.86, - ["==", ["get", "highlighted"], 1], - 0.82, - 0.66, - ] as never, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn("Ship symbol layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(symbolId, "visibility", visibility); - map.setLayoutProperty( - symbolId, - "symbol-sort-key", - [ - "case", - ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], - 140, - ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], - 135, - ["==", ["get", "permitted"], 1], - 130, - ["==", ["get", "selected"], 1], - 80, - ["==", ["get", "highlighted"], 1], - 75, - 45, - ] as never, - ); - map.setPaintProperty( - symbolId, - "icon-opacity", - [ - "case", - ["==", ["get", "permitted"], 1], - 1, - ["==", ["get", "selected"], 1], - 0.86, - ["==", ["get", "highlighted"], 1], - 0.82, - 0.66, - ] as never, - ); - } catch { - // ignore - } - } - - // Optional ship name labels (toggle). Keep labels readable and avoid clutter. - const labelVisibility = overlays.shipLabels ? "visible" : "none"; - const labelFilter = [ - "all", - ["!=", ["to-string", ["coalesce", ["get", "labelName"], ""]], ""], - [ - "any", - ["==", ["get", "permitted"], 1], - ["==", ["get", "selected"], 1], - ["==", ["get", "highlighted"], 1], - ], - ] as unknown as unknown[]; - - if (!map.getLayer(labelId)) { - try { - map.addLayer( - { - id: labelId, - type: "symbol", - source: srcId, - minzoom: 7, - filter: labelFilter as never, - layout: { - visibility: labelVisibility, - "symbol-placement": "point", - "text-field": ["get", "labelName"] as never, - "text-font": ["Noto Sans Regular", "Open Sans Regular"], - "text-size": ["interpolate", ["linear"], ["zoom"], 7, 10, 10, 11, 12, 12, 14, 13] as never, - "text-anchor": "top", - "text-offset": [0, 1.1], - "text-padding": 2, - "text-allow-overlap": false, - "text-ignore-placement": false, - }, - paint: { - "text-color": [ - "case", - ["==", ["get", "selected"], 1], - "rgba(14,234,255,0.95)", - ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,0.95)", - "rgba(226,232,240,0.92)", - ] as never, - "text-halo-color": "rgba(2,6,23,0.85)", - "text-halo-width": 1.2, - "text-halo-blur": 0.8, - }, - } as unknown as LayerSpecification, - undefined, - ); - } catch (e) { - console.warn("Ship label layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(labelId, "visibility", labelVisibility); - map.setFilter(labelId, labelFilter as never); - map.setLayoutProperty(labelId, "text-field", ["get", "labelName"] as never); - } catch { - // ignore - } - } - - // Selection and highlight are now source-data driven. - reorderGlobeFeatureLayers(); - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - return () => { - stop(); - }; - }, [ - projection, - settings.showShips, - overlays.shipLabels, - shipData, - legacyHits, - selectedMmsi, - isBaseHighlightedMmsi, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); - - // Globe hover overlay ships: update only hovered/selected targets to avoid rebuilding full ship layer on every hover. - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const imgId = "ship-globe-icon"; - const srcId = "ships-globe-hover-src"; - const haloId = "ships-globe-hover-halo"; - const outlineId = "ships-globe-hover-outline"; - const symbolId = "ships-globe-hover"; - - const remove = () => { - for (const id of [symbolId, outlineId, haloId]) { - try { - if (map.getLayer(id)) map.removeLayer(id); - } catch { - // ignore - } - } - try { - if (map.getSource(srcId)) map.removeSource(srcId); - } catch { - // ignore - } - globeHoverShipSignatureRef.current = ""; - reorderGlobeFeatureLayers(); - kickRepaint(map); - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - - if (projection !== "globe" || !settings.showShips || shipHoverOverlaySet.size === 0) { - remove(); - return; - } - - if (globeShipsEpochRef.current !== mapSyncEpoch) { - globeShipsEpochRef.current = mapSyncEpoch; - } - - ensureFallbackShipImage(map, imgId); - if (!map.hasImage(imgId)) { - return; - } - - const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi)); - if (hovered.length === 0) { - remove(); - return; - } - const hoverSignature = hovered - .map((t) => `${t.mmsi}:${t.lon.toFixed(6)}:${t.lat.toFixed(6)}:${t.heading ?? 0}`) - .join("|"); - const hasHoverSource = map.getSource(srcId) != null; - const hasHoverLayers = [symbolId, outlineId, haloId].every((id) => map.getLayer(id)); - if (hoverSignature === globeHoverShipSignatureRef.current && hasHoverSource && hasHoverLayers) { - return; - } - globeHoverShipSignatureRef.current = hoverSignature; - const needReorder = !hasHoverSource || !hasHoverLayers; - - const hoverGeojson: GeoJSON.FeatureCollection = { - type: "FeatureCollection", - features: hovered.map((t) => { - const legacy = legacyHits?.get(t.mmsi) ?? null; - const heading = getDisplayHeading({ - cog: t.cog, - heading: t.heading, - offset: GLOBE_ICON_HEADING_OFFSET_DEG, - }); - const hull = clampNumber( - (isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, - 50, - 420, - ); - const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); - const selected = t.mmsi === selectedMmsi; - const scale = selected ? 1.16 : 1.1; - return { - type: "Feature", - ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), - geometry: { type: "Point", coordinates: [t.lon, t.lat] }, - properties: { - mmsi: t.mmsi, - name: t.name || "", - cog: heading, - heading, - sog: isFiniteNumber(t.sog) ? t.sog : 0, - shipColor: getGlobeBaseShipColor({ - legacy: legacy?.shipCode || null, - sog: isFiniteNumber(t.sog) ? t.sog : null, - }), - iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45), - iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7), - iconSize10: clampNumber(0.56 * sizeScale * scale, 0.35, 2.0), - iconSize14: clampNumber(0.72 * sizeScale * scale, 0.45, 2.4), - selected: selected ? 1 : 0, - permitted: legacy ? 1 : 0, - }, - }; - }), - }; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(hoverGeojson); - else map.addSource(srcId, { type: "geojson", data: hoverGeojson } as GeoJSONSourceSpecification); - } catch (e) { - console.warn("Ship hover source setup failed:", e); - return; - } - - const before = undefined; - - if (!map.getLayer(haloId)) { - try { - map.addLayer( - { - id: haloId, - type: "circle", - source: srcId, - layout: { - visibility: "visible", - "circle-sort-key": [ - "case", - ["==", ["get", "selected"], 1], - 120, - ["==", ["get", "permitted"], 1], - 115, - 110, - ] as never, - }, - paint: { - "circle-radius": GLOBE_SHIP_CIRCLE_RADIUS_EXPR, - "circle-color": [ - "case", - ["==", ["get", "selected"], 1], - "rgba(14,234,255,1)", - "rgba(245,158,11,1)", - ] as never, - "circle-opacity": 0.42, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn("Ship hover halo layer add failed:", e); - } - } else { - map.setLayoutProperty(haloId, "visibility", "visible"); - } - - if (!map.getLayer(outlineId)) { - try { - map.addLayer( - { - id: outlineId, - type: "circle", - source: srcId, - paint: { - "circle-radius": GLOBE_SHIP_CIRCLE_RADIUS_EXPR, - "circle-color": "rgba(0,0,0,0)", - "circle-stroke-color": [ - "case", - ["==", ["get", "selected"], 1], - "rgba(14,234,255,0.95)", - "rgba(245,158,11,0.95)", - ] as never, - "circle-stroke-width": [ - "case", - ["==", ["get", "selected"], 1], - 3.8, - 2.2, - ] as never, - "circle-stroke-opacity": 0.9, - }, - layout: { - visibility: "visible", - "circle-sort-key": [ - "case", - ["==", ["get", "selected"], 1], - 121, - ["==", ["get", "permitted"], 1], - 116, - 111, - ] as never, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn("Ship hover outline layer add failed:", e); - } - } else { - map.setLayoutProperty(outlineId, "visibility", "visible"); - } - - if (!map.getLayer(symbolId)) { - try { - map.addLayer( - { - id: symbolId, - type: "symbol", - source: srcId, - layout: { - visibility: "visible", - "symbol-sort-key": [ - "case", - ["==", ["get", "selected"], 1], - 122, - ["==", ["get", "permitted"], 1], - 117, - 112, - ] as never, - "icon-image": imgId, - "icon-size": [ - "interpolate", - ["linear"], - ["zoom"], - 3, - ["to-number", ["get", "iconSize3"], 0.35], - 7, - ["to-number", ["get", "iconSize7"], 0.45], - 10, - ["to-number", ["get", "iconSize10"], 0.56], - 14, - ["to-number", ["get", "iconSize14"], 0.72], - ] as unknown as number[], - "icon-allow-overlap": true, - "icon-ignore-placement": true, - "icon-anchor": "center", - "icon-rotate": ["to-number", ["get", "heading"], 0], - // Keep the icon on the sea surface. - "icon-rotation-alignment": "map", - "icon-pitch-alignment": "map", - }, - paint: { - "icon-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, - "icon-opacity": 1, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn("Ship hover symbol layer add failed:", e); - } - } else { - map.setLayoutProperty(symbolId, "visibility", "visible"); - } - - if (needReorder) { - reorderGlobeFeatureLayers(); - } - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - return () => { - stop(); - }; - }, [ - projection, - settings.showShips, - shipLayerData, - legacyHits, - shipHoverOverlaySet, - selectedMmsi, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); - - // Globe ship click selection (MapLibre-native ships layer) - useEffect(() => { - const map = mapRef.current; - if (!map) return; - if (projection !== "globe" || !settings.showShips) return; - - const symbolId = "ships-globe"; - const haloId = "ships-globe-halo"; - const outlineId = "ships-globe-outline"; - const clickedRadiusDeg2 = Math.pow(0.08, 2); - - const onClick = (e: maplibregl.MapMouseEvent) => { - try { - const layerIds = [symbolId, haloId, outlineId].filter((id) => map.getLayer(id)); - let feats: unknown[] = []; - if (layerIds.length > 0) { - try { - feats = map.queryRenderedFeatures(e.point, { layers: layerIds }) as unknown[]; - } catch { - feats = []; - } - } - const f = feats?.[0]; - const props = ((f as { properties?: Record } | undefined)?.properties || {}) as Record< - string, - unknown - >; - const mmsi = Number(props.mmsi); - if (Number.isFinite(mmsi)) { - if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) { - onToggleHighlightMmsi?.(mmsi); - return; - } - onSelectMmsi(mmsi); - return; - } - - const clicked = { lat: e.lngLat.lat, lon: e.lngLat.lng }; - const cosLat = Math.cos(clicked.lat * DEG2RAD); - let bestMmsi: number | null = null; - let bestD2 = Number.POSITIVE_INFINITY; - for (const t of targets) { - if (!isFiniteNumber(t.lat) || !isFiniteNumber(t.lon)) continue; - const dLon = (clicked.lon - t.lon) * cosLat; - const dLat = clicked.lat - t.lat; - const d2 = dLon * dLon + dLat * dLat; - if (d2 <= clickedRadiusDeg2 && d2 < bestD2) { - bestD2 = d2; - bestMmsi = t.mmsi; - } - } - if (bestMmsi != null) { - if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) { - onToggleHighlightMmsi?.(bestMmsi); - return; - } - onSelectMmsi(bestMmsi); - return; - } - } catch { - // ignore - } - onSelectMmsi(null); - }; - - map.on("click", onClick); - return () => { - try { - map.off("click", onClick); - } catch { - // ignore - } - }; - }, [projection, settings.showShips, onSelectMmsi, onToggleHighlightMmsi, mapSyncEpoch, targets]); - - - // Globe overlays (pair links / FC links / ranges) rendered as MapLibre GeoJSON layers. - // Deck custom layers are more fragile under globe projection; MapLibre-native rendering stays aligned like zones. - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = "pair-lines-ml-src"; - const layerId = "pair-lines-ml"; - - const remove = () => { - try { - if (map.getLayer(layerId)) map.setLayoutProperty(layerId, "visibility", "none"); - } catch { - // ignore - } - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - if (projection !== "globe" || !overlays.pairLines || (pairLinks?.length ?? 0) === 0) { - remove(); - return; - } - - const fc: GeoJSON.FeatureCollection = { - type: "FeatureCollection", - features: (pairLinks || []).map((p) => ({ - type: "Feature", - id: makePairLinkFeatureId(p.aMmsi, p.bMmsi), - geometry: { type: "LineString", coordinates: [p.from, p.to] }, - properties: { - type: "pair", - aMmsi: p.aMmsi, - bMmsi: p.bMmsi, - distanceNm: p.distanceNm, - warn: p.warn, - }, - })), - }; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(fc); - else map.addSource(srcId, { type: "geojson", data: fc } as GeoJSONSourceSpecification); - } catch (e) { - console.warn("Pair lines source setup failed:", e); - return; - } - - const before = undefined; - - if (!map.getLayer(layerId)) { - try { - map.addLayer( - { - id: layerId, - type: "line", - source: srcId, - layout: { "line-cap": "round", "line-join": "round", visibility: "visible" }, - paint: { - "line-color": [ - "case", - ["==", ["get", "highlighted"], 1], - ["case", ["boolean", ["get", "warn"], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], - ["boolean", ["get", "warn"], false], - PAIR_LINE_WARN_ML, - PAIR_LINE_NORMAL_ML, - ] as never, - "line-width": [ - "case", - ["==", ["get", "highlighted"], 1], - 2.8, - ["boolean", ["get", "warn"], false], - 2.2, - 1.4, - ] as never, - "line-opacity": 0.9, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn("Pair lines layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(layerId, "visibility", "visible"); - } catch { - // ignore - } - } - - reorderGlobeFeatureLayers(); - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - ensure(); - return () => { - stop(); - }; - }, [ - projection, - overlays.pairLines, - pairLinks, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); - - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = "fc-lines-ml-src"; - const layerId = "fc-lines-ml"; - - const remove = () => { - try { - if (map.getLayer(layerId)) map.setLayoutProperty(layerId, "visibility", "none"); - } catch { - // ignore - } - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - if (projection !== "globe" || !overlays.fcLines) { - remove(); - return; - } - - const segs: DashSeg[] = []; - for (const l of fcLinks || []) { - segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi)); - } - if (segs.length === 0) { - remove(); - return; - } - - const fc: GeoJSON.FeatureCollection = { - type: "FeatureCollection", - features: segs.map((s, idx) => ({ - type: "Feature", - id: makeFcSegmentFeatureId(s.fromMmsi ?? -1, s.toMmsi ?? -1, idx), - geometry: { type: "LineString", coordinates: [s.from, s.to] }, - properties: { - type: "fc", - suspicious: s.suspicious, - distanceNm: s.distanceNm, - fcMmsi: s.fromMmsi ?? -1, - otherMmsi: s.toMmsi ?? -1, - }, - })), - }; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(fc); - else map.addSource(srcId, { type: "geojson", data: fc } as GeoJSONSourceSpecification); - } catch (e) { - console.warn("FC lines source setup failed:", e); - return; - } - - const before = undefined; - - if (!map.getLayer(layerId)) { - try { - map.addLayer( - { - id: layerId, - type: "line", - source: srcId, - layout: { "line-cap": "round", "line-join": "round", visibility: "visible" }, - paint: { - "line-color": [ - "case", - ["==", ["get", "highlighted"], 1], - ["case", ["boolean", ["get", "suspicious"], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], - ["boolean", ["get", "suspicious"], false], - FC_LINE_SUSPICIOUS_ML, - FC_LINE_NORMAL_ML, - ] as never, - "line-width": ["case", ["==", ["get", "highlighted"], 1], 2.0, 1.3] as never, - "line-opacity": 0.9, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn("FC lines layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(layerId, "visibility", "visible"); - } catch { - // ignore - } - } - - reorderGlobeFeatureLayers(); - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - ensure(); - return () => { - stop(); - }; - }, [ - projection, - overlays.fcLines, - fcLinks, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); - - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = "fleet-circles-ml-src"; - const fillSrcId = "fleet-circles-ml-fill-src"; - const layerId = "fleet-circles-ml"; - const fillLayerId = "fleet-circles-ml-fill"; - - const remove = () => { - try { - if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, "visibility", "none"); - } catch { - // ignore - } - try { - if (map.getLayer(layerId)) map.setLayoutProperty(layerId, "visibility", "none"); - } catch { - // ignore - } - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - if (projection !== "globe" || !overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) { - remove(); - return; - } - - const fcLine: GeoJSON.FeatureCollection = { - type: "FeatureCollection", - features: (fleetCircles || []).map((c) => { - const ring = circleRingLngLat(c.center, c.radiusNm * 1852); - return { - type: "Feature", - id: makeFleetCircleFeatureId(c.ownerKey), - geometry: { type: "LineString", coordinates: ring }, - properties: { - type: "fleet", - ownerKey: c.ownerKey, - ownerLabel: c.ownerLabel, - count: c.count, - vesselMmsis: c.vesselMmsis, - // Kept for backward compatibility with existing paint expressions. - // Actual hover-state highlighting is now handled in - // updateGlobeOverlayPaintStates. - highlighted: 0, - }, - }; - }), - }; - - const fcFill: GeoJSON.FeatureCollection = { - type: "FeatureCollection", - features: (fleetCircles || []).map((c) => { - const ring = circleRingLngLat(c.center, c.radiusNm * 1852); - return { - type: "Feature", - id: `${makeFleetCircleFeatureId(c.ownerKey)}-fill`, - geometry: { type: "Polygon", coordinates: [ring] }, - properties: { - type: "fleet-fill", - ownerKey: c.ownerKey, - ownerLabel: c.ownerLabel, - count: c.count, - vesselMmsis: c.vesselMmsis, - // Kept for backward compatibility with existing paint expressions. - // Actual hover-state highlighting is now handled in - // updateGlobeOverlayPaintStates. - highlighted: 0, - }, - }; - }), - }; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(fcLine); - else map.addSource(srcId, { type: "geojson", data: fcLine } as GeoJSONSourceSpecification); - } catch (e) { - console.warn("Fleet circles source setup failed:", e); - return; - } - - try { - const existingFill = map.getSource(fillSrcId) as GeoJSONSource | undefined; - if (existingFill) existingFill.setData(fcFill); - else map.addSource(fillSrcId, { type: "geojson", data: fcFill } as GeoJSONSourceSpecification); - } catch (e) { - console.warn("Fleet circles source setup failed:", e); - return; - } - - const before = undefined; - - if (!map.getLayer(fillLayerId)) { - try { - map.addLayer( - { - id: fillLayerId, - type: "fill", - source: fillSrcId, - layout: { visibility: "visible" }, - paint: { - "fill-color": [ - "case", - ["==", ["get", "highlighted"], 1], - FLEET_FILL_ML_HL, - FLEET_FILL_ML, - ] as never, - "fill-opacity": ["case", ["==", ["get", "highlighted"], 1], 0.7, 0.36] as never, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn("Fleet circles fill layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(fillLayerId, "visibility", "visible"); - } catch { - // ignore - } - } - - if (!map.getLayer(layerId)) { - try { - map.addLayer( - { - id: layerId, - type: "line", - source: srcId, - layout: { "line-cap": "round", "line-join": "round", visibility: "visible" }, - paint: { - "line-color": ["case", ["==", ["get", "highlighted"], 1], FLEET_LINE_ML_HL, FLEET_LINE_ML] as never, - "line-width": ["case", ["==", ["get", "highlighted"], 1], 2, 1.1] as never, - "line-opacity": 0.85, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn("Fleet circles layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(layerId, "visibility", "visible"); - } catch { - // ignore - } - } - - reorderGlobeFeatureLayers(); - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - ensure(); - return () => { - stop(); - }; - }, [ - projection, - overlays.fleetCircles, - fleetCircles, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); - - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = "pair-range-ml-src"; - const layerId = "pair-range-ml"; - - const remove = () => { - try { - if (map.getLayer(layerId)) map.setLayoutProperty(layerId, "visibility", "none"); - } catch { - // ignore - } - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - if (projection !== "globe" || !overlays.pairRange) { - remove(); - return; - } - - const ranges: PairRangeCircle[] = []; - for (const p of pairLinks || []) { - const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2]; - ranges.push({ - center, - radiusNm: Math.max(0.05, p.distanceNm / 2), - warn: p.warn, - aMmsi: p.aMmsi, - bMmsi: p.bMmsi, - distanceNm: p.distanceNm, - }); - } - if (ranges.length === 0) { - remove(); - return; - } - - const fc: GeoJSON.FeatureCollection = { - type: "FeatureCollection", - features: ranges.map((c) => { - const ring = circleRingLngLat(c.center, c.radiusNm * 1852); - return { - type: "Feature", - id: makePairLinkFeatureId(c.aMmsi, c.bMmsi), - geometry: { type: "LineString", coordinates: ring }, - properties: { - type: "pair-range", - warn: c.warn, - aMmsi: c.aMmsi, - bMmsi: c.bMmsi, - distanceNm: c.distanceNm, - // Kept for backward compatibility with existing paint expressions. - // Actual hover-state highlighting is now handled in - // updateGlobeOverlayPaintStates. - highlighted: 0, - }, - }; - }), - }; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(fc); - else map.addSource(srcId, { type: "geojson", data: fc } as GeoJSONSourceSpecification); - } catch (e) { - console.warn("Pair range source setup failed:", e); - return; - } - - const before = undefined; - - if (!map.getLayer(layerId)) { - try { - map.addLayer( - { - id: layerId, - type: "line", - source: srcId, - layout: { "line-cap": "round", "line-join": "round", visibility: "visible" }, - paint: { - "line-color": [ - "case", - ["==", ["get", "highlighted"], 1], - ["case", ["boolean", ["get", "warn"], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], - ["boolean", ["get", "warn"], false], - PAIR_RANGE_WARN_ML, - PAIR_RANGE_NORMAL_ML, - ] as never, - "line-width": ["case", ["==", ["get", "highlighted"], 1], 1.6, 1.0] as never, - "line-opacity": 0.85, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn("Pair range layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(layerId, "visibility", "visible"); - } catch { - // ignore - } - } - - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - ensure(); - return () => { - stop(); - }; - }, [ - projection, - overlays.pairRange, - pairLinks, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); - - const updateGlobeOverlayPaintStates = useCallback(() => { - if (projection !== "globe" || projectionBusyRef.current) return; - - const map = mapRef.current; - if (!map || !map.isStyleLoaded()) return; - - const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]); - - const pairHighlightExpr = hoveredPairMmsiList.length >= 2 - ? makeMmsiPairHighlightExpr("aMmsi", "bMmsi", hoveredPairMmsiList) - : false; - - const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0 - ? makeMmsiAnyEndpointExpr("fcMmsi", "otherMmsi", fleetAwarePairMmsiList) - : false; - - const fleetOwnerMatchExpr = - hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false; - const fleetMemberExpr = - hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false; - const fleetHighlightExpr = - hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0 - ? (["any", fleetOwnerMatchExpr, fleetMemberExpr] as never) - : false; - - try { - if (map.getLayer("pair-lines-ml")) { - map.setPaintProperty( - "pair-lines-ml", - "line-color", - [ - "case", - pairHighlightExpr, - ["case", ["boolean", ["get", "warn"], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], - ["boolean", ["get", "warn"], false], - PAIR_LINE_WARN_ML, - PAIR_LINE_NORMAL_ML, - ] as never, - ); - map.setPaintProperty( - "pair-lines-ml", - "line-width", - ["case", pairHighlightExpr, 2.8, ["boolean", ["get", "warn"], false], 2.2, 1.4] as never, - ); - } - } catch { - // ignore - } - - try { - if (map.getLayer("fc-lines-ml")) { - map.setPaintProperty( - "fc-lines-ml", - "line-color", - [ - "case", - fcEndpointHighlightExpr, - ["case", ["boolean", ["get", "suspicious"], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], - ["boolean", ["get", "suspicious"], false], - FC_LINE_SUSPICIOUS_ML, - FC_LINE_NORMAL_ML, - ] as never, - ); - map.setPaintProperty( - "fc-lines-ml", - "line-width", - ["case", fcEndpointHighlightExpr, 2.0, 1.3] as never, - ); - } - } catch { - // ignore - } - - try { - if (map.getLayer("pair-range-ml")) { - map.setPaintProperty( - "pair-range-ml", - "line-color", - [ - "case", - pairHighlightExpr, - ["case", ["boolean", ["get", "warn"], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], - ["boolean", ["get", "warn"], false], - PAIR_RANGE_WARN_ML, - PAIR_RANGE_NORMAL_ML, - ] as never, - ); - map.setPaintProperty( - "pair-range-ml", - "line-width", - ["case", pairHighlightExpr, 1.6, 1.0] as never, - ); - } - } catch { - // ignore - } - - try { - if (map.getLayer("fleet-circles-ml-fill")) { - map.setPaintProperty( - "fleet-circles-ml-fill", - "fill-color", - [ - "case", - fleetHighlightExpr, - FLEET_FILL_ML_HL, - FLEET_FILL_ML, - ] as never, - ); - map.setPaintProperty( - "fleet-circles-ml-fill", - "fill-opacity", - ["case", fleetHighlightExpr, 0.7, 0.28] as never, - ); - } - if (map.getLayer("fleet-circles-ml")) { - map.setPaintProperty( - "fleet-circles-ml", - "line-color", - ["case", fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never, - ); - map.setPaintProperty( - "fleet-circles-ml", - "line-width", - ["case", fleetHighlightExpr, 2, 1.1] as never, - ); - } - } catch { - // ignore - } - }, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList]); - - useEffect(() => { - const map = mapRef.current; - if (!map) return; - const stop = onMapStyleReady(map, updateGlobeOverlayPaintStates); - updateGlobeOverlayPaintStates(); - return () => { - stop(); - }; - }, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateGlobeOverlayPaintStates]); - - const clearGlobeTooltip = useCallback(() => { - if (!mapTooltipRef.current) return; - try { - mapTooltipRef.current.remove(); - } catch { - // ignore - } - mapTooltipRef.current = null; - }, []); - - const setGlobeTooltip = useCallback((lngLat: maplibregl.LngLatLike, tooltipHtml: string) => { - const map = mapRef.current; - if (!map || !map.isStyleLoaded()) return; - if (!mapTooltipRef.current) { - mapTooltipRef.current = new maplibregl.Popup({ - closeButton: false, - closeOnClick: false, - maxWidth: "360px", - className: "maplibre-tooltip-popup", - }); - } - - const container = document.createElement("div"); - container.className = "maplibre-tooltip-popup__content"; - container.innerHTML = tooltipHtml; - - mapTooltipRef.current.setLngLat(lngLat).setDOMContent(container).addTo(map); - }, []); - - const buildGlobeFeatureTooltip = useCallback( - (feature: { properties?: Record | null; layer?: { id?: string } } | null | undefined) => { - if (!feature) return null; - const props = feature.properties || {}; - const layerId = feature.layer?.id; - - const maybeMmsi = toIntMmsi(props.mmsi); - if (maybeMmsi != null && maybeMmsi > 0) { - return getShipTooltipHtml({ mmsi: maybeMmsi, targetByMmsi: shipByMmsi, legacyHits }); - } - - if (layerId === "pair-lines-ml") { - const warn = props.warn === true; - const aMmsi = toIntMmsi(props.aMmsi); - const bMmsi = toIntMmsi(props.bMmsi); - if (aMmsi == null || bMmsi == null) return null; - return getPairLinkTooltipHtml({ - warn, - distanceNm: toSafeNumber(props.distanceNm), - aMmsi, - bMmsi, - legacyHits, - targetByMmsi: shipByMmsi, - }); - } - - if (layerId === "fc-lines-ml") { - const fcMmsi = toIntMmsi(props.fcMmsi); - const otherMmsi = toIntMmsi(props.otherMmsi); - if (fcMmsi == null || otherMmsi == null) return null; - return getFcLinkTooltipHtml({ - suspicious: props.suspicious === true, - distanceNm: toSafeNumber(props.distanceNm), - fcMmsi, - otherMmsi, - legacyHits, - targetByMmsi: shipByMmsi, - }); - } - - if (layerId === "pair-range-ml") { - const aMmsi = toIntMmsi(props.aMmsi); - const bMmsi = toIntMmsi(props.bMmsi); - if (aMmsi == null || bMmsi == null) return null; - return getRangeTooltipHtml({ - warn: props.warn === true, - distanceNm: toSafeNumber(props.distanceNm), - aMmsi, - bMmsi, - legacyHits, - }); - } - - if (layerId === "fleet-circles-ml" || layerId === "fleet-circles-ml-fill") { - return getFleetCircleTooltipHtml({ - ownerKey: String(props.ownerKey ?? ""), - ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ""), - count: Number(props.count ?? 0), - }); - } - - const zoneLabel = getZoneDisplayNameFromProps(props); - if (zoneLabel) { - return { html: `
${zoneLabel}
` }; - } - - return null; - }, - [legacyHits, shipByMmsi], - ); - - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const clearDeckGlobeHoverState = () => { - clearDeckHoverMmsi(); - clearDeckHoverPairs(); - setHoveredZoneId((prev) => (prev === null ? prev : null)); - clearMapFleetHoverState(); - }; - - const resetGlobeHoverStates = () => { - clearDeckHoverMmsi(); - clearDeckHoverPairs(); - setHoveredZoneId((prev) => (prev === null ? prev : null)); - clearMapFleetHoverState(); - }; - - const normalizeMmsiList = (value: unknown): number[] => { - if (!Array.isArray(value)) return []; - const out: number[] = []; - for (const n of value) { - const m = toIntMmsi(n); - if (m != null) out.push(m); - } - return out; - }; - - const onMouseMove = (e: maplibregl.MapMouseEvent) => { - if (projection !== "globe") { - clearGlobeTooltip(); - resetGlobeHoverStates(); - return; - } - if (projectionBusyRef.current) { - resetGlobeHoverStates(); - clearGlobeTooltip(); - return; - } - if (!map.isStyleLoaded()) { - clearDeckGlobeHoverState(); - clearGlobeTooltip(); - return; - } - - let candidateLayerIds: string[] = []; - try { - candidateLayerIds = [ - "ships-globe", - "ships-globe-halo", - "ships-globe-outline", - "pair-lines-ml", - "fc-lines-ml", - "fleet-circles-ml", - "fleet-circles-ml-fill", - "pair-range-ml", - "zones-fill", - "zones-line", - "zones-label", - ].filter((id) => map.getLayer(id)); - } catch { - candidateLayerIds = []; - } - - if (candidateLayerIds.length === 0) { - resetGlobeHoverStates(); - clearGlobeTooltip(); - return; - } - - let rendered: Array<{ properties?: Record | null; layer?: { id?: string } }> = []; - try { - rendered = map.queryRenderedFeatures(e.point, { layers: candidateLayerIds }) as unknown as Array<{ - properties?: Record | null; - layer?: { id?: string }; - }>; - } catch { - rendered = []; - } - - const priority = [ - "ships-globe", - "ships-globe-halo", - "ships-globe-outline", - "pair-lines-ml", - "fc-lines-ml", - "pair-range-ml", - "fleet-circles-ml-fill", - "fleet-circles-ml", - "zones-fill", - "zones-line", - "zones-label", - ]; - - const first = priority.map((id) => rendered.find((r) => r.layer?.id === id)).find(Boolean) as - | { properties?: Record | null; layer?: { id?: string } } - | undefined; - - if (!first) { - resetGlobeHoverStates(); - clearGlobeTooltip(); - return; - } - - const layerId = first.layer?.id; - const props = first.properties || {}; - const isShipLayer = layerId === "ships-globe" || layerId === "ships-globe-halo" || layerId === "ships-globe-outline"; - const isPairLayer = layerId === "pair-lines-ml" || layerId === "pair-range-ml"; - const isFcLayer = layerId === "fc-lines-ml"; - const isFleetLayer = layerId === "fleet-circles-ml" || layerId === "fleet-circles-ml-fill"; - const isZoneLayer = layerId === "zones-fill" || layerId === "zones-line" || layerId === "zones-label"; - - if (isShipLayer) { - const mmsi = toIntMmsi(props.mmsi); - setDeckHoverMmsi(mmsi == null ? [] : [mmsi]); - clearDeckHoverPairs(); - clearMapFleetHoverState(); - setHoveredZoneId((prev) => (prev === null ? prev : null)); - } else if (isPairLayer) { - const aMmsi = toIntMmsi(props.aMmsi); - const bMmsi = toIntMmsi(props.bMmsi); - setDeckHoverPairs([...(aMmsi == null ? [] : [aMmsi]), ...(bMmsi == null ? [] : [bMmsi])]); - clearDeckHoverMmsi(); - clearMapFleetHoverState(); - setHoveredZoneId((prev) => (prev === null ? prev : null)); - } else if (isFcLayer) { - const from = toIntMmsi(props.fcMmsi); - const to = toIntMmsi(props.otherMmsi); - const fromTo = [from, to].filter((v): v is number => v != null); - setDeckHoverPairs(fromTo); - setDeckHoverMmsi(fromTo); - clearMapFleetHoverState(); - setHoveredZoneId((prev) => (prev === null ? prev : null)); - } else if (isFleetLayer) { - const ownerKey = String(props.ownerKey ?? ""); - const list = normalizeMmsiList(props.vesselMmsis); - setMapFleetHoverState(ownerKey || null, list); - clearDeckHoverMmsi(); - clearDeckHoverPairs(); - setHoveredZoneId((prev) => (prev === null ? prev : null)); - } else if (isZoneLayer) { - clearMapFleetHoverState(); - clearDeckHoverMmsi(); - clearDeckHoverPairs(); - const zoneId = getZoneIdFromProps(props); - setHoveredZoneId(zoneId || null); - } else { - resetGlobeHoverStates(); - } - - const tooltip = buildGlobeFeatureTooltip(first); - if (!tooltip) { - if (!isZoneLayer) { - resetGlobeHoverStates(); - } - clearGlobeTooltip(); - return; - } - - const content = tooltip?.html ?? ""; - if (content) { - setGlobeTooltip(e.lngLat, content); - return; - } - clearGlobeTooltip(); - }; - - const onMouseOut = () => { - resetGlobeHoverStates(); - clearGlobeTooltip(); - }; - - map.on("mousemove", onMouseMove); - map.on("mouseout", onMouseOut); - - return () => { - map.off("mousemove", onMouseMove); - map.off("mouseout", onMouseOut); - clearGlobeTooltip(); - }; - }, [ - projection, - buildGlobeFeatureTooltip, - clearGlobeTooltip, - clearMapFleetHoverState, - clearDeckHoverPairs, - clearDeckHoverMmsi, - setDeckHoverPairs, - setDeckHoverMmsi, - setMapFleetHoverState, - setGlobeTooltip, - ]); - - const legacyTargets = useMemo(() => { - if (!legacyHits) return []; - return shipData.filter((t) => legacyHits.has(t.mmsi)); - }, [shipData, legacyHits]); - - const legacyTargetsOrdered = useMemo(() => { - if (legacyTargets.length === 0) return legacyTargets; - const layer = [...legacyTargets]; - layer.sort((a, b) => a.mmsi - b.mmsi); - return layer; - }, [legacyTargets]); - - const legacyOverlayTargets = useMemo(() => { - if (shipHighlightSet.size === 0) return []; - return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi)); - }, [legacyTargets, shipHighlightSet]); - + // ── Overlay data memos ─────────────────────────────────────────────── const fcDashed = useMemo(() => { const segs: DashSeg[] = []; for (const l of fcLinks || []) { @@ -3591,7 +417,7 @@ export function Map3D({ (line) => [line.fromMmsi, line.toMmsi].some((mmsi) => (mmsi == null ? false : highlightedMmsiSetCombined.has(mmsi))), ); - }, [fcDashed, hoveredShipSignature, overlays.fcLines, highlightedMmsiSetCombined]); + }, [fcDashed, overlays.fcLines, highlightedMmsiSetCombined]); const fleetCirclesInteractive = useMemo(() => { if (!overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) return []; @@ -3600,959 +426,84 @@ export function Map3D({ return circles.filter((item) => isHighlightedFleet(item.ownerKey, item.vesselMmsis)); }, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, highlightedMmsiSetCombined]); - // Static deck layers for mercator (positions + base states). Interaction overlays are handled separately. - useEffect(() => { - const map = mapRef.current; - if (!map) return; - if (projection !== "mercator" || projectionBusyRef.current) { - if (projection !== "mercator") { - try { - if (overlayRef.current) overlayRef.current.setProps({ layers: [] } as never); - } catch { - // ignore - } - } - return; - } + // ── Hook orchestration ─────────────────────────────────────────────── + const { ensureMercatorOverlay, clearGlobeNativeLayers, pulseMapSync } = useMapInit( + containerRef, mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, + baseMapRef, projectionRef, + { baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch }, + ); - const deckTarget = ensureMercatorOverlay(); - if (!deckTarget) return; + const reorderGlobeFeatureLayers = useProjectionToggle( + mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, projectionBusyRef, + { projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch }, + ); - const layers: unknown[] = []; - const overlayParams = DEPTH_DISABLED_PARAMS; - const clearDeckHover = () => { - touchDeckHoverState(false); - }; - const isTargetShip = (mmsi: number) => (legacyHits ? legacyHits.has(mmsi) : false); - const shipOtherData: AisTarget[] = []; - const shipTargetData: AisTarget[] = []; - for (const t of shipLayerData) { - if (isTargetShip(t.mmsi)) shipTargetData.push(t); - else shipOtherData.push(t); - } - const shipOverlayOtherData: AisTarget[] = []; - const shipOverlayTargetData: AisTarget[] = []; - for (const t of shipOverlayLayerData) { - if (isTargetShip(t.mmsi)) shipOverlayTargetData.push(t); - else shipOverlayOtherData.push(t); - } + useBaseMapToggle( + mapRef, + { baseMap, projection, showSeamark: settings.showSeamark, mapSyncEpoch, pulseMapSync }, + ); - if (settings.showDensity) { - layers.push( - new HexagonLayer({ - id: "density", - data: shipLayerData, - pickable: true, - extruded: true, - radius: 2500, - elevationScale: 35, - coverage: 0.92, - opacity: 0.35, - getPosition: (d) => [d.lon, d.lat], - }), - ); - } + useZonesLayer( + mapRef, projectionBusyRef, reorderGlobeFeatureLayers, + { zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch }, + ); - if (overlays.pairRange && pairRanges.length > 0) { - layers.push( - new ScatterplotLayer({ - id: "pair-range", - data: pairRanges, - pickable: true, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "meters", - getRadius: (d) => d.radiusNm * 1852, - radiusMinPixels: 10, - lineWidthUnits: "pixels", - getLineWidth: () => 1, - getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK), - getPosition: (d) => d.center, - onHover: (info) => { - if (!info.object) { - clearDeckHover(); - return; - } - touchDeckHoverState(true); - const p = info.object as PairRangeCircle; - setDeckHoverPairs([p.aMmsi, p.bMmsi]); - setDeckHoverMmsi([p.aMmsi, p.bMmsi]); - clearMapFleetHoverState(); - }, - onClick: (info) => { - if (!info.object) { - onSelectMmsi(null); - return; - } - const obj = info.object as PairRangeCircle; - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - onToggleHighlightMmsi?.(obj.aMmsi); - onToggleHighlightMmsi?.(obj.bMmsi); - return; - } - onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); - }, - }), - ); - } + usePredictionVectors( + mapRef, projectionBusyRef, reorderGlobeFeatureLayers, + { + overlays, settings, shipData, legacyHits, selectedMmsi, + externalHighlightedSetRef, projection, baseMap, mapSyncEpoch, + }, + ); - if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) { - layers.push( - new LineLayer({ - id: "pair-lines", - data: pairLinks, - pickable: true, - parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, - getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK), - getWidth: (d) => (d.warn ? 2.2 : 1.4), - widthUnits: "pixels", - onHover: (info) => { - if (!info.object) { - clearDeckHover(); - return; - } - touchDeckHoverState(true); - const obj = info.object as PairLink; - setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); - setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); - clearMapFleetHoverState(); - }, - onClick: (info) => { - if (!info.object) return; - const obj = info.object as PairLink; - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - onToggleHighlightMmsi?.(obj.aMmsi); - onToggleHighlightMmsi?.(obj.bMmsi); - return; - } - onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); - }, - }), - ); - } + useGlobeShips( + mapRef, projectionBusyRef, reorderGlobeFeatureLayers, + { + projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet, + shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch, + onSelectMmsi, onToggleHighlightMmsi, targets, overlays, + legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier, + }, + ); - if (overlays.fcLines && fcDashed.length > 0) { - layers.push( - new LineLayer({ - id: "fc-lines", - data: fcDashed, - pickable: true, - parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, - getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK), - getWidth: () => 1.3, - widthUnits: "pixels", - onHover: (info) => { - if (!info.object) { - clearDeckHover(); - return; - } - touchDeckHoverState(true); - const obj = info.object as DashSeg; - if (obj.fromMmsi == null || obj.toMmsi == null) { - clearDeckHover(); - return; - } - setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); - setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); - clearMapFleetHoverState(); - }, - onClick: (info) => { - if (!info.object) return; - const obj = info.object as DashSeg; - if (obj.fromMmsi == null || obj.toMmsi == null) return; - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - onToggleHighlightMmsi?.(obj.fromMmsi); - onToggleHighlightMmsi?.(obj.toMmsi); - return; - } - onDeckSelectOrHighlight({ mmsi: obj.fromMmsi }); - }, - }), - ); - } + useGlobeOverlays( + mapRef, projectionBusyRef, reorderGlobeFeatureLayers, + { + overlays, pairLinks, fcLinks, fleetCircles, projection, + mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, + }, + ); - if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) { - layers.push( - new ScatterplotLayer({ - id: "fleet-circles", - data: fleetCircles, - pickable: true, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "meters", - getRadius: (d) => d.radiusNm * 1852, - lineWidthUnits: "pixels", - getLineWidth: () => 1.1, - getLineColor: () => FLEET_RANGE_LINE_DECK, - getPosition: (d) => d.center, - onHover: (info) => { - if (!info.object) { - clearDeckHover(); - return; - } - touchDeckHoverState(true); - const obj = info.object as FleetCircle; - const list = toFleetMmsiList(obj.vesselMmsis); - setMapFleetHoverState(obj.ownerKey || null, list); - setDeckHoverMmsi(list); - clearDeckHoverPairs(); - }, - onClick: (info) => { - if (!info.object) return; - const obj = info.object as FleetCircle; - const list = toFleetMmsiList(obj.vesselMmsis); - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - for (const mmsi of list) onToggleHighlightMmsi?.(mmsi); - return; - } - const first = list[0]; - if (first != null) onDeckSelectOrHighlight({ mmsi: first }); - }, - }), - ); - } + useGlobeInteraction( + mapRef, projectionBusyRef, + { + projection, settings, overlays, targets, shipData, shipByMmsi, selectedMmsi, + hoveredZoneId, legacyHits, + clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState, + setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState, setHoveredZoneId, + }, + ); - if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) { - layers.push( - new ScatterplotLayer({ - id: "fleet-circles-fill", - data: fleetCircles, - pickable: false, - billboard: false, - parameters: overlayParams, - filled: true, - stroked: false, - radiusUnits: "meters", - getRadius: (d) => d.radiusNm * 1852, - getFillColor: () => FLEET_RANGE_FILL_DECK, - getPosition: (d) => d.center, - }), - ); - } + useDeckLayers( + mapRef, overlayRef, globeDeckLayerRef, projectionBusyRef, + { + projection, settings, shipLayerData, shipOverlayLayerData, shipData, + legacyHits, pairLinks, fcLinks, fcDashed, fleetCircles, pairRanges, + pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive, + overlays, shipByMmsi, selectedMmsi, shipHighlightSet, + isHighlightedFleet, isHighlightedPair, isHighlightedMmsi, + clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState, + setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState, + toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier, + onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi, + ensureMercatorOverlay, projectionRef, + }, + ); - if (settings.showShips) { - // Always render non-target ships below target ships. - const shipOnHover = (info: PickingInfo) => { - if (!info.object) { - clearDeckHover(); - return; - } - touchDeckHoverState(true); - const obj = info.object as AisTarget; - setDeckHoverMmsi([obj.mmsi]); - clearDeckHoverPairs(); - clearMapFleetHoverState(); - }; - const shipOnClick = (info: PickingInfo) => { - if (!info.object) { - onSelectMmsi(null); - return; - } - onDeckSelectOrHighlight( - { - mmsi: (info.object as AisTarget).mmsi, - srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent, - }, - true, - ); - }; + useFlyTo( + mapRef, projectionRef, + { selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom }, + ); - if (shipOtherData.length > 0) { - layers.push( - new IconLayer({ - id: "ships-other", - data: shipOtherData, - pickable: true, - billboard: false, - parameters: overlayParams, - iconAtlas: "/assets/ship.svg", - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => "ship", - getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => - -getDisplayHeading({ - cog: d.cog, - heading: d.heading, - }), - sizeUnits: "pixels", - getSize: () => FLAT_SHIP_ICON_SIZE, - getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET), - onHover: shipOnHover, - onClick: shipOnClick, - alphaCutoff: 0.05, - }), - ); - } - - // Hover/selection overlay for non-target ships stays below all target ships. - if (shipOverlayOtherData.length > 0) { - layers.push( - new IconLayer({ - id: "ships-overlay-other", - data: shipOverlayOtherData, - pickable: false, - billboard: false, - parameters: overlayParams, - iconAtlas: "/assets/ship.svg", - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => "ship", - getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => - -getDisplayHeading({ - cog: d.cog, - heading: d.heading, - }), - sizeUnits: "pixels", - getSize: (d) => { - if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; - if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; - return 0; - }, - getColor: (d) => getShipColor(d, selectedMmsi, null, shipHighlightSet), - alphaCutoff: 0.05, - }), - ); - } - - // Target ship halos and icons render above non-target ships. - if (legacyTargetsOrdered.length > 0) { - layers.push( - new ScatterplotLayer({ - id: "legacy-halo", - data: legacyTargetsOrdered, - pickable: false, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "pixels", - getRadius: () => FLAT_LEGACY_HALO_RADIUS, - lineWidthUnits: "pixels", - getLineWidth: () => 2, - getLineColor: (d) => { - const l = legacyHits?.get(d.mmsi); - const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; - if (!rgb) return [245, 158, 11, 200]; - return [rgb[0], rgb[1], rgb[2], 200]; - }, - getPosition: (d) => [d.lon, d.lat] as [number, number], - }), - ); - } - - if (shipTargetData.length > 0) { - layers.push( - new IconLayer({ - id: "ships-target", - data: shipTargetData, - pickable: true, - billboard: false, - parameters: overlayParams, - iconAtlas: "/assets/ship.svg", - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => "ship", - getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => - -getDisplayHeading({ - cog: d.cog, - heading: d.heading, - }), - sizeUnits: "pixels", - getSize: () => FLAT_SHIP_ICON_SIZE, - getColor: (d) => getShipColor(d, null, legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET), - onHover: shipOnHover, - onClick: shipOnClick, - alphaCutoff: 0.05, - }), - ); - } - } - - // Interaction overlays (hover/selection highlights) are appended so they always render above base layers. - if (overlays.pairRange && pairRangesInteractive.length > 0) { - layers.push( - new ScatterplotLayer({ - id: "pair-range-overlay", - data: pairRangesInteractive, - pickable: false, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "meters", - getRadius: (d) => d.radiusNm * 1852, - radiusMinPixels: 10, - lineWidthUnits: "pixels", - getLineWidth: () => 2.2, - getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), - getPosition: (d) => d.center, - }), - ); - } - - if (overlays.pairLines && pairLinksInteractive.length > 0) { - layers.push( - new LineLayer({ - id: "pair-lines-overlay", - data: pairLinksInteractive, - pickable: false, - parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, - getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), - getWidth: () => 2.6, - widthUnits: "pixels", - }), - ); - } - - if (overlays.fcLines && fcLinesInteractive.length > 0) { - layers.push( - new LineLayer({ - id: "fc-lines-overlay", - data: fcLinesInteractive, - pickable: false, - parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, - getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), - getWidth: () => 1.9, - widthUnits: "pixels", - }), - ); - } - - if (overlays.fleetCircles && fleetCirclesInteractive.length > 0) { - layers.push( - new ScatterplotLayer({ - id: "fleet-circles-overlay-fill", - data: fleetCirclesInteractive, - pickable: false, - billboard: false, - parameters: overlayParams, - filled: true, - stroked: false, - radiusUnits: "meters", - getRadius: (d) => d.radiusNm * 1852, - getFillColor: () => FLEET_RANGE_FILL_DECK_HL, - }), - ); - layers.push( - new ScatterplotLayer({ - id: "fleet-circles-overlay", - data: fleetCirclesInteractive, - pickable: false, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "meters", - getRadius: (d) => d.radiusNm * 1852, - lineWidthUnits: "pixels", - getLineWidth: () => 1.8, - getLineColor: () => FLEET_RANGE_LINE_DECK_HL, - getPosition: (d) => d.center, - }), - ); - } - - if (settings.showShips && legacyOverlayTargets.length > 0) { - layers.push( - new ScatterplotLayer({ - id: "legacy-halo-overlay", - data: legacyOverlayTargets, - pickable: false, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "pixels", - getRadius: (d) => { - if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; - return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; - }, - lineWidthUnits: "pixels", - getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2.2), - getLineColor: (d) => { - if (selectedMmsi && d.mmsi === selectedMmsi) return [14, 234, 255, 230]; - const l = legacyHits?.get(d.mmsi); - const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; - if (!rgb) return [245, 158, 11, 210]; - return [rgb[0], rgb[1], rgb[2], 210]; - }, - getPosition: (d) => [d.lon, d.lat] as [number, number], - }), - ); - } - - if (settings.showShips && shipOverlayTargetData.length > 0) { - layers.push( - new IconLayer({ - id: "ships-overlay-target", - data: shipOverlayTargetData, - pickable: false, - billboard: false, - parameters: overlayParams, - iconAtlas: "/assets/ship.svg", - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => "ship", - getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => - -getDisplayHeading({ - cog: d.cog, - heading: d.heading, - }), - sizeUnits: "pixels", - getSize: (d) => { - if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; - if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; - return 0; - }, - getColor: (d) => { - if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; - return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); - }, - }), - ); - } - - const normalizedLayers = sanitizeDeckLayerList(layers); - const deckProps = { - layers: normalizedLayers, - getTooltip: (info: PickingInfo) => { - if (!info.object) return null; - if (info.layer && info.layer.id === "density") { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const o: any = info.object; - const n = Array.isArray(o?.points) ? o.points.length : 0; - return { text: `AIS density: ${n}` }; - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const obj: any = info.object; - if (typeof obj.mmsi === "number") { - return getShipTooltipHtml({ mmsi: obj.mmsi, targetByMmsi: shipByMmsi, legacyHits }); - } - if (info.layer && info.layer.id === "pair-lines") { - const aMmsi = toSafeNumber(obj.aMmsi); - const bMmsi = toSafeNumber(obj.bMmsi); - if (aMmsi == null || bMmsi == null) return null; - return getPairLinkTooltipHtml({ - warn: !!obj.warn, - distanceNm: toSafeNumber(obj.distanceNm), - aMmsi, - bMmsi, - legacyHits, - targetByMmsi: shipByMmsi, - }); - } - if (info.layer && info.layer.id === "fc-lines") { - const fcMmsi = toSafeNumber(obj.fcMmsi); - const otherMmsi = toSafeNumber(obj.otherMmsi); - if (fcMmsi == null || otherMmsi == null) return null; - return getFcLinkTooltipHtml({ - suspicious: !!obj.suspicious, - distanceNm: toSafeNumber(obj.distanceNm), - fcMmsi, - otherMmsi, - legacyHits, - targetByMmsi: shipByMmsi, - }); - } - if (info.layer && info.layer.id === "pair-range") { - const aMmsi = toSafeNumber(obj.aMmsi); - const bMmsi = toSafeNumber(obj.bMmsi); - if (aMmsi == null || bMmsi == null) return null; - return getRangeTooltipHtml({ - warn: !!obj.warn, - distanceNm: toSafeNumber(obj.distanceNm), - aMmsi, - bMmsi, - legacyHits, - }); - } - if (info.layer && info.layer.id === "fleet-circles") { - return getFleetCircleTooltipHtml({ - ownerKey: String(obj.ownerKey ?? ""), - ownerLabel: String(obj.ownerKey ?? ""), - count: Number(obj.count ?? 0), - }); - } - return null; - }, - onClick: (info: PickingInfo) => { - if (!info.object) { - onSelectMmsi(null); - return; - } - if (info.layer && info.layer.id === "density") return; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const obj: any = info.object; - if (typeof obj.mmsi === "number") { - const t = obj as AisTarget; - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - onToggleHighlightMmsi?.(t.mmsi); - return; - } - onSelectMmsi(t.mmsi); - const clickOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 }; - if (projectionRef.current === "globe") { - map.flyTo(clickOpts); - } else { - map.easeTo(clickOpts); - } - } - }, - }; - - try { - deckTarget.setProps(deckProps as never); - } catch (e) { - console.error("Failed to apply base mercator deck props. Falling back to empty layer set.", e); - try { - deckTarget.setProps({ ...deckProps, layers: [] as unknown[] } as never); - } catch { - // Ignore secondary failure. - } - } - }, [ - ensureMercatorOverlay, - projection, - overlayRef, - projectionBusyRef, - shipLayerData, - shipByMmsi, - pairRanges, - pairLinks, - fcDashed, - fleetCircles, - legacyTargetsOrdered, - legacyHits, - legacyOverlayTargets, - shipOverlayLayerData, - pairRangesInteractive, - pairLinksInteractive, - fcLinesInteractive, - fleetCirclesInteractive, - overlays.pairRange, - overlays.pairLines, - overlays.fcLines, - overlays.fleetCircles, - settings.showDensity, - settings.showShips, - onDeckSelectOrHighlight, - onSelectMmsi, - onToggleHighlightMmsi, - setDeckHoverPairs, - clearMapFleetHoverState, - setDeckHoverMmsi, - clearDeckHoverMmsi, - toFleetMmsiList, - touchDeckHoverState, - hasAuxiliarySelectModifier, - ]); - - // Globe deck (3D) layer updates. Keep rendering logic deterministic and avoid per-frame churn. - useEffect(() => { - const map = mapRef.current; - if (!map || projection !== "globe" || projectionBusyRef.current) return; - const deckTarget = globeDeckLayerRef.current; - if (!deckTarget) return; - - const overlayParams = GLOBE_OVERLAY_PARAMS; - const globeLayers: unknown[] = []; - - if (overlays.pairRange && pairRanges.length > 0) { - globeLayers.push( - new ScatterplotLayer({ - id: "pair-range-globe", - data: pairRanges, - pickable: true, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "meters", - getRadius: (d) => d.radiusNm * 1852, - radiusMinPixels: 10, - lineWidthUnits: "pixels", - getLineWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), - getLineColor: (d) => { - const hl = isHighlightedPair(d.aMmsi, d.bMmsi); - if (hl) return d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL; - return d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK; - }, - getPosition: (d) => d.center, - onHover: (info) => { - if (!info.object) { - clearDeckHoverPairs(); - clearDeckHoverMmsi(); - return; - } - touchDeckHoverState(true); - const p = info.object as PairRangeCircle; - setDeckHoverPairs([p.aMmsi, p.bMmsi]); - setDeckHoverMmsi([p.aMmsi, p.bMmsi]); - clearMapFleetHoverState(); - }, - }), - ); - } - - if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) { - const links = pairLinks || []; - globeLayers.push( - new LineLayer({ - id: "pair-lines-globe", - data: links, - pickable: true, - parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, - getColor: (d) => { - const hl = isHighlightedPair(d.aMmsi, d.bMmsi); - if (hl) return d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL; - return d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK; - }, - getWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), - widthUnits: "pixels", - onHover: (info) => { - if (!info.object) { - clearDeckHoverPairs(); - clearDeckHoverMmsi(); - return; - } - touchDeckHoverState(true); - const obj = info.object as PairLink; - setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); - setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); - clearMapFleetHoverState(); - }, - }), - ); - } - - if (overlays.fcLines && fcDashed.length > 0) { - globeLayers.push( - new LineLayer({ - id: "fc-lines-globe", - data: fcDashed, - pickable: true, - parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, - getColor: (d) => { - const isHighlighted = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); - if (isHighlighted) return d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL; - return d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK; - }, - getWidth: (d) => { - const isHighlighted = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); - return isHighlighted ? 1.9 : 1.3; - }, - widthUnits: "pixels", - onHover: (info) => { - if (!info.object) { - clearDeckHoverPairs(); - clearDeckHoverMmsi(); - return; - } - touchDeckHoverState(true); - const obj = info.object as DashSeg; - const aMmsi = obj.fromMmsi; - const bMmsi = obj.toMmsi; - if (aMmsi == null || bMmsi == null) { - clearDeckHoverPairs(); - clearDeckHoverMmsi(); - return; - } - setDeckHoverPairs([aMmsi, bMmsi]); - setDeckHoverMmsi([aMmsi, bMmsi]); - clearMapFleetHoverState(); - }, - }), - ); - } - - if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) { - const circles = fleetCircles || []; - globeLayers.push( - new ScatterplotLayer({ - id: "fleet-circles-globe", - data: circles, - pickable: true, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "meters", - getRadius: (d) => d.radiusNm * 1852, - lineWidthUnits: "pixels", - getLineWidth: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), - getLineColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_LINE_DECK_HL : FLEET_RANGE_LINE_DECK), - getPosition: (d) => d.center, - onHover: (info) => { - if (!info.object) { - clearDeckHoverPairs(); - clearDeckHoverMmsi(); - clearMapFleetHoverState(); - return; - } - touchDeckHoverState(true); - const obj = info.object as FleetCircle; - const list = toFleetMmsiList(obj.vesselMmsis); - setMapFleetHoverState(obj.ownerKey || null, list); - setDeckHoverMmsi(list); - clearDeckHoverPairs(); - }, - }), - ); - globeLayers.push( - new ScatterplotLayer({ - id: "fleet-circles-fill-globe", - data: circles, - pickable: false, - billboard: false, - parameters: overlayParams, - filled: true, - stroked: false, - radiusUnits: "meters", - getRadius: (d) => d.radiusNm * 1852, - getFillColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_FILL_DECK_HL : FLEET_RANGE_FILL_DECK), - getPosition: (d) => d.center, - }), - ); - } - - if (settings.showShips && legacyTargetsOrdered.length > 0) { - globeLayers.push( - new ScatterplotLayer({ - id: "legacy-halo-globe", - data: legacyTargetsOrdered, - pickable: false, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "pixels", - getRadius: (d) => { - if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; - return FLAT_LEGACY_HALO_RADIUS; - }, - lineWidthUnits: "pixels", - getLineWidth: (d) => { - return selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2; - }, - getLineColor: (d) => { - if (selectedMmsi && d.mmsi === selectedMmsi) return [14, 234, 255, 230]; - const l = legacyHits?.get(d.mmsi); - const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; - if (!rgb) return [245, 158, 11, 200]; - return [rgb[0], rgb[1], rgb[2], 200]; - }, - getPosition: (d) => [d.lon, d.lat] as [number, number], - }), - ); - } - - const normalizedLayers = sanitizeDeckLayerList(globeLayers); - const globeDeckProps = { - layers: normalizedLayers, - getTooltip: undefined, - onClick: undefined, - }; - - try { - deckTarget.setProps(globeDeckProps as never); - } catch (e) { - console.error("Failed to apply globe deck props. Falling back to empty deck layer set.", e); - try { - deckTarget.setProps({ ...globeDeckProps, layers: [] as unknown[] } as never); - } catch { - // Ignore secondary failure. - } - } - }, [ - projection, - projectionBusyRef, - pairRanges, - pairLinks, - fcDashed, - fleetCircles, - legacyTargetsOrdered, - overlays.pairRange, - overlays.pairLines, - overlays.fcLines, - overlays.fleetCircles, - settings.showShips, - selectedMmsi, - isHighlightedFleet, - isHighlightedPair, - clearDeckHoverPairs, - clearDeckHoverMmsi, - clearMapFleetHoverState, - setDeckHoverPairs, - setDeckHoverMmsi, - setMapFleetHoverState, - toFleetMmsiList, - touchDeckHoverState, - legacyHits, - ]); - - // When the selected MMSI changes due to external UI (e.g., list click), fly to it. - useEffect(() => { - const map = mapRef.current; - if (!map) return; - if (!selectedMmsi) return; - const t = shipData.find((x) => x.mmsi === selectedMmsi); - if (!t) return; - const opts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 }; - if (projectionRef.current === "globe") { - map.flyTo(opts); - } else { - map.easeTo(opts); - } - }, [selectedMmsi, shipData]); - - useEffect(() => { - const map = mapRef.current; - if (!map || fleetFocusLon == null || fleetFocusLat == null || !Number.isFinite(fleetFocusLon) || !Number.isFinite(fleetFocusLat)) - return; - const lon = fleetFocusLon; - const lat = fleetFocusLat; - const zoom = fleetFocusZoom ?? 10; - - const apply = () => { - const opts = { center: [lon, lat] as [number, number], zoom, duration: 700 }; - if (projectionRef.current === "globe") { - map.flyTo(opts); - } else { - map.easeTo(opts); - } - }; - - if (map.isStyleLoaded()) { - apply(); - return; - } - - const stop = onMapStyleReady(map, apply); - return () => { - stop(); - }; - }, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]); - - return
; + return
; } diff --git a/apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts b/apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts new file mode 100644 index 0000000..9844603 --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts @@ -0,0 +1,132 @@ +import { useEffect, useRef, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import type { BaseMapId, MapProjectionId } from '../types'; +import { kickRepaint, onMapStyleReady, getLayerId } from '../lib/mapCore'; +import { ensureSeamarkOverlay } from '../layers/seamark'; +import { applyBathymetryZoomProfile, resolveMapStyle } from '../layers/bathymetry'; + +export function useBaseMapToggle( + mapRef: MutableRefObject, + opts: { + baseMap: BaseMapId; + projection: MapProjectionId; + showSeamark: boolean; + mapSyncEpoch: number; + pulseMapSync: () => void; + }, +) { + const { baseMap, projection, showSeamark, mapSyncEpoch, pulseMapSync } = opts; + + const showSeamarkRef = useRef(showSeamark); + const bathyZoomProfileKeyRef = useRef(''); + + useEffect(() => { + showSeamarkRef.current = showSeamark; + }, [showSeamark]); + + // Base map style toggle + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + let cancelled = false; + const controller = new AbortController(); + let stop: (() => void) | null = null; + + (async () => { + try { + const style = await resolveMapStyle(baseMap, controller.signal); + if (cancelled) return; + map.setStyle(style, { diff: false }); + stop = onMapStyleReady(map, () => { + kickRepaint(map); + requestAnimationFrame(() => kickRepaint(map)); + pulseMapSync(); + }); + } catch (e) { + if (cancelled) return; + console.warn('Base map switch failed:', e); + } + })(); + + return () => { + cancelled = true; + controller.abort(); + stop?.(); + }; + }, [baseMap]); + + // Bathymetry zoom profile + water layer visibility + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const apply = () => { + if (!map.isStyleLoaded()) return; + const seaVisibility = 'visible' as const; + const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i; + + const nextProfileKey = `bathyZoomV1|${baseMap}|${projection}`; + if (bathyZoomProfileKeyRef.current !== nextProfileKey) { + applyBathymetryZoomProfile(map, baseMap, projection); + bathyZoomProfileKeyRef.current = nextProfileKey; + kickRepaint(map); + } + + try { + const style = map.getStyle(); + const styleLayers = style && Array.isArray(style.layers) ? style.layers : []; + for (const layer of styleLayers) { + const id = getLayerId(layer); + if (!id) continue; + const sourceLayer = String((layer as Record)['source-layer'] ?? '').toLowerCase(); + const source = String((layer as { source?: unknown }).source ?? '').toLowerCase(); + const type = String((layer as { type?: unknown }).type ?? '').toLowerCase(); + const isSea = seaRegex.test(id) || seaRegex.test(sourceLayer) || seaRegex.test(source); + const isRaster = type === 'raster'; + if (!isSea) continue; + if (!map.getLayer(id)) continue; + if (isRaster && id === 'seamark') continue; + try { + map.setLayoutProperty(id, 'visibility', seaVisibility); + } catch { + // ignore + } + } + } catch { + // ignore + } + }; + + const stop = onMapStyleReady(map, apply); + return () => { + stop(); + }; + }, [projection, baseMap, mapSyncEpoch]); + + // Seamark toggle + useEffect(() => { + const map = mapRef.current; + if (!map) return; + if (showSeamark) { + try { + ensureSeamarkOverlay(map, 'bathymetry-lines'); + map.setPaintProperty('seamark', 'raster-opacity', 0.85); + } catch { + // ignore until style is ready + } + return; + } + + try { + if (map.getLayer('seamark')) map.removeLayer('seamark'); + } catch { + // ignore + } + try { + if (map.getSource('seamark')) map.removeSource('seamark'); + } catch { + // ignore + } + }, [showSeamark]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts new file mode 100644 index 0000000..b22a6c4 --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts @@ -0,0 +1,665 @@ +import { useEffect, useMemo, type MutableRefObject } from 'react'; +import { HexagonLayer } from '@deck.gl/aggregation-layers'; +import { IconLayer, LineLayer, ScatterplotLayer } from '@deck.gl/layers'; +import { MapboxOverlay } from '@deck.gl/mapbox'; +import { type PickingInfo } from '@deck.gl/core'; +import type { AisTarget } from '../../../entities/aisTarget/model/types'; +import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; +import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types'; +import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; +import type { DashSeg, Map3DSettings, MapProjectionId, PairRangeCircle } from '../types'; +import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer'; +import { + SHIP_ICON_MAPPING, + FLAT_SHIP_ICON_SIZE, + FLAT_SHIP_ICON_SIZE_SELECTED, + FLAT_SHIP_ICON_SIZE_HIGHLIGHTED, + FLAT_LEGACY_HALO_RADIUS, + FLAT_LEGACY_HALO_RADIUS_SELECTED, + FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED, + EMPTY_MMSI_SET, + DEPTH_DISABLED_PARAMS, + GLOBE_OVERLAY_PARAMS, + LEGACY_CODE_COLORS, + PAIR_RANGE_NORMAL_DECK, + PAIR_RANGE_WARN_DECK, + PAIR_LINE_NORMAL_DECK, + PAIR_LINE_WARN_DECK, + FC_LINE_NORMAL_DECK, + FC_LINE_SUSPICIOUS_DECK, + FLEET_RANGE_LINE_DECK, + FLEET_RANGE_FILL_DECK, + PAIR_RANGE_NORMAL_DECK_HL, + PAIR_RANGE_WARN_DECK_HL, + PAIR_LINE_NORMAL_DECK_HL, + PAIR_LINE_WARN_DECK_HL, + FC_LINE_NORMAL_DECK_HL, + FC_LINE_SUSPICIOUS_DECK_HL, + FLEET_RANGE_LINE_DECK_HL, + FLEET_RANGE_FILL_DECK_HL, +} from '../constants'; +import { toSafeNumber } from '../lib/setUtils'; +import { getDisplayHeading, getShipColor } from '../lib/shipUtils'; +import { + getShipTooltipHtml, + getPairLinkTooltipHtml, + getFcLinkTooltipHtml, + getRangeTooltipHtml, + getFleetCircleTooltipHtml, +} from '../lib/tooltips'; +import { sanitizeDeckLayerList } from '../lib/mapCore'; + +export function useDeckLayers( + mapRef: MutableRefObject, + overlayRef: MutableRefObject, + globeDeckLayerRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + opts: { + projection: MapProjectionId; + settings: Map3DSettings; + shipLayerData: AisTarget[]; + shipOverlayLayerData: AisTarget[]; + shipData: AisTarget[]; + legacyHits: Map | null | undefined; + pairLinks: PairLink[] | undefined; + fcLinks: FcLink[] | undefined; + fcDashed: DashSeg[]; + fleetCircles: FleetCircle[] | undefined; + pairRanges: PairRangeCircle[]; + pairLinksInteractive: PairLink[]; + pairRangesInteractive: PairRangeCircle[]; + fcLinesInteractive: DashSeg[]; + fleetCirclesInteractive: FleetCircle[]; + overlays: MapToggleState; + shipByMmsi: Map; + selectedMmsi: number | null; + shipHighlightSet: Set; + isHighlightedFleet: (ownerKey: string, vesselMmsis: number[]) => boolean; + isHighlightedPair: (aMmsi: number, bMmsi: number) => boolean; + isHighlightedMmsi: (mmsi: number) => boolean; + clearDeckHoverPairs: () => void; + clearDeckHoverMmsi: () => void; + clearMapFleetHoverState: () => void; + setDeckHoverPairs: (next: number[]) => void; + setDeckHoverMmsi: (next: number[]) => void; + setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void; + toFleetMmsiList: (value: unknown) => number[]; + touchDeckHoverState: (isHover: boolean) => void; + hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean; + onDeckSelectOrHighlight: (info: unknown, allowMultiSelect?: boolean) => void; + onSelectMmsi: (mmsi: number | null) => void; + onToggleHighlightMmsi?: (mmsi: number) => void; + ensureMercatorOverlay: () => MapboxOverlay | null; + projectionRef: MutableRefObject; + }, +) { + const { + projection, settings, shipLayerData, shipOverlayLayerData, shipData, + legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges, + pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive, + overlays, shipByMmsi, selectedMmsi, shipHighlightSet, + isHighlightedFleet, isHighlightedPair, isHighlightedMmsi, + clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState, + setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState, + toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier, + onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi, + ensureMercatorOverlay, projectionRef, + } = opts; + + const legacyTargets = useMemo(() => { + if (!legacyHits) return []; + return shipData.filter((t) => legacyHits.has(t.mmsi)); + }, [shipData, legacyHits]); + + const legacyTargetsOrdered = useMemo(() => { + if (legacyTargets.length === 0) return legacyTargets; + const layer = [...legacyTargets]; + layer.sort((a, b) => a.mmsi - b.mmsi); + return layer; + }, [legacyTargets]); + + const legacyOverlayTargets = useMemo(() => { + if (shipHighlightSet.size === 0) return []; + return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi)); + }, [legacyTargets, shipHighlightSet]); + + // Mercator Deck layers + useEffect(() => { + const map = mapRef.current; + if (!map) return; + if (projection !== 'mercator' || projectionBusyRef.current) { + if (projection !== 'mercator') { + try { + if (overlayRef.current) overlayRef.current.setProps({ layers: [] } as never); + } catch { + // ignore + } + } + return; + } + + const deckTarget = ensureMercatorOverlay(); + if (!deckTarget) return; + + const layers: unknown[] = []; + const overlayParams = DEPTH_DISABLED_PARAMS; + const clearDeckHover = () => { + touchDeckHoverState(false); + }; + const isTargetShip = (mmsi: number) => (legacyHits ? legacyHits.has(mmsi) : false); + const shipOtherData: AisTarget[] = []; + const shipTargetData: AisTarget[] = []; + for (const t of shipLayerData) { + if (isTargetShip(t.mmsi)) shipTargetData.push(t); + else shipOtherData.push(t); + } + const shipOverlayOtherData: AisTarget[] = []; + const shipOverlayTargetData: AisTarget[] = []; + for (const t of shipOverlayLayerData) { + if (isTargetShip(t.mmsi)) shipOverlayTargetData.push(t); + else shipOverlayOtherData.push(t); + } + + if (settings.showDensity) { + layers.push( + new HexagonLayer({ + id: 'density', + data: shipLayerData, + pickable: true, + extruded: true, + radius: 2500, + elevationScale: 35, + coverage: 0.92, + opacity: 0.35, + getPosition: (d) => [d.lon, d.lat], + }), + ); + } + + if (overlays.pairRange && pairRanges.length > 0) { + layers.push( + new ScatterplotLayer({ + id: 'pair-range', + data: pairRanges, + pickable: true, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: 'meters', + getRadius: (d) => d.radiusNm * 1852, + radiusMinPixels: 10, + lineWidthUnits: 'pixels', + getLineWidth: () => 1, + getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK), + getPosition: (d) => d.center, + onHover: (info) => { + if (!info.object) { clearDeckHover(); return; } + touchDeckHoverState(true); + const p = info.object as PairRangeCircle; + setDeckHoverPairs([p.aMmsi, p.bMmsi]); + setDeckHoverMmsi([p.aMmsi, p.bMmsi]); + clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) { onSelectMmsi(null); return; } + const obj = info.object as PairRangeCircle; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + onToggleHighlightMmsi?.(obj.aMmsi); + onToggleHighlightMmsi?.(obj.bMmsi); + return; + } + onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); + }, + }), + ); + } + + if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) { + layers.push( + new LineLayer({ + id: 'pair-lines', + data: pairLinks, + pickable: true, + parameters: overlayParams, + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, + getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK), + getWidth: (d) => (d.warn ? 2.2 : 1.4), + widthUnits: 'pixels', + onHover: (info) => { + if (!info.object) { clearDeckHover(); return; } + touchDeckHoverState(true); + const obj = info.object as PairLink; + setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); + setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); + clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) return; + const obj = info.object as PairLink; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + onToggleHighlightMmsi?.(obj.aMmsi); + onToggleHighlightMmsi?.(obj.bMmsi); + return; + } + onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); + }, + }), + ); + } + + if (overlays.fcLines && fcDashed.length > 0) { + layers.push( + new LineLayer({ + id: 'fc-lines', + data: fcDashed, + pickable: true, + parameters: overlayParams, + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, + getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK), + getWidth: () => 1.3, + widthUnits: 'pixels', + onHover: (info) => { + if (!info.object) { clearDeckHover(); return; } + touchDeckHoverState(true); + const obj = info.object as DashSeg; + if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHover(); return; } + setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); + setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); + clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) return; + const obj = info.object as DashSeg; + if (obj.fromMmsi == null || obj.toMmsi == null) return; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + onToggleHighlightMmsi?.(obj.fromMmsi); + onToggleHighlightMmsi?.(obj.toMmsi); + return; + } + onDeckSelectOrHighlight({ mmsi: obj.fromMmsi }); + }, + }), + ); + } + + if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) { + layers.push( + new ScatterplotLayer({ + id: 'fleet-circles', + data: fleetCircles, + pickable: true, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: 'meters', + getRadius: (d) => d.radiusNm * 1852, + lineWidthUnits: 'pixels', + getLineWidth: () => 1.1, + getLineColor: () => FLEET_RANGE_LINE_DECK, + getPosition: (d) => d.center, + onHover: (info) => { + if (!info.object) { clearDeckHover(); return; } + touchDeckHoverState(true); + const obj = info.object as FleetCircle; + const list = toFleetMmsiList(obj.vesselMmsis); + setMapFleetHoverState(obj.ownerKey || null, list); + setDeckHoverMmsi(list); + clearDeckHoverPairs(); + }, + onClick: (info) => { + if (!info.object) return; + const obj = info.object as FleetCircle; + const list = toFleetMmsiList(obj.vesselMmsis); + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + for (const mmsi of list) onToggleHighlightMmsi?.(mmsi); + return; + } + const first = list[0]; + if (first != null) onDeckSelectOrHighlight({ mmsi: first }); + }, + }), + ); + layers.push( + new ScatterplotLayer({ + id: 'fleet-circles-fill', + data: fleetCircles, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: true, + stroked: false, + radiusUnits: 'meters', + getRadius: (d) => d.radiusNm * 1852, + getFillColor: () => FLEET_RANGE_FILL_DECK, + getPosition: (d) => d.center, + }), + ); + } + + if (settings.showShips) { + const shipOnHover = (info: PickingInfo) => { + if (!info.object) { clearDeckHover(); return; } + touchDeckHoverState(true); + const obj = info.object as AisTarget; + setDeckHoverMmsi([obj.mmsi]); + clearDeckHoverPairs(); + clearMapFleetHoverState(); + }; + const shipOnClick = (info: PickingInfo) => { + if (!info.object) { onSelectMmsi(null); return; } + onDeckSelectOrHighlight( + { + mmsi: (info.object as AisTarget).mmsi, + srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent, + }, + true, + ); + }; + + if (shipOtherData.length > 0) { + layers.push( + new IconLayer({ + id: 'ships-other', + data: shipOtherData, + pickable: true, + billboard: false, + parameters: overlayParams, + iconAtlas: '/assets/ship.svg', + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => 'ship', + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), + sizeUnits: 'pixels', + getSize: () => FLAT_SHIP_ICON_SIZE, + getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET), + onHover: shipOnHover, + onClick: shipOnClick, + alphaCutoff: 0.05, + }), + ); + } + + if (shipOverlayOtherData.length > 0) { + layers.push( + new IconLayer({ + id: 'ships-overlay-other', + data: shipOverlayOtherData, + pickable: false, + billboard: false, + parameters: overlayParams, + iconAtlas: '/assets/ship.svg', + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => 'ship', + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), + sizeUnits: 'pixels', + getSize: (d) => { + if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; + if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; + return 0; + }, + getColor: (d) => getShipColor(d, selectedMmsi, null, shipHighlightSet), + alphaCutoff: 0.05, + }), + ); + } + + if (legacyTargetsOrdered.length > 0) { + layers.push( + new ScatterplotLayer({ + id: 'legacy-halo', + data: legacyTargetsOrdered, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: 'pixels', + getRadius: () => FLAT_LEGACY_HALO_RADIUS, + lineWidthUnits: 'pixels', + getLineWidth: () => 2, + getLineColor: (d) => { + const l = legacyHits?.get(d.mmsi); + const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; + if (!rgb) return [245, 158, 11, 200]; + return [rgb[0], rgb[1], rgb[2], 200]; + }, + getPosition: (d) => [d.lon, d.lat] as [number, number], + }), + ); + } + + if (shipTargetData.length > 0) { + layers.push( + new IconLayer({ + id: 'ships-target', + data: shipTargetData, + pickable: true, + billboard: false, + parameters: overlayParams, + iconAtlas: '/assets/ship.svg', + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => 'ship', + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), + sizeUnits: 'pixels', + getSize: () => FLAT_SHIP_ICON_SIZE, + getColor: (d) => getShipColor(d, null, legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET), + onHover: shipOnHover, + onClick: shipOnClick, + alphaCutoff: 0.05, + }), + ); + } + } + + if (overlays.pairRange && pairRangesInteractive.length > 0) { + layers.push(new ScatterplotLayer({ id: 'pair-range-overlay', data: pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.2, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center })); + } + if (overlays.pairLines && pairLinksInteractive.length > 0) { + layers.push(new LineLayer({ id: 'pair-lines-overlay', data: pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 2.6, widthUnits: 'pixels' })); + } + if (overlays.fcLines && fcLinesInteractive.length > 0) { + layers.push(new LineLayer({ id: 'fc-lines-overlay', data: fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 1.9, widthUnits: 'pixels' })); + } + if (overlays.fleetCircles && fleetCirclesInteractive.length > 0) { + layers.push(new ScatterplotLayer({ id: 'fleet-circles-overlay-fill', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL })); + layers.push(new ScatterplotLayer({ id: 'fleet-circles-overlay', data: fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 1.8, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center })); + } + + if (settings.showShips && legacyOverlayTargets.length > 0) { + layers.push(new ScatterplotLayer({ id: 'legacy-halo-overlay', data: legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return [14, 234, 255, 230]; const l = legacyHits?.get(d.mmsi); const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; if (!rgb) return [245, 158, 11, 210]; return [rgb[0], rgb[1], rgb[2], 210]; }, getPosition: (d) => [d.lon, d.lat] as [number, number] })); + } + + if (settings.showShips && shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)).length > 0) { + const shipOverlayTargetData2 = shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)); + layers.push(new IconLayer({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: '/assets/ship.svg', iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); } })); + } + + const normalizedLayers = sanitizeDeckLayerList(layers); + const deckProps = { + layers: normalizedLayers, + getTooltip: (info: PickingInfo) => { + if (!info.object) return null; + if (info.layer && info.layer.id === 'density') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const o: any = info.object; + const n = Array.isArray(o?.points) ? o.points.length : 0; + return { text: `AIS density: ${n}` }; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const obj: any = info.object; + if (typeof obj.mmsi === 'number') { + return getShipTooltipHtml({ mmsi: obj.mmsi, targetByMmsi: shipByMmsi, legacyHits }); + } + if (info.layer && info.layer.id === 'pair-lines') { + const aMmsi = toSafeNumber(obj.aMmsi); + const bMmsi = toSafeNumber(obj.bMmsi); + if (aMmsi == null || bMmsi == null) return null; + return getPairLinkTooltipHtml({ warn: !!obj.warn, distanceNm: toSafeNumber(obj.distanceNm), aMmsi, bMmsi, legacyHits, targetByMmsi: shipByMmsi }); + } + if (info.layer && info.layer.id === 'fc-lines') { + const fcMmsi = toSafeNumber(obj.fcMmsi); + const otherMmsi = toSafeNumber(obj.otherMmsi); + if (fcMmsi == null || otherMmsi == null) return null; + return getFcLinkTooltipHtml({ suspicious: !!obj.suspicious, distanceNm: toSafeNumber(obj.distanceNm), fcMmsi, otherMmsi, legacyHits, targetByMmsi: shipByMmsi }); + } + if (info.layer && info.layer.id === 'pair-range') { + const aMmsi = toSafeNumber(obj.aMmsi); + const bMmsi = toSafeNumber(obj.bMmsi); + if (aMmsi == null || bMmsi == null) return null; + return getRangeTooltipHtml({ warn: !!obj.warn, distanceNm: toSafeNumber(obj.distanceNm), aMmsi, bMmsi, legacyHits }); + } + if (info.layer && info.layer.id === 'fleet-circles') { + return getFleetCircleTooltipHtml({ ownerKey: String(obj.ownerKey ?? ''), ownerLabel: String(obj.ownerKey ?? ''), count: Number(obj.count ?? 0) }); + } + return null; + }, + onClick: (info: PickingInfo) => { + if (!info.object) { onSelectMmsi(null); return; } + if (info.layer && info.layer.id === 'density') return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const obj: any = info.object; + if (typeof obj.mmsi === 'number') { + const t = obj as AisTarget; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + onToggleHighlightMmsi?.(t.mmsi); + return; + } + onSelectMmsi(t.mmsi); + const clickOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 }; + if (projectionRef.current === 'globe') { + map.flyTo(clickOpts); + } else { + map.easeTo(clickOpts); + } + } + }, + }; + + try { + deckTarget.setProps(deckProps as never); + } catch (e) { + console.error('Failed to apply base mercator deck props. Falling back to empty layer set.', e); + try { + deckTarget.setProps({ ...deckProps, layers: [] as unknown[] } as never); + } catch { + // Ignore secondary failure. + } + } + }, [ + ensureMercatorOverlay, + projection, + shipLayerData, + shipByMmsi, + pairRanges, + pairLinks, + fcDashed, + fleetCircles, + legacyTargetsOrdered, + legacyHits, + legacyOverlayTargets, + shipOverlayLayerData, + pairRangesInteractive, + pairLinksInteractive, + fcLinesInteractive, + fleetCirclesInteractive, + overlays.pairRange, + overlays.pairLines, + overlays.fcLines, + overlays.fleetCircles, + settings.showDensity, + settings.showShips, + onDeckSelectOrHighlight, + onSelectMmsi, + onToggleHighlightMmsi, + setDeckHoverPairs, + clearMapFleetHoverState, + setDeckHoverMmsi, + clearDeckHoverMmsi, + toFleetMmsiList, + touchDeckHoverState, + hasAuxiliarySelectModifier, + ]); + + // Globe Deck overlay + useEffect(() => { + const map = mapRef.current; + if (!map || projection !== 'globe' || projectionBusyRef.current) return; + const deckTarget = globeDeckLayerRef.current; + if (!deckTarget) return; + + const overlayParams = GLOBE_OVERLAY_PARAMS; + const globeLayers: unknown[] = []; + + if (overlays.pairRange && pairRanges.length > 0) { + globeLayers.push(new ScatterplotLayer({ id: 'pair-range-globe', data: pairRanges, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), getLineColor: (d) => { const hl = isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL; return d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK; }, getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const p = info.object as PairRangeCircle; setDeckHoverPairs([p.aMmsi, p.bMmsi]); setDeckHoverMmsi([p.aMmsi, p.bMmsi]); clearMapFleetHoverState(); } })); + } + + if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) { + const links = pairLinks || []; + globeLayers.push(new LineLayer({ id: 'pair-lines-globe', data: links, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const hl = isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL; return d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK; }, getWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), widthUnits: 'pixels', onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const obj = info.object as PairLink; setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); clearMapFleetHoverState(); } })); + } + + if (overlays.fcLines && fcDashed.length > 0) { + globeLayers.push(new LineLayer({ id: 'fc-lines-globe', data: fcDashed, pickable: true, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); if (ih) return d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL; return d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK; }, getWidth: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); return ih ? 1.9 : 1.3; }, widthUnits: 'pixels', onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const obj = info.object as DashSeg; if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); clearMapFleetHoverState(); } })); + } + + if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) { + const circles = fleetCircles || []; + globeLayers.push(new ScatterplotLayer({ id: 'fleet-circles-globe', data: circles, pickable: true, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), getLineColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_LINE_DECK_HL : FLEET_RANGE_LINE_DECK), getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); clearMapFleetHoverState(); return; } touchDeckHoverState(true); const obj = info.object as FleetCircle; const list = toFleetMmsiList(obj.vesselMmsis); setMapFleetHoverState(obj.ownerKey || null, list); setDeckHoverMmsi(list); clearDeckHoverPairs(); } })); + globeLayers.push(new ScatterplotLayer({ id: 'fleet-circles-fill-globe', data: circles, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_FILL_DECK_HL : FLEET_RANGE_FILL_DECK), getPosition: (d) => d.center })); + } + + if (settings.showShips && legacyTargetsOrdered.length > 0) { + globeLayers.push(new ScatterplotLayer({ id: 'legacy-halo-globe', data: legacyTargetsOrdered, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return [14, 234, 255, 230]; const l = legacyHits?.get(d.mmsi); const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; if (!rgb) return [245, 158, 11, 200]; return [rgb[0], rgb[1], rgb[2], 200]; }, getPosition: (d) => [d.lon, d.lat] as [number, number] })); + } + + const normalizedLayers = sanitizeDeckLayerList(globeLayers); + const globeDeckProps = { layers: normalizedLayers, getTooltip: undefined, onClick: undefined }; + + try { + deckTarget.setProps(globeDeckProps as never); + } catch (e) { + console.error('Failed to apply globe deck props. Falling back to empty deck layer set.', e); + try { + deckTarget.setProps({ ...globeDeckProps, layers: [] as unknown[] } as never); + } catch { + // Ignore secondary failure. + } + } + }, [ + projection, + pairRanges, + pairLinks, + fcDashed, + fleetCircles, + legacyTargetsOrdered, + overlays.pairRange, + overlays.pairLines, + overlays.fcLines, + overlays.fleetCircles, + settings.showShips, + selectedMmsi, + isHighlightedFleet, + isHighlightedPair, + clearDeckHoverPairs, + clearDeckHoverMmsi, + clearMapFleetHoverState, + setDeckHoverPairs, + setDeckHoverMmsi, + setMapFleetHoverState, + toFleetMmsiList, + touchDeckHoverState, + legacyHits, + ]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useFlyTo.ts b/apps/web/src/widgets/map3d/hooks/useFlyTo.ts new file mode 100644 index 0000000..d0c65cb --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useFlyTo.ts @@ -0,0 +1,61 @@ +import { useEffect, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import { onMapStyleReady } from '../lib/mapCore'; +import type { MapProjectionId } from '../types'; + +export function useFlyTo( + mapRef: MutableRefObject, + projectionRef: MutableRefObject, + opts: { + selectedMmsi: number | null; + shipData: { mmsi: number; lon: number; lat: number }[]; + fleetFocusId: string | number | undefined; + fleetFocusLon: number | undefined; + fleetFocusLat: number | undefined; + fleetFocusZoom: number | undefined; + }, +) { + const { selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom } = opts; + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + if (!selectedMmsi) return; + const t = shipData.find((x) => x.mmsi === selectedMmsi); + if (!t) return; + const flyOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 }; + if (projectionRef.current === 'globe') { + map.flyTo(flyOpts); + } else { + map.easeTo(flyOpts); + } + }, [selectedMmsi, shipData]); + + useEffect(() => { + const map = mapRef.current; + if (!map || fleetFocusLon == null || fleetFocusLat == null || !Number.isFinite(fleetFocusLon) || !Number.isFinite(fleetFocusLat)) + return; + const lon = fleetFocusLon; + const lat = fleetFocusLat; + const zoom = fleetFocusZoom ?? 10; + + const apply = () => { + const flyOpts = { center: [lon, lat] as [number, number], zoom, duration: 700 }; + if (projectionRef.current === 'globe') { + map.flyTo(flyOpts); + } else { + map.easeTo(flyOpts); + } + }; + + if (map.isStyleLoaded()) { + apply(); + return; + } + + const stop = onMapStyleReady(map, apply); + return () => { + stop(); + }; + }, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts b/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts new file mode 100644 index 0000000..96892ae --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts @@ -0,0 +1,318 @@ +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'; +import maplibregl from 'maplibre-gl'; +import type { AisTarget } from '../../../entities/aisTarget/model/types'; +import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; +import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; +import type { Map3DSettings, MapProjectionId } from '../types'; +import { toIntMmsi, toSafeNumber } from '../lib/setUtils'; +import { + getShipTooltipHtml, + getPairLinkTooltipHtml, + getFcLinkTooltipHtml, + getRangeTooltipHtml, + getFleetCircleTooltipHtml, +} from '../lib/tooltips'; +import { getZoneIdFromProps, getZoneDisplayNameFromProps } from '../lib/zoneUtils'; + +export function useGlobeInteraction( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + opts: { + projection: MapProjectionId; + settings: Map3DSettings; + overlays: MapToggleState; + targets: AisTarget[]; + shipData: AisTarget[]; + shipByMmsi: Map; + selectedMmsi: number | null; + hoveredZoneId: string | null; + legacyHits: Map | null | undefined; + clearDeckHoverPairs: () => void; + clearDeckHoverMmsi: () => void; + clearMapFleetHoverState: () => void; + setDeckHoverPairs: (next: number[]) => void; + setDeckHoverMmsi: (next: number[]) => void; + setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void; + setHoveredZoneId: (updater: (prev: string | null) => string | null) => void; + }, +) { + const { + projection, legacyHits, shipByMmsi, + clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState, + setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState, setHoveredZoneId, + } = opts; + + const mapTooltipRef = useRef(null); + + const clearGlobeTooltip = useCallback(() => { + if (!mapTooltipRef.current) return; + try { + mapTooltipRef.current.remove(); + } catch { + // ignore + } + mapTooltipRef.current = null; + }, []); + + // eslint-disable-next-line react-hooks/preserve-manual-memoization + const setGlobeTooltip = useCallback((lngLat: maplibregl.LngLatLike, tooltipHtml: string) => { + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + if (!mapTooltipRef.current) { + mapTooltipRef.current = new maplibregl.Popup({ + closeButton: false, + closeOnClick: false, + maxWidth: '360px', + className: 'maplibre-tooltip-popup', + }); + } + + const container = document.createElement('div'); + container.className = 'maplibre-tooltip-popup__content'; + container.innerHTML = tooltipHtml; + + mapTooltipRef.current.setLngLat(lngLat).setDOMContent(container).addTo(map); + }, []); + + const buildGlobeFeatureTooltip = useCallback( + (feature: { properties?: Record | null; layer?: { id?: string } } | null | undefined) => { + if (!feature) return null; + const props = feature.properties || {}; + const layerId = feature.layer?.id; + + const maybeMmsi = toIntMmsi(props.mmsi); + if (maybeMmsi != null && maybeMmsi > 0) { + return getShipTooltipHtml({ mmsi: maybeMmsi, targetByMmsi: shipByMmsi, legacyHits }); + } + + if (layerId === 'pair-lines-ml') { + const warn = props.warn === true; + const aMmsi = toIntMmsi(props.aMmsi); + const bMmsi = toIntMmsi(props.bMmsi); + if (aMmsi == null || bMmsi == null) return null; + return getPairLinkTooltipHtml({ + warn, distanceNm: toSafeNumber(props.distanceNm), + aMmsi, bMmsi, legacyHits, targetByMmsi: shipByMmsi, + }); + } + + if (layerId === 'fc-lines-ml') { + const fcMmsi = toIntMmsi(props.fcMmsi); + const otherMmsi = toIntMmsi(props.otherMmsi); + if (fcMmsi == null || otherMmsi == null) return null; + return getFcLinkTooltipHtml({ + suspicious: props.suspicious === true, + distanceNm: toSafeNumber(props.distanceNm), + fcMmsi, otherMmsi, legacyHits, targetByMmsi: shipByMmsi, + }); + } + + if (layerId === 'pair-range-ml') { + const aMmsi = toIntMmsi(props.aMmsi); + const bMmsi = toIntMmsi(props.bMmsi); + if (aMmsi == null || bMmsi == null) return null; + return getRangeTooltipHtml({ + warn: props.warn === true, + distanceNm: toSafeNumber(props.distanceNm), + aMmsi, bMmsi, legacyHits, + }); + } + + if (layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill') { + return getFleetCircleTooltipHtml({ + ownerKey: String(props.ownerKey ?? ''), + ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ''), + count: Number(props.count ?? 0), + }); + } + + const zoneLabel = getZoneDisplayNameFromProps(props); + if (zoneLabel) { + return { html: `
${zoneLabel}
` }; + } + + return null; + }, + [legacyHits, shipByMmsi], + ); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const clearDeckGlobeHoverState = () => { + clearDeckHoverMmsi(); + clearDeckHoverPairs(); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + clearMapFleetHoverState(); + }; + + const resetGlobeHoverStates = () => { + clearDeckHoverMmsi(); + clearDeckHoverPairs(); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + clearMapFleetHoverState(); + }; + + const normalizeMmsiList = (value: unknown): number[] => { + if (!Array.isArray(value)) return []; + const out: number[] = []; + for (const n of value) { + const m = toIntMmsi(n); + if (m != null) out.push(m); + } + return out; + }; + + const onMouseMove = (e: maplibregl.MapMouseEvent) => { + if (projection !== 'globe') { + clearGlobeTooltip(); + resetGlobeHoverStates(); + return; + } + if (projectionBusyRef.current) { + resetGlobeHoverStates(); + clearGlobeTooltip(); + return; + } + if (!map.isStyleLoaded()) { + clearDeckGlobeHoverState(); + clearGlobeTooltip(); + return; + } + + let candidateLayerIds: string[] = []; + try { + candidateLayerIds = [ + 'ships-globe', 'ships-globe-halo', 'ships-globe-outline', + 'pair-lines-ml', 'fc-lines-ml', + 'fleet-circles-ml', 'fleet-circles-ml-fill', + 'pair-range-ml', + 'zones-fill', 'zones-line', 'zones-label', + ].filter((id) => map.getLayer(id)); + } catch { + candidateLayerIds = []; + } + + if (candidateLayerIds.length === 0) { + resetGlobeHoverStates(); + clearGlobeTooltip(); + return; + } + + let rendered: Array<{ properties?: Record | null; layer?: { id?: string } }> = []; + try { + rendered = map.queryRenderedFeatures(e.point, { layers: candidateLayerIds }) as unknown as Array<{ + properties?: Record | null; + layer?: { id?: string }; + }>; + } catch { + rendered = []; + } + + const priority = [ + 'ships-globe', 'ships-globe-halo', 'ships-globe-outline', + 'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml', + 'fleet-circles-ml-fill', 'fleet-circles-ml', + 'zones-fill', 'zones-line', 'zones-label', + ]; + + const first = priority.map((id) => rendered.find((r) => r.layer?.id === id)).find(Boolean) as + | { properties?: Record | null; layer?: { id?: string } } + | undefined; + + if (!first) { + resetGlobeHoverStates(); + clearGlobeTooltip(); + return; + } + + const layerId = first.layer?.id; + const props = first.properties || {}; + const isShipLayer = layerId === 'ships-globe' || layerId === 'ships-globe-halo' || layerId === 'ships-globe-outline'; + const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml'; + const isFcLayer = layerId === 'fc-lines-ml'; + const isFleetLayer = layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill'; + const isZoneLayer = layerId === 'zones-fill' || layerId === 'zones-line' || layerId === 'zones-label'; + + if (isShipLayer) { + const mmsi = toIntMmsi(props.mmsi); + setDeckHoverMmsi(mmsi == null ? [] : [mmsi]); + clearDeckHoverPairs(); + clearMapFleetHoverState(); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + } else if (isPairLayer) { + const aMmsi = toIntMmsi(props.aMmsi); + const bMmsi = toIntMmsi(props.bMmsi); + setDeckHoverPairs([...(aMmsi == null ? [] : [aMmsi]), ...(bMmsi == null ? [] : [bMmsi])]); + clearDeckHoverMmsi(); + clearMapFleetHoverState(); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + } else if (isFcLayer) { + const from = toIntMmsi(props.fcMmsi); + const to = toIntMmsi(props.otherMmsi); + const fromTo = [from, to].filter((v): v is number => v != null); + setDeckHoverPairs(fromTo); + setDeckHoverMmsi(fromTo); + clearMapFleetHoverState(); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + } else if (isFleetLayer) { + const ownerKey = String(props.ownerKey ?? ''); + const list = normalizeMmsiList(props.vesselMmsis); + setMapFleetHoverState(ownerKey || null, list); + clearDeckHoverMmsi(); + clearDeckHoverPairs(); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + } else if (isZoneLayer) { + clearMapFleetHoverState(); + clearDeckHoverMmsi(); + clearDeckHoverPairs(); + const zoneId = getZoneIdFromProps(props); + setHoveredZoneId(() => zoneId || null); + } else { + resetGlobeHoverStates(); + } + + const tooltip = buildGlobeFeatureTooltip(first); + if (!tooltip) { + if (!isZoneLayer) { + resetGlobeHoverStates(); + } + clearGlobeTooltip(); + return; + } + + const content = tooltip?.html ?? ''; + if (content) { + setGlobeTooltip(e.lngLat, content); + return; + } + clearGlobeTooltip(); + }; + + const onMouseOut = () => { + resetGlobeHoverStates(); + clearGlobeTooltip(); + }; + + map.on('mousemove', onMouseMove); + map.on('mouseout', onMouseOut); + + return () => { + map.off('mousemove', onMouseMove); + map.off('mouseout', onMouseOut); + clearGlobeTooltip(); + }; + }, [ + projection, + buildGlobeFeatureTooltip, + clearGlobeTooltip, + clearMapFleetHoverState, + clearDeckHoverPairs, + clearDeckHoverMmsi, + setDeckHoverPairs, + setDeckHoverMmsi, + setMapFleetHoverState, + setGlobeTooltip, + ]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts new file mode 100644 index 0000000..dad2779 --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts @@ -0,0 +1,618 @@ +import { useCallback, useEffect, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl'; +import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types'; +import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; +import type { DashSeg, MapProjectionId, PairRangeCircle } from '../types'; +import { + PAIR_LINE_NORMAL_ML, PAIR_LINE_WARN_ML, + PAIR_LINE_NORMAL_ML_HL, PAIR_LINE_WARN_ML_HL, + PAIR_RANGE_NORMAL_ML, PAIR_RANGE_WARN_ML, + PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL, + FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML, + FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL, + FLEET_FILL_ML, FLEET_FILL_ML_HL, + FLEET_LINE_ML, FLEET_LINE_ML_HL, +} from '../constants'; +import { makeUniqueSorted } from '../lib/setUtils'; +import { + makePairLinkFeatureId, + makeFcSegmentFeatureId, + makeFleetCircleFeatureId, +} from '../lib/featureIds'; +import { + makeMmsiPairHighlightExpr, + makeMmsiAnyEndpointExpr, + makeFleetOwnerMatchExpr, + makeFleetMemberMatchExpr, +} from '../lib/mlExpressions'; +import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; +import { circleRingLngLat } from '../lib/geometry'; +import { dashifyLine } from '../lib/dashifyLine'; + +export function useGlobeOverlays( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + reorderGlobeFeatureLayers: () => void, + opts: { + overlays: MapToggleState; + pairLinks: PairLink[] | undefined; + fcLinks: FcLink[] | undefined; + fleetCircles: FleetCircle[] | undefined; + projection: MapProjectionId; + mapSyncEpoch: number; + hoveredFleetMmsiList: number[]; + hoveredFleetOwnerKeyList: string[]; + hoveredPairMmsiList: number[]; + }, +) { + const { + overlays, pairLinks, fcLinks, fleetCircles, projection, mapSyncEpoch, + hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, + } = opts; + + // Pair lines + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'pair-lines-ml-src'; + const layerId = 'pair-lines-ml'; + + const remove = () => { + try { + if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none'); + } catch { + // ignore + } + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + if (projection !== 'globe' || !overlays.pairLines || (pairLinks?.length ?? 0) === 0) { + remove(); + return; + } + + const fc: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: (pairLinks || []).map((p) => ({ + type: 'Feature', + id: makePairLinkFeatureId(p.aMmsi, p.bMmsi), + geometry: { type: 'LineString', coordinates: [p.from, p.to] }, + properties: { + type: 'pair', + aMmsi: p.aMmsi, + bMmsi: p.bMmsi, + distanceNm: p.distanceNm, + warn: p.warn, + }, + })), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Pair lines source setup failed:', e); + return; + } + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: 'line', + source: srcId, + layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, + paint: { + 'line-color': [ + 'case', + ['==', ['get', 'highlighted'], 1], + ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], + ['boolean', ['get', 'warn'], false], + PAIR_LINE_WARN_ML, + PAIR_LINE_NORMAL_ML, + ] as never, + 'line-width': [ + 'case', + ['==', ['get', 'highlighted'], 1], 2.8, + ['boolean', ['get', 'warn'], false], 2.2, + 1.4, + ] as never, + 'line-opacity': 0.9, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Pair lines layer add failed:', e); + } + } else { + try { + map.setLayoutProperty(layerId, 'visibility', 'visible'); + } catch { + // ignore + } + } + + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + ensure(); + return () => { + stop(); + }; + }, [projection, overlays.pairLines, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]); + + // FC lines + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'fc-lines-ml-src'; + const layerId = 'fc-lines-ml'; + + const remove = () => { + try { + if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none'); + } catch { + // ignore + } + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + if (projection !== 'globe' || !overlays.fcLines) { + remove(); + return; + } + + const segs: DashSeg[] = []; + for (const l of fcLinks || []) { + segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi)); + } + if (segs.length === 0) { + remove(); + return; + } + + const fc: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: segs.map((s, idx) => ({ + type: 'Feature', + id: makeFcSegmentFeatureId(s.fromMmsi ?? -1, s.toMmsi ?? -1, idx), + geometry: { type: 'LineString', coordinates: [s.from, s.to] }, + properties: { + type: 'fc', + suspicious: s.suspicious, + distanceNm: s.distanceNm, + fcMmsi: s.fromMmsi ?? -1, + otherMmsi: s.toMmsi ?? -1, + }, + })), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('FC lines source setup failed:', e); + return; + } + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: 'line', + source: srcId, + layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, + paint: { + 'line-color': [ + 'case', + ['==', ['get', 'highlighted'], 1], + ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], + ['boolean', ['get', 'suspicious'], false], + FC_LINE_SUSPICIOUS_ML, + FC_LINE_NORMAL_ML, + ] as never, + 'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2.0, 1.3] as never, + 'line-opacity': 0.9, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('FC lines layer add failed:', e); + } + } else { + try { + map.setLayoutProperty(layerId, 'visibility', 'visible'); + } catch { + // ignore + } + } + + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + ensure(); + return () => { + stop(); + }; + }, [projection, overlays.fcLines, fcLinks, mapSyncEpoch, reorderGlobeFeatureLayers]); + + // Fleet circles + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'fleet-circles-ml-src'; + const fillSrcId = 'fleet-circles-ml-fill-src'; + const layerId = 'fleet-circles-ml'; + const fillLayerId = 'fleet-circles-ml-fill'; + + const remove = () => { + try { + if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'none'); + } catch { + // ignore + } + try { + if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none'); + } catch { + // ignore + } + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + if (projection !== 'globe' || !overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) { + remove(); + return; + } + + const fcLine: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: (fleetCircles || []).map((c) => { + const ring = circleRingLngLat(c.center, c.radiusNm * 1852); + return { + type: 'Feature', + id: makeFleetCircleFeatureId(c.ownerKey), + geometry: { type: 'LineString', coordinates: ring }, + properties: { + type: 'fleet', + ownerKey: c.ownerKey, + ownerLabel: c.ownerLabel, + count: c.count, + vesselMmsis: c.vesselMmsis, + highlighted: 0, + }, + }; + }), + }; + + const fcFill: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: (fleetCircles || []).map((c) => { + const ring = circleRingLngLat(c.center, c.radiusNm * 1852); + return { + type: 'Feature', + id: `${makeFleetCircleFeatureId(c.ownerKey)}-fill`, + geometry: { type: 'Polygon', coordinates: [ring] }, + properties: { + type: 'fleet-fill', + ownerKey: c.ownerKey, + ownerLabel: c.ownerLabel, + count: c.count, + vesselMmsis: c.vesselMmsis, + highlighted: 0, + }, + }; + }), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fcLine); + else map.addSource(srcId, { type: 'geojson', data: fcLine } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Fleet circles source setup failed:', e); + return; + } + + try { + const existingFill = map.getSource(fillSrcId) as GeoJSONSource | undefined; + if (existingFill) existingFill.setData(fcFill); + else map.addSource(fillSrcId, { type: 'geojson', data: fcFill } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Fleet circles source setup failed:', e); + return; + } + + if (!map.getLayer(fillLayerId)) { + try { + map.addLayer( + { + id: fillLayerId, + type: 'fill', + source: fillSrcId, + layout: { visibility: 'visible' }, + paint: { + 'fill-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_FILL_ML_HL, FLEET_FILL_ML] as never, + 'fill-opacity': ['case', ['==', ['get', 'highlighted'], 1], 0.7, 0.36] as never, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Fleet circles fill layer add failed:', e); + } + } else { + try { + map.setLayoutProperty(fillLayerId, 'visibility', 'visible'); + } catch { + // ignore + } + } + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: 'line', + source: srcId, + layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, + paint: { + 'line-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_LINE_ML_HL, FLEET_LINE_ML] as never, + 'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2, 1.1] as never, + 'line-opacity': 0.85, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Fleet circles layer add failed:', e); + } + } else { + try { + map.setLayoutProperty(layerId, 'visibility', 'visible'); + } catch { + // ignore + } + } + + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + ensure(); + return () => { + stop(); + }; + }, [projection, overlays.fleetCircles, fleetCircles, mapSyncEpoch, reorderGlobeFeatureLayers]); + + // Pair range + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'pair-range-ml-src'; + const layerId = 'pair-range-ml'; + + const remove = () => { + try { + if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none'); + } catch { + // ignore + } + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + if (projection !== 'globe' || !overlays.pairRange) { + remove(); + return; + } + + const ranges: PairRangeCircle[] = []; + for (const p of pairLinks || []) { + const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2]; + ranges.push({ + center, + radiusNm: Math.max(0.05, p.distanceNm / 2), + warn: p.warn, + aMmsi: p.aMmsi, + bMmsi: p.bMmsi, + distanceNm: p.distanceNm, + }); + } + if (ranges.length === 0) { + remove(); + return; + } + + const fc: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: ranges.map((c) => { + const ring = circleRingLngLat(c.center, c.radiusNm * 1852); + return { + type: 'Feature', + id: makePairLinkFeatureId(c.aMmsi, c.bMmsi), + geometry: { type: 'LineString', coordinates: ring }, + properties: { + type: 'pair-range', + warn: c.warn, + aMmsi: c.aMmsi, + bMmsi: c.bMmsi, + distanceNm: c.distanceNm, + highlighted: 0, + }, + }; + }), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Pair range source setup failed:', e); + return; + } + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: 'line', + source: srcId, + layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, + paint: { + 'line-color': [ + 'case', + ['==', ['get', 'highlighted'], 1], + ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], + ['boolean', ['get', 'warn'], false], + PAIR_RANGE_WARN_ML, + PAIR_RANGE_NORMAL_ML, + ] as never, + 'line-width': ['case', ['==', ['get', 'highlighted'], 1], 1.6, 1.0] as never, + 'line-opacity': 0.85, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Pair range layer add failed:', e); + } + } else { + try { + map.setLayoutProperty(layerId, 'visibility', 'visible'); + } catch { + // ignore + } + } + + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + ensure(); + return () => { + stop(); + }; + }, [projection, overlays.pairRange, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]); + + // Paint state updates for hover highlights + // eslint-disable-next-line react-hooks/preserve-manual-memoization + const updateGlobeOverlayPaintStates = useCallback(() => { + if (projection !== 'globe' || projectionBusyRef.current) return; + + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + + const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]); + + const pairHighlightExpr = hoveredPairMmsiList.length >= 2 + ? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList) + : false; + + const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0 + ? makeMmsiAnyEndpointExpr('fcMmsi', 'otherMmsi', fleetAwarePairMmsiList) + : false; + + const fleetOwnerMatchExpr = + hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false; + const fleetMemberExpr = + hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false; + const fleetHighlightExpr = + hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0 + ? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never) + : false; + + try { + if (map.getLayer('pair-lines-ml')) { + map.setPaintProperty( + 'pair-lines-ml', 'line-color', + ['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never, + ); + map.setPaintProperty( + 'pair-lines-ml', 'line-width', + ['case', pairHighlightExpr, 2.8, ['boolean', ['get', 'warn'], false], 2.2, 1.4] as never, + ); + } + } catch { + // ignore + } + + try { + if (map.getLayer('fc-lines-ml')) { + map.setPaintProperty( + 'fc-lines-ml', 'line-color', + ['case', fcEndpointHighlightExpr, ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never, + ); + map.setPaintProperty( + 'fc-lines-ml', 'line-width', + ['case', fcEndpointHighlightExpr, 2.0, 1.3] as never, + ); + } + } catch { + // ignore + } + + try { + if (map.getLayer('pair-range-ml')) { + map.setPaintProperty( + 'pair-range-ml', 'line-color', + ['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never, + ); + map.setPaintProperty( + 'pair-range-ml', 'line-width', + ['case', pairHighlightExpr, 1.6, 1.0] as never, + ); + } + } catch { + // ignore + } + + try { + if (map.getLayer('fleet-circles-ml-fill')) { + map.setPaintProperty('fleet-circles-ml-fill', 'fill-color', ['case', fleetHighlightExpr, FLEET_FILL_ML_HL, FLEET_FILL_ML] as never); + map.setPaintProperty('fleet-circles-ml-fill', 'fill-opacity', ['case', fleetHighlightExpr, 0.7, 0.28] as never); + } + if (map.getLayer('fleet-circles-ml')) { + map.setPaintProperty('fleet-circles-ml', 'line-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never); + map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never); + } + } catch { + // ignore + } + }, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList]); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + const stop = onMapStyleReady(map, updateGlobeOverlayPaintStates); + updateGlobeOverlayPaintStates(); + return () => { + stop(); + }; + }, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateGlobeOverlayPaintStates]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts new file mode 100644 index 0000000..525cb65 --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts @@ -0,0 +1,1029 @@ +import { useEffect, useRef, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl'; +import type { AisTarget } from '../../../entities/aisTarget/model/types'; +import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; +import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; +import type { Map3DSettings, MapProjectionId } from '../types'; +import { + ANCHORED_SHIP_ICON_ID, + GLOBE_ICON_HEADING_OFFSET_DEG, + DEG2RAD, +} from '../constants'; +import { isFiniteNumber } from '../lib/setUtils'; +import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions'; +import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; +import { + isAnchoredShip, + getDisplayHeading, + getGlobeBaseShipColor, +} from '../lib/shipUtils'; +import { + buildFallbackGlobeAnchoredShipIcon, + ensureFallbackShipImage, +} from '../lib/globeShipIcon'; +import { clampNumber } from '../lib/geometry'; + +export function useGlobeShips( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + reorderGlobeFeatureLayers: () => void, + opts: { + projection: MapProjectionId; + settings: Map3DSettings; + shipData: AisTarget[]; + shipHighlightSet: Set; + shipHoverOverlaySet: Set; + shipOverlayLayerData: AisTarget[]; + shipLayerData: AisTarget[]; + shipByMmsi: Map; + mapSyncEpoch: number; + onSelectMmsi: (mmsi: number | null) => void; + onToggleHighlightMmsi?: (mmsi: number) => void; + targets: AisTarget[]; + overlays: MapToggleState; + legacyHits: Map | null | undefined; + selectedMmsi: number | null; + isBaseHighlightedMmsi: (mmsi: number) => boolean; + hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean; + }, +) { + const { + projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet, + shipLayerData, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi, targets, + overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier, + } = opts; + + const globeShipsEpochRef = useRef(-1); + const globeShipIconLoadingRef = useRef(false); + const globeHoverShipSignatureRef = useRef(''); + + // Ship name labels in mercator + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'ship-labels-src'; + const layerId = 'ship-labels'; + + const remove = () => { + try { + if (map.getLayer(layerId)) map.removeLayer(layerId); + } catch { + // ignore + } + try { + if (map.getSource(srcId)) map.removeSource(srcId); + } catch { + // ignore + } + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + + if (projection !== 'mercator' || !settings.showShips) { + remove(); + return; + } + + const visibility = overlays.shipLabels ? 'visible' : 'none'; + + const features: GeoJSON.Feature[] = []; + for (const t of shipData) { + const legacy = legacyHits?.get(t.mmsi) ?? null; + const isTarget = !!legacy; + const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi; + const isPinnedHighlight = shipHighlightSet.has(t.mmsi); + if (!isTarget && !isSelected && !isPinnedHighlight) continue; + + const labelName = (legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '').trim(); + if (!labelName) continue; + + features.push({ + type: 'Feature', + id: `ship-label-${t.mmsi}`, + geometry: { type: 'Point', coordinates: [t.lon, t.lat] }, + properties: { + mmsi: t.mmsi, + labelName, + selected: isSelected ? 1 : 0, + highlighted: isPinnedHighlight ? 1 : 0, + permitted: isTarget ? 1 : 0, + }, + }); + } + + const fc: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Ship label source setup failed:', e); + return; + } + + const filter = ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''] as unknown as unknown[]; + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: 'symbol', + source: srcId, + minzoom: 7, + filter: filter as never, + layout: { + visibility, + 'symbol-placement': 'point', + 'text-field': ['get', 'labelName'] as never, + 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never, + 'text-anchor': 'top', + 'text-offset': [0, 1.1], + 'text-padding': 2, + 'text-allow-overlap': false, + 'text-ignore-placement': false, + }, + paint: { + 'text-color': [ + 'case', + ['==', ['get', 'selected'], 1], + 'rgba(14,234,255,0.95)', + ['==', ['get', 'highlighted'], 1], + 'rgba(245,158,11,0.95)', + 'rgba(226,232,240,0.92)', + ] as never, + 'text-halo-color': 'rgba(2,6,23,0.85)', + 'text-halo-width': 1.2, + 'text-halo-blur': 0.8, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Ship label layer add failed:', e); + } + } else { + try { + map.setLayoutProperty(layerId, 'visibility', visibility); + } catch { + // ignore + } + } + + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + return () => { + stop(); + }; + }, [ + projection, + settings.showShips, + overlays.shipLabels, + shipData, + legacyHits, + selectedMmsi, + shipHighlightSet, + mapSyncEpoch, + ]); + + // Ships in globe mode + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const imgId = 'ship-globe-icon'; + const anchoredImgId = ANCHORED_SHIP_ICON_ID; + const srcId = 'ships-globe-src'; + const haloId = 'ships-globe-halo'; + const outlineId = 'ships-globe-outline'; + const symbolId = 'ships-globe'; + const labelId = 'ships-globe-label'; + + const remove = () => { + for (const id of [labelId, symbolId, outlineId, haloId]) { + try { + if (map.getLayer(id)) map.removeLayer(id); + } catch { + // ignore + } + } + try { + if (map.getSource(srcId)) map.removeSource(srcId); + } catch { + // ignore + } + globeHoverShipSignatureRef.current = ''; + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const ensureImage = () => { + ensureFallbackShipImage(map, imgId); + ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); + if (globeShipIconLoadingRef.current) return; + if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return; + + const addFallbackImage = () => { + ensureFallbackShipImage(map, imgId); + ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); + kickRepaint(map); + }; + + let fallbackTimer: ReturnType | null = null; + try { + globeShipIconLoadingRef.current = true; + fallbackTimer = window.setTimeout(() => { + addFallbackImage(); + }, 80); + void map + .loadImage('/assets/ship.svg') + .then((response) => { + globeShipIconLoadingRef.current = false; + if (fallbackTimer != null) { + clearTimeout(fallbackTimer); + fallbackTimer = null; + } + + const loadedImage = (response as { data?: HTMLImageElement | ImageBitmap } | undefined)?.data; + if (!loadedImage) { + addFallbackImage(); + return; + } + + try { + if (map.hasImage(imgId)) { + try { + map.removeImage(imgId); + } catch { + // ignore + } + } + if (map.hasImage(anchoredImgId)) { + try { + map.removeImage(anchoredImgId); + } catch { + // ignore + } + } + map.addImage(imgId, loadedImage, { pixelRatio: 2, sdf: true }); + map.addImage(anchoredImgId, loadedImage, { pixelRatio: 2, sdf: true }); + kickRepaint(map); + } catch (e) { + console.warn('Ship icon image add failed:', e); + } + }) + .catch(() => { + globeShipIconLoadingRef.current = false; + if (fallbackTimer != null) { + clearTimeout(fallbackTimer); + fallbackTimer = null; + } + addFallbackImage(); + }); + } catch (e) { + globeShipIconLoadingRef.current = false; + if (fallbackTimer != null) { + clearTimeout(fallbackTimer); + fallbackTimer = null; + } + try { + addFallbackImage(); + } catch (fallbackError) { + console.warn('Ship icon image setup failed:', e, fallbackError); + } + } + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + + if (projection !== 'globe' || !settings.showShips) { + remove(); + return; + } + + if (globeShipsEpochRef.current !== mapSyncEpoch) { + globeShipsEpochRef.current = mapSyncEpoch; + } + + try { + ensureImage(); + } catch (e) { + console.warn('Ship icon image setup failed:', e); + } + + const globeShipData = shipData; + const geojson: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: globeShipData.map((t) => { + const legacy = legacyHits?.get(t.mmsi) ?? null; + const labelName = + legacy?.shipNameCn || + legacy?.shipNameRoman || + t.name || + ''; + const heading = getDisplayHeading({ + cog: t.cog, + heading: t.heading, + offset: GLOBE_ICON_HEADING_OFFSET_DEG, + }); + const isAnchored = isAnchoredShip({ + sog: t.sog, + cog: t.cog, + heading: t.heading, + }); + const shipHeading = isAnchored ? 0 : heading; + const hull = clampNumber((isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, 50, 420); + const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); + const selected = t.mmsi === selectedMmsi; + const highlighted = isBaseHighlightedMmsi(t.mmsi); + const selectedScale = selected ? 1.08 : 1; + const highlightScale = highlighted ? 1.06 : 1; + const iconScale = selected ? selectedScale : highlightScale; + const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3); + const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45); + const iconSize10 = clampNumber(0.56 * sizeScale * selectedScale, 0.35, 1.7); + const iconSize14 = clampNumber(0.72 * sizeScale * selectedScale, 0.45, 2.1); + return { + type: 'Feature', + ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), + geometry: { type: 'Point', coordinates: [t.lon, t.lat] }, + properties: { + mmsi: t.mmsi, + name: t.name || '', + labelName, + cog: shipHeading, + heading: shipHeading, + sog: isFiniteNumber(t.sog) ? t.sog : 0, + isAnchored: isAnchored ? 1 : 0, + shipColor: getGlobeBaseShipColor({ + legacy: legacy?.shipCode || null, + sog: isFiniteNumber(t.sog) ? t.sog : null, + }), + iconSize3: iconSize3 * iconScale, + iconSize7: iconSize7 * iconScale, + iconSize10: iconSize10 * iconScale, + iconSize14: iconSize14 * iconScale, + sizeScale, + selected: selected ? 1 : 0, + highlighted: highlighted ? 1 : 0, + permitted: legacy ? 1 : 0, + code: legacy?.shipCode || '', + }, + }; + }), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(geojson); + else map.addSource(srcId, { type: 'geojson', data: geojson } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Ship source setup failed:', e); + return; + } + + const visibility = settings.showShips ? 'visible' : 'none'; + const before = undefined; + + if (!map.getLayer(haloId)) { + try { + map.addLayer( + { + id: haloId, + type: 'circle', + source: srcId, + layout: { + visibility, + 'circle-sort-key': [ + 'case', + ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 120, + ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 115, + ['==', ['get', 'permitted'], 1], 110, + ['==', ['get', 'selected'], 1], 60, + ['==', ['get', 'highlighted'], 1], 55, + 20, + ] as never, + }, + paint: { + 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, + 'circle-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, + 'circle-opacity': [ + 'case', + ['==', ['get', 'selected'], 1], 0.38, + ['==', ['get', 'highlighted'], 1], 0.34, + 0.16, + ] as never, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn('Ship halo layer add failed:', e); + } + } else { + try { + map.setLayoutProperty(haloId, 'visibility', visibility); + map.setLayoutProperty(haloId, 'circle-sort-key', [ + 'case', + ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 120, + ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 115, + ['==', ['get', 'permitted'], 1], 110, + ['==', ['get', 'selected'], 1], 60, + ['==', ['get', 'highlighted'], 1], 55, + 20, + ] as never); + map.setPaintProperty(haloId, 'circle-color', [ + 'case', + ['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)', + ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,1)', + ['coalesce', ['get', 'shipColor'], '#64748b'], + ] as never); + map.setPaintProperty(haloId, 'circle-opacity', [ + 'case', + ['==', ['get', 'selected'], 1], 0.38, + ['==', ['get', 'highlighted'], 1], 0.34, + 0.16, + ] as never); + map.setPaintProperty(haloId, 'circle-radius', GLOBE_SHIP_CIRCLE_RADIUS_EXPR); + } catch { + // ignore + } + } + + if (!map.getLayer(outlineId)) { + try { + map.addLayer( + { + id: outlineId, + type: 'circle', + source: srcId, + paint: { + 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, + 'circle-color': 'rgba(0,0,0,0)', + 'circle-stroke-color': [ + 'case', + ['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', + ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)', + ['coalesce', ['get', 'shipColor'], '#64748b'], + ] as never, + 'circle-stroke-width': [ + 'case', + ['==', ['get', 'selected'], 1], 3.4, + ['==', ['get', 'highlighted'], 1], 2.7, + ['==', ['get', 'permitted'], 1], 1.8, + 0.0, + ] as never, + 'circle-stroke-opacity': 0.85, + }, + layout: { + visibility, + 'circle-sort-key': [ + 'case', + ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 130, + ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 125, + ['==', ['get', 'permitted'], 1], 120, + ['==', ['get', 'selected'], 1], 70, + ['==', ['get', 'highlighted'], 1], 65, + 30, + ] as never, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn('Ship outline layer add failed:', e); + } + } else { + try { + map.setLayoutProperty(outlineId, 'visibility', visibility); + map.setLayoutProperty(outlineId, 'circle-sort-key', [ + 'case', + ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 130, + ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 125, + ['==', ['get', 'permitted'], 1], 120, + ['==', ['get', 'selected'], 1], 70, + ['==', ['get', 'highlighted'], 1], 65, + 30, + ] as never); + map.setPaintProperty(outlineId, 'circle-stroke-color', [ + 'case', + ['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', + ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)', + ['coalesce', ['get', 'shipColor'], '#64748b'], + ] as never); + map.setPaintProperty(outlineId, 'circle-stroke-width', [ + 'case', + ['==', ['get', 'selected'], 1], 3.4, + ['==', ['get', 'highlighted'], 1], 2.7, + ['==', ['get', 'permitted'], 1], 1.8, + 0.0, + ] as never); + } catch { + // ignore + } + } + + if (!map.getLayer(symbolId)) { + try { + map.addLayer( + { + id: symbolId, + type: 'symbol', + source: srcId, + layout: { + visibility, + 'symbol-sort-key': [ + 'case', + ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 140, + ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 135, + ['==', ['get', 'permitted'], 1], 130, + ['==', ['get', 'selected'], 1], 80, + ['==', ['get', 'highlighted'], 1], 75, + 45, + ] as never, + 'icon-image': [ + 'case', + ['==', ['to-number', ['get', 'isAnchored'], 0], 1], + anchoredImgId, + imgId, + ] as never, + 'icon-size': [ + 'interpolate', ['linear'], ['zoom'], + 3, ['to-number', ['get', 'iconSize3'], 0.35], + 7, ['to-number', ['get', 'iconSize7'], 0.45], + 10, ['to-number', ['get', 'iconSize10'], 0.56], + 14, ['to-number', ['get', 'iconSize14'], 0.72], + ] as unknown as number[], + 'icon-allow-overlap': true, + 'icon-ignore-placement': true, + 'icon-anchor': 'center', + 'icon-rotate': [ + 'case', + ['==', ['to-number', ['get', 'isAnchored'], 0], 1], 0, + ['to-number', ['get', 'heading'], 0], + ] as never, + 'icon-rotation-alignment': 'map', + 'icon-pitch-alignment': 'map', + }, + paint: { + 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, + 'icon-opacity': [ + 'case', + ['==', ['get', 'permitted'], 1], 1, + ['==', ['get', 'selected'], 1], 0.86, + ['==', ['get', 'highlighted'], 1], 0.82, + 0.66, + ] as never, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn('Ship symbol layer add failed:', e); + } + } else { + try { + map.setLayoutProperty(symbolId, 'visibility', visibility); + map.setLayoutProperty(symbolId, 'symbol-sort-key', [ + 'case', + ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 140, + ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 135, + ['==', ['get', 'permitted'], 1], 130, + ['==', ['get', 'selected'], 1], 80, + ['==', ['get', 'highlighted'], 1], 75, + 45, + ] as never); + map.setPaintProperty(symbolId, 'icon-opacity', [ + 'case', + ['==', ['get', 'permitted'], 1], 1, + ['==', ['get', 'selected'], 1], 0.86, + ['==', ['get', 'highlighted'], 1], 0.82, + 0.66, + ] as never); + } catch { + // ignore + } + } + + const labelVisibility = overlays.shipLabels ? 'visible' : 'none'; + const labelFilter = [ + 'all', + ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''], + [ + 'any', + ['==', ['get', 'permitted'], 1], + ['==', ['get', 'selected'], 1], + ['==', ['get', 'highlighted'], 1], + ], + ] as unknown as unknown[]; + + if (!map.getLayer(labelId)) { + try { + map.addLayer( + { + id: labelId, + type: 'symbol', + source: srcId, + minzoom: 7, + filter: labelFilter as never, + layout: { + visibility: labelVisibility, + 'symbol-placement': 'point', + 'text-field': ['get', 'labelName'] as never, + 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never, + 'text-anchor': 'top', + 'text-offset': [0, 1.1], + 'text-padding': 2, + 'text-allow-overlap': false, + 'text-ignore-placement': false, + }, + paint: { + 'text-color': [ + 'case', + ['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', + ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)', + 'rgba(226,232,240,0.92)', + ] as never, + 'text-halo-color': 'rgba(2,6,23,0.85)', + 'text-halo-width': 1.2, + 'text-halo-blur': 0.8, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Ship label layer add failed:', e); + } + } else { + try { + map.setLayoutProperty(labelId, 'visibility', labelVisibility); + map.setFilter(labelId, labelFilter as never); + map.setLayoutProperty(labelId, 'text-field', ['get', 'labelName'] as never); + } catch { + // ignore + } + } + + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + return () => { + stop(); + }; + }, [ + projection, + settings.showShips, + overlays.shipLabels, + shipData, + legacyHits, + selectedMmsi, + isBaseHighlightedMmsi, + mapSyncEpoch, + reorderGlobeFeatureLayers, + ]); + + // Globe hover overlay ships + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const imgId = 'ship-globe-icon'; + const srcId = 'ships-globe-hover-src'; + const haloId = 'ships-globe-hover-halo'; + const outlineId = 'ships-globe-hover-outline'; + const symbolId = 'ships-globe-hover'; + + const remove = () => { + for (const id of [symbolId, outlineId, haloId]) { + try { + if (map.getLayer(id)) map.removeLayer(id); + } catch { + // ignore + } + } + try { + if (map.getSource(srcId)) map.removeSource(srcId); + } catch { + // ignore + } + globeHoverShipSignatureRef.current = ''; + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + + if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) { + remove(); + return; + } + + if (globeShipsEpochRef.current !== mapSyncEpoch) { + globeShipsEpochRef.current = mapSyncEpoch; + } + + ensureFallbackShipImage(map, imgId); + if (!map.hasImage(imgId)) { + return; + } + + const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi)); + if (hovered.length === 0) { + remove(); + return; + } + const hoverSignature = hovered + .map((t) => `${t.mmsi}:${t.lon.toFixed(6)}:${t.lat.toFixed(6)}:${t.heading ?? 0}`) + .join('|'); + const hasHoverSource = map.getSource(srcId) != null; + const hasHoverLayers = [symbolId, outlineId, haloId].every((id) => map.getLayer(id)); + if (hoverSignature === globeHoverShipSignatureRef.current && hasHoverSource && hasHoverLayers) { + return; + } + globeHoverShipSignatureRef.current = hoverSignature; + const needReorder = !hasHoverSource || !hasHoverLayers; + + const hoverGeojson: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: hovered.map((t) => { + const legacy = legacyHits?.get(t.mmsi) ?? null; + const heading = getDisplayHeading({ + cog: t.cog, + heading: t.heading, + offset: GLOBE_ICON_HEADING_OFFSET_DEG, + }); + const hull = clampNumber( + (isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, + 50, + 420, + ); + const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); + const selected = t.mmsi === selectedMmsi; + const scale = selected ? 1.16 : 1.1; + return { + type: 'Feature', + ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), + geometry: { type: 'Point', coordinates: [t.lon, t.lat] }, + properties: { + mmsi: t.mmsi, + name: t.name || '', + cog: heading, + heading, + sog: isFiniteNumber(t.sog) ? t.sog : 0, + shipColor: getGlobeBaseShipColor({ + legacy: legacy?.shipCode || null, + sog: isFiniteNumber(t.sog) ? t.sog : null, + }), + iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45), + iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7), + iconSize10: clampNumber(0.56 * sizeScale * scale, 0.35, 2.0), + iconSize14: clampNumber(0.72 * sizeScale * scale, 0.45, 2.4), + selected: selected ? 1 : 0, + permitted: legacy ? 1 : 0, + }, + }; + }), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(hoverGeojson); + else map.addSource(srcId, { type: 'geojson', data: hoverGeojson } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Ship hover source setup failed:', e); + return; + } + + const before = undefined; + + if (!map.getLayer(haloId)) { + try { + map.addLayer( + { + id: haloId, + type: 'circle', + source: srcId, + layout: { + visibility: 'visible', + 'circle-sort-key': [ + 'case', + ['==', ['get', 'selected'], 1], 120, + ['==', ['get', 'permitted'], 1], 115, + 110, + ] as never, + }, + paint: { + 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, + 'circle-color': [ + 'case', + ['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)', + 'rgba(245,158,11,1)', + ] as never, + 'circle-opacity': 0.42, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn('Ship hover halo layer add failed:', e); + } + } else { + map.setLayoutProperty(haloId, 'visibility', 'visible'); + } + + if (!map.getLayer(outlineId)) { + try { + map.addLayer( + { + id: outlineId, + type: 'circle', + source: srcId, + paint: { + 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, + 'circle-color': 'rgba(0,0,0,0)', + 'circle-stroke-color': [ + 'case', + ['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', + 'rgba(245,158,11,0.95)', + ] as never, + 'circle-stroke-width': [ + 'case', + ['==', ['get', 'selected'], 1], 3.8, + 2.2, + ] as never, + 'circle-stroke-opacity': 0.9, + }, + layout: { + visibility: 'visible', + 'circle-sort-key': [ + 'case', + ['==', ['get', 'selected'], 1], 121, + ['==', ['get', 'permitted'], 1], 116, + 111, + ] as never, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn('Ship hover outline layer add failed:', e); + } + } else { + map.setLayoutProperty(outlineId, 'visibility', 'visible'); + } + + if (!map.getLayer(symbolId)) { + try { + map.addLayer( + { + id: symbolId, + type: 'symbol', + source: srcId, + layout: { + visibility: 'visible', + 'symbol-sort-key': [ + 'case', + ['==', ['get', 'selected'], 1], 122, + ['==', ['get', 'permitted'], 1], 117, + 112, + ] as never, + 'icon-image': imgId, + 'icon-size': [ + 'interpolate', ['linear'], ['zoom'], + 3, ['to-number', ['get', 'iconSize3'], 0.35], + 7, ['to-number', ['get', 'iconSize7'], 0.45], + 10, ['to-number', ['get', 'iconSize10'], 0.56], + 14, ['to-number', ['get', 'iconSize14'], 0.72], + ] as unknown as number[], + 'icon-allow-overlap': true, + 'icon-ignore-placement': true, + 'icon-anchor': 'center', + 'icon-rotate': ['to-number', ['get', 'heading'], 0], + 'icon-rotation-alignment': 'map', + 'icon-pitch-alignment': 'map', + }, + paint: { + 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, + 'icon-opacity': 1, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn('Ship hover symbol layer add failed:', e); + } + } else { + map.setLayoutProperty(symbolId, 'visibility', 'visible'); + } + + if (needReorder) { + reorderGlobeFeatureLayers(); + } + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + return () => { + stop(); + }; + }, [ + projection, + settings.showShips, + shipLayerData, + legacyHits, + shipHoverOverlaySet, + selectedMmsi, + mapSyncEpoch, + reorderGlobeFeatureLayers, + ]); + + // Globe ship click selection + useEffect(() => { + const map = mapRef.current; + if (!map) return; + if (projection !== 'globe' || !settings.showShips) return; + + const symbolId = 'ships-globe'; + const haloId = 'ships-globe-halo'; + const outlineId = 'ships-globe-outline'; + const clickedRadiusDeg2 = Math.pow(0.08, 2); + + const onClick = (e: maplibregl.MapMouseEvent) => { + try { + const layerIds = [symbolId, haloId, outlineId].filter((id) => map.getLayer(id)); + let feats: unknown[] = []; + if (layerIds.length > 0) { + try { + feats = map.queryRenderedFeatures(e.point, { layers: layerIds }) as unknown[]; + } catch { + feats = []; + } + } + const f = feats?.[0]; + const props = ((f as { properties?: Record } | undefined)?.properties || {}) as Record< + string, + unknown + >; + const mmsi = Number(props.mmsi); + if (Number.isFinite(mmsi)) { + if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) { + onToggleHighlightMmsi?.(mmsi); + return; + } + onSelectMmsi(mmsi); + return; + } + + const clicked = { lat: e.lngLat.lat, lon: e.lngLat.lng }; + const cosLat = Math.cos(clicked.lat * DEG2RAD); + let bestMmsi: number | null = null; + let bestD2 = Number.POSITIVE_INFINITY; + for (const t of targets) { + if (!isFiniteNumber(t.lat) || !isFiniteNumber(t.lon)) continue; + const dLon = (clicked.lon - t.lon) * cosLat; + const dLat = clicked.lat - t.lat; + const d2 = dLon * dLon + dLat * dLat; + if (d2 <= clickedRadiusDeg2 && d2 < bestD2) { + bestD2 = d2; + bestMmsi = t.mmsi; + } + } + if (bestMmsi != null) { + if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) { + onToggleHighlightMmsi?.(bestMmsi); + return; + } + onSelectMmsi(bestMmsi); + return; + } + } catch { + // ignore + } + onSelectMmsi(null); + }; + + map.on('click', onClick); + return () => { + try { + map.off('click', onClick); + } catch { + // ignore + } + }; + }, [projection, settings.showShips, onSelectMmsi, onToggleHighlightMmsi, mapSyncEpoch, targets]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useMapInit.ts b/apps/web/src/widgets/map3d/hooks/useMapInit.ts new file mode 100644 index 0000000..26fb2de --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useMapInit.ts @@ -0,0 +1,196 @@ +import { useCallback, useEffect, useRef, type MutableRefObject, type Dispatch, type SetStateAction } from 'react'; +import maplibregl, { type StyleSpecification } from 'maplibre-gl'; +import { MapboxOverlay } from '@deck.gl/mapbox'; +import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer'; +import type { BaseMapId, MapProjectionId } from '../types'; +import { DECK_VIEW_ID } from '../constants'; +import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; +import { ensureSeamarkOverlay } from '../layers/seamark'; +import { resolveMapStyle } from '../layers/bathymetry'; +import { clearGlobeNativeLayers } from '../lib/layerHelpers'; + +export function useMapInit( + containerRef: MutableRefObject, + mapRef: MutableRefObject, + overlayRef: MutableRefObject, + overlayInteractionRef: MutableRefObject, + globeDeckLayerRef: MutableRefObject, + baseMapRef: MutableRefObject, + projectionRef: MutableRefObject, + opts: { + baseMap: BaseMapId; + projection: MapProjectionId; + showSeamark: boolean; + onViewBboxChange?: (bbox: [number, number, number, number]) => void; + setMapSyncEpoch: Dispatch>; + }, +) { + const { onViewBboxChange, setMapSyncEpoch, showSeamark } = opts; + const showSeamarkRef = useRef(showSeamark); + useEffect(() => { + showSeamarkRef.current = showSeamark; + }, [showSeamark]); + + const ensureMercatorOverlay = useCallback(() => { + const map = mapRef.current; + if (!map) return null; + if (overlayRef.current) return overlayRef.current; + try { + const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never); + map.addControl(next); + overlayRef.current = next; + return next; + } catch (e) { + console.warn('Deck overlay create failed:', e); + return null; + } + }, []); + + const clearGlobeNativeLayersCb = useCallback(() => { + const map = mapRef.current; + if (!map) return; + clearGlobeNativeLayers(map); + }, []); + + const pulseMapSync = useCallback(() => { + setMapSyncEpoch((prev) => prev + 1); + requestAnimationFrame(() => { + kickRepaint(mapRef.current); + setMapSyncEpoch((prev) => prev + 1); + }); + }, [setMapSyncEpoch]); + + useEffect(() => { + if (!containerRef.current || mapRef.current) return; + + let map: maplibregl.Map | null = null; + let cancelled = false; + const controller = new AbortController(); + + (async () => { + let style: string | StyleSpecification = '/map/styles/osm-seamark.json'; + try { + style = await resolveMapStyle(baseMapRef.current, controller.signal); + } catch (e) { + console.warn('Map style init failed, falling back to local raster style:', e); + style = '/map/styles/osm-seamark.json'; + } + if (cancelled || !containerRef.current) return; + + map = new maplibregl.Map({ + container: containerRef.current, + style, + center: [126.5, 34.2], + zoom: 7, + pitch: 45, + bearing: 0, + maxPitch: 85, + dragRotate: true, + pitchWithRotate: true, + touchPitch: true, + scrollZoom: { around: 'center' }, + }); + + map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), 'top-left'); + map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left'); + + mapRef.current = map; + + if (projectionRef.current === 'mercator') { + const overlay = ensureMercatorOverlay(); + if (!overlay) return; + overlayRef.current = overlay; + } else { + globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ + id: 'deck-globe', + viewId: DECK_VIEW_ID, + deckProps: { layers: [] }, + }); + } + + function applyProjection() { + if (!map) return; + const next = projectionRef.current; + if (next === 'mercator') return; + try { + map.setProjection({ type: next }); + map.setRenderWorldCopies(next !== 'globe'); + } catch (e) { + console.warn('Projection apply failed:', e); + } + } + + onMapStyleReady(map, () => { + applyProjection(); + const deckLayer = globeDeckLayerRef.current; + if (projectionRef.current === 'globe' && deckLayer && !map!.getLayer(deckLayer.id)) { + try { + map!.addLayer(deckLayer); + } catch { + // ignore + } + } + if (!showSeamarkRef.current) return; + try { + ensureSeamarkOverlay(map!, 'bathymetry-lines'); + } catch { + // ignore + } + }); + + const emitBbox = () => { + const cb = onViewBboxChange; + if (!cb || !map) return; + const b = map.getBounds(); + cb([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]); + }; + map.on('load', emitBbox); + map.on('moveend', emitBbox); + + map.once('load', () => { + if (showSeamarkRef.current) { + try { + ensureSeamarkOverlay(map!, 'bathymetry-lines'); + } catch { + // ignore + } + try { + const opacity = showSeamarkRef.current ? 0.85 : 0; + map!.setPaintProperty('seamark', 'raster-opacity', opacity); + } catch { + // ignore + } + } + }); + })(); + + return () => { + cancelled = true; + controller.abort(); + + try { + globeDeckLayerRef.current?.requestFinalize(); + } catch { + // ignore + } + + if (map) { + map.remove(); + map = null; + } + if (overlayRef.current) { + overlayRef.current.finalize(); + overlayRef.current = null; + } + if (overlayInteractionRef.current) { + overlayInteractionRef.current.finalize(); + overlayInteractionRef.current = null; + } + globeDeckLayerRef.current = null; + mapRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { ensureMercatorOverlay, clearGlobeNativeLayers: clearGlobeNativeLayersCb, pulseMapSync }; +} diff --git a/apps/web/src/widgets/map3d/hooks/usePredictionVectors.ts b/apps/web/src/widgets/map3d/hooks/usePredictionVectors.ts new file mode 100644 index 0000000..70a057f --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/usePredictionVectors.ts @@ -0,0 +1,210 @@ +import { useEffect, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl'; +import type { AisTarget } from '../../../entities/aisTarget/model/types'; +import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; +import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; +import type { Map3DSettings, MapProjectionId } from '../types'; +import { LEGACY_CODE_COLORS_RGB, OTHER_AIS_SPEED_RGB, rgba as rgbaCss } from '../../../shared/lib/map/palette'; +import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; +import { destinationPointLngLat } from '../lib/geometry'; +import { isFiniteNumber } from '../lib/setUtils'; +import { toValidBearingDeg, lightenColor } from '../lib/shipUtils'; + +export function usePredictionVectors( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + reorderGlobeFeatureLayers: () => void, + opts: { + overlays: MapToggleState; + settings: Map3DSettings; + shipData: AisTarget[]; + legacyHits: Map | null | undefined; + selectedMmsi: number | null; + externalHighlightedSetRef: Set; + projection: MapProjectionId; + baseMap: string; + mapSyncEpoch: number; + }, +) { + const { overlays, settings, shipData, legacyHits, selectedMmsi, externalHighlightedSetRef, projection, baseMap, mapSyncEpoch } = opts; + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'predict-vectors-src'; + const outlineId = 'predict-vectors-outline'; + const lineId = 'predict-vectors'; + const hlOutlineId = 'predict-vectors-hl-outline'; + const hlId = 'predict-vectors-hl'; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + + const visibility = overlays.predictVectors ? 'visible' : 'none'; + + const horizonMinutes = 15; + const horizonSeconds = horizonMinutes * 60; + const metersPerSecondPerKnot = 0.514444; + + const features: GeoJSON.Feature[] = []; + if (overlays.predictVectors && settings.showShips && shipData.length > 0) { + for (const t of shipData) { + const legacy = legacyHits?.get(t.mmsi) ?? null; + const isTarget = !!legacy; + const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi; + const isPinnedHighlight = externalHighlightedSetRef.has(t.mmsi); + if (!isTarget && !isSelected && !isPinnedHighlight) continue; + + const sog = isFiniteNumber(t.sog) ? t.sog : null; + const bearing = toValidBearingDeg(t.cog) ?? toValidBearingDeg(t.heading); + if (sog == null || bearing == null) continue; + if (sog < 0.2) continue; + + const distM = sog * metersPerSecondPerKnot * horizonSeconds; + if (!Number.isFinite(distM) || distM <= 0) continue; + + const to = destinationPointLngLat([t.lon, t.lat], bearing, distM); + + const baseRgb = isTarget + ? LEGACY_CODE_COLORS_RGB[legacy?.shipCode ?? ''] ?? OTHER_AIS_SPEED_RGB.moving + : OTHER_AIS_SPEED_RGB.moving; + const rgb = lightenColor(baseRgb, isTarget ? 0.55 : 0.62); + const alpha = isTarget ? 0.72 : 0.52; + const alphaHl = isTarget ? 0.92 : 0.84; + const hl = isSelected || isPinnedHighlight ? 1 : 0; + + features.push({ + type: 'Feature', + id: `pred-${t.mmsi}`, + geometry: { type: 'LineString', coordinates: [[t.lon, t.lat], to] }, + properties: { + mmsi: t.mmsi, + minutes: horizonMinutes, + sog, + cog: bearing, + target: isTarget ? 1 : 0, + hl, + color: rgbaCss(rgb, alpha), + colorHl: rgbaCss(rgb, alphaHl), + }, + }); + } + } + + const fc: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(fc); + else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Prediction vector source setup failed:', e); + return; + } + + const ensureLayer = (id: string, paint: LayerSpecification['paint'], filter: unknown[]) => { + if (!map.getLayer(id)) { + try { + map.addLayer( + { + id, + type: 'line', + source: srcId, + filter: filter as never, + layout: { + visibility, + 'line-cap': 'round', + 'line-join': 'round', + }, + paint, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Prediction vector layer add failed:', e); + } + } else { + try { + map.setLayoutProperty(id, 'visibility', visibility); + map.setFilter(id, filter as never); + if (paint && typeof paint === 'object') { + for (const [key, value] of Object.entries(paint)) { + map.setPaintProperty(id, key as never, value as never); + } + } + } catch { + // ignore + } + } + }; + + const baseFilter = ['==', ['to-number', ['get', 'hl'], 0], 0] as unknown as unknown[]; + const hlFilter = ['==', ['to-number', ['get', 'hl'], 0], 1] as unknown as unknown[]; + + ensureLayer( + outlineId, + { + 'line-color': 'rgba(2,6,23,0.86)', + 'line-width': 4.8, + 'line-opacity': 1, + 'line-blur': 0.2, + 'line-dasharray': [1.2, 1.8] as never, + } as never, + baseFilter, + ); + ensureLayer( + lineId, + { + 'line-color': ['coalesce', ['get', 'color'], 'rgba(226,232,240,0.62)'] as never, + 'line-width': 2.4, + 'line-opacity': 1, + 'line-dasharray': [1.2, 1.8] as never, + } as never, + baseFilter, + ); + ensureLayer( + hlOutlineId, + { + 'line-color': 'rgba(2,6,23,0.92)', + 'line-width': 6.4, + 'line-opacity': 1, + 'line-blur': 0.25, + 'line-dasharray': [1.2, 1.8] as never, + } as never, + hlFilter, + ); + ensureLayer( + hlId, + { + 'line-color': ['coalesce', ['get', 'colorHl'], ['get', 'color'], 'rgba(241,245,249,0.92)'] as never, + 'line-width': 3.6, + 'line-opacity': 1, + 'line-dasharray': [1.2, 1.8] as never, + } as never, + hlFilter, + ); + + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + return () => { + stop(); + }; + }, [ + overlays.predictVectors, + settings.showShips, + shipData, + legacyHits, + selectedMmsi, + externalHighlightedSetRef, + projection, + baseMap, + mapSyncEpoch, + reorderGlobeFeatureLayers, + ]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts new file mode 100644 index 0000000..ec1e0ce --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts @@ -0,0 +1,324 @@ +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import { MapboxOverlay } from '@deck.gl/mapbox'; +import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer'; +import type { MapProjectionId } from '../types'; +import { DECK_VIEW_ID } from '../constants'; +import { kickRepaint, onMapStyleReady, extractProjectionType } from '../lib/mapCore'; +import { removeLayerIfExists } from '../lib/layerHelpers'; + +export function useProjectionToggle( + mapRef: MutableRefObject, + overlayRef: MutableRefObject, + overlayInteractionRef: MutableRefObject, + globeDeckLayerRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + opts: { + projection: MapProjectionId; + clearGlobeNativeLayers: () => void; + ensureMercatorOverlay: () => MapboxOverlay | null; + onProjectionLoadingChange?: (loading: boolean) => void; + pulseMapSync: () => void; + setMapSyncEpoch: (updater: (prev: number) => number) => void; + }, +): () => void { + const { projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch } = opts; + + const projectionBusyTokenRef = useRef(0); + const projectionBusyTimerRef = useRef | null>(null); + const projectionPrevRef = useRef(projection); + const projectionRef = useRef(projection); + + useEffect(() => { + projectionRef.current = projection; + }, [projection]); + + const clearProjectionBusyTimer = useCallback(() => { + if (projectionBusyTimerRef.current == null) return; + clearTimeout(projectionBusyTimerRef.current); + projectionBusyTimerRef.current = null; + }, []); + + // eslint-disable-next-line react-hooks/preserve-manual-memoization + const endProjectionLoading = useCallback(() => { + if (!projectionBusyRef.current) return; + projectionBusyRef.current = false; + clearProjectionBusyTimer(); + if (onProjectionLoadingChange) { + onProjectionLoadingChange(false); + } + setMapSyncEpoch((prev) => prev + 1); + kickRepaint(mapRef.current); + }, [clearProjectionBusyTimer, onProjectionLoadingChange, setMapSyncEpoch]); + + const setProjectionLoading = useCallback( + // eslint-disable-next-line react-hooks/preserve-manual-memoization + (loading: boolean) => { + if (projectionBusyRef.current === loading) return; + if (!loading) { + endProjectionLoading(); + return; + } + + clearProjectionBusyTimer(); + projectionBusyRef.current = true; + const token = ++projectionBusyTokenRef.current; + if (onProjectionLoadingChange) { + onProjectionLoadingChange(true); + } + + projectionBusyTimerRef.current = setTimeout(() => { + if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return; + console.debug('Projection loading fallback timeout reached.'); + endProjectionLoading(); + }, 4000); + }, + [clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange], + ); + + useEffect(() => { + return () => { + clearProjectionBusyTimer(); + endProjectionLoading(); + }; + }, [clearProjectionBusyTimer, endProjectionLoading]); + + // eslint-disable-next-line react-hooks/preserve-manual-memoization + const reorderGlobeFeatureLayers = useCallback(() => { + const map = mapRef.current; + if (!map || projectionRef.current !== 'globe') return; + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + + const ordering = [ + 'zones-fill', + 'zones-line', + 'zones-label', + 'predict-vectors-outline', + 'predict-vectors', + 'predict-vectors-hl-outline', + 'predict-vectors-hl', + 'ships-globe-halo', + 'ships-globe-outline', + 'ships-globe', + 'ships-globe-label', + 'ships-globe-hover-halo', + 'ships-globe-hover-outline', + 'ships-globe-hover', + 'pair-lines-ml', + 'fc-lines-ml', + 'pair-range-ml', + 'fleet-circles-ml-fill', + 'fleet-circles-ml', + ]; + + for (const layerId of ordering) { + try { + if (map.getLayer(layerId)) map.moveLayer(layerId); + } catch { + // ignore + } + } + + kickRepaint(map); + }, []); + + // Projection toggle (mercator <-> globe) + useEffect(() => { + const map = mapRef.current; + if (!map) return; + let cancelled = false; + let retries = 0; + const maxRetries = 18; + const isTransition = projectionPrevRef.current !== projection; + projectionPrevRef.current = projection; + let settleScheduled = false; + let settleCleanup: (() => void) | null = null; + + const startProjectionSettle = () => { + if (!isTransition || settleScheduled) return; + settleScheduled = true; + + const finalize = () => { + if (!cancelled && isTransition) setProjectionLoading(false); + }; + + const finalizeSoon = () => { + if (cancelled || !isTransition || projectionBusyRef.current === false) return; + if (!map.isStyleLoaded()) { + requestAnimationFrame(finalizeSoon); + return; + } + requestAnimationFrame(finalize); + }; + + const onIdle = () => finalizeSoon(); + try { + map.on('idle', onIdle); + const styleReadyCleanup = onMapStyleReady(map, finalizeSoon); + settleCleanup = () => { + map.off('idle', onIdle); + styleReadyCleanup(); + }; + } catch { + requestAnimationFrame(finalize); + settleCleanup = null; + } + + finalizeSoon(); + }; + + if (isTransition) setProjectionLoading(true); + + const disposeMercatorOverlays = () => { + const disposeOne = (target: MapboxOverlay | null, toNull: 'base' | 'interaction') => { + if (!target) return; + try { + target.setProps({ layers: [] } as never); + } catch { + // ignore + } + try { + map.removeControl(target as never); + } catch { + // ignore + } + try { + target.finalize(); + } catch { + // ignore + } + if (toNull === 'base') { + overlayRef.current = null; + } else { + overlayInteractionRef.current = null; + } + }; + + disposeOne(overlayRef.current, 'base'); + disposeOne(overlayInteractionRef.current, 'interaction'); + }; + + const disposeGlobeDeckLayer = () => { + const current = globeDeckLayerRef.current; + if (!current) return; + removeLayerIfExists(map, current.id); + try { + current.requestFinalize(); + } catch { + // ignore + } + globeDeckLayerRef.current = null; + }; + + const syncProjectionAndDeck = () => { + if (cancelled) return; + if (!isTransition) { + return; + } + + if (!map.isStyleLoaded()) { + if (!cancelled && retries < maxRetries) { + retries += 1; + window.requestAnimationFrame(() => syncProjectionAndDeck()); + } + return; + } + + const next = projection; + const currentProjection = extractProjectionType(map); + const shouldSwitchProjection = currentProjection !== next; + + if (projection === 'globe') { + disposeMercatorOverlays(); + clearGlobeNativeLayers(); + } else { + disposeGlobeDeckLayer(); + clearGlobeNativeLayers(); + } + + try { + if (shouldSwitchProjection) { + map.setProjection({ type: next }); + } + map.setRenderWorldCopies(next !== 'globe'); + if (shouldSwitchProjection && currentProjection !== next && !cancelled && retries < maxRetries) { + retries += 1; + window.requestAnimationFrame(() => syncProjectionAndDeck()); + return; + } + } catch (e) { + if (!cancelled && retries < maxRetries) { + retries += 1; + window.requestAnimationFrame(() => syncProjectionAndDeck()); + return; + } + if (isTransition) setProjectionLoading(false); + console.warn('Projection switch failed:', e); + } + + if (projection === 'globe') { + disposeGlobeDeckLayer(); + + if (!globeDeckLayerRef.current) { + globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ + id: 'deck-globe', + viewId: DECK_VIEW_ID, + deckProps: { layers: [] }, + }); + } + + const layer = globeDeckLayerRef.current; + const layerId = layer?.id; + if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) { + try { + map.addLayer(layer); + } catch { + // ignore + } + if (!map.getLayer(layerId) && !cancelled && retries < maxRetries) { + retries += 1; + window.requestAnimationFrame(() => syncProjectionAndDeck()); + return; + } + } + } else { + disposeGlobeDeckLayer(); + ensureMercatorOverlay(); + } + + reorderGlobeFeatureLayers(); + kickRepaint(map); + try { + map.resize(); + } catch { + // ignore + } + if (isTransition) { + startProjectionSettle(); + } + pulseMapSync(); + }; + + if (!isTransition) return; + + if (map.isStyleLoaded()) syncProjectionAndDeck(); + else { + const stop = onMapStyleReady(map, syncProjectionAndDeck); + return () => { + cancelled = true; + if (settleCleanup) settleCleanup(); + stop(); + if (isTransition) setProjectionLoading(false); + }; + } + + return () => { + cancelled = true; + if (settleCleanup) settleCleanup(); + if (isTransition) setProjectionLoading(false); + }; + }, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]); + + return reorderGlobeFeatureLayers; +} diff --git a/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts b/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts new file mode 100644 index 0000000..e1f19fd --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts @@ -0,0 +1,230 @@ +import { useEffect, type MutableRefObject } from 'react'; +import maplibregl, { + type GeoJSONSource, + type GeoJSONSourceSpecification, + type LayerSpecification, +} from 'maplibre-gl'; +import type { ZoneId } from '../../../entities/zone/model/meta'; +import { ZONE_META } from '../../../entities/zone/model/meta'; +import type { ZonesGeoJson } from '../../../entities/zone/api/useZones'; +import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; +import type { BaseMapId, MapProjectionId } from '../types'; +import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; + +export function useZonesLayer( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + reorderGlobeFeatureLayers: () => void, + opts: { + zones: ZonesGeoJson | null; + overlays: MapToggleState; + projection: MapProjectionId; + baseMap: BaseMapId; + hoveredZoneId: string | null; + mapSyncEpoch: number; + }, +) { + const { zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch } = opts; + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'zones-src'; + const fillId = 'zones-fill'; + const lineId = 'zones-line'; + const labelId = 'zones-label'; + + const zoneColorExpr: unknown[] = ['match', ['get', 'zoneId']]; + for (const k of Object.keys(ZONE_META) as ZoneId[]) { + zoneColorExpr.push(k, ZONE_META[k].color); + } + zoneColorExpr.push('#3B82F6'); + const zoneLabelExpr: unknown[] = ['match', ['to-string', ['coalesce', ['get', 'zoneId'], '']]]; + for (const k of Object.keys(ZONE_META) as ZoneId[]) { + zoneLabelExpr.push(k, ZONE_META[k].name); + } + zoneLabelExpr.push(['coalesce', ['get', 'zoneName'], ['get', 'zoneLabel'], ['get', 'NAME'], '수역']); + + const ensure = () => { + if (projectionBusyRef.current) return; + const visibility = overlays.zones ? 'visible' : 'none'; + try { + if (map.getLayer(fillId)) map.setLayoutProperty(fillId, 'visibility', visibility); + } catch { + // ignore + } + try { + if (map.getLayer(lineId)) map.setLayoutProperty(lineId, 'visibility', visibility); + } catch { + // ignore + } + try { + if (map.getLayer(labelId)) map.setLayoutProperty(labelId, 'visibility', visibility); + } catch { + // ignore + } + + if (!zones) return; + if (!map.isStyleLoaded()) return; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) { + existing.setData(zones); + } else { + map.addSource(srcId, { type: 'geojson', data: zones } as GeoJSONSourceSpecification); + } + + const style = map.getStyle(); + const styleLayers = style && Array.isArray(style.layers) ? style.layers : []; + const firstSymbol = styleLayers.find((l) => (l as { type?: string } | undefined)?.type === 'symbol') as + | { id?: string } + | undefined; + const before = map.getLayer('deck-globe') + ? 'deck-globe' + : map.getLayer('ships') + ? 'ships' + : map.getLayer('seamark') + ? 'seamark' + : firstSymbol?.id; + + const zoneMatchExpr = + hoveredZoneId !== null + ? (['==', ['to-string', ['coalesce', ['get', 'zoneId'], '']], hoveredZoneId] as unknown[]) + : false; + const zoneLineWidthExpr = hoveredZoneId + ? ([ + 'interpolate', + ['linear'], + ['zoom'], + 4, + ['case', zoneMatchExpr, 1.6, 0.8], + 10, + ['case', zoneMatchExpr, 2.0, 1.4], + 14, + ['case', zoneMatchExpr, 2.8, 2.1], + ] as unknown as never) + : (['interpolate', ['linear'], ['zoom'], 4, 0.8, 10, 1.4, 14, 2.1] as never); + + if (map.getLayer(fillId)) { + try { + map.setPaintProperty( + fillId, + 'fill-opacity', + hoveredZoneId ? (['case', zoneMatchExpr, 0.24, 0.1] as unknown as number) : 0.12, + ); + } catch { + // ignore + } + } + + if (map.getLayer(lineId)) { + try { + map.setPaintProperty( + lineId, + 'line-color', + hoveredZoneId + ? (['case', zoneMatchExpr, 'rgba(125,211,252,0.98)', zoneColorExpr as never] as never) + : (zoneColorExpr as never), + ); + } catch { + // ignore + } + try { + map.setPaintProperty(lineId, 'line-opacity', hoveredZoneId ? (['case', zoneMatchExpr, 1, 0.85] as never) : 0.85); + } catch { + // ignore + } + try { + map.setPaintProperty(lineId, 'line-width', zoneLineWidthExpr); + } catch { + // ignore + } + } + + if (!map.getLayer(fillId)) { + map.addLayer( + { + id: fillId, + type: 'fill', + source: srcId, + paint: { + 'fill-color': zoneColorExpr as never, + 'fill-opacity': hoveredZoneId + ? ([ + 'case', + zoneMatchExpr, + 0.24, + 0.1, + ] as unknown as number) + : 0.12, + }, + layout: { visibility }, + } as unknown as LayerSpecification, + before, + ); + } + + if (!map.getLayer(lineId)) { + map.addLayer( + { + id: lineId, + type: 'line', + source: srcId, + paint: { + 'line-color': hoveredZoneId + ? (['case', zoneMatchExpr, 'rgba(125,211,252,0.98)', zoneColorExpr as never] as never) + : (zoneColorExpr as never), + 'line-opacity': hoveredZoneId + ? (['case', zoneMatchExpr, 1, 0.85] as never) + : 0.85, + 'line-width': zoneLineWidthExpr, + }, + layout: { visibility }, + } as unknown as LayerSpecification, + before, + ); + } + + if (!map.getLayer(labelId)) { + map.addLayer( + { + id: labelId, + type: 'symbol', + source: srcId, + layout: { + visibility, + 'symbol-placement': 'point', + 'text-field': zoneLabelExpr as never, + 'text-size': 11, + 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], + 'text-anchor': 'top', + 'text-offset': [0, 0.35], + 'text-allow-overlap': false, + 'text-ignore-placement': false, + }, + paint: { + 'text-color': '#dbeafe', + 'text-halo-color': 'rgba(2,6,23,0.85)', + 'text-halo-width': 1.2, + 'text-halo-blur': 0.8, + }, + } as unknown as LayerSpecification, + undefined, + ); + } + } catch (e) { + console.warn('Zones layer setup failed:', e); + } finally { + reorderGlobeFeatureLayers(); + kickRepaint(map); + } + }; + + const stop = onMapStyleReady(map, ensure); + return () => { + stop(); + }; + }, [zones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]); +} diff --git a/apps/web/src/widgets/map3d/lib/layerHelpers.ts b/apps/web/src/widgets/map3d/lib/layerHelpers.ts index 7eecc45..ff62d63 100644 --- a/apps/web/src/widgets/map3d/lib/layerHelpers.ts +++ b/apps/web/src/widgets/map3d/lib/layerHelpers.ts @@ -3,6 +3,62 @@ import maplibregl, { type LayerSpecification, } from 'maplibre-gl'; +export function removeLayerIfExists(map: maplibregl.Map, layerId: string | null | undefined) { + if (!layerId) return; + try { + if (map.getLayer(layerId)) { + map.removeLayer(layerId); + } + } catch { + // ignore + } +} + +export function removeSourceIfExists(map: maplibregl.Map, sourceId: string) { + try { + if (map.getSource(sourceId)) { + map.removeSource(sourceId); + } + } catch { + // ignore + } +} + +const GLOBE_NATIVE_LAYER_IDS = [ + 'ships-globe-halo', + 'ships-globe-outline', + 'ships-globe', + 'ships-globe-label', + 'ships-globe-hover-halo', + 'ships-globe-hover-outline', + 'ships-globe-hover', + 'pair-lines-ml', + 'fc-lines-ml', + 'fleet-circles-ml-fill', + 'fleet-circles-ml', + 'pair-range-ml', + 'deck-globe', +]; + +const GLOBE_NATIVE_SOURCE_IDS = [ + 'ships-globe-src', + 'ships-globe-hover-src', + 'pair-lines-ml-src', + 'fc-lines-ml-src', + 'fleet-circles-ml-src', + 'fleet-circles-ml-fill-src', + 'pair-range-ml-src', +]; + +export function clearGlobeNativeLayers(map: maplibregl.Map) { + for (const id of GLOBE_NATIVE_LAYER_IDS) { + removeLayerIfExists(map, id); + } + for (const id of GLOBE_NATIVE_SOURCE_IDS) { + removeSourceIfExists(map, id); + } +} + export function ensureGeoJsonSource( map: maplibregl.Map, sourceId: string, From 3ba6c02ba0da3a53cbf70237a1bb3648589f18ca Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 01:10:45 +0900 Subject: [PATCH 45/58] =?UTF-8?q?feat(map):=20=EC=84=A0=EB=B0=95=20?= =?UTF-8?q?=EC=99=B8=EA=B3=BD=EC=84=A0=20=EB=8C=80=EB=B9=84=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A4=8C=20=EC=8A=A4=EC=BC=80=EC=9D=BC=EB=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/src/pages/dashboard/DashboardPage.tsx | 4 +-- apps/web/src/widgets/map3d/constants.ts | 14 ++++++-- .../src/widgets/map3d/hooks/useDeckLayers.ts | 15 ++++----- .../src/widgets/map3d/hooks/useGlobeShips.ts | 33 ++++++++++++------- .../src/widgets/map3d/layers/bathymetry.ts | 18 +++++----- .../src/widgets/map3d/lib/mlExpressions.ts | 7 ++-- 6 files changed, 54 insertions(+), 37 deletions(-) diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 4c1ab37..2d24f41 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -115,8 +115,8 @@ export function DashboardPage() { fcLines: true, zones: true, fleetCircles: true, - predictVectors: false, - shipLabels: false, + predictVectors: true, + shipLabels: true, }); const [fleetRelationSortMode, setFleetRelationSortMode] = useState("count"); diff --git a/apps/web/src/widgets/map3d/constants.ts b/apps/web/src/widgets/map3d/constants.ts index 05a2940..66b2b78 100644 --- a/apps/web/src/widgets/map3d/constants.ts +++ b/apps/web/src/widgets/map3d/constants.ts @@ -47,6 +47,14 @@ export const MAP_SELECTED_SHIP_RGB: [number, number, number] = [34, 211, 238]; export const MAP_HIGHLIGHT_SHIP_RGB: [number, number, number] = [245, 158, 11]; export const MAP_DEFAULT_SHIP_RGB: [number, number, number] = [100, 116, 139]; +// ── Ship outline / halo contrast colors ── + +export const HALO_OUTLINE_COLOR: [number, number, number, number] = [210, 225, 240, 155]; +export const HALO_OUTLINE_COLOR_SELECTED: [number, number, number, number] = [14, 234, 255, 230]; +export const HALO_OUTLINE_COLOR_HIGHLIGHTED: [number, number, number, number] = [245, 158, 11, 210]; +export const GLOBE_OUTLINE_PERMITTED = 'rgba(210,225,240,0.62)'; +export const GLOBE_OUTLINE_OTHER = 'rgba(160,175,195,0.35)'; + // ── Flat map icon sizes ── export const FLAT_SHIP_ICON_SIZE = 19; @@ -152,7 +160,7 @@ export const FLEET_LINE_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95); // ── Bathymetry zoom ranges ── export const BATHY_ZOOM_RANGES: BathyZoomRange[] = [ - { id: 'bathymetry-fill', mercator: [6, 24], globe: [8, 24] }, - { id: 'bathymetry-borders', mercator: [6, 24], globe: [8, 24] }, - { id: 'bathymetry-borders-major', mercator: [4, 24], globe: [8, 24] }, + { id: 'bathymetry-fill', mercator: [5, 24], globe: [7, 24] }, + { id: 'bathymetry-borders', mercator: [5, 24], globe: [7, 24] }, + { id: 'bathymetry-borders-major', mercator: [3, 24], globe: [7, 24] }, ]; diff --git a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts index b22a6c4..4ab3b49 100644 --- a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts @@ -20,7 +20,9 @@ import { EMPTY_MMSI_SET, DEPTH_DISABLED_PARAMS, GLOBE_OVERLAY_PARAMS, - LEGACY_CODE_COLORS, + HALO_OUTLINE_COLOR, + HALO_OUTLINE_COLOR_SELECTED, + HALO_OUTLINE_COLOR_HIGHLIGHTED, PAIR_RANGE_NORMAL_DECK, PAIR_RANGE_WARN_DECK, PAIR_LINE_NORMAL_DECK, @@ -426,12 +428,7 @@ export function useDeckLayers( getRadius: () => FLAT_LEGACY_HALO_RADIUS, lineWidthUnits: 'pixels', getLineWidth: () => 2, - getLineColor: (d) => { - const l = legacyHits?.get(d.mmsi); - const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; - if (!rgb) return [245, 158, 11, 200]; - return [rgb[0], rgb[1], rgb[2], 200]; - }, + getLineColor: () => HALO_OUTLINE_COLOR, getPosition: (d) => [d.lon, d.lat] as [number, number], }), ); @@ -476,7 +473,7 @@ export function useDeckLayers( } if (settings.showShips && legacyOverlayTargets.length > 0) { - layers.push(new ScatterplotLayer({ id: 'legacy-halo-overlay', data: legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return [14, 234, 255, 230]; const l = legacyHits?.get(d.mmsi); const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; if (!rgb) return [245, 158, 11, 210]; return [rgb[0], rgb[1], rgb[2], 210]; }, getPosition: (d) => [d.lon, d.lat] as [number, number] })); + layers.push(new ScatterplotLayer({ id: 'legacy-halo-overlay', data: legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; if (shipHighlightSet.has(d.mmsi)) return HALO_OUTLINE_COLOR_HIGHLIGHTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] })); } if (settings.showShips && shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)).length > 0) { @@ -621,7 +618,7 @@ export function useDeckLayers( } if (settings.showShips && legacyTargetsOrdered.length > 0) { - globeLayers.push(new ScatterplotLayer({ id: 'legacy-halo-globe', data: legacyTargetsOrdered, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return [14, 234, 255, 230]; const l = legacyHits?.get(d.mmsi); const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; if (!rgb) return [245, 158, 11, 200]; return [rgb[0], rgb[1], rgb[2], 200]; }, getPosition: (d) => [d.lon, d.lat] as [number, number] })); + globeLayers.push(new ScatterplotLayer({ id: 'legacy-halo-globe', data: legacyTargetsOrdered, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2), getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] })); } const normalizedLayers = sanitizeDeckLayerList(globeLayers); diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts index 525cb65..b5b16fb 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts @@ -8,6 +8,8 @@ import type { Map3DSettings, MapProjectionId } from '../types'; import { ANCHORED_SHIP_ICON_ID, GLOBE_ICON_HEADING_OFFSET_DEG, + GLOBE_OUTLINE_PERMITTED, + GLOBE_OUTLINE_OTHER, DEG2RAD, } from '../constants'; import { isFiniteNumber } from '../lib/setUtils'; @@ -351,8 +353,9 @@ export function useGlobeShips( const iconScale = selected ? selectedScale : highlightScale; const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3); const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45); - const iconSize10 = clampNumber(0.56 * sizeScale * selectedScale, 0.35, 1.7); - const iconSize14 = clampNumber(0.72 * sizeScale * selectedScale, 0.45, 2.1); + const iconSize10 = clampNumber(0.58 * sizeScale * selectedScale, 0.35, 1.8); + const iconSize14 = clampNumber(0.85 * sizeScale * selectedScale, 0.45, 2.6); + const iconSize18 = clampNumber(2.5 * sizeScale * selectedScale, 1.0, 6.0); return { type: 'Feature', ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), @@ -373,6 +376,7 @@ export function useGlobeShips( iconSize7: iconSize7 * iconScale, iconSize10: iconSize10 * iconScale, iconSize14: iconSize14 * iconScale, + iconSize18: iconSize18 * iconScale, sizeScale, selected: selected ? 1 : 0, highlighted: highlighted ? 1 : 0, @@ -474,14 +478,15 @@ export function useGlobeShips( 'case', ['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)', - ['coalesce', ['get', 'shipColor'], '#64748b'], + ['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED, + GLOBE_OUTLINE_OTHER, ] as never, 'circle-stroke-width': [ 'case', ['==', ['get', 'selected'], 1], 3.4, ['==', ['get', 'highlighted'], 1], 2.7, ['==', ['get', 'permitted'], 1], 1.8, - 0.0, + 0.7, ] as never, 'circle-stroke-opacity': 0.85, }, @@ -519,14 +524,15 @@ export function useGlobeShips( 'case', ['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)', - ['coalesce', ['get', 'shipColor'], '#64748b'], + ['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED, + GLOBE_OUTLINE_OTHER, ] as never); map.setPaintProperty(outlineId, 'circle-stroke-width', [ 'case', ['==', ['get', 'selected'], 1], 3.4, ['==', ['get', 'highlighted'], 1], 2.7, ['==', ['get', 'permitted'], 1], 1.8, - 0.0, + 0.7, ] as never); } catch { // ignore @@ -561,8 +567,9 @@ export function useGlobeShips( 'interpolate', ['linear'], ['zoom'], 3, ['to-number', ['get', 'iconSize3'], 0.35], 7, ['to-number', ['get', 'iconSize7'], 0.45], - 10, ['to-number', ['get', 'iconSize10'], 0.56], - 14, ['to-number', ['get', 'iconSize14'], 0.72], + 10, ['to-number', ['get', 'iconSize10'], 0.58], + 14, ['to-number', ['get', 'iconSize14'], 0.85], + 18, ['to-number', ['get', 'iconSize18'], 2.5], ] as unknown as number[], 'icon-allow-overlap': true, 'icon-ignore-placement': true, @@ -791,8 +798,9 @@ export function useGlobeShips( }), iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45), iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7), - iconSize10: clampNumber(0.56 * sizeScale * scale, 0.35, 2.0), - iconSize14: clampNumber(0.72 * sizeScale * scale, 0.45, 2.4), + iconSize10: clampNumber(0.58 * sizeScale * scale, 0.35, 2.1), + iconSize14: clampNumber(0.85 * sizeScale * scale, 0.45, 3.0), + iconSize18: clampNumber(2.5 * sizeScale * scale, 1.0, 7.0), selected: selected ? 1 : 0, permitted: legacy ? 1 : 0, }, @@ -907,8 +915,9 @@ export function useGlobeShips( 'interpolate', ['linear'], ['zoom'], 3, ['to-number', ['get', 'iconSize3'], 0.35], 7, ['to-number', ['get', 'iconSize7'], 0.45], - 10, ['to-number', ['get', 'iconSize10'], 0.56], - 14, ['to-number', ['get', 'iconSize14'], 0.72], + 10, ['to-number', ['get', 'iconSize10'], 0.58], + 14, ['to-number', ['get', 'iconSize14'], 0.85], + 18, ['to-number', ['get', 'iconSize18'], 2.5], ] as unknown as number[], 'icon-allow-overlap': true, 'icon-ignore-placement': true, diff --git a/apps/web/src/widgets/map3d/layers/bathymetry.ts b/apps/web/src/widgets/map3d/layers/bathymetry.ts index e0f7004..6b55e33 100644 --- a/apps/web/src/widgets/map3d/layers/bathymetry.ts +++ b/apps/web/src/widgets/map3d/layers/bathymetry.ts @@ -66,11 +66,11 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK type: 'fill', source: oceanSourceId, 'source-layer': 'contour', - minzoom: 6, + minzoom: 5, maxzoom: 24, paint: { 'fill-color': bathyFillColor, - 'fill-opacity': ['interpolate', ['linear'], ['zoom'], 0, 0.9, 6, 0.86, 10, 0.78], + 'fill-opacity': ['interpolate', ['linear'], ['zoom'], 0, 0.9, 5, 0.88, 8, 0.84, 10, 0.78], }, } as unknown as LayerSpecification; @@ -79,7 +79,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK type: 'line', source: oceanSourceId, 'source-layer': 'contour', - minzoom: 6, + minzoom: 5, maxzoom: 24, paint: { 'line-color': 'rgba(255,255,255,0.06)', @@ -94,7 +94,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK type: 'line', source: oceanSourceId, 'source-layer': 'contour_line', - minzoom: 8, + minzoom: 7, paint: { 'line-color': [ 'interpolate', @@ -127,7 +127,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK type: 'line', source: oceanSourceId, 'source-layer': 'contour_line', - minzoom: 8, + minzoom: 7, maxzoom: 24, filter: bathyMajorDepthFilter as unknown as unknown[], paint: { @@ -143,14 +143,14 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK type: 'line', source: oceanSourceId, 'source-layer': 'contour', - minzoom: 4, + minzoom: 3, maxzoom: 24, filter: bathyMajorDepthFilter as unknown as unknown[], paint: { 'line-color': 'rgba(255,255,255,0.14)', - 'line-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0.14, 8, 0.2, 12, 0.26], - 'line-blur': ['interpolate', ['linear'], ['zoom'], 4, 0.3, 10, 0.15], - 'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.35, 8, 0.55, 12, 0.85], + 'line-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.10, 6, 0.16, 8, 0.2, 12, 0.26], + 'line-blur': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 6, 0.35, 10, 0.15], + 'line-width': ['interpolate', ['linear'], ['zoom'], 3, 0.25, 6, 0.4, 8, 0.55, 12, 0.85], }, } as unknown as LayerSpecification; diff --git a/apps/web/src/widgets/map3d/lib/mlExpressions.ts b/apps/web/src/widgets/map3d/lib/mlExpressions.ts index 7d9ede2..72c3ceb 100644 --- a/apps/web/src/widgets/map3d/lib/mlExpressions.ts +++ b/apps/web/src/widgets/map3d/lib/mlExpressions.ts @@ -45,7 +45,8 @@ export function makeGlobeCircleRadiusExpr() { const base3 = 4; const base7 = 6; const base10 = 8; - const base14 = 11; + const base14 = 12; + const base18 = 32; return [ 'interpolate', @@ -58,7 +59,9 @@ export function makeGlobeCircleRadiusExpr() { 10, ['case', ['==', ['get', 'selected'], 1], 9.0, ['==', ['get', 'highlighted'], 1], 8.2, base10], 14, - ['case', ['==', ['get', 'selected'], 1], 11.8, ['==', ['get', 'highlighted'], 1], 10.8, base14], + ['case', ['==', ['get', 'selected'], 1], 13.5, ['==', ['get', 'highlighted'], 1], 12.6, base14], + 18, + ['case', ['==', ['get', 'selected'], 1], 36, ['==', ['get', 'highlighted'], 1], 34, base18], ]; } From 621a5037c2cb9ce8c71e09e40038fa13d184e36c Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 20:59:45 +0900 Subject: [PATCH 46/58] chore(data): vendor submarine cable geojson/details --- .../data/subcables/cable-details.min.json | 1 + apps/web/public/data/subcables/cable-geo.json | 1 + package.json | 3 +- scripts/prepare-subcables.mjs | 176 ++++++++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 apps/web/public/data/subcables/cable-details.min.json create mode 100644 apps/web/public/data/subcables/cable-geo.json create mode 100644 scripts/prepare-subcables.mjs diff --git a/apps/web/public/data/subcables/cable-details.min.json b/apps/web/public/data/subcables/cable-details.min.json new file mode 100644 index 0000000..b0c6f8b --- /dev/null +++ b/apps/web/public/data/subcables/cable-details.min.json @@ -0,0 +1 @@ +{"version":1,"generated_at":"2026-02-15T11:58:10.837Z","by_id":{"2africa":{"id":"2africa","name":"2Africa","length":"45,000 km","rfs":"2024","rfs_year":2024,"is_planned":false,"owners":"Bayobab, China Mobile, Meta, Orange, Telecom Egypt, Vodafone, WIOCC, center3","suppliers":"ASN","landing_points":[{"id":"luanda-angola","name":"Luanda, Angola","country":"Angola","is_tbd":false},{"id":"manama-bahrain","name":"Manama, Bahrain","country":"Bahrain","is_tbd":false},{"id":"moroni-comoros","name":"Moroni, Comoros","country":"Comoros","is_tbd":false},{"id":"muanda-congo-dem-rep-","name":"Muanda, Congo, Dem. Rep.","country":"Congo, Dem. Rep.","is_tbd":false},{"id":"pointe-noire-congo-rep-","name":"Pointe-Noire, Congo, Rep.","country":"Congo, Rep.","is_tbd":false},{"id":"abidjan-cte-divoire","name":"Abidjan, Côte d'Ivoire","country":"Côte d'Ivoire","is_tbd":false},{"id":"djibouti-city-djibouti","name":"Djibouti City, Djibouti","country":"Djibouti","is_tbd":false},{"id":"port-said-egypt","name":"Port Said, Egypt","country":"Egypt","is_tbd":false},{"id":"ras-ghareb-egypt","name":"Ras Ghareb, Egypt","country":"Egypt","is_tbd":false},{"id":"suez-egypt","name":"Suez, Egypt","country":"Egypt","is_tbd":false},{"id":"zafarana-egypt","name":"Zafarana, Egypt","country":"Egypt","is_tbd":false},{"id":"marseille-france","name":"Marseille, France","country":"France","is_tbd":false},{"id":"libreville-gabon","name":"Libreville, Gabon","country":"Gabon","is_tbd":false},{"id":"accra-ghana","name":"Accra, Ghana","country":"Ghana","is_tbd":false},{"id":"tympaki-greece","name":"Tympaki, Greece","country":"Greece","is_tbd":false},{"id":"mumbai-india","name":"Mumbai, India","country":"India","is_tbd":false},{"id":"al-faw-iraq","name":"Al Faw, Iraq","country":"Iraq","is_tbd":false},{"id":"genoa-italy","name":"Genoa, Italy","country":"Italy","is_tbd":false},{"id":"mombasa-kenya","name":"Mombasa, Kenya","country":"Kenya","is_tbd":false},{"id":"mtwapa-kenya","name":"Mtwapa, Kenya","country":"Kenya","is_tbd":false},{"id":"kuwait-city-kuwait","name":"Kuwait City, Kuwait","country":"Kuwait","is_tbd":false},{"id":"mahajanga-madagascar","name":"Mahajanga, Madagascar","country":"Madagascar","is_tbd":false},{"id":"maputo-mozambique","name":"Maputo, Mozambique","country":"Mozambique","is_tbd":false},{"id":"nacala-mozambique","name":"Nacala, Mozambique","country":"Mozambique","is_tbd":false},{"id":"kwa-ibo-nigeria","name":"Kwa Ibo, Nigeria","country":"Nigeria","is_tbd":false},{"id":"lagos-nigeria","name":"Lagos, Nigeria","country":"Nigeria","is_tbd":false},{"id":"barka-oman","name":"Barka, Oman","country":"Oman","is_tbd":false},{"id":"salalah-oman","name":"Salalah, Oman","country":"Oman","is_tbd":false},{"id":"karachi-pakistan","name":"Karachi, Pakistan","country":"Pakistan","is_tbd":false},{"id":"carcavelos-portugal","name":"Carcavelos, Portugal","country":"Portugal","is_tbd":false},{"id":"doha-qatar","name":"Doha, Qatar","country":"Qatar","is_tbd":false},{"id":"al-khobar-saudi-arabia","name":"Al Khobar, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"duba-saudi-arabia","name":"Duba, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"jeddah-saudi-arabia","name":"Jeddah, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"yanbu-saudi-arabia","name":"Yanbu, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"dakar-senegal","name":"Dakar, Senegal","country":"Senegal","is_tbd":false},{"id":"carana-seychelles","name":"Carana, Seychelles","country":"Seychelles","is_tbd":false},{"id":"berbera-somalia","name":"Berbera, Somalia","country":"Somalia","is_tbd":false},{"id":"mogadishu-somalia","name":"Mogadishu, Somalia","country":"Somalia","is_tbd":false},{"id":"amanzimtoti-south-africa","name":"Amanzimtoti, South Africa","country":"South Africa","is_tbd":false},{"id":"duynefontein-south-africa","name":"Duynefontein, South Africa","country":"South Africa","is_tbd":false},{"id":"gqeberha-south-africa","name":"Gqeberha, South Africa","country":"South Africa","is_tbd":false},{"id":"yzerfontein-south-africa","name":"Yzerfontein, South Africa","country":"South Africa","is_tbd":false},{"id":"barcelona-spain","name":"Barcelona, Spain","country":"Spain","is_tbd":false},{"id":"gran-canaria-canary-islands-spain","name":"Gran Canaria, Canary Islands, Spain","country":"Spain","is_tbd":false},{"id":"port-sudan-sudan","name":"Port Sudan, Sudan","country":"Sudan","is_tbd":false},{"id":"dar-es-salaam-tanzania","name":"Dar Es Salaam, Tanzania","country":"Tanzania","is_tbd":false},{"id":"abu-dhabi-united-arab-emirates","name":"Abu Dhabi, United Arab Emirates","country":"United Arab Emirates","is_tbd":false},{"id":"kalba-united-arab-emirates","name":"Kalba, United Arab Emirates","country":"United Arab Emirates","is_tbd":false},{"id":"bude-united-kingdom","name":"Bude, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"https://www.2africacable.net/"},"africa-coast-to-europe-ace":{"id":"africa-coast-to-europe-ace","name":"Africa Coast to Europe (ACE)","length":"17,000 km","rfs":"2012 December","rfs_year":2012,"is_planned":false,"owners":"Bayobab, Cable Consortium of Liberia, Canalink, Dolphin Telecom, GUILAB, Gambia Submarine Cable Company, International Mauritania Telecom, Orange, Orange Cameroun, Orange Cote d’Ivoire, Orange Mali, Republic of Cameroon, Republic of Equatorial Guinea, Republic of Gabon, Republic of Guinea Bissau, SBIN (Société Béninoise des Infrastructures Numériques du Bénin), STP Cabo, Sierra Leone Cable Company, Sonatel, Zamani Telecom","suppliers":"ASN","landing_points":[{"id":"cotonou-benin","name":"Cotonou, Benin","country":"Benin","is_tbd":false},{"id":"abidjan-cte-divoire","name":"Abidjan, Côte d'Ivoire","country":"Côte d'Ivoire","is_tbd":false},{"id":"bata-equatorial-guinea","name":"Bata, Equatorial Guinea","country":"Equatorial Guinea","is_tbd":false},{"id":"penmarch-france","name":"Penmarch, France","country":"France","is_tbd":false},{"id":"libreville-gabon","name":"Libreville, Gabon","country":"Gabon","is_tbd":false},{"id":"banjul-gambia","name":"Banjul, Gambia","country":"Gambia","is_tbd":false},{"id":"accra-ghana","name":"Accra, Ghana","country":"Ghana","is_tbd":false},{"id":"conakry-guinea","name":"Conakry, Guinea","country":"Guinea","is_tbd":false},{"id":"suro-guinea-bissau","name":"Suro, Guinea-Bissau","country":"Guinea-Bissau","is_tbd":false},{"id":"monrovia-liberia","name":"Monrovia, Liberia","country":"Liberia","is_tbd":false},{"id":"nouakchott-mauritania","name":"Nouakchott, Mauritania","country":"Mauritania","is_tbd":false},{"id":"lagos-nigeria","name":"Lagos, Nigeria","country":"Nigeria","is_tbd":false},{"id":"carcavelos-portugal","name":"Carcavelos, Portugal","country":"Portugal","is_tbd":false},{"id":"sao-tome-sao-tome-and-principe","name":"Sao Tome, Sao Tome and Principe","country":"Sao Tome and Principe","is_tbd":false},{"id":"dakar-senegal","name":"Dakar, Senegal","country":"Senegal","is_tbd":false},{"id":"freetown-sierra-leone","name":"Freetown, Sierra Leone","country":"Sierra Leone","is_tbd":false},{"id":"duynefontein-south-africa","name":"Duynefontein, South Africa","country":"South Africa","is_tbd":false},{"id":"granadilla-de-abona-spain","name":"Granadilla de Abona, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":null},"africa-1":{"id":"africa-1","name":"Africa-1","length":"10,000 km","rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"G42, Mobily, Pakistan Telecommunications Company Ltd., TeleYemen, Telecom Egypt, Zain Omantel International, e&","suppliers":"ASN","landing_points":[{"id":"bejaia-algeria","name":"Bejaia, Algeria","country":"Algeria","is_tbd":false},{"id":"djibouti-city-djibouti","name":"Djibouti City, Djibouti","country":"Djibouti","is_tbd":false},{"id":"ras-ghareb-egypt","name":"Ras Ghareb, Egypt","country":"Egypt","is_tbd":false},{"id":"sidi-kerir-egypt","name":"Sidi Kerir, Egypt","country":"Egypt","is_tbd":false},{"id":"marseille-france","name":"Marseille, France","country":"France","is_tbd":false},{"id":"mombasa-kenya","name":"Mombasa, Kenya","country":"Kenya","is_tbd":false},{"id":"karachi-pakistan","name":"Karachi, Pakistan","country":"Pakistan","is_tbd":false},{"id":"duba-saudi-arabia","name":"Duba, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"berbera-somalia","name":"Berbera, Somalia","country":"Somalia","is_tbd":false},{"id":"kalba-united-arab-emirates","name":"Kalba, United Arab Emirates","country":"United Arab Emirates","is_tbd":false},{"id":"al-hudaydah-yemen","name":"Al Hudaydah, Yemen","country":"Yemen","is_tbd":false}],"notes":null,"url":null},"alaska-united-southeast-au-se":{"id":"alaska-united-southeast-au-se","name":"Alaska United Southeast (AU-SE)","length":"626 km","rfs":"2008 November","rfs_year":2008,"is_planned":false,"owners":"GCI Communication Corp","suppliers":"SubCom","landing_points":[{"id":"angoon-ak-united-states","name":"Angoon, AK, United States","country":"United States","is_tbd":false},{"id":"hawk-inlet-ak-united-states","name":"Hawk Inlet, AK, United States","country":"United States","is_tbd":false},{"id":"juneau-ak-united-states","name":"Juneau, AK, United States","country":"United States","is_tbd":false},{"id":"ketchikan-ak-united-states","name":"Ketchikan, AK, United States","country":"United States","is_tbd":false},{"id":"petersburg-ak-united-states","name":"Petersburg, AK, United States","country":"United States","is_tbd":false},{"id":"sitka-ak-united-states","name":"Sitka, AK, United States","country":"United States","is_tbd":false},{"id":"wrangell-ak-united-states","name":"Wrangell, AK, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.gci.com/"},"airraq":{"id":"airraq","name":"Airraq","length":"680 km","rfs":"2025 Q1","rfs_year":2025,"is_planned":false,"owners":"Unicom, Inc.","suppliers":null,"landing_points":[{"id":"dillingham-ak-united-states","name":"Dillingham, AK, United States","country":"United States","is_tbd":false},{"id":"eek-ak-united-states","name":"Eek, AK, United States","country":"United States","is_tbd":false},{"id":"platinum-ak-united-states","name":"Platinum, AK, United States","country":"United States","is_tbd":false},{"id":"quinhagak-ak-united-states","name":"Quinhagak, AK, United States","country":"United States","is_tbd":false},{"id":"togiak-ak-united-states","name":"Togiak, AK, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.gci.com/"},"adria-1":{"id":"adria-1","name":"Adria-1","length":"440 km","rfs":"1996 September","rfs_year":1996,"is_planned":false,"owners":"ALBtelecom, Hrvatski Telekom","suppliers":"ASN","landing_points":[{"id":"durres-albania","name":"Durres, Albania","country":"Albania","is_tbd":false},{"id":"dubrovnik-croatia","name":"Dubrovnik, Croatia","country":"Croatia","is_tbd":false},{"id":"corfu-greece","name":"Corfu, Greece","country":"Greece","is_tbd":false}],"notes":null,"url":null},"alaska-united-east-au-east":{"id":"alaska-united-east-au-east","name":"Alaska United East (AU-East)","length":"3,751 km","rfs":"1999 February","rfs_year":1999,"is_planned":false,"owners":"GCI Communication Corp","suppliers":"SubCom","landing_points":[{"id":"juneau-ak-united-states","name":"Juneau, AK, United States","country":"United States","is_tbd":false},{"id":"lynnwood-wa-united-states","name":"Lynnwood, WA, United States","country":"United States","is_tbd":false},{"id":"valdez-ak-united-states","name":"Valdez, AK, United States","country":"United States","is_tbd":false},{"id":"whittier-ak-united-states","name":"Whittier, AK, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.gci.com/"},"5-villages-6-islands":{"id":"5-villages-6-islands","name":"5 Villages 6 Islands","length":"355 km","rfs":"2019","rfs_year":2019,"is_planned":false,"owners":"Tokyo Metropolitan Government","suppliers":"NEC","landing_points":[{"id":"aogashima-japan","name":"Aogashima, Japan","country":"Japan","is_tbd":false},{"id":"hachijo-japan","name":"Hachijo, Japan","country":"Japan","is_tbd":false},{"id":"kozushima-japan","name":"Kozushima, Japan","country":"Japan","is_tbd":false},{"id":"mikurashima-japan","name":"Mikurashima, Japan","country":"Japan","is_tbd":false},{"id":"miyake-japan","name":"Miyake, Japan","country":"Japan","is_tbd":false},{"id":"niijima-japan","name":"Niijima, Japan","country":"Japan","is_tbd":false},{"id":"oshima-japan","name":"Oshima, Japan","country":"Japan","is_tbd":false},{"id":"shikinejima-japan","name":"Shikinejima, Japan","country":"Japan","is_tbd":false},{"id":"toshima-japan","name":"Toshima, Japan","country":"Japan","is_tbd":false}],"notes":null,"url":null},"aec-1":{"id":"aec-1","name":"AEC-1","length":"5,521 km","rfs":"2016 January","rfs_year":2016,"is_planned":false,"owners":"EXA Infrastructure","suppliers":"SubCom","landing_points":[{"id":"killala-ireland","name":"Killala, Ireland","country":"Ireland","is_tbd":false},{"id":"shirley-ny-united-states","name":"Shirley, NY, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://exainfra.net/"},"aden-djibouti":{"id":"aden-djibouti","name":"Aden-Djibouti","length":"269 km","rfs":"1994","rfs_year":1994,"is_planned":false,"owners":"Djibouti Telecom, Orange, Sparkle, Tata Communications, TeleYemen","suppliers":"ASN","landing_points":[{"id":"djibouti-city-djibouti","name":"Djibouti City, Djibouti","country":"Djibouti","is_tbd":false},{"id":"aden-yemen","name":"Aden, Yemen","country":"Yemen","is_tbd":false}],"notes":null,"url":"https://www.teleyemen.com.ye/"},"acs-alaska-oregon-network-akorn":{"id":"acs-alaska-oregon-network-akorn","name":"ACS Alaska-Oregon Network (AKORN)","length":"3,000 km","rfs":"2009 April","rfs_year":2009,"is_planned":false,"owners":"Alaska Communications","suppliers":"SubCom","landing_points":[{"id":"anchorage-ak-united-states","name":"Anchorage, AK, United States","country":"United States","is_tbd":false},{"id":"florence-or-united-states","name":"Florence, OR, United States","country":"United States","is_tbd":false},{"id":"homer-ak-united-states","name":"Homer, AK, United States","country":"United States","is_tbd":false},{"id":"nikiski-ak-united-states","name":"Nikiski, AK, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.alaskacommunications.com"},"adamasia-cable-system-1":{"id":"adamasia-cable-system-1","name":"Adamasia Cable System 1","length":"1,015 km","rfs":"2027 Q4","rfs_year":2027,"is_planned":true,"owners":"Solomon Island Submarine Cable Company","suppliers":"SubCom","landing_points":[{"id":"honiara-solomon-islands","name":"Honiara, Solomon Islands","country":"Solomon Islands","is_tbd":false}],"notes":null,"url":null},"alba-1":{"id":"alba-1","name":"ALBA-1","length":"1,860 km","rfs":"2012 August","rfs_year":2012,"is_planned":false,"owners":"Telecom Venezuela, Transbit","suppliers":"ASN","landing_points":[{"id":"santiago-de-cuba-cuba","name":"Santiago de Cuba, Cuba","country":"Cuba","is_tbd":false},{"id":"siboney-cuba","name":"Siboney, Cuba","country":"Cuba","is_tbd":false},{"id":"ocho-rios-jamaica","name":"Ocho Rios, Jamaica","country":"Jamaica","is_tbd":false},{"id":"la-guaira-venezuela","name":"La Guaira, Venezuela","country":"Venezuela","is_tbd":false}],"notes":null,"url":null},"aletar":{"id":"aletar","name":"Aletar","length":"787 km","rfs":"1997 April","rfs_year":1997,"is_planned":false,"owners":"Liban Telecom, Syrian Telecommunications Establishment, Telecom Egypt","suppliers":"ASN","landing_points":[{"id":"alexandria-egypt","name":"Alexandria, Egypt","country":"Egypt","is_tbd":false},{"id":"tartous-syria","name":"Tartous, Syria","country":"Syria","is_tbd":false}],"notes":null,"url":null},"alaska-united-west-au-west":{"id":"alaska-united-west-au-west","name":"Alaska United West (AU-West)","length":"2,485 km","rfs":"2004 June","rfs_year":2004,"is_planned":false,"owners":"GCI Communication Corp","suppliers":"NEC","landing_points":[{"id":"ketchikan-ak-united-states","name":"Ketchikan, AK, United States","country":"United States","is_tbd":false},{"id":"seward-ak-united-states","name":"Seward, AK, United States","country":"United States","is_tbd":false},{"id":"warrenton-or-united-states","name":"Warrenton, OR, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.gci.com/"},"almera-melilla-alme":{"id":"almera-melilla-alme","name":"Almería-Melilla (ALME)","length":"198 km","rfs":"1990","rfs_year":1990,"is_planned":false,"owners":"Telefonica","suppliers":"Nexans","landing_points":[{"id":"almera-spain","name":"Almería, Spain","country":"Spain","is_tbd":false},{"id":"melilla-spain","name":"Melilla, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":"https://www.telefonica.com/"},"alonso-de-ojeda":{"id":"alonso-de-ojeda","name":"Alonso de Ojeda","length":"122 km","rfs":"1999","rfs_year":1999,"is_planned":false,"owners":"Setar, United Telecommunication Services (UTS)","suppliers":"ASN","landing_points":[{"id":"baby-beach-aruba","name":"Baby Beach, Aruba","country":"Aruba","is_tbd":false},{"id":"willemstad-curaao","name":"Willemstad, Curaçao","country":"Curaçao","is_tbd":false}],"notes":null,"url":null},"alpal-2":{"id":"alpal-2","name":"Alpal-2","length":"312 km","rfs":"2002 July","rfs_year":2002,"is_planned":false,"owners":"Algerie Telecom, Orange, Sparkle, Telxius","suppliers":"Prysmian","landing_points":[{"id":"el-djamila-algeria","name":"El Djamila, Algeria","country":"Algeria","is_tbd":false},{"id":"ses-covetes-spain","name":"Ses Covetes, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":null},"america-movil-submarine-cable-system-1-amx-1":{"id":"america-movil-submarine-cable-system-1-amx-1","name":"America Movil Submarine Cable System-1 (AMX-1)","length":"17,800 km","rfs":"2014","rfs_year":2014,"is_planned":false,"owners":"América Móvil (Claro)","suppliers":"ASN","landing_points":[{"id":"fortaleza-brazil","name":"Fortaleza, Brazil","country":"Brazil","is_tbd":false},{"id":"rio-de-janeiro-brazil","name":"Rio de Janeiro, Brazil","country":"Brazil","is_tbd":false},{"id":"salvador-brazil","name":"Salvador, Brazil","country":"Brazil","is_tbd":false},{"id":"barranquilla-colombia","name":"Barranquilla, Colombia","country":"Colombia","is_tbd":false},{"id":"cartagena-colombia","name":"Cartagena, Colombia","country":"Colombia","is_tbd":false},{"id":"schooner-bight-colombia","name":"Schooner Bight, Colombia","country":"Colombia","is_tbd":false},{"id":"puerto-limon-costa-rica","name":"Puerto Limon, Costa Rica","country":"Costa Rica","is_tbd":false},{"id":"puerto-plata-dominican-republic","name":"Puerto Plata, Dominican Republic","country":"Dominican Republic","is_tbd":false},{"id":"santo-domingo-dominican-republic","name":"Santo Domingo, Dominican Republic","country":"Dominican Republic","is_tbd":false},{"id":"puerto-barrios-guatemala","name":"Puerto Barrios, Guatemala","country":"Guatemala","is_tbd":false},{"id":"cancn-mexico","name":"Cancún, Mexico","country":"Mexico","is_tbd":false},{"id":"hollywood-fl-united-states","name":"Hollywood, FL, United States","country":"United States","is_tbd":false},{"id":"jacksonville-fl-united-states","name":"Jacksonville, FL, United States","country":"United States","is_tbd":false},{"id":"ponce-pr-united-states","name":"Ponce, PR, United States","country":"United States","is_tbd":false},{"id":"san-juan-pr-united-states","name":"San Juan, PR, United States","country":"United States","is_tbd":false}],"notes":null,"url":"http://www.americamovil.com"},"american-1":{"id":"american-1","name":"AmeriCan-1","length":"140 km","rfs":"1999 December","rfs_year":1999,"is_planned":false,"owners":"Bell Canada, Ledcor Industries Inc., Rogers Communications, Zayo","suppliers":null,"landing_points":[{"id":"cordova-bay-bc-canada","name":"Cordova Bay, BC, Canada","country":"Canada","is_tbd":false},{"id":"esquimalt-bc-canada","name":"Esquimalt, BC, Canada","country":"Canada","is_tbd":false},{"id":"oak-harbor-wa-united-states","name":"Oak Harbor, WA, United States","country":"United States","is_tbd":false},{"id":"point-roberts-wa-united-states","name":"Point Roberts, WA, United States","country":"United States","is_tbd":false},{"id":"seattle-wa-united-states","name":"Seattle, WA, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"americas-i-north":{"id":"americas-i-north","name":"Americas-I North","length":"2,012 km","rfs":"1994 September","rfs_year":1994,"is_planned":false,"owners":"AT&T","suppliers":"SubCom","landing_points":[{"id":"magens-bay-vi-united-states","name":"Magen’s Bay, VI, United States","country":"United States","is_tbd":false},{"id":"vero-beach-fl-united-states","name":"Vero Beach, FL, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"americas-ii-west":{"id":"americas-ii-west","name":"Americas-II West","length":null,"rfs":"2000 August","rfs_year":2000,"is_planned":false,"owners":"AT&T","suppliers":"SubCom","landing_points":[{"id":"miramar-pr-united-states","name":"Miramar, PR, United States","country":"United States","is_tbd":false},{"id":"st-croix-virgin-islands-virgin-islands-u-s-","name":"St. Croix, Virgin Islands, Virgin Islands (U.S.)","country":"Virgin Islands (U.S.)","is_tbd":false}],"notes":null,"url":null},"alaska-united-turnagain-arm-auta":{"id":"alaska-united-turnagain-arm-auta","name":"Alaska United Turnagain Arm (AUTA)","length":"53 km","rfs":"2012","rfs_year":2012,"is_planned":false,"owners":"GCI Communication Corp","suppliers":null,"landing_points":[{"id":"mchugh-point-ak-united-states","name":"McHugh Point, AK, United States","country":"United States","is_tbd":false},{"id":"portage-ak-united-states","name":"Portage, AK, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.gci.com/"},"amerigo-vespucci":{"id":"amerigo-vespucci","name":"Amerigo Vespucci","length":"87 km","rfs":"1999","rfs_year":1999,"is_planned":false,"owners":"Antelecom","suppliers":"ASN","landing_points":[{"id":"willemstad-curaao","name":"Willemstad, Curaçao","country":"Curaçao","is_tbd":false},{"id":"kralendijk-bonaire-netherlands","name":"Kralendijk, Bonaire, Netherlands","country":"Netherlands","is_tbd":false}],"notes":null,"url":null},"anjana":{"id":"anjana","name":"Anjana","length":"7,121 km","rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"Meta","suppliers":"NEC","landing_points":[{"id":"santander-spain","name":"Santander, Spain","country":"Spain","is_tbd":false},{"id":"myrtle-beach-sc-united-states","name":"Myrtle Beach, SC, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://about.meta.com/"},"antigua-st-kitts":{"id":"antigua-st-kitts","name":"Antigua-St.Kitts","length":"14 km","rfs":"1995","rfs_year":1995,"is_planned":false,"owners":"Liberty Networks","suppliers":"ASN","landing_points":[{"id":"st-johns-antigua-and-barbuda","name":"St. John’s, Antigua and Barbuda","country":"Antigua and Barbuda","is_tbd":false},{"id":"basseterre-saint-kitts-and-nevis","name":"Basseterre, Saint Kitts and Nevis","country":"Saint Kitts and Nevis","is_tbd":false}],"notes":null,"url":"https://libertynet.com"},"antillas-1":{"id":"antillas-1","name":"Antillas 1","length":"601 km","rfs":"1997 June","rfs_year":1997,"is_planned":false,"owners":"Altice Dominicana, Antelecom, Claro Dominicana (Codetel), Liberty Networks, Setar","suppliers":"SubCom","landing_points":[{"id":"cacique-dominican-republic","name":"Cacique, Dominican Republic","country":"Dominican Republic","is_tbd":false},{"id":"punta-cana-dominican-republic","name":"Punta Cana, Dominican Republic","country":"Dominican Republic","is_tbd":false},{"id":"isla-verde-pr-united-states","name":"Isla Verde, PR, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"amitie":{"id":"amitie","name":"Amitie","length":"6,792 km","rfs":"2023 July","rfs_year":2023,"is_planned":false,"owners":"EXA Infrastructure, Meta, Microsoft, Orange, Vodafone","suppliers":"ASN","landing_points":[{"id":"le-porge-france","name":"Le Porge, France","country":"France","is_tbd":false},{"id":"bude-united-kingdom","name":"Bude, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"lynn-ma-united-states","name":"Lynn, MA, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"apcn-2":{"id":"apcn-2","name":"APCN-2","length":"19,000 km","rfs":"2001 December","rfs_year":2001,"is_planned":false,"owners":"AT&T, BT, China Telecom, China Unicom, Chunghwa Telecom, HKBN, KDDI, KT, LG Uplus, NTT, Orange, PCCW, PLDT, Singtel, Singtel Optus, Softbank, Starhub, Tata Communications, Telekom Malaysia, Telstra, Verizon, Vodafone","suppliers":"NEC","landing_points":[{"id":"chongming-china","name":"Chongming, China","country":"China","is_tbd":false},{"id":"lantau-island-china","name":"Lantau Island, China","country":"China","is_tbd":false},{"id":"shantou-china","name":"Shantou, China","country":"China","is_tbd":false},{"id":"chikura-japan","name":"Chikura, Japan","country":"Japan","is_tbd":false},{"id":"kitaibaraki-japan","name":"Kitaibaraki, Japan","country":"Japan","is_tbd":false},{"id":"cherating-malaysia","name":"Cherating, Malaysia","country":"Malaysia","is_tbd":false},{"id":"batangas-philippines","name":"Batangas, Philippines","country":"Philippines","is_tbd":false},{"id":"katong-singapore","name":"Katong, Singapore","country":"Singapore","is_tbd":false},{"id":"busan-south-korea","name":"Busan, South Korea","country":"South Korea","is_tbd":false},{"id":"tanshui-taiwan","name":"Tanshui, Taiwan","country":"Taiwan","is_tbd":false}],"notes":null,"url":null},"apocs-1":{"id":"apocs-1","name":"APOCS 1","length":null,"rfs":"1991","rfs_year":1991,"is_planned":false,"owners":"Bell Canada","suppliers":null,"landing_points":[{"id":"cape-ray-nl-canada","name":"Cape Ray, NL, Canada","country":"Canada","is_tbd":false},{"id":"sydney-mines-ns-canada","name":"Sydney Mines, NS, Canada","country":"Canada","is_tbd":false}],"notes":null,"url":"https://www.bce.ca/"},"apocs-2":{"id":"apocs-2","name":"APOCS 2","length":null,"rfs":"1995","rfs_year":1995,"is_planned":false,"owners":"Bell Canada","suppliers":null,"landing_points":[{"id":"aylesford-ns-canada","name":"Aylesford, NS, Canada","country":"Canada","is_tbd":false},{"id":"codroy-nl-canada","name":"Codroy, NL, Canada","country":"Canada","is_tbd":false},{"id":"dingwall-ns-canada","name":"Dingwall, NS, Canada","country":"Canada","is_tbd":false},{"id":"st-martins-nb-canada","name":"St. Martins, NB, Canada","country":"Canada","is_tbd":false}],"notes":null,"url":"https://www.bce.ca/"},"apollo":{"id":"apollo","name":"Apollo","length":"13,000 km","rfs":"2003 February","rfs_year":2003,"is_planned":false,"owners":"Vodafone","suppliers":"ASN","landing_points":[{"id":"lannion-france","name":"Lannion, France","country":"France","is_tbd":false},{"id":"bude-united-kingdom","name":"Bude, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"manasquan-nj-united-states","name":"Manasquan, NJ, United States","country":"United States","is_tbd":false},{"id":"shirley-ny-united-states","name":"Shirley, NY, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.vodafone.com/business/solutions/by-business-type/carrier-and-infrastructure-provider/submarine-and-terrestrial-cables/apollo"},"apollo-east-and-west":{"id":"apollo-east-and-west","name":"Apollo East and West","length":"670 km","rfs":"2025 May","rfs_year":2025,"is_planned":false,"owners":"Grid Telecom","suppliers":null,"landing_points":[{"id":"korakia-greece","name":"Korakia, Greece","country":"Greece","is_tbd":false},{"id":"pachi-greece","name":"Pachi, Greece","country":"Greece","is_tbd":false}],"notes":"Apollo East and West are fiber optic cables in parallel (but not attached) to power cables. Each leg is 335 km in length, for a total of 670 km. The cables extend terrestrially to Athens and Pachi in Greece.","url":"https://www.grid-telecom.com/"},"apricot":{"id":"apricot","name":"Apricot","length":"11,972 km","rfs":"2025 Q4","rfs_year":2025,"is_planned":false,"owners":"Chunghwa Telecom, Google, Meta, NTT, PLDT","suppliers":"SubCom","landing_points":[{"id":"agat-guam","name":"Agat, Guam","country":"Guam","is_tbd":false},{"id":"batam-indonesia","name":"Batam, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tanjung-pakis-indonesia","name":"Tanjung Pakis, Indonesia","country":"Indonesia","is_tbd":false},{"id":"minamiboso-japan","name":"Minamiboso, Japan","country":"Japan","is_tbd":false},{"id":"baler-philippines","name":"Baler, Philippines","country":"Philippines","is_tbd":false},{"id":"davao-philippines","name":"Davao, Philippines","country":"Philippines","is_tbd":false},{"id":"tuas-singapore","name":"Tuas, Singapore","country":"Singapore","is_tbd":false},{"id":"toucheng-taiwan","name":"Toucheng, Taiwan","country":"Taiwan","is_tbd":false}],"notes":null,"url":null},"apx-east":{"id":"apx-east","name":"APX East","length":"13,000 km","rfs":"2028 Q4","rfs_year":2028,"is_planned":true,"owners":"SUBCO","suppliers":null,"landing_points":[{"id":"sydney-nsw-australia","name":"Sydney, NSW, Australia","country":"Australia","is_tbd":false},{"id":"suva-fiji","name":"Suva, Fiji","country":"Fiji","is_tbd":true},{"id":"kapolei-hi-united-states","name":"Kapolei, HI, United States","country":"United States","is_tbd":true},{"id":"san-diego-ca-united-states","name":"San Diego, CA, United States","country":"United States","is_tbd":false}],"notes":null,"url":"http://www.subpartners.net"},"aqualink":{"id":"aqualink","name":"Aqualink","length":null,"rfs":"2001 December","rfs_year":2001,"is_planned":false,"owners":"One NZ","suppliers":null,"landing_points":[{"id":"auckland-new-zealand","name":"Auckland, New Zealand","country":"New Zealand","is_tbd":false},{"id":"christchurch-new-zealand","name":"Christchurch, New Zealand","country":"New Zealand","is_tbd":false},{"id":"kaikoura-new-zealand","name":"Kaikoura, New Zealand","country":"New Zealand","is_tbd":false},{"id":"new-plymouth-new-zealand","name":"New Plymouth, New Zealand","country":"New Zealand","is_tbd":false},{"id":"oara-new-zealand","name":"Oara, New Zealand","country":"New Zealand","is_tbd":false},{"id":"paraparaumu-new-zealand","name":"Paraparaumu, New Zealand","country":"New Zealand","is_tbd":false},{"id":"raglan-new-zealand","name":"Raglan, New Zealand","country":"New Zealand","is_tbd":false},{"id":"titahi-bay-new-zealand","name":"Titahi Bay, New Zealand","country":"New Zealand","is_tbd":false},{"id":"waikanae-new-zealand","name":"Waikanae, New Zealand","country":"New Zealand","is_tbd":false},{"id":"wellington-new-zealand","name":"Wellington, New Zealand","country":"New Zealand","is_tbd":false},{"id":"whanganui-new-zealand","name":"Whanganui, New Zealand","country":"New Zealand","is_tbd":false}],"notes":null,"url":null},"arctic-way":{"id":"arctic-way","name":"Arctic Way","length":"2,568 km","rfs":"2028 Q2","rfs_year":2028,"is_planned":true,"owners":"Space Norway","suppliers":"SubCom","landing_points":[{"id":"bod-norway","name":"Bodø, Norway","country":"Norway","is_tbd":false},{"id":"fauske-norway","name":"Fauske, Norway","country":"Norway","is_tbd":false},{"id":"longyearbyen-svalbard-norway","name":"Longyearbyen, Svalbard, Norway","country":"Norway","is_tbd":false},{"id":"olonkinbyen-norway","name":"Olonkinbyen, Norway","country":"Norway","is_tbd":false}],"notes":null,"url":"https://spacenorway.com/"},"arcos":{"id":"arcos","name":"ARCOS","length":"8,704 km","rfs":"2001 December","rfs_year":2001,"is_planned":false,"owners":"AT&T, Alestra, Bahamas Telecommunications Company, Belize Telemedia, CANTV, Claro Dominicana (Codetel), Enitel, Hondutel, ICE (Kolbi), Internexa, Liberty Networks, Orbinet Overseas, RACSA, Telecomunicaciones Ultramarinas de Puerto Rico, Telepuerto San Isidro, Tigo Colombia, Tricom USA, United Telecommunication Services (UTS), Verizon","suppliers":"Prysmian, SubCom","landing_points":[{"id":"cat-island-bahamas","name":"Cat Island, Bahamas","country":"Bahamas","is_tbd":false},{"id":"crooked-island-bahamas","name":"Crooked Island, Bahamas","country":"Bahamas","is_tbd":false},{"id":"nassau-bahamas","name":"Nassau, Bahamas","country":"Bahamas","is_tbd":false},{"id":"belize-city-belize","name":"Belize City, Belize","country":"Belize","is_tbd":false},{"id":"cartagena-colombia","name":"Cartagena, Colombia","country":"Colombia","is_tbd":false},{"id":"riohacha-colombia","name":"Riohacha, Colombia","country":"Colombia","is_tbd":false},{"id":"puerto-limon-costa-rica","name":"Puerto Limon, Costa Rica","country":"Costa Rica","is_tbd":false},{"id":"willemstad-curaao","name":"Willemstad, Curaçao","country":"Curaçao","is_tbd":false},{"id":"puerto-plata-dominican-republic","name":"Puerto Plata, Dominican Republic","country":"Dominican Republic","is_tbd":false},{"id":"punta-cana-dominican-republic","name":"Punta Cana, Dominican Republic","country":"Dominican Republic","is_tbd":false},{"id":"puerto-barrios-guatemala","name":"Puerto Barrios, Guatemala","country":"Guatemala","is_tbd":false},{"id":"puerto-cortes-honduras","name":"Puerto Cortes, Honduras","country":"Honduras","is_tbd":false},{"id":"puerto-lempira-honduras","name":"Puerto Lempira, Honduras","country":"Honduras","is_tbd":false},{"id":"trujillo-honduras","name":"Trujillo, Honduras","country":"Honduras","is_tbd":false},{"id":"cancn-mexico","name":"Cancún, Mexico","country":"Mexico","is_tbd":false},{"id":"tulum-mexico","name":"Tulum, Mexico","country":"Mexico","is_tbd":false},{"id":"bluefields-nicaragua","name":"Bluefields, Nicaragua","country":"Nicaragua","is_tbd":false},{"id":"puerto-cabezas-nicaragua","name":"Puerto Cabezas, Nicaragua","country":"Nicaragua","is_tbd":false},{"id":"maria-chiquita-panama","name":"Maria Chiquita, Panama","country":"Panama","is_tbd":false},{"id":"ustupo-panama","name":"Ustupo, Panama","country":"Panama","is_tbd":false},{"id":"providenciales-turks-and-caicos-islands","name":"Providenciales, Turks and Caicos Islands","country":"Turks and Caicos Islands","is_tbd":false},{"id":"isla-verde-pr-united-states","name":"Isla Verde, PR, United States","country":"United States","is_tbd":false},{"id":"north-miami-beach-fl-united-states","name":"North Miami Beach, FL, United States","country":"United States","is_tbd":false},{"id":"punto-fijo-venezuela","name":"Punto Fijo, Venezuela","country":"Venezuela","is_tbd":false}],"notes":null,"url":"https://libertynet.com"},"arimao":{"id":"arimao","name":"ARIMAO","length":"2,470 km","rfs":"2023","rfs_year":2023,"is_planned":false,"owners":"Empresa de Telecomunicaciones de Cuba, Orange","suppliers":null,"landing_points":[{"id":"cienfuegos-cuba","name":"Cienfuegos, Cuba","country":"Cuba","is_tbd":false},{"id":"schoelcher-martinique","name":"Schoelcher, Martinique","country":"Martinique","is_tbd":false}],"notes":null,"url":null},"arsat-submarine-fiber-optic-cable":{"id":"arsat-submarine-fiber-optic-cable","name":"ARSAT Submarine Fiber Optic Cable","length":"40 km","rfs":"2012 Q2","rfs_year":2012,"is_planned":false,"owners":"ARSAT","suppliers":null,"landing_points":[{"id":"cabo-espiritu-santo-argentina","name":"Cabo Espiritu Santo, Argentina","country":"Argentina","is_tbd":false},{"id":"punta-dungeness-argentina","name":"Punta Dungeness, Argentina","country":"Argentina","is_tbd":false}],"notes":null,"url":"http://www.arsat.com.ar/"},"asia-america-gateway-aag-cable-system":{"id":"asia-america-gateway-aag-cable-system","name":"Asia-America Gateway (AAG) Cable System","length":"20,000 km","rfs":"2009 November","rfs_year":2009,"is_planned":false,"owners":"AT&T, BT, Bharti Airtel, Eastern Telecom, Ezecom, Globe Telecom, Indosat Ooredoo, National Telecom, PLDT, Saigon Postel Corporation, Spark New Zealand, Starhub, Telekom Malaysia, Telkom Indonesia, Telstra, Unified National Networks (UNN), VNPT International, Viettel Corporation","suppliers":"ASN, NEC","landing_points":[{"id":"tungku-brunei","name":"Tungku, Brunei","country":"Brunei","is_tbd":false},{"id":"lantau-island-china","name":"Lantau Island, China","country":"China","is_tbd":false},{"id":"tanguisson-point-guam","name":"Tanguisson Point, Guam","country":"Guam","is_tbd":false},{"id":"mersing-malaysia","name":"Mersing, Malaysia","country":"Malaysia","is_tbd":false},{"id":"la-union-philippines","name":"La Union, Philippines","country":"Philippines","is_tbd":false},{"id":"changi-north-singapore","name":"Changi North, Singapore","country":"Singapore","is_tbd":false},{"id":"sriracha-thailand","name":"Sriracha, Thailand","country":"Thailand","is_tbd":false},{"id":"keawaula-hi-united-states","name":"Keawaula, HI, United States","country":"United States","is_tbd":false},{"id":"morro-bay-ca-united-states","name":"Morro Bay, CA, United States","country":"United States","is_tbd":false},{"id":"vung-tau-vietnam","name":"Vung Tau, Vietnam","country":"Vietnam","is_tbd":false}],"notes":null,"url":"https://asia-america-gateway.com/"},"asia-africa-europe-1-aae-1":{"id":"asia-africa-europe-1-aae-1","name":"Asia Africa Europe-1 (AAE-1)","length":"25,000 km","rfs":"2017 June","rfs_year":2017,"is_planned":false,"owners":"China Unicom, Djibouti Telecom, Hyalroute, Metfone, Mobily, National Telecom, OTEGLOBE, Ooredoo, PCCW, Pakistan Telecommunications Company Ltd., Reliance Jio Infocomm, Retelit, TIME dotCom, TeleYemen, Telecom Egypt, VNPT International, Viettel Corporation, Zain Omantel International, e&","suppliers":"NEC, SubCom","landing_points":[{"id":"sihanoukville-cambodia","name":"Sihanoukville, Cambodia","country":"Cambodia","is_tbd":false},{"id":"cape-daguilar-china","name":"Cape D’Aguilar, China","country":"China","is_tbd":false},{"id":"djibouti-city-djibouti","name":"Djibouti City, Djibouti","country":"Djibouti","is_tbd":false},{"id":"abu-talat-egypt","name":"Abu Talat, Egypt","country":"Egypt","is_tbd":false},{"id":"zafarana-egypt","name":"Zafarana, Egypt","country":"Egypt","is_tbd":false},{"id":"marseille-france","name":"Marseille, France","country":"France","is_tbd":false},{"id":"chania-greece","name":"Chania, Greece","country":"Greece","is_tbd":false},{"id":"mumbai-india","name":"Mumbai, India","country":"India","is_tbd":false},{"id":"bari-italy","name":"Bari, Italy","country":"Italy","is_tbd":false},{"id":"penang-malaysia","name":"Penang, Malaysia","country":"Malaysia","is_tbd":false},{"id":"ngwe-saung-myanmar","name":"Ngwe Saung, Myanmar","country":"Myanmar","is_tbd":false},{"id":"al-bustan-oman","name":"Al Bustan, Oman","country":"Oman","is_tbd":false},{"id":"karachi-pakistan","name":"Karachi, Pakistan","country":"Pakistan","is_tbd":false},{"id":"doha-qatar","name":"Doha, Qatar","country":"Qatar","is_tbd":false},{"id":"jeddah-saudi-arabia","name":"Jeddah, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"satun-thailand","name":"Satun, Thailand","country":"Thailand","is_tbd":false},{"id":"songkhla-thailand","name":"Songkhla, Thailand","country":"Thailand","is_tbd":false},{"id":"fujairah-united-arab-emirates","name":"Fujairah, United Arab Emirates","country":"United Arab Emirates","is_tbd":false},{"id":"vung-tau-vietnam","name":"Vung Tau, Vietnam","country":"Vietnam","is_tbd":false},{"id":"aden-yemen","name":"Aden, Yemen","country":"Yemen","is_tbd":false}],"notes":null,"url":"http://www.aaeone.com"},"asia-connect-cable-1-acc-1":{"id":"asia-connect-cable-1-acc-1","name":"Asia Connect Cable-1 (ACC-1)","length":"19,000 km","rfs":"2028 Q1","rfs_year":2028,"is_planned":true,"owners":"Inligo Networks","suppliers":null,"landing_points":[{"id":"darwin-nt-australia","name":"Darwin, NT, Australia","country":"Australia","is_tbd":false},{"id":"alupang-guam","name":"Alupang, Guam","country":"Guam","is_tbd":false},{"id":"batam-indonesia","name":"Batam, Indonesia","country":"Indonesia","is_tbd":false},{"id":"jakarta-indonesia","name":"Jakarta, Indonesia","country":"Indonesia","is_tbd":false},{"id":"makassar-indonesia","name":"Makassar, Indonesia","country":"Indonesia","is_tbd":false},{"id":"manado-indonesia","name":"Manado, Indonesia","country":"Indonesia","is_tbd":false},{"id":"davao-philippines","name":"Davao, Philippines","country":"Philippines","is_tbd":false},{"id":"singapore-singapore","name":"Singapore, Singapore","country":"Singapore","is_tbd":false},{"id":"dili-timor-leste","name":"Dili, Timor-Leste","country":"Timor-Leste","is_tbd":false},{"id":"hermosa-beach-ca-united-states","name":"Hermosa Beach, CA, United States","country":"United States","is_tbd":false}],"notes":null,"url":"http://www.inligonetworks.com/"},"asia-direct-cable-adc":{"id":"asia-direct-cable-adc","name":"Asia Direct Cable (ADC)","length":"9,988 km","rfs":"2024 December","rfs_year":2024,"is_planned":false,"owners":"China Telecom, China Unicom, National Telecom, PLDT, Singtel, Softbank, Tata Communications, Viettel Corporation","suppliers":"NEC","landing_points":[{"id":"chung-hom-kok-china","name":"Chung Hom Kok, China","country":"China","is_tbd":false},{"id":"shantou-china","name":"Shantou, China","country":"China","is_tbd":false},{"id":"maruyama-japan","name":"Maruyama, Japan","country":"Japan","is_tbd":false},{"id":"batangas-philippines","name":"Batangas, Philippines","country":"Philippines","is_tbd":false},{"id":"tuas-singapore","name":"Tuas, Singapore","country":"Singapore","is_tbd":false},{"id":"sriracha-thailand","name":"Sriracha, Thailand","country":"Thailand","is_tbd":false},{"id":"quy-nhon-vietnam","name":"Quy Nhon, Vietnam","country":"Vietnam","is_tbd":false}],"notes":null,"url":null},"asia-link-cable-alc":{"id":"asia-link-cable-alc","name":"Asia Link Cable (ALC)","length":"7,200 km","rfs":"2027 Q1","rfs_year":2027,"is_planned":true,"owners":"China Telecom, China Unicom, DITO Telecommunity, FPT Telecom, Globe Telecom, Matrix NAP Info, Singtel, TIME dotCom, Telekom Malaysia, Unified National Networks (UNN), VNPT, Viettel Corporation","suppliers":"HMN Tech","landing_points":[{"id":"tungku-brunei","name":"Tungku, Brunei","country":"Brunei","is_tbd":false},{"id":"chung-hom-kok-china","name":"Chung Hom Kok, China","country":"China","is_tbd":false},{"id":"lingshui-china","name":"Lingshui, China","country":"China","is_tbd":false},{"id":"kuala-sedili-malaysia","name":"Kuala Sedili, Malaysia","country":"Malaysia","is_tbd":false},{"id":"bauang-philippines","name":"Bauang, Philippines","country":"Philippines","is_tbd":false},{"id":"luna-philippines","name":"Luna, Philippines","country":"Philippines","is_tbd":false},{"id":"changi-singapore","name":"Changi, Singapore","country":"Singapore","is_tbd":false},{"id":"danang-vietnam","name":"Danang, Vietnam","country":"Vietnam","is_tbd":false}],"notes":null,"url":null},"asia-pacific-gateway-apg":{"id":"asia-pacific-gateway-apg","name":"Asia Pacific Gateway (APG)","length":"10,400 km","rfs":"2016 November","rfs_year":2016,"is_planned":false,"owners":"China Mobile, China Telecom, China Unicom, Chunghwa Telecom, KT, LG Uplus, Meta, NTT, National Telecom, Starhub, TIME dotCom, VNPT International, Viettel Corporation","suppliers":"NEC","landing_points":[{"id":"chongming-china","name":"Chongming, China","country":"China","is_tbd":false},{"id":"nanhui-china","name":"Nanhui, China","country":"China","is_tbd":false},{"id":"tseung-kwan-o-china","name":"Tseung Kwan O, China","country":"China","is_tbd":false},{"id":"maruyama-japan","name":"Maruyama, Japan","country":"Japan","is_tbd":false},{"id":"shima-japan","name":"Shima, Japan","country":"Japan","is_tbd":false},{"id":"cherating-malaysia","name":"Cherating, Malaysia","country":"Malaysia","is_tbd":false},{"id":"changi-south-singapore","name":"Changi South, Singapore","country":"Singapore","is_tbd":false},{"id":"busan-south-korea","name":"Busan, South Korea","country":"South Korea","is_tbd":false},{"id":"toucheng-taiwan","name":"Toucheng, Taiwan","country":"Taiwan","is_tbd":false},{"id":"songkhla-thailand","name":"Songkhla, Thailand","country":"Thailand","is_tbd":false},{"id":"danang-vietnam","name":"Danang, Vietnam","country":"Vietnam","is_tbd":false}],"notes":null,"url":null},"asia-submarine-cable-express-asecahaya-malaysia":{"id":"asia-submarine-cable-express-asecahaya-malaysia","name":"Asia Submarine-cable Express (ASE)/Cahaya Malaysia","length":"8,148 km","rfs":"2012 August","rfs_year":2012,"is_planned":false,"owners":"NTT, PLDT, Starhub, Telekom Malaysia","suppliers":"NEC","landing_points":[{"id":"tseung-kwan-o-china","name":"Tseung Kwan O, China","country":"China","is_tbd":false},{"id":"komesu-japan","name":"Komesu, Japan","country":"Japan","is_tbd":false},{"id":"maruyama-japan","name":"Maruyama, Japan","country":"Japan","is_tbd":false},{"id":"mersing-malaysia","name":"Mersing, Malaysia","country":"Malaysia","is_tbd":false},{"id":"daet-philippines","name":"Daet, Philippines","country":"Philippines","is_tbd":false},{"id":"changi-south-singapore","name":"Changi South, Singapore","country":"Singapore","is_tbd":false}],"notes":"Telekom Malaysia owns two fiber pairs, which it refers to as the Cahaya Malaysia system. NTT, PLDT, and Starhub jointly own the other four fiber pairs.","url":null},"asia-united-gateway-east-aug-east":{"id":"asia-united-gateway-east-aug-east","name":"Asia United Gateway East (AUG East)","length":"8,900 km","rfs":"2029 Q4","rfs_year":2029,"is_planned":true,"owners":"Amazon Web Services, Arteria, Chunghwa Telecom, Dreamline, Globe Telecom, Microsoft, Singtel, Telekom Malaysia, Unified National Networks (UNN)","suppliers":"NEC","landing_points":[{"id":"mumong-brunei","name":"Mumong, Brunei","country":"Brunei","is_tbd":false},{"id":"batam-indonesia","name":"Batam, Indonesia","country":"Indonesia","is_tbd":false},{"id":"wada-japan","name":"Wada, Japan","country":"Japan","is_tbd":false},{"id":"sedili-malaysia","name":"Sedili, Malaysia","country":"Malaysia","is_tbd":false},{"id":"luna-philippines","name":"Luna, Philippines","country":"Philippines","is_tbd":false},{"id":"changi-singapore","name":"Changi, Singapore","country":"Singapore","is_tbd":false},{"id":"gunsan-south-korea","name":"Gunsan, South Korea","country":"South Korea","is_tbd":false},{"id":"dawu-taiwan","name":"Dawu, Taiwan","country":"Taiwan","is_tbd":false},{"id":"wujie-taiwan","name":"Wujie, Taiwan","country":"Taiwan","is_tbd":false}],"notes":null,"url":null},"atlantic-crossing-1-ac-1":{"id":"atlantic-crossing-1-ac-1","name":"Atlantic Crossing-1 (AC-1)","length":"14,301 km","rfs":"1998 May","rfs_year":1998,"is_planned":false,"owners":"Colt","suppliers":"SubCom","landing_points":[{"id":"sylt-germany","name":"Sylt, Germany","country":"Germany","is_tbd":false},{"id":"beverwijk-netherlands","name":"Beverwijk, Netherlands","country":"Netherlands","is_tbd":false},{"id":"whitesands-bay-united-kingdom","name":"Whitesands Bay, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"brookhaven-ny-united-states","name":"Brookhaven, NY, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"atisa":{"id":"atisa","name":"Atisa","length":"279 km","rfs":"2017 June","rfs_year":2017,"is_planned":false,"owners":"Docomo Pacific","suppliers":"NEC","landing_points":[{"id":"piti-guam","name":"Piti, Guam","country":"Guam","is_tbd":false},{"id":"san-jose-northern-mariana-islands","name":"San Jose, Northern Mariana Islands","country":"Northern Mariana Islands","is_tbd":false},{"id":"sasanlagu-northern-mariana-islands","name":"Sasanlagu, Northern Mariana Islands","country":"Northern Mariana Islands","is_tbd":false},{"id":"sugar-dock-saipan-northern-mariana-islands","name":"Sugar Dock, Saipan, Northern Mariana Islands","country":"Northern Mariana Islands","is_tbd":false}],"notes":null,"url":"http://atisa.docomopacific.com"},"au-aleutian":{"id":"au-aleutian","name":"AU-Aleutian","length":"1,491 km","rfs":"2022 December","rfs_year":2022,"is_planned":false,"owners":"GCI Communication Corp","suppliers":null,"landing_points":[{"id":"akutan-ak-united-states","name":"Akutan, AK, United States","country":"United States","is_tbd":false},{"id":"chignik-bay-ak-united-states","name":"Chignik Bay, AK, United States","country":"United States","is_tbd":false},{"id":"chignik-lagoon-ak-united-states","name":"Chignik Lagoon, AK, United States","country":"United States","is_tbd":false},{"id":"chignik-lake-ak-united-states","name":"Chignik Lake, AK, United States","country":"United States","is_tbd":false},{"id":"cold-bay-ak-united-states","name":"Cold Bay, AK, United States","country":"United States","is_tbd":false},{"id":"false-pass-ak-united-states","name":"False Pass, AK, United States","country":"United States","is_tbd":false},{"id":"king-cove-ak-united-states","name":"King Cove, AK, United States","country":"United States","is_tbd":false},{"id":"kodiak-ak-united-states","name":"Kodiak, AK, United States","country":"United States","is_tbd":false},{"id":"larsen-bay-ak-united-states","name":"Larsen Bay, AK, United States","country":"United States","is_tbd":false},{"id":"ouzinkie-ak-united-states","name":"Ouzinkie, AK, United States","country":"United States","is_tbd":false},{"id":"perryville-ak-united-states","name":"Perryville, AK, United States","country":"United States","is_tbd":false},{"id":"port-lions-ak-united-states","name":"Port Lions, AK, United States","country":"United States","is_tbd":false},{"id":"sand-point-ak-united-states","name":"Sand Point, AK, United States","country":"United States","is_tbd":false},{"id":"unalaska-ak-united-states","name":"Unalaska, AK, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.gci.com/"},"atlas-offshore":{"id":"atlas-offshore","name":"Atlas Offshore","length":"1,634 km","rfs":"2007 July","rfs_year":2007,"is_planned":false,"owners":"Maroc Telecom","suppliers":"ASN","landing_points":[{"id":"marseille-france","name":"Marseille, France","country":"France","is_tbd":false},{"id":"asilah-morocco","name":"Asilah, Morocco","country":"Morocco","is_tbd":false}],"notes":null,"url":"https://www.iam.ma"},"australia-japan-cable-ajc":{"id":"australia-japan-cable-ajc","name":"Australia-Japan Cable (AJC)","length":"12,700 km","rfs":"2001 December","rfs_year":2001,"is_planned":false,"owners":"AT&T, NTT, Softbank, Telstra, Verizon","suppliers":"SubCom","landing_points":[{"id":"oxford-falls-nsw-australia","name":"Oxford Falls, NSW, Australia","country":"Australia","is_tbd":false},{"id":"paddington-nsw-australia","name":"Paddington, NSW, Australia","country":"Australia","is_tbd":false},{"id":"tanguisson-point-guam","name":"Tanguisson Point, Guam","country":"Guam","is_tbd":false},{"id":"tumon-bay-guam","name":"Tumon Bay, Guam","country":"Guam","is_tbd":false},{"id":"maruyama-japan","name":"Maruyama, Japan","country":"Japan","is_tbd":false},{"id":"shima-japan","name":"Shima, Japan","country":"Japan","is_tbd":false}],"notes":null,"url":"https://ajcable.com/"},"aurora":{"id":"aurora","name":"Aurora","length":"500 km","rfs":"2024 Q1","rfs_year":2024,"is_planned":false,"owners":"GlobalConnect","suppliers":"Hexatronic","landing_points":[{"id":"brondby-denmark","name":"Brondby, Denmark","country":"Denmark","is_tbd":false},{"id":"hasle-denmark","name":"Hasle, Denmark","country":"Denmark","is_tbd":false},{"id":"rnne-denmark","name":"Rønne, Denmark","country":"Denmark","is_tbd":false},{"id":"tejn-denmark","name":"Tejn, Denmark","country":"Denmark","is_tbd":false},{"id":"sassnitz-germany","name":"Sassnitz, Germany","country":"Germany","is_tbd":false},{"id":"borbby-strandbad-sweden","name":"Borbby Strandbad, Sweden","country":"Sweden","is_tbd":false},{"id":"byxelkrok-sweden","name":"Byxelkrok, Sweden","country":"Sweden","is_tbd":false},{"id":"farosund-sweden","name":"Farosund, Sweden","country":"Sweden","is_tbd":false},{"id":"klagshamn-sweden","name":"Klagshamn, Sweden","country":"Sweden","is_tbd":false},{"id":"nsby-sweden","name":"Näsby, Sweden","country":"Sweden","is_tbd":false},{"id":"uto-sweden","name":"Uto, Sweden","country":"Sweden","is_tbd":false},{"id":"visby-sweden","name":"Visby, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":"https://www.globalconnectcarrier.com/"},"avassa":{"id":"avassa","name":"Avassa","length":"260 km","rfs":"2016 November","rfs_year":2016,"is_planned":false,"owners":"Comores Telecom, STOI","suppliers":"HMN Tech","landing_points":[{"id":"chindini-comoros","name":"Chindini, Comoros","country":"Comoros","is_tbd":false},{"id":"moroni-comoros","name":"Moroni, Comoros","country":"Comoros","is_tbd":false},{"id":"mutsamudu-comoros","name":"Mutsamudu, Comoros","country":"Comoros","is_tbd":false},{"id":"mamoudzou-mayotte","name":"Mamoudzou, Mayotte","country":"Mayotte","is_tbd":false}],"notes":null,"url":null},"australia-singapore-cable-asc":{"id":"australia-singapore-cable-asc","name":"Australia-Singapore Cable (ASC)","length":"4,600 km","rfs":"2018 September","rfs_year":2018,"is_planned":false,"owners":"Vocus Communications","suppliers":"ASN","landing_points":[{"id":"perth-wa-australia","name":"Perth, WA, Australia","country":"Australia","is_tbd":false},{"id":"flying-fish-cove-christmas-island","name":"Flying Fish Cove, Christmas Island","country":"Christmas Island","is_tbd":false},{"id":"anyer-indonesia","name":"Anyer, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tanah-merah-singapore","name":"Tanah Merah, Singapore","country":"Singapore","is_tbd":false}],"notes":null,"url":"https://www.australiasingaporecable.com"},"awashima-murakami":{"id":"awashima-murakami","name":"Awashima-Murakami","length":"66 km","rfs":"2022","rfs_year":2022,"is_planned":false,"owners":"Awashimaura Village","suppliers":null,"landing_points":[{"id":"awashimaura-japan","name":"Awashimaura, Japan","country":"Japan","is_tbd":false},{"id":"murakami-japan","name":"Murakami, Japan","country":"Japan","is_tbd":false}],"notes":null,"url":null},"azores-fiber-optic-system-afos":{"id":"azores-fiber-optic-system-afos","name":"Azores Fiber Optic System (AFOS)","length":"1,100 km","rfs":"1998 July","rfs_year":1998,"is_planned":false,"owners":"Altice Portugal","suppliers":"Norddeutsche Seekabelwerke GmbH (NSW)","landing_points":[{"id":"angra-do-heroismo-portugal","name":"Angra do Heroismo, Portugal","country":"Portugal","is_tbd":false},{"id":"faial-portugal","name":"Faial, Portugal","country":"Portugal","is_tbd":false},{"id":"graciosa-portugal","name":"Graciosa, Portugal","country":"Portugal","is_tbd":false},{"id":"ponta-delgada-portugal","name":"Ponta Delgada, Portugal","country":"Portugal","is_tbd":false},{"id":"sao-mateus-portugal","name":"Sao Mateus, Portugal","country":"Portugal","is_tbd":false},{"id":"velas-portugal","name":"Velas, Portugal","country":"Portugal","is_tbd":false},{"id":"vila-do-porto-portugal","name":"Vila do Porto, Portugal","country":"Portugal","is_tbd":false}],"notes":null,"url":null},"bahamas-2":{"id":"bahamas-2","name":"Bahamas 2","length":"476 km","rfs":"1997 June","rfs_year":1997,"is_planned":false,"owners":"Bermuda Telephone Company (BTC)","suppliers":"SubCom","landing_points":[{"id":"eight-mile-rock-bahamas","name":"Eight-Mile Rock, Bahamas","country":"Bahamas","is_tbd":false},{"id":"nassau-bahamas","name":"Nassau, Bahamas","country":"Bahamas","is_tbd":false},{"id":"vero-beach-fl-united-states","name":"Vero Beach, FL, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"bahamas-domestic-submarine-network-bdsni":{"id":"bahamas-domestic-submarine-network-bdsni","name":"Bahamas Domestic Submarine Network (BDSNi)","length":"2,735 km","rfs":"2006","rfs_year":2006,"is_planned":false,"owners":"Bahamas Telecommunications Company","suppliers":"SubCom","landing_points":[{"id":"cat-island-bahamas","name":"Cat Island, Bahamas","country":"Bahamas","is_tbd":false},{"id":"clarence-town-bahamas","name":"Clarence Town, Bahamas","country":"Bahamas","is_tbd":false},{"id":"cockburn-town-bahamas","name":"Cockburn Town, Bahamas","country":"Bahamas","is_tbd":false},{"id":"duncan-town-bahamas","name":"Duncan Town, Bahamas","country":"Bahamas","is_tbd":false},{"id":"fresh-creek-bahamas","name":"Fresh Creek, Bahamas","country":"Bahamas","is_tbd":false},{"id":"george-town-bahamas","name":"George Town, Bahamas","country":"Bahamas","is_tbd":false},{"id":"governors-harbor-bahamas","name":"Governors Harbor, Bahamas","country":"Bahamas","is_tbd":false},{"id":"hawksbill-bahamas","name":"Hawksbill, Bahamas","country":"Bahamas","is_tbd":false},{"id":"matthew-town-bahamas","name":"Matthew Town, Bahamas","country":"Bahamas","is_tbd":false},{"id":"mayaguana-bahamas","name":"Mayaguana, Bahamas","country":"Bahamas","is_tbd":false},{"id":"nassau-bahamas","name":"Nassau, Bahamas","country":"Bahamas","is_tbd":false},{"id":"port-nelson-bahamas","name":"Port Nelson, Bahamas","country":"Bahamas","is_tbd":false},{"id":"rock-sound-bahamas","name":"Rock Sound, Bahamas","country":"Bahamas","is_tbd":false},{"id":"sandy-point-bahamas","name":"Sandy Point, Bahamas","country":"Bahamas","is_tbd":false},{"id":"port-au-prince-haiti","name":"Port-au-Prince, Haiti","country":"Haiti","is_tbd":false}],"notes":null,"url":"http://www.btcbahamas.com"},"balalink":{"id":"balalink","name":"Balalink","length":"274 km","rfs":"2001 October","rfs_year":2001,"is_planned":false,"owners":"IslaLink","suppliers":null,"landing_points":[{"id":"palma-spain","name":"Palma, Spain","country":"Spain","is_tbd":false},{"id":"valencia-spain","name":"Valencia, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":"http://www.islalink.es"},"bahamas-internet-cable-system-bics":{"id":"bahamas-internet-cable-system-bics","name":"Bahamas Internet Cable System (BICS)","length":"1,100 km","rfs":"2001 July","rfs_year":2001,"is_planned":false,"owners":"Caribbean Crossings","suppliers":null,"landing_points":[{"id":"caves-point-bahamas","name":"Caves Point, Bahamas","country":"Bahamas","is_tbd":false},{"id":"crown-haven-bahamas","name":"Crown Haven, Bahamas","country":"Bahamas","is_tbd":false},{"id":"current-bahamas","name":"Current, Bahamas","country":"Bahamas","is_tbd":false},{"id":"hawksbill-bahamas","name":"Hawksbill, Bahamas","country":"Bahamas","is_tbd":false},{"id":"riding-point-bahamas","name":"Riding Point, Bahamas","country":"Bahamas","is_tbd":false},{"id":"sandy-point-bahamas","name":"Sandy Point, Bahamas","country":"Bahamas","is_tbd":false},{"id":"boca-raton-fl-united-states","name":"Boca Raton, FL, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.rev.bs/business-solutions/"},"baltic-sea-submarine-cable":{"id":"baltic-sea-submarine-cable","name":"Baltic Sea Submarine Cable","length":"1,042 km","rfs":"2000","rfs_year":2000,"is_planned":false,"owners":"CITIC Telecom International","suppliers":"ASN","landing_points":[{"id":"tallinn-estonia","name":"Tallinn, Estonia","country":"Estonia","is_tbd":false},{"id":"helsinki-finland","name":"Helsinki, Finland","country":"Finland","is_tbd":false},{"id":"stockholm-sweden","name":"Stockholm, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":"https://www.citictel.com/"},"balok":{"id":"balok","name":"BALOK","length":"60 km","rfs":"2016 February","rfs_year":2016,"is_planned":false,"owners":"XLSmart","suppliers":null,"landing_points":[{"id":"senggigi-indonesia","name":"Senggigi, Indonesia","country":"Indonesia","is_tbd":false},{"id":"seraya-indonesia","name":"Seraya, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":null},"bangladesh-private-cable-system-bpcs":{"id":"bangladesh-private-cable-system-bpcs","name":"Bangladesh Private Cable System (BPCS)","length":"1,300 km","rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"CdNet Communications, Metacore Subcom, Summit Communications","suppliers":null,"landing_points":[{"id":"coxs-bazar-bangladesh","name":"Cox’s Bazar, Bangladesh","country":"Bangladesh","is_tbd":false}],"notes":"The Bangladesh Private Cable System (BPCS) will link Bangladesh to a branching unit on the UMO cable.","url":null},"baltica":{"id":"baltica","name":"Baltica","length":"437 km","rfs":"1997 March","rfs_year":1997,"is_planned":false,"owners":"Arelion, Orange Polska, Slovak Telekom, TDC Group, Telenor, Ukrtelecom","suppliers":null,"landing_points":[{"id":"gedser-denmark","name":"Gedser, Denmark","country":"Denmark","is_tbd":false},{"id":"pedersker-denmark","name":"Pedersker, Denmark","country":"Denmark","is_tbd":false},{"id":"koobrzeg-poland","name":"Kołobrzeg, Poland","country":"Poland","is_tbd":false},{"id":"ystad-sweden","name":"Ystad, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":null},"barat-timur-indonesia-1-bti-1":{"id":"barat-timur-indonesia-1-bti-1","name":"Barat Timur Indonesia-1 (BTI-1)","length":"4,500 km","rfs":"2028 Q1","rfs_year":2028,"is_planned":true,"owners":"Super Sistem (PT Super Sistem Data)","suppliers":null,"landing_points":[{"id":"balikpapan-indonesia","name":"Balikpapan, Indonesia","country":"Indonesia","is_tbd":false},{"id":"gresik-indonesia","name":"Gresik, Indonesia","country":"Indonesia","is_tbd":false},{"id":"makassar-indonesia","name":"Makassar, Indonesia","country":"Indonesia","is_tbd":false},{"id":"manado-indonesia","name":"Manado, Indonesia","country":"Indonesia","is_tbd":false},{"id":"nongsa-indonesia","name":"Nongsa, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tanjung-pakis-indonesia","name":"Tanjung Pakis, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":null},"barat-timur-indonesia-2-bti-2":{"id":"barat-timur-indonesia-2-bti-2","name":"Barat Timur Indonesia-2 (BTI-2)","length":"11,600 km","rfs":null,"rfs_year":null,"is_planned":true,"owners":"Super Sistem (PT Super Sistem Data)","suppliers":null,"landing_points":[{"id":"banjarmasin-indonesia","name":"Banjarmasin, Indonesia","country":"Indonesia","is_tbd":false},{"id":"batam-indonesia","name":"Batam, Indonesia","country":"Indonesia","is_tbd":false},{"id":"jeneponto-indonesia","name":"Jeneponto, Indonesia","country":"Indonesia","is_tbd":false},{"id":"kendari-indonesia","name":"Kendari, Indonesia","country":"Indonesia","is_tbd":false},{"id":"luwuk-indonesia","name":"Luwuk, Indonesia","country":"Indonesia","is_tbd":false},{"id":"manado-indonesia","name":"Manado, Indonesia","country":"Indonesia","is_tbd":false},{"id":"pantai-mutiara-indonesia","name":"Pantai Mutiara, Indonesia","country":"Indonesia","is_tbd":false},{"id":"pontianak-indonesia","name":"Pontianak, Indonesia","country":"Indonesia","is_tbd":false},{"id":"semarang-indonesia","name":"Semarang, Indonesia","country":"Indonesia","is_tbd":false},{"id":"singaraja-indonesia","name":"Singaraja, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":null},"bass-strait-1":{"id":"bass-strait-1","name":"Bass Strait-1","length":"241 km","rfs":"1995","rfs_year":1995,"is_planned":false,"owners":"Telstra","suppliers":"ASN","landing_points":[{"id":"boat-harbour-tas-australia","name":"Boat Harbour, TAS, Australia","country":"Australia","is_tbd":false},{"id":"sandy-point-vic-australia","name":"Sandy Point, VIC, Australia","country":"Australia","is_tbd":false}],"notes":null,"url":null},"barracuda":{"id":"barracuda","name":"Barracuda","length":"1,050 km","rfs":"2028 Q2","rfs_year":2028,"is_planned":false,"owners":"","suppliers":null,"landing_points":[{"id":"genoa-italy","name":"Genoa, Italy","country":"Italy","is_tbd":false},{"id":"sagunto-spain","name":"Sagunto, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":"https://valenciadigitalport.com/barracuda-infrastructure/"},"bass-strait-2":{"id":"bass-strait-2","name":"Bass Strait-2","length":"239 km","rfs":"2003","rfs_year":2003,"is_planned":false,"owners":"Telstra","suppliers":"ASN","landing_points":[{"id":"inverloch-vic-australia","name":"Inverloch, VIC, Australia","country":"Australia","is_tbd":false},{"id":"stanley-tas-australia","name":"Stanley, TAS, Australia","country":"Australia","is_tbd":false}],"notes":null,"url":null},"basslink":{"id":"basslink","name":"Basslink","length":"298 km","rfs":"2005 November","rfs_year":2005,"is_planned":false,"owners":"Basslink Telecoms","suppliers":null,"landing_points":[{"id":"four-mile-bluff-tas-australia","name":"Four Mile Bluff, TAS, Australia","country":"Australia","is_tbd":false},{"id":"mcgaurans-beach-vic-australia","name":"McGaurans Beach, VIC, Australia","country":"Australia","is_tbd":false}],"notes":null,"url":null},"batam-rengit-cable-system-brcs":{"id":"batam-rengit-cable-system-brcs","name":"Batam-Rengit Cable System (BRCS)","length":"64 km","rfs":"2007 October","rfs_year":2007,"is_planned":false,"owners":"XLSmart","suppliers":null,"landing_points":[{"id":"tanjung-pinggir-indonesia","name":"Tanjung Pinggir, Indonesia","country":"Indonesia","is_tbd":false},{"id":"rengit-malaysia","name":"Rengit, Malaysia","country":"Malaysia","is_tbd":false}],"notes":null,"url":"https://www.xl.co.id/"},"batam-dumai-melaka-bdm":{"id":"batam-dumai-melaka-bdm","name":"Batam Dumai Melaka (BDM)","length":"353 km","rfs":"2009 November","rfs_year":2009,"is_planned":false,"owners":"Moratelindo, Telekom Malaysia","suppliers":"HMN Tech","landing_points":[{"id":"batam-indonesia","name":"Batam, Indonesia","country":"Indonesia","is_tbd":false},{"id":"dumai-indonesia","name":"Dumai, Indonesia","country":"Indonesia","is_tbd":false},{"id":"melaka-malaysia","name":"Melaka, Malaysia","country":"Malaysia","is_tbd":false}],"notes":null,"url":"http://www.moratelindo.co.id"},"batam-singapore-cable-system-bscs":{"id":"batam-singapore-cable-system-bscs","name":"Batam Singapore Cable System (BSCS)","length":"73 km","rfs":"2009","rfs_year":2009,"is_planned":false,"owners":"Telkom Indonesia","suppliers":"NEC","landing_points":[{"id":"batam-indonesia","name":"Batam, Indonesia","country":"Indonesia","is_tbd":false},{"id":"changi-north-singapore","name":"Changi North, Singapore","country":"Singapore","is_tbd":false}],"notes":null,"url":null},"batam-sarawak-internet-cable-system-basics":{"id":"batam-sarawak-internet-cable-system-basics","name":"Batam Sarawak Internet Cable System (BaSICS)","length":"762 km","rfs":"2021 Q1","rfs_year":2021,"is_planned":false,"owners":"Irix Sdn Bhd","suppliers":null,"landing_points":[{"id":"batam-indonesia","name":"Batam, Indonesia","country":"Indonesia","is_tbd":false},{"id":"kuching-malaysia","name":"Kuching, Malaysia","country":"Malaysia","is_tbd":false}],"notes":null,"url":"https://www.irix.my/"},"bcs-east":{"id":"bcs-east","name":"BCS East","length":"97 km","rfs":"1995","rfs_year":1995,"is_planned":false,"owners":"Arelion","suppliers":"Ericsson","landing_points":[{"id":"liepaja-latvia","name":"Liepaja, Latvia","country":"Latvia","is_tbd":false},{"id":"sventoji-lithuania","name":"Sventoji, Lithuania","country":"Lithuania","is_tbd":false}],"notes":null,"url":null},"bay-of-bengal-gateway-bbg":{"id":"bay-of-bengal-gateway-bbg","name":"Bay of Bengal Gateway (BBG)","length":"8,100 km","rfs":"2016 Q1","rfs_year":2016,"is_planned":false,"owners":"AT&T, China Telecom, Dialog Axiata, Reliance Jio Infocomm, Telekom Malaysia, Telstra, Vodafone, Zain Omantel International, e&","suppliers":"ASN","landing_points":[{"id":"chennai-india","name":"Chennai, India","country":"India","is_tbd":false},{"id":"mumbai-india","name":"Mumbai, India","country":"India","is_tbd":false},{"id":"penang-malaysia","name":"Penang, Malaysia","country":"Malaysia","is_tbd":false},{"id":"barka-oman","name":"Barka, Oman","country":"Oman","is_tbd":false},{"id":"ratmalana-sri-lanka","name":"Ratmalana, Sri Lanka","country":"Sri Lanka","is_tbd":false},{"id":"fujairah-united-arab-emirates","name":"Fujairah, United Arab Emirates","country":"United Arab Emirates","is_tbd":false}],"notes":null,"url":"https://www.bayofbengalgateway.com"},"bcs-north---phase-1":{"id":"bcs-north---phase-1","name":"BCS North - Phase 1","length":"513 km","rfs":"1998","rfs_year":1998,"is_planned":false,"owners":"Arelion","suppliers":"Ericsson","landing_points":[{"id":"hanko-finland","name":"Hanko, Finland","country":"Finland","is_tbd":false},{"id":"haradsholm-finland","name":"Haradsholm, Finland","country":"Finland","is_tbd":false},{"id":"helsinki-finland","name":"Helsinki, Finland","country":"Finland","is_tbd":false},{"id":"mariehamn-finland","name":"Mariehamn, Finland","country":"Finland","is_tbd":false},{"id":"stavsnas-sweden","name":"Stavsnas, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":null},"bcs-east-west-interlink":{"id":"bcs-east-west-interlink","name":"BCS East-West Interlink","length":"218 km","rfs":"1997 November","rfs_year":1997,"is_planned":false,"owners":"Arelion","suppliers":"Ericsson","landing_points":[{"id":"sventoji-lithuania","name":"Sventoji, Lithuania","country":"Lithuania","is_tbd":false},{"id":"katthammarsvik-sweden","name":"Katthammarsvik, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":null},"bcs-north---phase-2":{"id":"bcs-north---phase-2","name":"BCS North - Phase 2","length":"280 km","rfs":"2000","rfs_year":2000,"is_planned":false,"owners":"Arelion","suppliers":"Ericsson","landing_points":[{"id":"helsinki-finland","name":"Helsinki, Finland","country":"Finland","is_tbd":false},{"id":"kotka-finland","name":"Kotka, Finland","country":"Finland","is_tbd":false},{"id":"logi-russia","name":"Logi, Russia","country":"Russia","is_tbd":false}],"notes":null,"url":null},"beaufort":{"id":"beaufort","name":"Beaufort","length":"38 km","rfs":"2027","rfs_year":2027,"is_planned":true,"owners":"Amazon Web Services, Vodafone","suppliers":null,"landing_points":[{"id":"kilmore-quay-ireland","name":"Kilmore Quay, Ireland","country":"Ireland","is_tbd":false},{"id":"bude-united-kingdom","name":"Bude, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"port-eynon-united-kingdom","name":"Port Eynon, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"berytar":{"id":"berytar","name":"BERYTAR","length":"134 km","rfs":"1997 April","rfs_year":1997,"is_planned":false,"owners":"Lebanese Ministry of Telecommunications, Syrian Telecommunications Establishment","suppliers":"ASN","landing_points":[{"id":"beirut-lebanon","name":"Beirut, Lebanon","country":"Lebanon","is_tbd":false},{"id":"saida-lebanon","name":"Saida, Lebanon","country":"Lebanon","is_tbd":false},{"id":"tripoli-lebanon","name":"Tripoli, Lebanon","country":"Lebanon","is_tbd":false},{"id":"tartous-syria","name":"Tartous, Syria","country":"Syria","is_tbd":false}],"notes":null,"url":null},"besut-perhentian-islands":{"id":"besut-perhentian-islands","name":"Besut-Perhentian Islands","length":"21 km","rfs":"2019","rfs_year":2019,"is_planned":false,"owners":"Telekom Malaysia","suppliers":null,"landing_points":[{"id":"kampung-raja-malaysia","name":"Kampung Raja, Malaysia","country":"Malaysia","is_tbd":false},{"id":"pulau-perhentian-besar-malaysia","name":"Pulau Perhentian Besar, Malaysia","country":"Malaysia","is_tbd":false},{"id":"pulau-perhentian-kecil-malaysia","name":"Pulau Perhentian Kecil, Malaysia","country":"Malaysia","is_tbd":false}],"notes":null,"url":null},"bicentenario":{"id":"bicentenario","name":"Bicentenario","length":"250 km","rfs":"2011 December","rfs_year":2011,"is_planned":false,"owners":"Antel Uruguay, Telecom Argentina","suppliers":"ASN","landing_points":[{"id":"las-toninas-argentina","name":"Las Toninas, Argentina","country":"Argentina","is_tbd":false},{"id":"maldonado-uruguay","name":"Maldonado, Uruguay","country":"Uruguay","is_tbd":false}],"notes":null,"url":null},"bharat-lanka-cable-system":{"id":"bharat-lanka-cable-system","name":"Bharat Lanka Cable System","length":"325 km","rfs":"2006 June","rfs_year":2006,"is_planned":false,"owners":"Bharat Sanchar Nigam Ltd. (BSNL), Sri Lanka Telecom","suppliers":"NEC","landing_points":[{"id":"tuticorine-india","name":"Tuticorine, India","country":"India","is_tbd":false},{"id":"mt-lavinia-sri-lanka","name":"Mt. Lavinia, Sri Lanka","country":"Sri Lanka","is_tbd":false}],"notes":null,"url":null},"biznet-nusantara-cable-system-1-bncs-1":{"id":"biznet-nusantara-cable-system-1-bncs-1","name":"Biznet Nusantara Cable System-1 (BNCS-1)","length":"105 km","rfs":"2024 Q1","rfs_year":2024,"is_planned":false,"owners":"Biznet","suppliers":"CCSI","landing_points":[{"id":"anyer-indonesia","name":"Anyer, Indonesia","country":"Indonesia","is_tbd":false},{"id":"kalianda-indonesia","name":"Kalianda, Indonesia","country":"Indonesia","is_tbd":false},{"id":"muntok-indonesia","name":"Muntok, Indonesia","country":"Indonesia","is_tbd":false},{"id":"sungsang-indonesia","name":"Sungsang, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"https://www.biznetnetworks.com/"},"bifrost":{"id":"bifrost","name":"Bifrost","length":"19,888 km","rfs":"2025 December","rfs_year":2025,"is_planned":false,"owners":"Keppel T&T, Meta, Telin","suppliers":"ASN","landing_points":[{"id":"alupang-guam","name":"Alupang, Guam","country":"Guam","is_tbd":false},{"id":"jakarta-indonesia","name":"Jakarta, Indonesia","country":"Indonesia","is_tbd":false},{"id":"manado-indonesia","name":"Manado, Indonesia","country":"Indonesia","is_tbd":false},{"id":"rosarito-mexico","name":"Rosarito, Mexico","country":"Mexico","is_tbd":false},{"id":"davao-philippines","name":"Davao, Philippines","country":"Philippines","is_tbd":false},{"id":"tuas-singapore","name":"Tuas, Singapore","country":"Singapore","is_tbd":false},{"id":"grover-beach-ca-united-states","name":"Grover Beach, CA, United States","country":"United States","is_tbd":false},{"id":"winema-or-united-states","name":"Winema, OR, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"bodo-rost-cable":{"id":"bodo-rost-cable","name":"Bodo-Rost Cable","length":"109 km","rfs":"2016","rfs_year":2016,"is_planned":false,"owners":"Telenor","suppliers":"Nexans","landing_points":[{"id":"bod-norway","name":"Bodø, Norway","country":"Norway","is_tbd":false},{"id":"rst-norway","name":"Røst, Norway","country":"Norway","is_tbd":false}],"notes":null,"url":null},"blue":{"id":"blue","name":"Blue","length":"5,055 km","rfs":"2023","rfs_year":2023,"is_planned":false,"owners":"Google, Sparkle, Zain Omantel International","suppliers":"ASN","landing_points":[{"id":"yeroskipos-cyprus","name":"Yeroskipos, Cyprus","country":"Cyprus","is_tbd":false},{"id":"bastia-france","name":"Bastia, France","country":"France","is_tbd":false},{"id":"marseille-france","name":"Marseille, France","country":"France","is_tbd":false},{"id":"chania-greece","name":"Chania, Greece","country":"Greece","is_tbd":false},{"id":"tel-aviv-israel","name":"Tel Aviv, Israel","country":"Israel","is_tbd":false},{"id":"genoa-italy","name":"Genoa, Italy","country":"Italy","is_tbd":false},{"id":"golfo-aranci-italy","name":"Golfo Aranci, Italy","country":"Italy","is_tbd":false},{"id":"palermo-italy","name":"Palermo, Italy","country":"Italy","is_tbd":false},{"id":"rome-italy","name":"Rome, Italy","country":"Italy","is_tbd":false},{"id":"aqaba-jordan","name":"Aqaba, Jordan","country":"Jordan","is_tbd":false}],"notes":null,"url":null},"bosun":{"id":"bosun","name":"Bosun","length":null,"rfs":"2027","rfs_year":2027,"is_planned":true,"owners":"Google","suppliers":"SubCom","landing_points":[{"id":"darwin-nt-australia","name":"Darwin, NT, Australia","country":"Australia","is_tbd":false},{"id":"flying-fish-cove-christmas-island","name":"Flying Fish Cove, Christmas Island","country":"Christmas Island","is_tbd":false}],"notes":null,"url":"https://cloud.google.com"},"boracay-palawan-submarine-cable-system-bpscs":{"id":"boracay-palawan-submarine-cable-system-bpscs","name":"Boracay-Palawan Submarine Cable System (BPSCS)","length":"332 km","rfs":"2013 June","rfs_year":2013,"is_planned":false,"owners":"Globe Telecom","suppliers":"HMN Tech","landing_points":[{"id":"boracay-philippines","name":"Boracay, Philippines","country":"Philippines","is_tbd":false},{"id":"caticlan-philippines","name":"Caticlan, Philippines","country":"Philippines","is_tbd":false},{"id":"coron-philippines","name":"Coron, Philippines","country":"Philippines","is_tbd":false},{"id":"san-jose-philippines","name":"San Jose, Philippines","country":"Philippines","is_tbd":false},{"id":"taytay-philippines","name":"Taytay, Philippines","country":"Philippines","is_tbd":false}],"notes":null,"url":null},"brazilian-festoon":{"id":"brazilian-festoon","name":"Brazilian Festoon","length":"2,552 km","rfs":"1996","rfs_year":1996,"is_planned":false,"owners":"Embratel","suppliers":null,"landing_points":[{"id":"aracaj-brazil","name":"Aracajú, Brazil","country":"Brazil","is_tbd":false},{"id":"atafona-brazil","name":"Atafona, Brazil","country":"Brazil","is_tbd":false},{"id":"ilhus-brazil","name":"Ilhéus, Brazil","country":"Brazil","is_tbd":false},{"id":"joo-pessoa-brazil","name":"João Pessoa, Brazil","country":"Brazil","is_tbd":false},{"id":"maca-brazil","name":"Macaé, Brazil","country":"Brazil","is_tbd":false},{"id":"macei-brazil","name":"Maceió, Brazil","country":"Brazil","is_tbd":false},{"id":"natal-brazil","name":"Natal, Brazil","country":"Brazil","is_tbd":false},{"id":"porto-seguro-brazil","name":"Porto Seguro, Brazil","country":"Brazil","is_tbd":false},{"id":"recife-brazil","name":"Recife, Brazil","country":"Brazil","is_tbd":false},{"id":"rio-de-janeiro-brazil","name":"Rio de Janeiro, Brazil","country":"Brazil","is_tbd":false},{"id":"salvador-brazil","name":"Salvador, Brazil","country":"Brazil","is_tbd":false},{"id":"sitio-brazil","name":"Sitio, Brazil","country":"Brazil","is_tbd":false},{"id":"so-mateus-brazil","name":"São Mateus, Brazil","country":"Brazil","is_tbd":false},{"id":"vitria-brazil","name":"Vitória, Brazil","country":"Brazil","is_tbd":false}],"notes":null,"url":"http://www.embratel.com.br"},"botnia":{"id":"botnia","name":"Botnia","length":"93 km","rfs":"1994","rfs_year":1994,"is_planned":false,"owners":"Arelion","suppliers":"ASN","landing_points":[{"id":"vaasa-finland","name":"Vaasa, Finland","country":"Finland","is_tbd":false},{"id":"ume-sweden","name":"Umeå, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":null},"brusa":{"id":"brusa","name":"BRUSA","length":"11,000 km","rfs":"2018 August","rfs_year":2018,"is_planned":false,"owners":"Telxius","suppliers":"ASN","landing_points":[{"id":"fortaleza-brazil","name":"Fortaleza, Brazil","country":"Brazil","is_tbd":false},{"id":"rio-de-janeiro-brazil","name":"Rio de Janeiro, Brazil","country":"Brazil","is_tbd":false},{"id":"san-juan-pr-united-states","name":"San Juan, PR, United States","country":"United States","is_tbd":false},{"id":"virginia-beach-va-united-states","name":"Virginia Beach, VA, United States","country":"United States","is_tbd":false}],"notes":null,"url":"http://www.telxius.com"},"bt-highlands-and-islands-submarine-cable-system":{"id":"bt-highlands-and-islands-submarine-cable-system","name":"BT Highlands and Islands Submarine Cable System","length":"402 km","rfs":"2014 December","rfs_year":2014,"is_planned":false,"owners":"BT","suppliers":null,"landing_points":[{"id":"achnaba-united-kingdom","name":"Achnaba, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"aikerness-bay-united-kingdom","name":"Aikerness Bay, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"ardbeg-point-united-kingdom","name":"Ardbeg Point, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"ardgour-united-kingdom","name":"Ardgour, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"ardmair-united-kingdom","name":"Ardmair, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"ardnacross-united-kingdom","name":"Ardnacross, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"ardneil-bay-united-kingdom","name":"Ardneil Bay, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"ardvasar-united-kingdom","name":"Ardvasar, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"ardyne-point-united-kingdom","name":"Ardyne Point, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"balla-united-kingdom","name":"Balla, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"bay-of-tuquoy-united-kingdom","name":"Bay of Tuquoy, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"blackwaterfoot-united-kingdom","name":"Blackwaterfoot, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"branahuie-bay-united-kingdom","name":"Branahuie Bay, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"calgary-united-kingdom","name":"Calgary, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"coilleag-united-kingdom","name":"Coilleag, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"corran-united-kingdom","name":"Corran, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"corrie-united-kingdom","name":"Corrie, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"craighouse-united-kingdom","name":"Craighouse, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"down-craig-united-kingdom","name":"Down Craig, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"duart-bay-united-kingdom","name":"Duart Bay, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"dunvegan-united-kingdom","name":"Dunvegan, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"feolin-ferry-united-kingdom","name":"Feolin Ferry, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"ganavan-bay-united-kingdom","name":"Ganavan Bay, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"glenbarr-united-kingdom","name":"Glenbarr, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"holmar-united-kingdom","name":"Holmar, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"kilchatten-bay-united-kingdom","name":"Kilchatten Bay, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"kilchoan-ferry-united-kingdom","name":"Kilchoan Ferry, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"lagavulin-united-kingdom","name":"Lagavulin, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"largs-united-kingdom","name":"Largs, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"leverburgh-united-kingdom","name":"Leverburgh, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"lochmaddy-united-kingdom","name":"Lochmaddy, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"ludag-united-kingdom","name":"Ludag, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"mallaig-united-kingdom","name":"Mallaig, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"north-bay-united-kingdom","name":"North Bay, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"ormsary-united-kingdom","name":"Ormsary, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"otter-ferry-united-kingdom","name":"Otter Ferry, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"port-askaig-united-kingdom","name":"Port Askaig, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"portachur-point-united-kingdom","name":"Portachur Point, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"scarinish-united-kingdom","name":"Scarinish, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"tobermory-united-kingdom","name":"Tobermory, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"bugio":{"id":"bugio","name":"BUGIO","length":"73 km","rfs":"1996 July","rfs_year":1996,"is_planned":false,"owners":"Altice Portugal","suppliers":null,"landing_points":[{"id":"carcavelos-portugal","name":"Carcavelos, Portugal","country":"Portugal","is_tbd":false},{"id":"sesimbra-portugal","name":"Sesimbra, Portugal","country":"Portugal","is_tbd":false}],"notes":null,"url":null},"bt-mt-1":{"id":"bt-mt-1","name":"BT-MT-1","length":"80 km","rfs":"1990","rfs_year":1990,"is_planned":false,"owners":"BT, Manx Telecom","suppliers":null,"landing_points":[{"id":"groudle-bay-isle-of-man","name":"Groudle Bay, Isle of Man","country":"Isle of Man","is_tbd":false},{"id":"silecroft-beach-united-kingdom","name":"Silecroft Beach, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"bulikula":{"id":"bulikula","name":"Bulikula","length":"21,600 km","rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"Google","suppliers":"SubCom","landing_points":[{"id":"natadola-fiji","name":"Natadola, Fiji","country":"Fiji","is_tbd":false},{"id":"suva-fiji","name":"Suva, Fiji","country":"Fiji","is_tbd":false},{"id":"tahiti-iti-french-polynesia","name":"Tahiti Iti, French Polynesia","country":"French Polynesia","is_tbd":true},{"id":"tahiti-nui-french-polynesia","name":"Tahiti Nui, French Polynesia","country":"French Polynesia","is_tbd":true},{"id":"piti-guam","name":"Piti, Guam","country":"Guam","is_tbd":false},{"id":"tinian-northern-mariana-islands","name":"Tinian, Northern Mariana Islands","country":"Northern Mariana Islands","is_tbd":false},{"id":"kapolei-hi-united-states","name":"Kapolei, HI, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://cloud.google.com"},"cabo-verde-telecom-domestic-submarine-cable-phase-2":{"id":"cabo-verde-telecom-domestic-submarine-cable-phase-2","name":"Cabo Verde Telecom Domestic Submarine Cable Phase 2","length":null,"rfs":"2002","rfs_year":2002,"is_planned":false,"owners":"Cabo Verde Telecom (CVT)","suppliers":null,"landing_points":[{"id":"porto-novo-cape-verde","name":"Porto Novo, Cape Verde","country":"Cape Verde","is_tbd":false},{"id":"sao-pedro-cape-verde","name":"Sao Pedro, Cape Verde","country":"Cape Verde","is_tbd":false},{"id":"tarrafal-de-santiago-cape-verde","name":"Tarrafal de Santiago, Cape Verde","country":"Cape Verde","is_tbd":false}],"notes":null,"url":"https://www.cvtelecom.cv"},"cabo-verde-telecom-domestic-submarine-cable-phase-1":{"id":"cabo-verde-telecom-domestic-submarine-cable-phase-1","name":"Cabo Verde Telecom Domestic Submarine Cable Phase 1","length":null,"rfs":"1997","rfs_year":1997,"is_planned":false,"owners":"Cabo Verde Telecom (CVT)","suppliers":null,"landing_points":[{"id":"murdeira-cape-verde","name":"Murdeira, Cape Verde","country":"Cape Verde","is_tbd":false},{"id":"praia-cape-verde","name":"Praia, Cape Verde","country":"Cape Verde","is_tbd":false},{"id":"sal-rei-cape-verde","name":"Sal Rei, Cape Verde","country":"Cape Verde","is_tbd":false},{"id":"sao-pedro-cape-verde","name":"Sao Pedro, Cape Verde","country":"Cape Verde","is_tbd":false},{"id":"tarrafal-de-so-nicolau-cape-verde","name":"Tarrafal de São Nicolau, Cape Verde","country":"Cape Verde","is_tbd":false}],"notes":null,"url":"https://www.cvtelecom.cv"},"cabo-verde-telecom-domestic-submarine-cable-phase-3":{"id":"cabo-verde-telecom-domestic-submarine-cable-phase-3","name":"Cabo Verde Telecom Domestic Submarine Cable Phase 3","length":null,"rfs":"2011","rfs_year":2011,"is_planned":false,"owners":"Cabo Verde Telecom (CVT)","suppliers":null,"landing_points":[{"id":"nova-sintra-cape-verde","name":"Nova Sintra, Cape Verde","country":"Cape Verde","is_tbd":false},{"id":"porto-novo-cape-verde","name":"Porto Novo, Cape Verde","country":"Cape Verde","is_tbd":false},{"id":"praia-cape-verde","name":"Praia, Cape Verde","country":"Cape Verde","is_tbd":false},{"id":"sal-rei-cape-verde","name":"Sal Rei, Cape Verde","country":"Cape Verde","is_tbd":false},{"id":"sao-filipe-cape-verde","name":"Sao Filipe, Cape Verde","country":"Cape Verde","is_tbd":false},{"id":"sao-pedro-cape-verde","name":"Sao Pedro, Cape Verde","country":"Cape Verde","is_tbd":false},{"id":"tarrafal-de-santiago-cape-verde","name":"Tarrafal de Santiago, Cape Verde","country":"Cape Verde","is_tbd":false},{"id":"vila-do-maio-cape-verde","name":"Vila do Maio, Cape Verde","country":"Cape Verde","is_tbd":false}],"notes":null,"url":"https://www.cvtelecom.cv"},"cadmos":{"id":"cadmos","name":"CADMOS","length":"230 km","rfs":"1995 September","rfs_year":1995,"is_planned":false,"owners":"A1 Telekom Austria, AT&T, Cyta, Deutsche Telekom, Lebanese Ministry of Telecommunications, Orange, Sparkle, Syrian Telecommunications Establishment, Tata Communications","suppliers":"SubCom","landing_points":[{"id":"pentaskhinos-cyprus","name":"Pentaskhinos, Cyprus","country":"Cyprus","is_tbd":false},{"id":"beirut-lebanon","name":"Beirut, Lebanon","country":"Lebanon","is_tbd":false},{"id":"jdaide-lebanon","name":"Jdaide, Lebanon","country":"Lebanon","is_tbd":false}],"notes":null,"url":null},"calvi-st-florent":{"id":"calvi-st-florent","name":"Calvi-St. Florent","length":"64 km","rfs":"1997","rfs_year":1997,"is_planned":false,"owners":"Orange","suppliers":"ASN","landing_points":[{"id":"calvi-france","name":"Calvi, France","country":"France","is_tbd":false},{"id":"st-florent-france","name":"St. Florent, France","country":"France","is_tbd":false}],"notes":null,"url":null},"cadmos-2":{"id":"cadmos-2","name":"CADMOS-2","length":"250 km","rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"Cyta, Lebanese Ministry of Telecommunications","suppliers":null,"landing_points":[{"id":"pentaskhinos-cyprus","name":"Pentaskhinos, Cyprus","country":"Cyprus","is_tbd":false},{"id":"beirut-lebanon","name":"Beirut, Lebanon","country":"Lebanon","is_tbd":false}],"notes":null,"url":null},"cam-ring":{"id":"cam-ring","name":"CAM Ring","length":"1,120 km","rfs":"2003","rfs_year":2003,"is_planned":false,"owners":"Altice Portugal","suppliers":"ASN","landing_points":[{"id":"funchal-portugal","name":"Funchal, Portugal","country":"Portugal","is_tbd":false},{"id":"ponta-delgada-portugal","name":"Ponta Delgada, Portugal","country":"Portugal","is_tbd":false},{"id":"porto-santo-portugal","name":"Porto Santo, Portugal","country":"Portugal","is_tbd":false}],"notes":null,"url":null},"canalink":{"id":"canalink","name":"Canalink","length":"1,835 km","rfs":"2011","rfs_year":2011,"is_planned":false,"owners":"IT3","suppliers":"ASN","landing_points":[{"id":"asilah-morocco","name":"Asilah, Morocco","country":"Morocco","is_tbd":false},{"id":"conil-de-la-frontera-spain","name":"Conil de la Frontera, Spain","country":"Spain","is_tbd":false},{"id":"el-goro-canary-islands-spain","name":"El Goro, Canary Islands, Spain","country":"Spain","is_tbd":false},{"id":"granadilla-de-abona-spain","name":"Granadilla de Abona, Spain","country":"Spain","is_tbd":false},{"id":"gimar-canary-islands-spain","name":"Güimar, Canary Islands, Spain","country":"Spain","is_tbd":false},{"id":"rota-spain","name":"Rota, Spain","country":"Spain","is_tbd":false},{"id":"santa-cruz-de-la-palma-canary-islands-spain","name":"Santa Cruz de La Palma, Canary Islands, Spain","country":"Spain","is_tbd":false},{"id":"tinocas-canary-islands-spain","name":"Tinocas, Canary Islands, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":"http://www.canalink.tel/"},"candle":{"id":"candle","name":"Candle","length":"8,000 km","rfs":"2028","rfs_year":2028,"is_planned":true,"owners":"IPS, Inc., Meta, Softbank, Telekom Malaysia, XLSmart","suppliers":"NEC","landing_points":[{"id":"batam-indonesia","name":"Batam, Indonesia","country":"Indonesia","is_tbd":false},{"id":"maruyama-japan","name":"Maruyama, Japan","country":"Japan","is_tbd":false},{"id":"sedili-malaysia","name":"Sedili, Malaysia","country":"Malaysia","is_tbd":false},{"id":"baler-philippines","name":"Baler, Philippines","country":"Philippines","is_tbd":false},{"id":"nasugbu-philippines","name":"Nasugbu, Philippines","country":"Philippines","is_tbd":false},{"id":"changi-north-singapore","name":"Changi North, Singapore","country":"Singapore","is_tbd":false},{"id":"toucheng-taiwan","name":"Toucheng, Taiwan","country":"Taiwan","is_tbd":false}],"notes":null,"url":null},"candalta":{"id":"candalta","name":"CANDALTA","length":"110 km","rfs":"1999","rfs_year":1999,"is_planned":false,"owners":"Telefonica","suppliers":null,"landing_points":[{"id":"alta-vista-canary-islands-spain","name":"Alta Vista, Canary Islands, Spain","country":"Spain","is_tbd":false},{"id":"candelaria-canary-islands-spain","name":"Candelaria, Canary Islands, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":null},"cantat-3":{"id":"cantat-3","name":"CANTAT-3","length":"270 km","rfs":"1994 November","rfs_year":1994,"is_planned":false,"owners":"Shefa","suppliers":"ASN","landing_points":[{"id":"blaabjerg-denmark","name":"Blaabjerg, Denmark","country":"Denmark","is_tbd":false},{"id":"south-arne-denmark","name":"South Arne, Denmark","country":"Denmark","is_tbd":false},{"id":"tyra-denmark","name":"Tyra, Denmark","country":"Denmark","is_tbd":false},{"id":"valdemar-denmark","name":"Valdemar, Denmark","country":"Denmark","is_tbd":false}],"notes":null,"url":"http://www.shefa.fo"},"caribbean-bermuda-u-s-cbus":{"id":"caribbean-bermuda-u-s-cbus","name":"Caribbean-Bermuda U.S. (CBUS)","length":"1,700 km","rfs":"2009 October","rfs_year":2009,"is_planned":false,"owners":"Liberty Networks, Orange","suppliers":"Xtera","landing_points":[{"id":"st-davids-bermuda","name":"St. David’s, Bermuda","country":"Bermuda","is_tbd":false},{"id":"tortola-virgin-islands-u-k-","name":"Tortola, Virgin Islands (U.K.)","country":"Virgin Islands (U.K.)","is_tbd":false}],"notes":null,"url":null},"caribbean-regional-communications-infrastructure-program-carcip":{"id":"caribbean-regional-communications-infrastructure-program-carcip","name":"Caribbean Regional Communications Infrastructure Program (CARCIP)","length":"225 km","rfs":"2019 June","rfs_year":2019,"is_planned":false,"owners":"Digicel","suppliers":null,"landing_points":[{"id":"carriacou-grenada","name":"Carriacou, Grenada","country":"Grenada","is_tbd":false},{"id":"conference-grenada","name":"Conference, Grenada","country":"Grenada","is_tbd":false},{"id":"bequia-saint-vincent-and-the-grenadines","name":"Bequia, Saint Vincent and the Grenadines","country":"Saint Vincent and the Grenadines","is_tbd":false},{"id":"canouan-saint-vincent-and-the-grenadines","name":"Canouan, Saint Vincent and the Grenadines","country":"Saint Vincent and the Grenadines","is_tbd":false},{"id":"chateaubelair-saint-vincent-and-the-grenadines","name":"Chateaubelair, Saint Vincent and the Grenadines","country":"Saint Vincent and the Grenadines","is_tbd":false},{"id":"kingstown-saint-vincent-and-the-grenadines","name":"Kingstown, Saint Vincent and the Grenadines","country":"Saint Vincent and the Grenadines","is_tbd":false},{"id":"mustique-saint-vincent-and-the-grenadines","name":"Mustique, Saint Vincent and the Grenadines","country":"Saint Vincent and the Grenadines","is_tbd":false},{"id":"owia-saint-vincent-and-the-grenadines","name":"Owia, Saint Vincent and the Grenadines","country":"Saint Vincent and the Grenadines","is_tbd":false},{"id":"union-island-saint-vincent-and-the-grenadines","name":"Union Island, Saint Vincent and the Grenadines","country":"Saint Vincent and the Grenadines","is_tbd":false}],"notes":null,"url":"https://www.digicelgroup.com"},"carnival-submarine-network-1-csn-1":{"id":"carnival-submarine-network-1-csn-1","name":"Carnival Submarine Network-1 (CSN-1)","length":"4,670 km","rfs":"2026 Q4","rfs_year":2026,"is_planned":true,"owners":"Telconet","suppliers":"ASN","landing_points":[{"id":"barranquilla-colombia","name":"Barranquilla, Colombia","country":"Colombia","is_tbd":false},{"id":"ancon-ecuador","name":"Ancon, Ecuador","country":"Ecuador","is_tbd":false},{"id":"cancn-mexico","name":"Cancún, Mexico","country":"Mexico","is_tbd":false},{"id":"cristbal-panama","name":"Cristóbal, Panama","country":"Panama","is_tbd":false},{"id":"panama-city-panama","name":"Panama City, Panama","country":"Panama","is_tbd":false},{"id":"naples-fl-united-states","name":"Naples, FL, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.telconet.net/"},"cat-submarine-network-csn":{"id":"cat-submarine-network-csn","name":"CAT Submarine Network (CSN)","length":"1,240 km","rfs":"2013 Q1","rfs_year":2013,"is_planned":false,"owners":"National Telecom","suppliers":null,"landing_points":[{"id":"songkhla-thailand","name":"Songkhla, Thailand","country":"Thailand","is_tbd":false},{"id":"sriracha-thailand","name":"Sriracha, Thailand","country":"Thailand","is_tbd":false}],"notes":null,"url":null},"caucasus-cable-system":{"id":"caucasus-cable-system","name":"Caucasus Cable System","length":"1,200 km","rfs":"2008 November","rfs_year":2008,"is_planned":false,"owners":"Caucasus Online","suppliers":"SubCom","landing_points":[{"id":"balchik-bulgaria","name":"Balchik, Bulgaria","country":"Bulgaria","is_tbd":false},{"id":"poti-georgia","name":"Poti, Georgia","country":"Georgia","is_tbd":false}],"notes":null,"url":"http://www.co.ge/en/"},"cayman-jamaica-fiber-system-cjfs":{"id":"cayman-jamaica-fiber-system-cjfs","name":"Cayman-Jamaica Fiber System (CJFS)","length":"1,197 km","rfs":"1997 June","rfs_year":1997,"is_planned":false,"owners":"CW Cayman, CW Jamaica","suppliers":"ASN","landing_points":[{"id":"cayman-brac-cayman-islands","name":"Cayman Brac, Cayman Islands","country":"Cayman Islands","is_tbd":false},{"id":"half-moon-bay-cayman-islands","name":"Half Moon Bay, Cayman Islands","country":"Cayman Islands","is_tbd":false},{"id":"bull-bay-jamaica","name":"Bull Bay, Jamaica","country":"Jamaica","is_tbd":false},{"id":"montego-bay-jamaica","name":"Montego Bay, Jamaica","country":"Jamaica","is_tbd":false},{"id":"ocho-rios-jamaica","name":"Ocho Rios, Jamaica","country":"Jamaica","is_tbd":false},{"id":"port-antonio-jamaica","name":"Port Antonio, Jamaica","country":"Jamaica","is_tbd":false}],"notes":null,"url":null},"ceiba-1":{"id":"ceiba-1","name":"Ceiba-1","length":"287 km","rfs":"2011 June","rfs_year":2011,"is_planned":false,"owners":"GITGE (Gestor de Infraestructuras de Telecomunicaciones de Guinea Ecuatorial)","suppliers":null,"landing_points":[{"id":"bata-equatorial-guinea","name":"Bata, Equatorial Guinea","country":"Equatorial Guinea","is_tbd":false},{"id":"malabo-equatorial-guinea","name":"Malabo, Equatorial Guinea","country":"Equatorial Guinea","is_tbd":false}],"notes":null,"url":"http://www.gitge.com"},"ceiba-2":{"id":"ceiba-2","name":"Ceiba-2","length":"290 km","rfs":"2017","rfs_year":2017,"is_planned":false,"owners":"GITGE (Gestor de Infraestructuras de Telecomunicaciones de Guinea Ecuatorial)","suppliers":"HMN Tech","landing_points":[{"id":"kribi-cameroon","name":"Kribi, Cameroon","country":"Cameroon","is_tbd":false},{"id":"bata-equatorial-guinea","name":"Bata, Equatorial Guinea","country":"Equatorial Guinea","is_tbd":false},{"id":"malabo-equatorial-guinea","name":"Malabo, Equatorial Guinea","country":"Equatorial Guinea","is_tbd":false}],"notes":null,"url":"http://www.gitge.com"},"celia":{"id":"celia","name":"CELIA","length":"3,700 km","rfs":"2027 Q3","rfs_year":2027,"is_planned":true,"owners":"APUA, Orange, Setar, Telxius","suppliers":"ASN","landing_points":[{"id":"morris-bay-antigua-and-barbuda","name":"Morris Bay, Antigua and Barbuda","country":"Antigua and Barbuda","is_tbd":false},{"id":"baby-beach-aruba","name":"Baby Beach, Aruba","country":"Aruba","is_tbd":false},{"id":"kralendijk-bonaire-sint-eustatius-and-saba","name":"Kralendijk, Bonaire, Sint Eustatius and Saba","country":"Bonaire, Sint Eustatius and Saba","is_tbd":false},{"id":"willemstad-curaao","name":"Willemstad, Curaçao","country":"Curaçao","is_tbd":false},{"id":"le-lamentin-martinique","name":"Le Lamentin, Martinique","country":"Martinique","is_tbd":false},{"id":"boca-raton-fl-united-states","name":"Boca Raton, FL, United States","country":"United States","is_tbd":false},{"id":"san-juan-pr-united-states","name":"San Juan, PR, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"celtixconnect-1-cc-1":{"id":"celtixconnect-1-cc-1","name":"CeltixConnect-1 (CC-1)","length":"131 km","rfs":"2012 January","rfs_year":2012,"is_planned":false,"owners":"EXA Infrastructure","suppliers":"ASN","landing_points":[{"id":"dublin-ireland","name":"Dublin, Ireland","country":"Ireland","is_tbd":false},{"id":"holyhead-united-kingdom","name":"Holyhead, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"https://exainfra.net/"},"challenger-one-bermuda":{"id":"challenger-one-bermuda","name":"Challenger One Bermuda","length":null,"rfs":"2024 February","rfs_year":2024,"is_planned":false,"owners":"ATN International","suppliers":"ASN","landing_points":[{"id":"paget-bermuda","name":"Paget, Bermuda","country":"Bermuda","is_tbd":false}],"notes":null,"url":null},"channel-islands-9-liberty-submarine-cable":{"id":"channel-islands-9-liberty-submarine-cable","name":"Channel Islands-9 Liberty Submarine Cable","length":null,"rfs":"2008 June","rfs_year":2008,"is_planned":false,"owners":"JTGlobal","suppliers":null,"landing_points":[{"id":"lancresse-bay-guernsey","name":"L'Ancresse Bay, Guernsey","country":"Guernsey","is_tbd":false},{"id":"stoke-fleming-united-kingdom","name":"Stoke Fleming, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"chennai-andaman-nicobar-islands-cable-cani":{"id":"chennai-andaman-nicobar-islands-cable-cani","name":"Chennai-Andaman & Nicobar Islands Cable (CANI)","length":"2,300 km","rfs":"2020 August","rfs_year":2020,"is_planned":false,"owners":"Bharat Sanchar Nigam Ltd. (BSNL)","suppliers":"NEC","landing_points":[{"id":"car-nicobar-india","name":"Car Nicobar, India","country":"India","is_tbd":false},{"id":"chennai-india","name":"Chennai, India","country":"India","is_tbd":false},{"id":"great-nicobar-india","name":"Great Nicobar, India","country":"India","is_tbd":false},{"id":"havelock-india","name":"Havelock, India","country":"India","is_tbd":false},{"id":"kamorta-india","name":"Kamorta, India","country":"India","is_tbd":false},{"id":"little-andaman-india","name":"Little Andaman, India","country":"India","is_tbd":false},{"id":"long-island-india","name":"Long Island, India","country":"India","is_tbd":false},{"id":"rangat-india","name":"Rangat, India","country":"India","is_tbd":false},{"id":"sri-vijaya-puram-india","name":"Sri Vijaya Puram, India","country":"India","is_tbd":false}],"notes":null,"url":"http://www.bsnl.co.in"},"c-lion1":{"id":"c-lion1","name":"C-Lion1","length":"1,172 km","rfs":"2016 March","rfs_year":2016,"is_planned":false,"owners":"Cinia Oy","suppliers":"ASN","landing_points":[{"id":"hanko-finland","name":"Hanko, Finland","country":"Finland","is_tbd":false},{"id":"helsinki-finland","name":"Helsinki, Finland","country":"Finland","is_tbd":false},{"id":"rostock-germany","name":"Rostock, Germany","country":"Germany","is_tbd":false}],"notes":null,"url":"https://www.cinia.fi/en/"},"chi":{"id":"chi","name":"CHI","length":"3,932 km","rfs":"2001","rfs_year":2001,"is_planned":false,"owners":"Hawaiian Telcom, Verizon","suppliers":"ASN, Fujitsu","landing_points":[{"id":"makaha-hi-united-states","name":"Makaha, HI, United States","country":"United States","is_tbd":false},{"id":"morro-bay-ca-united-states","name":"Morro Bay, CA, United States","country":"United States","is_tbd":false}],"notes":"CHI is the only remaining operational segment of the Japan-U.S. (JUS) Cable Network that was retired in July 2023.","url":null},"chuuk-pohnpei-cable":{"id":"chuuk-pohnpei-cable","name":"Chuuk-Pohnpei Cable","length":"1,200 km","rfs":"2019 May","rfs_year":2019,"is_planned":false,"owners":"Federated States of Micronesia Telecommunications Cable Corporation (FSMTCC)","suppliers":"NEC","landing_points":[{"id":"pohnpei-micronesia","name":"Pohnpei, Micronesia","country":"Micronesia","is_tbd":false},{"id":"weno-chuuk-micronesia","name":"Weno, Chuuk, Micronesia","country":"Micronesia","is_tbd":false}],"notes":null,"url":"https://fsmcable.com/chuuk"},"circe-north":{"id":"circe-north","name":"Circe North","length":"203 km","rfs":"1999 February","rfs_year":1999,"is_planned":false,"owners":"Zayo, euNetworks","suppliers":"ASN","landing_points":[{"id":"zandvoort-netherlands","name":"Zandvoort, Netherlands","country":"Netherlands","is_tbd":false},{"id":"lowestoft-united-kingdom","name":"Lowestoft, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"circe-south":{"id":"circe-south","name":"Circe South","length":"115 km","rfs":"1999 February","rfs_year":1999,"is_planned":false,"owners":"Zayo, euNetworks","suppliers":"ASN","landing_points":[{"id":"cayeux-sur-mer-france","name":"Cayeux-sur-Mer, France","country":"France","is_tbd":false},{"id":"pevensey-bay-united-kingdom","name":"Pevensey Bay, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"cobracable":{"id":"cobracable","name":"COBRAcable","length":"304 km","rfs":"2019 November","rfs_year":2019,"is_planned":false,"owners":"Relined","suppliers":null,"landing_points":[{"id":"endrup-denmark","name":"Endrup, Denmark","country":"Denmark","is_tbd":false},{"id":"eemshaven-netherlands","name":"Eemshaven, Netherlands","country":"Netherlands","is_tbd":false}],"notes":"The COBRAcable is a power cable that also contains a fiber-optic cable with 48 fiber pairs.","url":"https://www.tennet.eu/our-projects/international-connections"},"cogim":{"id":"cogim","name":"COGIM","length":"438 km","rfs":"2005","rfs_year":2005,"is_planned":false,"owners":"Télébec","suppliers":null,"landing_points":[{"id":"lanse--beaufils-qc-canada","name":"L'Anse-à-Beaufils, QC, Canada","country":"Canada","is_tbd":false},{"id":"les-les-de-la-madeleine-qc-canada","name":"Les Îles-de-la-Madeleine, QC, Canada","country":"Canada","is_tbd":false}],"notes":null,"url":null},"colombian-festoon":{"id":"colombian-festoon","name":"Colombian Festoon","length":"400 km","rfs":"1997","rfs_year":1997,"is_planned":false,"owners":"","suppliers":null,"landing_points":[{"id":"cartagena-colombia","name":"Cartagena, Colombia","country":"Colombia","is_tbd":false},{"id":"parque-isla-de-salamanca-colombia","name":"Parque Isla de Salamanca, Colombia","country":"Colombia","is_tbd":false},{"id":"puerto-colombia-colombia","name":"Puerto Colombia, Colombia","country":"Colombia","is_tbd":false},{"id":"santa-marta-colombia","name":"Santa Marta, Colombia","country":"Colombia","is_tbd":false},{"id":"tolu-colombia","name":"Tolu, Colombia","country":"Colombia","is_tbd":false}],"notes":null,"url":null},"colombia-florida-express-cfx-1":{"id":"colombia-florida-express-cfx-1","name":"Colombia-Florida Express (CFX-1)","length":"2,438 km","rfs":"2008 August","rfs_year":2008,"is_planned":false,"owners":"Liberty Networks","suppliers":"SubCom","landing_points":[{"id":"cartagena-colombia","name":"Cartagena, Colombia","country":"Colombia","is_tbd":false},{"id":"copa-club-jamaica","name":"Copa Club, Jamaica","country":"Jamaica","is_tbd":false},{"id":"morant-point-jamaica","name":"Morant Point, Jamaica","country":"Jamaica","is_tbd":false},{"id":"boca-raton-fl-united-states","name":"Boca Raton, FL, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://libertynet.com/contact"},"columbus-ii-b":{"id":"columbus-ii-b","name":"Columbus-II b","length":"2,068 km","rfs":"1994 December","rfs_year":1994,"is_planned":false,"owners":"AT&T, Setar","suppliers":"SubCom","landing_points":[{"id":"magens-bay-vi-united-states","name":"Magen’s Bay, VI, United States","country":"United States","is_tbd":false},{"id":"west-palm-beach-fl-united-states","name":"West Palm Beach, FL, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"comoros-domestic-cable-system":{"id":"comoros-domestic-cable-system","name":"Comoros Domestic Cable System","length":null,"rfs":"2010","rfs_year":2010,"is_planned":false,"owners":"Comores Telecom","suppliers":"ASN","landing_points":[{"id":"chindini-comoros","name":"Chindini, Comoros","country":"Comoros","is_tbd":false},{"id":"fomboni-moheli-comoros","name":"Fomboni Moheli, Comoros","country":"Comoros","is_tbd":false},{"id":"mutsamudu-comoros","name":"Mutsamudu, Comoros","country":"Comoros","is_tbd":false}],"notes":null,"url":null},"columbus-iii-azores-portugal":{"id":"columbus-iii-azores-portugal","name":"Columbus-III Azores-Portugal","length":null,"rfs":"1999 December","rfs_year":1999,"is_planned":false,"owners":"Altice Portugal","suppliers":null,"landing_points":[{"id":"carcavelos-portugal","name":"Carcavelos, Portugal","country":"Portugal","is_tbd":false},{"id":"ponta-delgada-portugal","name":"Ponta Delgada, Portugal","country":"Portugal","is_tbd":false}],"notes":null,"url":"https://altice.net/altice-international"},"confluence-1":{"id":"confluence-1","name":"Confluence-1","length":"2,571 km","rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"Confluence Networks","suppliers":"SubCom","landing_points":[{"id":"boca-raton-fl-united-states","name":"Boca Raton, FL, United States","country":"United States","is_tbd":false},{"id":"jacksonville-fl-united-states","name":"Jacksonville, FL, United States","country":"United States","is_tbd":false},{"id":"myrtle-beach-sc-united-states","name":"Myrtle Beach, SC, United States","country":"United States","is_tbd":false},{"id":"virginia-beach-va-united-states","name":"Virginia Beach, VA, United States","country":"United States","is_tbd":false},{"id":"wall-township-nj-united-states","name":"Wall Township, NJ, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://confluencenetworks.net"},"concerto":{"id":"concerto","name":"Concerto","length":"550 km","rfs":"1999","rfs_year":1999,"is_planned":false,"owners":"EXA Infrastructure","suppliers":"ASN","landing_points":[{"id":"zandvoort-netherlands","name":"Zandvoort, Netherlands","country":"Netherlands","is_tbd":false},{"id":"sizewell-united-kingdom","name":"Sizewell, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"https://exainfra.net/"},"connected-coast":{"id":"connected-coast","name":"Connected Coast","length":null,"rfs":"2024","rfs_year":2024,"is_planned":false,"owners":"Connected Coast Network Partnership","suppliers":null,"landing_points":[{"id":"addenbroke-island-bc-canada","name":"Addenbroke Island, BC, Canada","country":"Canada","is_tbd":false},{"id":"ahousat-bc-canada","name":"Ahousat, BC, Canada","country":"Canada","is_tbd":false},{"id":"alert-bay-bc-canada","name":"Alert Bay, BC, Canada","country":"Canada","is_tbd":false},{"id":"bamfield-bc-canada","name":"Bamfield, BC, Canada","country":"Canada","is_tbd":false},{"id":"bella-bella-bc-canada","name":"Bella Bella, BC, Canada","country":"Canada","is_tbd":false},{"id":"bella-coola-bc-canada","name":"Bella Coola, BC, Canada","country":"Canada","is_tbd":false},{"id":"blind-channel-bc-canada","name":"Blind Channel, BC, Canada","country":"Canada","is_tbd":false},{"id":"bliss-landing-bc-canada","name":"Bliss Landing, BC, Canada","country":"Canada","is_tbd":false},{"id":"boat-bluff-bc-canada","name":"Boat Bluff, BC, Canada","country":"Canada","is_tbd":false},{"id":"bold-point-bc-canada","name":"Bold Point, BC, Canada","country":"Canada","is_tbd":false},{"id":"bonilla-island-bc-canada","name":"Bonilla Island, BC, Canada","country":"Canada","is_tbd":false},{"id":"bowser-bc-canada","name":"Bowser, BC, Canada","country":"Canada","is_tbd":false},{"id":"butedale-bc-canada","name":"Butedale, BC, Canada","country":"Canada","is_tbd":false},{"id":"campbell-river-bc-canada","name":"Campbell River, BC, Canada","country":"Canada","is_tbd":false},{"id":"chatham-point-bc-canada","name":"Chatham Point, BC, Canada","country":"Canada","is_tbd":false},{"id":"coal-harbour-bc-canada","name":"Coal Harbour, BC, Canada","country":"Canada","is_tbd":false},{"id":"comox-bc-canada","name":"Comox, BC, Canada","country":"Canada","is_tbd":false},{"id":"cousins-inlet-bc-canada","name":"Cousins Inlet, BC, Canada","country":"Canada","is_tbd":false},{"id":"denman-island-bc-canada","name":"Denman Island, BC, Canada","country":"Canada","is_tbd":false},{"id":"dodge-cove-bc-canada","name":"Dodge Cove, BC, Canada","country":"Canada","is_tbd":false},{"id":"dryad-point-bc-canada","name":"Dryad Point, BC, Canada","country":"Canada","is_tbd":false},{"id":"duncanby-bc-canada","name":"Duncanby, BC, Canada","country":"Canada","is_tbd":false},{"id":"eastbourne-bc-canada","name":"Eastbourne, BC, Canada","country":"Canada","is_tbd":false},{"id":"echo-bay-bc-canada","name":"Echo Bay, BC, Canada","country":"Canada","is_tbd":false},{"id":"edith-point-bc-canada","name":"Edith Point, BC, Canada","country":"Canada","is_tbd":false},{"id":"egg-island-bc-canada","name":"Egg Island, BC, Canada","country":"Canada","is_tbd":false},{"id":"elk-bay-bc-canada","name":"Elk Bay, BC, Canada","country":"Canada","is_tbd":false},{"id":"esperanza-bc-canada","name":"Esperanza, BC, Canada","country":"Canada","is_tbd":false},{"id":"gabriola-bc-canada","name":"Gabriola, BC, Canada","country":"Canada","is_tbd":false},{"id":"gambier-harbour-bc-canada","name":"Gambier Harbour, BC, Canada","country":"Canada","is_tbd":false},{"id":"gibsons-bc-canada","name":"Gibsons, BC, Canada","country":"Canada","is_tbd":false},{"id":"gillies-bay-bc-canada","name":"Gillies Bay, BC, Canada","country":"Canada","is_tbd":false},{"id":"gold-river-bc-canada","name":"Gold River, BC, Canada","country":"Canada","is_tbd":false},{"id":"granite-bay-bc-canada","name":"Granite Bay, BC, Canada","country":"Canada","is_tbd":false},{"id":"haggards-cove-bc-canada","name":"Haggard's Cove, BC, Canada","country":"Canada","is_tbd":false},{"id":"harbledown-island-bc-canada","name":"Harbledown Island, BC, Canada","country":"Canada","is_tbd":false},{"id":"hartley-bay-bc-canada","name":"Hartley Bay, BC, Canada","country":"Canada","is_tbd":false},{"id":"heriot-bay-bc-canada","name":"Heriot Bay, BC, Canada","country":"Canada","is_tbd":false},{"id":"holberg-bc-canada","name":"Holberg, BC, Canada","country":"Canada","is_tbd":false},{"id":"hornby-island-bc-canada","name":"Hornby Island, BC, Canada","country":"Canada","is_tbd":false},{"id":"hot-springs-cove-bc-canada","name":"Hot Springs Cove, BC, Canada","country":"Canada","is_tbd":false},{"id":"hyde-creek-bc-canada","name":"Hyde Creek, BC, Canada","country":"Canada","is_tbd":false},{"id":"ivory-island-bc-canada","name":"Ivory Island, BC, Canada","country":"Canada","is_tbd":false},{"id":"kaiete-point-bc-canada","name":"Kaiete Point, BC, Canada","country":"Canada","is_tbd":false},{"id":"kains-island-bc-canada","name":"Kains Island, BC, Canada","country":"Canada","is_tbd":false},{"id":"kingcome-inlet-bc-canada","name":"Kingcome Inlet, BC, Canada","country":"Canada","is_tbd":false},{"id":"kitamaat-village-bc-canada","name":"Kitamaat Village, BC, Canada","country":"Canada","is_tbd":false},{"id":"kitimat-bc-canada","name":"Kitimat, BC, Canada","country":"Canada","is_tbd":false},{"id":"kitkatla-bc-canada","name":"Kitkatla, BC, Canada","country":"Canada","is_tbd":false},{"id":"klemtu-bc-canada","name":"Klemtu, BC, Canada","country":"Canada","is_tbd":false},{"id":"kwakiutl-cluxewe-bc-canada","name":"Kwakiutl (Cluxewe), BC, Canada","country":"Canada","is_tbd":false},{"id":"kwikwasutinuxw-haxwamis-bc-canada","name":"Kwikwasut'inuxw Haxwa’mis, BC, Canada","country":"Canada","is_tbd":false},{"id":"kyuquot-bc-canada","name":"Kyuquot, BC, Canada","country":"Canada","is_tbd":false},{"id":"lax-kwalaams-bc-canada","name":"Lax Kw'alaams, BC, Canada","country":"Canada","is_tbd":false},{"id":"lund-bc-canada","name":"Lund, BC, Canada","country":"Canada","is_tbd":false},{"id":"macoah-bc-canada","name":"Macoah, BC, Canada","country":"Canada","is_tbd":false},{"id":"mansons-landing-bc-canada","name":"Mansons Landing, BC, Canada","country":"Canada","is_tbd":false},{"id":"martin-valley-bc-canada","name":"Martin Valley, BC, Canada","country":"Canada","is_tbd":false},{"id":"mcinnes-island-bc-canada","name":"McInnes Island, BC, Canada","country":"Canada","is_tbd":false},{"id":"metlakatla-bc-canada","name":"Metlakatla, BC, Canada","country":"Canada","is_tbd":false},{"id":"mitchell-bay-bc-canada","name":"Mitchell Bay, BC, Canada","country":"Canada","is_tbd":false},{"id":"nuchatlaht-bc-canada","name":"Nuchatlaht, BC, Canada","country":"Canada","is_tbd":false},{"id":"ocean-falls-bc-canada","name":"Ocean Falls, BC, Canada","country":"Canada","is_tbd":false},{"id":"old-bella-bella-bc-canada","name":"Old Bella Bella, BC, Canada","country":"Canada","is_tbd":false},{"id":"oona-river-bc-canada","name":"Oona River, BC, Canada","country":"Canada","is_tbd":false},{"id":"open-bay-bc-canada","name":"Open Bay, BC, Canada","country":"Canada","is_tbd":false},{"id":"opitsat-bc-canada","name":"Opitsat, BC, Canada","country":"Canada","is_tbd":false},{"id":"pine-island-bc-canada","name":"Pine Island, BC, Canada","country":"Canada","is_tbd":false},{"id":"port-alice-bc-canada","name":"Port Alice, BC, Canada","country":"Canada","is_tbd":false},{"id":"port-hardy-bc-canada","name":"Port Hardy, BC, Canada","country":"Canada","is_tbd":false},{"id":"port-neville-bc-canada","name":"Port Neville, BC, Canada","country":"Canada","is_tbd":false},{"id":"powell-river-bc-canada","name":"Powell River, BC, Canada","country":"Canada","is_tbd":false},{"id":"prince-rupert-bc-canada","name":"Prince Rupert, BC, Canada","country":"Canada","is_tbd":false},{"id":"pulteney-point-bc-canada","name":"Pulteney Point, BC, Canada","country":"Canada","is_tbd":false},{"id":"qualicum-beach-bc-canada","name":"Qualicum Beach, BC, Canada","country":"Canada","is_tbd":false},{"id":"quatsino-bc-canada","name":"Quatsino, BC, Canada","country":"Canada","is_tbd":false},{"id":"queens-cove-bc-canada","name":"Queens Cove, BC, Canada","country":"Canada","is_tbd":false},{"id":"refuge-cove-bc-canada","name":"Refuge Cove, BC, Canada","country":"Canada","is_tbd":false},{"id":"ridley-island-bc-canada","name":"Ridley Island, BC, Canada","country":"Canada","is_tbd":false},{"id":"salmon-beach-bc-canada","name":"Salmon Beach, BC, Canada","country":"Canada","is_tbd":false},{"id":"saltery-bay-bc-canada","name":"Saltery Bay, BC, Canada","country":"Canada","is_tbd":false},{"id":"sarita-bc-canada","name":"Sarita, BC, Canada","country":"Canada","is_tbd":false},{"id":"sayward-bc-canada","name":"Sayward, BC, Canada","country":"Canada","is_tbd":false},{"id":"scarlett-point-bc-canada","name":"Scarlett Point, BC, Canada","country":"Canada","is_tbd":false},{"id":"seaford-bc-canada","name":"Seaford, BC, Canada","country":"Canada","is_tbd":false},{"id":"shawl-bay-bc-canada","name":"Shawl Bay, BC, Canada","country":"Canada","is_tbd":false},{"id":"shearwater-bc-canada","name":"Shearwater, BC, Canada","country":"Canada","is_tbd":false},{"id":"smith-island-bc-canada","name":"Smith Island, BC, Canada","country":"Canada","is_tbd":false},{"id":"snaw-naw-as-bc-canada","name":"Snaw-naw-as, BC, Canada","country":"Canada","is_tbd":false},{"id":"squirrel-cove-bc-canada","name":"Squirrel Cove, BC, Canada","country":"Canada","is_tbd":false},{"id":"stories-beach-bc-canada","name":"Stories Beach, BC, Canada","country":"Canada","is_tbd":false},{"id":"surge-narrows-bc-canada","name":"Surge Narrows, BC, Canada","country":"Canada","is_tbd":false},{"id":"tahsis-bc-canada","name":"Tahsis, BC, Canada","country":"Canada","is_tbd":false},{"id":"telegraph-cove-bc-canada","name":"Telegraph Cove, BC, Canada","country":"Canada","is_tbd":false},{"id":"tlell-bc-canada","name":"Tlell, BC, Canada","country":"Canada","is_tbd":false},{"id":"tofino-bc-canada","name":"Tofino, BC, Canada","country":"Canada","is_tbd":false},{"id":"tom-island-bc-canada","name":"Tom Island, BC, Canada","country":"Canada","is_tbd":false},{"id":"toquaht-bay-bc-canada","name":"Toquaht Bay, BC, Canada","country":"Canada","is_tbd":false},{"id":"uchucklesaht-bc-canada","name":"Uchucklesaht, BC, Canada","country":"Canada","is_tbd":false},{"id":"ucluelet-bc-canada","name":"Ucluelet, BC, Canada","country":"Canada","is_tbd":false},{"id":"van-anda-bc-canada","name":"Van Anda, BC, Canada","country":"Canada","is_tbd":false},{"id":"vancouver-bc-canada","name":"Vancouver, BC, Canada","country":"Canada","is_tbd":false},{"id":"whaletown-bc-canada","name":"Whaletown, BC, Canada","country":"Canada","is_tbd":false},{"id":"williams-beach-bc-canada","name":"Williams Beach, BC, Canada","country":"Canada","is_tbd":false},{"id":"winter-harbour-bc-canada","name":"Winter Harbour, BC, Canada","country":"Canada","is_tbd":false},{"id":"wuikinuxv-bc-canada","name":"Wuikinuxv, BC, Canada","country":"Canada","is_tbd":false},{"id":"yuquot-bc-canada","name":"Yuquot, BC, Canada","country":"Canada","is_tbd":false},{"id":"zeballos-bc-canada","name":"Zeballos, BC, Canada","country":"Canada","is_tbd":false}],"notes":null,"url":"https://connectedcoast.ca/"},"continente-madeira":{"id":"continente-madeira","name":"Continente-Madeira","length":"1,179 km","rfs":"2000 February","rfs_year":2000,"is_planned":false,"owners":"Altice Portugal","suppliers":null,"landing_points":[{"id":"carcavelos-portugal","name":"Carcavelos, Portugal","country":"Portugal","is_tbd":false},{"id":"funchal-portugal","name":"Funchal, Portugal","country":"Portugal","is_tbd":false}],"notes":null,"url":"https://altice.net/altice-international"},"converge-domestic-submarine-cable-network-cdscn":{"id":"converge-domestic-submarine-cable-network-cdscn","name":"Converge Domestic Submarine Cable Network (CDSCN)","length":"1,300 km","rfs":"2021 Q4","rfs_year":2021,"is_planned":false,"owners":"Converge ICT","suppliers":"HMN Tech","landing_points":[{"id":"baclayon-philippines","name":"Baclayon, Philippines","country":"Philippines","is_tbd":false},{"id":"bacong-philippines","name":"Bacong, Philippines","country":"Philippines","is_tbd":false},{"id":"bogo-philippines","name":"Bogo, Philippines","country":"Philippines","is_tbd":false},{"id":"boracay-philippines","name":"Boracay, Philippines","country":"Philippines","is_tbd":false},{"id":"buenavista-philippines","name":"Buenavista, Philippines","country":"Philippines","is_tbd":false},{"id":"cagayan-de-oro-philippines","name":"Cagayan de Oro, Philippines","country":"Philippines","is_tbd":false},{"id":"coron-philippines","name":"Coron, Philippines","country":"Philippines","is_tbd":false},{"id":"leganes-philippines","name":"Leganes, Philippines","country":"Philippines","is_tbd":false},{"id":"masbate-city-philippines","name":"Masbate City, Philippines","country":"Philippines","is_tbd":false},{"id":"milagros-philippines","name":"Milagros, Philippines","country":"Philippines","is_tbd":false},{"id":"naga-philippines","name":"Naga, Philippines","country":"Philippines","is_tbd":false},{"id":"ormoc-philippines","name":"Ormoc, Philippines","country":"Philippines","is_tbd":false},{"id":"pasacao-philippines","name":"Pasacao, Philippines","country":"Philippines","is_tbd":false},{"id":"roxas-city-philippines","name":"Roxas City, Philippines","country":"Philippines","is_tbd":false},{"id":"roxas-philippines","name":"Roxas, Philippines","country":"Philippines","is_tbd":false},{"id":"san-carlos-philippines","name":"San Carlos, Philippines","country":"Philippines","is_tbd":false},{"id":"san-juan-philippines","name":"San Juan, Philippines","country":"Philippines","is_tbd":false},{"id":"san-remigio-philippines","name":"San Remigio, Philippines","country":"Philippines","is_tbd":false},{"id":"tagbilaran-philippines","name":"Tagbilaran, Philippines","country":"Philippines","is_tbd":false},{"id":"talisay-city-philippines","name":"Talisay City, Philippines","country":"Philippines","is_tbd":false},{"id":"taytay-philippines","name":"Taytay, Philippines","country":"Philippines","is_tbd":false},{"id":"toledo-philippines","name":"Toledo, Philippines","country":"Philippines","is_tbd":false}],"notes":null,"url":"https://www.convergeict.com"},"cook-strait":{"id":"cook-strait","name":"Cook Strait","length":"40 km","rfs":"2020","rfs_year":2020,"is_planned":false,"owners":"Transpower NZ","suppliers":null,"landing_points":[{"id":"fighting-bay-new-zealand","name":"Fighting Bay, New Zealand","country":"New Zealand","is_tbd":false},{"id":"oteranga-bay-new-zealand","name":"Oteranga Bay, New Zealand","country":"New Zealand","is_tbd":false}],"notes":null,"url":"https://www.transpower.co.nz/"},"coral-bridge":{"id":"coral-bridge","name":"Coral Bridge","length":null,"rfs":"2025 August","rfs_year":2025,"is_planned":false,"owners":"NaiTel, Telecom Egypt","suppliers":null,"landing_points":[{"id":"taba-egypt","name":"Taba, Egypt","country":"Egypt","is_tbd":false},{"id":"aqaba-jordan","name":"Aqaba, Jordan","country":"Jordan","is_tbd":false}],"notes":null,"url":null},"corse-continent-4-cc4":{"id":"corse-continent-4-cc4","name":"Corse-Continent 4 (CC4)","length":"190 km","rfs":"1992","rfs_year":1992,"is_planned":false,"owners":"Orange","suppliers":"ASN","landing_points":[{"id":"cannes-france","name":"Cannes, France","country":"France","is_tbd":false},{"id":"lle-rousse-france","name":"L’Île-Rousse, France","country":"France","is_tbd":false}],"notes":null,"url":null},"coral-sea-cable-system-cs":{"id":"coral-sea-cable-system-cs","name":"Coral Sea Cable System (CS²)","length":"4,700 km","rfs":"2020 February","rfs_year":2020,"is_planned":false,"owners":"PNG DataCo Limited, Solomon Island Submarine Cable Company","suppliers":"ASN","landing_points":[{"id":"sydney-nsw-australia","name":"Sydney, NSW, Australia","country":"Australia","is_tbd":false},{"id":"port-moresby-papua-new-guinea","name":"Port Moresby, Papua New Guinea","country":"Papua New Guinea","is_tbd":false},{"id":"auki-solomon-islands","name":"Auki, Solomon Islands","country":"Solomon Islands","is_tbd":false},{"id":"honiara-solomon-islands","name":"Honiara, Solomon Islands","country":"Solomon Islands","is_tbd":false},{"id":"noro-solomon-islands","name":"Noro, Solomon Islands","country":"Solomon Islands","is_tbd":false},{"id":"taro-solomon-islands","name":"Taro, Solomon Islands","country":"Solomon Islands","is_tbd":false}],"notes":null,"url":"https://coralseacablecompany.com/"},"corse-continent-5-cc5":{"id":"corse-continent-5-cc5","name":"Corse-Continent 5 (CC5)","length":"299 km","rfs":"1995","rfs_year":1995,"is_planned":false,"owners":"Orange","suppliers":"ASN","landing_points":[{"id":"ajaccio-france","name":"Ajaccio, France","country":"France","is_tbd":false},{"id":"la-seyne-france","name":"La Seyne, France","country":"France","is_tbd":false}],"notes":null,"url":null},"cowes-fawley-2":{"id":"cowes-fawley-2","name":"Cowes-Fawley 2","length":null,"rfs":"2018","rfs_year":2018,"is_planned":false,"owners":"BT","suppliers":null,"landing_points":[{"id":"gurnard-united-kingdom","name":"Gurnard, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"lepe-united-kingdom","name":"Lepe, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"cross-sound-cable":{"id":"cross-sound-cable","name":"Cross Sound Cable","length":"40 km","rfs":"2003","rfs_year":2003,"is_planned":false,"owners":"Cross Sound Cable Company","suppliers":"Ericsson","landing_points":[{"id":"new-haven-ct-united-states","name":"New Haven, CT, United States","country":"United States","is_tbd":false},{"id":"shoreham-ny-united-states","name":"Shoreham, NY, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.crosssoundcable.com/dark-fiber-optics/"},"crosschannel-fibre":{"id":"crosschannel-fibre","name":"CrossChannel Fibre","length":"149 km","rfs":"2021 December","rfs_year":2021,"is_planned":false,"owners":"Crosslake Fibre","suppliers":"Hexatronic","landing_points":[{"id":"veules-les-roses-france","name":"Veules-les-Roses, France","country":"France","is_tbd":false},{"id":"brighton-united-kingdom","name":"Brighton, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"http://www.crosslakefibre.ca"},"cross-straits-cable-network-cscn":{"id":"cross-straits-cable-network-cscn","name":"Cross-Straits Cable Network (CSCN)","length":"21 km","rfs":"2012 August","rfs_year":2012,"is_planned":false,"owners":"China Mobile, China Telecom, China Unicom, Chunghwa Telecom","suppliers":null,"landing_points":[{"id":"dadeng-island-china","name":"Dadeng Island, China","country":"China","is_tbd":false},{"id":"guanyin-mountain-china","name":"Guanyin Mountain, China","country":"China","is_tbd":false},{"id":"guningtou-taiwan","name":"Guningtou, Taiwan","country":"Taiwan","is_tbd":false},{"id":"lake-ci-taiwan","name":"Lake Ci, Taiwan","country":"Taiwan","is_tbd":false}],"notes":null,"url":null},"crosslake-fibre":{"id":"crosslake-fibre","name":"Crosslake Fibre","length":"59 km","rfs":"2019 October","rfs_year":2019,"is_planned":false,"owners":"Crosslake Fibre","suppliers":"Hexatronic","landing_points":[{"id":"toronto-on-canada","name":"Toronto, ON, Canada","country":"Canada","is_tbd":false},{"id":"buffalo-ny-united-states","name":"Buffalo, NY, United States","country":"United States","is_tbd":false}],"notes":null,"url":"http://www.crosslakefibre.ca"},"curie":{"id":"curie","name":"Curie","length":"10,476 km","rfs":"2020 Q2","rfs_year":2020,"is_planned":false,"owners":"Google","suppliers":"SubCom","landing_points":[{"id":"valparaso-chile","name":"Valparaíso, Chile","country":"Chile","is_tbd":false},{"id":"balboa-panama","name":"Balboa, Panama","country":"Panama","is_tbd":false},{"id":"el-segundo-ca-united-states","name":"El Segundo, CA, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://cloud.google.com"},"cyclades-a":{"id":"cyclades-a","name":"Cyclades A","length":"222 km","rfs":"2018","rfs_year":2018,"is_planned":false,"owners":"Grid Telecom","suppliers":"Hellenic Cables, Prysmian","landing_points":[{"id":"ermoupoli-greece","name":"Ermoupoli, Greece","country":"Greece","is_tbd":false},{"id":"lavrio-greece","name":"Lavrio, Greece","country":"Greece","is_tbd":false},{"id":"marlas-greece","name":"Marlas, Greece","country":"Greece","is_tbd":false},{"id":"mykonos-greece","name":"Mykonos, Greece","country":"Greece","is_tbd":false},{"id":"naousa-greece","name":"Naousa, Greece","country":"Greece","is_tbd":false}],"notes":null,"url":null},"cyclades-b":{"id":"cyclades-b","name":"Cyclades B","length":"52 km","rfs":"2020","rfs_year":2020,"is_planned":false,"owners":"Grid Telecom","suppliers":"Hellenic Cables","landing_points":[{"id":"mykonos-greece","name":"Mykonos, Greece","country":"Greece","is_tbd":false},{"id":"naousa-greece","name":"Naousa, Greece","country":"Greece","is_tbd":false},{"id":"naxos-greece","name":"Naxos, Greece","country":"Greece","is_tbd":false}],"notes":null,"url":null},"daito-loop":{"id":"daito-loop","name":"Daito Loop","length":"18 km","rfs":"2025","rfs_year":2025,"is_planned":false,"owners":"Okinawa Prefecture","suppliers":null,"landing_points":[{"id":"kitadaito-japan","name":"Kitadaito, Japan","country":"Japan","is_tbd":false},{"id":"minamidaito-japan","name":"Minamidaito, Japan","country":"Japan","is_tbd":false}],"notes":null,"url":null},"dalian-yantai-cable":{"id":"dalian-yantai-cable","name":"Dalian-Yantai Cable","length":"146 km","rfs":"1998 March","rfs_year":1998,"is_planned":false,"owners":"China Telecom","suppliers":"ASN","landing_points":[{"id":"dalian-china","name":"Dalian, China","country":"China","is_tbd":false},{"id":"yantai-china","name":"Yantai, China","country":"China","is_tbd":false}],"notes":null,"url":null},"damai-cable-system":{"id":"damai-cable-system","name":"DAMAI Cable System","length":"575 km","rfs":"2019 July","rfs_year":2019,"is_planned":false,"owners":"Triasmitra","suppliers":null,"landing_points":[{"id":"dumai-indonesia","name":"Dumai, Indonesia","country":"Indonesia","is_tbd":false},{"id":"medan-indonesia","name":"Medan, Indonesia","country":"Indonesia","is_tbd":false},{"id":"panipahan-indonesia","name":"Panipahan, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"http://www.triasmitra.com/"},"danice":{"id":"danice","name":"DANICE","length":"2,304 km","rfs":"2009 August","rfs_year":2009,"is_planned":false,"owners":"Farice","suppliers":"SubCom","landing_points":[{"id":"blaabjerg-denmark","name":"Blaabjerg, Denmark","country":"Denmark","is_tbd":false},{"id":"landeyjar-iceland","name":"Landeyjar, Iceland","country":"Iceland","is_tbd":false}],"notes":null,"url":"http://www.farice.is"},"danica-north":{"id":"danica-north","name":"Danica North","length":"25 km","rfs":"1998","rfs_year":1998,"is_planned":false,"owners":"Colt","suppliers":"Ericsson","landing_points":[{"id":"tuborg-denmark","name":"Tuborg, Denmark","country":"Denmark","is_tbd":false},{"id":"barsebck-sweden","name":"Barsebäck, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":null},"daraja":{"id":"daraja","name":"Daraja","length":"4,108 km","rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"Meta, Safaricom","suppliers":"ASN","landing_points":[{"id":"nyali-kenya","name":"Nyali, Kenya","country":"Kenya","is_tbd":false},{"id":"salalah-oman","name":"Salalah, Oman","country":"Oman","is_tbd":false}],"notes":null,"url":null},"deep-blue-one":{"id":"deep-blue-one","name":"Deep Blue One","length":"2,250 km","rfs":"2024 May","rfs_year":2024,"is_planned":false,"owners":"Digicel","suppliers":"ASN","landing_points":[{"id":"cayenne-french-guiana","name":"Cayenne, French Guiana","country":"French Guiana","is_tbd":false},{"id":"georgetown-guyana","name":"Georgetown, Guyana","country":"Guyana","is_tbd":false},{"id":"paramaribo-suriname","name":"Paramaribo, Suriname","country":"Suriname","is_tbd":false},{"id":"chaguaramas-trinidad-and-tobago","name":"Chaguaramas, Trinidad and Tobago","country":"Trinidad and Tobago","is_tbd":false},{"id":"rockly-bay-trinidad-and-tobago","name":"Rockly Bay, Trinidad and Tobago","country":"Trinidad and Tobago","is_tbd":false}],"notes":null,"url":"https://www.digicelgroup.com/"},"darwin-jakarta-singapore-cable-djsc":{"id":"darwin-jakarta-singapore-cable-djsc","name":"Darwin-Jakarta-Singapore Cable (DJSC)","length":"1,000 km","rfs":"2023 July","rfs_year":2023,"is_planned":false,"owners":"Vocus Communications","suppliers":"ASN","landing_points":[{"id":"port-hedland-wa-australia","name":"Port Hedland, WA, Australia","country":"Australia","is_tbd":false}],"notes":null,"url":"https://www.vocus.com.au/"},"denmark-sweden-17":{"id":"denmark-sweden-17","name":"Denmark-Sweden 17","length":"11 km","rfs":"1994","rfs_year":1994,"is_planned":false,"owners":"TDC Group, Tele2","suppliers":null,"landing_points":[{"id":"alsgarde-denmark","name":"Alsgarde, Denmark","country":"Denmark","is_tbd":false},{"id":"kristinelund-sweden","name":"Kristinelund, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":null},"denmark-sweden-18":{"id":"denmark-sweden-18","name":"Denmark-Sweden 18","length":null,"rfs":"1996","rfs_year":1996,"is_planned":false,"owners":"TDC Group, Telenor","suppliers":null,"landing_points":[{"id":"helsingr-denmark","name":"Helsingør, Denmark","country":"Denmark","is_tbd":false},{"id":"helsingborg-sweden","name":"Helsingborg, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":null},"denpasar-waingapu-cable-systems":{"id":"denpasar-waingapu-cable-systems","name":"Denpasar-Waingapu Cable Systems","length":"814 km","rfs":"2019","rfs_year":2019,"is_planned":false,"owners":"Moratelindo","suppliers":null,"landing_points":[{"id":"lombok-indonesia","name":"Lombok, Indonesia","country":"Indonesia","is_tbd":false},{"id":"padang-galak-indonesia","name":"Padang Galak, Indonesia","country":"Indonesia","is_tbd":false},{"id":"sumbawa-besar-indonesia","name":"Sumbawa Besar, Indonesia","country":"Indonesia","is_tbd":false},{"id":"waingapu-indonesia","name":"Waingapu, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"http://www.moratelindo.co.id"},"dhiraagu-cable-network":{"id":"dhiraagu-cable-network","name":"Dhiraagu Cable Network","length":"1,253 km","rfs":"2012 April","rfs_year":2012,"is_planned":false,"owners":"Dhiraagu","suppliers":"NEC","landing_points":[{"id":"dhangethi-maldives","name":"Dhangethi, Maldives","country":"Maldives","is_tbd":false},{"id":"eydhafushi-maldives","name":"Eydhafushi, Maldives","country":"Maldives","is_tbd":false},{"id":"fuvahmulah-maldives","name":"Fuvahmulah, Maldives","country":"Maldives","is_tbd":false},{"id":"gahdhoo-maldives","name":"Gahdhoo, Maldives","country":"Maldives","is_tbd":false},{"id":"gan-maldives","name":"Gan, Maldives","country":"Maldives","is_tbd":false},{"id":"hithadhoo-maldives","name":"Hithadhoo, Maldives","country":"Maldives","is_tbd":false},{"id":"hulhumale-maldives","name":"Hulhumale, Maldives","country":"Maldives","is_tbd":false},{"id":"kulhudhufushi-maldives","name":"Kulhudhufushi, Maldives","country":"Maldives","is_tbd":false}],"notes":null,"url":"https://www.dhiraagu.com.mv/"},"dhiraagu-slt-submarine-cable-network":{"id":"dhiraagu-slt-submarine-cable-network","name":"Dhiraagu-SLT Submarine Cable Network","length":"850 km","rfs":"2007 February","rfs_year":2007,"is_planned":false,"owners":"Dhiraagu, Sri Lanka Telecom","suppliers":"NEC","landing_points":[{"id":"male-maldives","name":"Male, Maldives","country":"Maldives","is_tbd":false},{"id":"colombo-sri-lanka","name":"Colombo, Sri Lanka","country":"Sri Lanka","is_tbd":false}],"notes":null,"url":"https://www.dhiraagu.com.mv/"},"dhivaru":{"id":"dhivaru","name":"Dhivaru","length":null,"rfs":null,"rfs_year":null,"is_planned":true,"owners":"Google","suppliers":"SubCom","landing_points":[{"id":"flying-fish-cove-christmas-island","name":"Flying Fish Cove, Christmas Island","country":"Christmas Island","is_tbd":false},{"id":"hithadhoo-maldives","name":"Hithadhoo, Maldives","country":"Maldives","is_tbd":false},{"id":"muscat-oman","name":"Muscat, Oman","country":"Oman","is_tbd":true}],"notes":null,"url":null},"didon":{"id":"didon","name":"Didon","length":"170 km","rfs":"2014 May","rfs_year":2014,"is_planned":false,"owners":"Ooredoo Tunisie, Orange Tunisie","suppliers":"ASN","landing_points":[{"id":"mazara-del-vallo-italy","name":"Mazara del Vallo, Italy","country":"Italy","is_tbd":false},{"id":"kelibia-tunisia","name":"Kelibia, Tunisia","country":"Tunisia","is_tbd":false}],"notes":null,"url":null},"djibouti-africa-regional-express-1-dare-1":{"id":"djibouti-africa-regional-express-1-dare-1","name":"Djibouti Africa Regional Express 1 (DARE 1)","length":"4,854 km","rfs":"2021 February","rfs_year":2021,"is_planned":false,"owners":"Djibouti Telecom, Hormuud Telecom Somalia, Somtel International, Telkom Kenya","suppliers":"SubCom","landing_points":[{"id":"djibouti-city-djibouti","name":"Djibouti City, Djibouti","country":"Djibouti","is_tbd":false},{"id":"mombasa-kenya","name":"Mombasa, Kenya","country":"Kenya","is_tbd":false},{"id":"mahajanga-madagascar","name":"Mahajanga, Madagascar","country":"Madagascar","is_tbd":false},{"id":"toliara-madagascar","name":"Toliara, Madagascar","country":"Madagascar","is_tbd":false},{"id":"beira-mozambique","name":"Beira, Mozambique","country":"Mozambique","is_tbd":false},{"id":"maputo-mozambique","name":"Maputo, Mozambique","country":"Mozambique","is_tbd":false},{"id":"nacala-mozambique","name":"Nacala, Mozambique","country":"Mozambique","is_tbd":false},{"id":"bosaso-somalia","name":"Bosaso, Somalia","country":"Somalia","is_tbd":false},{"id":"mogadishu-somalia","name":"Mogadishu, Somalia","country":"Somalia","is_tbd":false},{"id":"mtunzini-south-africa","name":"Mtunzini, South Africa","country":"South Africa","is_tbd":false},{"id":"dar-es-salaam-tanzania","name":"Dar Es Salaam, Tanzania","country":"Tanzania","is_tbd":false},{"id":"mtwara-tanzania","name":"Mtwara, Tanzania","country":"Tanzania","is_tbd":false}],"notes":null,"url":null},"domestic-submarine-cable-of-maldives-dscom":{"id":"domestic-submarine-cable-of-maldives-dscom","name":"Domestic Submarine Cable of Maldives (DSCoM)","length":"286 km","rfs":"2024","rfs_year":2024,"is_planned":false,"owners":"Dhiraagu, Ooredoo Maldives","suppliers":"HMN Tech","landing_points":[{"id":"dhangethi-maldives","name":"Dhangethi, Maldives","country":"Maldives","is_tbd":false},{"id":"dhuvaafaru-maldives","name":"Dhuvaafaru, Maldives","country":"Maldives","is_tbd":false},{"id":"eydhafushi-maldives","name":"Eydhafushi, Maldives","country":"Maldives","is_tbd":false},{"id":"hulhumale-maldives","name":"Hulhumale, Maldives","country":"Maldives","is_tbd":false},{"id":"kudahuvadhoo-maldives","name":"Kudahuvadhoo, Maldives","country":"Maldives","is_tbd":false},{"id":"maafushi-maldives","name":"Maafushi, Maldives","country":"Maldives","is_tbd":false},{"id":"maamigili-maldives","name":"Maamigili, Maldives","country":"Maldives","is_tbd":false},{"id":"velidhoo-maldives","name":"Velidhoo, Maldives","country":"Maldives","is_tbd":false}],"notes":null,"url":null},"dumai-melaka-cable-system-dmcs":{"id":"dumai-melaka-cable-system-dmcs","name":"Dumai-Melaka Cable System (DMCS)","length":"159 km","rfs":"2005 February","rfs_year":2005,"is_planned":false,"owners":"Telekom Malaysia, Telkom Indonesia","suppliers":"NEC","landing_points":[{"id":"dumai-indonesia","name":"Dumai, Indonesia","country":"Indonesia","is_tbd":false},{"id":"melaka-malaysia","name":"Melaka, Malaysia","country":"Malaysia","is_tbd":false}],"notes":null,"url":"https://www.tm.com.my"},"dos-continentes-l-ll":{"id":"dos-continentes-l-ll","name":"DOS CONTINENTES l & ll","length":"95 km","rfs":"2020 March","rfs_year":2020,"is_planned":false,"owners":"GTD España","suppliers":null,"landing_points":[{"id":"la-lnea-spain","name":"La Línea, Spain","country":"Spain","is_tbd":false},{"id":"playa-de-benitez-spain","name":"Playa de Benitez, Spain","country":"Spain","is_tbd":false},{"id":"playa-de-la-ribera-spain","name":"Playa de la Ribera, Spain","country":"Spain","is_tbd":false},{"id":"tarifa-spain","name":"Tarifa, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":"http://www.gtdespana.com/"},"dunant":{"id":"dunant","name":"Dunant","length":"6,400 km","rfs":"2021 January","rfs_year":2021,"is_planned":false,"owners":"Google","suppliers":"SubCom","landing_points":[{"id":"saint-hilaire-de-riez-france","name":"Saint-Hilaire-de-Riez, France","country":"France","is_tbd":false},{"id":"virginia-beach-va-united-states","name":"Virginia Beach, VA, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://cloud.google.com"},"e-finest":{"id":"e-finest","name":"E-FINEST","length":null,"rfs":"2019 Q4","rfs_year":2019,"is_planned":false,"owners":"Elisa Corporation","suppliers":null,"landing_points":[{"id":"tallinn-estonia","name":"Tallinn, Estonia","country":"Estonia","is_tbd":false},{"id":"espoo-finland","name":"Espoo, Finland","country":"Finland","is_tbd":false}],"notes":null,"url":null},"e-llan":{"id":"e-llan","name":"E-LLAN","length":null,"rfs":"2007","rfs_year":2007,"is_planned":false,"owners":"Manx Electricity Authority","suppliers":null,"landing_points":[{"id":"douglas-isle-of-man","name":"Douglas, Isle of Man","country":"Isle of Man","is_tbd":false},{"id":"blackpool-united-kingdom","name":"Blackpool, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"e2a":{"id":"e2a","name":"E2A","length":"12,500 km","rfs":"2029","rfs_year":2029,"is_planned":true,"owners":"Chunghwa Telecom, SK Broadband, Softbank, Verizon","suppliers":"ASN","landing_points":[{"id":"itoshima-japan","name":"Itoshima, Japan","country":"Japan","is_tbd":false},{"id":"maruyama-japan","name":"Maruyama, Japan","country":"Japan","is_tbd":false},{"id":"tomakomai-japan","name":"Tomakomai, Japan","country":"Japan","is_tbd":false},{"id":"busan-south-korea","name":"Busan, South Korea","country":"South Korea","is_tbd":false},{"id":"toucheng-taiwan","name":"Toucheng, Taiwan","country":"Taiwan","is_tbd":false},{"id":"morro-bay-ca-united-states","name":"Morro Bay, CA, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"eac-c2c":{"id":"eac-c2c","name":"EAC-C2C","length":"36,500 km","rfs":"2002 November","rfs_year":2002,"is_planned":false,"owners":"Telstra","suppliers":"ASN, KDD-SCS, SubCom","landing_points":[{"id":"chung-hom-kok-china","name":"Chung Hom Kok, China","country":"China","is_tbd":false},{"id":"nanhui-china","name":"Nanhui, China","country":"China","is_tbd":false},{"id":"qingdao-china","name":"Qingdao, China","country":"China","is_tbd":false},{"id":"tseung-kwan-o-china","name":"Tseung Kwan O, China","country":"China","is_tbd":false},{"id":"ajigaura-japan","name":"Ajigaura, Japan","country":"Japan","is_tbd":false},{"id":"chikura-japan","name":"Chikura, Japan","country":"Japan","is_tbd":false},{"id":"shima-japan","name":"Shima, Japan","country":"Japan","is_tbd":false},{"id":"batangas-philippines","name":"Batangas, Philippines","country":"Philippines","is_tbd":false},{"id":"cavite-philippines","name":"Cavite, Philippines","country":"Philippines","is_tbd":false},{"id":"changi-north-singapore","name":"Changi North, Singapore","country":"Singapore","is_tbd":false},{"id":"changi-south-singapore","name":"Changi South, Singapore","country":"Singapore","is_tbd":false},{"id":"busan-south-korea","name":"Busan, South Korea","country":"South Korea","is_tbd":false},{"id":"shindu-ri-south-korea","name":"Shindu-Ri, South Korea","country":"South Korea","is_tbd":false},{"id":"fangshan-taiwan","name":"Fangshan, Taiwan","country":"Taiwan","is_tbd":false},{"id":"pa-li-taiwan","name":"Pa Li, Taiwan","country":"Taiwan","is_tbd":false},{"id":"tanshui-taiwan","name":"Tanshui, Taiwan","country":"Taiwan","is_tbd":false}],"notes":null,"url":"https://www.telstrainternational.com/"},"eagle":{"id":"eagle","name":"EAGLE","length":"2,000 km","rfs":"2028","rfs_year":2028,"is_planned":true,"owners":"4iG Group, Telecom Egypt","suppliers":null,"landing_points":[{"id":"seman-albania","name":"Seman, Albania","country":"Albania","is_tbd":false},{"id":"sidi-kerir-egypt","name":"Sidi Kerir, Egypt","country":"Egypt","is_tbd":false},{"id":"lecce-italy","name":"Lecce, Italy","country":"Italy","is_tbd":false}],"notes":null,"url":null},"east-micronesia-cable-system-emcs":{"id":"east-micronesia-cable-system-emcs","name":"East Micronesia Cable System (EMCS)","length":"2,250 km","rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"BwebwerikiNET Limited (BNL), Federated States of Micronesia Telecommunications Cable Corporation (FSMTCC), Nauru Fibre Cable Corporation","suppliers":"NEC","landing_points":[{"id":"tarawa-kiribati","name":"Tarawa, Kiribati","country":"Kiribati","is_tbd":false},{"id":"kosrae-micronesia","name":"Kosrae, Micronesia","country":"Micronesia","is_tbd":false},{"id":"pohnpei-micronesia","name":"Pohnpei, Micronesia","country":"Micronesia","is_tbd":false},{"id":"yaren-nauru","name":"Yaren, Nauru","country":"Nauru","is_tbd":false}],"notes":null,"url":"https://www.eastmicronesiacable.com/"},"east-west-cable-ewc":{"id":"east-west-cable-ewc","name":"East-West Cable (EWC)","length":"1,705 km","rfs":"2011 February","rfs_year":2011,"is_planned":false,"owners":"Liberty Networks","suppliers":"Xtera","landing_points":[{"id":"haina-dominican-republic","name":"Haina, Dominican Republic","country":"Dominican Republic","is_tbd":false},{"id":"harbour-view-jamaica","name":"Harbour View, Jamaica","country":"Jamaica","is_tbd":false},{"id":"nanny-cay-virgin-islands-u-k-","name":"Nanny Cay, Virgin Islands (U.K.)","country":"Virgin Islands (U.K.)","is_tbd":false}],"notes":null,"url":"https://libertynet.com/contact"},"east-west-submarine-cable-system":{"id":"east-west-submarine-cable-system","name":"East-West Submarine Cable System","length":"950 km","rfs":"2004","rfs_year":2004,"is_planned":false,"owners":"Sacofa","suppliers":null,"landing_points":[{"id":"penarik-indonesia","name":"Penarik, Indonesia","country":"Indonesia","is_tbd":false},{"id":"terempa-indonesia","name":"Terempa, Indonesia","country":"Indonesia","is_tbd":false},{"id":"kuching-malaysia","name":"Kuching, Malaysia","country":"Malaysia","is_tbd":false},{"id":"mersing-malaysia","name":"Mersing, Malaysia","country":"Malaysia","is_tbd":false}],"notes":null,"url":"https://www.sacofa.com.my"},"eastern-africa-submarine-system-eassy":{"id":"eastern-africa-submarine-system-eassy","name":"Eastern Africa Submarine System (EASSy)","length":"10,500 km","rfs":"2010 July","rfs_year":2010,"is_planned":false,"owners":"BT, Bayobab, Bharti Airtel, Botswana Fibre Networks, Comores Telecom, Djibouti Telecom, Liquid Intelligent Technologies, Mauritius Telecom, Orange, Sudatel, Tanzania Telecommunication Corporation, Telkom Kenya, Telkom South Africa, Telma (Telecom Malagasy), Vodacom DRC, WIOCC, Zambia Telecom, center3, e&","suppliers":"ASN","landing_points":[{"id":"moroni-comoros","name":"Moroni, Comoros","country":"Comoros","is_tbd":false},{"id":"haramous-djibouti","name":"Haramous, Djibouti","country":"Djibouti","is_tbd":false},{"id":"mombasa-kenya","name":"Mombasa, Kenya","country":"Kenya","is_tbd":false},{"id":"toliara-madagascar","name":"Toliara, Madagascar","country":"Madagascar","is_tbd":false},{"id":"maputo-mozambique","name":"Maputo, Mozambique","country":"Mozambique","is_tbd":false},{"id":"mogadishu-somalia","name":"Mogadishu, Somalia","country":"Somalia","is_tbd":false},{"id":"mtunzini-south-africa","name":"Mtunzini, South Africa","country":"South Africa","is_tbd":false},{"id":"port-sudan-sudan","name":"Port Sudan, Sudan","country":"Sudan","is_tbd":false},{"id":"dar-es-salaam-tanzania","name":"Dar Es Salaam, Tanzania","country":"Tanzania","is_tbd":false}],"notes":null,"url":null},"eastern-light-sweden-finland-i":{"id":"eastern-light-sweden-finland-i","name":"Eastern Light Sweden-Finland I","length":null,"rfs":"2019 October","rfs_year":2019,"is_planned":false,"owners":"Eastern Light","suppliers":null,"landing_points":[{"id":"hanko-finland","name":"Hanko, Finland","country":"Finland","is_tbd":false},{"id":"helsinki-finland","name":"Helsinki, Finland","country":"Finland","is_tbd":false},{"id":"kotka-finland","name":"Kotka, Finland","country":"Finland","is_tbd":false},{"id":"stockholm-sweden","name":"Stockholm, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":"https://easternlight.se/"},"eastern-caribbean-fiber-system-ecfs":{"id":"eastern-caribbean-fiber-system-ecfs","name":"Eastern Caribbean Fiber System (ECFS)","length":"1,730 km","rfs":"1995 September","rfs_year":1995,"is_planned":false,"owners":"AT&T, Claro Dominicana (Codetel), Guyana Telephone and Telegraph (GT&T), Liberty Networks, Orange, Verizon","suppliers":"ASN","landing_points":[{"id":"the-valley-anguilla","name":"The Valley, Anguilla","country":"Anguilla","is_tbd":false},{"id":"st-johns-antigua-and-barbuda","name":"St. John’s, Antigua and Barbuda","country":"Antigua and Barbuda","is_tbd":false},{"id":"bridgetown-barbados","name":"Bridgetown, Barbados","country":"Barbados","is_tbd":false},{"id":"roseau-dominica","name":"Roseau, Dominica","country":"Dominica","is_tbd":false},{"id":"point-salines-grenada","name":"Point Salines, Grenada","country":"Grenada","is_tbd":false},{"id":"pointe-a-pitre-guadeloupe","name":"Pointe-a-Pitre, Guadeloupe","country":"Guadeloupe","is_tbd":false},{"id":"le-lamentin-martinique","name":"Le Lamentin, Martinique","country":"Martinique","is_tbd":false},{"id":"frigate-bay-saint-kitts-and-nevis","name":"Frigate Bay, Saint Kitts and Nevis","country":"Saint Kitts and Nevis","is_tbd":false},{"id":"castries-saint-lucia","name":"Castries, Saint Lucia","country":"Saint Lucia","is_tbd":false},{"id":"kingstown-saint-vincent-and-the-grenadines","name":"Kingstown, Saint Vincent and the Grenadines","country":"Saint Vincent and the Grenadines","is_tbd":false},{"id":"saint-maarten-sint-maarten","name":"Saint Maarten, Sint Maarten","country":"Sint Maarten","is_tbd":false},{"id":"chaguaramas-trinidad-and-tobago","name":"Chaguaramas, Trinidad and Tobago","country":"Trinidad and Tobago","is_tbd":false},{"id":"tortola-virgin-islands-u-k-","name":"Tortola, Virgin Islands (U.K.)","country":"Virgin Islands (U.K.)","is_tbd":false}],"notes":null,"url":null},"eaufon-1":{"id":"eaufon-1","name":"EAUFON 1","length":"1,175 km","rfs":"2022 Q1","rfs_year":2022,"is_planned":false,"owners":"Tamaani Internet","suppliers":"ASN","landing_points":[{"id":"chisasibi-qc-canada","name":"Chisasibi, QC, Canada","country":"Canada","is_tbd":false},{"id":"inukjuak-qc-canada","name":"Inukjuak, QC, Canada","country":"Canada","is_tbd":false},{"id":"kuujjuarapik-qc-canada","name":"Kuujjuarapik, QC, Canada","country":"Canada","is_tbd":false},{"id":"puvirnituq-qc-canada","name":"Puvirnituq, QC, Canada","country":"Canada","is_tbd":false},{"id":"umiujaq-qc-canada","name":"Umiujaq, QC, Canada","country":"Canada","is_tbd":false}],"notes":null,"url":"http://krg.ca/"},"eastern-light-sweden-finland-ii":{"id":"eastern-light-sweden-finland-ii","name":"Eastern Light Sweden-Finland II","length":null,"rfs":"2027","rfs_year":2027,"is_planned":true,"owners":"Eastern Light","suppliers":null,"landing_points":[{"id":"espoo-finland","name":"Espoo, Finland","country":"Finland","is_tbd":false},{"id":"hanko-finland","name":"Hanko, Finland","country":"Finland","is_tbd":false},{"id":"kkar-finland","name":"Kökar, Finland","country":"Finland","is_tbd":false},{"id":"kista-sweden","name":"Kista, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":"https://easternlight.se/"},"eaufon-2":{"id":"eaufon-2","name":"EAUFON 2","length":"675 km","rfs":"2024 February","rfs_year":2024,"is_planned":false,"owners":"Tamaani Internet","suppliers":"ASN","landing_points":[{"id":"akulivik-qc-canada","name":"Akulivik, QC, Canada","country":"Canada","is_tbd":false},{"id":"deception-qc-canada","name":"Deception, QC, Canada","country":"Canada","is_tbd":false},{"id":"ivujivik-qc-canada","name":"Ivujivik, QC, Canada","country":"Canada","is_tbd":false},{"id":"kangiqsujuaq-qc-canada","name":"Kangiqsujuaq, QC, Canada","country":"Canada","is_tbd":false},{"id":"puvirnituq-qc-canada","name":"Puvirnituq, QC, Canada","country":"Canada","is_tbd":false},{"id":"salluit-qc-canada","name":"Salluit, QC, Canada","country":"Canada","is_tbd":false}],"notes":null,"url":null},"eaufon-3":{"id":"eaufon-3","name":"EAUFON 3","length":"900 km","rfs":"2027","rfs_year":2027,"is_planned":true,"owners":"Tamaani Internet","suppliers":null,"landing_points":[{"id":"aupaluk-qc-canada","name":"Aupaluk, QC, Canada","country":"Canada","is_tbd":false},{"id":"kangiqsualujjuaq-qc-canada","name":"Kangiqsualujjuaq, QC, Canada","country":"Canada","is_tbd":false},{"id":"kangiqsujuaq-qc-canada","name":"Kangiqsujuaq, QC, Canada","country":"Canada","is_tbd":false},{"id":"kangirsuk-qc-canada","name":"Kangirsuk, QC, Canada","country":"Canada","is_tbd":false},{"id":"kuujjuaq-qc-canada","name":"Kuujjuaq, QC, Canada","country":"Canada","is_tbd":false},{"id":"quaqtaq-qc-canada","name":"Quaqtaq, QC, Canada","country":"Canada","is_tbd":false},{"id":"tasiujaq-qc-canada","name":"Tasiujaq, QC, Canada","country":"Canada","is_tbd":false}],"notes":null,"url":null},"ec-link":{"id":"ec-link","name":"EC Link","length":"1,078 km","rfs":"2007 October","rfs_year":2007,"is_planned":false,"owners":"Liberty Networks","suppliers":"SubCom","landing_points":[{"id":"willemstad-curaao","name":"Willemstad, Curaçao","country":"Curaçao","is_tbd":false},{"id":"chaguaramas-trinidad-and-tobago","name":"Chaguaramas, Trinidad and Tobago","country":"Trinidad and Tobago","is_tbd":false}],"notes":null,"url":"https://libertynet.com"},"echo":{"id":"echo","name":"Echo","length":"17,184 km","rfs":"2025","rfs_year":2025,"is_planned":false,"owners":"Google, Meta","suppliers":"NEC","landing_points":[{"id":"agat-guam","name":"Agat, Guam","country":"Guam","is_tbd":false},{"id":"piti-guam","name":"Piti, Guam","country":"Guam","is_tbd":false},{"id":"tanjung-pakis-indonesia","name":"Tanjung Pakis, Indonesia","country":"Indonesia","is_tbd":false},{"id":"ngeremlengui-palau","name":"Ngeremlengui, Palau","country":"Palau","is_tbd":false},{"id":"changi-north-singapore","name":"Changi North, Singapore","country":"Singapore","is_tbd":false},{"id":"eureka-ca-united-states","name":"Eureka, CA, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"elektra-globalconnect-1-gc1":{"id":"elektra-globalconnect-1-gc1","name":"Elektra-GlobalConnect 1 (GC1)","length":"44 km","rfs":"2000 August","rfs_year":2000,"is_planned":false,"owners":"GlobalConnect","suppliers":null,"landing_points":[{"id":"gedser-denmark","name":"Gedser, Denmark","country":"Denmark","is_tbd":false},{"id":"rostock-germany","name":"Rostock, Germany","country":"Germany","is_tbd":false}],"notes":null,"url":"https://www.globalconnectcarrier.com/"},"ellalink":{"id":"ellalink","name":"EllaLink","length":"6,200 km","rfs":"2021 June","rfs_year":2021,"is_planned":false,"owners":"EllaLink","suppliers":"ASN","landing_points":[{"id":"fortaleza-brazil","name":"Fortaleza, Brazil","country":"Brazil","is_tbd":false},{"id":"praia-cape-verde","name":"Praia, Cape Verde","country":"Cape Verde","is_tbd":false},{"id":"cayenne-french-guiana","name":"Cayenne, French Guiana","country":"French Guiana","is_tbd":false},{"id":"nouadhibou-mauritania","name":"Nouadhibou, Mauritania","country":"Mauritania","is_tbd":false},{"id":"casablanca-morocco","name":"Casablanca, Morocco","country":"Morocco","is_tbd":false},{"id":"funchal-portugal","name":"Funchal, Portugal","country":"Portugal","is_tbd":false},{"id":"sines-portugal","name":"Sines, Portugal","country":"Portugal","is_tbd":false}],"notes":null,"url":"https://www.ella.link/"},"emc-west-1":{"id":"emc-west-1","name":"EMC West-1","length":"3,639 km","rfs":"2027 December","rfs_year":2027,"is_planned":true,"owners":"EMC Subsea Cable Company Limited","suppliers":"ASN","landing_points":[{"id":"athens-greece","name":"Athens, Greece","country":"Greece","is_tbd":false},{"id":"tympaki-greece","name":"Tympaki, Greece","country":"Greece","is_tbd":false},{"id":"netanya-israel","name":"Netanya, Israel","country":"Israel","is_tbd":false},{"id":"genoa-italy","name":"Genoa, Italy","country":"Italy","is_tbd":false},{"id":"haql-saudi-arabia","name":"Haql, Saudi Arabia","country":"Saudi Arabia","is_tbd":false}],"notes":"Integrated dual terrestrial links are planned to connect the EMC West-1 landing station in Israel to Haql","url":null},"emc-west-2":{"id":"emc-west-2","name":"EMC West-2","length":"3,978 km","rfs":"2027 December","rfs_year":2027,"is_planned":true,"owners":"EMC Subsea Cable Company Limited","suppliers":"ASN","landing_points":[{"id":"marseille-france","name":"Marseille, France","country":"France","is_tbd":false},{"id":"tympaki-greece","name":"Tympaki, Greece","country":"Greece","is_tbd":false},{"id":"ashkelon-israel","name":"Ashkelon, Israel","country":"Israel","is_tbd":false},{"id":"haql-saudi-arabia","name":"Haql, Saudi Arabia","country":"Saudi Arabia","is_tbd":false}],"notes":"Integrated dual terrestrial links are planned to connect the EMC West-2 landing station in Israel to Haql","url":null},"energinet-laeso-varberg":{"id":"energinet-laeso-varberg","name":"Energinet Laeso-Varberg","length":null,"rfs":"2011","rfs_year":2011,"is_planned":false,"owners":"Energinet","suppliers":null,"landing_points":[{"id":"laeso-denmark","name":"Laeso, Denmark","country":"Denmark","is_tbd":false},{"id":"varberg-sweden","name":"Varberg, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":null},"emerald-bridge-fibres":{"id":"emerald-bridge-fibres","name":"Emerald Bridge Fibres","length":"120 km","rfs":"2012 December","rfs_year":2012,"is_planned":false,"owners":"Zayo","suppliers":"Nexans","landing_points":[{"id":"clonshaugh-ireland","name":"Clonshaugh, Ireland","country":"Ireland","is_tbd":false},{"id":"holyhead-united-kingdom","name":"Holyhead, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"https://zayoeurope.com/"},"energy-bridge-cable":{"id":"energy-bridge-cable","name":"Energy Bridge Cable","length":"13 km","rfs":"2017 July","rfs_year":2017,"is_planned":false,"owners":"Miranda Media","suppliers":null,"landing_points":[{"id":"ilyich-russia","name":"Ilyich, Russia","country":"Russia","is_tbd":false},{"id":"osoviny-ukraine","name":"Osoviny, Ukraine","country":"Ukraine","is_tbd":false}],"notes":null,"url":null},"energinet-lyngsa-laeso":{"id":"energinet-lyngsa-laeso","name":"Energinet Lyngsa-Laeso","length":null,"rfs":"2011 August","rfs_year":2011,"is_planned":false,"owners":"Energinet","suppliers":null,"landing_points":[{"id":"laeso-denmark","name":"Laeso, Denmark","country":"Denmark","is_tbd":false},{"id":"lyngsa-denmark","name":"Lyngsa, Denmark","country":"Denmark","is_tbd":false}],"notes":null,"url":null},"epic-malta-sicily-cable-system-emscs":{"id":"epic-malta-sicily-cable-system-emscs","name":"Epic Malta-Sicily Cable System (EMSCS)","length":"260 km","rfs":"2004 July","rfs_year":2004,"is_planned":false,"owners":"Epic","suppliers":"ASN","landing_points":[{"id":"catania-italy","name":"Catania, Italy","country":"Italy","is_tbd":false},{"id":"balluta-bay-malta","name":"Balluta Bay, Malta","country":"Malta","is_tbd":false}],"notes":null,"url":"https://epic.com.mt/"},"equiano":{"id":"equiano","name":"Equiano","length":"15,000 km","rfs":"2023 February","rfs_year":2023,"is_planned":false,"owners":"Google","suppliers":"ASN","landing_points":[{"id":"swakopmund-namibia","name":"Swakopmund, Namibia","country":"Namibia","is_tbd":false},{"id":"lagos-nigeria","name":"Lagos, Nigeria","country":"Nigeria","is_tbd":false},{"id":"sesimbra-portugal","name":"Sesimbra, Portugal","country":"Portugal","is_tbd":false},{"id":"ruperts-bay-saint-helena-ascension-and-tristan-da-cunha","name":"Rupert's Bay, Saint Helena, Ascension and Tristan da Cunha","country":"Saint Helena, Ascension and Tristan da Cunha","is_tbd":false},{"id":"melkbosstrand-south-africa","name":"Melkbosstrand, South Africa","country":"South Africa","is_tbd":false},{"id":"lome-togo","name":"Lome, Togo","country":"Togo","is_tbd":false}],"notes":null,"url":"https://cloud.google.com"},"esat-2":{"id":"esat-2","name":"ESAT-2","length":"245 km","rfs":"2000","rfs_year":2000,"is_planned":false,"owners":"Esat BT","suppliers":"SubCom","landing_points":[{"id":"sandymount-ireland","name":"Sandymount, Ireland","country":"Ireland","is_tbd":false},{"id":"southport-united-kingdom","name":"Southport, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"http://www.fiberatlantic.com/system/KrQqY"},"est-tet":{"id":"est-tet","name":"Est-Tet","length":"113 km","rfs":"1994 July","rfs_year":1994,"is_planned":false,"owners":"Maroc Telecom, Telxius","suppliers":"STC Submarine Systems","landing_points":[{"id":"ttouan-morocco","name":"Tétouan, Morocco","country":"Morocco","is_tbd":false},{"id":"estepona-spain","name":"Estepona, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":null},"europe-india-gateway-eig":{"id":"europe-india-gateway-eig","name":"Europe India Gateway (EIG)","length":"15,000 km","rfs":"2011 February","rfs_year":2011,"is_planned":false,"owners":"AT&T, Altice Portugal, BT, Bayobab, Bharat Sanchar Nigam Ltd. (BSNL), Bharti Airtel, Djibouti Telecom, Gibtelecom, Kalaam Telecom, Libya International Telecommunications Company, Telecom Egypt, Telkom South Africa, Verizon, Vodafone, Zain Omantel International, center3, du","suppliers":"ASN, SubCom","landing_points":[{"id":"haramous-djibouti","name":"Haramous, Djibouti","country":"Djibouti","is_tbd":false},{"id":"abu-talat-egypt","name":"Abu Talat, Egypt","country":"Egypt","is_tbd":false},{"id":"zafarana-egypt","name":"Zafarana, Egypt","country":"Egypt","is_tbd":false},{"id":"gibraltar-gibraltar","name":"Gibraltar, Gibraltar","country":"Gibraltar","is_tbd":false},{"id":"mumbai-india","name":"Mumbai, India","country":"India","is_tbd":false},{"id":"tripoli-libya","name":"Tripoli, Libya","country":"Libya","is_tbd":false},{"id":"monaco-monaco","name":"Monaco, Monaco","country":"Monaco","is_tbd":false},{"id":"barka-oman","name":"Barka, Oman","country":"Oman","is_tbd":false},{"id":"sesimbra-portugal","name":"Sesimbra, Portugal","country":"Portugal","is_tbd":false},{"id":"jeddah-saudi-arabia","name":"Jeddah, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"fujairah-united-arab-emirates","name":"Fujairah, United Arab Emirates","country":"United Arab Emirates","is_tbd":false},{"id":"bude-united-kingdom","name":"Bude, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"eviny-digital":{"id":"eviny-digital","name":"Eviny Digital","length":"210 km","rfs":"2020 Q2","rfs_year":2020,"is_planned":false,"owners":"Eviny Digital AS","suppliers":null,"landing_points":[{"id":"bergen-norway","name":"Bergen, Norway","country":"Norway","is_tbd":false},{"id":"krst-norway","name":"Kårstø, Norway","country":"Norway","is_tbd":false},{"id":"stavanger-norway","name":"Stavanger, Norway","country":"Norway","is_tbd":false}],"notes":null,"url":"https://www.eviny.no/"},"exa-express":{"id":"exa-express","name":"EXA Express","length":"4,600 km","rfs":"2015 September","rfs_year":2015,"is_planned":false,"owners":"EXA Infrastructure","suppliers":"SubCom","landing_points":[{"id":"halifax-ns-canada","name":"Halifax, NS, Canada","country":"Canada","is_tbd":false},{"id":"cork-ireland","name":"Cork, Ireland","country":"Ireland","is_tbd":false},{"id":"brean-united-kingdom","name":"Brean, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"https://exainfra.net/"},"falcon":{"id":"falcon","name":"FALCON","length":"10,300 km","rfs":"2006 September","rfs_year":2006,"is_planned":false,"owners":"FLAG","suppliers":"ASN","landing_points":[{"id":"manama-bahrain","name":"Manama, Bahrain","country":"Bahrain","is_tbd":false},{"id":"suez-egypt","name":"Suez, Egypt","country":"Egypt","is_tbd":false},{"id":"mumbai-india","name":"Mumbai, India","country":"India","is_tbd":false},{"id":"trivandrum-india","name":"Trivandrum, India","country":"India","is_tbd":false},{"id":"bandar-abbas-iran","name":"Bandar Abbas, Iran","country":"Iran","is_tbd":false},{"id":"chabahar-iran","name":"Chabahar, Iran","country":"Iran","is_tbd":false},{"id":"al-faw-iraq","name":"Al Faw, Iraq","country":"Iraq","is_tbd":false},{"id":"al-safat-kuwait","name":"Al Safat, Kuwait","country":"Kuwait","is_tbd":false},{"id":"male-maldives","name":"Male, Maldives","country":"Maldives","is_tbd":false},{"id":"al-seeb-oman","name":"Al Seeb, Oman","country":"Oman","is_tbd":false},{"id":"khasab-oman","name":"Khasab, Oman","country":"Oman","is_tbd":false},{"id":"doha-qatar","name":"Doha, Qatar","country":"Qatar","is_tbd":false},{"id":"al-khobar-saudi-arabia","name":"Al Khobar, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"jeddah-saudi-arabia","name":"Jeddah, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"colombo-sri-lanka","name":"Colombo, Sri Lanka","country":"Sri Lanka","is_tbd":false},{"id":"port-sudan-sudan","name":"Port Sudan, Sudan","country":"Sudan","is_tbd":false},{"id":"dubai-united-arab-emirates","name":"Dubai, United Arab Emirates","country":"United Arab Emirates","is_tbd":false},{"id":"al-ghaydah-yemen","name":"Al Ghaydah, Yemen","country":"Yemen","is_tbd":false},{"id":"al-hudaydah-yemen","name":"Al Hudaydah, Yemen","country":"Yemen","is_tbd":false}],"notes":null,"url":"https://flagtel.com/"},"exa-north-and-south":{"id":"exa-north-and-south","name":"EXA North and South","length":"12,200 km","rfs":"2001 April","rfs_year":2001,"is_planned":false,"owners":"EXA Infrastructure","suppliers":"SubCom","landing_points":[{"id":"halifax-ns-canada","name":"Halifax, NS, Canada","country":"Canada","is_tbd":false},{"id":"dublin-ireland","name":"Dublin, Ireland","country":"Ireland","is_tbd":false},{"id":"coleraine-united-kingdom","name":"Coleraine, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"southport-united-kingdom","name":"Southport, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"lynn-ma-united-states","name":"Lynn, MA, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://exainfra.net/"},"exelera-north":{"id":"exelera-north","name":"Exelera North","length":"345 km","rfs":"2012 January","rfs_year":2012,"is_planned":false,"owners":"Exelera","suppliers":null,"landing_points":[{"id":"yeroskipos-cyprus","name":"Yeroskipos, Cyprus","country":"Cyprus","is_tbd":false},{"id":"tirat-carmel-israel","name":"Tirat Carmel, Israel","country":"Israel","is_tbd":false}],"notes":null,"url":"https://www.exelera.net/"},"far-east-submarine-cable-system":{"id":"far-east-submarine-cable-system","name":"Far East Submarine Cable System","length":"1,855 km","rfs":"2016 Q2","rfs_year":2016,"is_planned":false,"owners":"Rostelecom","suppliers":"HMN Tech","landing_points":[{"id":"okha-russia","name":"Okha, Russia","country":"Russia","is_tbd":false},{"id":"ola-russia","name":"Ola, Russia","country":"Russia","is_tbd":false},{"id":"ust-bolsheretsk-russia","name":"Ust-Bolsheretsk, Russia","country":"Russia","is_tbd":false}],"notes":null,"url":"https://www.company.rt.ru/"},"farewell-change-fogo":{"id":"farewell-change-fogo","name":"Farewell-Change-Fogo","length":"16 km","rfs":"2025 August","rfs_year":2025,"is_planned":false,"owners":"Bell Canada","suppliers":null,"landing_points":[{"id":"farewell-nl-canada","name":"Farewell, NL, Canada","country":"Canada","is_tbd":false},{"id":"fogo-island-nl-canada","name":"Fogo Island, NL, Canada","country":"Canada","is_tbd":false},{"id":"stag-harbour-nl-canada","name":"Stag Harbour, NL, Canada","country":"Canada","is_tbd":false}],"notes":null,"url":null},"farice-1":{"id":"farice-1","name":"FARICE-1","length":"1,205 km","rfs":"2004 January","rfs_year":2004,"is_planned":false,"owners":"Farice","suppliers":"Prysmian","landing_points":[{"id":"funningsfjordur-faroe-islands","name":"Funningsfjordur, Faroe Islands","country":"Faroe Islands","is_tbd":false},{"id":"seydisfjordur-iceland","name":"Seydisfjordur, Iceland","country":"Iceland","is_tbd":false},{"id":"dunnet-bay-united-kingdom","name":"Dunnet Bay, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"http://www.farice.is"},"farland-north":{"id":"farland-north","name":"Farland North","length":"150 km","rfs":"1998","rfs_year":1998,"is_planned":false,"owners":"BT","suppliers":null,"landing_points":[{"id":"domburg-netherlands","name":"Domburg, Netherlands","country":"Netherlands","is_tbd":false},{"id":"aldeburgh-united-kingdom","name":"Aldeburgh, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"faster":{"id":"faster","name":"FASTER","length":"11,629 km","rfs":"2016 June","rfs_year":2016,"is_planned":false,"owners":"China Mobile, China Telecom, Google, KDDI, Singtel, TIME dotCom","suppliers":"NEC","landing_points":[{"id":"chikura-japan","name":"Chikura, Japan","country":"Japan","is_tbd":false},{"id":"shima-japan","name":"Shima, Japan","country":"Japan","is_tbd":false},{"id":"tanshui-taiwan","name":"Tanshui, Taiwan","country":"Taiwan","is_tbd":false},{"id":"bandon-or-united-states","name":"Bandon, OR, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"fastnet":{"id":"fastnet","name":"Fastnet","length":null,"rfs":"2028","rfs_year":2028,"is_planned":true,"owners":"Amazon Web Services","suppliers":null,"landing_points":[{"id":"castlefreke-ireland","name":"Castlefreke, Ireland","country":"Ireland","is_tbd":true},{"id":"ocean-city-md-united-states","name":"Ocean City, MD, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://aws.amazon.com/"},"fehmarn-blt":{"id":"fehmarn-blt","name":"Fehmarn Bält","length":"20 km","rfs":"2000","rfs_year":2000,"is_planned":false,"owners":"Arelion","suppliers":"Ericsson","landing_points":[{"id":"rodbyhavn-denmark","name":"Rodbyhavn, Denmark","country":"Denmark","is_tbd":false},{"id":"puttgarden-germany","name":"Puttgarden, Germany","country":"Germany","is_tbd":false}],"notes":null,"url":null},"fiber-optic-gulf-fog":{"id":"fiber-optic-gulf-fog","name":"Fiber Optic Gulf (FOG)","length":"1,300 km","rfs":"1998 June","rfs_year":1998,"is_planned":false,"owners":"Bahrain Telecommunications Company (Batelco), Kuwait Ministry of Communications, Ooredoo, e&","suppliers":"Fujitsu","landing_points":[{"id":"manama-bahrain","name":"Manama, Bahrain","country":"Bahrain","is_tbd":false},{"id":"kuwait-city-kuwait","name":"Kuwait City, Kuwait","country":"Kuwait","is_tbd":false},{"id":"doha-qatar","name":"Doha, Qatar","country":"Qatar","is_tbd":false},{"id":"dubai-united-arab-emirates","name":"Dubai, United Arab Emirates","country":"United Arab Emirates","is_tbd":false}],"notes":null,"url":null},"fibra-optica-al-pacfico":{"id":"fibra-optica-al-pacfico","name":"Fibra Optica al Pacífico","length":"1,180 km","rfs":"2020 September","rfs_year":2020,"is_planned":false,"owners":"Entel Bolivia","suppliers":"HMN Tech","landing_points":[{"id":"ilo-peru","name":"Ilo, Peru","country":"Peru","is_tbd":false},{"id":"lurin-peru","name":"Lurin, Peru","country":"Peru","is_tbd":false}],"notes":null,"url":"https://www.entel.bo/"},"fibralink":{"id":"fibralink","name":"Fibralink","length":"1,102 km","rfs":"2006 March","rfs_year":2006,"is_planned":false,"owners":"Liberty Networks","suppliers":"ASN","landing_points":[{"id":"puerto-plata-dominican-republic","name":"Puerto Plata, Dominican Republic","country":"Dominican Republic","is_tbd":false},{"id":"kaliko-haiti","name":"Kaliko, Haiti","country":"Haiti","is_tbd":false},{"id":"bull-bay-jamaica","name":"Bull Bay, Jamaica","country":"Jamaica","is_tbd":false},{"id":"montego-bay-jamaica","name":"Montego Bay, Jamaica","country":"Jamaica","is_tbd":false},{"id":"ocho-rios-jamaica","name":"Ocho Rios, Jamaica","country":"Jamaica","is_tbd":false}],"notes":null,"url":"https://libertynet.com"},"fibre-in-gulf-fig":{"id":"fibre-in-gulf-fig","name":"Fibre in Gulf (FIG)","length":"1,931 km","rfs":"2027 December","rfs_year":2027,"is_planned":true,"owners":"Ooredoo","suppliers":"ASN","landing_points":[{"id":"al-hidd-bahrain","name":"Al Hidd, Bahrain","country":"Bahrain","is_tbd":false},{"id":"al-faw-iraq","name":"Al Faw, Iraq","country":"Iraq","is_tbd":false},{"id":"kuwait-city-kuwait","name":"Kuwait City, Kuwait","country":"Kuwait","is_tbd":false},{"id":"barka-oman","name":"Barka, Oman","country":"Oman","is_tbd":false},{"id":"al-ghariya-qatar","name":"Al Ghariya, Qatar","country":"Qatar","is_tbd":false},{"id":"al-khobar-saudi-arabia","name":"Al Khobar, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"abu-dhabi-united-arab-emirates","name":"Abu Dhabi, United Arab Emirates","country":"United Arab Emirates","is_tbd":false}],"notes":null,"url":"https://www.ooredoo.com/"},"fibra-optica-austral":{"id":"fibra-optica-austral","name":"Fibra Optica Austral","length":"2,800 km","rfs":"2020 Q1","rfs_year":2020,"is_planned":false,"owners":"Subtel","suppliers":"HMN Tech","landing_points":[{"id":"puerto-montt-chile","name":"Puerto Montt, Chile","country":"Chile","is_tbd":false},{"id":"puerto-williams-chile","name":"Puerto Williams, Chile","country":"Chile","is_tbd":false},{"id":"punta-arenas-chile","name":"Punta Arenas, Chile","country":"Chile","is_tbd":false},{"id":"tortel-chile","name":"Tortel, Chile","country":"Chile","is_tbd":false}],"notes":null,"url":"https://fibraopticaaustral.cl/"},"finland-estonia-2-eesf-2":{"id":"finland-estonia-2-eesf-2","name":"Finland-Estonia 2 (EESF-2)","length":"98 km","rfs":"1992","rfs_year":1992,"is_planned":false,"owners":"Arelion, Telia Eesti (formerly Eesti Telekom, EMT, Elion)","suppliers":null,"landing_points":[{"id":"tallinn-estonia","name":"Tallinn, Estonia","country":"Estonia","is_tbd":false},{"id":"helsinki-finland","name":"Helsinki, Finland","country":"Finland","is_tbd":false}],"notes":null,"url":null},"finland-estonia-3-eesf-3":{"id":"finland-estonia-3-eesf-3","name":"Finland-Estonia 3 (EESF-3)","length":"104 km","rfs":"1994","rfs_year":1994,"is_planned":false,"owners":"Arelion, Telia Eesti (formerly Eesti Telekom, EMT, Elion)","suppliers":null,"landing_points":[{"id":"meremisa-estonia","name":"Meremöisa, Estonia","country":"Estonia","is_tbd":false},{"id":"helsinki-finland","name":"Helsinki, Finland","country":"Finland","is_tbd":false}],"notes":null,"url":null},"finland-estonia-connection-1-fec-1":{"id":"finland-estonia-connection-1-fec-1","name":"Finland Estonia Connection 1 (FEC-1)","length":null,"rfs":"2000 January","rfs_year":2000,"is_planned":false,"owners":"Elisa Corporation","suppliers":"ASN","landing_points":[{"id":"tallinn-estonia","name":"Tallinn, Estonia","country":"Estonia","is_tbd":false},{"id":"helsinki-finland","name":"Helsinki, Finland","country":"Finland","is_tbd":false}],"notes":null,"url":"http://www.elisa.com"},"finland-estonia-connection-2-fec-2":{"id":"finland-estonia-connection-2-fec-2","name":"Finland Estonia Connection 2 (FEC-2)","length":null,"rfs":"2000","rfs_year":2000,"is_planned":false,"owners":"Elisa Corporation","suppliers":"ASN","landing_points":[{"id":"tallinn-estonia","name":"Tallinn, Estonia","country":"Estonia","is_tbd":false},{"id":"helsinki-finland","name":"Helsinki, Finland","country":"Finland","is_tbd":false}],"notes":null,"url":"http://www.elisa.com"},"firmina":{"id":"firmina","name":"Firmina","length":"14,517 km","rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"Google","suppliers":"SubCom","landing_points":[{"id":"las-toninas-argentina","name":"Las Toninas, Argentina","country":"Argentina","is_tbd":false},{"id":"praia-grande-brazil","name":"Praia Grande, Brazil","country":"Brazil","is_tbd":false},{"id":"myrtle-beach-sc-united-states","name":"Myrtle Beach, SC, United States","country":"United States","is_tbd":false},{"id":"punta-del-este-uruguay","name":"Punta del Este, Uruguay","country":"Uruguay","is_tbd":false}],"notes":null,"url":"https://cloud.google.com"},"fish-north":{"id":"fish-north","name":"FISH North","length":"90 km","rfs":"2011","rfs_year":2011,"is_planned":false,"owners":"Cordova Telecom Cooperative","suppliers":null,"landing_points":[{"id":"cordova-ak-united-states","name":"Cordova, AK, United States","country":"United States","is_tbd":false},{"id":"valdez-ak-united-states","name":"Valdez, AK, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.ctcak.net/"},"fish-west":{"id":"fish-west","name":"FISH West","length":"276 km","rfs":"2027","rfs_year":2027,"is_planned":true,"owners":"Cordova Telecom Cooperative","suppliers":null,"landing_points":[{"id":"chenega-ak-united-states","name":"Chenega, AK, United States","country":"United States","is_tbd":false},{"id":"cordova-ak-united-states","name":"Cordova, AK, United States","country":"United States","is_tbd":false},{"id":"johnstone-point-ak-united-states","name":"Johnstone Point, AK, United States","country":"United States","is_tbd":false},{"id":"seward-ak-united-states","name":"Seward, AK, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.ctcak.net/"},"flag-atlantic-1-fa-1":{"id":"flag-atlantic-1-fa-1","name":"FLAG Atlantic-1 (FA-1)","length":"14,500 km","rfs":"2001 June","rfs_year":2001,"is_planned":false,"owners":"FLAG","suppliers":"ASN","landing_points":[{"id":"plerin-france","name":"Plerin, France","country":"France","is_tbd":false},{"id":"skewjack-united-kingdom","name":"Skewjack, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"island-park-ny-united-states","name":"Island Park, NY, United States","country":"United States","is_tbd":false},{"id":"northport-ny-united-states","name":"Northport, NY, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://flagtel.com/"},"fish-south":{"id":"fish-south","name":"FISH South","length":"900 km","rfs":"2027","rfs_year":2027,"is_planned":true,"owners":"Cordova Telecom Cooperative","suppliers":"Xtera","landing_points":[{"id":"cordova-ak-united-states","name":"Cordova, AK, United States","country":"United States","is_tbd":false},{"id":"gustavus-ak-united-states","name":"Gustavus, AK, United States","country":"United States","is_tbd":false},{"id":"hoonah-ak-united-states","name":"Hoonah, AK, United States","country":"United States","is_tbd":false},{"id":"juneau-ak-united-states","name":"Juneau, AK, United States","country":"United States","is_tbd":false},{"id":"pelican-ak-united-states","name":"Pelican, AK, United States","country":"United States","is_tbd":false},{"id":"yakutat-ak-united-states","name":"Yakutat, AK, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.ctcak.net/"},"flag-north-asia-loopreach-north-asia-loop":{"id":"flag-north-asia-loopreach-north-asia-loop","name":"FLAG North Asia Loop/REACH North Asia Loop","length":"9,504 km","rfs":"2001 June","rfs_year":2001,"is_planned":false,"owners":"FLAG, PCCW, Telstra","suppliers":"ASN, Fujitsu","landing_points":[{"id":"tong-fuk-china","name":"Tong Fuk, China","country":"China","is_tbd":false},{"id":"wada-japan","name":"Wada, Japan","country":"Japan","is_tbd":false},{"id":"busan-south-korea","name":"Busan, South Korea","country":"South Korea","is_tbd":false},{"id":"toucheng-taiwan","name":"Toucheng, Taiwan","country":"Taiwan","is_tbd":false}],"notes":"FLAG owns 3 fiber pairs which it refers to as FLAG North Asia Loop. Of the three remaining fiber pairs comprising the REACH North Asia Loop, Telstra owns one fiber pair, PCCW owns one fiber pair, with the final fiber pair is jointly owned by Telstra and PCCW","url":null},"flag-europe-asia-fea":{"id":"flag-europe-asia-fea","name":"FLAG Europe-Asia (FEA)","length":"28,000 km","rfs":"1997 November","rfs_year":1997,"is_planned":false,"owners":"FLAG","suppliers":"SubCom","landing_points":[{"id":"lantau-island-china","name":"Lantau Island, China","country":"China","is_tbd":false},{"id":"nanhui-china","name":"Nanhui, China","country":"China","is_tbd":false},{"id":"alexandria-egypt","name":"Alexandria, Egypt","country":"Egypt","is_tbd":false},{"id":"port-said-egypt","name":"Port Said, Egypt","country":"Egypt","is_tbd":false},{"id":"suez-egypt","name":"Suez, Egypt","country":"Egypt","is_tbd":false},{"id":"mumbai-india","name":"Mumbai, India","country":"India","is_tbd":false},{"id":"palermo-italy","name":"Palermo, Italy","country":"Italy","is_tbd":false},{"id":"miura-japan","name":"Miura, Japan","country":"Japan","is_tbd":false},{"id":"aqaba-jordan","name":"Aqaba, Jordan","country":"Jordan","is_tbd":false},{"id":"penang-malaysia","name":"Penang, Malaysia","country":"Malaysia","is_tbd":false},{"id":"jeddah-saudi-arabia","name":"Jeddah, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"geoje-south-korea","name":"Geoje, South Korea","country":"South Korea","is_tbd":false},{"id":"estepona-spain","name":"Estepona, Spain","country":"Spain","is_tbd":false},{"id":"satun-thailand","name":"Satun, Thailand","country":"Thailand","is_tbd":false},{"id":"songkhla-thailand","name":"Songkhla, Thailand","country":"Thailand","is_tbd":false},{"id":"fujairah-united-arab-emirates","name":"Fujairah, United Arab Emirates","country":"United Arab Emirates","is_tbd":false},{"id":"porthcurno-united-kingdom","name":"Porthcurno, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"https://flagtel.com/"},"fly-lion3":{"id":"fly-lion3","name":"FLY-LION3","length":"400 km","rfs":"2019 October","rfs_year":2019,"is_planned":false,"owners":"Comoros Cables, Orange, Société Réunionnaise du Radiotéléphone","suppliers":"ASN","landing_points":[{"id":"moroni-comoros","name":"Moroni, Comoros","country":"Comoros","is_tbd":false},{"id":"kaweni-mayotte","name":"Kaweni, Mayotte","country":"Mayotte","is_tbd":false}],"notes":null,"url":null},"fos-quellon-chacabuco":{"id":"fos-quellon-chacabuco","name":"FOS Quellon-Chacabuco","length":"350 km","rfs":"2015 January","rfs_year":2015,"is_planned":false,"owners":"Grupo Gtd","suppliers":null,"landing_points":[{"id":"puerto-chacabuco-chile","name":"Puerto Chacabuco, Chile","country":"Chile","is_tbd":false},{"id":"quellon-chile","name":"Quellon, Chile","country":"Chile","is_tbd":false}],"notes":null,"url":"https://www.gtd.cl"},"galapagos-cable-system":{"id":"galapagos-cable-system","name":"Galapagos Cable System","length":"1,250 km","rfs":"2027","rfs_year":2027,"is_planned":true,"owners":"Galápagos Cable Systems","suppliers":"Xtera","landing_points":[{"id":"manta-ecuador","name":"Manta, Ecuador","country":"Ecuador","is_tbd":false},{"id":"puerto-ayora-ecuador","name":"Puerto Ayora, Ecuador","country":"Ecuador","is_tbd":false},{"id":"puerto-baquerizo-moreno-ecuador","name":"Puerto Baquerizo Moreno, Ecuador","country":"Ecuador","is_tbd":false},{"id":"puerto-general-villamil-ecuador","name":"Puerto General Villamil, Ecuador","country":"Ecuador","is_tbd":false}],"notes":null,"url":"https://www.gcs.ec/"},"gc-lnz-fu-ring":{"id":"gc-lnz-fu-ring","name":"GC-LNZ-FU Ring","length":"553 km","rfs":"2028 Q1","rfs_year":2028,"is_planned":true,"owners":"Canalink","suppliers":null,"landing_points":[{"id":"arrecife-canary-islands-spain","name":"Arrecife, Canary Islands, Spain","country":"Spain","is_tbd":false},{"id":"corralejo-canary-islands-spain","name":"Corralejo, Canary Islands, Spain","country":"Spain","is_tbd":false},{"id":"las-palmas-spain","name":"Las Palmas, Spain","country":"Spain","is_tbd":false},{"id":"playa-blanca-canary-islands-spain","name":"Playa Blanca, Canary Islands, Spain","country":"Spain","is_tbd":false},{"id":"puerto-del-rosario-canary-islands-spain","name":"Puerto del Rosario, Canary Islands, Spain","country":"Spain","is_tbd":false},{"id":"telde-spain","name":"Telde, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":null},"gemini-bermuda":{"id":"gemini-bermuda","name":"Gemini Bermuda","length":"1,501 km","rfs":"2007 October","rfs_year":2007,"is_planned":false,"owners":"Liberty Networks, Orange","suppliers":"Orange Marine, Xtera","landing_points":[{"id":"st-davids-bermuda","name":"St. David’s, Bermuda","country":"Bermuda","is_tbd":false},{"id":"manasquan-nj-united-states","name":"Manasquan, NJ, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"geo-eirgrid":{"id":"geo-eirgrid","name":"Geo-Eirgrid","length":"187 km","rfs":"2012 December","rfs_year":2012,"is_planned":false,"owners":"Eirgrid","suppliers":"Nexans","landing_points":[{"id":"lusk-ireland","name":"Lusk, Ireland","country":"Ireland","is_tbd":false},{"id":"deeside-clwyd-united-kingdom","name":"Deeside Clwyd, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"http://www.zayo.com"},"georgia-russia":{"id":"georgia-russia","name":"Georgia-Russia","length":"433 km","rfs":"2000 December","rfs_year":2000,"is_planned":false,"owners":"FOPTNET, GEO-METRIA, Rostelecom","suppliers":"ASN","landing_points":[{"id":"poti-georgia","name":"Poti, Georgia","country":"Georgia","is_tbd":false},{"id":"dzhubga-russia","name":"Dzhubga, Russia","country":"Russia","is_tbd":false},{"id":"sochi-russia","name":"Sochi, Russia","country":"Russia","is_tbd":false}],"notes":null,"url":"https://www.company.rt.ru/"},"germany-denmark-3":{"id":"germany-denmark-3","name":"Germany-Denmark 3","length":null,"rfs":"2000 March","rfs_year":2000,"is_planned":false,"owners":"TDC Group","suppliers":null,"landing_points":[{"id":"gedser-denmark","name":"Gedser, Denmark","country":"Denmark","is_tbd":false},{"id":"markgrafenheide-germany","name":"Markgrafenheide, Germany","country":"Germany","is_tbd":false}],"notes":null,"url":null},"glo-1":{"id":"glo-1","name":"Glo-1","length":"9,800 km","rfs":"2010 October","rfs_year":2010,"is_planned":false,"owners":"Globacom Limited","suppliers":"ASN","landing_points":[{"id":"accra-ghana","name":"Accra, Ghana","country":"Ghana","is_tbd":false},{"id":"lagos-nigeria","name":"Lagos, Nigeria","country":"Nigeria","is_tbd":false},{"id":"bude-united-kingdom","name":"Bude, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"https://www.gloworld.com"},"globalconnect-2-gc2":{"id":"globalconnect-2-gc2","name":"GlobalConnect 2 (GC2)","length":"95 km","rfs":"2001 August","rfs_year":2001,"is_planned":false,"owners":"GlobalConnect","suppliers":null,"landing_points":[{"id":"saeby-denmark","name":"Saeby, Denmark","country":"Denmark","is_tbd":false},{"id":"kungsbacka-sweden","name":"Kungsbacka, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":"https://www.globalconnectcarrier.com/"},"global-caribbean-network-gcn":{"id":"global-caribbean-network-gcn","name":"Global Caribbean Network (GCN)","length":"890 km","rfs":"2006 September","rfs_year":2006,"is_planned":false,"owners":"Loret Group","suppliers":null,"landing_points":[{"id":"baillif-guadeloupe","name":"Baillif, Guadeloupe","country":"Guadeloupe","is_tbd":false},{"id":"jarry-guadeloupe","name":"Jarry, Guadeloupe","country":"Guadeloupe","is_tbd":false},{"id":"gustavia-saint-barthlemy","name":"Gustavia, Saint Barthélemy","country":"Saint Barthélemy","is_tbd":false},{"id":"marigot-saint-martin","name":"Marigot, Saint Martin","country":"Saint Martin","is_tbd":false},{"id":"san-juan-pr-united-states","name":"San Juan, PR, United States","country":"United States","is_tbd":false},{"id":"st-croix-virgin-islands-virgin-islands-u-s-","name":"St. Croix, Virgin Islands, Virgin Islands (U.S.)","country":"Virgin Islands (U.S.)","is_tbd":false}],"notes":null,"url":"https://groupeloret.net/brand/global-caribbean-network/"},"globalconnect-3-gc3":{"id":"globalconnect-3-gc3","name":"GlobalConnect 3 (GC3)","length":"19 km","rfs":"2006","rfs_year":2006,"is_planned":false,"owners":"GlobalConnect","suppliers":null,"landing_points":[{"id":"korsor-denmark","name":"Korsor, Denmark","country":"Denmark","is_tbd":false},{"id":"nybor-denmark","name":"Nybor, Denmark","country":"Denmark","is_tbd":false}],"notes":null,"url":"https://www.globalconnectcarrier.com/"},"globalconnect-6-gc6":{"id":"globalconnect-6-gc6","name":"GlobalConnect 6 (GC6)","length":null,"rfs":"2019","rfs_year":2019,"is_planned":false,"owners":"GlobalConnect","suppliers":"Hexatronic","landing_points":[{"id":"bogense-denmark","name":"Bogense, Denmark","country":"Denmark","is_tbd":false},{"id":"juelsminde-denmark","name":"Juelsminde, Denmark","country":"Denmark","is_tbd":false}],"notes":null,"url":"https://globalconnectcarrier.com/"},"globalconnect-denmark-sweden":{"id":"globalconnect-denmark-sweden","name":"GlobalConnect Denmark-Sweden","length":null,"rfs":"1994","rfs_year":1994,"is_planned":false,"owners":"GlobalConnect","suppliers":null,"landing_points":[{"id":"brondby-denmark","name":"Brondby, Denmark","country":"Denmark","is_tbd":false},{"id":"klagshamn-sweden","name":"Klagshamn, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":null},"globalconnect-kpn":{"id":"globalconnect-kpn","name":"GlobalConnect-KPN","length":"43 km","rfs":"2006","rfs_year":2006,"is_planned":false,"owners":"GlobalConnect","suppliers":null,"landing_points":[{"id":"gedser-denmark","name":"Gedser, Denmark","country":"Denmark","is_tbd":false},{"id":"rostock-germany","name":"Rostock, Germany","country":"Germany","is_tbd":false}],"notes":null,"url":"https://www.globalconnectcarrier.com/"},"globenet":{"id":"globenet","name":"GlobeNet","length":"23,500 km","rfs":"2000 October","rfs_year":2000,"is_planned":false,"owners":"V.tal","suppliers":"ASN","landing_points":[{"id":"st-davids-bermuda","name":"St. David’s, Bermuda","country":"Bermuda","is_tbd":false},{"id":"fortaleza-brazil","name":"Fortaleza, Brazil","country":"Brazil","is_tbd":false},{"id":"rio-de-janeiro-brazil","name":"Rio de Janeiro, Brazil","country":"Brazil","is_tbd":false},{"id":"barranquilla-colombia","name":"Barranquilla, Colombia","country":"Colombia","is_tbd":false},{"id":"boca-raton-fl-united-states","name":"Boca Raton, FL, United States","country":"United States","is_tbd":false},{"id":"tuckerton-nj-united-states","name":"Tuckerton, NJ, United States","country":"United States","is_tbd":false},{"id":"maiquetia-venezuela","name":"Maiquetia, Venezuela","country":"Venezuela","is_tbd":false}],"notes":null,"url":"https://www.vtal.com/en/home/"},"go-1-mediterranean-cable-system":{"id":"go-1-mediterranean-cable-system","name":"GO-1 Mediterranean Cable System","length":"290 km","rfs":"2008 December","rfs_year":2008,"is_planned":false,"owners":"GO plc","suppliers":"ASN","landing_points":[{"id":"mazara-del-vallo-italy","name":"Mazara del Vallo, Italy","country":"Italy","is_tbd":false},{"id":"st-pauls-bay-malta","name":"St. Paul's Bay, Malta","country":"Malta","is_tbd":false}],"notes":null,"url":"https://www.go.com.mt/business"},"gondwana-1":{"id":"gondwana-1","name":"Gondwana-1","length":"2,151 km","rfs":"2008 September","rfs_year":2008,"is_planned":false,"owners":"OPT","suppliers":"ASN","landing_points":[{"id":"sydney-nsw-australia","name":"Sydney, NSW, Australia","country":"Australia","is_tbd":false},{"id":"noumea-new-caledonia","name":"Noumea, New Caledonia","country":"New Caledonia","is_tbd":false}],"notes":null,"url":"https://www.opt.nc/"},"gondwana-2picot-2":{"id":"gondwana-2picot-2","name":"Gondwana-2/Picot-2","length":"1,515 km","rfs":"2022 August","rfs_year":2022,"is_planned":false,"owners":"OPT","suppliers":"ASN","landing_points":[{"id":"suva-fiji","name":"Suva, Fiji","country":"Fiji","is_tbd":false},{"id":"mont-dore-new-caledonia","name":"Mont-Dore, New Caledonia","country":"New Caledonia","is_tbd":false},{"id":"noumea-new-caledonia","name":"Noumea, New Caledonia","country":"New Caledonia","is_tbd":false},{"id":"tadine-new-caledonia","name":"Tadine, New Caledonia","country":"New Caledonia","is_tbd":false},{"id":"vao-new-caledonia","name":"Vao, New Caledonia","country":"New Caledonia","is_tbd":false},{"id":"we-new-caledonia","name":"We, New Caledonia","country":"New Caledonia","is_tbd":false},{"id":"yate-new-caledonia","name":"Yate, New Caledonia","country":"New Caledonia","is_tbd":false}],"notes":null,"url":"https://www.opt.nc/"},"grand-bahama-bimini-submarine-cable":{"id":"grand-bahama-bimini-submarine-cable","name":"Grand Bahama Bimini Submarine Cable","length":"117 km","rfs":"2005","rfs_year":2005,"is_planned":false,"owners":"Bahamas Telecommunications Company","suppliers":null,"landing_points":[{"id":"alice-town-bahamas","name":"Alice Town, Bahamas","country":"Bahamas","is_tbd":false},{"id":"eight-mile-rock-bahamas","name":"Eight-Mile Rock, Bahamas","country":"Bahamas","is_tbd":false}],"notes":null,"url":null},"grace-hopper":{"id":"grace-hopper","name":"Grace Hopper","length":"7,191 km","rfs":"2022 September","rfs_year":2022,"is_planned":false,"owners":"Google","suppliers":"SubCom","landing_points":[{"id":"bilbao-spain","name":"Bilbao, Spain","country":"Spain","is_tbd":false},{"id":"bude-united-kingdom","name":"Bude, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"bellport-ny-united-states","name":"Bellport, NY, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://cloud.google.com"},"flores-corvo-cable-system":{"id":"flores-corvo-cable-system","name":"Flores-Corvo Cable System","length":"685 km","rfs":"2014 January","rfs_year":2014,"is_planned":false,"owners":"Viatel","suppliers":"HMN Tech","landing_points":[{"id":"corvo-portugal","name":"Corvo, Portugal","country":"Portugal","is_tbd":false},{"id":"faial-portugal","name":"Faial, Portugal","country":"Portugal","is_tbd":false},{"id":"flores-portugal","name":"Flores, Portugal","country":"Portugal","is_tbd":false},{"id":"graciosa-portugal","name":"Graciosa, Portugal","country":"Portugal","is_tbd":false}],"notes":null,"url":"http://www.viatel.pt"},"greenland-connect":{"id":"greenland-connect","name":"Greenland Connect","length":"4,580 km","rfs":"2009 March","rfs_year":2009,"is_planned":false,"owners":"Tusass A/S","suppliers":"ASN","landing_points":[{"id":"milton-nl-canada","name":"Milton, NL, Canada","country":"Canada","is_tbd":false},{"id":"nuuk-greenland","name":"Nuuk, Greenland","country":"Greenland","is_tbd":false},{"id":"qaqortoq-greenland","name":"Qaqortoq, Greenland","country":"Greenland","is_tbd":false},{"id":"landeyjar-iceland","name":"Landeyjar, Iceland","country":"Iceland","is_tbd":false}],"notes":null,"url":"https://www.tusass.gl/"},"greenland-connect-north":{"id":"greenland-connect-north","name":"Greenland Connect North","length":"680 km","rfs":"2017 December","rfs_year":2017,"is_planned":false,"owners":"Tusass A/S","suppliers":"ASN","landing_points":[{"id":"aasiaat-greenland","name":"Aasiaat, Greenland","country":"Greenland","is_tbd":false},{"id":"maniitsoq-greenland","name":"Maniitsoq, Greenland","country":"Greenland","is_tbd":false},{"id":"nuuk-greenland","name":"Nuuk, Greenland","country":"Greenland","is_tbd":false},{"id":"sisimiut-greenland","name":"Sisimiut, Greenland","country":"Greenland","is_tbd":false}],"notes":null,"url":"https://www.tusass.gl/"},"groix-4":{"id":"groix-4","name":"Groix 4","length":"7 km","rfs":"2022","rfs_year":2022,"is_planned":false,"owners":"Orange","suppliers":"Prysmian","landing_points":[{"id":"petit-perello-france","name":"Petit Perello, France","country":"France","is_tbd":false},{"id":"port-nl-france","name":"Port Nâl, France","country":"France","is_tbd":false}],"notes":"Groix 4 is a hybrid cable that contains a power cable owned by Enedis alongside 24 fiber pairs operated by Orange.","url":null},"groote-eylandt":{"id":"groote-eylandt","name":"Groote Eylandt","length":"95 km","rfs":"2011","rfs_year":2011,"is_planned":false,"owners":"Telstra","suppliers":null,"landing_points":[{"id":"alyangula-nt-australia","name":"Alyangula, NT, Australia","country":"Australia","is_tbd":false},{"id":"numbulwar-nt-australia","name":"Numbulwar, NT, Australia","country":"Australia","is_tbd":false}],"notes":null,"url":null},"gtmo-1":{"id":"gtmo-1","name":"GTMO-1","length":"1,528 km","rfs":"2016","rfs_year":2016,"is_planned":false,"owners":"U.S. Government","suppliers":"Xtera","landing_points":[{"id":"guantanamo-bay-cuba","name":"Guantanamo Bay, Cuba","country":"Cuba","is_tbd":false},{"id":"dania-beach-fl-united-states","name":"Dania Beach, FL, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"gtmo-pr":{"id":"gtmo-pr","name":"GTMO-PR","length":"1,400 km","rfs":"2019 April","rfs_year":2019,"is_planned":false,"owners":"U.S. Government","suppliers":"Xtera","landing_points":[{"id":"guantanamo-bay-cuba","name":"Guantanamo Bay, Cuba","country":"Cuba","is_tbd":false},{"id":"punta-salinas-pr-united-states","name":"Punta Salinas, PR, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"guadeloupe-cable-des-iles-du-sud-gcis":{"id":"guadeloupe-cable-des-iles-du-sud-gcis","name":"Guadeloupe Cable des Iles du Sud (GCIS)","length":"118 km","rfs":"2020","rfs_year":2020,"is_planned":false,"owners":"Regional Councel of Guadeloupe","suppliers":null,"landing_points":[{"id":"beausejour-guadeloupe","name":"Beausejour, Guadeloupe","country":"Guadeloupe","is_tbd":false},{"id":"capesterre-belle-eau-guadeloupe","name":"Capesterre-Belle-Eau, Guadeloupe","country":"Guadeloupe","is_tbd":false},{"id":"saint-franois-guadeloupe","name":"Saint-François, Guadeloupe","country":"Guadeloupe","is_tbd":false},{"id":"saint-louis-guadeloupe","name":"Saint-Louis, Guadeloupe","country":"Guadeloupe","is_tbd":false},{"id":"terre-de-haut-guadeloupe","name":"Terre-de-Haut, Guadeloupe","country":"Guadeloupe","is_tbd":false}],"notes":null,"url":"https://www.regionguadeloupe.fr/accueil/#_"},"guam-okinawa-kyushu-incheon-goki":{"id":"guam-okinawa-kyushu-incheon-goki","name":"Guam Okinawa Kyushu Incheon (GOKI)","length":"4,244 km","rfs":"2013 December","rfs_year":2013,"is_planned":false,"owners":"AT&T","suppliers":"Xtera","landing_points":[{"id":"tumon-bay-guam","name":"Tumon Bay, Guam","country":"Guam","is_tbd":false},{"id":"kitakyushu-japan","name":"Kitakyushu, Japan","country":"Japan","is_tbd":false},{"id":"naha-japan","name":"Naha, Japan","country":"Japan","is_tbd":false}],"notes":null,"url":"http://www.att.com"},"guernsey-jersey-4":{"id":"guernsey-jersey-4","name":"Guernsey-Jersey-4","length":"36 km","rfs":"1994 January","rfs_year":1994,"is_planned":false,"owners":"Sure","suppliers":null,"landing_points":[{"id":"saints-bay-guernsey","name":"Saints Bay, Guernsey","country":"Guernsey","is_tbd":false},{"id":"greve-de-lecq-jersey","name":"Greve de Lecq, Jersey","country":"Jersey","is_tbd":false}],"notes":null,"url":null},"gulf-of-california-cable":{"id":"gulf-of-california-cable","name":"Gulf of California Cable","length":"250 km","rfs":"2019 Q4","rfs_year":2019,"is_planned":false,"owners":"Megacable","suppliers":"HMN Tech","landing_points":[{"id":"la-paz-mexico","name":"La Paz, Mexico","country":"Mexico","is_tbd":false},{"id":"topolobampo-mexico","name":"Topolobampo, Mexico","country":"Mexico","is_tbd":false}],"notes":null,"url":"https://www.megacable.com.mx/"},"gulf-bridge-international-cable-systemmiddle-east-north-africa-cable-system-gbicsmena":{"id":"gulf-bridge-international-cable-systemmiddle-east-north-africa-cable-system-gbicsmena","name":"Gulf Bridge International Cable System/Middle East North Africa Cable System (GBICS/MENA)","length":"5,270 km","rfs":"2012 February","rfs_year":2012,"is_planned":false,"owners":"Gulf Bridge International","suppliers":"SubCom","landing_points":[{"id":"al-hidd-bahrain","name":"Al Hidd, Bahrain","country":"Bahrain","is_tbd":false},{"id":"mumbai-india","name":"Mumbai, India","country":"India","is_tbd":false},{"id":"bushehr-iran","name":"Bushehr, Iran","country":"Iran","is_tbd":false},{"id":"al-faw-iraq","name":"Al Faw, Iraq","country":"Iraq","is_tbd":false},{"id":"kuwait-city-kuwait","name":"Kuwait City, Kuwait","country":"Kuwait","is_tbd":false},{"id":"al-seeb-oman","name":"Al Seeb, Oman","country":"Oman","is_tbd":false},{"id":"al-daayen-qatar","name":"Al Daayen, Qatar","country":"Qatar","is_tbd":false},{"id":"al-khobar-saudi-arabia","name":"Al Khobar, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"fujairah-united-arab-emirates","name":"Fujairah, United Arab Emirates","country":"United Arab Emirates","is_tbd":false}],"notes":"GBI owns the entire system. Telecom Egypt owns one of the fiber pairs from the branching unit near Oman to Mumbai which it acquired from the MENA Cable Company. GBI owns one fiber pair on the MENA cable, which links Oman to Italy.","url":"http://www.gbiinc.com"},"gulf-of-mexico-fiber-optic-network":{"id":"gulf-of-mexico-fiber-optic-network","name":"Gulf of Mexico Fiber Optic Network","length":"1,200 km","rfs":"2008","rfs_year":2008,"is_planned":false,"owners":"Tampnet","suppliers":"SubCom","landing_points":[{"id":"freeport-tx-united-states","name":"Freeport, TX, United States","country":"United States","is_tbd":false},{"id":"pascagoula-ms-united-states","name":"Pascagoula, MS, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.tampnet.com/"},"gulf2africa-g2a":{"id":"gulf2africa-g2a","name":"Gulf2Africa (G2A)","length":"1,500 km","rfs":"2017 December","rfs_year":2017,"is_planned":false,"owners":"Golis Telecommunications, Telesom, Zain Omantel International","suppliers":"HMN Tech","landing_points":[{"id":"salalah-oman","name":"Salalah, Oman","country":"Oman","is_tbd":false},{"id":"berbera-somalia","name":"Berbera, Somalia","country":"Somalia","is_tbd":false},{"id":"bosaso-somalia","name":"Bosaso, Somalia","country":"Somalia","is_tbd":false}],"notes":null,"url":null},"hachijojima-mainland":{"id":"hachijojima-mainland","name":"Hachijojima-Mainland","length":null,"rfs":"2008","rfs_year":2008,"is_planned":false,"owners":"NTT","suppliers":null,"landing_points":[{"id":"hachijo-japan","name":"Hachijo, Japan","country":"Japan","is_tbd":false},{"id":"miura-japan","name":"Miura, Japan","country":"Japan","is_tbd":false}],"notes":null,"url":null},"haikou-beihai-cable":{"id":"haikou-beihai-cable","name":"Haikou-Beihai Cable","length":"198 km","rfs":"1999","rfs_year":1999,"is_planned":false,"owners":"China Telecom","suppliers":"ASN","landing_points":[{"id":"beihai-china","name":"Beihai, China","country":"China","is_tbd":false},{"id":"lingao-china","name":"Lingao, China","country":"China","is_tbd":false}],"notes":null,"url":null},"hainan-to-hong-kong-express-h2he":{"id":"hainan-to-hong-kong-express-h2he","name":"Hainan to Hong Kong Express (H2HE)","length":"675 km","rfs":"2021 September","rfs_year":2021,"is_planned":false,"owners":"China Mobile","suppliers":"HMN Tech","landing_points":[{"id":"chung-hom-kok-china","name":"Chung Hom Kok, China","country":"China","is_tbd":false},{"id":"wenchang-china","name":"Wenchang, China","country":"China","is_tbd":false},{"id":"zhuhai-china","name":"Zhuhai, China","country":"China","is_tbd":false}],"notes":null,"url":"https://www.chinamobileltd.com/en/global/home.php"},"halaihai":{"id":"halaihai","name":"Halaihai","length":"17,483 km","rfs":"2027","rfs_year":2027,"is_planned":true,"owners":"Google","suppliers":"SubCom","landing_points":[{"id":"valparaso-chile","name":"Valparaíso, Chile","country":"Chile","is_tbd":true},{"id":"tahiti-iti-french-polynesia","name":"Tahiti Iti, French Polynesia","country":"French Polynesia","is_tbd":true},{"id":"tahiti-nui-french-polynesia","name":"Tahiti Nui, French Polynesia","country":"French Polynesia","is_tbd":true},{"id":"tanguisson-point-guam","name":"Tanguisson Point, Guam","country":"Guam","is_tbd":true},{"id":"tinian-northern-mariana-islands","name":"Tinian, Northern Mariana Islands","country":"Northern Mariana Islands","is_tbd":true}],"notes":null,"url":"https://cloud.google.com"},"hannibal-system":{"id":"hannibal-system","name":"HANNIBAL System","length":"178 km","rfs":"2009 October","rfs_year":2009,"is_planned":false,"owners":"Tunisia Telecom","suppliers":"HMN Tech","landing_points":[{"id":"mazara-del-vallo-italy","name":"Mazara del Vallo, Italy","country":"Italy","is_tbd":false},{"id":"kelibia-tunisia","name":"Kelibia, Tunisia","country":"Tunisia","is_tbd":false}],"notes":null,"url":"http://www.tunisietelecom.tn"},"hantru1-cable-system":{"id":"hantru1-cable-system","name":"HANTRU1 Cable System","length":"2,917 km","rfs":"2010 March","rfs_year":2010,"is_planned":false,"owners":"Federated States of Micronesia Telecommunications Cable Corporation (FSMTCC), Hannon Armstrong, Marshall Islands Telecommunications Authority","suppliers":"SubCom","landing_points":[{"id":"piti-guam","name":"Piti, Guam","country":"Guam","is_tbd":false},{"id":"kwajalein-marshall-islands","name":"Kwajalein, Marshall Islands","country":"Marshall Islands","is_tbd":false},{"id":"majuro-marshall-islands","name":"Majuro, Marshall Islands","country":"Marshall Islands","is_tbd":false},{"id":"pohnpei-micronesia","name":"Pohnpei, Micronesia","country":"Micronesia","is_tbd":false}],"notes":null,"url":null},"havfrueaec-2":{"id":"havfrueaec-2","name":"Havfrue/AEC-2","length":"7,650 km","rfs":"2020 November","rfs_year":2020,"is_planned":false,"owners":"Bulk Infrastructure, EXA Infrastructure, Google, Meta","suppliers":"SubCom","landing_points":[{"id":"blaabjerg-denmark","name":"Blaabjerg, Denmark","country":"Denmark","is_tbd":false},{"id":"lecanvey-ireland","name":"Lecanvey, Ireland","country":"Ireland","is_tbd":false},{"id":"kristiansand-norway","name":"Kristiansand, Norway","country":"Norway","is_tbd":false},{"id":"wall-township-nj-united-states","name":"Wall Township, NJ, United States","country":"United States","is_tbd":false}],"notes":"EXA Infrastructure acts as the overall system administrator for the Havfrue cable, and has dubbed the portion of the cable where it has ownership (Denmark, Ireland, and U.S. segments) as AEC-2. Bulk Infrastructure is the Norwegian landing party for Havfrue.","url":null},"havhingstennorth-sea-connect-nsc":{"id":"havhingstennorth-sea-connect-nsc","name":"Havhingsten/North Sea Connect (NSC)","length":"661 km","rfs":"2022 March","rfs_year":2022,"is_planned":false,"owners":"Bulk Infrastructure, EXA Infrastructure, Meta","suppliers":"ASN","landing_points":[{"id":"houstrup-denmark","name":"Houstrup, Denmark","country":"Denmark","is_tbd":false},{"id":"newcastle-united-kingdom","name":"Newcastle, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"havhingstenceltixconnect-2-cc-2":{"id":"havhingstenceltixconnect-2-cc-2","name":"Havhingsten/CeltixConnect-2 (CC-2)","length":"301 km","rfs":"2022 March","rfs_year":2022,"is_planned":false,"owners":"Bulk Infrastructure, EXA Infrastructure, Meta","suppliers":"ASN","landing_points":[{"id":"loughshinny-ireland","name":"LoughShinny, Ireland","country":"Ireland","is_tbd":false},{"id":"port-erin-isle-of-man","name":"Port Erin, Isle of Man","country":"Isle of Man","is_tbd":false},{"id":"port-grenaugh-isle-of-man","name":"Port Grenaugh, Isle of Man","country":"Isle of Man","is_tbd":false},{"id":"blackpool-united-kingdom","name":"Blackpool, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"havsil":{"id":"havsil","name":"Havsil","length":"120 km","rfs":"2022 March","rfs_year":2022,"is_planned":false,"owners":"Bulk Infrastructure","suppliers":null,"landing_points":[{"id":"hanstholm-denmark","name":"Hanstholm, Denmark","country":"Denmark","is_tbd":false},{"id":"kristiansand-norway","name":"Kristiansand, Norway","country":"Norway","is_tbd":false}],"notes":null,"url":"http://bulkinfrastructure.com/"},"hawaii-inter-island-cable-system-hics":{"id":"hawaii-inter-island-cable-system-hics","name":"Hawaii Inter-Island Cable System (HICS)","length":"479 km","rfs":"1994 July","rfs_year":1994,"is_planned":false,"owners":"Hawaiian Telcom","suppliers":null,"landing_points":[{"id":"kawaihae-hi-united-states","name":"Kawaihae, HI, United States","country":"United States","is_tbd":false},{"id":"kihei-hi-united-states","name":"Kihei, HI, United States","country":"United States","is_tbd":false},{"id":"ko-olina-hi-united-states","name":"Ko Olina, HI, United States","country":"United States","is_tbd":false},{"id":"koko-head-hi-united-states","name":"Koko Head, HI, United States","country":"United States","is_tbd":false},{"id":"lihue-hi-united-states","name":"Lihue, HI, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"hawaii-island-fibre-network-hifn":{"id":"hawaii-island-fibre-network-hifn","name":"Hawaii Island Fibre Network (HIFN)","length":"529 km","rfs":"1997 June","rfs_year":1997,"is_planned":false,"owners":"Hawaiian Telcom, Lumen","suppliers":null,"landing_points":[{"id":"kaunakakai-hi-united-states","name":"Kaunakakai, HI, United States","country":"United States","is_tbd":false},{"id":"kawaihae-hi-united-states","name":"Kawaihae, HI, United States","country":"United States","is_tbd":false},{"id":"kihei-hi-united-states","name":"Kihei, HI, United States","country":"United States","is_tbd":false},{"id":"koko-head-hi-united-states","name":"Koko Head, HI, United States","country":"United States","is_tbd":false},{"id":"lihue-hi-united-states","name":"Lihue, HI, United States","country":"United States","is_tbd":false},{"id":"makaha-hi-united-states","name":"Makaha, HI, United States","country":"United States","is_tbd":false},{"id":"manele-bay-hi-united-states","name":"Manele Bay, HI, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"hawaiian-islands-fiber-link-hifl":{"id":"hawaiian-islands-fiber-link-hifl","name":"Hawaiian Islands Fiber Link (HIFL)","length":"740 km","rfs":"2026 December","rfs_year":2026,"is_planned":true,"owners":"Ocean Networks, University of Hawai’i","suppliers":"Prysmian","landing_points":[{"id":"barbers-point-hi-united-states","name":"Barber’s Point, HI, United States","country":"United States","is_tbd":false},{"id":"hilo-hi-united-states","name":"Hilo, HI, United States","country":"United States","is_tbd":false},{"id":"kahului-hi-united-states","name":"Kahului, HI, United States","country":"United States","is_tbd":false},{"id":"kaunakakai-hi-united-states","name":"Kaunakakai, HI, United States","country":"United States","is_tbd":false},{"id":"lihue-hi-united-states","name":"Lihue, HI, United States","country":"United States","is_tbd":false},{"id":"manele-bay-hi-united-states","name":"Manele Bay, HI, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.oceannetworks.com/"},"hawaiki":{"id":"hawaiki","name":"Hawaiki","length":"14,000 km","rfs":"2018 July","rfs_year":2018,"is_planned":false,"owners":"BW Digital","suppliers":"SubCom","landing_points":[{"id":"pago-pago-american-samoa","name":"Pago Pago, American Samoa","country":"American Samoa","is_tbd":false},{"id":"sydney-nsw-australia","name":"Sydney, NSW, Australia","country":"Australia","is_tbd":false},{"id":"mangawhai-new-zealand","name":"Mangawhai, New Zealand","country":"New Zealand","is_tbd":false},{"id":"neiafu-tonga","name":"Neiafu, Tonga","country":"Tonga","is_tbd":false},{"id":"hillsboro-or-united-states","name":"Hillsboro, OR, United States","country":"United States","is_tbd":false},{"id":"kapolei-hi-united-states","name":"Kapolei, HI, United States","country":"United States","is_tbd":false}],"notes":"","url":"https://www.bw-digital.com/"},"hawaiki-nui-1":{"id":"hawaiki-nui-1","name":"Hawaiki Nui 1","length":"10,000 km","rfs":"2027","rfs_year":2027,"is_planned":true,"owners":"BW Digital","suppliers":"ASN","landing_points":[{"id":"brisbane-qld-australia","name":"Brisbane, QLD, Australia","country":"Australia","is_tbd":false},{"id":"darwin-nt-australia","name":"Darwin, NT, Australia","country":"Australia","is_tbd":false},{"id":"sydney-nsw-australia","name":"Sydney, NSW, Australia","country":"Australia","is_tbd":false},{"id":"batam-indonesia","name":"Batam, Indonesia","country":"Indonesia","is_tbd":false},{"id":"jakarta-indonesia","name":"Jakarta, Indonesia","country":"Indonesia","is_tbd":false},{"id":"port-moresby-papua-new-guinea","name":"Port Moresby, Papua New Guinea","country":"Papua New Guinea","is_tbd":true},{"id":"changi-singapore","name":"Changi, Singapore","country":"Singapore","is_tbd":false},{"id":"honiara-solomon-islands","name":"Honiara, Solomon Islands","country":"Solomon Islands","is_tbd":true},{"id":"dili-timor-leste","name":"Dili, Timor-Leste","country":"Timor-Leste","is_tbd":true}],"notes":null,"url":"https://www.bw-digital.com/"},"hawk":{"id":"hawk","name":"Hawk","length":"3,400 km","rfs":"2011","rfs_year":2011,"is_planned":false,"owners":"FLAG","suppliers":"Fujitsu","landing_points":[{"id":"yeroskipos-cyprus","name":"Yeroskipos, Cyprus","country":"Cyprus","is_tbd":false},{"id":"alexandria-egypt","name":"Alexandria, Egypt","country":"Egypt","is_tbd":false},{"id":"marseille-france","name":"Marseille, France","country":"France","is_tbd":false}],"notes":null,"url":"https://flagtel.com/"},"high-capacity-undersea-guernsey-optical-fibre-hugo":{"id":"high-capacity-undersea-guernsey-optical-fibre-hugo","name":"High-capacity Undersea Guernsey Optical-fibre (HUGO)","length":"425 km","rfs":"2007","rfs_year":2007,"is_planned":false,"owners":"Sure, Vodafone","suppliers":"Xtera","landing_points":[{"id":"lannion-france","name":"Lannion, France","country":"France","is_tbd":false},{"id":"pembroke-guernsey","name":"Pembroke, Guernsey","country":"Guernsey","is_tbd":false},{"id":"saints-bay-guernsey","name":"Saints Bay, Guernsey","country":"Guernsey","is_tbd":false},{"id":"porthcurno-united-kingdom","name":"Porthcurno, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"hokkaido-akita-cable":{"id":"hokkaido-akita-cable","name":"Hokkaido-Akita Cable","length":"770 km","rfs":"2023 Q4","rfs_year":2023,"is_planned":false,"owners":"KDDI, NTT, Rakuten, Softbank","suppliers":"NEC","landing_points":[{"id":"akita-japan","name":"Akita, Japan","country":"Japan","is_tbd":false},{"id":"sapporo-japan","name":"Sapporo, Japan","country":"Japan","is_tbd":false}],"notes":null,"url":null},"hokkaido-rebun-rishiri":{"id":"hokkaido-rebun-rishiri","name":"Hokkaido-Rebun-Rishiri","length":"64 km","rfs":"2011","rfs_year":2011,"is_planned":false,"owners":"NTT","suppliers":null,"landing_points":[{"id":"rebun-japan","name":"Rebun, Japan","country":"Japan","is_tbd":false},{"id":"rishirifuji-japan","name":"Rishirifuji, Japan","country":"Japan","is_tbd":false},{"id":"wakkanai-japan","name":"Wakkanai, Japan","country":"Japan","is_tbd":false}],"notes":null,"url":null},"hokkaido-sakhalin-cable-system-hscs":{"id":"hokkaido-sakhalin-cable-system-hscs","name":"Hokkaido-Sakhalin Cable System (HSCS)","length":"570 km","rfs":"2008 July","rfs_year":2008,"is_planned":false,"owners":"NTT, TTK","suppliers":"NEC","landing_points":[{"id":"ishikari-japan","name":"Ishikari, Japan","country":"Japan","is_tbd":false},{"id":"nevelsk-russia","name":"Nevelsk, Russia","country":"Russia","is_tbd":false}],"notes":null,"url":null},"honomoana":{"id":"honomoana","name":"Honomoana","length":"15,215 km","rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"Google","suppliers":"SubCom","landing_points":[{"id":"melbourne-vic-australia","name":"Melbourne, VIC, Australia","country":"Australia","is_tbd":false},{"id":"sydney-nsw-australia","name":"Sydney, NSW, Australia","country":"Australia","is_tbd":false},{"id":"tahiti-iti-french-polynesia","name":"Tahiti Iti, French Polynesia","country":"French Polynesia","is_tbd":true},{"id":"tahiti-nui-french-polynesia","name":"Tahiti Nui, French Polynesia","country":"French Polynesia","is_tbd":true},{"id":"auckland-new-zealand","name":"Auckland, New Zealand","country":"New Zealand","is_tbd":false},{"id":"san-diego-ca-united-states","name":"San Diego, CA, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://cloud.google.com"},"honotua":{"id":"honotua","name":"Honotua","length":"4,805 km","rfs":"2010 September","rfs_year":2010,"is_planned":false,"owners":"OPT French Polynesia","suppliers":"ASN","landing_points":[{"id":"huahine-french-polynesia","name":"Huahine, French Polynesia","country":"French Polynesia","is_tbd":false},{"id":"moorea-french-polynesia","name":"Moorea, French Polynesia","country":"French Polynesia","is_tbd":false},{"id":"papenoo-french-polynesia","name":"Papenoo, French Polynesia","country":"French Polynesia","is_tbd":false},{"id":"uturoa-french-polynesia","name":"Uturoa, French Polynesia","country":"French Polynesia","is_tbd":false},{"id":"vaitape-french-polynesia","name":"Vaitape, French Polynesia","country":"French Polynesia","is_tbd":false},{"id":"kawaihae-hi-united-states","name":"Kawaihae, HI, United States","country":"United States","is_tbd":false}],"notes":null,"url":"http://www.opt.pf"},"hronn":{"id":"hronn","name":"Hronn","length":"270 km","rfs":"2022 September","rfs_year":2022,"is_planned":false,"owners":"Shefa","suppliers":null,"landing_points":[{"id":"fano-denmark","name":"Fano, Denmark","country":"Denmark","is_tbd":false},{"id":"totalenergies-halfdan-denmark","name":"TotalEnergies Halfdan, Denmark","country":"Denmark","is_tbd":false},{"id":"totalenergies-tyra-denmark","name":"TotalEnergies Tyra, Denmark","country":"Denmark","is_tbd":false}],"notes":null,"url":"https://www.shefa.fo/cables/"},"i-am-cable":{"id":"i-am-cable","name":"I-AM Cable","length":"8,100 km","rfs":"2029","rfs_year":2029,"is_planned":true,"owners":"Intra‑Asia Marine Networks Co., Ltd.","suppliers":"ASN","landing_points":[{"id":"fukuoka-japan","name":"Fukuoka, Japan","country":"Japan","is_tbd":false},{"id":"minamiboso-japan","name":"Minamiboso, Japan","country":"Japan","is_tbd":false},{"id":"shima-japan","name":"Shima, Japan","country":"Japan","is_tbd":false},{"id":"sedili-malaysia","name":"Sedili, Malaysia","country":"Malaysia","is_tbd":true},{"id":"changi-singapore","name":"Changi, Singapore","country":"Singapore","is_tbd":true},{"id":"busan-south-korea","name":"Busan, South Korea","country":"South Korea","is_tbd":true}],"notes":null,"url":null},"iceni":{"id":"iceni","name":"Iceni","length":null,"rfs":"2024","rfs_year":2024,"is_planned":false,"owners":"BT","suppliers":"SubCom","landing_points":[{"id":"callantsoog-netherlands","name":"Callantsoog, Netherlands","country":"Netherlands","is_tbd":false},{"id":"winterton-on-sea-united-kingdom","name":"Winterton-on-Sea, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"i2i-cable-network-i2icn":{"id":"i2i-cable-network-i2icn","name":"i2i Cable Network (i2icn)","length":"3,200 km","rfs":"2002 April","rfs_year":2002,"is_planned":false,"owners":"Bharti Airtel","suppliers":"ASN","landing_points":[{"id":"chennai-india","name":"Chennai, India","country":"India","is_tbd":false},{"id":"tuas-singapore","name":"Tuas, Singapore","country":"Singapore","is_tbd":false}],"notes":null,"url":"http://www.airtel.in"},"imewe":{"id":"imewe","name":"IMEWE","length":"12,091 km","rfs":"2010 December","rfs_year":2010,"is_planned":false,"owners":"Bharti Airtel, Ogero, Orange, Pakistan Telecommunications Company Ltd., Sparkle, Tata Communications, Telecom Egypt, center3, e&","suppliers":"ASN, NEC","landing_points":[{"id":"alexandria-egypt","name":"Alexandria, Egypt","country":"Egypt","is_tbd":false},{"id":"suez-egypt","name":"Suez, Egypt","country":"Egypt","is_tbd":false},{"id":"marseille-france","name":"Marseille, France","country":"France","is_tbd":false},{"id":"mumbai-india","name":"Mumbai, India","country":"India","is_tbd":false},{"id":"catania-italy","name":"Catania, Italy","country":"Italy","is_tbd":false},{"id":"tripoli-lebanon","name":"Tripoli, Lebanon","country":"Lebanon","is_tbd":false},{"id":"karachi-pakistan","name":"Karachi, Pakistan","country":"Pakistan","is_tbd":false},{"id":"jeddah-saudi-arabia","name":"Jeddah, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"fujairah-united-arab-emirates","name":"Fujairah, United Arab Emirates","country":"United Arab Emirates","is_tbd":false}],"notes":null,"url":"https://imewecable.com/"},"india-asia-xpress-iax":{"id":"india-asia-xpress-iax","name":"India Asia Xpress (IAX)","length":"5,791 km","rfs":"2024 Q4","rfs_year":2024,"is_planned":false,"owners":"China Mobile, Reliance Jio Infocomm","suppliers":"SubCom","landing_points":[{"id":"chennai-india","name":"Chennai, India","country":"India","is_tbd":false},{"id":"digha-india","name":"Digha, India","country":"India","is_tbd":false},{"id":"machilipatnam-india","name":"Machilipatnam, India","country":"India","is_tbd":false},{"id":"mumbai-india","name":"Mumbai, India","country":"India","is_tbd":false},{"id":"morib-malaysia","name":"Morib, Malaysia","country":"Malaysia","is_tbd":false},{"id":"hulhumale-maldives","name":"Hulhumale, Maldives","country":"Maldives","is_tbd":false},{"id":"tuas-singapore","name":"Tuas, Singapore","country":"Singapore","is_tbd":false},{"id":"matara-sri-lanka","name":"Matara, Sri Lanka","country":"Sri Lanka","is_tbd":false},{"id":"satun-thailand","name":"Satun, Thailand","country":"Thailand","is_tbd":false}],"notes":"The cable has other owners besides Reliance Jio and China Mobile who have yet to be disclosed.","url":null},"india-europe-xpress-iex":{"id":"india-europe-xpress-iex","name":"India Europe Xpress (IEX)","length":"9,775 km","rfs":"2026 Q3","rfs_year":2026,"is_planned":true,"owners":"China Mobile, Reliance Jio Infocomm","suppliers":"SubCom","landing_points":[{"id":"djibouti-city-djibouti","name":"Djibouti City, Djibouti","country":"Djibouti","is_tbd":false},{"id":"sidi-kerir-egypt","name":"Sidi Kerir, Egypt","country":"Egypt","is_tbd":false},{"id":"zafarana-egypt","name":"Zafarana, Egypt","country":"Egypt","is_tbd":false},{"id":"marseille-france","name":"Marseille, France","country":"France","is_tbd":false},{"id":"tympaki-greece","name":"Tympaki, Greece","country":"Greece","is_tbd":false},{"id":"mumbai-india","name":"Mumbai, India","country":"India","is_tbd":false},{"id":"savona-italy","name":"Savona, Italy","country":"Italy","is_tbd":false},{"id":"salalah-oman","name":"Salalah, Oman","country":"Oman","is_tbd":false},{"id":"jeddah-saudi-arabia","name":"Jeddah, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"neom-saudi-arabia","name":"Neom, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"yanbu-saudi-arabia","name":"Yanbu, Saudi Arabia","country":"Saudi Arabia","is_tbd":false}],"notes":"The cable has other owners besides Reliance Jio and China Mobile who have yet to be disclosed.","url":null},"indigo-central":{"id":"indigo-central","name":"INDIGO-Central","length":"4,850 km","rfs":"2019 May","rfs_year":2019,"is_planned":false,"owners":"Australia’s Academic and Research Network (AARNET), Google, Indosat Ooredoo, Singtel Optus, Superloop","suppliers":"ASN","landing_points":[{"id":"alexandria-nsw-australia","name":"Alexandria, NSW, Australia","country":"Australia","is_tbd":false},{"id":"perth-wa-australia","name":"Perth, WA, Australia","country":"Australia","is_tbd":false}],"notes":null,"url":null},"indigo-west":{"id":"indigo-west","name":"INDIGO-West","length":"4,600 km","rfs":"2019 May","rfs_year":2019,"is_planned":false,"owners":"Australia’s Academic and Research Network (AARNET), Google, Indosat Ooredoo, Singtel, Superloop, Telstra","suppliers":"ASN","landing_points":[{"id":"perth-wa-australia","name":"Perth, WA, Australia","country":"Australia","is_tbd":false},{"id":"jakarta-indonesia","name":"Jakarta, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tuas-singapore","name":"Tuas, Singapore","country":"Singapore","is_tbd":false}],"notes":null,"url":null},"indonesia-global-gateway-igg-system":{"id":"indonesia-global-gateway-igg-system","name":"Indonesia Global Gateway (IGG) System","length":"5,300 km","rfs":"2018 May","rfs_year":2018,"is_planned":false,"owners":"Telin, Telkom Indonesia","suppliers":"NEC","landing_points":[{"id":"bali-indonesia","name":"Bali, Indonesia","country":"Indonesia","is_tbd":false},{"id":"balikpapan-indonesia","name":"Balikpapan, Indonesia","country":"Indonesia","is_tbd":false},{"id":"batam-indonesia","name":"Batam, Indonesia","country":"Indonesia","is_tbd":false},{"id":"dumai-indonesia","name":"Dumai, Indonesia","country":"Indonesia","is_tbd":false},{"id":"jakarta-indonesia","name":"Jakarta, Indonesia","country":"Indonesia","is_tbd":false},{"id":"madura-indonesia","name":"Madura, Indonesia","country":"Indonesia","is_tbd":false},{"id":"makassar-indonesia","name":"Makassar, Indonesia","country":"Indonesia","is_tbd":false},{"id":"manado-indonesia","name":"Manado, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tarakan-indonesia","name":"Tarakan, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tuas-singapore","name":"Tuas, Singapore","country":"Singapore","is_tbd":false}],"notes":null,"url":"https://www.telin.net/"},"indonesia-tengah-cable-systems":{"id":"indonesia-tengah-cable-systems","name":"Indonesia Tengah Cable Systems","length":"2,641 km","rfs":"2027 Q4","rfs_year":2027,"is_planned":true,"owners":"PT Jejaring Mitra Persada, Triasmitra","suppliers":null,"landing_points":[{"id":"baubau-indonesia","name":"Baubau, Indonesia","country":"Indonesia","is_tbd":false},{"id":"kawinda-nae-indonesia","name":"Kawinda Nae, Indonesia","country":"Indonesia","is_tbd":false},{"id":"kendari-indonesia","name":"Kendari, Indonesia","country":"Indonesia","is_tbd":false},{"id":"labuhan-bajo-indonesia","name":"Labuhan Bajo, Indonesia","country":"Indonesia","is_tbd":false},{"id":"luwuk-indonesia","name":"Luwuk, Indonesia","country":"Indonesia","is_tbd":false},{"id":"makassar-indonesia","name":"Makassar, Indonesia","country":"Indonesia","is_tbd":false},{"id":"morowali-indonesia","name":"Morowali, Indonesia","country":"Indonesia","is_tbd":false},{"id":"sanur-indonesia","name":"Sanur, Indonesia","country":"Indonesia","is_tbd":false},{"id":"selayar-indonesia","name":"Selayar, Indonesia","country":"Indonesia","is_tbd":false},{"id":"wakatobi-indonesia","name":"Wakatobi, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":null},"ingrid":{"id":"ingrid","name":"INGRID","length":"64 km","rfs":"2004 October","rfs_year":2004,"is_planned":false,"owners":"CIEG","suppliers":null,"landing_points":[{"id":"surville-france","name":"Surville, France","country":"France","is_tbd":false},{"id":"havelet-bay-guernsey","name":"Havelet Bay, Guernsey","country":"Guernsey","is_tbd":false},{"id":"archirondel-jersey","name":"Archirondel, Jersey","country":"Jersey","is_tbd":false},{"id":"greve-de-lecq-jersey","name":"Greve de Lecq, Jersey","country":"Jersey","is_tbd":false}],"notes":null,"url":null},"insica":{"id":"insica","name":"INSICA","length":"100 km","rfs":"2026 Q4","rfs_year":2026,"is_planned":true,"owners":"Singtel, Telin","suppliers":null,"landing_points":[{"id":"tanjung-bemban-indonesia","name":"Tanjung Bemban, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tuas-singapore","name":"Tuas, Singapore","country":"Singapore","is_tbd":false}],"notes":null,"url":null},"interchange-cable-network-1-icn1":{"id":"interchange-cable-network-1-icn1","name":"Interchange Cable Network 1 (ICN1)","length":"1,259 km","rfs":"2014 January","rfs_year":2014,"is_planned":false,"owners":"Interchange","suppliers":"ASN","landing_points":[{"id":"suva-fiji","name":"Suva, Fiji","country":"Fiji","is_tbd":false},{"id":"port-vila-vanuatu","name":"Port Vila, Vanuatu","country":"Vanuatu","is_tbd":false}],"notes":null,"url":"http://www.interchange.vu"},"ionian":{"id":"ionian","name":"Ionian","length":"320 km","rfs":"2023 April","rfs_year":2023,"is_planned":false,"owners":"IslaLink","suppliers":null,"landing_points":[{"id":"preveza-greece","name":"Preveza, Greece","country":"Greece","is_tbd":false},{"id":"crotone-italy","name":"Crotone, Italy","country":"Italy","is_tbd":false}],"notes":null,"url":"http://www.islalink.com/"},"ioema":{"id":"ioema","name":"IOEMA","length":"1,620 km","rfs":"2028","rfs_year":2028,"is_planned":true,"owners":"IOEMA Fibre","suppliers":null,"landing_points":[{"id":"blaabjerg-denmark","name":"Blaabjerg, Denmark","country":"Denmark","is_tbd":false},{"id":"wilhelmshaven-germany","name":"Wilhelmshaven, Germany","country":"Germany","is_tbd":false},{"id":"eemshaven-netherlands","name":"Eemshaven, Netherlands","country":"Netherlands","is_tbd":false},{"id":"the-hague-netherlands","name":"The Hague, Netherlands","country":"Netherlands","is_tbd":false},{"id":"kristiansand-norway","name":"Kristiansand, Norway","country":"Norway","is_tbd":false},{"id":"dumpton-gap-united-kingdom","name":"Dumpton Gap, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"leiston-united-kingdom","name":"Leiston, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"https://ioemafibre.eu/"},"iris":{"id":"iris","name":"IRIS","length":"1,770 km","rfs":"2023 March","rfs_year":2023,"is_planned":false,"owners":"Farice","suppliers":"SubCom","landing_points":[{"id":"thorlakshofn-iceland","name":"Thorlakshofn, Iceland","country":"Iceland","is_tbd":false},{"id":"galway-ireland","name":"Galway, Ireland","country":"Ireland","is_tbd":false}],"notes":null,"url":"http://www.farice.is"},"isle-au-haut-cable":{"id":"isle-au-haut-cable","name":"Isle Au Haut Cable","length":"10 km","rfs":"2024 November","rfs_year":2024,"is_planned":false,"owners":"Axiom Technologies","suppliers":"SubCom","landing_points":[{"id":"isle-au-haut-me-united-states","name":"Isle au Haut, ME, United States","country":"United States","is_tbd":false},{"id":"stonington-me-united-states","name":"Stonington, ME, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"isles-of-scilly-cable":{"id":"isles-of-scilly-cable","name":"Isles of Scilly Cable","length":null,"rfs":"2014","rfs_year":2014,"is_planned":false,"owners":"BT","suppliers":null,"landing_points":[{"id":"porthcressa-beach-united-kingdom","name":"Porthcressa Beach, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"porthcurno-united-kingdom","name":"Porthcurno, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"israel-coasting-1-ic-1":{"id":"israel-coasting-1-ic-1","name":"Israel Coasting 1 (IC-1)","length":"340 km","rfs":"2000 September","rfs_year":2000,"is_planned":false,"owners":"Partner Communications Company","suppliers":"Prysmian","landing_points":[{"id":"ashkelon-israel","name":"Ashkelon, Israel","country":"Israel","is_tbd":false},{"id":"haifa-israel","name":"Haifa, Israel","country":"Israel","is_tbd":false},{"id":"herzeliyya-israel","name":"Herzeliyya, Israel","country":"Israel","is_tbd":false},{"id":"nahariyya-israel","name":"Nahariyya, Israel","country":"Israel","is_tbd":false},{"id":"netanya-israel","name":"Netanya, Israel","country":"Israel","is_tbd":false},{"id":"rishon-lezion-israel","name":"Rishon Le’Zion, Israel","country":"Israel","is_tbd":false},{"id":"tel-aviv-israel","name":"Tel Aviv, Israel","country":"Israel","is_tbd":false}],"notes":null,"url":null},"italy-albania":{"id":"italy-albania","name":"Italy-Albania","length":"240 km","rfs":"1997","rfs_year":1997,"is_planned":false,"owners":"ALBtelecom, Sparkle","suppliers":null,"landing_points":[{"id":"durres-albania","name":"Durres, Albania","country":"Albania","is_tbd":false},{"id":"bari-italy","name":"Bari, Italy","country":"Italy","is_tbd":false}],"notes":null,"url":null},"italy-greece-1-ig-1":{"id":"italy-greece-1-ig-1","name":"Italy-Greece 1 (IG-1)","length":"169 km","rfs":"1995","rfs_year":1995,"is_planned":false,"owners":"WIS Telecom","suppliers":null,"landing_points":[{"id":"aethos-greece","name":"Aethos, Greece","country":"Greece","is_tbd":false},{"id":"otranto-italy","name":"Otranto, Italy","country":"Italy","is_tbd":false}],"notes":null,"url":"http://www.wis.one"},"italy-libya":{"id":"italy-libya","name":"Italy-Libya","length":"570 km","rfs":"1998","rfs_year":1998,"is_planned":false,"owners":"Libya International Telecommunications Company, Sparkle","suppliers":"ASN","landing_points":[{"id":"mazara-del-vallo-italy","name":"Mazara del Vallo, Italy","country":"Italy","is_tbd":false},{"id":"tripoli-libya","name":"Tripoli, Libya","country":"Libya","is_tbd":false}],"notes":null,"url":null},"italy-croatia":{"id":"italy-croatia","name":"Italy-Croatia","length":"230 km","rfs":"1994","rfs_year":1994,"is_planned":false,"owners":"Hrvatski Telekom, Sparkle","suppliers":null,"landing_points":[{"id":"umag-croatia","name":"Umag, Croatia","country":"Croatia","is_tbd":false},{"id":"mestre-italy","name":"Mestre, Italy","country":"Italy","is_tbd":false}],"notes":null,"url":null},"ixchel":{"id":"ixchel","name":"Ixchel","length":"20 km","rfs":"2007 August","rfs_year":2007,"is_planned":false,"owners":"Telmex","suppliers":"ASN","landing_points":[{"id":"isla-de-cozumel-mexico","name":"Isla de Cozumel, Mexico","country":"Mexico","is_tbd":false},{"id":"playa-del-carmen-mexico","name":"Playa del Carmen, Mexico","country":"Mexico","is_tbd":false}],"notes":null,"url":"https://telmex.com/"},"italy-malta":{"id":"italy-malta","name":"Italy-Malta","length":"238 km","rfs":"1994","rfs_year":1994,"is_planned":false,"owners":"GO plc, Sparkle","suppliers":"ASN","landing_points":[{"id":"catania-italy","name":"Catania, Italy","country":"Italy","is_tbd":false},{"id":"st-georges-bay-malta","name":"St. George's Bay, Malta","country":"Malta","is_tbd":false}],"notes":null,"url":null},"ithaafushi-maafushi-hulhumale":{"id":"ithaafushi-maafushi-hulhumale","name":"Ithaafushi-Maafushi-Hulhumale","length":null,"rfs":"2025 August","rfs_year":2025,"is_planned":false,"owners":"Ooredoo Maldives","suppliers":null,"landing_points":[{"id":"hulhumale-maldives","name":"Hulhumale, Maldives","country":"Maldives","is_tbd":false},{"id":"ithaafushi-maldives","name":"Ithaafushi, Maldives","country":"Maldives","is_tbd":false},{"id":"maafushi-maldives","name":"Maafushi, Maldives","country":"Maldives","is_tbd":false}],"notes":null,"url":null},"italy-monaco":{"id":"italy-monaco","name":"Italy-Monaco","length":"162 km","rfs":"1995","rfs_year":1995,"is_planned":false,"owners":"Monaco Telecom, Sparkle","suppliers":"Prysmian","landing_points":[{"id":"savona-italy","name":"Savona, Italy","country":"Italy","is_tbd":false},{"id":"monte-carlo-monaco","name":"Monte Carlo, Monaco","country":"Monaco","is_tbd":false}],"notes":null,"url":null},"izu-islands-cable-system":{"id":"izu-islands-cable-system","name":"Izu Islands Cable System","length":null,"rfs":"1996","rfs_year":1996,"is_planned":false,"owners":"NTT","suppliers":null,"landing_points":[{"id":"hachijo-japan","name":"Hachijo, Japan","country":"Japan","is_tbd":false},{"id":"it-japan","name":"Itō, Japan","country":"Japan","is_tbd":false},{"id":"miyake-japan","name":"Miyake, Japan","country":"Japan","is_tbd":false},{"id":"oshima-japan","name":"Oshima, Japan","country":"Japan","is_tbd":false}],"notes":null,"url":null},"jaka2ladema":{"id":"jaka2ladema","name":"JaKa2LaDeMa","length":"1,700 km","rfs":"2010","rfs_year":2010,"is_planned":false,"owners":"Telkom Indonesia","suppliers":"Fujitsu, Norddeutsche Seekabelwerke GmbH (NSW)","landing_points":[{"id":"banjarmasin-indonesia","name":"Banjarmasin, Indonesia","country":"Indonesia","is_tbd":false},{"id":"beculuk-indonesia","name":"Beculuk, Indonesia","country":"Indonesia","is_tbd":false},{"id":"denpasar-indonesia","name":"Denpasar, Indonesia","country":"Indonesia","is_tbd":false},{"id":"gianyar-indonesia","name":"Gianyar, Indonesia","country":"Indonesia","is_tbd":false},{"id":"ketapang-indonesia","name":"Ketapang, Indonesia","country":"Indonesia","is_tbd":false},{"id":"mataram-indonesia","name":"Mataram, Indonesia","country":"Indonesia","is_tbd":false},{"id":"pankalan-indonesia","name":"Pankalan, Indonesia","country":"Indonesia","is_tbd":false},{"id":"pontianak-indonesia","name":"Pontianak, Indonesia","country":"Indonesia","is_tbd":false},{"id":"sangata-indonesia","name":"Sangata, Indonesia","country":"Indonesia","is_tbd":false},{"id":"toweli-indonesia","name":"Toweli, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":null},"jakabare":{"id":"jakabare","name":"JAKABARE","length":"1,330 km","rfs":"2009 November","rfs_year":2009,"is_planned":false,"owners":"Indosat Ooredoo","suppliers":"NEC","landing_points":[{"id":"sungai-kakap-indonesia","name":"Sungai Kakap, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tanjung-bemban-indonesia","name":"Tanjung Bemban, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tanjung-pakis-indonesia","name":"Tanjung Pakis, Indonesia","country":"Indonesia","is_tbd":false},{"id":"changi-north-singapore","name":"Changi North, Singapore","country":"Singapore","is_tbd":false}],"notes":null,"url":"https://indosatooredoo.com"},"jakarta-bangka-batam-singapore-b2js":{"id":"jakarta-bangka-batam-singapore-b2js","name":"Jakarta-Bangka-Batam-Singapore (B2JS)","length":"759 km","rfs":"2013 Q2","rfs_year":2013,"is_planned":false,"owners":"Triasmitra","suppliers":null,"landing_points":[{"id":"batam-indonesia","name":"Batam, Indonesia","country":"Indonesia","is_tbd":false},{"id":"batu-prahu-indonesia","name":"Batu Prahu, Indonesia","country":"Indonesia","is_tbd":false},{"id":"jakarta-indonesia","name":"Jakarta, Indonesia","country":"Indonesia","is_tbd":false},{"id":"pesaren-indonesia","name":"Pesaren, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tanah-merah-singapore","name":"Tanah Merah, Singapore","country":"Singapore","is_tbd":false}],"notes":null,"url":null},"jalapati":{"id":"jalapati","name":"Jalapati","length":"45 km","rfs":"2023 Q1","rfs_year":2023,"is_planned":false,"owners":"CCSI","suppliers":"CCSI","landing_points":[{"id":"candikusuma-indonesia","name":"Candikusuma, Indonesia","country":"Indonesia","is_tbd":false},{"id":"muncar-indonesia","name":"Muncar, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"https://www.ccsi.co.id/home-id-jalapati/"},"jakarta-bangka-bintan-batam-singapore-b3js":{"id":"jakarta-bangka-bintan-batam-singapore-b3js","name":"Jakarta-Bangka-Bintan-Batam-Singapore (B3JS)","length":"1,031 km","rfs":"2012 November","rfs_year":2012,"is_planned":false,"owners":"Moratelindo","suppliers":null,"landing_points":[{"id":"batam-indonesia","name":"Batam, Indonesia","country":"Indonesia","is_tbd":false},{"id":"batu-prahu-indonesia","name":"Batu Prahu, Indonesia","country":"Indonesia","is_tbd":false},{"id":"bintan-indonesia","name":"Bintan, Indonesia","country":"Indonesia","is_tbd":false},{"id":"jakarta-indonesia","name":"Jakarta, Indonesia","country":"Indonesia","is_tbd":false},{"id":"pesaren-indonesia","name":"Pesaren, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tanah-merah-singapore","name":"Tanah Merah, Singapore","country":"Singapore","is_tbd":false}],"notes":null,"url":"http://www.moratelindo.co.id"},"jako":{"id":"jako","name":"JAKO","length":"260 km","rfs":"2027 Q3","rfs_year":2027,"is_planned":true,"owners":"Amazon Web Services, Arteria, Dreamline, Microsoft","suppliers":"LS Cable & System","landing_points":[{"id":"fukuoka-japan","name":"Fukuoka, Japan","country":"Japan","is_tbd":false},{"id":"busan-south-korea","name":"Busan, South Korea","country":"South Korea","is_tbd":false}],"notes":null,"url":null},"jakarta-surabaya-cable-system-jayabaya":{"id":"jakarta-surabaya-cable-system-jayabaya","name":"Jakarta Surabaya Cable System (JAYABAYA)","length":"888 km","rfs":"2018 December","rfs_year":2018,"is_planned":false,"owners":"Triasmitra","suppliers":null,"landing_points":[{"id":"banyu-urip-indonesia","name":"Banyu Urip, Indonesia","country":"Indonesia","is_tbd":false},{"id":"cirebon-indonesia","name":"Cirebon, Indonesia","country":"Indonesia","is_tbd":false},{"id":"kendal-indonesia","name":"Kendal, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tanjung-pakis-indonesia","name":"Tanjung Pakis, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"http://www.triasmitra.com/"},"janna":{"id":"janna","name":"Janna","length":"634 km","rfs":"2005 April","rfs_year":2005,"is_planned":false,"owners":"EXA Infrastructure, Regione Sardegne, Tiscali, WINDTRE","suppliers":"Norddeutsche Seekabelwerke GmbH (NSW)","landing_points":[{"id":"cagliari-italy","name":"Cagliari, Italy","country":"Italy","is_tbd":false},{"id":"civitavecchia-italy","name":"Civitavecchia, Italy","country":"Italy","is_tbd":false},{"id":"mazara-del-vallo-italy","name":"Mazara del Vallo, Italy","country":"Italy","is_tbd":false},{"id":"olbia-italy","name":"Olbia, Italy","country":"Italy","is_tbd":false}],"notes":null,"url":null},"jambi-batam-cable-system-jiba":{"id":"jambi-batam-cable-system-jiba","name":"Jambi-Batam Cable System (JIBA)","length":"267 km","rfs":"2014","rfs_year":2014,"is_planned":false,"owners":"Indosat Ooredoo, Moratelindo, XLSmart","suppliers":null,"landing_points":[{"id":"kuala-tungkal-indonesia","name":"Kuala Tungkal, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tanjung-pinggir-indonesia","name":"Tanjung Pinggir, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"http://www.moratelindo.co.id"},"japan-guam-australia-north-jga-n":{"id":"japan-guam-australia-north-jga-n","name":"Japan-Guam-Australia North (JGA-N)","length":"2,600 km","rfs":"2020 July","rfs_year":2020,"is_planned":false,"owners":"Lightstorm Telecom","suppliers":"NEC","landing_points":[{"id":"piti-guam","name":"Piti, Guam","country":"Guam","is_tbd":false},{"id":"minamiboso-japan","name":"Minamiboso, Japan","country":"Japan","is_tbd":false}],"notes":null,"url":"https://www.lightstorm.net/"},"japan-information-highway-jih":{"id":"japan-information-highway-jih","name":"Japan Information Highway (JIH)","length":"5,150 km","rfs":"1999","rfs_year":1999,"is_planned":false,"owners":"KDDI","suppliers":"NEC","landing_points":[{"id":"akita-japan","name":"Akita, Japan","country":"Japan","is_tbd":false},{"id":"chikura-japan","name":"Chikura, Japan","country":"Japan","is_tbd":false},{"id":"ibaraki-japan","name":"Ibaraki, Japan","country":"Japan","is_tbd":false},{"id":"ishikari-japan","name":"Ishikari, Japan","country":"Japan","is_tbd":false},{"id":"miyazaki-japan","name":"Miyazaki, Japan","country":"Japan","is_tbd":false},{"id":"naha-japan","name":"Naha, Japan","country":"Japan","is_tbd":false},{"id":"ninomiya-japan","name":"Ninomiya, Japan","country":"Japan","is_tbd":false},{"id":"sendai-japan","name":"Sendai, Japan","country":"Japan","is_tbd":false},{"id":"shima-japan","name":"Shima, Japan","country":"Japan","is_tbd":false}],"notes":null,"url":null},"jasuka":{"id":"jasuka","name":"JaSuKa","length":null,"rfs":"2006","rfs_year":2006,"is_planned":false,"owners":"Telkom Indonesia","suppliers":"NEC","landing_points":[{"id":"bandar-lampung-indonesia","name":"Bandar Lampung, Indonesia","country":"Indonesia","is_tbd":false},{"id":"batam-indonesia","name":"Batam, Indonesia","country":"Indonesia","is_tbd":false},{"id":"dumai-indonesia","name":"Dumai, Indonesia","country":"Indonesia","is_tbd":false},{"id":"jakarta-indonesia","name":"Jakarta, Indonesia","country":"Indonesia","is_tbd":false},{"id":"pontianak-indonesia","name":"Pontianak, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tanjung-pakis-indonesia","name":"Tanjung Pakis, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tanjung-pandan-indonesia","name":"Tanjung Pandan, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"https://www.telkom.co.id/"},"java-bali-cable-system-jbcs":{"id":"java-bali-cable-system-jbcs","name":"Java Bali Cable System (JBCS)","length":null,"rfs":"2013","rfs_year":2013,"is_planned":false,"owners":"Triasmitra","suppliers":null,"landing_points":[{"id":"candikusuma-indonesia","name":"Candikusuma, Indonesia","country":"Indonesia","is_tbd":false},{"id":"muncar-indonesia","name":"Muncar, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"http://www.triasmitra.com/"},"java-kalimantan-sulawesi-jakasusi":{"id":"java-kalimantan-sulawesi-jakasusi","name":"Java-Kalimantan-Sulawesi (JAKASUSI)","length":"1,100 km","rfs":"2006","rfs_year":2006,"is_planned":false,"owners":"Indosat Ooredoo","suppliers":"ASN","landing_points":[{"id":"aeng-batu-batu-indonesia","name":"Aeng Batu Batu, Indonesia","country":"Indonesia","is_tbd":false},{"id":"banyu-urip-indonesia","name":"Banyu Urip, Indonesia","country":"Indonesia","is_tbd":false},{"id":"takesung-indonesia","name":"Takesung, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"https://indosatooredoo.com/"},"jeju-mainland-2":{"id":"jeju-mainland-2","name":"Jeju-Mainland 2","length":"191 km","rfs":"1996","rfs_year":1996,"is_planned":false,"owners":"KT","suppliers":"NEC","landing_points":[{"id":"goheung-south-korea","name":"Goheung, South Korea","country":"South Korea","is_tbd":false},{"id":"goseong-ri-south-korea","name":"Goseong-ri, South Korea","country":"South Korea","is_tbd":false}],"notes":null,"url":null},"jeju-udo":{"id":"jeju-udo","name":"Jeju-Udo","length":"3 km","rfs":"2023 December","rfs_year":2023,"is_planned":false,"owners":"KCTV Jeju Broadcasting, KT, LG Uplus, SK Telecom (SKT)","suppliers":null,"landing_points":[{"id":"goseong-ri-south-korea","name":"Goseong-ri, South Korea","country":"South Korea","is_tbd":false},{"id":"udo-south-korea","name":"Udo, South Korea","country":"South Korea","is_tbd":false}],"notes":null,"url":null},"jeju-mainland-3":{"id":"jeju-mainland-3","name":"Jeju-Mainland 3","length":"236 km","rfs":"2000","rfs_year":2000,"is_planned":false,"owners":"KT","suppliers":"NEC","landing_points":[{"id":"goseong-ri-south-korea","name":"Goseong-ri, South Korea","country":"South Korea","is_tbd":false},{"id":"mijo-myeon-south-korea","name":"Mijo-myeon, South Korea","country":"South Korea","is_tbd":false}],"notes":null,"url":null},"javali":{"id":"javali","name":"JAVALI","length":null,"rfs":"2011 December","rfs_year":2011,"is_planned":false,"owners":"Indosat Ooredoo","suppliers":"Norddeutsche Seekabelwerke GmbH (NSW)","landing_points":[{"id":"benculuk-indonesia","name":"Benculuk, Indonesia","country":"Indonesia","is_tbd":false},{"id":"jimbaran-indonesia","name":"Jimbaran, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":null},"jerry-newton":{"id":"jerry-newton","name":"Jerry Newton","length":"88 km","rfs":"2007 October","rfs_year":2007,"is_planned":false,"owners":"Liberty Networks","suppliers":"SubCom","landing_points":[{"id":"north-salina-bonaire-sint-eustatius-and-saba","name":"North Salina, Bonaire, Sint Eustatius and Saba","country":"Bonaire, Sint Eustatius and Saba","is_tbd":false},{"id":"willemstad-curaao","name":"Willemstad, Curaçao","country":"Curaçao","is_tbd":false}],"notes":null,"url":"https://libertynet.com"},"jonah":{"id":"jonah","name":"Jonah","length":"2,297 km","rfs":"2012 January","rfs_year":2012,"is_planned":false,"owners":"Bezeq International Ltd.","suppliers":"ASN","landing_points":[{"id":"tel-aviv-israel","name":"Tel Aviv, Israel","country":"Israel","is_tbd":false},{"id":"bari-italy","name":"Bari, Italy","country":"Italy","is_tbd":false}],"notes":null,"url":"https://www.bezeqint.net/"},"jscfs":{"id":"jscfs","name":"JSCFS","length":"342 km","rfs":"1997","rfs_year":1997,"is_planned":false,"owners":"CW Jamaica","suppliers":null,"landing_points":[{"id":"black-river-jamaica","name":"Black River, Jamaica","country":"Jamaica","is_tbd":false},{"id":"bull-bay-jamaica","name":"Bull Bay, Jamaica","country":"Jamaica","is_tbd":false},{"id":"montego-bay-jamaica","name":"Montego Bay, Jamaica","country":"Jamaica","is_tbd":false},{"id":"negril-jamaica","name":"Negril, Jamaica","country":"Jamaica","is_tbd":false}],"notes":null,"url":"https://libertynet.com/contact"},"junior":{"id":"junior","name":"Junior","length":"390 km","rfs":"2018 Q3","rfs_year":2018,"is_planned":false,"owners":"Google","suppliers":"Padtec","landing_points":[{"id":"rio-de-janeiro-brazil","name":"Rio de Janeiro, Brazil","country":"Brazil","is_tbd":false},{"id":"santos-brazil","name":"Santos, Brazil","country":"Brazil","is_tbd":false}],"notes":null,"url":"https://cloud.google.com"},"juno":{"id":"juno","name":"JUNO","length":"11,710 km","rfs":"2025 May","rfs_year":2025,"is_planned":false,"owners":"Seren Juno","suppliers":"NEC","landing_points":[{"id":"minamiboso-japan","name":"Minamiboso, Japan","country":"Japan","is_tbd":false},{"id":"shima-japan","name":"Shima, Japan","country":"Japan","is_tbd":false},{"id":"grover-beach-ca-united-states","name":"Grover Beach, CA, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://seren-juno.com/"},"jupiter":{"id":"jupiter","name":"JUPITER","length":"14,557 km","rfs":"2020","rfs_year":2020,"is_planned":false,"owners":"Amazon Web Services, Meta, NTT, PCCW, PLDT, Softbank","suppliers":"SubCom","landing_points":[{"id":"maruyama-japan","name":"Maruyama, Japan","country":"Japan","is_tbd":false},{"id":"shima-japan","name":"Shima, Japan","country":"Japan","is_tbd":false},{"id":"daet-philippines","name":"Daet, Philippines","country":"Philippines","is_tbd":false},{"id":"cloverdale-or-united-states","name":"Cloverdale, OR, United States","country":"United States","is_tbd":false},{"id":"hermosa-beach-ca-united-states","name":"Hermosa Beach, CA, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"kafos":{"id":"kafos","name":"KAFOS","length":"538 km","rfs":"1997 June","rfs_year":1997,"is_planned":false,"owners":"Turk Telekom International","suppliers":"ASN","landing_points":[{"id":"varna-bulgaria","name":"Varna, Bulgaria","country":"Bulgaria","is_tbd":false},{"id":"mangalia-romania","name":"Mangalia, Romania","country":"Romania","is_tbd":false},{"id":"igneada-turkey","name":"Igneada, Turkey","country":"Turkey","is_tbd":false},{"id":"istanbul-turkey","name":"Istanbul, Turkey","country":"Turkey","is_tbd":false}],"notes":null,"url":null},"kanawa":{"id":"kanawa","name":"Kanawa","length":"1,746 km","rfs":"2019 January","rfs_year":2019,"is_planned":false,"owners":"Orange","suppliers":"ASN","landing_points":[{"id":"kourou-french-guiana","name":"Kourou, French Guiana","country":"French Guiana","is_tbd":false},{"id":"schoelcher-martinique","name":"Schoelcher, Martinique","country":"Martinique","is_tbd":false}],"notes":null,"url":null},"kangaroo-island-2":{"id":"kangaroo-island-2","name":"Kangaroo Island 2","length":null,"rfs":"2024 July","rfs_year":2024,"is_planned":false,"owners":"SA Power Networks","suppliers":null,"landing_points":[{"id":"cape-jervis-sa-australia","name":"Cape Jervis, SA, Australia","country":"Australia","is_tbd":false},{"id":"kingscote-sa-australia","name":"Kingscote, SA, Australia","country":"Australia","is_tbd":false}],"notes":null,"url":null},"kardesa":{"id":"kardesa","name":"Kardesa","length":"1,385 km","rfs":"2027","rfs_year":2027,"is_planned":true,"owners":"Neqsol Holding, Vodafone","suppliers":"Xtera","landing_points":[{"id":"aheloy-bulgaria","name":"Aheloy, Bulgaria","country":"Bulgaria","is_tbd":false},{"id":"poti-georgia","name":"Poti, Georgia","country":"Georgia","is_tbd":false},{"id":"sile-turkey","name":"Sile, Turkey","country":"Turkey","is_tbd":false},{"id":"odessa-ukraine","name":"Odessa, Ukraine","country":"Ukraine","is_tbd":false}],"notes":null,"url":null},"kattegat-2":{"id":"kattegat-2","name":"Kattegat 2","length":"75 km","rfs":"2001","rfs_year":2001,"is_planned":false,"owners":"TDC Group","suppliers":null,"landing_points":[{"id":"lyngsa-denmark","name":"Lyngsa, Denmark","country":"Denmark","is_tbd":false},{"id":"osterby-denmark","name":"Osterby, Denmark","country":"Denmark","is_tbd":false},{"id":"vestero-denmark","name":"Vestero, Denmark","country":"Denmark","is_tbd":false},{"id":"skalvik-sweden","name":"Skalvik, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":null},"ketchcan1-submarine-fiber-cable-system":{"id":"ketchcan1-submarine-fiber-cable-system","name":"KetchCan1 Submarine Fiber Cable System","length":"167 km","rfs":"2020 November","rfs_year":2020,"is_planned":false,"owners":"Ketchican Public Utilities","suppliers":null,"landing_points":[{"id":"prince-rupert-bc-canada","name":"Prince Rupert, BC, Canada","country":"Canada","is_tbd":false},{"id":"ketchikan-ak-united-states","name":"Ketchikan, AK, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.kputel.com/"},"kingisepp-kaliningrad-system-baltika":{"id":"kingisepp-kaliningrad-system-baltika","name":"Kingisepp-Kaliningrad System (Baltika)","length":"1,115 km","rfs":"2021","rfs_year":2021,"is_planned":false,"owners":"Rostelecom","suppliers":null,"landing_points":[{"id":"kingisepp-russia","name":"Kingisepp, Russia","country":"Russia","is_tbd":false},{"id":"zelenogradsk-russia","name":"Zelenogradsk, Russia","country":"Russia","is_tbd":false}],"notes":null,"url":"https://www.company.rt.ru/en/"},"kerch-strait-cable":{"id":"kerch-strait-cable","name":"Kerch Strait Cable","length":"46 km","rfs":"2014 April","rfs_year":2014,"is_planned":false,"owners":"Miranda Media","suppliers":null,"landing_points":[{"id":"ilyich-russia","name":"Ilyich, Russia","country":"Russia","is_tbd":false},{"id":"kerch-ukraine","name":"Kerch, Ukraine","country":"Ukraine","is_tbd":false}],"notes":null,"url":null},"kitadaito-island":{"id":"kitadaito-island","name":"Kitadaito Island","length":"410 km","rfs":"2022","rfs_year":2022,"is_planned":false,"owners":"Okinawa Prefecture","suppliers":null,"landing_points":[{"id":"kitadaito-japan","name":"Kitadaito, Japan","country":"Japan","is_tbd":false},{"id":"yaese-japan","name":"Yaese, Japan","country":"Japan","is_tbd":false}],"notes":null,"url":null},"japan-guam-australia-south-jga-s":{"id":"japan-guam-australia-south-jga-s","name":"Japan-Guam-Australia South (JGA-S)","length":"7,081 km","rfs":"2020 March","rfs_year":2020,"is_planned":false,"owners":"Australia’s Academic and Research Network (AARNET), Google, Lightstorm Telecom","suppliers":"ASN","landing_points":[{"id":"brookvale-nsw-australia","name":"Brookvale, NSW, Australia","country":"Australia","is_tbd":false},{"id":"maroochydore-qld-australia","name":"Maroochydore, QLD, Australia","country":"Australia","is_tbd":false},{"id":"piti-guam","name":"Piti, Guam","country":"Guam","is_tbd":false}],"notes":null,"url":"https://www.lightstorm.net/"},"kochi-lakshadweep-islands-kli-sofc":{"id":"kochi-lakshadweep-islands-kli-sofc","name":"Kochi-Lakshadweep Islands (KLI-SOFC)","length":"1,989 km","rfs":"2024 January","rfs_year":2024,"is_planned":false,"owners":"Bharat Sanchar Nigam Ltd. (BSNL)","suppliers":"NEC","landing_points":[{"id":"agatti-india","name":"Agatti, India","country":"India","is_tbd":false},{"id":"amini-india","name":"Amini, India","country":"India","is_tbd":false},{"id":"andrott-india","name":"Andrott, India","country":"India","is_tbd":false},{"id":"bangaram-india","name":"Bangaram, India","country":"India","is_tbd":false},{"id":"bitra-india","name":"Bitra, India","country":"India","is_tbd":false},{"id":"chetlat-india","name":"Chetlat, India","country":"India","is_tbd":false},{"id":"kadmat-india","name":"Kadmat, India","country":"India","is_tbd":false},{"id":"kalpeni-india","name":"Kalpeni, India","country":"India","is_tbd":false},{"id":"kavaratti-india","name":"Kavaratti, India","country":"India","is_tbd":false},{"id":"kiltan-india","name":"Kiltan, India","country":"India","is_tbd":false},{"id":"kochi-india","name":"Kochi, India","country":"India","is_tbd":false},{"id":"minicoy-india","name":"Minicoy, India","country":"India","is_tbd":false}],"notes":null,"url":"http://www.bsnl.co.in"},"kodiak-kenai-fiber-link-kkfl":{"id":"kodiak-kenai-fiber-link-kkfl","name":"Kodiak Kenai Fiber Link (KKFL)","length":"966 km","rfs":"2007 January","rfs_year":2007,"is_planned":false,"owners":"GCI Communication Corp","suppliers":"ASN","landing_points":[{"id":"anchorage-ak-united-states","name":"Anchorage, AK, United States","country":"United States","is_tbd":false},{"id":"homer-ak-united-states","name":"Homer, AK, United States","country":"United States","is_tbd":false},{"id":"kenai-ak-united-states","name":"Kenai, AK, United States","country":"United States","is_tbd":false},{"id":"kodiak-ak-united-states","name":"Kodiak, AK, United States","country":"United States","is_tbd":false},{"id":"narrow-cape-ak-united-states","name":"Narrow Cape, AK, United States","country":"United States","is_tbd":false},{"id":"seward-ak-united-states","name":"Seward, AK, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.gci.com/"},"konstanz-friedrichshafen":{"id":"konstanz-friedrichshafen","name":"Konstanz-Friedrichshafen","length":"26 km","rfs":"2007","rfs_year":2007,"is_planned":false,"owners":"Stadtwerke Konstanz","suppliers":null,"landing_points":[{"id":"friedrichshafen-germany","name":"Friedrichshafen, Germany","country":"Germany","is_tbd":false},{"id":"konstanz-germany","name":"Konstanz, Germany","country":"Germany","is_tbd":false}],"notes":null,"url":null},"korea-japan-cable-network-kjcn":{"id":"korea-japan-cable-network-kjcn","name":"Korea-Japan Cable Network (KJCN)","length":"500 km","rfs":"2002 March","rfs_year":2002,"is_planned":false,"owners":"KT, NTT, QTNet, Softbank","suppliers":"ASN, Fujitsu","landing_points":[{"id":"fukuoka-japan","name":"Fukuoka, Japan","country":"Japan","is_tbd":false},{"id":"kitakyushu-japan","name":"Kitakyushu, Japan","country":"Japan","is_tbd":false},{"id":"busan-south-korea","name":"Busan, South Korea","country":"South Korea","is_tbd":false}],"notes":null,"url":null},"konstanz-meersburg":{"id":"konstanz-meersburg","name":"Konstanz-Meersburg","length":"5 km","rfs":"2010 Q4","rfs_year":2010,"is_planned":false,"owners":"Stadtwerke Konstanz","suppliers":null,"landing_points":[{"id":"konstanz-germany","name":"Konstanz, Germany","country":"Germany","is_tbd":false},{"id":"meersburg-germany","name":"Meersburg, Germany","country":"Germany","is_tbd":false}],"notes":null,"url":null},"kumul-domestic-submarine-cable-system":{"id":"kumul-domestic-submarine-cable-system","name":"Kumul Domestic Submarine Cable System","length":"5,457 km","rfs":"2019 February","rfs_year":2019,"is_planned":false,"owners":"PNG DataCo Limited","suppliers":"HMN Tech","landing_points":[{"id":"jayapura-indonesia","name":"Jayapura, Indonesia","country":"Indonesia","is_tbd":false},{"id":"alotau-papua-new-guinea","name":"Alotau, Papua New Guinea","country":"Papua New Guinea","is_tbd":false},{"id":"arawa-papua-new-guinea","name":"Arawa, Papua New Guinea","country":"Papua New Guinea","is_tbd":false},{"id":"daru-papua-new-guinea","name":"Daru, Papua New Guinea","country":"Papua New Guinea","is_tbd":false},{"id":"kavieng-papua-new-guinea","name":"Kavieng, Papua New Guinea","country":"Papua New Guinea","is_tbd":false},{"id":"kerema-papua-new-guinea","name":"Kerema, Papua New Guinea","country":"Papua New Guinea","is_tbd":false},{"id":"kimbe-papua-new-guinea","name":"Kimbe, Papua New Guinea","country":"Papua New Guinea","is_tbd":false},{"id":"kokopo-papua-new-guinea","name":"Kokopo, Papua New Guinea","country":"Papua New Guinea","is_tbd":false},{"id":"lae-papua-new-guinea","name":"Lae, Papua New Guinea","country":"Papua New Guinea","is_tbd":false},{"id":"lorengau-papua-new-guinea","name":"Lorengau, Papua New Guinea","country":"Papua New Guinea","is_tbd":false},{"id":"madang-papua-new-guinea","name":"Madang, Papua New Guinea","country":"Papua New Guinea","is_tbd":false},{"id":"popondetta-papua-new-guinea","name":"Popondetta, Papua New Guinea","country":"Papua New Guinea","is_tbd":false},{"id":"port-moresby-papua-new-guinea","name":"Port Moresby, Papua New Guinea","country":"Papua New Guinea","is_tbd":false},{"id":"vanimo-papua-new-guinea","name":"Vanimo, Papua New Guinea","country":"Papua New Guinea","is_tbd":false},{"id":"wewak-papua-new-guinea","name":"Wewak, Papua New Guinea","country":"Papua New Guinea","is_tbd":false}],"notes":null,"url":"https://www.pngdataco.com/"},"kupang-alor-cable-systems":{"id":"kupang-alor-cable-systems","name":"Kupang-Alor Cable Systems","length":"273 km","rfs":"2019","rfs_year":2019,"is_planned":false,"owners":"Moratelindo","suppliers":null,"landing_points":[{"id":"alor-indonesia","name":"Alor, Indonesia","country":"Indonesia","is_tbd":false},{"id":"kupang-indonesia","name":"Kupang, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"http://moratelindo.co.id"},"kuwait-iran":{"id":"kuwait-iran","name":"Kuwait-Iran","length":"380 km","rfs":"2005 June","rfs_year":2005,"is_planned":false,"owners":"Kuwait Ministry of Communications, Telecommunication Infrastructure Company of Iran","suppliers":"Prysmian","landing_points":[{"id":"ganaveh-iran","name":"Ganaveh, Iran","country":"Iran","is_tbd":false},{"id":"khark-island-iran","name":"Khark Island, Iran","country":"Iran","is_tbd":false},{"id":"soroosh-platform-iran","name":"Soroosh Platform, Iran","country":"Iran","is_tbd":false},{"id":"kuwait-city-kuwait","name":"Kuwait City, Kuwait","country":"Kuwait","is_tbd":false}],"notes":null,"url":null},"labuan-brunei-submarine-cable":{"id":"labuan-brunei-submarine-cable","name":"Labuan-Brunei Submarine Cable","length":"52 km","rfs":"2017 April","rfs_year":2017,"is_planned":false,"owners":"Common Tower Technologies Sdn Bhd","suppliers":null,"landing_points":[{"id":"tungku-brunei","name":"Tungku, Brunei","country":"Brunei","is_tbd":false},{"id":"kiamsam-malaysia","name":"Kiamsam, Malaysia","country":"Malaysia","is_tbd":false}],"notes":null,"url":"http://www.cttsb.com.my"},"la-gomera-el-hierro":{"id":"la-gomera-el-hierro","name":"La Gomera-El Hierro","length":null,"rfs":null,"rfs_year":null,"is_planned":false,"owners":"Telefonica","suppliers":null,"landing_points":[{"id":"san-sebastian-de-la-gomera-canary-islands-spain","name":"San Sebastian de la Gomera, Canary Islands, Spain","country":"Spain","is_tbd":false},{"id":"valverde-canary-islands-spain","name":"Valverde, Canary Islands, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":null},"lake-albert-2":{"id":"lake-albert-2","name":"Lake Albert 2","length":"44 km","rfs":"2020","rfs_year":2020,"is_planned":false,"owners":"Bandwidth and Cloud Services (BCS)","suppliers":null,"landing_points":[{"id":"kasenyi-congo-dem-rep-","name":"Kasenyi, Congo, Dem. Rep.","country":"Congo, Dem. Rep.","is_tbd":false},{"id":"mpeefu-uganda","name":"Mpeefu, Uganda","country":"Uganda","is_tbd":false}],"notes":null,"url":"https://www.bcs-ea.com/"},"lake-albert-1":{"id":"lake-albert-1","name":"Lake Albert 1","length":"51 km","rfs":"2019","rfs_year":2019,"is_planned":false,"owners":"Bandwidth and Cloud Services (BCS)","suppliers":null,"landing_points":[{"id":"tchoima-congo-dem-rep-","name":"Tchoima, Congo, Dem. Rep.","country":"Congo, Dem. Rep.","is_tbd":false},{"id":"nkusi-uganda","name":"Nkusi, Uganda","country":"Uganda","is_tbd":false}],"notes":null,"url":"https://www.bcs-ea.com/"},"lake-michigan-chicago-crossing":{"id":"lake-michigan-chicago-crossing","name":"Lake Michigan Chicago Crossing","length":null,"rfs":"2028","rfs_year":2028,"is_planned":true,"owners":"123NET, Peninsula Fiber Network (PFN)","suppliers":null,"landing_points":[{"id":"benton-harbor-mi-united-states","name":"Benton Harbor, MI, United States","country":"United States","is_tbd":false},{"id":"chicago-il-united-states","name":"Chicago, IL, United States","country":"United States","is_tbd":false},{"id":"st-joseph-mi-united-states","name":"St. Joseph, MI, United States","country":"United States","is_tbd":false}],"notes":"These two segments crossing Lake Michigan make diverse landings at Chicago and form a loop.","url":null},"lake-michigan-crossing-peninsula-and-island-connection":{"id":"lake-michigan-crossing-peninsula-and-island-connection","name":"Lake Michigan Crossing Peninsula and Island Connection","length":null,"rfs":"2028","rfs_year":2028,"is_planned":true,"owners":"123NET, Peninsula Fiber Network (PFN)","suppliers":null,"landing_points":[{"id":"beaver-island-mi-united-states","name":"Beaver Island, MI, United States","country":"United States","is_tbd":false},{"id":"charlevoix-mi-united-states","name":"Charlevoix, MI, United States","country":"United States","is_tbd":false},{"id":"gulliver-mi-united-states","name":"Gulliver, MI, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"lake-tanganyika":{"id":"lake-tanganyika","name":"Lake Tanganyika","length":"370 km","rfs":"2025 Q1","rfs_year":2025,"is_planned":false,"owners":"Bandwidth and Cloud Services (BCS)","suppliers":null,"landing_points":[{"id":"kalemie-congo-dem-rep-","name":"Kalemie, Congo, Dem. Rep.","country":"Congo, Dem. Rep.","is_tbd":false},{"id":"uvira-congo-dem-rep-","name":"Uvira, Congo, Dem. Rep.","country":"Congo, Dem. Rep.","is_tbd":false}],"notes":null,"url":"https://www.bcs-ea.com/"},"lanis-2":{"id":"lanis-2","name":"Lanis-2","length":"67 km","rfs":"1992","rfs_year":1992,"is_planned":false,"owners":"Vodafone","suppliers":null,"landing_points":[{"id":"peel-isle-of-man","name":"Peel, Isle of Man","country":"Isle of Man","is_tbd":false},{"id":"ballywater-united-kingdom","name":"Ballywater, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"lanis-1":{"id":"lanis-1","name":"Lanis-1","length":"113 km","rfs":"1992","rfs_year":1992,"is_planned":false,"owners":"Vodafone","suppliers":null,"landing_points":[{"id":"port-grenaugh-isle-of-man","name":"Port Grenaugh, Isle of Man","country":"Isle of Man","is_tbd":false},{"id":"blackpool-united-kingdom","name":"Blackpool, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"lanis-3":{"id":"lanis-3","name":"Lanis-3","length":"122 km","rfs":"1992","rfs_year":1992,"is_planned":false,"owners":"Vodafone","suppliers":null,"landing_points":[{"id":"troon-united-kingdom","name":"Troon, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"whitehead-united-kingdom","name":"Whitehead, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"latvia-sweden-1-lv-se-1":{"id":"latvia-sweden-1-lv-se-1","name":"Latvia-Sweden 1 (LV-SE 1)","length":"304 km","rfs":"1994","rfs_year":1994,"is_planned":false,"owners":"Tele2, Tet","suppliers":null,"landing_points":[{"id":"ventspils-latvia","name":"Ventspils, Latvia","country":"Latvia","is_tbd":false},{"id":"nynashamn-sweden","name":"Nynashamn, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":null},"lazaro-cardenas-manzanillo-santiago-submarine-cable-system-lcmsscs":{"id":"lazaro-cardenas-manzanillo-santiago-submarine-cable-system-lcmsscs","name":"Lazaro Cardenas-Manzanillo Santiago Submarine Cable System (LCMSSCS)","length":"322 km","rfs":"2013 Q4","rfs_year":2013,"is_planned":false,"owners":"Telmex","suppliers":null,"landing_points":[{"id":"ciudad-lzaro-crdenas-mexico","name":"Ciudad Lázaro Cárdenas, Mexico","country":"Mexico","is_tbd":false},{"id":"ixtapa-zihuatanejo-mexico","name":"Ixtapa-Zihuatanejo, Mexico","country":"Mexico","is_tbd":false},{"id":"manzanillo-mexico","name":"Manzanillo, Mexico","country":"Mexico","is_tbd":false}],"notes":null,"url":"https://telmex.com/"},"les-dhyres-cable":{"id":"les-dhyres-cable","name":"Îles d'Hyères Cable","length":"45 km","rfs":"1996","rfs_year":1996,"is_planned":false,"owners":"Orange","suppliers":"ASN","landing_points":[{"id":"hliopolis-france","name":"Héliopolis, France","country":"France","is_tbd":false},{"id":"la-tour-fondue-france","name":"La Tour Fondue, France","country":"France","is_tbd":false},{"id":"le-lavandou-france","name":"Le Lavandou, France","country":"France","is_tbd":false},{"id":"porquerolles-france","name":"Porquerolles, France","country":"France","is_tbd":false},{"id":"port-cros-france","name":"Port-Cros, France","country":"France","is_tbd":false}],"notes":null,"url":null},"le-vasa":{"id":"le-vasa","name":"Le Vasa","length":null,"rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"ASTCA","suppliers":"SubCom","landing_points":[{"id":"pago-pago-american-samoa","name":"Pago Pago, American Samoa","country":"American Samoa","is_tbd":false}],"notes":null,"url":null},"lfon-libyan-fiber-optic-network":{"id":"lfon-libyan-fiber-optic-network","name":"LFON (Libyan Fiber Optic Network)","length":"1,639 km","rfs":"1999","rfs_year":1999,"is_planned":false,"owners":"Libyan Post, Telecommunications and Information Technology Company (LPTIC Holding)","suppliers":"ASN","landing_points":[{"id":"al-bayda-libya","name":"Al Bayda, Libya","country":"Libya","is_tbd":false},{"id":"al-khums-libya","name":"Al Khums, Libya","country":"Libya","is_tbd":false},{"id":"benghazi-libya","name":"Benghazi, Libya","country":"Libya","is_tbd":false},{"id":"brega-libya","name":"Brega, Libya","country":"Libya","is_tbd":false},{"id":"derna-libya","name":"Derna, Libya","country":"Libya","is_tbd":false},{"id":"misuratah-libya","name":"Misuratah, Libya","country":"Libya","is_tbd":false},{"id":"ras-lanuf-libya","name":"Ras Lanuf, Libya","country":"Libya","is_tbd":false},{"id":"sirte-libya","name":"Sirte, Libya","country":"Libya","is_tbd":false},{"id":"tobruk-libya","name":"Tobruk, Libya","country":"Libya","is_tbd":false},{"id":"tolmeta-libya","name":"Tolmeta, Libya","country":"Libya","is_tbd":false},{"id":"tripoli-libya","name":"Tripoli, Libya","country":"Libya","is_tbd":false},{"id":"zawia-libya","name":"Zawia, Libya","country":"Libya","is_tbd":false},{"id":"zuwara-libya","name":"Zuwara, Libya","country":"Libya","is_tbd":false}],"notes":null,"url":null},"libreville-port-gentil-cable":{"id":"libreville-port-gentil-cable","name":"Libreville-Port Gentil Cable","length":"198 km","rfs":"2012 April","rfs_year":2012,"is_planned":false,"owners":"Republic of Gabon","suppliers":null,"landing_points":[{"id":"libreville-gabon","name":"Libreville, Gabon","country":"Gabon","is_tbd":false},{"id":"port-gentil-gabon","name":"Port Gentil, Gabon","country":"Gabon","is_tbd":false}],"notes":null,"url":null},"lic-lin-lamp":{"id":"lic-lin-lamp","name":"Lic-Lin-Lamp","length":"240 km","rfs":"2000","rfs_year":2000,"is_planned":false,"owners":"Sparkle","suppliers":null,"landing_points":[{"id":"lampedusa-italy","name":"Lampedusa, Italy","country":"Italy","is_tbd":false},{"id":"licata-italy","name":"Licata, Italy","country":"Italy","is_tbd":false},{"id":"linosa-italy","name":"Linosa, Italy","country":"Italy","is_tbd":false}],"notes":null,"url":null},"link-1-phase-1":{"id":"link-1-phase-1","name":"Link 1 Phase-1","length":"368 km","rfs":"2003 December","rfs_year":2003,"is_planned":false,"owners":"XLSmart","suppliers":null,"landing_points":[{"id":"bulobulo-indonesia","name":"Bulobulo, Indonesia","country":"Indonesia","is_tbd":false},{"id":"kawinda-nae-indonesia","name":"Kawinda Nae, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"https://www.xl.co.id"},"link-1-phase-2":{"id":"link-1-phase-2","name":"Link 1 Phase-2","length":"94 km","rfs":"2005 May","rfs_year":2005,"is_planned":false,"owners":"XLSmart","suppliers":null,"landing_points":[{"id":"sanur-indonesia","name":"Sanur, Indonesia","country":"Indonesia","is_tbd":false},{"id":"senggigi-indonesia","name":"Senggigi, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"https://www.xl.co.id"},"link-2-phase-2":{"id":"link-2-phase-2","name":"Link 2 Phase-2","length":"221 km","rfs":"2005 May","rfs_year":2005,"is_planned":false,"owners":"XLSmart","suppliers":null,"landing_points":[{"id":"jimbaran-indonesia","name":"Jimbaran, Indonesia","country":"Indonesia","is_tbd":false},{"id":"puger-indonesia","name":"Puger, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"https://www.xl.co.id"},"link-2-phase-1":{"id":"link-2-phase-1","name":"Link 2 Phase-1","length":"281 km","rfs":"2004 January","rfs_year":2004,"is_planned":false,"owners":"XLSmart","suppliers":null,"landing_points":[{"id":"sangatta-indonesia","name":"Sangatta, Indonesia","country":"Indonesia","is_tbd":false},{"id":"towale-indonesia","name":"Towale, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"https://www.xl.co.id"},"link-3-phase-1":{"id":"link-3-phase-1","name":"Link 3 Phase-1","length":"275 km","rfs":"2003 December","rfs_year":2003,"is_planned":false,"owners":"XLSmart","suppliers":null,"landing_points":[{"id":"kawinda-nae-indonesia","name":"Kawinda Nae, Indonesia","country":"Indonesia","is_tbd":false},{"id":"senggigi-indonesia","name":"Senggigi, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"https://www.xl.co.id"},"link-3-phase-2":{"id":"link-3-phase-2","name":"Link 3 Phase-2","length":"342 km","rfs":"2005 May","rfs_year":2005,"is_planned":false,"owners":"XLSmart","suppliers":null,"landing_points":[{"id":"ancol-indonesia","name":"Ancol, Indonesia","country":"Indonesia","is_tbd":false},{"id":"mentigi-indonesia","name":"Mentigi, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tanjung-pakis-indonesia","name":"Tanjung Pakis, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"https://www.xl.co.id"},"link-4-phase-2":{"id":"link-4-phase-2","name":"Link 4 Phase-2","length":"300 km","rfs":"2005 March","rfs_year":2005,"is_planned":false,"owners":"XLSmart","suppliers":null,"landing_points":[{"id":"mentigi-indonesia","name":"Mentigi, Indonesia","country":"Indonesia","is_tbd":false},{"id":"sungailiat-indonesia","name":"Sungailiat, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"https://www.xl.co.id"},"link-5-phase-2":{"id":"link-5-phase-2","name":"Link 5 Phase-2","length":"365 km","rfs":"2005 July","rfs_year":2005,"is_planned":false,"owners":"XLSmart","suppliers":null,"landing_points":[{"id":"kuala-tungkal-indonesia","name":"Kuala Tungkal, Indonesia","country":"Indonesia","is_tbd":false},{"id":"sungailiat-indonesia","name":"Sungailiat, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"https://www.xl.co.id"},"longyearbyen-ny-lesund":{"id":"longyearbyen-ny-lesund","name":"Longyearbyen-Ny-Ålesund","length":"540 km","rfs":"2015 May","rfs_year":2015,"is_planned":false,"owners":"Sikt","suppliers":"Nexans","landing_points":[{"id":"longyearbyen-svalbard-norway","name":"Longyearbyen, Svalbard, Norway","country":"Norway","is_tbd":false},{"id":"ny-lesund-norway","name":"Ny-Ålesund, Norway","country":"Norway","is_tbd":false}],"notes":null,"url":null},"lower-indian-ocean-network-2-lion2":{"id":"lower-indian-ocean-network-2-lion2","name":"Lower Indian Ocean Network 2 (LION2)","length":"2,700 km","rfs":"2012 April","rfs_year":2012,"is_planned":false,"owners":"Emtel, Mauritius Telecom, Orange, Orange Madagascar, Société Réunionnaise du Radiotéléphone, Telkom Kenya","suppliers":"ASN","landing_points":[{"id":"nyali-kenya","name":"Nyali, Kenya","country":"Kenya","is_tbd":false},{"id":"kaweni-mayotte","name":"Kaweni, Mayotte","country":"Mayotte","is_tbd":false}],"notes":null,"url":"https://lion-submarinesystem.com"},"lower-indian-ocean-network-lion":{"id":"lower-indian-ocean-network-lion","name":"Lower Indian Ocean Network (LION)","length":"1,060 km","rfs":"2009 November","rfs_year":2009,"is_planned":false,"owners":"Mauritius Telecom, Orange, Orange Madagascar","suppliers":"ASN","landing_points":[{"id":"toamasina-madagascar","name":"Toamasina, Madagascar","country":"Madagascar","is_tbd":false},{"id":"terre-rouge-mauritius","name":"Terre Rouge, Mauritius","country":"Mauritius","is_tbd":false},{"id":"sainte-marie-runion","name":"Sainte Marie, Réunion","country":"Réunion","is_tbd":false}],"notes":null,"url":"https://lion-submarinesystem.com"},"lumut-pangkor-island":{"id":"lumut-pangkor-island","name":"Lumut-Pangkor Island","length":"4 km","rfs":"2018","rfs_year":2018,"is_planned":false,"owners":"Telekom Malaysia","suppliers":null,"landing_points":[{"id":"lumut-malaysia","name":"Lumut, Malaysia","country":"Malaysia","is_tbd":false},{"id":"pantai-teluk-baharu-malaysia","name":"Pantai Teluk Baharu, Malaysia","country":"Malaysia","is_tbd":false}],"notes":null,"url":null},"luwuk-tutuyan-cable-system-ltcs":{"id":"luwuk-tutuyan-cable-system-ltcs","name":"Luwuk Tutuyan Cable System (LTCS)","length":"446 km","rfs":"2015","rfs_year":2015,"is_planned":false,"owners":"Telkom Indonesia","suppliers":null,"landing_points":[{"id":"luwuk-indonesia","name":"Luwuk, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tutuyan-indonesia","name":"Tutuyan, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":null},"lynn-canal-fiber":{"id":"lynn-canal-fiber","name":"Lynn Canal Fiber","length":"138 km","rfs":"2016 October","rfs_year":2016,"is_planned":false,"owners":"Alaska Power & Telephone Company (AP&T)","suppliers":null,"landing_points":[{"id":"haines-ak-united-states","name":"Haines, AK, United States","country":"United States","is_tbd":false},{"id":"lena-point-ak-united-states","name":"Lena Point, AK, United States","country":"United States","is_tbd":false},{"id":"skagway-ak-united-states","name":"Skagway, AK, United States","country":"United States","is_tbd":false}],"notes":null,"url":"http://www.aptalaska.com"},"mainone":{"id":"mainone","name":"MainOne","length":"7,000 km","rfs":"2010 July","rfs_year":2010,"is_planned":false,"owners":"MainOne - An Equinix Company","suppliers":"SubCom","landing_points":[{"id":"abidjan-cte-divoire","name":"Abidjan, Côte d'Ivoire","country":"Côte d'Ivoire","is_tbd":false},{"id":"accra-ghana","name":"Accra, Ghana","country":"Ghana","is_tbd":false},{"id":"lagos-nigeria","name":"Lagos, Nigeria","country":"Nigeria","is_tbd":false},{"id":"seixal-portugal","name":"Seixal, Portugal","country":"Portugal","is_tbd":false},{"id":"dakar-senegal","name":"Dakar, Senegal","country":"Senegal","is_tbd":false}],"notes":null,"url":"https://www.mainone.net/"},"malaysia-cambodia-thailand-mct-cable":{"id":"malaysia-cambodia-thailand-mct-cable","name":"Malaysia-Cambodia-Thailand (MCT) Cable","length":"1,300 km","rfs":"2017 March","rfs_year":2017,"is_planned":false,"owners":"Symphony, Telcotech, Telekom Malaysia","suppliers":"HMN Tech","landing_points":[{"id":"sihanoukville-cambodia","name":"Sihanoukville, Cambodia","country":"Cambodia","is_tbd":false},{"id":"cherating-malaysia","name":"Cherating, Malaysia","country":"Malaysia","is_tbd":false},{"id":"rayong-thailand","name":"Rayong, Thailand","country":"Thailand","is_tbd":false}],"notes":null,"url":null},"maldives-sri-lanka-cable-msc":{"id":"maldives-sri-lanka-cable-msc","name":"Maldives Sri Lanka Cable (MSC)","length":"863 km","rfs":"2021 February","rfs_year":2021,"is_planned":false,"owners":"Dhiraagu, Dialog Axiata, Ooredoo Maldives","suppliers":"HMN Tech","landing_points":[{"id":"hulhumale-maldives","name":"Hulhumale, Maldives","country":"Maldives","is_tbd":false},{"id":"mt-lavinia-sri-lanka","name":"Mt. Lavinia, Sri Lanka","country":"Sri Lanka","is_tbd":false}],"notes":null,"url":null},"malbec":{"id":"malbec","name":"Malbec","length":"2,880 km","rfs":"2021 June","rfs_year":2021,"is_planned":false,"owners":"Meta, V.tal","suppliers":"ASN","landing_points":[{"id":"las-toninas-argentina","name":"Las Toninas, Argentina","country":"Argentina","is_tbd":false},{"id":"porto-alegre-brazil","name":"Porto Alegre, Brazil","country":"Brazil","is_tbd":false},{"id":"praia-grande-brazil","name":"Praia Grande, Brazil","country":"Brazil","is_tbd":false},{"id":"rio-de-janeiro-brazil","name":"Rio de Janeiro, Brazil","country":"Brazil","is_tbd":false}],"notes":null,"url":"https://www.vtal.com/en/home/"},"malta-gozo-cable":{"id":"malta-gozo-cable","name":"Malta-Gozo Cable","length":"13 km","rfs":"2020 December","rfs_year":2020,"is_planned":false,"owners":"Gozo Fibre Optic Cable Ltd.","suppliers":null,"landing_points":[{"id":"golden-bay-malta","name":"Golden Bay, Malta","country":"Malta","is_tbd":false},{"id":"mgarr-ix-xini-malta","name":"Mgarr ix-Xini, Malta","country":"Malta","is_tbd":false}],"notes":null,"url":null},"malta-italy-interconnector":{"id":"malta-italy-interconnector","name":"Malta-Italy Interconnector","length":"95 km","rfs":"2015","rfs_year":2015,"is_planned":false,"owners":"Government of Malta","suppliers":null,"landing_points":[{"id":"marina-di-ragusa-italy","name":"Marina di Ragusa, Italy","country":"Italy","is_tbd":false},{"id":"bahar-ic-caghaq-malta","name":"Bahar ic-Caghaq, Malta","country":"Malta","is_tbd":false}],"notes":"The Malta-Italy Interconnector is a power cable, which has optical fiber attached to it.","url":null},"manatua":{"id":"manatua","name":"Manatua","length":"3,634 km","rfs":"2020 July","rfs_year":2020,"is_planned":false,"owners":"Avaroa Cable Ltd., OPT French Polynesia, Samoa Submarine Cable Company, Telecom Niue","suppliers":"SubCom","landing_points":[{"id":"aitutaki-cook-islands","name":"Aitutaki, Cook Islands","country":"Cook Islands","is_tbd":false},{"id":"rarotonga-cook-islands","name":"Rarotonga, Cook Islands","country":"Cook Islands","is_tbd":false},{"id":"toahotu-french-polynesia","name":"To'ahotu, French Polynesia","country":"French Polynesia","is_tbd":false},{"id":"vaitape-french-polynesia","name":"Vaitape, French Polynesia","country":"French Polynesia","is_tbd":false},{"id":"alofi-niue","name":"Alofi, Niue","country":"Niue","is_tbd":false},{"id":"apia-samoa","name":"Apia, Samoa","country":"Samoa","is_tbd":false}],"notes":null,"url":null},"manta":{"id":"manta","name":"MANTA","length":"5,600 km","rfs":"2028 Q1","rfs_year":2028,"is_planned":true,"owners":"Gold Data, Liberty Networks, Sparkle","suppliers":"SubCom","landing_points":[{"id":"cartagena-colombia","name":"Cartagena, Colombia","country":"Colombia","is_tbd":false},{"id":"cancn-mexico","name":"Cancún, Mexico","country":"Mexico","is_tbd":false},{"id":"veracruz-mexico","name":"Veracruz, Mexico","country":"Mexico","is_tbd":false},{"id":"maria-chiquita-panama","name":"Maria Chiquita, Panama","country":"Panama","is_tbd":false},{"id":"north-miami-beach-fl-united-states","name":"North Miami Beach, FL, United States","country":"United States","is_tbd":false},{"id":"san-blas-fl-united-states","name":"San Blas, FL, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"manx-northern-ireland":{"id":"manx-northern-ireland","name":"Manx-Northern Ireland","length":"59 km","rfs":"2000","rfs_year":2000,"is_planned":false,"owners":"BT","suppliers":null,"landing_points":[{"id":"peel-isle-of-man","name":"Peel, Isle of Man","country":"Isle of Man","is_tbd":false},{"id":"ballyhornan-united-kingdom","name":"Ballyhornan, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"marea":{"id":"marea","name":"MAREA","length":"6,605 km","rfs":"2018 May","rfs_year":2018,"is_planned":false,"owners":"Meta, Microsoft, Telxius","suppliers":"SubCom","landing_points":[{"id":"bilbao-spain","name":"Bilbao, Spain","country":"Spain","is_tbd":false},{"id":"virginia-beach-va-united-states","name":"Virginia Beach, VA, United States","country":"United States","is_tbd":false}],"notes":null,"url":"http://www.telxius.com"},"mariana-guam-cable":{"id":"mariana-guam-cable","name":"Mariana-Guam Cable","length":"268 km","rfs":"1997","rfs_year":1997,"is_planned":false,"owners":"PTI Pacifica","suppliers":null,"landing_points":[{"id":"tanguisson-point-guam","name":"Tanguisson Point, Guam","country":"Guam","is_tbd":false},{"id":"rota-northern-mariana-islands","name":"Rota, Northern Mariana Islands","country":"Northern Mariana Islands","is_tbd":false},{"id":"saipan-northern-mariana-islands","name":"Saipan, Northern Mariana Islands","country":"Northern Mariana Islands","is_tbd":false},{"id":"tinian-northern-mariana-islands","name":"Tinian, Northern Mariana Islands","country":"Northern Mariana Islands","is_tbd":false}],"notes":null,"url":null},"maroc-telecom-west-africa":{"id":"maroc-telecom-west-africa","name":"Maroc Telecom West Africa","length":"8,600 km","rfs":"2021 July","rfs_year":2021,"is_planned":false,"owners":"Maroc Telecom","suppliers":"ASN","landing_points":[{"id":"cotonou-benin","name":"Cotonou, Benin","country":"Benin","is_tbd":false},{"id":"abidjan-cte-divoire","name":"Abidjan, Côte d'Ivoire","country":"Côte d'Ivoire","is_tbd":false},{"id":"libreville-gabon","name":"Libreville, Gabon","country":"Gabon","is_tbd":false},{"id":"casablanca-morocco","name":"Casablanca, Morocco","country":"Morocco","is_tbd":false},{"id":"dakhla-morocco","name":"Dakhla, Morocco","country":"Morocco","is_tbd":false},{"id":"lome-togo","name":"Lome, Togo","country":"Togo","is_tbd":false}],"notes":null,"url":"http://www.iam.ma"},"mataram-kupang-cable-system-mkcs":{"id":"mataram-kupang-cable-system-mkcs","name":"Mataram Kupang Cable System (MKCS)","length":"1,318 km","rfs":"2011 April","rfs_year":2011,"is_planned":false,"owners":"Telkom Indonesia","suppliers":"HMN Tech","landing_points":[{"id":"bima-indonesia","name":"Bima, Indonesia","country":"Indonesia","is_tbd":false},{"id":"ende-indonesia","name":"Ende, Indonesia","country":"Indonesia","is_tbd":false},{"id":"kupang-indonesia","name":"Kupang, Indonesia","country":"Indonesia","is_tbd":false},{"id":"pringgabaya-indonesia","name":"Pringgabaya, Indonesia","country":"Indonesia","is_tbd":false},{"id":"saraemee-indonesia","name":"Saraemee, Indonesia","country":"Indonesia","is_tbd":false},{"id":"sumbawa-besar-indonesia","name":"Sumbawa Besar, Indonesia","country":"Indonesia","is_tbd":false},{"id":"waingapu-indonesia","name":"Waingapu, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":null},"matrix-cable-system":{"id":"matrix-cable-system","name":"Matrix Cable System","length":"1,055 km","rfs":"2008 August","rfs_year":2008,"is_planned":false,"owners":"Matrix NAP Info","suppliers":"SubCom","landing_points":[{"id":"batam-indonesia","name":"Batam, Indonesia","country":"Indonesia","is_tbd":false},{"id":"jakarta-indonesia","name":"Jakarta, Indonesia","country":"Indonesia","is_tbd":false},{"id":"changi-south-singapore","name":"Changi South, Singapore","country":"Singapore","is_tbd":false}],"notes":null,"url":"https://matrixnetworks.sg/"},"mauritius-and-rodrigues-submarine-cable-system-mars":{"id":"mauritius-and-rodrigues-submarine-cable-system-mars","name":"Mauritius and Rodrigues Submarine Cable System (MARS)","length":"677 km","rfs":"2019 February","rfs_year":2019,"is_planned":false,"owners":"Mauritius Telecom","suppliers":"HMN Tech","landing_points":[{"id":"baie-jacotet-mauritius","name":"Baie Jacotet, Mauritius","country":"Mauritius","is_tbd":false},{"id":"grand-baie-rodrigues-mauritius","name":"Grand Baie (Rodrigues), Mauritius","country":"Mauritius","is_tbd":false}],"notes":null,"url":"https://www.myt.mu/mars"},"maya-1-2":{"id":"maya-1-2","name":"Maya-1.2","length":"2,386 km","rfs":"2000 October","rfs_year":2000,"is_planned":false,"owners":"Hondutel, Liberty Networks, Ufinet","suppliers":"ASN","landing_points":[{"id":"half-moon-bay-cayman-islands","name":"Half Moon Bay, Cayman Islands","country":"Cayman Islands","is_tbd":false},{"id":"puerto-cortes-honduras","name":"Puerto Cortes, Honduras","country":"Honduras","is_tbd":false},{"id":"hollywood-fl-united-states","name":"Hollywood, FL, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://libertynetworks.com/"},"mednautilus-submarine-system":{"id":"mednautilus-submarine-system","name":"MedNautilus Submarine System","length":"7,000 km","rfs":"2001 November","rfs_year":2001,"is_planned":false,"owners":"Sparkle","suppliers":"ASN","landing_points":[{"id":"pentaskhinos-cyprus","name":"Pentaskhinos, Cyprus","country":"Cyprus","is_tbd":false},{"id":"athens-greece","name":"Athens, Greece","country":"Greece","is_tbd":false},{"id":"chania-greece","name":"Chania, Greece","country":"Greece","is_tbd":false},{"id":"tel-aviv-israel","name":"Tel Aviv, Israel","country":"Israel","is_tbd":false},{"id":"tirat-carmel-israel","name":"Tirat Carmel, Israel","country":"Israel","is_tbd":false},{"id":"catania-italy","name":"Catania, Italy","country":"Italy","is_tbd":false},{"id":"istanbul-turkey","name":"Istanbul, Turkey","country":"Turkey","is_tbd":false}],"notes":null,"url":"https://www.globalbackbone.tisparkle.com/"},"med-cable-network":{"id":"med-cable-network","name":"Med Cable Network","length":"1,300 km","rfs":"2005 October","rfs_year":2005,"is_planned":false,"owners":"Orascom Telecom Holding","suppliers":"ASN","landing_points":[{"id":"algiers-algeria","name":"Algiers, Algeria","country":"Algeria","is_tbd":false},{"id":"annaba-algeria","name":"Annaba, Algeria","country":"Algeria","is_tbd":false},{"id":"oran-algeria","name":"Oran, Algeria","country":"Algeria","is_tbd":false},{"id":"marseille-france","name":"Marseille, France","country":"France","is_tbd":false}],"notes":null,"url":null},"medloop":{"id":"medloop","name":"Medloop","length":"1,360 km","rfs":"2023","rfs_year":2023,"is_planned":false,"owners":"SIPARTECH Sarl","suppliers":"ASN","landing_points":[{"id":"ajaccio-france","name":"Ajaccio, France","country":"France","is_tbd":false},{"id":"marseille-france","name":"Marseille, France","country":"France","is_tbd":false},{"id":"genoa-italy","name":"Genoa, Italy","country":"Italy","is_tbd":false},{"id":"barcelona-spain","name":"Barcelona, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":"https://www.sipartech.com/"},"medusa-submarine-cable-system":{"id":"medusa-submarine-cable-system","name":"Medusa Submarine Cable System","length":"8,760 km","rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"AFRIX Telecom","suppliers":"ASN","landing_points":[{"id":"algiers-algeria","name":"Algiers, Algeria","country":"Algeria","is_tbd":false},{"id":"collo-algeria","name":"Collo, Algeria","country":"Algeria","is_tbd":false},{"id":"yeroskipos-cyprus","name":"Yeroskipos, Cyprus","country":"Cyprus","is_tbd":false},{"id":"port-said-egypt","name":"Port Said, Egypt","country":"Egypt","is_tbd":false},{"id":"marseille-france","name":"Marseille, France","country":"France","is_tbd":false},{"id":"athens-greece","name":"Athens, Greece","country":"Greece","is_tbd":false},{"id":"mazara-del-vallo-italy","name":"Mazara del Vallo, Italy","country":"Italy","is_tbd":false},{"id":"benghazi-libya","name":"Benghazi, Libya","country":"Libya","is_tbd":false},{"id":"misuratah-libya","name":"Misuratah, Libya","country":"Libya","is_tbd":false},{"id":"mellieha-malta","name":"Mellieha, Malta","country":"Malta","is_tbd":false},{"id":"nador-morocco","name":"Nador, Morocco","country":"Morocco","is_tbd":false},{"id":"ttouan-morocco","name":"Tétouan, Morocco","country":"Morocco","is_tbd":false},{"id":"carcavelos-portugal","name":"Carcavelos, Portugal","country":"Portugal","is_tbd":false},{"id":"barcelona-spain","name":"Barcelona, Spain","country":"Spain","is_tbd":false},{"id":"manilva-spain","name":"Manilva, Spain","country":"Spain","is_tbd":false},{"id":"zahara-de-los-atunes-spain","name":"Zahara de los Atunes, Spain","country":"Spain","is_tbd":false},{"id":"tartous-syria","name":"Tartous, Syria","country":"Syria","is_tbd":false},{"id":"bizerte-tunisia","name":"Bizerte, Tunisia","country":"Tunisia","is_tbd":false}],"notes":"Orange Tunisie owns the Tunisia branch.","url":"https://medusascs.com/"},"melita-1":{"id":"melita-1","name":"Melita 1","length":"97 km","rfs":"2009 June","rfs_year":2009,"is_planned":false,"owners":"Melita","suppliers":"Norddeutsche Seekabelwerke GmbH (NSW)","landing_points":[{"id":"pozzallo-italy","name":"Pozzallo, Italy","country":"Italy","is_tbd":false},{"id":"bahar-ic-caghaq-malta","name":"Bahar ic-Caghaq, Malta","country":"Malta","is_tbd":false}],"notes":null,"url":"http://www.melita.com"},"meltingpot-indianoceanic-submarine-system-metiss":{"id":"meltingpot-indianoceanic-submarine-system-metiss","name":"Meltingpot Indianoceanic Submarine System (METISS)","length":"3,200 km","rfs":"2021 March","rfs_year":2021,"is_planned":false,"owners":"*Canal+ Telecom, CEB Fibernet, Emtel, SFR, Telma (Telecom Malagasy), Zeop (incl. Reunicable)","suppliers":"ASN","landing_points":[{"id":"fort-dauphin-madagascar","name":"Fort Dauphin, Madagascar","country":"Madagascar","is_tbd":false},{"id":"baie-du-tombeau-mauritius","name":"Baie du Tombeau, Mauritius","country":"Mauritius","is_tbd":false},{"id":"le-port-runion","name":"Le Port, Réunion","country":"Réunion","is_tbd":false},{"id":"umbogintwini-south-africa","name":"Umbogintwini, South Africa","country":"South Africa","is_tbd":false}],"notes":null,"url":null},"mercator":{"id":"mercator","name":"Mercator","length":null,"rfs":"2023 June","rfs_year":2023,"is_planned":false,"owners":"BT","suppliers":"SubCom","landing_points":[{"id":"ostend-belgium","name":"Ostend, Belgium","country":"Belgium","is_tbd":false},{"id":"broadstairs-united-kingdom","name":"Broadstairs, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"mid-atlantic-crossing-mac":{"id":"mid-atlantic-crossing-mac","name":"Mid-Atlantic Crossing (MAC)","length":"7,500 km","rfs":"2000 June","rfs_year":2000,"is_planned":false,"owners":"Cirion Technologies","suppliers":"ASN","landing_points":[{"id":"brookhaven-ny-united-states","name":"Brookhaven, NY, United States","country":"United States","is_tbd":false},{"id":"hollywood-fl-united-states","name":"Hollywood, FL, United States","country":"United States","is_tbd":false},{"id":"st-croix-virgin-islands-virgin-islands-u-s-","name":"St. Croix, Virgin Islands, Virgin Islands (U.S.)","country":"Virgin Islands (U.S.)","is_tbd":false}],"notes":null,"url":"https://www.ciriontechnologies.com"},"minamidaito-island":{"id":"minamidaito-island","name":"Minamidaito Island","length":"410 km","rfs":"2011","rfs_year":2011,"is_planned":false,"owners":"Okinawa Prefecture","suppliers":null,"landing_points":[{"id":"itoman-japan","name":"Itoman, Japan","country":"Japan","is_tbd":false},{"id":"minamidaito-japan","name":"Minamidaito, Japan","country":"Japan","is_tbd":false}],"notes":null,"url":null},"middle-east-north-africa-mena-cable-systemgulf-bridge-international":{"id":"middle-east-north-africa-mena-cable-systemgulf-bridge-international","name":"Middle East North Africa (MENA) Cable System/Gulf Bridge International","length":"8,000 km","rfs":"2014 December","rfs_year":2014,"is_planned":false,"owners":"Gulf Bridge International, Telecom Egypt","suppliers":"ASN","landing_points":[{"id":"abu-talat-egypt","name":"Abu Talat, Egypt","country":"Egypt","is_tbd":false},{"id":"zafarana-egypt","name":"Zafarana, Egypt","country":"Egypt","is_tbd":false},{"id":"mazara-del-vallo-italy","name":"Mazara del Vallo, Italy","country":"Italy","is_tbd":false},{"id":"al-seeb-oman","name":"Al Seeb, Oman","country":"Oman","is_tbd":false},{"id":"jeddah-saudi-arabia","name":"Jeddah, Saudi Arabia","country":"Saudi Arabia","is_tbd":false}],"notes":"Telecom Egypt owns five fiber pairs on the cable. Gulf Bridge International owns one fiber pair across the MENA cable that connects to the company’s Gulf ring system. TE owns one fiber pair on the Gulf Bridge cable from Oman to India.","url":"https://www.te.eg/wps/portal/te/Business/Wholesale/"},"minoas-east-and-west":{"id":"minoas-east-and-west","name":"Minoas East and West","length":"270 km","rfs":"2021","rfs_year":2021,"is_planned":false,"owners":"Grid Telecom","suppliers":"Prysmian","landing_points":[{"id":"neapoli-greece","name":"Neapoli, Greece","country":"Greece","is_tbd":false},{"id":"nopigeia-greece","name":"Nopigeia, Greece","country":"Greece","is_tbd":false}],"notes":"Minoas East and West are fiber optic cables attached to power cables. Minoas East was RFS in December, 2021. Minoas West was RFS in May, 2021. Each leg is 135 km in length, for a total of 270 km of fiber optic cable.","url":"https://www.grid-telecom.com/"},"mishima-village":{"id":"mishima-village","name":"Mishima Village","length":"192 km","rfs":"2010","rfs_year":2010,"is_planned":false,"owners":"Mishima Village","suppliers":null,"landing_points":[{"id":"iojima-japan","name":"Iojima, Japan","country":"Japan","is_tbd":false},{"id":"kuroshima-japan","name":"Kuroshima, Japan","country":"Japan","is_tbd":false},{"id":"makurazaki-japan","name":"Makurazaki, Japan","country":"Japan","is_tbd":false},{"id":"takeshima-japan","name":"Takeshima, Japan","country":"Japan","is_tbd":false}],"notes":null,"url":null},"miyazaki-okinawa-cable-moc":{"id":"miyazaki-okinawa-cable-moc","name":"Miyazaki-Okinawa Cable (MOC)","length":null,"rfs":"1997","rfs_year":1997,"is_planned":false,"owners":"KDDI","suppliers":"NEC","landing_points":[{"id":"miyazaki-japan","name":"Miyazaki, Japan","country":"Japan","is_tbd":false},{"id":"naha-japan","name":"Naha, Japan","country":"Japan","is_tbd":false}],"notes":null,"url":null},"mist":{"id":"mist","name":"MIST","length":"8,100 km","rfs":"2024 December","rfs_year":2024,"is_planned":false,"owners":"Orient Link","suppliers":"NEC","landing_points":[{"id":"chennai-india","name":"Chennai, India","country":"India","is_tbd":false},{"id":"mumbai-india","name":"Mumbai, India","country":"India","is_tbd":false},{"id":"morib-malaysia","name":"Morib, Malaysia","country":"Malaysia","is_tbd":false},{"id":"tuas-singapore","name":"Tuas, Singapore","country":"Singapore","is_tbd":false},{"id":"satun-thailand","name":"Satun, Thailand","country":"Thailand","is_tbd":false}],"notes":null,"url":"https://orient-link.com/home/"},"mjolner-east":{"id":"mjolner-east","name":"Mjolner East","length":"450 km","rfs":"2027","rfs_year":2027,"is_planned":true,"owners":"GlobalConnect","suppliers":null,"landing_points":[{"id":"kihelkonna-estonia","name":"Kihelkonna, Estonia","country":"Estonia","is_tbd":false},{"id":"tallinn-estonia","name":"Tallinn, Estonia","country":"Estonia","is_tbd":false},{"id":"helsinki-finland","name":"Helsinki, Finland","country":"Finland","is_tbd":false},{"id":"farosund-sweden","name":"Farosund, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":"www.globalconnectcarrier.com"},"mjolner-west":{"id":"mjolner-west","name":"Mjolner West","length":"250 km","rfs":"2027","rfs_year":2027,"is_planned":true,"owners":"GlobalConnect","suppliers":null,"landing_points":[{"id":"lokalahti-finland","name":"Lokalahti, Finland","country":"Finland","is_tbd":false},{"id":"sthammar-sweden","name":"Östhammar, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":"www.globalconnectcarrier.com"},"mobily-red-sea-cable-mrsc":{"id":"mobily-red-sea-cable-mrsc","name":"Mobily Red Sea Cable (MRSC)","length":null,"rfs":"2027","rfs_year":2027,"is_planned":true,"owners":"Mobily","suppliers":null,"landing_points":[{"id":"sharm-el-sheikh-egypt","name":"Sharm el-Sheikh, Egypt","country":"Egypt","is_tbd":false},{"id":"duba-saudi-arabia","name":"Duba, Saudi Arabia","country":"Saudi Arabia","is_tbd":false}],"notes":null,"url":"http://www.mobily.com.sa/"},"monet":{"id":"monet","name":"Monet","length":"10,556 km","rfs":"2017 December","rfs_year":2017,"is_planned":false,"owners":"Algar Telecom, Angola Cables, Antel Uruguay, Google","suppliers":"SubCom","landing_points":[{"id":"fortaleza-brazil","name":"Fortaleza, Brazil","country":"Brazil","is_tbd":false},{"id":"santos-brazil","name":"Santos, Brazil","country":"Brazil","is_tbd":false},{"id":"boca-raton-fl-united-states","name":"Boca Raton, FL, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"moratelindo-international-cable-system-1-mic-1":{"id":"moratelindo-international-cable-system-1-mic-1","name":"Moratelindo International Cable System-1 (MIC-1)","length":"70 km","rfs":"2008 January","rfs_year":2008,"is_planned":false,"owners":"Moratelindo","suppliers":null,"landing_points":[{"id":"batam-indonesia","name":"Batam, Indonesia","country":"Indonesia","is_tbd":false},{"id":"changi-north-singapore","name":"Changi North, Singapore","country":"Singapore","is_tbd":false}],"notes":null,"url":"http://www.moratelindo.co.id"},"muroran-hachinohe":{"id":"muroran-hachinohe","name":"Muroran-Hachinohe","length":"280 km","rfs":"2002","rfs_year":2002,"is_planned":false,"owners":"Softbank","suppliers":"NEC","landing_points":[{"id":"hachinohe-japan","name":"Hachinohe, Japan","country":"Japan","is_tbd":false},{"id":"muroran-japan","name":"Muroran, Japan","country":"Japan","is_tbd":false}],"notes":null,"url":null},"n0r5ke-viking":{"id":"n0r5ke-viking","name":"N0r5ke Viking","length":"810 km","rfs":"2022 December","rfs_year":2022,"is_planned":false,"owners":"NOR5KE Fibre AS","suppliers":null,"landing_points":[{"id":"andalsnes-norway","name":"Andalsnes, Norway","country":"Norway","is_tbd":false},{"id":"bergen-norway","name":"Bergen, Norway","country":"Norway","is_tbd":false},{"id":"brekstad-norway","name":"Brekstad, Norway","country":"Norway","is_tbd":false},{"id":"edya-norway","name":"Edøya, Norway","country":"Norway","is_tbd":false},{"id":"flor-norway","name":"Florø, Norway","country":"Norway","is_tbd":false},{"id":"herlandsvika-norway","name":"Herlandsvika, Norway","country":"Norway","is_tbd":false},{"id":"hitra-norway","name":"Hitra, Norway","country":"Norway","is_tbd":false},{"id":"hyllestad-norway","name":"Hyllestad, Norway","country":"Norway","is_tbd":false},{"id":"kristiansund-norway","name":"Kristiansund, Norway","country":"Norway","is_tbd":false},{"id":"lefdal-norway","name":"Lefdal, Norway","country":"Norway","is_tbd":false},{"id":"molde-norway","name":"Molde, Norway","country":"Norway","is_tbd":false},{"id":"sture-norway","name":"Sture, Norway","country":"Norway","is_tbd":false},{"id":"tjeldbergodden-norway","name":"Tjeldbergodden, Norway","country":"Norway","is_tbd":false},{"id":"trondheim-norway","name":"Trondheim, Norway","country":"Norway","is_tbd":false},{"id":"heim-norway","name":"Åheim, Norway","country":"Norway","is_tbd":false},{"id":"lesund-norway","name":"Ålesund, Norway","country":"Norway","is_tbd":false}],"notes":null,"url":"https://www.n0r5kefibre.com/"},"n0r5ke-viking-2":{"id":"n0r5ke-viking-2","name":"N0r5ke Viking 2","length":"900 km","rfs":"2028","rfs_year":2028,"is_planned":true,"owners":"NOR5KE Fibre AS","suppliers":null,"landing_points":[{"id":"arendal-norway","name":"Arendal, Norway","country":"Norway","is_tbd":false},{"id":"bergen-norway","name":"Bergen, Norway","country":"Norway","is_tbd":false},{"id":"bunnefjorden-norway","name":"Bunnefjorden, Norway","country":"Norway","is_tbd":false},{"id":"egersund-norway","name":"Egersund, Norway","country":"Norway","is_tbd":false},{"id":"haugesund-norway","name":"Haugesund, Norway","country":"Norway","is_tbd":false},{"id":"horten-norway","name":"Horten, Norway","country":"Norway","is_tbd":false},{"id":"hvik-norway","name":"Håvik, Norway","country":"Norway","is_tbd":false},{"id":"kristiansand-norway","name":"Kristiansand, Norway","country":"Norway","is_tbd":false},{"id":"larvik-norway","name":"Larvik, Norway","country":"Norway","is_tbd":false},{"id":"lista-norway","name":"Lista, Norway","country":"Norway","is_tbd":false},{"id":"oslo-norway","name":"Oslo, Norway","country":"Norway","is_tbd":false},{"id":"porsgrunn-norway","name":"Porsgrunn, Norway","country":"Norway","is_tbd":false},{"id":"stavanger-norway","name":"Stavanger, Norway","country":"Norway","is_tbd":false},{"id":"tananger-norway","name":"Tananger, Norway","country":"Norway","is_tbd":false},{"id":"vikevg-norway","name":"Vikevåg, Norway","country":"Norway","is_tbd":false},{"id":"lagunen-sweden","name":"Lagunen, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":"https://www.n0r5kefibre.com/"},"nationwide-submarine-cable-ooredoo-maldives-nascom":{"id":"nationwide-submarine-cable-ooredoo-maldives-nascom","name":"Nationwide Submarine Cable Ooredoo Maldives (NaSCOM)","length":"1,136 km","rfs":"2016 December","rfs_year":2016,"is_planned":false,"owners":"Ooredoo Maldives","suppliers":"HMN Tech","landing_points":[{"id":"eydhafushi-maldives","name":"Eydhafushi, Maldives","country":"Maldives","is_tbd":false},{"id":"hithadhoo-maldives","name":"Hithadhoo, Maldives","country":"Maldives","is_tbd":false},{"id":"hulhumale-maldives","name":"Hulhumale, Maldives","country":"Maldives","is_tbd":false},{"id":"kolhufushi-maldives","name":"Kolhufushi, Maldives","country":"Maldives","is_tbd":false},{"id":"kulhudhufushi-maldives","name":"Kulhudhufushi, Maldives","country":"Maldives","is_tbd":false},{"id":"thinadhoo-maldives","name":"Thinadhoo, Maldives","country":"Maldives","is_tbd":false}],"notes":null,"url":"http://ooredoo.mv"},"national-digital-transmission-network-ndtn":{"id":"national-digital-transmission-network-ndtn","name":"National Digital Transmission Network (NDTN)","length":"1,400 km","rfs":"1999 March","rfs_year":1999,"is_planned":false,"owners":"Telecoms Infrastructure Corporation of the Philippines (TelicPhil)","suppliers":null,"landing_points":[{"id":"dumaguete-philippines","name":"Dumaguete, Philippines","country":"Philippines","is_tbd":false},{"id":"iloilo-city-philippines","name":"Iloilo City, Philippines","country":"Philippines","is_tbd":false},{"id":"lucena-philippines","name":"Lucena, Philippines","country":"Philippines","is_tbd":false},{"id":"san-jose-philippines","name":"San Jose, Philippines","country":"Philippines","is_tbd":false}],"notes":null,"url":null},"natitua":{"id":"natitua","name":"Natitua","length":"2,680 km","rfs":"2018 December","rfs_year":2018,"is_planned":false,"owners":"OPT French Polynesia","suppliers":"ASN","landing_points":[{"id":"arutua-french-polynesia","name":"Arutua, French Polynesia","country":"French Polynesia","is_tbd":false},{"id":"fakarava-french-polynesia","name":"Fakarava, French Polynesia","country":"French Polynesia","is_tbd":false},{"id":"hao-french-polynesia","name":"Hao, French Polynesia","country":"French Polynesia","is_tbd":false},{"id":"hitiaa-french-polynesia","name":"Hitia'a, French Polynesia","country":"French Polynesia","is_tbd":false},{"id":"hiva-oa-french-polynesia","name":"Hiva Oa, French Polynesia","country":"French Polynesia","is_tbd":false},{"id":"kaukura-french-polynesia","name":"Kaukura, French Polynesia","country":"French Polynesia","is_tbd":false},{"id":"makemo-french-polynesia","name":"Makemo, French Polynesia","country":"French Polynesia","is_tbd":false},{"id":"manihi-french-polynesia","name":"Manihi, French Polynesia","country":"French Polynesia","is_tbd":false},{"id":"nuku-hiva-french-polynesia","name":"Nuku Hiva, French Polynesia","country":"French Polynesia","is_tbd":false},{"id":"rangiroa-french-polynesia","name":"Rangiroa, French Polynesia","country":"French Polynesia","is_tbd":false},{"id":"takaroa-french-polynesia","name":"Takaroa, French Polynesia","country":"French Polynesia","is_tbd":false}],"notes":null,"url":"https://www.natitua.pf/"},"natitua-sud":{"id":"natitua-sud","name":"Natitua Sud","length":"820 km","rfs":"2023 August","rfs_year":2023,"is_planned":false,"owners":"OPT French Polynesia","suppliers":"ASN","landing_points":[{"id":"hitiaa-french-polynesia","name":"Hitia'a, French Polynesia","country":"French Polynesia","is_tbd":false},{"id":"rurutu-french-polynesia","name":"Rurutu, French Polynesia","country":"French Polynesia","is_tbd":false},{"id":"tubuai-french-polynesia","name":"Tubuai, French Polynesia","country":"French Polynesia","is_tbd":false}],"notes":null,"url":"https://www.natitua.pf/"},"nelson-levin":{"id":"nelson-levin","name":"Nelson-Levin","length":"212 km","rfs":"2001 June","rfs_year":2001,"is_planned":false,"owners":"Spark New Zealand","suppliers":null,"landing_points":[{"id":"levin-new-zealand","name":"Levin, New Zealand","country":"New Zealand","is_tbd":false},{"id":"nelson-new-zealand","name":"Nelson, New Zealand","country":"New Zealand","is_tbd":false}],"notes":null,"url":"http://www.spark.co.nz"},"new-cross-pacific-ncp-cable-system":{"id":"new-cross-pacific-ncp-cable-system","name":"New Cross Pacific (NCP) Cable System","length":"13,618 km","rfs":"2018 May","rfs_year":2018,"is_planned":false,"owners":"China Mobile, China Telecom, China Unicom, Chunghwa Telecom, KT, Microsoft, Softbank","suppliers":"SubCom","landing_points":[{"id":"chongming-china","name":"Chongming, China","country":"China","is_tbd":false},{"id":"lingang-china","name":"Lingang, China","country":"China","is_tbd":false},{"id":"nanhui-china","name":"Nanhui, China","country":"China","is_tbd":false},{"id":"maruyama-japan","name":"Maruyama, Japan","country":"Japan","is_tbd":false},{"id":"busan-south-korea","name":"Busan, South Korea","country":"South Korea","is_tbd":false},{"id":"toucheng-taiwan","name":"Toucheng, Taiwan","country":"Taiwan","is_tbd":false},{"id":"pacific-city-or-united-states","name":"Pacific City, OR, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"nigeria-cameroon-submarine-cable-system-ncscs":{"id":"nigeria-cameroon-submarine-cable-system-ncscs","name":"Nigeria Cameroon Submarine Cable System (NCSCS)","length":"1,100 km","rfs":"2015 December","rfs_year":2015,"is_planned":false,"owners":"Camtel","suppliers":"HMN Tech","landing_points":[{"id":"kribi-cameroon","name":"Kribi, Cameroon","country":"Cameroon","is_tbd":false},{"id":"lagos-nigeria","name":"Lagos, Nigeria","country":"Nigeria","is_tbd":false}],"notes":null,"url":null},"new-cam-ring":{"id":"new-cam-ring","name":"New CAM Ring","length":"3,812 km","rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"IP Telecom","suppliers":"ASN","landing_points":[{"id":"carcavelos-portugal","name":"Carcavelos, Portugal","country":"Portugal","is_tbd":false},{"id":"funchal-portugal","name":"Funchal, Portugal","country":"Portugal","is_tbd":false},{"id":"machico-portugal","name":"Machico, Portugal","country":"Portugal","is_tbd":false},{"id":"sines-portugal","name":"Sines, Portugal","country":"Portugal","is_tbd":false},{"id":"so-miguel-portugal","name":"São Miguel, Portugal","country":"Portugal","is_tbd":false},{"id":"terceira-portugal","name":"Terceira, Portugal","country":"Portugal","is_tbd":false}],"notes":null,"url":"https://www.iptelecom.pt/"},"ningbo-zhoushan-cable":{"id":"ningbo-zhoushan-cable","name":"Ningbo-Zhoushan Cable","length":"35 km","rfs":"1999 July","rfs_year":1999,"is_planned":false,"owners":"China Telecom","suppliers":"ASN","landing_points":[{"id":"mamu-china","name":"Mamu, China","country":"China","is_tbd":false},{"id":"xiepu-china","name":"Xiepu, China","country":"China","is_tbd":false}],"notes":null,"url":null},"no-uk":{"id":"no-uk","name":"NO-UK","length":"713 km","rfs":"2021 December","rfs_year":2021,"is_planned":false,"owners":"NO-UK COM AS","suppliers":"Xtera","landing_points":[{"id":"stavanger-norway","name":"Stavanger, Norway","country":"Norway","is_tbd":false},{"id":"newcastle-united-kingdom","name":"Newcastle, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"https://www.altiboxcarrier.com/"},"nome-to-homer-express-nthe":{"id":"nome-to-homer-express-nthe","name":"Nome to Homer Express (NTHE)","length":"1,545 km","rfs":"2027","rfs_year":2027,"is_planned":true,"owners":"Quintillion","suppliers":null,"landing_points":[{"id":"alakanuk-ak-united-states","name":"Alakanuk, AK, United States","country":"United States","is_tbd":false},{"id":"homer-ak-united-states","name":"Homer, AK, United States","country":"United States","is_tbd":false},{"id":"igiugig-ak-united-states","name":"Igiugig, AK, United States","country":"United States","is_tbd":false},{"id":"naknek-ak-united-states","name":"Naknek, AK, United States","country":"United States","is_tbd":false},{"id":"nome-ak-united-states","name":"Nome, AK, United States","country":"United States","is_tbd":false},{"id":"pile-bay-ak-united-states","name":"Pile Bay, AK, United States","country":"United States","is_tbd":false},{"id":"williamsport-ak-united-states","name":"Williamsport, AK, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.quintillionglobal.com/"},"nongsa-changi":{"id":"nongsa-changi","name":"Nongsa-Changi","length":"50 km","rfs":"2026 May","rfs_year":2026,"is_planned":true,"owners":"BW Digital, Telin","suppliers":null,"landing_points":[{"id":"nongsa-indonesia","name":"Nongsa, Indonesia","country":"Indonesia","is_tbd":false},{"id":"changi-singapore","name":"Changi, Singapore","country":"Singapore","is_tbd":false}],"notes":null,"url":null},"nordbalt":{"id":"nordbalt","name":"NordBalt","length":"400 km","rfs":"2016","rfs_year":2016,"is_planned":false,"owners":"Litgrid, Svenska Kraftnät","suppliers":null,"landing_points":[{"id":"klaipeda-lithuania","name":"Klaipeda, Lithuania","country":"Lithuania","is_tbd":false},{"id":"nybro-sweden","name":"Nybro, Sweden","country":"Sweden","is_tbd":false}],"notes":"Lietuvos Energija and Svenska Kraftnät operate the NordBalt cable, a high-voltage power cable that also has a fiber-optic cable alongside it.","url":null},"norfest":{"id":"norfest","name":"Norfest","length":"749 km","rfs":"2023 December","rfs_year":2023,"is_planned":false,"owners":"Tampnet","suppliers":"Nexans","landing_points":[{"id":"arendal-norway","name":"Arendal, Norway","country":"Norway","is_tbd":false},{"id":"drbak-norway","name":"Drøbak, Norway","country":"Norway","is_tbd":false},{"id":"egersund-norway","name":"Egersund, Norway","country":"Norway","is_tbd":false},{"id":"kristiansand-norway","name":"Kristiansand, Norway","country":"Norway","is_tbd":false},{"id":"larvik-norway","name":"Larvik, Norway","country":"Norway","is_tbd":false},{"id":"lista-norway","name":"Lista, Norway","country":"Norway","is_tbd":false},{"id":"moss-norway","name":"Moss, Norway","country":"Norway","is_tbd":false},{"id":"oslo-norway","name":"Oslo, Norway","country":"Norway","is_tbd":false},{"id":"stavanger-norway","name":"Stavanger, Norway","country":"Norway","is_tbd":false},{"id":"capri-strand-sweden","name":"Capri Strand, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":"https://www.tampnet.com/norfest"},"norte-conectado-infovia-01":{"id":"norte-conectado-infovia-01","name":"Norte Conectado (Infovia 01)","length":"1,100 km","rfs":"2023 August","rfs_year":2023,"is_planned":false,"owners":"Alloha Fibra, América Móvil (Claro), Aquamar Group, BR Fibra, ICOM Telecom, Ozônio Telecom, PPLink, SEA Telecom, TIM Brasil, Telefonica, V.tal, Você Telecom","suppliers":"HMN Tech, HT Cabos","landing_points":[{"id":"autazes-brazil","name":"Autazes, Brazil","country":"Brazil","is_tbd":false},{"id":"curu-brazil","name":"Curuá, Brazil","country":"Brazil","is_tbd":false},{"id":"itacoatiara-brazil","name":"Itacoatiara, Brazil","country":"Brazil","is_tbd":false},{"id":"juruti-brazil","name":"Juruti, Brazil","country":"Brazil","is_tbd":false},{"id":"manaus-brazil","name":"Manaus, Brazil","country":"Brazil","is_tbd":false},{"id":"oriximin-brazil","name":"Oriximiná, Brazil","country":"Brazil","is_tbd":false},{"id":"parintins-brazil","name":"Parintins, Brazil","country":"Brazil","is_tbd":false},{"id":"santarm-brazil","name":"Santarém, Brazil","country":"Brazil","is_tbd":false},{"id":"terra-santa-brazil","name":"Terra Santa, Brazil","country":"Brazil","is_tbd":false},{"id":"urucurituba-brazil","name":"Urucurituba, Brazil","country":"Brazil","is_tbd":false},{"id":"bidos-brazil","name":"Óbidos, Brazil","country":"Brazil","is_tbd":false}],"notes":"Norte Conectado is a Brazilian initiative to lay submarine fiber under the Amazon river and its tributaries.","url":"https://www.gov.br/mcom/pt-br/acesso-a-informacao/acoes-e-programas/programas-projetos-acoes-obras-e-atividades/norte-conectado"},"norte-conectado-infovia-00":{"id":"norte-conectado-infovia-00","name":"Norte Conectado (Infovia 00)","length":"770 km","rfs":"2022 July","rfs_year":2022,"is_planned":false,"owners":"Aquamar Group, BR.Digital Telecom, ICOM Telecom, SEA Telecom, Telefonica, Wirelink","suppliers":"Prysmian","landing_points":[{"id":"alenquer-brazil","name":"Alenquer, Brazil","country":"Brazil","is_tbd":false},{"id":"almeirim-brazil","name":"Almeirim, Brazil","country":"Brazil","is_tbd":false},{"id":"macap-brazil","name":"Macapá, Brazil","country":"Brazil","is_tbd":false},{"id":"monte-alegre-brazil","name":"Monte Alegre, Brazil","country":"Brazil","is_tbd":false},{"id":"santarm-brazil","name":"Santarém, Brazil","country":"Brazil","is_tbd":false}],"notes":"Norte Conectado is a Brazilian initiative to lay submarine fiber under the Amazon river and its tributaries.","url":"https://www.gov.br/mcom/pt-br/acesso-a-informacao/acoes-e-programas/programas-projetos-acoes-obras-e-atividades/norte-conectado"},"norte-conectado-infovia-02":{"id":"norte-conectado-infovia-02","name":"Norte Conectado (Infovia 02)","length":"1,796 km","rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"Entidade Administradora da Faixa (EAF)","suppliers":"ZTT","landing_points":[{"id":"alvares-brazil","name":"Alvarães, Brazil","country":"Brazil","is_tbd":false},{"id":"amatur-brazil","name":"Amaturá, Brazil","country":"Brazil","is_tbd":false},{"id":"atalaia-do-norte-brazil","name":"Atalaia do Norte, Brazil","country":"Brazil","is_tbd":false},{"id":"belm-do-solimes-brazil","name":"Belém do Solimões, Brazil","country":"Brazil","is_tbd":false},{"id":"benjamin-constant-brazil","name":"Benjamin Constant, Brazil","country":"Brazil","is_tbd":false},{"id":"fonte-boa-brazil","name":"Fonte Boa, Brazil","country":"Brazil","is_tbd":false},{"id":"juta-brazil","name":"Jutaí, Brazil","country":"Brazil","is_tbd":false},{"id":"santo-antnio-do-i-brazil","name":"Santo Antônio do Içá, Brazil","country":"Brazil","is_tbd":false},{"id":"so-paulo-de-olivena-brazil","name":"São Paulo de Olivença, Brazil","country":"Brazil","is_tbd":false},{"id":"tabatinga-brazil","name":"Tabatinga, Brazil","country":"Brazil","is_tbd":false},{"id":"tef-brazil","name":"Tefé, Brazil","country":"Brazil","is_tbd":false},{"id":"tonantins-brazil","name":"Tonantins, Brazil","country":"Brazil","is_tbd":false},{"id":"uarini-brazil","name":"Uarini, Brazil","country":"Brazil","is_tbd":false}],"notes":"Norte Conectado is a Brazilian initiative to lay submarine fiber under the Amazon river and its tributaries. EAF is a non-governmental, non-profit organization formed by the operators Claro, Tim and Vivo.","url":"https://eaf.org.br/infovias"},"north-west-cable-system":{"id":"north-west-cable-system","name":"North-West Cable System","length":"2,100 km","rfs":"2016 September","rfs_year":2016,"is_planned":false,"owners":"Vocus Communications","suppliers":"ASN","landing_points":[{"id":"darwin-nt-australia","name":"Darwin, NT, Australia","country":"Australia","is_tbd":false},{"id":"port-hedland-wa-australia","name":"Port Hedland, WA, Australia","country":"Australia","is_tbd":false},{"id":"wurrumiyanga-nt-australia","name":"Wurrumiyanga, NT, Australia","country":"Australia","is_tbd":false}],"notes":null,"url":"http://www.vocus.com.au"},"norte-conectado-infovia-03":{"id":"norte-conectado-infovia-03","name":"Norte Conectado (Infovia 03)","length":"600 km","rfs":"2025","rfs_year":2025,"is_planned":false,"owners":"Entidade Administradora da Faixa (EAF)","suppliers":"ZTT","landing_points":[{"id":"afu-brazil","name":"Afuá, Brazil","country":"Brazil","is_tbd":false},{"id":"bagre-brazil","name":"Bagre, Brazil","country":"Brazil","is_tbd":false},{"id":"belm-brazil","name":"Belém, Brazil","country":"Brazil","is_tbd":false},{"id":"breves-brazil","name":"Breves, Brazil","country":"Brazil","is_tbd":false},{"id":"curralinho-brazil","name":"Curralinho, Brazil","country":"Brazil","is_tbd":false},{"id":"macap-brazil","name":"Macapá, Brazil","country":"Brazil","is_tbd":false},{"id":"ponta-de-pedras-brazil","name":"Ponta de Pedras, Brazil","country":"Brazil","is_tbd":false},{"id":"so-sebastio-da-boa-vista-brazil","name":"São Sebastião da Boa Vista, Brazil","country":"Brazil","is_tbd":false}],"notes":"Norte Conectado is a Brazilian initiative to lay submarine fiber under the Amazon river and its tributaries. EAF is a non-governmental, non-profit organization formed by the operators Claro, Tim and Vivo.","url":"https://eaf.org.br/infovias"},"norte-conectado-infovia-04":{"id":"norte-conectado-infovia-04","name":"Norte Conectado (Infovia 04)","length":"515 km","rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"Entidade Administradora da Faixa (EAF)","suppliers":"ZTT","landing_points":[{"id":"boa-vista-brazil","name":"Boa Vista, Brazil","country":"Brazil","is_tbd":false},{"id":"caracara-brazil","name":"Caracaraí, Brazil","country":"Brazil","is_tbd":false},{"id":"moura-brazil","name":"Moura, Brazil","country":"Brazil","is_tbd":false},{"id":"santa-maria-do-boiau-brazil","name":"Santa Maria do Boiaçu, Brazil","country":"Brazil","is_tbd":false}],"notes":"Norte Conectado is a Brazilian initiative to lay submarine fiber under the Amazon river and its tributaries. EAF is a non-governmental, non-profit organization formed by the operators Claro, Tim and Vivo.","url":"https://eaf.org.br/infovias"},"northern-lights":{"id":"northern-lights","name":"Northern Lights","length":"67 km","rfs":"2008","rfs_year":2008,"is_planned":false,"owners":"BT","suppliers":null,"landing_points":[{"id":"dunnet-head-united-kingdom","name":"Dunnet Head, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"skaill-united-kingdom","name":"Skaill, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"northstar":{"id":"northstar","name":"NorthStar","length":"3,229 km","rfs":"1999 October","rfs_year":1999,"is_planned":false,"owners":"Alaska Communications","suppliers":"ASN","landing_points":[{"id":"hillsboro-or-united-states","name":"Hillsboro, OR, United States","country":"United States","is_tbd":false},{"id":"lena-point-ak-united-states","name":"Lena Point, AK, United States","country":"United States","is_tbd":false},{"id":"valdez-ak-united-states","name":"Valdez, AK, United States","country":"United States","is_tbd":false},{"id":"whittier-ak-united-states","name":"Whittier, AK, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.alaskacommunications.com"},"nuvem":{"id":"nuvem","name":"Nuvem","length":"7,194 km","rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"Google","suppliers":"SubCom","landing_points":[{"id":"annies-bay-bermuda","name":"Annie's Bay, Bermuda","country":"Bermuda","is_tbd":false},{"id":"sines-portugal","name":"Sines, Portugal","country":"Portugal","is_tbd":false},{"id":"so-miguel-portugal","name":"São Miguel, Portugal","country":"Portugal","is_tbd":false},{"id":"myrtle-beach-sc-united-states","name":"Myrtle Beach, SC, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://cloud.google.com"},"ogasawara-cable-network":{"id":"ogasawara-cable-network","name":"Ogasawara Cable Network","length":"1,038 km","rfs":"2011 July","rfs_year":2011,"is_planned":false,"owners":"Tokyo Metropolitan Government","suppliers":"NEC","landing_points":[{"id":"chichijima-japan","name":"Chichijima, Japan","country":"Japan","is_tbd":false},{"id":"hachijo-japan","name":"Hachijo, Japan","country":"Japan","is_tbd":false},{"id":"hahajima-japan","name":"Hahajima, Japan","country":"Japan","is_tbd":false}],"notes":null,"url":null},"okinawa-cellular-cable":{"id":"okinawa-cellular-cable","name":"Okinawa Cellular Cable","length":"760 km","rfs":"2020 April","rfs_year":2020,"is_planned":false,"owners":"Okinawa Cellular Telephone Company","suppliers":"NEC","landing_points":[{"id":"kagoshima-japan","name":"Kagoshima, Japan","country":"Japan","is_tbd":false},{"id":"nago-japan","name":"Nago, Japan","country":"Japan","is_tbd":false}],"notes":null,"url":"https://www.au.com/okinawa_cellular/"},"okinawa-miyakojima-ishigaki":{"id":"okinawa-miyakojima-ishigaki","name":"Okinawa-Miyakojima-Ishigaki","length":"467 km","rfs":"2004","rfs_year":2004,"is_planned":false,"owners":"NTT","suppliers":null,"landing_points":[{"id":"gusukube-japan","name":"Gusukube, Japan","country":"Japan","is_tbd":false},{"id":"shiraho-japan","name":"Shiraho, Japan","country":"Japan","is_tbd":false},{"id":"yaese-japan","name":"Yaese, Japan","country":"Japan","is_tbd":false}],"notes":null,"url":null},"okinawa-remote-islands":{"id":"okinawa-remote-islands","name":"Okinawa Remote Islands","length":"915 km","rfs":"2017 January","rfs_year":2017,"is_planned":false,"owners":"Okinawa Prefecture","suppliers":null,"landing_points":[{"id":"aguni-japan","name":"Aguni, Japan","country":"Japan","is_tbd":false},{"id":"ama-japan","name":"Ama, Japan","country":"Japan","is_tbd":false},{"id":"hateruma-japan","name":"Hateruma, Japan","country":"Japan","is_tbd":false},{"id":"itoman-japan","name":"Itoman, Japan","country":"Japan","is_tbd":false},{"id":"kumejima-japan","name":"Kumejima, Japan","country":"Japan","is_tbd":false},{"id":"tarama-japan","name":"Tarama, Japan","country":"Japan","is_tbd":false},{"id":"tokashiki-japan","name":"Tokashiki, Japan","country":"Japan","is_tbd":false},{"id":"yomitan-japan","name":"Yomitan, Japan","country":"Japan","is_tbd":false},{"id":"yonaguni-japan","name":"Yonaguni, Japan","country":"Japan","is_tbd":false}],"notes":"This cable was constructed as part of the Okinawa Remote Islands Information and Communication Infrastructure Development Promotion Project.","url":null},"olisipo":{"id":"olisipo","name":"Olisipo","length":"110 km","rfs":"2026 Q2","rfs_year":2026,"is_planned":true,"owners":"EllaLink","suppliers":null,"landing_points":[{"id":"carcavelos-portugal","name":"Carcavelos, Portugal","country":"Portugal","is_tbd":false},{"id":"sines-portugal","name":"Sines, Portugal","country":"Portugal","is_tbd":false}],"notes":null,"url":"https://www.ella.link/"},"oman-australia-cable-oac":{"id":"oman-australia-cable-oac","name":"Oman Australia Cable (OAC)","length":"11,000 km","rfs":"2022 October","rfs_year":2022,"is_planned":false,"owners":"SUBCO","suppliers":"SubCom","landing_points":[{"id":"perth-wa-australia","name":"Perth, WA, Australia","country":"Australia","is_tbd":false},{"id":"diego-garcia-british-indian-ocean-territory","name":"Diego Garcia, British Indian Ocean Territory","country":"British Indian Ocean Territory","is_tbd":false},{"id":"west-island-cocos-keeling-islands","name":"West Island, Cocos (Keeling) Islands","country":"Cocos (Keeling) Islands","is_tbd":false},{"id":"barka-oman","name":"Barka, Oman","country":"Oman","is_tbd":false},{"id":"salalah-oman","name":"Salalah, Oman","country":"Oman","is_tbd":false}],"notes":null,"url":"https://sub.co/"},"omranepeg":{"id":"omranepeg","name":"OMRAN/EPEG","length":"600 km","rfs":"2013 Q1","rfs_year":2013,"is_planned":false,"owners":"Vodafone, Zain Omantel International","suppliers":null,"landing_points":[{"id":"chabahar-iran","name":"Chabahar, Iran","country":"Iran","is_tbd":false},{"id":"jask-iran","name":"Jask, Iran","country":"Iran","is_tbd":false},{"id":"barka-oman","name":"Barka, Oman","country":"Oman","is_tbd":false},{"id":"diba-oman","name":"Diba, Oman","country":"Oman","is_tbd":false},{"id":"khasab-oman","name":"Khasab, Oman","country":"Oman","is_tbd":false}],"notes":null,"url":"http://www.omantel.om"},"oran-valencia-orval":{"id":"oran-valencia-orval","name":"Oran-Valencia (ORVAL)","length":"770 km","rfs":"2020 December","rfs_year":2020,"is_planned":false,"owners":"Algerie Telecom","suppliers":"ASN","landing_points":[{"id":"algiers-algeria","name":"Algiers, Algeria","country":"Algeria","is_tbd":false},{"id":"oran-algeria","name":"Oran, Algeria","country":"Algeria","is_tbd":false},{"id":"valencia-spain","name":"Valencia, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":"https://www.algerietelecom.dz/"},"orca":{"id":"orca","name":"ORCA","length":"12,482 km","rfs":"2027 Q1","rfs_year":2027,"is_planned":true,"owners":"Meta","suppliers":"ASN","landing_points":[{"id":"toucheng-taiwan","name":"Toucheng, Taiwan","country":"Taiwan","is_tbd":false},{"id":"hermosa-beach-ca-united-states","name":"Hermosa Beach, CA, United States","country":"United States","is_tbd":false},{"id":"manchester-ca-united-states","name":"Manchester, CA, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.meta.com/"},"oskarshamn-visby":{"id":"oskarshamn-visby","name":"Oskarshamn-Visby","length":null,"rfs":"2008","rfs_year":2008,"is_planned":false,"owners":"GlobalConnect","suppliers":null,"landing_points":[{"id":"oskarshamn-sweden","name":"Oskarshamn, Sweden","country":"Sweden","is_tbd":false},{"id":"visby-sweden","name":"Visby, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":"https://globalconnectcarrier.com/"},"oteglobe-kokkini-bari":{"id":"oteglobe-kokkini-bari","name":"OTEGLOBE Kokkini-Bari","length":"700 km","rfs":"2004 June","rfs_year":2004,"is_planned":false,"owners":"OTEGLOBE","suppliers":"ASN","landing_points":[{"id":"kokkini-greece","name":"Kokkini, Greece","country":"Greece","is_tbd":false},{"id":"bari-italy","name":"Bari, Italy","country":"Italy","is_tbd":false}],"notes":null,"url":"http://www.oteglobe.gr"},"pacific-caribbean-cable-system-pccs":{"id":"pacific-caribbean-cable-system-pccs","name":"Pacific Caribbean Cable System (PCCS)","length":"6,163 km","rfs":"2015 September","rfs_year":2015,"is_planned":false,"owners":"Liberty Networks, Setar, Telconet, Telxius, United Telecommunication Services (UTS)","suppliers":"ASN","landing_points":[{"id":"hudishibana-aruba","name":"Hudishibana, Aruba","country":"Aruba","is_tbd":false},{"id":"cartagena-colombia","name":"Cartagena, Colombia","country":"Colombia","is_tbd":false},{"id":"mahuma-curaao","name":"Mahuma, Curaçao","country":"Curaçao","is_tbd":false},{"id":"manta-ecuador","name":"Manta, Ecuador","country":"Ecuador","is_tbd":false},{"id":"balboa-panama","name":"Balboa, Panama","country":"Panama","is_tbd":false},{"id":"maria-chiquita-panama","name":"Maria Chiquita, Panama","country":"Panama","is_tbd":false},{"id":"jacksonville-fl-united-states","name":"Jacksonville, FL, United States","country":"United States","is_tbd":false},{"id":"san-juan-pr-united-states","name":"San Juan, PR, United States","country":"United States","is_tbd":false},{"id":"tortola-virgin-islands-u-k-","name":"Tortola, Virgin Islands (U.K.)","country":"Virgin Islands (U.K.)","is_tbd":false}],"notes":"The portion of PCCS linking Aruba with Curacao is operated by Setar and United Telecommunication Services (UTS). This portion of the cable is called Alonso de Ojeda II.","url":null},"pacific-crossing-1-pc-1":{"id":"pacific-crossing-1-pc-1","name":"Pacific Crossing-1 (PC-1)","length":"21,000 km","rfs":"1999 December","rfs_year":1999,"is_planned":false,"owners":"Pacific Crossing","suppliers":"SubCom","landing_points":[{"id":"ajigaura-japan","name":"Ajigaura, Japan","country":"Japan","is_tbd":false},{"id":"shima-japan","name":"Shima, Japan","country":"Japan","is_tbd":false},{"id":"grover-beach-ca-united-states","name":"Grover Beach, CA, United States","country":"United States","is_tbd":false},{"id":"harbour-pointe-wa-united-states","name":"Harbour Pointe, WA, United States","country":"United States","is_tbd":false}],"notes":null,"url":"http://www.pc1.com"},"pacific-light-cable-network-plcn":{"id":"pacific-light-cable-network-plcn","name":"Pacific Light Cable Network (PLCN)","length":"11,806 km","rfs":"2022 January","rfs_year":2022,"is_planned":false,"owners":"Google, Meta","suppliers":"SubCom","landing_points":[{"id":"baler-philippines","name":"Baler, Philippines","country":"Philippines","is_tbd":false},{"id":"toucheng-taiwan","name":"Toucheng, Taiwan","country":"Taiwan","is_tbd":false},{"id":"el-segundo-ca-united-states","name":"El Segundo, CA, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"padang-tua-pejat":{"id":"padang-tua-pejat","name":"Padang-Tua Pejat","length":"160 km","rfs":"2019","rfs_year":2019,"is_planned":false,"owners":"Telkom Indonesia","suppliers":null,"landing_points":[{"id":"padang-indonesia","name":"Padang, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tua-pejat-indonesia","name":"Tua Pejat, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":null},"palapa-ring-middle":{"id":"palapa-ring-middle","name":"Palapa Ring Middle","length":"2,100 km","rfs":"2018 December","rfs_year":2018,"is_planned":false,"owners":"Indonesian Government","suppliers":"HMN Tech","landing_points":[{"id":"bangga-indonesia","name":"Bangga, Indonesia","country":"Indonesia","is_tbd":false},{"id":"baubau-indonesia","name":"Baubau, Indonesia","country":"Indonesia","is_tbd":false},{"id":"buranga-indonesia","name":"Buranga, Indonesia","country":"Indonesia","is_tbd":false},{"id":"kendari-indonesia","name":"Kendari, Indonesia","country":"Indonesia","is_tbd":false},{"id":"lakudo-indonesia","name":"Lakudo, Indonesia","country":"Indonesia","is_tbd":false},{"id":"luwuk-indonesia","name":"Luwuk, Indonesia","country":"Indonesia","is_tbd":false},{"id":"manado-indonesia","name":"Manado, Indonesia","country":"Indonesia","is_tbd":false},{"id":"melonguane-indonesia","name":"Melonguane, Indonesia","country":"Indonesia","is_tbd":false},{"id":"morotai-indonesia","name":"Morotai, Indonesia","country":"Indonesia","is_tbd":false},{"id":"ondong-siau-indonesia","name":"Ondong Siau, Indonesia","country":"Indonesia","is_tbd":false},{"id":"raha-indonesia","name":"Raha, Indonesia","country":"Indonesia","is_tbd":false},{"id":"salakan-indonesia","name":"Salakan, Indonesia","country":"Indonesia","is_tbd":false},{"id":"sanana-indonesia","name":"Sanana, Indonesia","country":"Indonesia","is_tbd":false},{"id":"sofifi-indonesia","name":"Sofifi, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tahuna-indonesia","name":"Tahuna, Indonesia","country":"Indonesia","is_tbd":false},{"id":"taliabu-indonesia","name":"Taliabu, Indonesia","country":"Indonesia","is_tbd":false},{"id":"ternate-indonesia","name":"Ternate, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tidore-indonesia","name":"Tidore, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tobelo-indonesia","name":"Tobelo, Indonesia","country":"Indonesia","is_tbd":false},{"id":"wawonii-indonesia","name":"Wawonii, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":null},"palapa-ring-east":{"id":"palapa-ring-east","name":"Palapa Ring East","length":"6,300 km","rfs":"2019 October","rfs_year":2019,"is_planned":false,"owners":"Indonesian Government, Moratelindo, Telekom PT SmartFren","suppliers":null,"landing_points":[{"id":"agats-indonesia","name":"Agats, Indonesia","country":"Indonesia","is_tbd":false},{"id":"baa-indonesia","name":"Baa, Indonesia","country":"Indonesia","is_tbd":false},{"id":"kep-aru-indonesia","name":"Kep. Aru, Indonesia","country":"Indonesia","is_tbd":false},{"id":"kokar-indonesia","name":"Kokar, Indonesia","country":"Indonesia","is_tbd":false},{"id":"kota-mappi-indonesia","name":"Kota Mappi, Indonesia","country":"Indonesia","is_tbd":false},{"id":"kupang-indonesia","name":"Kupang, Indonesia","country":"Indonesia","is_tbd":false},{"id":"manokwari-indonesia","name":"Manokwari, Indonesia","country":"Indonesia","is_tbd":false},{"id":"nabire-indonesia","name":"Nabire, Indonesia","country":"Indonesia","is_tbd":false},{"id":"seba-indonesia","name":"Seba, Indonesia","country":"Indonesia","is_tbd":false},{"id":"serwaru-indonesia","name":"Serwaru, Indonesia","country":"Indonesia","is_tbd":false},{"id":"suemlaki-indonesia","name":"Suemlaki, Indonesia","country":"Indonesia","is_tbd":false},{"id":"supiori-indonesia","name":"Supiori, Indonesia","country":"Indonesia","is_tbd":false},{"id":"teluk-indonesia","name":"Teluk, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tiakur-indonesia","name":"Tiakur, Indonesia","country":"Indonesia","is_tbd":false},{"id":"timika-indonesia","name":"Timika, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tual-indonesia","name":"Tual, Indonesia","country":"Indonesia","is_tbd":false},{"id":"waingapu-indonesia","name":"Waingapu, Indonesia","country":"Indonesia","is_tbd":false},{"id":"yapen-indonesia","name":"Yapen, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":null},"palapa-ring-west":{"id":"palapa-ring-west","name":"Palapa Ring West","length":"1,980 km","rfs":"2018 February","rfs_year":2018,"is_planned":false,"owners":"Indonesian Government","suppliers":null,"landing_points":[{"id":"batam-indonesia","name":"Batam, Indonesia","country":"Indonesia","is_tbd":false},{"id":"bengkalis-indonesia","name":"Bengkalis, Indonesia","country":"Indonesia","is_tbd":false},{"id":"dumai-indonesia","name":"Dumai, Indonesia","country":"Indonesia","is_tbd":false},{"id":"karimun-indonesia","name":"Karimun, Indonesia","country":"Indonesia","is_tbd":false},{"id":"kuala-tungkal-indonesia","name":"Kuala Tungkal, Indonesia","country":"Indonesia","is_tbd":false},{"id":"lingga-indonesia","name":"Lingga, Indonesia","country":"Indonesia","is_tbd":false},{"id":"natuna-indonesia","name":"Natuna, Indonesia","country":"Indonesia","is_tbd":false},{"id":"ranai-indonesia","name":"Ranai, Indonesia","country":"Indonesia","is_tbd":false},{"id":"singkawang-indonesia","name":"Singkawang, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tebingtinggi-island-indonesia","name":"Tebingtinggi Island, Indonesia","country":"Indonesia","is_tbd":false},{"id":"terempa-indonesia","name":"Terempa, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":null},"pan-american-crossing-pac":{"id":"pan-american-crossing-pac","name":"Pan-American Crossing (PAC)","length":"10,000 km","rfs":"2000 March","rfs_year":2000,"is_planned":false,"owners":"Cirion Technologies","suppliers":"SubCom","landing_points":[{"id":"unqui-costa-rica","name":"Unqui, Costa Rica","country":"Costa Rica","is_tbd":false},{"id":"mazatln-mexico","name":"Mazatlán, Mexico","country":"Mexico","is_tbd":false},{"id":"tijuana-mexico","name":"Tijuana, Mexico","country":"Mexico","is_tbd":false},{"id":"fort-amador-panama","name":"Fort Amador, Panama","country":"Panama","is_tbd":false},{"id":"grover-beach-ca-united-states","name":"Grover Beach, CA, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.ciriontechnologies.com"},"palawan-iloilo-cable-system":{"id":"palawan-iloilo-cable-system","name":"Palawan-Iloilo Cable System","length":"300 km","rfs":"2014 January","rfs_year":2014,"is_planned":false,"owners":"PLDT","suppliers":null,"landing_points":[{"id":"san-jose-de-buenavista-philippines","name":"San Jose de Buenavista, Philippines","country":"Philippines","is_tbd":false},{"id":"taytay-philippines","name":"Taytay, Philippines","country":"Philippines","is_tbd":false}],"notes":null,"url":null},"pan-european-crossing-uk-belgium":{"id":"pan-european-crossing-uk-belgium","name":"Pan European Crossing (UK-Belgium)","length":"117 km","rfs":"1999","rfs_year":1999,"is_planned":false,"owners":"Colt","suppliers":null,"landing_points":[{"id":"bredene-belgium","name":"Bredene, Belgium","country":"Belgium","is_tbd":false},{"id":"dumpton-gap-united-kingdom","name":"Dumpton Gap, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"pan-european-crossing-uk-ireland":{"id":"pan-european-crossing-uk-ireland","name":"Pan European Crossing (UK-Ireland)","length":"495 km","rfs":"2000 September","rfs_year":2000,"is_planned":false,"owners":"Colt","suppliers":null,"landing_points":[{"id":"ballinesker-ireland","name":"Ballinesker, Ireland","country":"Ireland","is_tbd":false},{"id":"ballygrangans-ireland","name":"Ballygrangans, Ireland","country":"Ireland","is_tbd":false},{"id":"bude-united-kingdom","name":"Bude, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"whitesands-bay-united-kingdom","name":"Whitesands Bay, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"panam-south":{"id":"panam-south","name":"PanAm South","length":"1,340 km","rfs":"1999","rfs_year":1999,"is_planned":false,"owners":"Corporacion Nacional de Telecomunicaciones (CNT), Liberty Networks","suppliers":"ASN, SubCom","landing_points":[{"id":"punta-carnero-ecuador","name":"Punta Carnero, Ecuador","country":"Ecuador","is_tbd":false},{"id":"panama-city-panama","name":"Panama City, Panama","country":"Panama","is_tbd":false}],"notes":null,"url":null},"pasuli":{"id":"pasuli","name":"PASULI","length":"40 km","rfs":"2019 August","rfs_year":2019,"is_planned":false,"owners":"FiberStar, XLSmart","suppliers":null,"landing_points":[{"id":"muntok-indonesia","name":"Muntok, Indonesia","country":"Indonesia","is_tbd":false},{"id":"sungsang-indonesia","name":"Sungsang, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":null},"paniolo-cable-network":{"id":"paniolo-cable-network","name":"Paniolo Cable Network","length":"576 km","rfs":"2009","rfs_year":2009,"is_planned":false,"owners":"Hawaiian Telcom","suppliers":null,"landing_points":[{"id":"hawaii-kai-hi-united-states","name":"Hawaii Kai, HI, United States","country":"United States","is_tbd":false},{"id":"kaunakakai-hi-united-states","name":"Kaunakakai, HI, United States","country":"United States","is_tbd":false},{"id":"kawaihae-hi-united-states","name":"Kawaihae, HI, United States","country":"United States","is_tbd":false},{"id":"kekaha-hi-united-states","name":"Kekaha, HI, United States","country":"United States","is_tbd":false},{"id":"lahaina-hi-united-states","name":"Lahaina, HI, United States","country":"United States","is_tbd":false},{"id":"makaha-hi-united-states","name":"Makaha, HI, United States","country":"United States","is_tbd":false},{"id":"makena-hi-united-states","name":"Makena, HI, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"patara-2":{"id":"patara-2","name":"Patara-2","length":"1,200 km","rfs":"2023 October","rfs_year":2023,"is_planned":false,"owners":"Telin","suppliers":"NEC","landing_points":[{"id":"manokwari-indonesia","name":"Manokwari, Indonesia","country":"Indonesia","is_tbd":false},{"id":"supiori-indonesia","name":"Supiori, Indonesia","country":"Indonesia","is_tbd":false},{"id":"waisai-indonesia","name":"Waisai, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":null},"peace-cable":{"id":"peace-cable","name":"PEACE Cable","length":"25,000 km","rfs":"2022 March","rfs_year":2022,"is_planned":false,"owners":"Peace Cable International Network Co. Ltd.","suppliers":"HMN Tech","landing_points":[{"id":"yeroskipos-cyprus","name":"Yeroskipos, Cyprus","country":"Cyprus","is_tbd":false},{"id":"abu-talat-egypt","name":"Abu Talat, Egypt","country":"Egypt","is_tbd":false},{"id":"zafarana-egypt","name":"Zafarana, Egypt","country":"Egypt","is_tbd":false},{"id":"marseille-france","name":"Marseille, France","country":"France","is_tbd":false},{"id":"mombasa-kenya","name":"Mombasa, Kenya","country":"Kenya","is_tbd":false},{"id":"kulhudhufushi-maldives","name":"Kulhudhufushi, Maldives","country":"Maldives","is_tbd":false},{"id":"mellieha-malta","name":"Mellieha, Malta","country":"Malta","is_tbd":false},{"id":"karachi-pakistan","name":"Karachi, Pakistan","country":"Pakistan","is_tbd":false},{"id":"jeddah-saudi-arabia","name":"Jeddah, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"victoria-seychelles","name":"Victoria, Seychelles","country":"Seychelles","is_tbd":false},{"id":"tuas-singapore","name":"Tuas, Singapore","country":"Singapore","is_tbd":false},{"id":"berbera-somalia","name":"Berbera, Somalia","country":"Somalia","is_tbd":false},{"id":"bizerte-tunisia","name":"Bizerte, Tunisia","country":"Tunisia","is_tbd":false},{"id":"kalba-united-arab-emirates","name":"Kalba, United Arab Emirates","country":"United Arab Emirates","is_tbd":false}],"notes":"Cyta owns the branch to Cyprus which they market as Arsinoe. GO owns the branch to Malta which they market as LaValette. Ooredoo Tunisia owns the Tunisian branch which they market as IFRIQYA. Zain owns the branch to Jeddah which they market as J2M (Jeddah to Marseille). du owns a branch to the UAE that will be completed in 2026.","url":"http://www.peacecable.net"},"penbal-5":{"id":"penbal-5","name":"Penbal-5","length":"315 km","rfs":"1994","rfs_year":1994,"is_planned":false,"owners":"Telefonica","suppliers":null,"landing_points":[{"id":"gav-spain","name":"Gavá, Spain","country":"Spain","is_tbd":false},{"id":"ses-covetes-spain","name":"Ses Covetes, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":null},"penbal-4":{"id":"penbal-4","name":"Penbal-4","length":"317 km","rfs":"1991","rfs_year":1991,"is_planned":false,"owners":"Telefonica","suppliers":null,"landing_points":[{"id":"ibiza-spain","name":"Ibiza, Spain","country":"Spain","is_tbd":false},{"id":"mallorca-spain","name":"Mallorca, Spain","country":"Spain","is_tbd":false},{"id":"valencia-spain","name":"Valencia, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":null},"pencan-8":{"id":"pencan-8","name":"Pencan-8","length":"1,400 km","rfs":"2011","rfs_year":2011,"is_planned":false,"owners":"Telefonica","suppliers":"ASN","landing_points":[{"id":"candelaria-canary-islands-spain","name":"Candelaria, Canary Islands, Spain","country":"Spain","is_tbd":false},{"id":"conil-de-la-frontera-spain","name":"Conil de la Frontera, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":null},"pencan-9":{"id":"pencan-9","name":"Pencan-9","length":"1,398 km","rfs":"2016","rfs_year":2016,"is_planned":false,"owners":"Telefonica","suppliers":null,"landing_points":[{"id":"chipiona-spain","name":"Chipiona, Spain","country":"Spain","is_tbd":false},{"id":"tarahales-spain","name":"Tarahales, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":null},"persona":{"id":"persona","name":"Persona","length":"800 km","rfs":"2008","rfs_year":2008,"is_planned":false,"owners":"Eastlink","suppliers":"Nexans","landing_points":[{"id":"new-victoria-ns-canada","name":"New Victoria, NS, Canada","country":"Canada","is_tbd":false},{"id":"rose-blanche-nl-canada","name":"Rose Blanche, NL, Canada","country":"Canada","is_tbd":false}],"notes":null,"url":null},"petropavlovsk-kamchatsky---anadyr":{"id":"petropavlovsk-kamchatsky---anadyr","name":"Petropavlovsk-Kamchatsky - Anadyr","length":"2,173 km","rfs":"2022 December","rfs_year":2022,"is_planned":false,"owners":"Rostelecom","suppliers":"HMN Tech","landing_points":[{"id":"anadyr-russia","name":"Anadyr, Russia","country":"Russia","is_tbd":false},{"id":"petropavlovsk-kamchatsky-russia","name":"Petropavlovsk-Kamchatsky, Russia","country":"Russia","is_tbd":false},{"id":"ugolnye-kopi-russia","name":"Ugolnye Kopi, Russia","country":"Russia","is_tbd":false}],"notes":null,"url":"https://www.company.rt.ru/"},"pgascom":{"id":"pgascom","name":"PGASCOM","length":"264 km","rfs":"2010","rfs_year":2010,"is_planned":false,"owners":"PGASCOM","suppliers":null,"landing_points":[{"id":"batam-indonesia","name":"Batam, Indonesia","country":"Indonesia","is_tbd":false},{"id":"kuala-tungkal-indonesia","name":"Kuala Tungkal, Indonesia","country":"Indonesia","is_tbd":false},{"id":"sakra-island-singapore","name":"Sakra Island, Singapore","country":"Singapore","is_tbd":false}],"notes":"The PGASCOM cable is comprised of fiber that is attached to a gas pipeline.","url":"http://www.pgascom.co.id"},"philippine-domestic-submarine-cable-network-pdscn":{"id":"philippine-domestic-submarine-cable-network-pdscn","name":"Philippine Domestic Submarine Cable Network (PDSCN)","length":"2,500 km","rfs":"2023 April","rfs_year":2023,"is_planned":false,"owners":"Eastern Telecom, Globe Telecom, Infinivan Inc.","suppliers":"Nexans","landing_points":[{"id":"baclayon-philippines","name":"Baclayon, Philippines","country":"Philippines","is_tbd":false},{"id":"bacolod-philippines","name":"Bacolod, Philippines","country":"Philippines","is_tbd":false},{"id":"boac-philippines","name":"Boac, Philippines","country":"Philippines","is_tbd":false},{"id":"boracay-philippines","name":"Boracay, Philippines","country":"Philippines","is_tbd":false},{"id":"bulan-philippines","name":"Bulan, Philippines","country":"Philippines","is_tbd":false},{"id":"cagayan-de-oro-philippines","name":"Cagayan de Oro, Philippines","country":"Philippines","is_tbd":false},{"id":"cagdianao-philippines","name":"Cagdianao, Philippines","country":"Philippines","is_tbd":false},{"id":"calatrava-philippines","name":"Calatrava, Philippines","country":"Philippines","is_tbd":false},{"id":"calbayog-philippines","name":"Calbayog, Philippines","country":"Philippines","is_tbd":false},{"id":"camiguin-island-philippines","name":"Camiguin Island, Philippines","country":"Philippines","is_tbd":false},{"id":"caticlan-philippines","name":"Caticlan, Philippines","country":"Philippines","is_tbd":false},{"id":"dipolog-city-philippines","name":"Dipolog City, Philippines","country":"Philippines","is_tbd":false},{"id":"ilijan-philippines","name":"Ilijan, Philippines","country":"Philippines","is_tbd":false},{"id":"iloilo-city-philippines","name":"Iloilo City, Philippines","country":"Philippines","is_tbd":false},{"id":"kinoguitan-philippines","name":"Kinoguitan, Philippines","country":"Philippines","is_tbd":false},{"id":"liloan-philippines","name":"Liloan, Philippines","country":"Philippines","is_tbd":false},{"id":"liloy-philippines","name":"Liloy, Philippines","country":"Philippines","is_tbd":false},{"id":"lucena-philippines","name":"Lucena, Philippines","country":"Philippines","is_tbd":false},{"id":"maasin-philippines","name":"Maasin, Philippines","country":"Philippines","is_tbd":false},{"id":"palanas-philippines","name":"Palanas, Philippines","country":"Philippines","is_tbd":false},{"id":"palompon-philippines","name":"Palompon, Philippines","country":"Philippines","is_tbd":false},{"id":"pasacao-philippines","name":"Pasacao, Philippines","country":"Philippines","is_tbd":false},{"id":"pinamalayan-philippines","name":"Pinamalayan, Philippines","country":"Philippines","is_tbd":false},{"id":"placer-philippines","name":"Placer, Philippines","country":"Philippines","is_tbd":false},{"id":"roxas-city-philippines","name":"Roxas City, Philippines","country":"Philippines","is_tbd":false},{"id":"san-carlos-philippines","name":"San Carlos, Philippines","country":"Philippines","is_tbd":false},{"id":"siargao-island-philippines","name":"Siargao Island, Philippines","country":"Philippines","is_tbd":false},{"id":"surigao-city-philippines","name":"Surigao City, Philippines","country":"Philippines","is_tbd":false},{"id":"tagbilaran-philippines","name":"Tagbilaran, Philippines","country":"Philippines","is_tbd":false},{"id":"talisay-philippines","name":"Talisay, Philippines","country":"Philippines","is_tbd":false},{"id":"toledo-philippines","name":"Toledo, Philippines","country":"Philippines","is_tbd":false},{"id":"zamboanga-philippines","name":"Zamboanga, Philippines","country":"Philippines","is_tbd":false},{"id":"zamboanguita-philippines","name":"Zamboanguita, Philippines","country":"Philippines","is_tbd":false}],"notes":null,"url":null},"picot-1":{"id":"picot-1","name":"Picot-1","length":null,"rfs":"2008","rfs_year":2008,"is_planned":false,"owners":"OPT","suppliers":"ASN","landing_points":[{"id":"mouly-new-caledonia","name":"Mouly, New Caledonia","country":"New Caledonia","is_tbd":false},{"id":"poindimie-new-caledonia","name":"Poindimie, New Caledonia","country":"New Caledonia","is_tbd":false},{"id":"xepenehe-new-caledonia","name":"Xepenehe, New Caledonia","country":"New Caledonia","is_tbd":false}],"notes":null,"url":"https://www.opt.nc/"},"piano-isole-minori":{"id":"piano-isole-minori","name":"Piano Isole Minori","length":"830 km","rfs":"2024 December","rfs_year":2024,"is_planned":false,"owners":"Infratel Italia","suppliers":null,"landing_points":[{"id":"alicudi-porto-italy","name":"Alicudi Porto, Italy","country":"Italy","is_tbd":false},{"id":"cala-doliva-italy","name":"Cala d'Oliva, Italy","country":"Italy","is_tbd":false},{"id":"canneto-italy","name":"Canneto, Italy","country":"Italy","is_tbd":false},{"id":"capraia-isola-italy","name":"Capraia Isola, Italy","country":"Italy","is_tbd":false},{"id":"carloforte-italy","name":"Carloforte, Italy","country":"Italy","is_tbd":false},{"id":"ditella-italy","name":"Ditella, Italy","country":"Italy","is_tbd":false},{"id":"filicudi-porto-italy","name":"Filicudi Porto, Italy","country":"Italy","is_tbd":false},{"id":"gaeta-italy","name":"Gaeta, Italy","country":"Italy","is_tbd":false},{"id":"isola-delle-femmine-italy","name":"Isola delle Femmine, Italy","country":"Italy","is_tbd":false},{"id":"lampedusa-italy","name":"Lampedusa, Italy","country":"Italy","is_tbd":false},{"id":"levanzo-italy","name":"Levanzo, Italy","country":"Italy","is_tbd":false},{"id":"linosa-italy","name":"Linosa, Italy","country":"Italy","is_tbd":false},{"id":"lipari-italy","name":"Lipari, Italy","country":"Italy","is_tbd":false},{"id":"malfa-italy","name":"Malfa, Italy","country":"Italy","is_tbd":false},{"id":"marettimo-italy","name":"Marettimo, Italy","country":"Italy","is_tbd":false},{"id":"marsala-italy","name":"Marsala, Italy","country":"Italy","is_tbd":false},{"id":"pantelleria-italy","name":"Pantelleria, Italy","country":"Italy","is_tbd":false},{"id":"patti-italy","name":"Patti, Italy","country":"Italy","is_tbd":false},{"id":"ponza-italy","name":"Ponza, Italy","country":"Italy","is_tbd":false},{"id":"portoferraio-italy","name":"Portoferraio, Italy","country":"Italy","is_tbd":false},{"id":"portoscuso-italy","name":"Portoscuso, Italy","country":"Italy","is_tbd":false},{"id":"san-domino-italy","name":"San Domino, Italy","country":"Italy","is_tbd":false},{"id":"san-nicola-di-tremiti-italy","name":"San Nicola di Tremiti, Italy","country":"Italy","is_tbd":false},{"id":"santo-stefano-italy","name":"Santo Stefano, Italy","country":"Italy","is_tbd":false},{"id":"stintino-italy","name":"Stintino, Italy","country":"Italy","is_tbd":false},{"id":"stromboli-italy","name":"Stromboli, Italy","country":"Italy","is_tbd":false},{"id":"torre-mileto-italy","name":"Torre Mileto, Italy","country":"Italy","is_tbd":false},{"id":"trapani-italy","name":"Trapani, Italy","country":"Italy","is_tbd":false},{"id":"ustica-italy","name":"Ustica, Italy","country":"Italy","is_tbd":false},{"id":"ventotene-italy","name":"Ventotene, Italy","country":"Italy","is_tbd":false},{"id":"vulcano-bleu-italy","name":"Vulcano Bleu, Italy","country":"Italy","is_tbd":false}],"notes":null,"url":"https://www.infratelitalia.it/"},"pishgaman-oman-iran-poi-network":{"id":"pishgaman-oman-iran-poi-network","name":"Pishgaman Oman Iran (POI) Network","length":"400 km","rfs":"2012 June","rfs_year":2012,"is_planned":false,"owners":"Pishgaman Kavir","suppliers":null,"landing_points":[{"id":"chabahar-iran","name":"Chabahar, Iran","country":"Iran","is_tbd":false},{"id":"jask-iran","name":"Jask, Iran","country":"Iran","is_tbd":false},{"id":"barka-oman","name":"Barka, Oman","country":"Oman","is_tbd":false}],"notes":null,"url":"http://www.poiconnections.com/"},"pipe-pacific-cable-1-ppc-1":{"id":"pipe-pacific-cable-1-ppc-1","name":"PIPE Pacific Cable-1 (PPC-1)","length":"6,900 km","rfs":"2009 October","rfs_year":2009,"is_planned":false,"owners":"Vocus Communications","suppliers":"SubCom","landing_points":[{"id":"sydney-nsw-australia","name":"Sydney, NSW, Australia","country":"Australia","is_tbd":false},{"id":"piti-guam","name":"Piti, Guam","country":"Guam","is_tbd":false},{"id":"madang-papua-new-guinea","name":"Madang, Papua New Guinea","country":"Papua New Guinea","is_tbd":false}],"notes":null,"url":"http://www.vocus.com.au"},"pldt-domestic-fiber-optic-network-dfon":{"id":"pldt-domestic-fiber-optic-network-dfon","name":"PLDT Domestic Fiber Optic Network (DFON)","length":"11,100 km","rfs":"1997","rfs_year":1997,"is_planned":false,"owners":"PLDT","suppliers":null,"landing_points":[{"id":"butuan-city-philippines","name":"Butuan City, Philippines","country":"Philippines","is_tbd":false},{"id":"cadiz-city-philippines","name":"Cadiz City, Philippines","country":"Philippines","is_tbd":false},{"id":"cagayan-de-oro-philippines","name":"Cagayan de Oro, Philippines","country":"Philippines","is_tbd":false},{"id":"calbayog-philippines","name":"Calbayog, Philippines","country":"Philippines","is_tbd":false},{"id":"cebu-philippines","name":"Cebu, Philippines","country":"Philippines","is_tbd":false},{"id":"dumaguete-philippines","name":"Dumaguete, Philippines","country":"Philippines","is_tbd":false},{"id":"legazpi-city-philippines","name":"Legazpi City, Philippines","country":"Philippines","is_tbd":false},{"id":"masbate-city-philippines","name":"Masbate City, Philippines","country":"Philippines","is_tbd":false},{"id":"nasugbu-philippines","name":"Nasugbu, Philippines","country":"Philippines","is_tbd":false},{"id":"ormoc-philippines","name":"Ormoc, Philippines","country":"Philippines","is_tbd":false},{"id":"ozamiz-city-philippines","name":"Ozamiz City, Philippines","country":"Philippines","is_tbd":false},{"id":"pinamalayan-philippines","name":"Pinamalayan, Philippines","country":"Philippines","is_tbd":false},{"id":"roxas-city-philippines","name":"Roxas City, Philippines","country":"Philippines","is_tbd":false}],"notes":null,"url":"http://www.pldt.com.ph"},"png-lng":{"id":"png-lng","name":"PNG LNG","length":"200 km","rfs":"2014","rfs_year":2014,"is_planned":false,"owners":"Telikom Papua New Guinea","suppliers":null,"landing_points":[{"id":"kikori-papua-new-guinea","name":"Kikori, Papua New Guinea","country":"Papua New Guinea","is_tbd":false},{"id":"port-moresby-papua-new-guinea","name":"Port Moresby, Papua New Guinea","country":"Papua New Guinea","is_tbd":false}],"notes":null,"url":null},"polar-circle-cable":{"id":"polar-circle-cable","name":"Polar Circle Cable","length":"1,004 km","rfs":"2007 September","rfs_year":2007,"is_planned":false,"owners":"KystTele","suppliers":null,"landing_points":[{"id":"bod-norway","name":"Bodø, Norway","country":"Norway","is_tbd":false},{"id":"brnnysund-norway","name":"Brønnøysund, Norway","country":"Norway","is_tbd":false},{"id":"narvik-norway","name":"Narvik, Norway","country":"Norway","is_tbd":false},{"id":"nesna-norway","name":"Nesna, Norway","country":"Norway","is_tbd":false},{"id":"rrvik-norway","name":"Rørvik, Norway","country":"Norway","is_tbd":false},{"id":"sandnessjen-norway","name":"Sandnessjøen, Norway","country":"Norway","is_tbd":false},{"id":"trondheim-norway","name":"Trondheim, Norway","country":"Norway","is_tbd":false}],"notes":null,"url":"http://www.kysttele.no"},"portsmouth-ryde-10":{"id":"portsmouth-ryde-10","name":"Portsmouth-Ryde 10","length":null,"rfs":"2015","rfs_year":2015,"is_planned":false,"owners":"BT","suppliers":"Hexatronic","landing_points":[{"id":"portsmouth-united-kingdom","name":"Portsmouth, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"ryde-united-kingdom","name":"Ryde, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"polar-express":{"id":"polar-express","name":"Polar Express","length":"12,650 km","rfs":"2022 October","rfs_year":2022,"is_planned":false,"owners":"Russian Government","suppliers":"JSC Optic Fiber Systems","landing_points":[{"id":"amderma-russia","name":"Amderma, Russia","country":"Russia","is_tbd":false},{"id":"anadyr-russia","name":"Anadyr, Russia","country":"Russia","is_tbd":false},{"id":"dikson-russia","name":"Dikson, Russia","country":"Russia","is_tbd":false},{"id":"nahodka-russia","name":"Nahodka, Russia","country":"Russia","is_tbd":false},{"id":"petropavlovsk-kamchatsky-russia","name":"Petropavlovsk-Kamchatsky, Russia","country":"Russia","is_tbd":false},{"id":"pevek-russia","name":"Pevek, Russia","country":"Russia","is_tbd":false},{"id":"teriberka-russia","name":"Teriberka, Russia","country":"Russia","is_tbd":false},{"id":"tiksi-russia","name":"Tiksi, Russia","country":"Russia","is_tbd":false},{"id":"vladivostok-russia","name":"Vladivostok, Russia","country":"Russia","is_tbd":false},{"id":"yuzhno-sakhalinsk-russia","name":"Yuzhno-Sakhalinsk, Russia","country":"Russia","is_tbd":false}],"notes":"The first phase linking Teriberka and Amderma was completed in October 2022. The remaining portions are scheduled to be in service between 2026 and 2028.","url":"https://xn--e1ahdckegffejda6k5a1a.xn--p1ai/en/"},"portsmouth-ryde-11":{"id":"portsmouth-ryde-11","name":"Portsmouth-Ryde 11","length":null,"rfs":"2015","rfs_year":2015,"is_planned":false,"owners":"BT","suppliers":"Hexatronic","landing_points":[{"id":"portsmouth-united-kingdom","name":"Portsmouth, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"ryde-united-kingdom","name":"Ryde, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"poseidon":{"id":"poseidon","name":"POSEIDON","length":"800 km","rfs":"2014 April","rfs_year":2014,"is_planned":false,"owners":"Ocean Specialists, Inc (OSI)","suppliers":null,"landing_points":[{"id":"pentaskhinos-cyprus","name":"Pentaskhinos, Cyprus","country":"Cyprus","is_tbd":false},{"id":"yeroskipos-cyprus","name":"Yeroskipos, Cyprus","country":"Cyprus","is_tbd":false}],"notes":null,"url":null},"prat":{"id":"prat","name":"Prat","length":"3,500 km","rfs":"2020 Q2","rfs_year":2020,"is_planned":false,"owners":"Grupo Gtd","suppliers":"Prysmian","landing_points":[{"id":"antofagasta-chile","name":"Antofagasta, Chile","country":"Chile","is_tbd":false},{"id":"arica-chile","name":"Arica, Chile","country":"Chile","is_tbd":false},{"id":"caldera-chile","name":"Caldera, Chile","country":"Chile","is_tbd":false},{"id":"cartagena-chile","name":"Cartagena, Chile","country":"Chile","is_tbd":false},{"id":"constitucin-chile","name":"Constitución, Chile","country":"Chile","is_tbd":false},{"id":"iquique-chile","name":"Iquique, Chile","country":"Chile","is_tbd":false},{"id":"la-serena-chile","name":"La Serena, Chile","country":"Chile","is_tbd":false},{"id":"puerto-montt-chile","name":"Puerto Montt, Chile","country":"Chile","is_tbd":false},{"id":"puerto-saavedra-chile","name":"Puerto Saavedra, Chile","country":"Chile","is_tbd":false},{"id":"san-pedro-de-la-paz-chile","name":"San Pedro de la Paz, Chile","country":"Chile","is_tbd":false},{"id":"tocopilla-chile","name":"Tocopilla, Chile","country":"Chile","is_tbd":false},{"id":"valparaso-chile","name":"Valparaíso, Chile","country":"Chile","is_tbd":false}],"notes":null,"url":"https://www.gtd.cl"},"project-waterworth":{"id":"project-waterworth","name":"Project Waterworth","length":"50,000 km","rfs":null,"rfs_year":null,"is_planned":true,"owners":"Meta","suppliers":null,"landing_points":[{"id":"darwin-nt-australia","name":"Darwin, NT, Australia","country":"Australia","is_tbd":true},{"id":"fortaleza-brazil","name":"Fortaleza, Brazil","country":"Brazil","is_tbd":true},{"id":"chennai-india","name":"Chennai, India","country":"India","is_tbd":true},{"id":"mumbai-india","name":"Mumbai, India","country":"India","is_tbd":true},{"id":"penang-malaysia","name":"Penang, Malaysia","country":"Malaysia","is_tbd":true},{"id":"amanzimtoti-south-africa","name":"Amanzimtoti, South Africa","country":"South Africa","is_tbd":true},{"id":"cape-town-south-africa","name":"Cape Town, South Africa","country":"South Africa","is_tbd":true},{"id":"los-angeles-ca-united-states","name":"Los Angeles, CA, United States","country":"United States","is_tbd":false},{"id":"myrtle-beach-sc-united-states","name":"Myrtle Beach, SC, United States","country":"United States","is_tbd":true}],"notes":null,"url":"https://about.meta.com/"},"proa":{"id":"proa","name":"Proa","length":"2,891 km","rfs":"2026 Q1","rfs_year":2026,"is_planned":true,"owners":"Google","suppliers":"NEC","landing_points":[{"id":"tanguisson-point-guam","name":"Tanguisson Point, Guam","country":"Guam","is_tbd":false},{"id":"shima-japan","name":"Shima, Japan","country":"Japan","is_tbd":false},{"id":"tinian-northern-mariana-islands","name":"Tinian, Northern Mariana Islands","country":"Northern Mariana Islands","is_tbd":false}],"notes":null,"url":"https://cloud.google.com"},"projeto-amaznia-conectada-pac-01":{"id":"projeto-amaznia-conectada-pac-01","name":"Projeto Amazônia Conectada (PAC 01)","length":"800 km","rfs":"2017","rfs_year":2017,"is_planned":false,"owners":"Government of Brazil","suppliers":null,"landing_points":[{"id":"coari-brazil","name":"Coari, Brazil","country":"Brazil","is_tbd":false},{"id":"iranduba-brazil","name":"Iranduba, Brazil","country":"Brazil","is_tbd":false},{"id":"manacapuru-brazil","name":"Manacapuru, Brazil","country":"Brazil","is_tbd":false},{"id":"manaus-brazil","name":"Manaus, Brazil","country":"Brazil","is_tbd":false},{"id":"novo-airo-brazil","name":"Novo Airão, Brazil","country":"Brazil","is_tbd":false},{"id":"tef-brazil","name":"Tefé, Brazil","country":"Brazil","is_tbd":false}],"notes":"PAC 01 was laid in the Amazon riverbed in a pilot initiative coordinated by the Brazilian Ministry of Defense.","url":null},"projeto-amaznia-conectada-pac-02":{"id":"projeto-amaznia-conectada-pac-02","name":"Projeto Amazônia Conectada (PAC 02)","length":"1,001 km","rfs":"2021","rfs_year":2021,"is_planned":false,"owners":"Government of Brazil","suppliers":null,"landing_points":[{"id":"barcelos-brazil","name":"Barcelos, Brazil","country":"Brazil","is_tbd":false},{"id":"moura-brazil","name":"Moura, Brazil","country":"Brazil","is_tbd":false},{"id":"novo-airo-brazil","name":"Novo Airão, Brazil","country":"Brazil","is_tbd":false},{"id":"santa-isabel-do-rio-negro-brazil","name":"Santa Isabel do Rio Negro, Brazil","country":"Brazil","is_tbd":false},{"id":"so-gabriel-da-cachoeira-brazil","name":"São Gabriel da Cachoeira, Brazil","country":"Brazil","is_tbd":false}],"notes":"PAC 02 was laid under Rio Negro through an interagency effort coordinated by the Brazilian Ministry of Defense.","url":null},"qe-north":{"id":"qe-north","name":"Q&E North","length":"112 km","rfs":"2025","rfs_year":2025,"is_planned":false,"owners":"EXA Infrastructure","suppliers":null,"landing_points":[{"id":"ostend-belgium","name":"Ostend, Belgium","country":"Belgium","is_tbd":false},{"id":"joss-bay-united-kingdom","name":"Joss Bay, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"https://exainfra.net/"},"qatar-u-a-e-submarine-cable-system":{"id":"qatar-u-a-e-submarine-cable-system","name":"Qatar-U.A.E. Submarine Cable System","length":"100 km","rfs":"2004 December","rfs_year":2004,"is_planned":false,"owners":"Ooredoo, e&","suppliers":null,"landing_points":[{"id":"doha-qatar","name":"Doha, Qatar","country":"Qatar","is_tbd":false},{"id":"halul-island-qatar","name":"Halul Island, Qatar","country":"Qatar","is_tbd":false},{"id":"abu-dhabi-united-arab-emirates","name":"Abu Dhabi, United Arab Emirates","country":"United Arab Emirates","is_tbd":false},{"id":"das-island-united-arab-emirates","name":"Das Island, United Arab Emirates","country":"United Arab Emirates","is_tbd":false}],"notes":null,"url":null},"qe-south":{"id":"qe-south","name":"Q&E South","length":"141 km","rfs":"2026 Q2","rfs_year":2026,"is_planned":true,"owners":"euNetworks","suppliers":null,"landing_points":[{"id":"cayeux-sur-mer-france","name":"Cayeux-sur-Mer, France","country":"France","is_tbd":false},{"id":"newhaven-united-kingdom","name":"Newhaven, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"r100-north":{"id":"r100-north","name":"R100 North","length":"224 km","rfs":"2023 Q1","rfs_year":2023,"is_planned":false,"owners":"BT","suppliers":"Nexans","landing_points":[{"id":"baile-mr-united-kingdom","name":"Baile Mòr, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"bay-of-london-united-kingdom","name":"Bay of London, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"belmont-united-kingdom","name":"Belmont, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"burravoe-united-kingdom","name":"Burravoe, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"crockness-united-kingdom","name":"Crockness, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"cusbay-united-kingdom","name":"Cusbay, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"evie-united-kingdom","name":"Evie, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"fair-isle-united-kingdom","name":"Fair Isle, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"fionnaphort-united-kingdom","name":"Fionnaphort, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"gump-of-spurness-united-kingdom","name":"Gump of Spurness, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"gutcher-united-kingdom","name":"Gutcher, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"hoxa-united-kingdom","name":"Hoxa, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"kiloran-bay-united-kingdom","name":"Kiloran Bay, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"laig-united-kingdom","name":"Laig, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"linksness-united-kingdom","name":"Linksness, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"morar-united-kingdom","name":"Morar, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"mossbank-united-kingdom","name":"Mossbank, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"odie-united-kingdom","name":"Odie, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"port-appin-united-kingdom","name":"Port Appin, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"port-ramsay-united-kingdom","name":"Port Ramsay, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"quoyness-united-kingdom","name":"Quoyness, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"rapness-united-kingdom","name":"Rapness, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"sandgarth-united-kingdom","name":"Sandgarth, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"scoor-united-kingdom","name":"Scoor, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"scuthvie-bay-united-kingdom","name":"Scuthvie Bay, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"skelberry-united-kingdom","name":"Skelberry, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"stove-united-kingdom","name":"Stove, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"sumburgh-united-kingdom","name":"Sumburgh, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"symbister-united-kingdom","name":"Symbister, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"weddel-united-kingdom","name":"Weddel, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"westness-united-kingdom","name":"Westness, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"https://www.scotlandsuperfast.com/r100-programme/r100-subsea-deployment/"},"quintillion-subsea-cable-network":{"id":"quintillion-subsea-cable-network","name":"Quintillion Subsea Cable Network","length":"1,900 km","rfs":"2017 December","rfs_year":2017,"is_planned":false,"owners":"Quintillion","suppliers":"ASN","landing_points":[{"id":"kotzebue-ak-united-states","name":"Kotzebue, AK, United States","country":"United States","is_tbd":false},{"id":"nome-ak-united-states","name":"Nome, AK, United States","country":"United States","is_tbd":false},{"id":"point-hope-ak-united-states","name":"Point Hope, AK, United States","country":"United States","is_tbd":false},{"id":"prudhoe-bay-ak-united-states","name":"Prudhoe Bay, AK, United States","country":"United States","is_tbd":false},{"id":"utqiavik-ak-united-states","name":"Utqiaġvik, AK, United States","country":"United States","is_tbd":false},{"id":"wainwright-ak-united-states","name":"Wainwright, AK, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.quintillionglobal.com/"},"raman":{"id":"raman","name":"Raman","length":"7,376 km","rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"Google, Sparkle, Zain Omantel International","suppliers":"ASN","landing_points":[{"id":"djibouti-city-djibouti","name":"Djibouti City, Djibouti","country":"Djibouti","is_tbd":false},{"id":"mumbai-india","name":"Mumbai, India","country":"India","is_tbd":false},{"id":"aqaba-jordan","name":"Aqaba, Jordan","country":"Jordan","is_tbd":false},{"id":"barka-oman","name":"Barka, Oman","country":"Oman","is_tbd":false},{"id":"salalah-oman","name":"Salalah, Oman","country":"Oman","is_tbd":false},{"id":"duba-saudi-arabia","name":"Duba, Saudi Arabia","country":"Saudi Arabia","is_tbd":false}],"notes":null,"url":null},"red-hook-little-saint-james":{"id":"red-hook-little-saint-james","name":"Red Hook-Little Saint James","length":"5 km","rfs":"2005","rfs_year":2005,"is_planned":false,"owners":"Virgin Islands Water and Power Authority","suppliers":"Prysmian","landing_points":[{"id":"great-bay-virgin-islands-u-s-","name":"Great Bay, Virgin Islands (U.S.)","country":"Virgin Islands (U.S.)","is_tbd":false},{"id":"little-saint-james-virgin-islands-u-s-","name":"Little Saint James, Virgin Islands (U.S.)","country":"Virgin Islands (U.S.)","is_tbd":false}],"notes":null,"url":null},"red2med":{"id":"red2med","name":"Red2Med","length":"420 km","rfs":"2023 Q1","rfs_year":2023,"is_planned":false,"owners":"Telecom Egypt","suppliers":"ASN","landing_points":[{"id":"port-said-egypt","name":"Port Said, Egypt","country":"Egypt","is_tbd":false},{"id":"ras-ghareb-egypt","name":"Ras Ghareb, Egypt","country":"Egypt","is_tbd":false},{"id":"suez-egypt","name":"Suez, Egypt","country":"Egypt","is_tbd":false},{"id":"zafarana-egypt","name":"Zafarana, Egypt","country":"Egypt","is_tbd":false}],"notes":null,"url":"https://www.te.eg/interactivemap/#/the-digital-hub"},"rnne-rdvig":{"id":"rnne-rdvig","name":"Rønne-Rødvig","length":"153 km","rfs":"1989","rfs_year":1989,"is_planned":false,"owners":"TDC Group","suppliers":null,"landing_points":[{"id":"rdvig-denmark","name":"Rødvig, Denmark","country":"Denmark","is_tbd":false},{"id":"rnne-denmark","name":"Rønne, Denmark","country":"Denmark","is_tbd":false}],"notes":null,"url":null},"rising-8":{"id":"rising-8","name":"RISING 8","length":"1,104 km","rfs":"2026 Q1","rfs_year":2026,"is_planned":true,"owners":"Moratelindo, Triasmitra","suppliers":"Norddeutsche Seekabelwerke GmbH (NSW)","landing_points":[{"id":"tanjung-bemban-indonesia","name":"Tanjung Bemban, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tanjung-pakis-indonesia","name":"Tanjung Pakis, Indonesia","country":"Indonesia","is_tbd":false},{"id":"changi-north-singapore","name":"Changi North, Singapore","country":"Singapore","is_tbd":false}],"notes":null,"url":null},"rockabill":{"id":"rockabill","name":"Rockabill","length":"221 km","rfs":"2019 November","rfs_year":2019,"is_planned":false,"owners":"euNetworks","suppliers":null,"landing_points":[{"id":"portrane-ireland","name":"Portrane, Ireland","country":"Ireland","is_tbd":false},{"id":"southport-united-kingdom","name":"Southport, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"http://www.eunetworks.com"},"rompin-tioman-island":{"id":"rompin-tioman-island","name":"Rompin-Tioman Island","length":"75 km","rfs":"2018","rfs_year":2018,"is_planned":false,"owners":"Telekom Malaysia","suppliers":null,"landing_points":[{"id":"kampung-tekek-malaysia","name":"Kampung Tekek, Malaysia","country":"Malaysia","is_tbd":false},{"id":"kuala-rompin-malaysia","name":"Kuala Rompin, Malaysia","country":"Malaysia","is_tbd":false}],"notes":null,"url":null},"romsar-2":{"id":"romsar-2","name":"ROMSAR 2","length":null,"rfs":"1996","rfs_year":1996,"is_planned":false,"owners":"Sparkle","suppliers":null,"landing_points":[{"id":"civitavecchia-italy","name":"Civitavecchia, Italy","country":"Italy","is_tbd":false},{"id":"giglio-italy","name":"Giglio, Italy","country":"Italy","is_tbd":false},{"id":"la-maddalena-italy","name":"La Maddalena, Italy","country":"Italy","is_tbd":false},{"id":"sassari-italy","name":"Sassari, Italy","country":"Italy","is_tbd":false}],"notes":null,"url":null},"romulo":{"id":"romulo","name":"Romulo","length":"237 km","rfs":"2011","rfs_year":2011,"is_planned":false,"owners":"Red Electrica","suppliers":null,"landing_points":[{"id":"sagunto-spain","name":"Sagunto, Spain","country":"Spain","is_tbd":false},{"id":"santa-ponsa-spain","name":"Santa Ponsa, Spain","country":"Spain","is_tbd":false}],"notes":"Romulo is a power cable, which has optical fiber attached to it.","url":"https://www.ree.es/"},"roquetas-melilla-cam":{"id":"roquetas-melilla-cam","name":"Roquetas-Melilla (CAM)","length":"181 km","rfs":"2014 January","rfs_year":2014,"is_planned":false,"owners":"Telefonica","suppliers":null,"landing_points":[{"id":"melilla-spain","name":"Melilla, Spain","country":"Spain","is_tbd":false},{"id":"roquetas-de-mar-spain","name":"Roquetas de Mar, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":"https://www.telefonica.com/"},"ruppione-isolella":{"id":"ruppione-isolella","name":"Ruppione-Isolella","length":"62 km","rfs":"2007","rfs_year":2007,"is_planned":false,"owners":"Corsica Haut Débit","suppliers":null,"landing_points":[{"id":"propriano-france","name":"Propriano, France","country":"France","is_tbd":false},{"id":"ruppione-france","name":"Ruppione, France","country":"France","is_tbd":false}],"notes":null,"url":null},"russia-japan-cable-network-rjcn":{"id":"russia-japan-cable-network-rjcn","name":"Russia-Japan Cable Network (RJCN)","length":"1,800 km","rfs":"2008 September","rfs_year":2008,"is_planned":false,"owners":"KDDI, Rostelecom","suppliers":"NEC","landing_points":[{"id":"joetsu-japan","name":"Joetsu, Japan","country":"Japan","is_tbd":false},{"id":"nahodka-russia","name":"Nahodka, Russia","country":"Russia","is_tbd":false}],"notes":null,"url":null},"s-u-b-cable-system":{"id":"s-u-b-cable-system","name":"S-U-B Cable System","length":"2,009 km","rfs":"2008","rfs_year":2008,"is_planned":false,"owners":"Telkom Indonesia","suppliers":"NEC","landing_points":[{"id":"banjarmasin-indonesia","name":"Banjarmasin, Indonesia","country":"Indonesia","is_tbd":false},{"id":"makassar-indonesia","name":"Makassar, Indonesia","country":"Indonesia","is_tbd":false},{"id":"surabaya-indonesia","name":"Surabaya, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":null},"saba-statia-cable-system-sscs":{"id":"saba-statia-cable-system-sscs","name":"Saba, Statia Cable System (SSCS)","length":"198 km","rfs":"2013","rfs_year":2013,"is_planned":false,"owners":"Government of the Netherlands","suppliers":null,"landing_points":[{"id":"gallows-bay-bonaire-sint-eustatius-and-saba","name":"Gallows Bay, Bonaire, Sint Eustatius and Saba","country":"Bonaire, Sint Eustatius and Saba","is_tbd":false},{"id":"great-level-bay-bonaire-sint-eustatius-and-saba","name":"Great Level Bay, Bonaire, Sint Eustatius and Saba","country":"Bonaire, Sint Eustatius and Saba","is_tbd":false},{"id":"gustavia-saint-barthlemy","name":"Gustavia, Saint Barthélemy","country":"Saint Barthélemy","is_tbd":false},{"id":"basseterre-saint-kitts-and-nevis","name":"Basseterre, Saint Kitts and Nevis","country":"Saint Kitts and Nevis","is_tbd":false},{"id":"great-bay-beach-sint-maarten","name":"Great Bay Beach, Sint Maarten","country":"Sint Maarten","is_tbd":false}],"notes":null,"url":"http://www.sscsbv.com"},"safe":{"id":"safe","name":"SAFE","length":"13,500 km","rfs":"2002 April","rfs_year":2002,"is_planned":false,"owners":"AT&T, Angola Telecom, BICS, Camtel, China Telecom, Chunghwa Telecom, Cogent, Ghana Telecommunications Company, KPN, Liquid Intelligent Technologies, Maroc Telecom, Mauritius Telecom, NATCOM (Nigeria), OPT, Orange, Orange Cote d’Ivoire, PCCW, Singtel, Sonatel, Sparkle, Tata Communications, Telecom Namibia, Telefonica, Telekom Malaysia, Telkom South Africa, Telstra, Verizon, Vodafone","suppliers":"SubCom","landing_points":[{"id":"kochi-india","name":"Kochi, India","country":"India","is_tbd":false},{"id":"penang-malaysia","name":"Penang, Malaysia","country":"Malaysia","is_tbd":false},{"id":"baie-jacotet-mauritius","name":"Baie Jacotet, Mauritius","country":"Mauritius","is_tbd":false},{"id":"saint-paul-runion","name":"Saint Paul, Réunion","country":"Réunion","is_tbd":false},{"id":"melkbosstrand-south-africa","name":"Melkbosstrand, South Africa","country":"South Africa","is_tbd":false},{"id":"mtunzini-south-africa","name":"Mtunzini, South Africa","country":"South Africa","is_tbd":false}],"notes":null,"url":null},"sakhalin-kuril-islands-cable":{"id":"sakhalin-kuril-islands-cable","name":"Sakhalin-Kuril Islands Cable","length":"940 km","rfs":"2019 March","rfs_year":2019,"is_planned":false,"owners":"Rostelecom","suppliers":"HMN Tech","landing_points":[{"id":"krabozavodskoye-russia","name":"Krabozavodskoye, Russia","country":"Russia","is_tbd":false},{"id":"kurilsk-russia","name":"Kurilsk, Russia","country":"Russia","is_tbd":false},{"id":"okhotskoe-russia","name":"Okhotskoe, Russia","country":"Russia","is_tbd":false},{"id":"yuzhno-kurilsk-russia","name":"Yuzhno Kurilsk, Russia","country":"Russia","is_tbd":false}],"notes":null,"url":"https://www.company.rt.ru/"},"sagres":{"id":"sagres","name":"Sagres","length":"302 km","rfs":"1998 July","rfs_year":1998,"is_planned":false,"owners":"Altice Portugal","suppliers":null,"landing_points":[{"id":"burgau-portugal","name":"Burgau, Portugal","country":"Portugal","is_tbd":false},{"id":"sesimbra-portugal","name":"Sesimbra, Portugal","country":"Portugal","is_tbd":false}],"notes":null,"url":null},"samoa-american-samoa-sas":{"id":"samoa-american-samoa-sas","name":"Samoa-American Samoa (SAS)","length":"250 km","rfs":"2009 May","rfs_year":2009,"is_planned":false,"owners":"American Samoa Government, Elandia","suppliers":null,"landing_points":[{"id":"pago-pago-american-samoa","name":"Pago Pago, American Samoa","country":"American Samoa","is_tbd":false},{"id":"apia-samoa","name":"Apia, Samoa","country":"Samoa","is_tbd":false}],"notes":null,"url":null},"san-andres-isla-tolu-submarine-cable-sait":{"id":"san-andres-isla-tolu-submarine-cable-sait","name":"San Andres Isla Tolu Submarine Cable (SAIT)","length":"826 km","rfs":"2010 December","rfs_year":2010,"is_planned":false,"owners":"Energía Integral Andina","suppliers":null,"landing_points":[{"id":"san-andres-colombia","name":"San Andres, Colombia","country":"Colombia","is_tbd":false},{"id":"tolu-colombia","name":"Tolu, Colombia","country":"Colombia","is_tbd":false}],"notes":null,"url":null},"sape-labuan-bajo-ende-kupang":{"id":"sape-labuan-bajo-ende-kupang","name":"Sape-Labuan Bajo-Ende-Kupang","length":"474 km","rfs":"2021 Q1","rfs_year":2021,"is_planned":false,"owners":"Moratelindo","suppliers":null,"landing_points":[{"id":"ende-indonesia","name":"Ende, Indonesia","country":"Indonesia","is_tbd":false},{"id":"kupang-indonesia","name":"Kupang, Indonesia","country":"Indonesia","is_tbd":false},{"id":"labuan-bajo-indonesia","name":"Labuan Bajo, Indonesia","country":"Indonesia","is_tbd":false},{"id":"sape-indonesia","name":"Sape, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"http://moratelindo.co.id"},"sarco":{"id":"sarco","name":"SARCO","length":null,"rfs":"2006","rfs_year":2006,"is_planned":false,"owners":"Free","suppliers":null,"landing_points":[{"id":"bonifacio-france","name":"Bonifacio, France","country":"France","is_tbd":false},{"id":"santa-teresa-gallura-italy","name":"Santa Teresa Gallura, Italy","country":"Italy","is_tbd":false}],"notes":null,"url":null},"sat-3wasc":{"id":"sat-3wasc","name":"SAT-3/WASC","length":"14,350 km","rfs":"2002 April","rfs_year":2002,"is_planned":false,"owners":"AT&T, Altice Portugal, Angola Telecom, BICS, BT, Camtel, China Telecom, Chunghwa Telecom, Cogent, Cyta, Deutsche Telekom, Ghana Telecommunications Company, KPN, KT, Liquid Intelligent Technologies, Maroc Telecom, Mauritius Telecom, NATCOM (Nigeria), OPT, Orange, Orange Cote d’Ivoire, PCCW, SBIN (La Société Béninoise d’Infrastructures Numériques), Singtel, Sparkle, Tata Communications, Telecom Namibia, Telekom Malaysia, Telkom South Africa, Telstra, Telxius, Verizon, Vodafone","suppliers":"ASN","landing_points":[{"id":"cacuaco-angola","name":"Cacuaco, Angola","country":"Angola","is_tbd":false},{"id":"cotonou-benin","name":"Cotonou, Benin","country":"Benin","is_tbd":false},{"id":"douala-cameroon","name":"Douala, Cameroon","country":"Cameroon","is_tbd":false},{"id":"abidjan-cte-divoire","name":"Abidjan, Côte d'Ivoire","country":"Côte d'Ivoire","is_tbd":false},{"id":"libreville-gabon","name":"Libreville, Gabon","country":"Gabon","is_tbd":false},{"id":"accra-ghana","name":"Accra, Ghana","country":"Ghana","is_tbd":false},{"id":"lagos-nigeria","name":"Lagos, Nigeria","country":"Nigeria","is_tbd":false},{"id":"sesimbra-portugal","name":"Sesimbra, Portugal","country":"Portugal","is_tbd":false},{"id":"dakar-senegal","name":"Dakar, Senegal","country":"Senegal","is_tbd":false},{"id":"melkbosstrand-south-africa","name":"Melkbosstrand, South Africa","country":"South Africa","is_tbd":false},{"id":"alta-vista-canary-islands-spain","name":"Alta Vista, Canary Islands, Spain","country":"Spain","is_tbd":false},{"id":"chipiona-spain","name":"Chipiona, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":null},"saudi-arabia-sudan-2-sas-2":{"id":"saudi-arabia-sudan-2-sas-2","name":"Saudi Arabia-Sudan-2 (SAS-2)","length":"330 km","rfs":"2011 July","rfs_year":2011,"is_planned":false,"owners":"Sudatel, center3","suppliers":null,"landing_points":[{"id":"jeddah-saudi-arabia","name":"Jeddah, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"port-sudan-sudan","name":"Port Sudan, Sudan","country":"Sudan","is_tbd":false}],"notes":null,"url":null},"saudi-arabia-sudan-1-sas-1":{"id":"saudi-arabia-sudan-1-sas-1","name":"Saudi Arabia-Sudan-1 (SAS-1)","length":"333 km","rfs":"2003 April","rfs_year":2003,"is_planned":false,"owners":"Sudatel, The Arab Investment Company, center3","suppliers":"ASN","landing_points":[{"id":"jeddah-saudi-arabia","name":"Jeddah, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"port-sudan-sudan","name":"Port Sudan, Sudan","country":"Sudan","is_tbd":false}],"notes":null,"url":null},"saudi-vision":{"id":"saudi-vision","name":"Saudi Vision","length":"1,071 km","rfs":"2023 May","rfs_year":2023,"is_planned":false,"owners":"center3","suppliers":"ASN","landing_points":[{"id":"duba-saudi-arabia","name":"Duba, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"haql-saudi-arabia","name":"Haql, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"jeddah-saudi-arabia","name":"Jeddah, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"yanbu-saudi-arabia","name":"Yanbu, Saudi Arabia","country":"Saudi Arabia","is_tbd":false}],"notes":null,"url":"https://center3.com/"},"scandinavian-ring-north":{"id":"scandinavian-ring-north","name":"Scandinavian Ring North","length":"5 km","rfs":"2000","rfs_year":2000,"is_planned":false,"owners":"Arelion","suppliers":"Ericsson","landing_points":[{"id":"helsingr-denmark","name":"Helsingør, Denmark","country":"Denmark","is_tbd":false},{"id":"helsingborg-sweden","name":"Helsingborg, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":null},"scotland-northern-ireland-4":{"id":"scotland-northern-ireland-4","name":"Scotland-Northern Ireland 4","length":"85 km","rfs":"2022","rfs_year":2022,"is_planned":false,"owners":"BT","suppliers":null,"landing_points":[{"id":"girvan-united-kingdom","name":"Girvan, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"larne-united-kingdom","name":"Larne, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"scandinavian-ring-south":{"id":"scandinavian-ring-south","name":"Scandinavian Ring South","length":"21 km","rfs":"2000","rfs_year":2000,"is_planned":false,"owners":"Arelion","suppliers":"Ericsson","landing_points":[{"id":"dragor-denmark","name":"Dragor, Denmark","country":"Denmark","is_tbd":false},{"id":"bunkeflostand-sweden","name":"Bunkeflostand, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":null},"sea-h2x":{"id":"sea-h2x","name":"SEA-H2X","length":"6,000 km","rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"China Mobile, China Unicom, Converge ICT","suppliers":"HMN Tech","landing_points":[{"id":"lingshui-china","name":"Lingshui, China","country":"China","is_tbd":false},{"id":"tseung-kwan-o-china","name":"Tseung Kwan O, China","country":"China","is_tbd":false},{"id":"kuching-malaysia","name":"Kuching, Malaysia","country":"Malaysia","is_tbd":true},{"id":"la-union-philippines","name":"La Union, Philippines","country":"Philippines","is_tbd":false},{"id":"tuas-singapore","name":"Tuas, Singapore","country":"Singapore","is_tbd":false},{"id":"songkhla-thailand","name":"Songkhla, Thailand","country":"Thailand","is_tbd":false}],"notes":null,"url":null},"sea-us":{"id":"sea-us","name":"SEA-US","length":"14,500 km","rfs":"2017 August","rfs_year":2017,"is_planned":false,"owners":"GTA TeleGuam, Globe Telecom, Hawaiian Telcom, Lightstorm Telecom, Telin","suppliers":"NEC","landing_points":[{"id":"piti-guam","name":"Piti, Guam","country":"Guam","is_tbd":false},{"id":"kauditan-indonesia","name":"Kauditan, Indonesia","country":"Indonesia","is_tbd":false},{"id":"magachgil-yap-micronesia","name":"Magachgil, Yap, Micronesia","country":"Micronesia","is_tbd":false},{"id":"ngeremlengui-palau","name":"Ngeremlengui, Palau","country":"Palau","is_tbd":false},{"id":"davao-philippines","name":"Davao, Philippines","country":"Philippines","is_tbd":false},{"id":"hermosa-beach-ca-united-states","name":"Hermosa Beach, CA, United States","country":"United States","is_tbd":false},{"id":"makaha-hi-united-states","name":"Makaha, HI, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"scylla":{"id":"scylla","name":"Scylla","length":"204 km","rfs":"2021 August","rfs_year":2021,"is_planned":false,"owners":"euNetworks","suppliers":null,"landing_points":[{"id":"ijmuiden-netherlands","name":"Ijmuiden, Netherlands","country":"Netherlands","is_tbd":false},{"id":"lowestoft-united-kingdom","name":"Lowestoft, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"http://www.eunetworks.com"},"sea2shore":{"id":"sea2shore","name":"sea2shore","length":"32 km","rfs":"2016","rfs_year":2016,"is_planned":false,"owners":"National Grid","suppliers":null,"landing_points":[{"id":"crescent-beach-ri-united-states","name":"Crescent Beach, RI, United States","country":"United States","is_tbd":false},{"id":"narragansett-ri-united-states","name":"Narragansett, RI, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"seabras-1":{"id":"seabras-1","name":"Seabras-1","length":"10,800 km","rfs":"2017 September","rfs_year":2017,"is_planned":false,"owners":"Seaborn Networks, Sparkle","suppliers":"ASN","landing_points":[{"id":"praia-grande-brazil","name":"Praia Grande, Brazil","country":"Brazil","is_tbd":false},{"id":"wall-township-nj-united-states","name":"Wall Township, NJ, United States","country":"United States","is_tbd":false}],"notes":null,"url":"http://www.seabornnetworks.com"},"seacomtata-tgn-eurasia":{"id":"seacomtata-tgn-eurasia","name":"SEACOM/Tata TGN-Eurasia","length":"15,000 km","rfs":"2009 July","rfs_year":2009,"is_planned":false,"owners":"SEACOM, Tata Communications","suppliers":"SubCom","landing_points":[{"id":"djibouti-city-djibouti","name":"Djibouti City, Djibouti","country":"Djibouti","is_tbd":false},{"id":"zafarana-egypt","name":"Zafarana, Egypt","country":"Egypt","is_tbd":false},{"id":"mumbai-india","name":"Mumbai, India","country":"India","is_tbd":false},{"id":"mombasa-kenya","name":"Mombasa, Kenya","country":"Kenya","is_tbd":false},{"id":"maputo-mozambique","name":"Maputo, Mozambique","country":"Mozambique","is_tbd":false},{"id":"jeddah-saudi-arabia","name":"Jeddah, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"mtunzini-south-africa","name":"Mtunzini, South Africa","country":"South Africa","is_tbd":false},{"id":"dar-es-salaam-tanzania","name":"Dar Es Salaam, Tanzania","country":"Tanzania","is_tbd":false}],"notes":"SEACOM owns the entire East African portion of the system and two fiber pairs between Egypt and India. Tata Communications owns two fiber pairs from Egypt to India and the branch to Jeddah, which the company refers to as TGN-Eurasia. Both SEACOM and Tata Communications own fiber pairs on TE North for connectivity across Egypt to Europe.","url":"http://www.seacom.mu"},"sealink":{"id":"sealink","name":"SEALink","length":"345 km","rfs":"2023 Q1","rfs_year":2023,"is_planned":false,"owners":"Alaska Power & Telephone Company Wireless (APTW)","suppliers":"Prysmian","landing_points":[{"id":"coffman-cove-ak-united-states","name":"Coffman Cove, AK, United States","country":"United States","is_tbd":false},{"id":"lena-point-ak-united-states","name":"Lena Point, AK, United States","country":"United States","is_tbd":false},{"id":"petersburg-ak-united-states","name":"Petersburg, AK, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.aptalaska.com/"},"sealink-south":{"id":"sealink-south","name":"SEALink South","length":"159 km","rfs":"2024 November","rfs_year":2024,"is_planned":false,"owners":"Alaska Power & Telephone Company Wireless (APTW)","suppliers":null,"landing_points":[{"id":"coffman-cove-ak-united-states","name":"Coffman Cove, AK, United States","country":"United States","is_tbd":false},{"id":"hollis-ak-united-states","name":"Hollis, AK, United States","country":"United States","is_tbd":false},{"id":"ketchikan-ak-united-states","name":"Ketchikan, AK, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.aptalaska.com/"},"seamewe-4":{"id":"seamewe-4","name":"SeaMeWe-4","length":"20,000 km","rfs":"2005 December","rfs_year":2005,"is_planned":false,"owners":"Algerie Telecom, Bangladesh Submarine Cable Company Limited (BSCCL), Bharti Airtel, National Telecom, Orange, Pakistan Telecommunications Company Ltd., Singtel, Sparkle, Sri Lanka Telecom, Tata Communications, Telecom Egypt, Telekom Malaysia, Tunisia Telecom, Verizon, center3, e&","suppliers":"ASN, Fujitsu","landing_points":[{"id":"annaba-algeria","name":"Annaba, Algeria","country":"Algeria","is_tbd":false},{"id":"coxs-bazar-bangladesh","name":"Cox’s Bazar, Bangladesh","country":"Bangladesh","is_tbd":false},{"id":"alexandria-egypt","name":"Alexandria, Egypt","country":"Egypt","is_tbd":false},{"id":"suez-egypt","name":"Suez, Egypt","country":"Egypt","is_tbd":false},{"id":"marseille-france","name":"Marseille, France","country":"France","is_tbd":false},{"id":"chennai-india","name":"Chennai, India","country":"India","is_tbd":false},{"id":"mumbai-india","name":"Mumbai, India","country":"India","is_tbd":false},{"id":"palermo-italy","name":"Palermo, Italy","country":"Italy","is_tbd":false},{"id":"melaka-malaysia","name":"Melaka, Malaysia","country":"Malaysia","is_tbd":false},{"id":"karachi-pakistan","name":"Karachi, Pakistan","country":"Pakistan","is_tbd":false},{"id":"jeddah-saudi-arabia","name":"Jeddah, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"tuas-singapore","name":"Tuas, Singapore","country":"Singapore","is_tbd":false},{"id":"colombo-sri-lanka","name":"Colombo, Sri Lanka","country":"Sri Lanka","is_tbd":false},{"id":"satun-thailand","name":"Satun, Thailand","country":"Thailand","is_tbd":false},{"id":"bizerte-tunisia","name":"Bizerte, Tunisia","country":"Tunisia","is_tbd":false},{"id":"fujairah-united-arab-emirates","name":"Fujairah, United Arab Emirates","country":"United Arab Emirates","is_tbd":false}],"notes":null,"url":"https://www.seamewe4.net/"},"seamewe-6":{"id":"seamewe-6","name":"SeaMeWe-6","length":"21,700 km","rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"Bahrain Telecommunications Company (Batelco), Bangladesh Submarine Cable Company Limited (BSCCL), Bharti Airtel, China Unicom, Dhiraagu, Djibouti Telecom, Microsoft, Mobily, Orange, PCCW, Singtel, Sri Lanka Telecom, Telecom Egypt, Telekom Malaysia, Telin, Transworld","suppliers":"SubCom","landing_points":[{"id":"manama-bahrain","name":"Manama, Bahrain","country":"Bahrain","is_tbd":false},{"id":"coxs-bazar-bangladesh","name":"Cox’s Bazar, Bangladesh","country":"Bangladesh","is_tbd":false},{"id":"djibouti-city-djibouti","name":"Djibouti City, Djibouti","country":"Djibouti","is_tbd":false},{"id":"port-said-egypt","name":"Port Said, Egypt","country":"Egypt","is_tbd":false},{"id":"ras-ghareb-egypt","name":"Ras Ghareb, Egypt","country":"Egypt","is_tbd":false},{"id":"marseille-france","name":"Marseille, France","country":"France","is_tbd":false},{"id":"chennai-india","name":"Chennai, India","country":"India","is_tbd":false},{"id":"mumbai-india","name":"Mumbai, India","country":"India","is_tbd":false},{"id":"morib-malaysia","name":"Morib, Malaysia","country":"Malaysia","is_tbd":false},{"id":"hulhumale-maldives","name":"Hulhumale, Maldives","country":"Maldives","is_tbd":false},{"id":"muscat-oman","name":"Muscat, Oman","country":"Oman","is_tbd":false},{"id":"karachi-pakistan","name":"Karachi, Pakistan","country":"Pakistan","is_tbd":false},{"id":"doha-qatar","name":"Doha, Qatar","country":"Qatar","is_tbd":false},{"id":"yanbu-saudi-arabia","name":"Yanbu, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"tuas-singapore","name":"Tuas, Singapore","country":"Singapore","is_tbd":false},{"id":"matara-sri-lanka","name":"Matara, Sri Lanka","country":"Sri Lanka","is_tbd":false},{"id":"abu-dhabi-united-arab-emirates","name":"Abu Dhabi, United Arab Emirates","country":"United Arab Emirates","is_tbd":false}],"notes":"SeaMeWe-6's branch into the Gulf extends to Bahrain. The landings in Bahrain, Oman, Qatar, and the United Arab Emirates along with additional fiber pairs on the trunk from Bahrain to Oman are fully owned by Batelco, which the company refers to as Al Khaleej. Airtel has privately owned pairs extending from Singapore to Chennai and Mumbai.","url":null},"seamewe-5":{"id":"seamewe-5","name":"SeaMeWe-5","length":"20,000 km","rfs":"2016 December","rfs_year":2016,"is_planned":false,"owners":"Bangladesh Submarine Cable Company Limited (BSCCL), China Mobile, China Telecom, China Unicom, Djibouti Telecom, Myanmar Post and Telecommunication (MPT), Ooredoo, Orange, Singtel, Sparkle, Sri Lanka Telecom, TeleYemen, Telecom Egypt, Telekom Malaysia, Telkom Indonesia, Transworld, center3, du","suppliers":"ASN, NEC","landing_points":[{"id":"kuakata-bangladesh","name":"Kuakata, Bangladesh","country":"Bangladesh","is_tbd":false},{"id":"haramous-djibouti","name":"Haramous, Djibouti","country":"Djibouti","is_tbd":false},{"id":"abu-talat-egypt","name":"Abu Talat, Egypt","country":"Egypt","is_tbd":false},{"id":"zafarana-egypt","name":"Zafarana, Egypt","country":"Egypt","is_tbd":false},{"id":"toulon-france","name":"Toulon, France","country":"France","is_tbd":false},{"id":"dumai-indonesia","name":"Dumai, Indonesia","country":"Indonesia","is_tbd":false},{"id":"medan-indonesia","name":"Medan, Indonesia","country":"Indonesia","is_tbd":false},{"id":"catania-italy","name":"Catania, Italy","country":"Italy","is_tbd":false},{"id":"melaka-malaysia","name":"Melaka, Malaysia","country":"Malaysia","is_tbd":false},{"id":"ngwe-saung-myanmar","name":"Ngwe Saung, Myanmar","country":"Myanmar","is_tbd":false},{"id":"qalhat-oman","name":"Qalhat, Oman","country":"Oman","is_tbd":false},{"id":"karachi-pakistan","name":"Karachi, Pakistan","country":"Pakistan","is_tbd":false},{"id":"yanbu-saudi-arabia","name":"Yanbu, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"tuas-singapore","name":"Tuas, Singapore","country":"Singapore","is_tbd":false},{"id":"matara-sri-lanka","name":"Matara, Sri Lanka","country":"Sri Lanka","is_tbd":false},{"id":"marmaris-turkey","name":"Marmaris, Turkey","country":"Turkey","is_tbd":false},{"id":"fujairah-united-arab-emirates","name":"Fujairah, United Arab Emirates","country":"United Arab Emirates","is_tbd":false},{"id":"al-hudaydah-yemen","name":"Al Hudaydah, Yemen","country":"Yemen","is_tbd":false}],"notes":null,"url":null},"segunda-fos-canal-de-chacao":{"id":"segunda-fos-canal-de-chacao","name":"Segunda FOS Canal de Chacao","length":"40 km","rfs":"2015 January","rfs_year":2015,"is_planned":false,"owners":"Grupo Gtd","suppliers":null,"landing_points":[{"id":"linao-chile","name":"Linao, Chile","country":"Chile","is_tbd":false},{"id":"meimen-chile","name":"Meimen, Chile","country":"Chile","is_tbd":false}],"notes":null,"url":"https://www.gtd.cl"},"seax-1":{"id":"seax-1","name":"SEAX-1","length":"250 km","rfs":"2018 May","rfs_year":2018,"is_planned":false,"owners":"SEAX","suppliers":"HMN Tech","landing_points":[{"id":"batam-indonesia","name":"Batam, Indonesia","country":"Indonesia","is_tbd":false},{"id":"mersing-malaysia","name":"Mersing, Malaysia","country":"Malaysia","is_tbd":false},{"id":"tanah-merah-singapore","name":"Tanah Merah, Singapore","country":"Singapore","is_tbd":false}],"notes":null,"url":"https://seax.net/"},"senegal-horn-of-africa-regional-express-share-cable":{"id":"senegal-horn-of-africa-regional-express-share-cable","name":"Senegal Horn of Africa Regional Express (SHARE) Cable","length":"720 km","rfs":"2023 Q3","rfs_year":2023,"is_planned":false,"owners":"Agence De L’informatique del’Etat","suppliers":"HMN Tech","landing_points":[{"id":"praia-cape-verde","name":"Praia, Cape Verde","country":"Cape Verde","is_tbd":false},{"id":"dakar-senegal","name":"Dakar, Senegal","country":"Senegal","is_tbd":false}],"notes":null,"url":"https://adie.sn/"},"seychelles-to-east-africa-system-seas":{"id":"seychelles-to-east-africa-system-seas","name":"Seychelles to East Africa System (SEAS)","length":"1,930 km","rfs":"2012 August","rfs_year":2012,"is_planned":false,"owners":"Seychelles Cable System Ltd.","suppliers":"ASN","landing_points":[{"id":"victoria-seychelles","name":"Victoria, Seychelles","country":"Seychelles","is_tbd":false},{"id":"dar-es-salaam-tanzania","name":"Dar Es Salaam, Tanzania","country":"Tanzania","is_tbd":false}],"notes":null,"url":null},"shefa-2":{"id":"shefa-2","name":"SHEFA-2","length":"1,000 km","rfs":"2008 March","rfs_year":2008,"is_planned":false,"owners":"Shefa","suppliers":"Xtera","landing_points":[{"id":"torshavn-faroe-islands","name":"Torshavn, Faroe Islands","country":"Faroe Islands","is_tbd":false},{"id":"ayre-of-cara-united-kingdom","name":"Ayre of Cara, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"banff-united-kingdom","name":"Banff, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"bp-clair-ridge-united-kingdom","name":"BP Clair Ridge, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"glen-lyon-united-kingdom","name":"Glen Lyon, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"maywick-united-kingdom","name":"Maywick, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"sandwick-united-kingdom","name":"Sandwick, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"http://www.shefa.fo"},"sihanoukville-hong-kong-shv-hk":{"id":"sihanoukville-hong-kong-shv-hk","name":"Sihanoukville-Hong Kong (SHV-HK)","length":"2,938 km","rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"Government of Cambodia","suppliers":"HMN Tech","landing_points":[{"id":"sihanoukville-cambodia","name":"Sihanoukville, Cambodia","country":"Cambodia","is_tbd":false},{"id":"tseung-kwan-o-china","name":"Tseung Kwan O, China","country":"China","is_tbd":false}],"notes":null,"url":null},"silphium":{"id":"silphium","name":"Silphium","length":"425 km","rfs":"2013 January","rfs_year":2013,"is_planned":false,"owners":"Libya International Telecommunications Company","suppliers":"HMN Tech","landing_points":[{"id":"chania-greece","name":"Chania, Greece","country":"Greece","is_tbd":false},{"id":"derna-libya","name":"Derna, Libya","country":"Libya","is_tbd":false}],"notes":null,"url":"http://www.litc.ly"},"sint-maarten-puerto-rico-network-one-smpr-1":{"id":"sint-maarten-puerto-rico-network-one-smpr-1","name":"Sint Maarten Puerto Rico Network One (SMPR-1)","length":"375 km","rfs":"2004 December","rfs_year":2004,"is_planned":false,"owners":"Dauphin Telecom, TelEm Group","suppliers":null,"landing_points":[{"id":"baie-longue-saint-martin","name":"Baie Longue, Saint Martin","country":"Saint Martin","is_tbd":false},{"id":"philipsburg-sint-maarten","name":"Philipsburg, Sint Maarten","country":"Sint Maarten","is_tbd":false},{"id":"isla-verde-pr-united-states","name":"Isla Verde, PR, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"sir-abu-nuayr-cable":{"id":"sir-abu-nuayr-cable","name":"Sir Abu Nu’ayr Cable","length":"84 km","rfs":"2018","rfs_year":2018,"is_planned":false,"owners":"e&","suppliers":null,"landing_points":[{"id":"sharjah-united-arab-emirates","name":"Sharjah, United Arab Emirates","country":"United Arab Emirates","is_tbd":false},{"id":"sir-abu-nuayr-island-united-arab-emirates","name":"Sir Abu Nu'Ayr Island, United Arab Emirates","country":"United Arab Emirates","is_tbd":false}],"notes":null,"url":"https://www.etisalat.ae/"},"scotland-northern-ireland-3":{"id":"scotland-northern-ireland-3","name":"Scotland-Northern Ireland 3","length":"42 km","rfs":"2022","rfs_year":2022,"is_planned":false,"owners":"BT","suppliers":null,"landing_points":[{"id":"donaghadee-united-kingdom","name":"Donaghadee, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"portpatrick-united-kingdom","name":"Portpatrick, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"sirius-north":{"id":"sirius-north","name":"Sirius North","length":"147 km","rfs":"1999","rfs_year":1999,"is_planned":false,"owners":"Virgin Media Business","suppliers":null,"landing_points":[{"id":"carrickfergus-united-kingdom","name":"Carrickfergus, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"saltcoats-united-kingdom","name":"Saltcoats, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"sirius-south":{"id":"sirius-south","name":"Sirius South","length":"219 km","rfs":"1999","rfs_year":1999,"is_planned":false,"owners":"Virgin Media Business","suppliers":null,"landing_points":[{"id":"dublin-ireland","name":"Dublin, Ireland","country":"Ireland","is_tbd":false},{"id":"blackpool-united-kingdom","name":"Blackpool, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"sjjk":{"id":"sjjk","name":"SJJK","length":"543 km","rfs":"2008 December","rfs_year":2008,"is_planned":false,"owners":"XLSmart","suppliers":null,"landing_points":[{"id":"anyer-indonesia","name":"Anyer, Indonesia","country":"Indonesia","is_tbd":false},{"id":"bawean-indonesia","name":"Bawean, Indonesia","country":"Indonesia","is_tbd":false},{"id":"kalianda-indonesia","name":"Kalianda, Indonesia","country":"Indonesia","is_tbd":false},{"id":"takesung-indonesia","name":"Takesung, Indonesia","country":"Indonesia","is_tbd":false},{"id":"ujung-pankah-indonesia","name":"Ujung Pankah, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"https://www.xl.co.id"},"sistem-kabel-rakyat-1malaysia-skr1m":{"id":"sistem-kabel-rakyat-1malaysia-skr1m","name":"Sistem Kabel Rakyat 1Malaysia (SKR1M)","length":"3,800 km","rfs":"2017 September","rfs_year":2017,"is_planned":false,"owners":"TIME dotCom, Telekom Malaysia","suppliers":"NEC","landing_points":[{"id":"bintulu-malaysia","name":"Bintulu, Malaysia","country":"Malaysia","is_tbd":false},{"id":"cherating-malaysia","name":"Cherating, Malaysia","country":"Malaysia","is_tbd":false},{"id":"kota-kinabalu-malaysia","name":"Kota Kinabalu, Malaysia","country":"Malaysia","is_tbd":false},{"id":"kuching-malaysia","name":"Kuching, Malaysia","country":"Malaysia","is_tbd":false},{"id":"mersing-malaysia","name":"Mersing, Malaysia","country":"Malaysia","is_tbd":false},{"id":"miri-malaysia","name":"Miri, Malaysia","country":"Malaysia","is_tbd":false}],"notes":null,"url":null},"skagenfiber-west":{"id":"skagenfiber-west","name":"Skagenfiber West","length":"170 km","rfs":"2020 November","rfs_year":2020,"is_planned":false,"owners":"Altibox","suppliers":null,"landing_points":[{"id":"hirtshals-denmark","name":"Hirtshals, Denmark","country":"Denmark","is_tbd":false},{"id":"larvik-norway","name":"Larvik, Norway","country":"Norway","is_tbd":false}],"notes":null,"url":"http://www.skagenfiber.no"},"skagerrak-4":{"id":"skagerrak-4","name":"Skagerrak 4","length":"137 km","rfs":"2014 December","rfs_year":2014,"is_planned":false,"owners":"Statnett","suppliers":null,"landing_points":[{"id":"tjele-denmark","name":"Tjele, Denmark","country":"Denmark","is_tbd":false},{"id":"kristiansand-norway","name":"Kristiansand, Norway","country":"Norway","is_tbd":false}],"notes":null,"url":null},"smpcs-packet-1":{"id":"smpcs-packet-1","name":"SMPCS Packet-1","length":"3,156 km","rfs":"2015","rfs_year":2015,"is_planned":false,"owners":"Telkom Indonesia","suppliers":"ASN","landing_points":[{"id":"ambon-indonesia","name":"Ambon, Indonesia","country":"Indonesia","is_tbd":false},{"id":"bandaneria-indonesia","name":"Bandaneria, Indonesia","country":"Indonesia","is_tbd":false},{"id":"fakfak-indonesia","name":"Fakfak, Indonesia","country":"Indonesia","is_tbd":false},{"id":"kendari-indonesia","name":"Kendari, Indonesia","country":"Indonesia","is_tbd":false},{"id":"labuha-indonesia","name":"Labuha, Indonesia","country":"Indonesia","is_tbd":false},{"id":"manado-indonesia","name":"Manado, Indonesia","country":"Indonesia","is_tbd":false},{"id":"masohi-indonesia","name":"Masohi, Indonesia","country":"Indonesia","is_tbd":false},{"id":"namlea-indonesia","name":"Namlea, Indonesia","country":"Indonesia","is_tbd":false},{"id":"sanana-indonesia","name":"Sanana, Indonesia","country":"Indonesia","is_tbd":false},{"id":"sofifi-indonesia","name":"Sofifi, Indonesia","country":"Indonesia","is_tbd":false},{"id":"sorong-indonesia","name":"Sorong, Indonesia","country":"Indonesia","is_tbd":false},{"id":"ternate-indonesia","name":"Ternate, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":null},"smpcs-packet-2":{"id":"smpcs-packet-2","name":"SMPCS Packet-2","length":"3,498 km","rfs":"2015","rfs_year":2015,"is_planned":false,"owners":"Telkom Indonesia","suppliers":"NEC","landing_points":[{"id":"biak-indonesia","name":"Biak, Indonesia","country":"Indonesia","is_tbd":false},{"id":"fakfak-indonesia","name":"Fakfak, Indonesia","country":"Indonesia","is_tbd":false},{"id":"jayapura-indonesia","name":"Jayapura, Indonesia","country":"Indonesia","is_tbd":false},{"id":"kaimana-indonesia","name":"Kaimana, Indonesia","country":"Indonesia","is_tbd":false},{"id":"manokwari-indonesia","name":"Manokwari, Indonesia","country":"Indonesia","is_tbd":false},{"id":"merauke-indonesia","name":"Merauke, Indonesia","country":"Indonesia","is_tbd":false},{"id":"sarmi-indonesia","name":"Sarmi, Indonesia","country":"Indonesia","is_tbd":false},{"id":"sorong-indonesia","name":"Sorong, Indonesia","country":"Indonesia","is_tbd":false},{"id":"timika-indonesia","name":"Timika, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tual-indonesia","name":"Tual, Indonesia","country":"Indonesia","is_tbd":false},{"id":"waisai-indonesia","name":"Waisai, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":null},"sol":{"id":"sol","name":"Sol","length":"8,153 km","rfs":"2027","rfs_year":2027,"is_planned":true,"owners":"Google","suppliers":"SubCom","landing_points":[{"id":"annies-bay-bermuda","name":"Annie's Bay, Bermuda","country":"Bermuda","is_tbd":false},{"id":"so-miguel-portugal","name":"São Miguel, Portugal","country":"Portugal","is_tbd":false},{"id":"santander-spain","name":"Santander, Spain","country":"Spain","is_tbd":false},{"id":"palm-coast-fl-united-states","name":"Palm Coast, FL, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://cloud.google.com"},"solas":{"id":"solas","name":"Solas","length":"232 km","rfs":"1999 April","rfs_year":1999,"is_planned":false,"owners":"Vodafone, eir","suppliers":null,"landing_points":[{"id":"kilmore-quay-ireland","name":"Kilmore Quay, Ireland","country":"Ireland","is_tbd":false},{"id":"oxwich-bay-united-kingdom","name":"Oxwich Bay, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"sorsogon-samar-submarine-fiber-optical-interconnection-project-sssfoip":{"id":"sorsogon-samar-submarine-fiber-optical-interconnection-project-sssfoip","name":"Sorsogon-Samar Submarine Fiber Optical Interconnection Project (SSSFOIP)","length":"21 km","rfs":"2019","rfs_year":2019,"is_planned":false,"owners":"National Grid Corporation of the Philippines","suppliers":"HMN Tech","landing_points":[{"id":"allen-philippines","name":"Allen, Philippines","country":"Philippines","is_tbd":false},{"id":"santa-magdalena-philippines","name":"Santa Magdalena, Philippines","country":"Philippines","is_tbd":false}],"notes":"The Sorsogon-Samar Submarine Fiber Optical Interconnection Project is a power cable, which has optical fiber attached to it.","url":null},"south-america-1-sam-1":{"id":"south-america-1-sam-1","name":"South America-1 (SAm-1)","length":"25,000 km","rfs":"2001 March","rfs_year":2001,"is_planned":false,"owners":"Telxius","suppliers":"SubCom","landing_points":[{"id":"las-toninas-argentina","name":"Las Toninas, Argentina","country":"Argentina","is_tbd":false},{"id":"fortaleza-brazil","name":"Fortaleza, Brazil","country":"Brazil","is_tbd":false},{"id":"rio-de-janeiro-brazil","name":"Rio de Janeiro, Brazil","country":"Brazil","is_tbd":false},{"id":"salvador-brazil","name":"Salvador, Brazil","country":"Brazil","is_tbd":false},{"id":"santos-brazil","name":"Santos, Brazil","country":"Brazil","is_tbd":false},{"id":"arica-chile","name":"Arica, Chile","country":"Chile","is_tbd":false},{"id":"valparaso-chile","name":"Valparaíso, Chile","country":"Chile","is_tbd":false},{"id":"barranquilla-colombia","name":"Barranquilla, Colombia","country":"Colombia","is_tbd":false},{"id":"punta-cana-dominican-republic","name":"Punta Cana, Dominican Republic","country":"Dominican Republic","is_tbd":false},{"id":"punta-carnero-ecuador","name":"Punta Carnero, Ecuador","country":"Ecuador","is_tbd":false},{"id":"puerto-barrios-guatemala","name":"Puerto Barrios, Guatemala","country":"Guatemala","is_tbd":false},{"id":"puerto-san-jose-guatemala","name":"Puerto San Jose, Guatemala","country":"Guatemala","is_tbd":false},{"id":"lurin-peru","name":"Lurin, Peru","country":"Peru","is_tbd":false},{"id":"mancora-peru","name":"Mancora, Peru","country":"Peru","is_tbd":false},{"id":"boca-raton-fl-united-states","name":"Boca Raton, FL, United States","country":"United States","is_tbd":false},{"id":"san-juan-pr-united-states","name":"San Juan, PR, United States","country":"United States","is_tbd":false}],"notes":null,"url":"http://www.telxius.com/"},"south-atlantic-cable-system-sacs":{"id":"south-atlantic-cable-system-sacs","name":"South Atlantic Cable System (SACS)","length":"6,165 km","rfs":"2018 September","rfs_year":2018,"is_planned":false,"owners":"Angola Cables","suppliers":"NEC","landing_points":[{"id":"sangano-angola","name":"Sangano, Angola","country":"Angola","is_tbd":false},{"id":"fortaleza-brazil","name":"Fortaleza, Brazil","country":"Brazil","is_tbd":false}],"notes":null,"url":"https://www.angolacables.co.ao/"},"south-american-crossing-sac":{"id":"south-american-crossing-sac","name":"South American Crossing (SAC)","length":"20,000 km","rfs":"2000 September","rfs_year":2000,"is_planned":false,"owners":"Cirion Technologies, Sparkle","suppliers":"ASN","landing_points":[{"id":"las-toninas-argentina","name":"Las Toninas, Argentina","country":"Argentina","is_tbd":false},{"id":"fortaleza-brazil","name":"Fortaleza, Brazil","country":"Brazil","is_tbd":false},{"id":"rio-de-janeiro-brazil","name":"Rio de Janeiro, Brazil","country":"Brazil","is_tbd":false},{"id":"santos-brazil","name":"Santos, Brazil","country":"Brazil","is_tbd":false},{"id":"valparaso-chile","name":"Valparaíso, Chile","country":"Chile","is_tbd":false},{"id":"buenaventura-colombia","name":"Buenaventura, Colombia","country":"Colombia","is_tbd":false},{"id":"coln-panama","name":"Colón, Panama","country":"Panama","is_tbd":false},{"id":"fort-amador-panama","name":"Fort Amador, Panama","country":"Panama","is_tbd":false},{"id":"lurin-peru","name":"Lurin, Peru","country":"Peru","is_tbd":false},{"id":"puerto-viejo-venezuela","name":"Puerto Viejo, Venezuela","country":"Venezuela","is_tbd":false},{"id":"st-croix-virgin-islands-virgin-islands-u-s-","name":"St. Croix, Virgin Islands, Virgin Islands (U.S.)","country":"Virgin Islands (U.S.)","is_tbd":false}],"notes":"Cirion owns three fiber pairs on the systems, while Sparkle owns one fiber. Only Cirion has capacity on the branch to Colombia.","url":"https://www.ciriontechnologies.com"},"south-atlantic-inter-link-sail":{"id":"south-atlantic-inter-link-sail","name":"South Atlantic Inter Link (SAIL)","length":"5,800 km","rfs":"2020","rfs_year":2020,"is_planned":false,"owners":"Camtel, China Unicom","suppliers":"HMN Tech","landing_points":[{"id":"fortaleza-brazil","name":"Fortaleza, Brazil","country":"Brazil","is_tbd":false},{"id":"kribi-cameroon","name":"Kribi, Cameroon","country":"Cameroon","is_tbd":false}],"notes":null,"url":null},"south-pacific-cable-system-spcsmistral":{"id":"south-pacific-cable-system-spcsmistral","name":"South Pacific Cable System (SPCS)/Mistral","length":"7,300 km","rfs":"2021 August","rfs_year":2021,"is_planned":false,"owners":"América Móvil (Claro), Telxius","suppliers":"SubCom","landing_points":[{"id":"arica-chile","name":"Arica, Chile","country":"Chile","is_tbd":false},{"id":"valparaso-chile","name":"Valparaíso, Chile","country":"Chile","is_tbd":false},{"id":"salinas-ecuador","name":"Salinas, Ecuador","country":"Ecuador","is_tbd":false},{"id":"puerto-san-jose-guatemala","name":"Puerto San Jose, Guatemala","country":"Guatemala","is_tbd":false},{"id":"lurin-peru","name":"Lurin, Peru","country":"Peru","is_tbd":false}],"notes":null,"url":null},"southeast-asia-japan-cable-2-sjc2":{"id":"southeast-asia-japan-cable-2-sjc2","name":"Southeast Asia-Japan Cable 2 (SJC2)","length":"10,500 km","rfs":"2025 July","rfs_year":2025,"is_planned":false,"owners":"China Mobile, Chunghwa Telecom, DongHwa Telecom, KDDI, Meta, SK Broadband, Singtel, Telin, True Corporation, VNPT International","suppliers":"NEC","landing_points":[{"id":"chung-hom-kok-china","name":"Chung Hom Kok, China","country":"China","is_tbd":false},{"id":"lingang-china","name":"Lingang, China","country":"China","is_tbd":false},{"id":"chikura-japan","name":"Chikura, Japan","country":"Japan","is_tbd":false},{"id":"shima-japan","name":"Shima, Japan","country":"Japan","is_tbd":false},{"id":"changi-south-singapore","name":"Changi South, Singapore","country":"Singapore","is_tbd":false},{"id":"busan-south-korea","name":"Busan, South Korea","country":"South Korea","is_tbd":false},{"id":"fangshan-taiwan","name":"Fangshan, Taiwan","country":"Taiwan","is_tbd":false},{"id":"tanshui-taiwan","name":"Tanshui, Taiwan","country":"Taiwan","is_tbd":false},{"id":"songkhla-thailand","name":"Songkhla, Thailand","country":"Thailand","is_tbd":false},{"id":"quy-nhon-vietnam","name":"Quy Nhon, Vietnam","country":"Vietnam","is_tbd":false}],"notes":null,"url":null},"southeast-asia-japan-cable-sjc":{"id":"southeast-asia-japan-cable-sjc","name":"Southeast Asia-Japan Cable (SJC)","length":"8,900 km","rfs":"2013 June","rfs_year":2013,"is_planned":false,"owners":"China Mobile, China Telecom, Chunghwa Telecom, Globe Telecom, Google, KDDI, National Telecom, Singtel, Telkom Indonesia, Unified National Networks (UNN)","suppliers":"NEC, SubCom","landing_points":[{"id":"telisai-brunei","name":"Telisai, Brunei","country":"Brunei","is_tbd":false},{"id":"chung-hom-kok-china","name":"Chung Hom Kok, China","country":"China","is_tbd":false},{"id":"shantou-china","name":"Shantou, China","country":"China","is_tbd":false},{"id":"chikura-japan","name":"Chikura, Japan","country":"Japan","is_tbd":false},{"id":"nasugbu-philippines","name":"Nasugbu, Philippines","country":"Philippines","is_tbd":false},{"id":"tuas-singapore","name":"Tuas, Singapore","country":"Singapore","is_tbd":false}],"notes":null,"url":null},"southern-caribbean-fiber":{"id":"southern-caribbean-fiber","name":"Southern Caribbean Fiber","length":"3,000 km","rfs":"2006 September","rfs_year":2006,"is_planned":false,"owners":"Digicel","suppliers":"ASN, SubCom","landing_points":[{"id":"dickenson-bay-antigua-and-barbuda","name":"Dickenson Bay, Antigua and Barbuda","country":"Antigua and Barbuda","is_tbd":false},{"id":"needhams-point-barbados","name":"Needham’s Point, Barbados","country":"Barbados","is_tbd":false},{"id":"canefield-dominica","name":"Canefield, Dominica","country":"Dominica","is_tbd":false},{"id":"point-salines-grenada","name":"Point Salines, Grenada","country":"Grenada","is_tbd":false},{"id":"baie-mahault-guadeloupe","name":"Baie-Mahault, Guadeloupe","country":"Guadeloupe","is_tbd":false},{"id":"baillif-guadeloupe","name":"Baillif, Guadeloupe","country":"Guadeloupe","is_tbd":false},{"id":"le-lamentin-martinique","name":"Le Lamentin, Martinique","country":"Martinique","is_tbd":false},{"id":"bunkum-bay-montserrat","name":"Bunkum Bay, Montserrat","country":"Montserrat","is_tbd":false},{"id":"gustavia-saint-barthlemy","name":"Gustavia, Saint Barthélemy","country":"Saint Barthélemy","is_tbd":false},{"id":"basseterre-saint-kitts-and-nevis","name":"Basseterre, Saint Kitts and Nevis","country":"Saint Kitts and Nevis","is_tbd":false},{"id":"rodney-bay-saint-lucia","name":"Rodney Bay, Saint Lucia","country":"Saint Lucia","is_tbd":false},{"id":"st-louis-saint-martin","name":"St. Louis, Saint Martin","country":"Saint Martin","is_tbd":false},{"id":"kingstown-saint-vincent-and-the-grenadines","name":"Kingstown, Saint Vincent and the Grenadines","country":"Saint Vincent and the Grenadines","is_tbd":false},{"id":"chaguaramas-trinidad-and-tobago","name":"Chaguaramas, Trinidad and Tobago","country":"Trinidad and Tobago","is_tbd":false},{"id":"san-juan-pr-united-states","name":"San Juan, PR, United States","country":"United States","is_tbd":false},{"id":"st-croix-virgin-islands-virgin-islands-u-s-","name":"St. Croix, Virgin Islands, Virgin Islands (U.S.)","country":"Virgin Islands (U.S.)","is_tbd":false}],"notes":null,"url":null},"southern-cross-next":{"id":"southern-cross-next","name":"Southern Cross NEXT","length":"13,700 km","rfs":"2022 July","rfs_year":2022,"is_planned":false,"owners":"Southern Cross Cable Network","suppliers":"ASN","landing_points":[{"id":"alexandria-nsw-australia","name":"Alexandria, NSW, Australia","country":"Australia","is_tbd":false},{"id":"savusavu-fiji","name":"Savusavu, Fiji","country":"Fiji","is_tbd":false},{"id":"suva-fiji","name":"Suva, Fiji","country":"Fiji","is_tbd":false},{"id":"tabwakea-kiribati","name":"Tabwakea, Kiribati","country":"Kiribati","is_tbd":false},{"id":"takapuna-new-zealand","name":"Takapuna, New Zealand","country":"New Zealand","is_tbd":false},{"id":"nukunonu-tokelau","name":"Nukunonu, Tokelau","country":"Tokelau","is_tbd":false},{"id":"hermosa-beach-ca-united-states","name":"Hermosa Beach, CA, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.southerncrosscables.com/"},"southern-cross-cable-network-sccn":{"id":"southern-cross-cable-network-sccn","name":"Southern Cross Cable Network (SCCN)","length":"30,500 km","rfs":"2000 November","rfs_year":2000,"is_planned":false,"owners":"Southern Cross Cable Network","suppliers":"ASN, Fujitsu","landing_points":[{"id":"alexandria-nsw-australia","name":"Alexandria, NSW, Australia","country":"Australia","is_tbd":false},{"id":"brookvale-nsw-australia","name":"Brookvale, NSW, Australia","country":"Australia","is_tbd":false},{"id":"suva-fiji","name":"Suva, Fiji","country":"Fiji","is_tbd":false},{"id":"takapuna-new-zealand","name":"Takapuna, New Zealand","country":"New Zealand","is_tbd":false},{"id":"whenuapai-new-zealand","name":"Whenuapai, New Zealand","country":"New Zealand","is_tbd":false},{"id":"hillsboro-or-united-states","name":"Hillsboro, OR, United States","country":"United States","is_tbd":false},{"id":"kahe-point-hi-united-states","name":"Kahe Point, HI, United States","country":"United States","is_tbd":false},{"id":"morro-bay-ca-united-states","name":"Morro Bay, CA, United States","country":"United States","is_tbd":false},{"id":"spencer-beach-hi-united-states","name":"Spencer Beach, HI, United States","country":"United States","is_tbd":false}],"notes":null,"url":"http://www.southerncrosscables.com"},"sovetskaya-gavan-ilyinskoye":{"id":"sovetskaya-gavan-ilyinskoye","name":"Sovetskaya Gavan-Ilyinskoye","length":"214 km","rfs":"2007","rfs_year":2007,"is_planned":false,"owners":"TTK","suppliers":null,"landing_points":[{"id":"ilyinskoye-russia","name":"Ilyinskoye, Russia","country":"Russia","is_tbd":false},{"id":"sovetskaya-gavan-russia","name":"Sovetskaya Gavan, Russia","country":"Russia","is_tbd":false}],"notes":null,"url":null},"sovetskaya-gavan-uglegorsk":{"id":"sovetskaya-gavan-uglegorsk","name":"Sovetskaya Gavan-Uglegorsk","length":"127 km","rfs":"2019","rfs_year":2019,"is_planned":false,"owners":"Rostelecom","suppliers":"HMN Tech","landing_points":[{"id":"sovetskaya-gavan-russia","name":"Sovetskaya Gavan, Russia","country":"Russia","is_tbd":false},{"id":"uglegorsk-russia","name":"Uglegorsk, Russia","country":"Russia","is_tbd":false}],"notes":null,"url":"https://www.company.rt.ru/en/"},"st-pierre-and-miquelon-cable":{"id":"st-pierre-and-miquelon-cable","name":"St. Pierre and Miquelon Cable","length":"200 km","rfs":"2018 May","rfs_year":2018,"is_planned":false,"owners":"French Authority of St. Pierre and Miquelon","suppliers":"ASN","landing_points":[{"id":"fortune-nl-canada","name":"Fortune, NL, Canada","country":"Canada","is_tbd":false},{"id":"lamaline-nl-canada","name":"Lamaline, NL, Canada","country":"Canada","is_tbd":false},{"id":"miquelon-langlade-saint-pierre-and-miquelon","name":"Miquelon-Langlade, Saint Pierre and Miquelon","country":"Saint Pierre and Miquelon","is_tbd":false},{"id":"saint-pierre-saint-pierre-and-miquelon","name":"Saint-Pierre, Saint Pierre and Miquelon","country":"Saint Pierre and Miquelon","is_tbd":false}],"notes":null,"url":null},"st-thomas-st-croix-system":{"id":"st-thomas-st-croix-system","name":"St. Thomas-St. Croix System","length":"183 km","rfs":"1997 May","rfs_year":1997,"is_planned":false,"owners":"Virgin Islands Next Generation Networks, Inc.","suppliers":null,"landing_points":[{"id":"banana-bay-virgin-islands-u-s-","name":"Banana Bay, Virgin Islands (U.S.)","country":"Virgin Islands (U.S.)","is_tbd":false},{"id":"brewers-bay-virgin-islands-u-s-","name":"Brewer's Bay, Virgin Islands (U.S.)","country":"Virgin Islands (U.S.)","is_tbd":false},{"id":"christiansted-virgin-islands-u-s-","name":"Christiansted, Virgin Islands (U.S.)","country":"Virgin Islands (U.S.)","is_tbd":false},{"id":"flamingo-bay-virgin-islands-u-s-","name":"Flamingo Bay, Virgin Islands (U.S.)","country":"Virgin Islands (U.S.)","is_tbd":false},{"id":"frederiksted-virgin-islands-u-s-","name":"Frederiksted, Virgin Islands (U.S.)","country":"Virgin Islands (U.S.)","is_tbd":false},{"id":"great-bay-virgin-islands-u-s-","name":"Great Bay, Virgin Islands (U.S.)","country":"Virgin Islands (U.S.)","is_tbd":false},{"id":"vila-olga-virgin-islands-u-s-","name":"Vila Olga, Virgin Islands (U.S.)","country":"Virgin Islands (U.S.)","is_tbd":false}],"notes":null,"url":"http://www.vingn.com"},"sto-hel-one":{"id":"sto-hel-one","name":"STO-HEL-One","length":"560 km","rfs":"2008","rfs_year":2008,"is_planned":false,"owners":"GlobalConnect","suppliers":"Ericsson","landing_points":[{"id":"hamns-finland","name":"Hamnäs, Finland","country":"Finland","is_tbd":false},{"id":"lokalahti-finland","name":"Lokalahti, Finland","country":"Finland","is_tbd":false},{"id":"skatrarna-finland","name":"Skatörarna, Finland","country":"Finland","is_tbd":false},{"id":"nothamnsbacken-sweden","name":"Nothamnsbacken, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":"https://www.globalconnectcarrier.com/"},"subcan-link-1":{"id":"subcan-link-1","name":"Subcan Link 1","length":"143 km","rfs":"2002 May","rfs_year":2002,"is_planned":false,"owners":"Cable Submarine de Canarias","suppliers":"SubCom","landing_points":[{"id":"las-caletillas-spain","name":"Las Caletillas, Spain","country":"Spain","is_tbd":false},{"id":"piedra-santa-spain","name":"Piedra Santa, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":null},"strategic-evolution-underwater-link-seul":{"id":"strategic-evolution-underwater-link-seul","name":"Strategic Evolution Underwater Link (SEUL)","length":"24 km","rfs":"2017 July","rfs_year":2017,"is_planned":false,"owners":"Belize Telemedia","suppliers":"HMN Tech","landing_points":[{"id":"bomba-belize","name":"Bomba, Belize","country":"Belize","is_tbd":false},{"id":"san-pedro-belize","name":"San Pedro, Belize","country":"Belize","is_tbd":false}],"notes":null,"url":"https://www.livedigi.com/"},"subcan-link-2":{"id":"subcan-link-2","name":"Subcan Link 2","length":"136 km","rfs":"2002 May","rfs_year":2002,"is_planned":false,"owners":"Cable Submarine de Canarias","suppliers":"SubCom","landing_points":[{"id":"las-caletillas-spain","name":"Las Caletillas, Spain","country":"Spain","is_tbd":false},{"id":"piedra-santa-spain","name":"Piedra Santa, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":null},"submarine-cable-in-the-philippines-scip":{"id":"submarine-cable-in-the-philippines-scip","name":"Submarine Cable in the Philippines (SCiP)","length":"1,638 km","rfs":"2022","rfs_year":2022,"is_planned":false,"owners":"DITO Telecommunity","suppliers":"HMN Tech","landing_points":[{"id":"allen-philippines","name":"Allen, Philippines","country":"Philippines","is_tbd":false},{"id":"batangas-philippines","name":"Batangas, Philippines","country":"Philippines","is_tbd":false},{"id":"butuan-city-philippines","name":"Butuan City, Philippines","country":"Philippines","is_tbd":false},{"id":"cagayan-de-oro-philippines","name":"Cagayan de Oro, Philippines","country":"Philippines","is_tbd":false},{"id":"coron-philippines","name":"Coron, Philippines","country":"Philippines","is_tbd":false},{"id":"daanbantayan-philippines","name":"Daanbantayan, Philippines","country":"Philippines","is_tbd":false},{"id":"dumaguete-philippines","name":"Dumaguete, Philippines","country":"Philippines","is_tbd":false},{"id":"liloan-philippines","name":"Liloan, Philippines","country":"Philippines","is_tbd":false},{"id":"maasin-philippines","name":"Maasin, Philippines","country":"Philippines","is_tbd":false},{"id":"matnog-philippines","name":"Matnog, Philippines","country":"Philippines","is_tbd":false},{"id":"nabas-philippines","name":"Nabas, Philippines","country":"Philippines","is_tbd":false},{"id":"ormoc-philippines","name":"Ormoc, Philippines","country":"Philippines","is_tbd":false},{"id":"pinamalayan-philippines","name":"Pinamalayan, Philippines","country":"Philippines","is_tbd":false},{"id":"roxas-philippines","name":"Roxas, Philippines","country":"Philippines","is_tbd":false},{"id":"san-jose-philippines","name":"San Jose, Philippines","country":"Philippines","is_tbd":false},{"id":"talisay-philippines","name":"Talisay, Philippines","country":"Philippines","is_tbd":false},{"id":"taytay-philippines","name":"Taytay, Philippines","country":"Philippines","is_tbd":false}],"notes":null,"url":"https://dito.ph/"},"sumatera-bangka-cable-system-sbcs":{"id":"sumatera-bangka-cable-system-sbcs","name":"Sumatera Bangka Cable System (SBCS)","length":"57 km","rfs":"2014","rfs_year":2014,"is_planned":false,"owners":"Telkom Indonesia","suppliers":null,"landing_points":[{"id":"muntok-indonesia","name":"Muntok, Indonesia","country":"Indonesia","is_tbd":false},{"id":"palembang-indonesia","name":"Palembang, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":null},"sunoque-i":{"id":"sunoque-i","name":"Sunoque I","length":null,"rfs":"1999 October","rfs_year":1999,"is_planned":false,"owners":"Hydro-Québec, Telus","suppliers":null,"landing_points":[{"id":"baie-comeau-qc-canada","name":"Baie-Comeau, QC, Canada","country":"Canada","is_tbd":false},{"id":"pointe-au-pre-qc-canada","name":"Pointe-au-Père, QC, Canada","country":"Canada","is_tbd":false}],"notes":null,"url":null},"sunoque-ii":{"id":"sunoque-ii","name":"Sunoque II","length":null,"rfs":"1999 October","rfs_year":1999,"is_planned":false,"owners":"Hydro-Québec, Telus","suppliers":null,"landing_points":[{"id":"bic-qc-canada","name":"Bic, QC, Canada","country":"Canada","is_tbd":false},{"id":"forestville-qc-canada","name":"Forestville, QC, Canada","country":"Canada","is_tbd":false}],"notes":null,"url":null},"sunoque-iii":{"id":"sunoque-iii","name":"Sunoque III","length":"130 km","rfs":"2027","rfs_year":2027,"is_planned":true,"owners":"Hydro-Québec, Telus","suppliers":"Hexatronic","landing_points":[{"id":"sainte-anne-des-monts-qc-canada","name":"Sainte-Anne-des-Monts, QC, Canada","country":"Canada","is_tbd":false},{"id":"sept-les-qc-canada","name":"Sept-Îles, QC, Canada","country":"Canada","is_tbd":false}],"notes":null,"url":"https://www.telus.com/"},"suriname-guyana-submarine-cable-system-sg-scs":{"id":"suriname-guyana-submarine-cable-system-sg-scs","name":"Suriname-Guyana Submarine Cable System (SG-SCS)","length":"1,249 km","rfs":"2010 July","rfs_year":2010,"is_planned":false,"owners":"Guyana Telephone and Telegraph (GT&T), Telesur","suppliers":"HMN Tech","landing_points":[{"id":"georgetown-guyana","name":"Georgetown, Guyana","country":"Guyana","is_tbd":false},{"id":"totness-suriname","name":"Totness, Suriname","country":"Suriname","is_tbd":false},{"id":"chaguaramas-trinidad-and-tobago","name":"Chaguaramas, Trinidad and Tobago","country":"Trinidad and Tobago","is_tbd":false}],"notes":null,"url":null},"svalbard-undersea-cable-system":{"id":"svalbard-undersea-cable-system","name":"Svalbard Undersea Cable System","length":"2,714 km","rfs":"2004 January","rfs_year":2004,"is_planned":false,"owners":"Space Norway","suppliers":"SubCom","landing_points":[{"id":"breivika-norway","name":"Breivika, Norway","country":"Norway","is_tbd":false},{"id":"longyearbyen-svalbard-norway","name":"Longyearbyen, Svalbard, Norway","country":"Norway","is_tbd":false}],"notes":null,"url":"https://spacenorway.no"},"sweden-estonia-ee-s-1":{"id":"sweden-estonia-ee-s-1","name":"Sweden-Estonia (EE-S 1)","length":"240 km","rfs":"1995 June","rfs_year":1995,"is_planned":false,"owners":"Arelion, GN Great Nordic, Telia Eesti (formerly Eesti Telekom, EMT, Elion)","suppliers":"ASN","landing_points":[{"id":"krdla-estonia","name":"Kärdla, Estonia","country":"Estonia","is_tbd":false},{"id":"tallinn-estonia","name":"Tallinn, Estonia","country":"Estonia","is_tbd":false},{"id":"stavsnas-sweden","name":"Stavsnas, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":null},"sweden-latvia":{"id":"sweden-latvia","name":"Sweden-Latvia","length":"391 km","rfs":"2005 January","rfs_year":2005,"is_planned":false,"owners":"Latvia State Radio and Television Centre","suppliers":null,"landing_points":[{"id":"ventspils-latvia","name":"Ventspils, Latvia","country":"Latvia","is_tbd":false},{"id":"farosund-sweden","name":"Farosund, Sweden","country":"Sweden","is_tbd":false},{"id":"stockholm-sweden","name":"Stockholm, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":"http://www.lvrtc.lv"},"sx-tasman-express-sx-tx":{"id":"sx-tasman-express-sx-tx","name":"SX Tasman Express (SX-TX)","length":"2,276 km","rfs":"2028","rfs_year":2028,"is_planned":true,"owners":"Southern Cross Cable Network","suppliers":"ASN","landing_points":[{"id":"clovelly-nsw-australia","name":"Clovelly, NSW, Australia","country":"Australia","is_tbd":false},{"id":"whenuapai-new-zealand","name":"Whenuapai, New Zealand","country":"New Zealand","is_tbd":false}],"notes":null,"url":"http://www.southerncrosscables.com"},"sydney-melbourne-adelaide-perth-smap":{"id":"sydney-melbourne-adelaide-perth-smap","name":"Sydney-Melbourne-Adelaide-Perth (SMAP)","length":"5,000 km","rfs":"2026 Q1","rfs_year":2026,"is_planned":true,"owners":"SUBCO","suppliers":"ASN","landing_points":[{"id":"adelaide-sa-australia","name":"Adelaide, SA, Australia","country":"Australia","is_tbd":false},{"id":"maroubra-nsw-australia","name":"Maroubra, NSW, Australia","country":"Australia","is_tbd":false},{"id":"perth-wa-australia","name":"Perth, WA, Australia","country":"Australia","is_tbd":false},{"id":"sydney-nsw-australia","name":"Sydney, NSW, Australia","country":"Australia","is_tbd":false},{"id":"torquay-vic-australia","name":"Torquay, VIC, Australia","country":"Australia","is_tbd":false}],"notes":null,"url":"https://sub.co/"},"t3":{"id":"t3","name":"T3","length":"3,200 km","rfs":"2023 Q4","rfs_year":2023,"is_planned":false,"owners":"Liquid Intelligent Technologies, Mauritius Telecom","suppliers":"ASN","landing_points":[{"id":"baie-jacotet-mauritius","name":"Baie Jacotet, Mauritius","country":"Mauritius","is_tbd":false},{"id":"amanzimtoti-south-africa","name":"Amanzimtoti, South Africa","country":"South Africa","is_tbd":false}],"notes":null,"url":null},"taba-aqaba":{"id":"taba-aqaba","name":"Taba-Aqaba","length":"13 km","rfs":"1998","rfs_year":1998,"is_planned":false,"owners":"National Electric Power Company of Jordan","suppliers":null,"landing_points":[{"id":"taba-egypt","name":"Taba, Egypt","country":"Egypt","is_tbd":false},{"id":"aqaba-jordan","name":"Aqaba, Jordan","country":"Jordan","is_tbd":false}],"notes":null,"url":null},"tabua":{"id":"tabua","name":"Tabua","length":null,"rfs":"2026 Q1","rfs_year":2026,"is_planned":true,"owners":"Google","suppliers":"SubCom","landing_points":[{"id":"maroochydore-qld-australia","name":"Maroochydore, QLD, Australia","country":"Australia","is_tbd":false},{"id":"sydney-nsw-australia","name":"Sydney, NSW, Australia","country":"Australia","is_tbd":false},{"id":"natadola-fiji","name":"Natadola, Fiji","country":"Fiji","is_tbd":false},{"id":"suva-fiji","name":"Suva, Fiji","country":"Fiji","is_tbd":false},{"id":"kapolei-hi-united-states","name":"Kapolei, HI, United States","country":"United States","is_tbd":false},{"id":"los-angeles-ca-united-states","name":"Los Angeles, CA, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://cloud.google.com"},"taino-carib":{"id":"taino-carib","name":"Taino-Carib","length":"187 km","rfs":"1992 January","rfs_year":1992,"is_planned":false,"owners":"AT&T, Altice USA, CANTV, Cogent, Embratel, Liberty Networks, Orange, Setar","suppliers":"AT&T SSI","landing_points":[{"id":"condado-beach-pr-united-states","name":"Condado Beach, PR, United States","country":"United States","is_tbd":false},{"id":"isla-verde-pr-united-states","name":"Isla Verde, PR, United States","country":"United States","is_tbd":false},{"id":"magens-bay-vi-united-states","name":"Magen’s Bay, VI, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"taihei":{"id":"taihei","name":"Taihei","length":"7,000 km","rfs":"2027","rfs_year":2027,"is_planned":true,"owners":"Google","suppliers":"NEC","landing_points":[{"id":"takahagi-japan","name":"Takahagi, Japan","country":"Japan","is_tbd":false},{"id":"kapolei-hi-united-states","name":"Kapolei, HI, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://cloud.google.com"},"taiwan-matsu-no-4":{"id":"taiwan-matsu-no-4","name":"Taiwan-Matsu No.4","length":"300 km","rfs":"2026 June","rfs_year":2026,"is_planned":true,"owners":"Chunghwa Telecom","suppliers":null,"landing_points":[{"id":"dongyin-taiwan","name":"Dongyin, Taiwan","country":"Taiwan","is_tbd":false},{"id":"nangan-taiwan","name":"Nangan, Taiwan","country":"Taiwan","is_tbd":false},{"id":"tanshui-taiwan","name":"Tanshui, Taiwan","country":"Taiwan","is_tbd":false},{"id":"xiju-taiwan","name":"Xiju, Taiwan","country":"Taiwan","is_tbd":false}],"notes":null,"url":null},"taiwan-penghu-kinmen-matsu-no-2-tpkm2":{"id":"taiwan-penghu-kinmen-matsu-no-2-tpkm2","name":"Taiwan Penghu Kinmen Matsu No.2 (TPKM2)","length":"467 km","rfs":"2000","rfs_year":2000,"is_planned":false,"owners":"Chunghwa Telecom","suppliers":"NEC","landing_points":[{"id":"budai-taiwan","name":"Budai, Taiwan","country":"Taiwan","is_tbd":false},{"id":"dongyin-taiwan","name":"Dongyin, Taiwan","country":"Taiwan","is_tbd":false},{"id":"huxi-township-taiwan","name":"Huxi Township, Taiwan","country":"Taiwan","is_tbd":false},{"id":"jinhu-township-taiwan","name":"Jinhu Township, Taiwan","country":"Taiwan","is_tbd":false},{"id":"tanshui-taiwan","name":"Tanshui, Taiwan","country":"Taiwan","is_tbd":false},{"id":"yuanli-taiwan","name":"Yuanli, Taiwan","country":"Taiwan","is_tbd":false}],"notes":null,"url":null},"taiwan-penghu-kinmen-matsu-no-3-tpkm3":{"id":"taiwan-penghu-kinmen-matsu-no-3-tpkm3","name":"Taiwan Penghu Kinmen Matsu No.3 (TPKM3)","length":"510 km","rfs":"2013","rfs_year":2013,"is_planned":false,"owners":"Chunghwa Telecom","suppliers":"NEC","landing_points":[{"id":"beigan-taiwan","name":"Beigan, Taiwan","country":"Taiwan","is_tbd":false},{"id":"dongyin-taiwan","name":"Dongyin, Taiwan","country":"Taiwan","is_tbd":false},{"id":"huxi-township-taiwan","name":"Huxi Township, Taiwan","country":"Taiwan","is_tbd":false},{"id":"jincheng-township-taiwan","name":"Jincheng Township, Taiwan","country":"Taiwan","is_tbd":false},{"id":"magong-taiwan","name":"Magong, Taiwan","country":"Taiwan","is_tbd":false},{"id":"nangan-taiwan","name":"Nangan, Taiwan","country":"Taiwan","is_tbd":false},{"id":"tainan-taiwan","name":"Tainan, Taiwan","country":"Taiwan","is_tbd":false},{"id":"taoyuan-taiwan","name":"Taoyuan, Taiwan","country":"Taiwan","is_tbd":false},{"id":"xiju-taiwan","name":"Xiju, Taiwan","country":"Taiwan","is_tbd":false},{"id":"xiyu-township-taiwan","name":"Xiyu Township, Taiwan","country":"Taiwan","is_tbd":false}],"notes":null,"url":null},"taiwan-strait-express-1-tse-1":{"id":"taiwan-strait-express-1-tse-1","name":"Taiwan Strait Express-1 (TSE-1)","length":"260 km","rfs":"2013 January","rfs_year":2013,"is_planned":false,"owners":"China Mobile, China Unicom, Chunghwa Telecom, Far EasTone (FET), Taiwan International Gateway Corporation, Taiwan Mobile","suppliers":"HMN Tech","landing_points":[{"id":"fuzhou-china","name":"Fuzhou, China","country":"China","is_tbd":false},{"id":"tanshui-taiwan","name":"Tanshui, Taiwan","country":"Taiwan","is_tbd":false}],"notes":null,"url":null},"talaylink":{"id":"talaylink","name":"TalayLink","length":null,"rfs":"2027","rfs_year":2027,"is_planned":true,"owners":"Google","suppliers":"SubCom","landing_points":[{"id":"mandurah-wa-australia","name":"Mandurah, WA, Australia","country":"Australia","is_tbd":false},{"id":"melbourne-vic-australia","name":"Melbourne, VIC, Australia","country":"Australia","is_tbd":false},{"id":"flying-fish-cove-christmas-island","name":"Flying Fish Cove, Christmas Island","country":"Christmas Island","is_tbd":false},{"id":"satun-thailand","name":"Satun, Thailand","country":"Thailand","is_tbd":true}],"notes":null,"url":"https://cloud.google.com"},"tam-1":{"id":"tam-1","name":"TAM-1","length":"7,200 km","rfs":"2026 Q1","rfs_year":2026,"is_planned":true,"owners":"Trans Americas Fiber","suppliers":"Xtera","landing_points":[{"id":"barranquilla-colombia","name":"Barranquilla, Colombia","country":"Colombia","is_tbd":false},{"id":"puerto-limon-costa-rica","name":"Puerto Limon, Costa Rica","country":"Costa Rica","is_tbd":false},{"id":"puerto-barrios-guatemala","name":"Puerto Barrios, Guatemala","country":"Guatemala","is_tbd":false},{"id":"puerto-cortes-honduras","name":"Puerto Cortes, Honduras","country":"Honduras","is_tbd":false},{"id":"cancn-mexico","name":"Cancún, Mexico","country":"Mexico","is_tbd":false},{"id":"maria-chiquita-panama","name":"Maria Chiquita, Panama","country":"Panama","is_tbd":false},{"id":"hollywood-fl-united-states","name":"Hollywood, FL, United States","country":"United States","is_tbd":false},{"id":"san-juan-pr-united-states","name":"San Juan, PR, United States","country":"United States","is_tbd":false},{"id":"vero-beach-fl-united-states","name":"Vero Beach, FL, United States","country":"United States","is_tbd":false},{"id":"butler-bay-virgin-islands-u-s-","name":"Butler Bay, Virgin Islands (U.S.)","country":"Virgin Islands (U.S.)","is_tbd":false}],"notes":null,"url":"https://transamericasfiber.com/"},"tampnet-north":{"id":"tampnet-north","name":"Tampnet North","length":"1,751 km","rfs":"1999 July","rfs_year":1999,"is_planned":false,"owners":"Tampnet","suppliers":"ASN","landing_points":[{"id":"krst-norway","name":"Kårstø, Norway","country":"Norway","is_tbd":false},{"id":"ygarden-norway","name":"Øygarden, Norway","country":"Norway","is_tbd":false},{"id":"aberdeen-united-kingdom","name":"Aberdeen, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"https://www.tampnet.com/"},"tampnet-south":{"id":"tampnet-south","name":"Tampnet South","length":"1,751 km","rfs":"1999 July","rfs_year":1999,"is_planned":false,"owners":"Tampnet","suppliers":"ASN","landing_points":[{"id":"lista-norway","name":"Lista, Norway","country":"Norway","is_tbd":false},{"id":"lowestoft-united-kingdom","name":"Lowestoft, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"https://www.tampnet.com/"},"tamtam":{"id":"tamtam","name":"Tamtam","length":"411 km","rfs":"2027 Q4","rfs_year":2027,"is_planned":true,"owners":"Prima","suppliers":"ASN","landing_points":[{"id":"we-new-caledonia","name":"We, New Caledonia","country":"New Caledonia","is_tbd":true},{"id":"luganville-vanuatu","name":"Luganville, Vanuatu","country":"Vanuatu","is_tbd":false},{"id":"norsup-vanuatu","name":"Norsup, Vanuatu","country":"Vanuatu","is_tbd":false},{"id":"port-vila-vanuatu","name":"Port Vila, Vanuatu","country":"Vanuatu","is_tbd":false},{"id":"tanna-vanuatu","name":"Tanna, Vanuatu","country":"Vanuatu","is_tbd":false}],"notes":null,"url":null},"tangerine":{"id":"tangerine","name":"Tangerine","length":"112 km","rfs":"2000 September","rfs_year":2000,"is_planned":false,"owners":"Colt","suppliers":null,"landing_points":[{"id":"ostend-belgium","name":"Ostend, Belgium","country":"Belgium","is_tbd":false},{"id":"broadstairs-united-kingdom","name":"Broadstairs, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"tannat":{"id":"tannat","name":"Tannat","length":"2,000 km","rfs":"2018 Q1","rfs_year":2018,"is_planned":false,"owners":"Antel Uruguay, Google","suppliers":"ASN","landing_points":[{"id":"las-toninas-argentina","name":"Las Toninas, Argentina","country":"Argentina","is_tbd":false},{"id":"santos-brazil","name":"Santos, Brazil","country":"Brazil","is_tbd":false},{"id":"maldonado-uruguay","name":"Maldonado, Uruguay","country":"Uruguay","is_tbd":false}],"notes":null,"url":null},"tanjung-pandan-sungai-kakap":{"id":"tanjung-pandan-sungai-kakap","name":"Tanjung Pandan-Sungai Kakap","length":"348 km","rfs":"2019 Q4","rfs_year":2019,"is_planned":false,"owners":"Moratelindo","suppliers":null,"landing_points":[{"id":"sungai-kakap-indonesia","name":"Sungai Kakap, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tanjung-pandan-indonesia","name":"Tanjung Pandan, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"http://www.moratelindo.co.id"},"tarakan-selor-cable-system-tscs":{"id":"tarakan-selor-cable-system-tscs","name":"Tarakan Selor Cable System (TSCS)","length":"83 km","rfs":"2014","rfs_year":2014,"is_planned":false,"owners":"Telkom Indonesia","suppliers":null,"landing_points":[{"id":"tanjung-selor-indonesia","name":"Tanjung Selor, Indonesia","country":"Indonesia","is_tbd":false},{"id":"tarakan-indonesia","name":"Tarakan, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":null},"tasman-global-access-tga-cable":{"id":"tasman-global-access-tga-cable","name":"Tasman Global Access (TGA) Cable","length":"2,288 km","rfs":"2017 March","rfs_year":2017,"is_planned":false,"owners":"One NZ, Spark New Zealand, Telstra","suppliers":"ASN","landing_points":[{"id":"oxford-falls-nsw-australia","name":"Oxford Falls, NSW, Australia","country":"Australia","is_tbd":false},{"id":"raglan-new-zealand","name":"Raglan, New Zealand","country":"New Zealand","is_tbd":false}],"notes":null,"url":null},"tata-tgn-atlantic-south":{"id":"tata-tgn-atlantic-south","name":"Tata TGN-Atlantic South","length":"6,830 km","rfs":"2001 June","rfs_year":2001,"is_planned":false,"owners":"Tata Communications","suppliers":null,"landing_points":[{"id":"highbridge-united-kingdom","name":"Highbridge, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"wall-township-nj-united-states","name":"Wall Township, NJ, United States","country":"United States","is_tbd":false}],"notes":null,"url":"http://www.tatacommunications.com"},"tata-tgn-gulf":{"id":"tata-tgn-gulf","name":"Tata TGN-Gulf","length":"4,031 km","rfs":"2012 February","rfs_year":2012,"is_planned":false,"owners":"Tata Communications","suppliers":"SubCom","landing_points":[{"id":"amwaj-island-bahrain","name":"Amwaj Island, Bahrain","country":"Bahrain","is_tbd":false},{"id":"qalhat-oman","name":"Qalhat, Oman","country":"Oman","is_tbd":false},{"id":"al-kheesa-qatar","name":"Al-Kheesa, Qatar","country":"Qatar","is_tbd":false},{"id":"al-khobar-saudi-arabia","name":"Al Khobar, Saudi Arabia","country":"Saudi Arabia","is_tbd":false},{"id":"dubai-united-arab-emirates","name":"Dubai, United Arab Emirates","country":"United Arab Emirates","is_tbd":false},{"id":"fujairah-united-arab-emirates","name":"Fujairah, United Arab Emirates","country":"United Arab Emirates","is_tbd":false}],"notes":null,"url":"http://www.tatacommunications.com"},"tasman-ring-network":{"id":"tasman-ring-network","name":"Tasman Ring Network","length":"6,000 km","rfs":"2027 Q4","rfs_year":2027,"is_planned":true,"owners":"Datagrid New Zealand","suppliers":null,"landing_points":[{"id":"melbourne-vic-australia","name":"Melbourne, VIC, Australia","country":"Australia","is_tbd":false},{"id":"sydney-nsw-australia","name":"Sydney, NSW, Australia","country":"Australia","is_tbd":false},{"id":"auckland-new-zealand","name":"Auckland, New Zealand","country":"New Zealand","is_tbd":false},{"id":"greymouth-new-zealand","name":"Greymouth, New Zealand","country":"New Zealand","is_tbd":false},{"id":"invercargill-new-zealand","name":"Invercargill, New Zealand","country":"New Zealand","is_tbd":false},{"id":"new-plymouth-new-zealand","name":"New Plymouth, New Zealand","country":"New Zealand","is_tbd":false}],"notes":null,"url":"https://www.datagrid.nz/"},"tata-tgn-intra-asia-tgn-ia":{"id":"tata-tgn-intra-asia-tgn-ia","name":"Tata TGN-Intra Asia (TGN-IA)","length":"6,700 km","rfs":"2009 March","rfs_year":2009,"is_planned":false,"owners":"Tata Communications","suppliers":"SubCom","landing_points":[{"id":"deep-water-bay-china","name":"Deep Water Bay, China","country":"China","is_tbd":false},{"id":"ballesteros-philippines","name":"Ballesteros, Philippines","country":"Philippines","is_tbd":false},{"id":"changi-north-singapore","name":"Changi North, Singapore","country":"Singapore","is_tbd":false},{"id":"vung-tau-vietnam","name":"Vung Tau, Vietnam","country":"Vietnam","is_tbd":false}],"notes":null,"url":"http://www.tatacommunications.com"},"tata-tgn-pacific":{"id":"tata-tgn-pacific","name":"Tata TGN-Pacific","length":"22,300 km","rfs":"2002 December","rfs_year":2002,"is_planned":false,"owners":"Tata Communications","suppliers":"SubCom","landing_points":[{"id":"piti-guam","name":"Piti, Guam","country":"Guam","is_tbd":false},{"id":"emi-japan","name":"Emi, Japan","country":"Japan","is_tbd":false},{"id":"toyohashi-japan","name":"Toyohashi, Japan","country":"Japan","is_tbd":false},{"id":"hillsboro-or-united-states","name":"Hillsboro, OR, United States","country":"United States","is_tbd":false},{"id":"los-angeles-ca-united-states","name":"Los Angeles, CA, United States","country":"United States","is_tbd":false}],"notes":null,"url":"http://www.tatacommunications.com"},"tata-tgn-tata-indicom":{"id":"tata-tgn-tata-indicom","name":"Tata TGN-Tata Indicom","length":"3,175 km","rfs":"2004 November","rfs_year":2004,"is_planned":false,"owners":"Tata Communications","suppliers":"SubCom","landing_points":[{"id":"chennai-india","name":"Chennai, India","country":"India","is_tbd":false},{"id":"changi-north-singapore","name":"Changi North, Singapore","country":"Singapore","is_tbd":false}],"notes":null,"url":"http://www.tatacommunications.com"},"tata-tgn-western-europe":{"id":"tata-tgn-western-europe","name":"Tata TGN-Western Europe","length":"3,578 km","rfs":"2002 June","rfs_year":2002,"is_planned":false,"owners":"Tata Communications","suppliers":"SubCom","landing_points":[{"id":"seixal-portugal","name":"Seixal, Portugal","country":"Portugal","is_tbd":false},{"id":"bilbao-spain","name":"Bilbao, Spain","country":"Spain","is_tbd":false},{"id":"highbridge-united-kingdom","name":"Highbridge, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"http://www.tatacommunications.com"},"te-northtgn-eurasiaseacomalexandrosmedex":{"id":"te-northtgn-eurasiaseacomalexandrosmedex","name":"TE North/TGN-Eurasia/SEACOM/Alexandros/Medex","length":"3,634 km","rfs":"2011 July","rfs_year":2011,"is_planned":false,"owners":"Algerie Telecom, Cyta, PCCW, SEACOM, Tata Communications, Telecom Egypt","suppliers":"ASN","landing_points":[{"id":"annaba-algeria","name":"Annaba, Algeria","country":"Algeria","is_tbd":false},{"id":"pentaskhinos-cyprus","name":"Pentaskhinos, Cyprus","country":"Cyprus","is_tbd":false},{"id":"abu-talat-egypt","name":"Abu Talat, Egypt","country":"Egypt","is_tbd":false},{"id":"marseille-france","name":"Marseille, France","country":"France","is_tbd":false}],"notes":"Telecom Egypt operates TE North, but has sold fiber pairs to several parties. Tata Communications owns one fiber pair on the cable which the company refers to as TGN-Eurasia. SEACOM owns one fiber pair. Cyta owns one fiber pair and whollly-owns the branch to Cyprus which the company refers to as Alexandros. PCCW and Algerie Telecom operate a branch to Algeria called Medex.","url":"https://www.te.eg/wps/portal/te/Business/Wholesale/"},"tautira-teahupoo":{"id":"tautira-teahupoo","name":"Tautira-Teahupo'o","length":"38 km","rfs":"2023 Q4","rfs_year":2023,"is_planned":false,"owners":"OPT French Polynesia","suppliers":"Prysmian","landing_points":[{"id":"tautira-french-polynesia","name":"Tautira, French Polynesia","country":"French Polynesia","is_tbd":false},{"id":"teahupoo-french-polynesia","name":"Teahupo'o, French Polynesia","country":"French Polynesia","is_tbd":false}],"notes":null,"url":null},"tegopa":{"id":"tegopa","name":"TEGOPA","length":"222 km","rfs":"1994","rfs_year":1994,"is_planned":false,"owners":"Telefonica","suppliers":null,"landing_points":[{"id":"granadilla-de-abona-spain","name":"Granadilla de Abona, Spain","country":"Spain","is_tbd":false},{"id":"san-sebastian-de-la-gomera-canary-islands-spain","name":"San Sebastian de la Gomera, Canary Islands, Spain","country":"Spain","is_tbd":false},{"id":"santa-cruz-de-la-palma-canary-islands-spain","name":"Santa Cruz de La Palma, Canary Islands, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":null},"telstra-endeavour":{"id":"telstra-endeavour","name":"Telstra Endeavour","length":"9,125 km","rfs":"2008 September","rfs_year":2008,"is_planned":false,"owners":"Telstra","suppliers":"ASN","landing_points":[{"id":"paddington-nsw-australia","name":"Paddington, NSW, Australia","country":"Australia","is_tbd":false},{"id":"keawaula-hi-united-states","name":"Keawaula, HI, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.telstrainternational.com/"},"tenerife-gran-canaria":{"id":"tenerife-gran-canaria","name":"Tenerife-Gran Canaria","length":"110 km","rfs":"1999","rfs_year":1999,"is_planned":false,"owners":"Telefonica","suppliers":null,"landing_points":[{"id":"el-mdano-canary-islands-spain","name":"El Médano, Canary Islands, Spain","country":"Spain","is_tbd":false},{"id":"sardina-canary-islands-spain","name":"Sardina, Canary Islands, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":null},"tenerife-la-palma":{"id":"tenerife-la-palma","name":"Tenerife-La Palma","length":null,"rfs":null,"rfs_year":null,"is_planned":false,"owners":"Telefonica","suppliers":null,"landing_points":[{"id":"los-realejos-canary-islands-spain","name":"Los Realejos, Canary Islands, Spain","country":"Spain","is_tbd":false},{"id":"santa-cruz-de-la-palma-canary-islands-spain","name":"Santa Cruz de La Palma, Canary Islands, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":null},"thailand-domestic-submarine-cable-network-tdscn":{"id":"thailand-domestic-submarine-cable-network-tdscn","name":"Thailand Domestic Submarine Cable Network (TDSCN)","length":"884 km","rfs":"2001 May","rfs_year":2001,"is_planned":false,"owners":"National Telecom","suppliers":null,"landing_points":[{"id":"chumphon-thailand","name":"Chumphon, Thailand","country":"Thailand","is_tbd":false},{"id":"koh-samui-thailand","name":"Koh Samui, Thailand","country":"Thailand","is_tbd":false},{"id":"phetchaburi-thailand","name":"Phetchaburi, Thailand","country":"Thailand","is_tbd":false},{"id":"songkhla-thailand","name":"Songkhla, Thailand","country":"Thailand","is_tbd":false},{"id":"sriracha-thailand","name":"Sriracha, Thailand","country":"Thailand","is_tbd":false}],"notes":null,"url":null},"terra-sw":{"id":"terra-sw","name":"TERRA SW","length":null,"rfs":"2012","rfs_year":2012,"is_planned":false,"owners":"GCI Communication Corp","suppliers":null,"landing_points":[{"id":"fish-camp-ak-united-states","name":"Fish Camp, AK, United States","country":"United States","is_tbd":false},{"id":"homer-ak-united-states","name":"Homer, AK, United States","country":"United States","is_tbd":false},{"id":"igiugig-ak-united-states","name":"Igiugig, AK, United States","country":"United States","is_tbd":false},{"id":"illiamna-ak-united-states","name":"Illiamna, AK, United States","country":"United States","is_tbd":false},{"id":"kokhanok-ak-united-states","name":"Kokhanok, AK, United States","country":"United States","is_tbd":false},{"id":"newhalen-ak-united-states","name":"Newhalen, AK, United States","country":"United States","is_tbd":false},{"id":"nondalton-ak-united-states","name":"Nondalton, AK, United States","country":"United States","is_tbd":false},{"id":"pedro-bay-ak-united-states","name":"Pedro Bay, AK, United States","country":"United States","is_tbd":false},{"id":"pile-bay-ak-united-states","name":"Pile Bay, AK, United States","country":"United States","is_tbd":false},{"id":"port-alsworth-ak-united-states","name":"Port Alsworth, AK, United States","country":"United States","is_tbd":false},{"id":"williamsport-ak-united-states","name":"Williamsport, AK, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://www.gci.com/"},"the-east-african-marine-system-teams":{"id":"the-east-african-marine-system-teams","name":"The East African Marine System (TEAMS)","length":"5,054 km","rfs":"2009 October","rfs_year":2009,"is_planned":false,"owners":"TEAMS Ltd., e&","suppliers":"ASN","landing_points":[{"id":"mombasa-kenya","name":"Mombasa, Kenya","country":"Kenya","is_tbd":false},{"id":"fujairah-united-arab-emirates","name":"Fujairah, United Arab Emirates","country":"United Arab Emirates","is_tbd":false}],"notes":null,"url":null},"thetis":{"id":"thetis","name":"Thetis","length":"660 km","rfs":"2022","rfs_year":2022,"is_planned":false,"owners":"Vodafone","suppliers":"Nexans","landing_points":[{"id":"aethos-greece","name":"Aethos, Greece","country":"Greece","is_tbd":false},{"id":"agios-sostis-greece","name":"Agios Sostis, Greece","country":"Greece","is_tbd":false},{"id":"baxedes-greece","name":"Baxedes, Greece","country":"Greece","is_tbd":false},{"id":"ermoupoli-greece","name":"Ermoupoli, Greece","country":"Greece","is_tbd":false},{"id":"filizi-greece","name":"Filizi, Greece","country":"Greece","is_tbd":false},{"id":"kalafati-greece","name":"Kalafati, Greece","country":"Greece","is_tbd":false},{"id":"kardamena-greece","name":"Kardamena, Greece","country":"Greece","is_tbd":false},{"id":"kavos-greece","name":"Kavos, Greece","country":"Greece","is_tbd":false},{"id":"kochilari-greece","name":"Kochilari, Greece","country":"Greece","is_tbd":false},{"id":"kontokali-greece","name":"Kontokali, Greece","country":"Greece","is_tbd":false},{"id":"kremasti-greece","name":"Kremasti, Greece","country":"Greece","is_tbd":false},{"id":"mykonos-greece","name":"Mykonos, Greece","country":"Greece","is_tbd":false},{"id":"naousa-greece","name":"Naousa, Greece","country":"Greece","is_tbd":false},{"id":"naxos-greece","name":"Naxos, Greece","country":"Greece","is_tbd":false},{"id":"perivolos-greece","name":"Perivolos, Greece","country":"Greece","is_tbd":false},{"id":"pirgaki-greece","name":"Pirgaki, Greece","country":"Greece","is_tbd":false},{"id":"plataria-greece","name":"Plataria, Greece","country":"Greece","is_tbd":false},{"id":"plimmiri-greece","name":"Plimmiri, Greece","country":"Greece","is_tbd":false},{"id":"sitia-greece","name":"Sitia, Greece","country":"Greece","is_tbd":false},{"id":"tinos-greece","name":"Tinos, Greece","country":"Greece","is_tbd":false}],"notes":null,"url":"https://www.vodafone.gr/"},"thetis-express":{"id":"thetis-express","name":"Thetis Express","length":"340 km","rfs":"2027 Q3","rfs_year":2027,"is_planned":true,"owners":"Vodafone","suppliers":null,"landing_points":[{"id":"athens-greece","name":"Athens, Greece","country":"Greece","is_tbd":false},{"id":"heraklion-greece","name":"Heraklion, Greece","country":"Greece","is_tbd":false}],"notes":null,"url":"https://www.vodafone.gr/"},"tikal-amx3":{"id":"tikal-amx3","name":"TIKAL-AMX3","length":"1,935 km","rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"América Móvil (Claro), Telxius","suppliers":"ASN","landing_points":[{"id":"puerto-barrios-guatemala","name":"Puerto Barrios, Guatemala","country":"Guatemala","is_tbd":false},{"id":"cancn-mexico","name":"Cancún, Mexico","country":"Mexico","is_tbd":false},{"id":"boca-raton-fl-united-states","name":"Boca Raton, FL, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"timor-leste-south-submarine-cable-tlssc":{"id":"timor-leste-south-submarine-cable-tlssc","name":"Timor-Leste South Submarine Cable (TLSSC)","length":"607 km","rfs":"2025","rfs_year":2025,"is_planned":false,"owners":"Government of Timor-Leste","suppliers":"ASN","landing_points":[{"id":"dili-timor-leste","name":"Dili, Timor-Leste","country":"Timor-Leste","is_tbd":false}],"notes":null,"url":null},"tko-connect":{"id":"tko-connect","name":"TKO Connect","length":"6 km","rfs":"2023 September","rfs_year":2023,"is_planned":false,"owners":"HKBN, iAdvantage","suppliers":null,"landing_points":[{"id":"chai-wan-china","name":"Chai Wan, China","country":"China","is_tbd":false},{"id":"tseung-kwan-o-china","name":"Tseung Kwan O, China","country":"China","is_tbd":false}],"notes":null,"url":null},"tmx5":{"id":"tmx5","name":"TMX5","length":"383 km","rfs":"2025","rfs_year":2025,"is_planned":false,"owners":"Telmex","suppliers":null,"landing_points":[{"id":"mazatln-mexico","name":"Mazatlán, Mexico","country":"Mexico","is_tbd":false},{"id":"san-jos-del-cabo-mexico","name":"San José del Cabo, Mexico","country":"Mexico","is_tbd":false}],"notes":null,"url":null},"tobrok-emasaed-cable-system":{"id":"tobrok-emasaed-cable-system","name":"Tobrok-Emasaed Cable System","length":"178 km","rfs":"2010 October","rfs_year":2010,"is_planned":false,"owners":"Libya International Telecommunications Company","suppliers":"HMN Tech","landing_points":[{"id":"el-quawef-libya","name":"El-Quawef, Libya","country":"Libya","is_tbd":false},{"id":"tobruk-libya","name":"Tobruk, Libya","country":"Libya","is_tbd":false}],"notes":null,"url":null},"tonga-cable":{"id":"tonga-cable","name":"Tonga Cable","length":"827 km","rfs":"2013 August","rfs_year":2013,"is_planned":false,"owners":"Digicel Tonga, Government of Tonga, Tonga Communications Corporation","suppliers":"ASN","landing_points":[{"id":"suva-fiji","name":"Suva, Fiji","country":"Fiji","is_tbd":false},{"id":"nukualofa-tonga","name":"Nuku'alofa, Tonga","country":"Tonga","is_tbd":false}],"notes":null,"url":null},"tonga-domestic-cable-extension-tdce":{"id":"tonga-domestic-cable-extension-tdce","name":"Tonga Domestic Cable Extension (TDCE)","length":"410 km","rfs":"2018 January","rfs_year":2018,"is_planned":false,"owners":"Tonga Cable Limited","suppliers":"ASN","landing_points":[{"id":"neiafu-tonga","name":"Neiafu, Tonga","country":"Tonga","is_tbd":false},{"id":"nukualofa-tonga","name":"Nuku'alofa, Tonga","country":"Tonga","is_tbd":false},{"id":"pangai-tonga","name":"Pangai, Tonga","country":"Tonga","is_tbd":false}],"notes":null,"url":null},"tokelau-submarine-cable":{"id":"tokelau-submarine-cable","name":"Tokelau Submarine Cable","length":"250 km","rfs":"2023 August","rfs_year":2023,"is_planned":false,"owners":"Teletok","suppliers":"ASN","landing_points":[{"id":"atafu-tokelau","name":"Atafu, Tokelau","country":"Tokelau","is_tbd":false},{"id":"fakaofo-tokelau","name":"Fakaofo, Tokelau","country":"Tokelau","is_tbd":false},{"id":"nukunonu-tokelau","name":"Nukunonu, Tokelau","country":"Tokelau","is_tbd":false}],"notes":null,"url":"https://www.teletokco.tk"},"topaz":{"id":"topaz","name":"Topaz","length":null,"rfs":"2023","rfs_year":2023,"is_planned":false,"owners":"Google","suppliers":"NEC","landing_points":[{"id":"port-alberni-bc-canada","name":"Port Alberni, BC, Canada","country":"Canada","is_tbd":false},{"id":"vancouver-bc-canada","name":"Vancouver, BC, Canada","country":"Canada","is_tbd":false},{"id":"shima-japan","name":"Shima, Japan","country":"Japan","is_tbd":false},{"id":"takahagi-japan","name":"Takahagi, Japan","country":"Japan","is_tbd":false},{"id":"dawu-taiwan","name":"Dawu, Taiwan","country":"Taiwan","is_tbd":true}],"notes":null,"url":"https://cloud.google.com"},"tpu":{"id":"tpu","name":"TPU","length":"13,470 km","rfs":"2026","rfs_year":2026,"is_planned":true,"owners":"Google","suppliers":"NEC","landing_points":[{"id":"tanguisson-point-guam","name":"Tanguisson Point, Guam","country":"Guam","is_tbd":false},{"id":"tinian-northern-mariana-islands","name":"Tinian, Northern Mariana Islands","country":"Northern Mariana Islands","is_tbd":false},{"id":"claveria-philippines","name":"Claveria, Philippines","country":"Philippines","is_tbd":false},{"id":"dawu-taiwan","name":"Dawu, Taiwan","country":"Taiwan","is_tbd":false},{"id":"eureka-ca-united-states","name":"Eureka, CA, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://cloud.google.com"},"trans-adriatic-express-tae":{"id":"trans-adriatic-express-tae","name":"Trans Adriatic Express (TAE)","length":"106 km","rfs":"2023 May","rfs_year":2023,"is_planned":false,"owners":"EXA Infrastructure, TAP AG","suppliers":null,"landing_points":[{"id":"seman-albania","name":"Seman, Albania","country":"Albania","is_tbd":false},{"id":"san-foca-italy","name":"San Foca, Italy","country":"Italy","is_tbd":false}],"notes":null,"url":"https://exainfra.net/"},"trans-caspian-fiber-optic-cable-project":{"id":"trans-caspian-fiber-optic-cable-project","name":"Trans-Caspian Fiber Optic Cable Project","length":"341 km","rfs":"2026 Q4","rfs_year":2026,"is_planned":true,"owners":"Azertelecom, Kazakhtelecom","suppliers":null,"landing_points":[{"id":"sumgait-azerbaijan","name":"Sumgait, Azerbaijan","country":"Azerbaijan","is_tbd":false},{"id":"aktau-kazakhstan","name":"Aktau, Kazakhstan","country":"Kazakhstan","is_tbd":false}],"notes":null,"url":null},"trans-global-cable-system-tgcs":{"id":"trans-global-cable-system-tgcs","name":"Trans Global Cable System (TGCS)","length":"1,200 km","rfs":"2026 Q4","rfs_year":2026,"is_planned":true,"owners":"Trans Indonesia Supercorridor","suppliers":"HMN Tech","landing_points":[{"id":"balikpapan-indonesia","name":"Balikpapan, Indonesia","country":"Indonesia","is_tbd":true},{"id":"batam-indonesia","name":"Batam, Indonesia","country":"Indonesia","is_tbd":false},{"id":"ketapang-indonesia","name":"Ketapang, Indonesia","country":"Indonesia","is_tbd":true},{"id":"makassar-indonesia","name":"Makassar, Indonesia","country":"Indonesia","is_tbd":true},{"id":"manado-indonesia","name":"Manado, Indonesia","country":"Indonesia","is_tbd":true},{"id":"surabaya-indonesia","name":"Surabaya, Indonesia","country":"Indonesia","is_tbd":true},{"id":"tanjung-pakis-indonesia","name":"Tanjung Pakis, Indonesia","country":"Indonesia","is_tbd":false}],"notes":null,"url":"https://www.supercorridor.co.id/"},"trans-pacific-express-tpe-cable-system":{"id":"trans-pacific-express-tpe-cable-system","name":"Trans-Pacific Express (TPE) Cable System","length":"17,968 km","rfs":"2008 August","rfs_year":2008,"is_planned":false,"owners":"AT&T, China Telecom, China Unicom, Chunghwa Telecom, KT, NTT, Verizon","suppliers":"SubCom","landing_points":[{"id":"chongming-china","name":"Chongming, China","country":"China","is_tbd":false},{"id":"qingdao-china","name":"Qingdao, China","country":"China","is_tbd":false},{"id":"maruyama-japan","name":"Maruyama, Japan","country":"Japan","is_tbd":false},{"id":"geoje-south-korea","name":"Geoje, South Korea","country":"South Korea","is_tbd":false},{"id":"tanshui-taiwan","name":"Tanshui, Taiwan","country":"Taiwan","is_tbd":false},{"id":"nedonna-beach-or-united-states","name":"Nedonna Beach, OR, United States","country":"United States","is_tbd":false}],"notes":null,"url":"https://tpecable.org:59876/"},"transcan-2":{"id":"transcan-2","name":"TRANSCAN-2","length":"238 km","rfs":"1990","rfs_year":1990,"is_planned":false,"owners":"Telefonica","suppliers":null,"landing_points":[{"id":"aguimes-canary-islands-spain","name":"Aguimes, Canary Islands, Spain","country":"Spain","is_tbd":false},{"id":"arrecife-canary-islands-spain","name":"Arrecife, Canary Islands, Spain","country":"Spain","is_tbd":false},{"id":"morrojable-canary-islands-spain","name":"Morrojable, Canary Islands, Spain","country":"Spain","is_tbd":false},{"id":"puerto-del-rosario-canary-islands-spain","name":"Puerto del Rosario, Canary Islands, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":null},"transcan-3":{"id":"transcan-3","name":"TRANSCAN-3","length":"210 km","rfs":"2000","rfs_year":2000,"is_planned":false,"owners":"Telefonica","suppliers":null,"landing_points":[{"id":"alta-vista-canary-islands-spain","name":"Alta Vista, Canary Islands, Spain","country":"Spain","is_tbd":false},{"id":"playa-blanca-canary-islands-spain","name":"Playa Blanca, Canary Islands, Spain","country":"Spain","is_tbd":false}],"notes":null,"url":null},"transworld-tw1":{"id":"transworld-tw1","name":"Transworld (TW1)","length":"1,300 km","rfs":"2006 June","rfs_year":2006,"is_planned":false,"owners":"Transworld","suppliers":"SubCom","landing_points":[{"id":"al-seeb-oman","name":"Al Seeb, Oman","country":"Oman","is_tbd":false},{"id":"karachi-pakistan","name":"Karachi, Pakistan","country":"Pakistan","is_tbd":false},{"id":"fujairah-united-arab-emirates","name":"Fujairah, United Arab Emirates","country":"United Arab Emirates","is_tbd":false}],"notes":null,"url":"http://www.tw1.com"},"tt1":{"id":"tt1","name":"TT1","length":"44 km","rfs":"2015 August","rfs_year":2015,"is_planned":false,"owners":"Alliance Telecommunications, Liberty Networks, TSTT","suppliers":null,"landing_points":[{"id":"pigeon-point-trinidad-and-tobago","name":"Pigeon Point, Trinidad and Tobago","country":"Trinidad and Tobago","is_tbd":false},{"id":"toco-trinidad-and-tobago","name":"Toco, Trinidad and Tobago","country":"Trinidad and Tobago","is_tbd":false}],"notes":null,"url":"https://libertynet.com/contact"},"trapani-kelibia-2-keltra-2":{"id":"trapani-kelibia-2-keltra-2","name":"Trapani-Kelibia 2 (KELTRA-2)","length":"209 km","rfs":"2007 July","rfs_year":2007,"is_planned":false,"owners":"Sparkle, Tunisia Telecom","suppliers":"ASN","landing_points":[{"id":"trapani-italy","name":"Trapani, Italy","country":"Italy","is_tbd":false},{"id":"kelibia-tunisia","name":"Kelibia, Tunisia","country":"Tunisia","is_tbd":false}],"notes":null,"url":null},"tui-samoa":{"id":"tui-samoa","name":"Tui-Samoa","length":"1,693 km","rfs":"2018 February","rfs_year":2018,"is_planned":false,"owners":"Samoa Submarine Cable Company","suppliers":"ASN","landing_points":[{"id":"savusavu-fiji","name":"Savusavu, Fiji","country":"Fiji","is_tbd":false},{"id":"suva-fiji","name":"Suva, Fiji","country":"Fiji","is_tbd":false},{"id":"apia-samoa","name":"Apia, Samoa","country":"Samoa","is_tbd":false},{"id":"tuasivi-samoa","name":"Tuasivi, Samoa","country":"Samoa","is_tbd":false},{"id":"leava-wallis-and-futuna","name":"Leava, Wallis and Futuna","country":"Wallis and Futuna","is_tbd":false},{"id":"mata-utu-wallis-and-futuna","name":"Mata-Utu, Wallis and Futuna","country":"Wallis and Futuna","is_tbd":false}],"notes":null,"url":"http://ssccsamoa.com/"},"thailand-indonesia-singapore-tis":{"id":"thailand-indonesia-singapore-tis","name":"Thailand-Indonesia-Singapore (TIS)","length":"968 km","rfs":"2003 December","rfs_year":2003,"is_planned":false,"owners":"National Telecom, Singtel, Telkom Indonesia","suppliers":"NEC","landing_points":[{"id":"batam-indonesia","name":"Batam, Indonesia","country":"Indonesia","is_tbd":false},{"id":"changi-north-singapore","name":"Changi North, Singapore","country":"Singapore","is_tbd":false},{"id":"songkhla-thailand","name":"Songkhla, Thailand","country":"Thailand","is_tbd":false}],"notes":null,"url":null},"turcyos-1":{"id":"turcyos-1","name":"Turcyos-1","length":"110 km","rfs":"1993","rfs_year":1993,"is_planned":false,"owners":"Turk Telekom","suppliers":"ASN","landing_points":[{"id":"girne-cyprus","name":"Girne, Cyprus","country":"Cyprus","is_tbd":false},{"id":"bozyazi-turkey","name":"Bozyazi, Turkey","country":"Turkey","is_tbd":false}],"notes":null,"url":null},"turcyos-2":{"id":"turcyos-2","name":"Turcyos-2","length":"213 km","rfs":"2011 March","rfs_year":2011,"is_planned":false,"owners":"Turk Telekom","suppliers":null,"landing_points":[{"id":"iskele-cyprus","name":"Iskele, Cyprus","country":"Cyprus","is_tbd":false},{"id":"samandag-turkey","name":"Samandag, Turkey","country":"Turkey","is_tbd":false}],"notes":null,"url":"http://www.turktelekom.com.tr"},"uae-iran":{"id":"uae-iran","name":"UAE-Iran","length":"170 km","rfs":"1992","rfs_year":1992,"is_planned":false,"owners":"Telecommunication Infrastructure Company of Iran, e&","suppliers":"ASN","landing_points":[{"id":"jask-iran","name":"Jask, Iran","country":"Iran","is_tbd":false},{"id":"fujairah-united-arab-emirates","name":"Fujairah, United Arab Emirates","country":"United Arab Emirates","is_tbd":false}],"notes":null,"url":null},"tverrlinken":{"id":"tverrlinken","name":"Tverrlinken","length":null,"rfs":"2010","rfs_year":2010,"is_planned":false,"owners":"KystTele","suppliers":null,"landing_points":[{"id":"hemnesberget-norway","name":"Hemnesberget, Norway","country":"Norway","is_tbd":false},{"id":"mo-i-rana-norway","name":"Mo I Rana, Norway","country":"Norway","is_tbd":false},{"id":"nesna-norway","name":"Nesna, Norway","country":"Norway","is_tbd":false},{"id":"utskarpen-norway","name":"Utskarpen, Norway","country":"Norway","is_tbd":false}],"notes":null,"url":"http://www.kysttele.no"},"ugarit":{"id":"ugarit","name":"UGARIT","length":"239 km","rfs":"1995 February","rfs_year":1995,"is_planned":false,"owners":"A1 Telekom Austria, AT&T, BT, Cyta, Deutsche Telekom, Lebanese Ministry of Telecommunications, Orange, Singtel, Sparkle, Syrian Telecommunications Establishment, Tata Communications, Telefonica, Vivacom","suppliers":"SubCom","landing_points":[{"id":"pentaskhinos-cyprus","name":"Pentaskhinos, Cyprus","country":"Cyprus","is_tbd":false},{"id":"tartous-syria","name":"Tartous, Syria","country":"Syria","is_tbd":false}],"notes":null,"url":null},"uk-channel-islands-7":{"id":"uk-channel-islands-7","name":"UK-Channel Islands-7","length":"124 km","rfs":"1994 January","rfs_year":1994,"is_planned":false,"owners":"BT, Sure","suppliers":null,"landing_points":[{"id":"lancresse-bay-guernsey","name":"L'Ancresse Bay, Guernsey","country":"Guernsey","is_tbd":false},{"id":"stoke-fleming-united-kingdom","name":"Stoke Fleming, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"uk-channel-islands-8":{"id":"uk-channel-islands-8","name":"UK-Channel Islands-8","length":"237 km","rfs":"1994 January","rfs_year":1994,"is_planned":false,"owners":"BT","suppliers":null,"landing_points":[{"id":"st-ouens-bay-jersey","name":"St. Ouens Bay, Jersey","country":"Jersey","is_tbd":false},{"id":"goonhilly-downs-united-kingdom","name":"Goonhilly Downs, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"ulleung-mainland-2":{"id":"ulleung-mainland-2","name":"Ulleung-Mainland 2","length":"164 km","rfs":"2016","rfs_year":2016,"is_planned":false,"owners":"KT","suppliers":null,"landing_points":[{"id":"hosan-ri-south-korea","name":"Hosan-ri, South Korea","country":"South Korea","is_tbd":false},{"id":"ulleung-south-korea","name":"Ulleung, South Korea","country":"South Korea","is_tbd":false}],"notes":null,"url":null},"ultramar-ge":{"id":"ultramar-ge","name":"Ultramar GE","length":"263 km","rfs":"2023 March","rfs_year":2023,"is_planned":false,"owners":"GITGE (Gestor de Infraestructuras de Telecomunicaciones de Guinea Ecuatorial)","suppliers":"ASN","landing_points":[{"id":"annobon-equatorial-guinea","name":"Annobon, Equatorial Guinea","country":"Equatorial Guinea","is_tbd":false},{"id":"sao-tome-sao-tome-and-principe","name":"Sao Tome, Sao Tome and Principe","country":"Sao Tome and Principe","is_tbd":false}],"notes":null,"url":"https://gitge.com/en/home/"},"ulysses-2":{"id":"ulysses-2","name":"Ulysses 2","length":null,"rfs":"1997","rfs_year":1997,"is_planned":false,"owners":"Verizon","suppliers":null,"landing_points":[{"id":"ijmuiden-netherlands","name":"Ijmuiden, Netherlands","country":"Netherlands","is_tbd":false},{"id":"lowestoft-united-kingdom","name":"Lowestoft, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":null},"umo":{"id":"umo","name":"UMO","length":"2,227 km","rfs":"2023 September","rfs_year":2023,"is_planned":false,"owners":"Campana Group","suppliers":"HMN Tech","landing_points":[{"id":"thanlyin-myanmar","name":"Thanlyin, Myanmar","country":"Myanmar","is_tbd":false},{"id":"tuas-singapore","name":"Tuas, Singapore","country":"Singapore","is_tbd":false}],"notes":null,"url":"https://www.campanaworks.com/"},"umoja":{"id":"umoja","name":"Umoja","length":null,"rfs":"2027","rfs_year":2027,"is_planned":true,"owners":"Google","suppliers":"SubCom","landing_points":[{"id":"mandurah-wa-australia","name":"Mandurah, WA, Australia","country":"Australia","is_tbd":false},{"id":"amanzimtoti-south-africa","name":"Amanzimtoti, South Africa","country":"South Africa","is_tbd":true}],"notes":null,"url":"https://cloud.google.com"},"unisur":{"id":"unisur","name":"Unisur","length":"265 km","rfs":"1995 March","rfs_year":1995,"is_planned":false,"owners":"Antel Uruguay, Telxius","suppliers":"ASN","landing_points":[{"id":"las-toninas-argentina","name":"Las Toninas, Argentina","country":"Argentina","is_tbd":false},{"id":"maldonado-uruguay","name":"Maldonado, Uruguay","country":"Uruguay","is_tbd":false}],"notes":null,"url":null},"unitel-north-submarine-cable-unsc":{"id":"unitel-north-submarine-cable-unsc","name":"Unitel North Submarine Cable (UNSC)","length":"1,145 km","rfs":"2023 January","rfs_year":2023,"is_planned":false,"owners":"Unitel (Angola)","suppliers":"HMN Tech","landing_points":[{"id":"cabinda-angola","name":"Cabinda, Angola","country":"Angola","is_tbd":false},{"id":"cacongo-angola","name":"Cacongo, Angola","country":"Angola","is_tbd":false},{"id":"nzeto-angola","name":"N'zeto, Angola","country":"Angola","is_tbd":false},{"id":"soyo-angola","name":"Soyo, Angola","country":"Angola","is_tbd":false}],"notes":null,"url":"http://www.unitel.ao"},"unityeac-pacific":{"id":"unityeac-pacific","name":"Unity/EAC-Pacific","length":"9,620 km","rfs":"2010 March","rfs_year":2010,"is_planned":false,"owners":"Bharti Airtel, Google, KDDI, Singtel, TIME dotCom, Telstra","suppliers":"NEC, SubCom","landing_points":[{"id":"chikura-japan","name":"Chikura, Japan","country":"Japan","is_tbd":false},{"id":"redondo-beach-ca-united-states","name":"Redondo Beach, CA, United States","country":"United States","is_tbd":false}],"notes":"Unity is jointly owned by a consortium of six companies. Telstra owns two fiber pairs that the company refers to as EAC-Pacific. The remaining three fiber pairs are jointly owned by the other consortium members.","url":null},"unitirreno":{"id":"unitirreno","name":"Unitirreno","length":"1,156 km","rfs":"2025 October","rfs_year":2025,"is_planned":false,"owners":"Azimut, Unidata","suppliers":"ASN","landing_points":[{"id":"fiumicino-italy","name":"Fiumicino, Italy","country":"Italy","is_tbd":false},{"id":"genoa-italy","name":"Genoa, Italy","country":"Italy","is_tbd":false},{"id":"mazara-del-vallo-italy","name":"Mazara del Vallo, Italy","country":"Italy","is_tbd":false},{"id":"olbia-italy","name":"Olbia, Italy","country":"Italy","is_tbd":false}],"notes":null,"url":"http://www.unitirreno.com/"},"vaka":{"id":"vaka","name":"VAKA","length":"668 km","rfs":"2026 Q2","rfs_year":2026,"is_planned":true,"owners":"Tuvalu Telecommunications Corporation (TTC)","suppliers":"SubCom","landing_points":[{"id":"funafuti-tuvalu","name":"Funafuti, Tuvalu","country":"Tuvalu","is_tbd":false}],"notes":null,"url":"https://www.tuvalutelecom.tv/"},"vancouver-bowen-island-vancouver-island":{"id":"vancouver-bowen-island-vancouver-island","name":"Vancouver-Bowen Island-Vancouver Island","length":"75 km","rfs":"2019 April","rfs_year":2019,"is_planned":false,"owners":"Rogers Communications","suppliers":null,"landing_points":[{"id":"cape-roger-curtis-bc-canada","name":"Cape Roger Curtis, BC, Canada","country":"Canada","is_tbd":false},{"id":"french-creek-bc-canada","name":"French Creek, BC, Canada","country":"Canada","is_tbd":false},{"id":"horseshoe-bay-bc-canada","name":"Horseshoe Bay, BC, Canada","country":"Canada","is_tbd":false},{"id":"snug-cove-bc-canada","name":"Snug Cove, BC, Canada","country":"Canada","is_tbd":false}],"notes":null,"url":null},"venezuelan-festoon":{"id":"venezuelan-festoon","name":"Venezuelan Festoon","length":"1,200 km","rfs":"1998","rfs_year":1998,"is_planned":false,"owners":"CANTV","suppliers":null,"landing_points":[{"id":"cabimas-venezuela","name":"Cabimas, Venezuela","country":"Venezuela","is_tbd":false},{"id":"camuri-venezuela","name":"Camuri, Venezuela","country":"Venezuela","is_tbd":false},{"id":"carpano-venezuela","name":"Carúpano, Venezuela","country":"Venezuela","is_tbd":false},{"id":"chichiriviche-venezuela","name":"Chichiriviche, Venezuela","country":"Venezuela","is_tbd":false},{"id":"coro-venezuela","name":"Coro, Venezuela","country":"Venezuela","is_tbd":false},{"id":"cuman-venezuela","name":"Cumaná, Venezuela","country":"Venezuela","is_tbd":false},{"id":"higuerote-venezuela","name":"Higuerote, Venezuela","country":"Venezuela","is_tbd":false},{"id":"maracaibo-venezuela","name":"Maracaibo, Venezuela","country":"Venezuela","is_tbd":false},{"id":"porlamar-venezuela","name":"Porlamar, Venezuela","country":"Venezuela","is_tbd":false},{"id":"puerto-cabello-venezuela","name":"Puerto Cabello, Venezuela","country":"Venezuela","is_tbd":false},{"id":"puerto-la-cruz-venezuela","name":"Puerto La Cruz, Venezuela","country":"Venezuela","is_tbd":false},{"id":"punto-fijo-venezuela","name":"Punto Fijo, Venezuela","country":"Venezuela","is_tbd":false}],"notes":null,"url":null},"verena":{"id":"verena","name":"Verena","length":"630 km","rfs":"2028 Q4","rfs_year":2028,"is_planned":true,"owners":"Altibox","suppliers":"Xtera","landing_points":[{"id":"esbjerg-denmark","name":"Esbjerg, Denmark","country":"Denmark","is_tbd":false},{"id":"scarborough-united-kingdom","name":"Scarborough, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"https://www.altiboxcarrier.com/"},"vietnam-singapore-cable-system-vts":{"id":"vietnam-singapore-cable-system-vts","name":"Vietnam-Singapore Cable System (VTS)","length":null,"rfs":"2027 Q2","rfs_year":2027,"is_planned":true,"owners":"Singtel, Viettel Corporation","suppliers":null,"landing_points":[{"id":"kuala-sedili-malaysia","name":"Kuala Sedili, Malaysia","country":"Malaysia","is_tbd":true},{"id":"changi-singapore","name":"Changi, Singapore","country":"Singapore","is_tbd":false},{"id":"songkhla-thailand","name":"Songkhla, Thailand","country":"Thailand","is_tbd":true},{"id":"vung-tau-vietnam","name":"Vung Tau, Vietnam","country":"Vietnam","is_tbd":false}],"notes":null,"url":null},"vodafone-greece-domestic":{"id":"vodafone-greece-domestic","name":"Vodafone Greece Domestic","length":"92 km","rfs":"2008","rfs_year":2008,"is_planned":false,"owners":"Vodafone","suppliers":"Norddeutsche Seekabelwerke GmbH (NSW)","landing_points":[{"id":"porto-rafti-greece","name":"Porto Rafti, Greece","country":"Greece","is_tbd":false},{"id":"syros-greece","name":"Syros, Greece","country":"Greece","is_tbd":false}],"notes":null,"url":"https://www.vodafone.gr/"},"vstervik-visby":{"id":"vstervik-visby","name":"Västervik-Visby","length":null,"rfs":"2008","rfs_year":2008,"is_planned":false,"owners":"GlobalConnect","suppliers":null,"landing_points":[{"id":"visby-sweden","name":"Visby, Sweden","country":"Sweden","is_tbd":false},{"id":"vstervik-sweden","name":"Västervik, Sweden","country":"Sweden","is_tbd":false}],"notes":null,"url":"https://globalconnectcarrier.com/"},"west-africa-cable-system-wacs":{"id":"west-africa-cable-system-wacs","name":"West Africa Cable System (WACS)","length":"14,530 km","rfs":"2012 May","rfs_year":2012,"is_planned":false,"owners":"Altice Portugal, Angola Cables, Bayobab, Broadband Infraco, Camtel, Cape Verde Telecom, Congo Telecom, Liquid Intelligent Technologies, Office Congolais de Poste et Télécommunication, PCCW, Tata Communications, Telecom Namibia, Telkom South Africa, Togo Telecom, Vodacom DRC, Vodafone, Vodafone Espana, Vodafone Ghana","suppliers":"ASN","landing_points":[{"id":"sangano-angola","name":"Sangano, Angola","country":"Angola","is_tbd":false},{"id":"limbe-cameroon","name":"Limbe, Cameroon","country":"Cameroon","is_tbd":false},{"id":"praia-cape-verde","name":"Praia, Cape Verde","country":"Cape Verde","is_tbd":false},{"id":"muanda-congo-dem-rep-","name":"Muanda, Congo, Dem. Rep.","country":"Congo, Dem. Rep.","is_tbd":false},{"id":"pointe-noire-congo-rep-","name":"Pointe-Noire, Congo, Rep.","country":"Congo, Rep.","is_tbd":false},{"id":"abidjan-cte-divoire","name":"Abidjan, Côte d'Ivoire","country":"Côte d'Ivoire","is_tbd":false},{"id":"accra-ghana","name":"Accra, Ghana","country":"Ghana","is_tbd":false},{"id":"swakopmund-namibia","name":"Swakopmund, Namibia","country":"Namibia","is_tbd":false},{"id":"lagos-nigeria","name":"Lagos, Nigeria","country":"Nigeria","is_tbd":false},{"id":"seixal-portugal","name":"Seixal, Portugal","country":"Portugal","is_tbd":false},{"id":"yzerfontein-south-africa","name":"Yzerfontein, South Africa","country":"South Africa","is_tbd":false},{"id":"el-goro-canary-islands-spain","name":"El Goro, Canary Islands, Spain","country":"Spain","is_tbd":false},{"id":"lome-togo","name":"Lome, Togo","country":"Togo","is_tbd":false}],"notes":"The cable provides connectivity from Portugal to the United Kingdom via dedicated fiber pairs on an existing cable.","url":"https://wacscable.com/"},"whidbey-island-camano-island":{"id":"whidbey-island-camano-island","name":"Whidbey Island-Camano Island","length":"4 km","rfs":"1999","rfs_year":1999,"is_planned":false,"owners":"Whidbey Telecom","suppliers":"Prysmian","landing_points":[{"id":"cama-beach-wa-united-states","name":"Cama Beach, WA, United States","country":"United States","is_tbd":false},{"id":"greenbank-wa-united-states","name":"Greenbank, WA, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"whidbey-island-everett":{"id":"whidbey-island-everett","name":"Whidbey Island-Everett","length":"9 km","rfs":"1999","rfs_year":1999,"is_planned":false,"owners":"Whidbey Telecom","suppliers":"Prysmian","landing_points":[{"id":"clinton-wa-united-states","name":"Clinton, WA, United States","country":"United States","is_tbd":false},{"id":"everett-wa-united-states","name":"Everett, WA, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"whidbey-island-hat-island":{"id":"whidbey-island-hat-island","name":"Whidbey Island-Hat Island","length":"4 km","rfs":"1999","rfs_year":1999,"is_planned":false,"owners":"Whidbey Telecom","suppliers":"Prysmian","landing_points":[{"id":"clinton-wa-united-states","name":"Clinton, WA, United States","country":"United States","is_tbd":false},{"id":"hat-island-wa-united-states","name":"Hat Island, WA, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"whidbey-island-seattle":{"id":"whidbey-island-seattle","name":"Whidbey Island-Seattle","length":"44 km","rfs":"1999","rfs_year":1999,"is_planned":false,"owners":"Whidbey Telecom","suppliers":"Prysmian","landing_points":[{"id":"maxwelton-wa-united-states","name":"Maxwelton, WA, United States","country":"United States","is_tbd":false},{"id":"seattle-wa-united-states","name":"Seattle, WA, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"x-link-submarine-cable":{"id":"x-link-submarine-cable","name":"X-Link Submarine Cable","length":"775 km","rfs":"2019 December","rfs_year":2019,"is_planned":false,"owners":"E-Networks Inc.","suppliers":null,"landing_points":[{"id":"pegwell-barbados","name":"Pegwell, Barbados","country":"Barbados","is_tbd":false},{"id":"georgetown-guyana","name":"Georgetown, Guyana","country":"Guyana","is_tbd":false}],"notes":"The Grenada-Guyana and Guyana-Suriname segments are planned.","url":"https://www.enetworks.gy/"},"yellow":{"id":"yellow","name":"Yellow","length":"7,001 km","rfs":"2000 September","rfs_year":2000,"is_planned":false,"owners":"Colt","suppliers":"SubCom","landing_points":[{"id":"bude-united-kingdom","name":"Bude, United Kingdom","country":"United Kingdom","is_tbd":false},{"id":"bellport-ny-united-states","name":"Bellport, NY, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"yui":{"id":"yui","name":"YUI","length":"720 km","rfs":"2023 July","rfs_year":2023,"is_planned":false,"owners":"Okinawa Cellular Telephone Company","suppliers":"NEC","landing_points":[{"id":"hirara-japan","name":"Hirara, Japan","country":"Japan","is_tbd":false},{"id":"naha-japan","name":"Naha, Japan","country":"Japan","is_tbd":false},{"id":"nakadomari-japan","name":"Nakadomari, Japan","country":"Japan","is_tbd":false},{"id":"shiraho-japan","name":"Shiraho, Japan","country":"Japan","is_tbd":false}],"notes":null,"url":null},"yuza-tobishima":{"id":"yuza-tobishima","name":"Yuza-Tobishima","length":"31 km","rfs":"2022 February","rfs_year":2022,"is_planned":false,"owners":"NTT","suppliers":"NEC","landing_points":[{"id":"tobishima-japan","name":"Tobishima, Japan","country":"Japan","is_tbd":false},{"id":"yuza-japan","name":"Yuza, Japan","country":"Japan","is_tbd":false}],"notes":null,"url":null},"zayo-festoon":{"id":"zayo-festoon","name":"Zayo Festoon","length":null,"rfs":"2015","rfs_year":2015,"is_planned":false,"owners":"Zayo","suppliers":"SubCom","landing_points":[{"id":"los-angeles-ca-united-states","name":"Los Angeles, CA, United States","country":"United States","is_tbd":false},{"id":"san-luis-obispo-ca-united-states","name":"San Luis Obispo, CA, United States","country":"United States","is_tbd":false},{"id":"santa-barbara-ca-united-states","name":"Santa Barbara, CA, United States","country":"United States","is_tbd":false}],"notes":null,"url":null},"zeus":{"id":"zeus","name":"Zeus","length":null,"rfs":"2022 July","rfs_year":2022,"is_planned":false,"owners":"Zayo","suppliers":"Hexatronic","landing_points":[{"id":"zandvoort-netherlands","name":"Zandvoort, Netherlands","country":"Netherlands","is_tbd":false},{"id":"lowestoft-united-kingdom","name":"Lowestoft, United Kingdom","country":"United Kingdom","is_tbd":false}],"notes":null,"url":"https://zayoeurope.com/"}}} \ No newline at end of file diff --git a/apps/web/public/data/subcables/cable-geo.json b/apps/web/public/data/subcables/cable-geo.json new file mode 100644 index 0000000..651d0c7 --- /dev/null +++ b/apps/web/public/data/subcables/cable-geo.json @@ -0,0 +1 @@ +{"type":"FeatureCollection","name":"submarine_cables","crs":{"type":"name","properties":{"name":"urn:ogc:def:crs:OGC:1.3:CRS84"}},"features":[{"type":"Feature","properties":{"id":"le-vasa","name":"Le Vasa","color":"#939597","feature_id":"le-vasa-0","coordinates":[-170.32784271097782,-15.326581845302284]},"geometry":{"type":"MultiLineString","coordinates":[[[-170.69570484162125,-14.276544564158804],[-170.38838405751085,-14.652114975378018],[-170.22389530791605,-16.48461818896953]]]}},{"type":"Feature","properties":{"id":"apx-east","name":"APX East","color":"#939597","feature_id":"apx-east-0","coordinates":[-158.27113338492862,19.750197618517358]},"geometry":{"type":"MultiLineString","coordinates":[[[151.20699711948467,-33.86955173177822],[152.0782218382938,-34.02550502281781],[154.79996457419256,-32.7971829776099],[160.65491043157013,-30.92010189533491],[166.46655787137254,-26.350166054761747],[171.08750007969573,-23.120800845543467],[178.93328068350618,-18.437353927623583],[179.99916547224635,-18.061134844662394]],[[178.43744782917764,-18.123810943537187],[178.73193115309707,-18.559310645747416]],[[-179.9997982511102,-18.061131292991963],[-179.42038154188631,-17.39087476566283],[-178.74461906750656,-15.006817032918805],[-174.85607630903357,-6.488084689915104],[-170.61177896620688,2.034307151732652],[-164.88359854945884,10.466900881405515],[-158.3545375332775,19.60543660356217],[-157.612209709934,20.89386327374593],[-157.1766943014276,21.31382923952174],[-153.02181855404697,23.579250199271907],[-147.51596477447862,25.207427175713182],[-138.5998275786419,26.642332131240735],[-127.7443269907507,30.931712269623308],[-117.59240577997376,32.74410431204178],[-117.16576683559319,32.70640764608389]],[[-158.08325,21.33416151999962],[-157.612209709934,20.89386327374593]]]}},{"type":"Feature","properties":{"id":"i-am-cable","name":"I-AM Cable","color":"#939597","feature_id":"i-am-cable-0","coordinates":[118.75874804835755,16.79348078083099]},"geometry":{"type":"MultiLineString","coordinates":[[[104.00411055785635,1.373499297000449],[104.94056871477744,1.736802757529686],[104.11414047991015,1.925884465105483]],[[104.94056871477744,1.736802757529686],[105.23637637273234,1.570164819342955],[108.77548545901945,1.892657008485546],[109.12499082666093,2.250177622978689],[109.83178358835691,2.952621888151889],[111.19765176138824,3.455890703299274],[114.40336233155375,5.789850566899605],[116.49540191466217,8.708813834577057],[119.08615923318717,12.264847835706872],[118.69402717326875,15.19740563655082],[118.79999007691224,17.81054639660834],[119.5708391396146,18.516117231747454],[120.14998912056028,19.02541587807438],[121.17742979836576,19.64951741529273],[122.9440789374799,20.680547036291784],[124.97763468202892,22.0068603894298],[125.68750771631929,23.606545664540995],[126.39045223093163,25.57323317368417],[126.97650517696137,26.30123502337342],[127.70079480862088,28.509888502156297],[127.95091132809287,29.540507745394493],[128.52566678030564,30.091027829845515],[127.68084378563216,31.39196231991538],[127.32517934949227,31.656682201919793],[128.48826446305196,34.32871402630493],[128.99949285148878,35.17037876180022]],[[127.32517934949227,31.656682201919793],[127.89442654126425,32.27726247137304],[129.67643493815459,33.95373326203758],[129.98803709203204,33.96159035645912],[130.40164185819341,33.59022724332908]],[[128.52566678030564,30.091027829845515],[130.674395726876,29.025552386567824],[131.5275258053045,28.952812528511625],[135.08525859472044,30.05919859667051],[137.66282478514836,30.80514961102188],[138.97867305155404,31.561902368184647],[139.72426021939998,33.43328037624602],[139.9190731999997,35.013343599999885]],[[136.7199507151282,30.53294284406713],[137.36641060200284,32.785366191258156],[136.87399727311598,34.33682825203173]]]}},{"type":"Feature","properties":{"id":"barracuda","name":"Barracuda","color":"#939597","feature_id":"barracuda-0","coordinates":[5.069024473018344,41.15519508537312]},"geometry":{"type":"MultiLineString","coordinates":[[[-0.21038561561685,39.662556],[0.483472379851483,39.58670754238123],[2.492748250707712,39.959265640999455],[6.715499215025335,41.91950273465837],[8.867232920304009,43.219996838594724],[9.05556965563947,43.55145303403965],[9.054846151238989,43.820955522508875],[8.938867903561944,44.41035752885385]]]}},{"type":"Feature","properties":{"id":"thetis-express","name":"Thetis Express","color":"#939597","feature_id":"thetis-express-0","coordinates":[24.443247867481475,36.644646867393696]},"geometry":{"type":"MultiLineString","coordinates":[[[23.73618742100205,37.97607797573181],[23.850307765318078,37.64303390027617],[24.075057180943467,37.23235432155614],[24.42759599310572,36.69916038653897],[24.655778198324487,35.90443032966631],[24.981892475437025,35.60332732895848],[25.1278602660048,35.33839682411487]]]}},{"type":"Feature","properties":{"id":"antigua-st-kitts","name":"Antigua-St.Kitts","color":"#276fac","feature_id":"antigua-st-kitts-0","coordinates":[-62.30109480148145,17.253804274762178]},"geometry":{"type":"MultiLineString","coordinates":[[[-62.729761325708566,17.298635546518675],[-62.48121517710202,17.39781910572708],[-62.14016494710522,17.125133180804305],[-61.85794194331368,17.051481714044815]]]}},{"type":"Feature","properties":{"id":"panam-south","name":"PanAm South","color":"#c86d28","feature_id":"panam-south-0","coordinates":[-80.41343461451028,3.3764964380344193]},"geometry":{"type":"MultiLineString","coordinates":[[[-79.53670942010494,8.964826000000414],[-79.47249508697996,8.218897733240102],[-78.8937724634585,7.259764560250654],[-78.9945475385525,5.061986954416114],[-81.25182445429934,2.380576362406587],[-81.71381918802314,0.568578852526193],[-81.40623013196011,-1.906094995190245],[-80.9263332794323,-2.276284296577346]]]}},{"type":"Feature","properties":{"id":"grand-bahama-bimini-submarine-cable","name":"Grand Bahama Bimini Submarine Cable","color":"#c86d28","feature_id":"grand-bahama-bimini-submarine-cable-0","coordinates":[-78.94767106902685,26.061450076344837]},"geometry":{"type":"MultiLineString","coordinates":[[[-78.81193048042013,26.536259889701554],[-78.96232205155829,26.010202101419708],[-79.29391256085444,25.720603599533877]]]}},{"type":"Feature","properties":{"id":"adamasia-cable-system-1","name":"Adamasia Cable System 1","color":"#939597","feature_id":"adamasia-cable-system-1-0","coordinates":[159.97629280393255,-6.967845031985698]},"geometry":{"type":"MultiLineString","coordinates":[[[161.2246209360468,-4.473704847762091],[159.89346682468684,-7.133330049762573],[159.8937666160936,-8.008623782040287],[160.25715498894073,-8.362421764470914],[160.13767651393036,-8.70116679265294],[159.94980844854496,-8.749364592835976],[159.94980844854496,-9.417176735429976]]]}},{"type":"Feature","properties":{"id":"red-hook-little-saint-james","name":"Red Hook-Little Saint James","color":"#c45527","feature_id":"red-hook-little-saint-james-0","coordinates":[-64.83241945567785,18.31236130004825]},"geometry":{"type":"MultiLineString","coordinates":[[[-64.83920983135545,18.32460417009705],[-64.82562908000025,18.300118429999443]]]}},{"type":"Feature","properties":{"id":"verena","name":"Verena","color":"#939597","feature_id":"verena-0","coordinates":[4.018840383165226,54.87980262900769]},"geometry":{"type":"MultiLineString","coordinates":[[[-0.411160999999824,54.27636578581614],[7.618209414856966,55.37009409803965],[8.21669807969593,55.403943032561145],[8.447237362321745,55.46495334929468]]]}},{"type":"Feature","properties":{"id":"dhivaru","name":"Dhivaru","color":"#939597","feature_id":"dhivaru-0","coordinates":[74.54454111498403,-1.4353149100485663]},"geometry":{"type":"MultiLineString","coordinates":[[[58.40778285872253,23.58412999999983],[59.12890734816923,23.95691876049908],[59.85003265596724,23.613306477496128],[61.43184006201105,22.443137450517234],[61.43184006201105,17.70450691214013],[66.58928706346421,4.200837482124073],[72.96721004468763,-0.856036275299538],[95.01270223995415,-8.952296383106004],[105.697069844276,-10.437358645528692]],[[73.08918245887737,-0.605519711481727],[72.96721004468763,-0.856036275299538]]]}},{"type":"Feature","properties":{"id":"fastnet","name":"Fastnet","color":"#939597","feature_id":"fastnet-0","coordinates":[-42.130994975499426,45.9247091404965]},"geometry":{"type":"MultiLineString","coordinates":[[[-75.0868362216179,38.333973036424055],[-74.15954119809128,38.98903709691993],[-71.20833234451034,40.384331936905745],[-68.39383801346253,40.87581011427994],[-61.11086880126824,41.90275121351782],[-50.3984584393863,43.90604356235435],[-39.60007920054038,46.54268254653617],[-23.399918393327315,50.45145951678565],[-16.199914287888834,50.32426129232395],[-10.759631602539102,50.4569444653093],[-8.963843834000034,51.557783405563]]]}},{"type":"Feature","properties":{"id":"longyearbyen-ny-lesund","name":"Longyearbyen-Ny-Ålesund","color":"#cf3a26","feature_id":"longyearbyen-ny-lesund-0","coordinates":[11.792249362979419,78.12717327583434]},"geometry":{"type":"MultiLineString","coordinates":[[[15.642983075583015,78.21811207261818],[14.399947770389728,78.1805481048246],[13.439915521912717,78.19553573312052],[12.263388654575373,78.07462869329086],[11.14997050809113,78.19880447898082],[10.33406490277247,78.54942321411524],[10.153754271763217,78.86018755510426],[10.622561912388363,78.98059783611505],[11.555669427862263,79.02102059975907],[12.00644600538694,78.96594066644326],[11.921007920000019,78.92419333144106]],[[15.642983075583015,78.21811207261818],[14.390139242279188,78.19893286543507],[13.425633978043717,78.21532136999372],[12.264958151590536,78.10800320893884],[11.267757793652537,78.21811207261818],[10.489481769969634,78.55852997828387],[10.296713510676524,78.85337381187666],[10.708671691332297,78.96094079551753],[11.535857562016208,79.00093560089958],[11.889317022209221,78.96157484541638],[11.921007920000019,78.92419333144106]]]}},{"type":"Feature","properties":{"id":"portsmouth-ryde-11","name":"Portsmouth-Ryde 11","color":"#50429a","feature_id":"portsmouth-ryde-11-0","coordinates":[-1.0997943654086653,50.74599612197139]},"geometry":{"type":"MultiLineString","coordinates":[[[-1.163284145999637,50.7293040384293],[-1.103827181491531,50.741177116843836],[-1.078430423480916,50.77152491961015],[-1.087284,50.80401000000017]]]}},{"type":"Feature","properties":{"id":"portsmouth-ryde-10","name":"Portsmouth-Ryde 10","color":"#923e97","feature_id":"portsmouth-ryde-10-0","coordinates":[-1.1252840729997904,50.76665701921473]},"geometry":{"type":"MultiLineString","coordinates":[[[-1.087284,50.80401000000017],[-1.163284145999637,50.7293040384293]]]}},{"type":"Feature","properties":{"id":"cowes-fawley-2","name":"Cowes-Fawley 2","color":"#8fc73e","feature_id":"cowes-fawley-2-0","coordinates":[-1.3444650684999404,50.77350782666271]},"geometry":{"type":"MultiLineString","coordinates":[[[-1.367483365000111,50.78676515744321],[-1.32144677199977,50.7602504958822]]]}},{"type":"Feature","properties":{"id":"kardesa","name":"Kardesa","color":"#939597","feature_id":"kardesa-0","coordinates":[34.68143413079067,42.206094956360786]},"geometry":{"type":"MultiLineString","coordinates":[[[41.66752471827441,42.14675635811542],[41.41783617710218,41.97043253752729],[35.57410782973017,42.14987092248887],[27.648440999999888,42.649060000000276]],[[29.91841246536385,42.50649430985511],[30.95183895748293,46.37235771784133],[30.684537421371765,46.48929000000015]],[[29.632133841504242,41.18215605694779],[30.43512571142348,42.47399656722068]]]}},{"type":"Feature","properties":{"id":"asia-united-gateway-east-aug-east","name":"Asia United Gateway East (AUG East)","color":"#939597","feature_id":"asia-united-gateway-east-aug-east-0","coordinates":[120.52294918662136,21.194716626934035]},"geometry":{"type":"MultiLineString","coordinates":[[[103.98701057056589,1.389451396800233],[104.36205904297623,1.270050569926827],[104.0166370000003,1.066798000000349]],[[104.36205904297623,1.270050569926827],[104.87449000000015,1.892657008485546],[105.01938569859679,2.694535330212616],[105.6249994591074,3.122369746724294],[108.12382576441128,4.52214502236528],[110.04937200000029,5.3874206225493],[110.77665596587559,5.851759500300678],[114.12509490580292,7.582140257363066],[116.33876816197802,8.922093709519404],[118.95653880706887,12.3233643030216],[118.54461916347348,15.204850542914642],[118.68094913578786,17.860172049184143],[119.6356458719126,18.712816516277243],[120.07376012753146,19.097338103155522],[120.15077449851793,19.766307370739014],[120.60995966425898,21.52866349547806],[121.00227992030457,21.515456423470585],[122.27260117306696,22.41379429784117],[122.81360897983394,24.28586318606878],[125.35516121382157,27.94597135692341],[128.9423827125133,30.00041066943345],[130.46892712243186,29.23580178344556],[135.04811304345853,29.018742247699468],[137.7291107270958,29.776436679647418],[138.86036096921643,30.49131312259013],[139.68430240954356,31.888856797764863],[140.59357815315917,32.88889579845178],[140.00231880638776,35.01763482425761]],[[126.73662929999966,35.96767720000027],[125.87119947589126,35.24748606079355],[125.29998547165854,34.21298798248237],[127.09744513582127,30.889576581749527],[128.20623341284528,29.582151688140414]],[[121.81483169057424,24.644044899818205],[122.54589674250994,23.362789999999872]],[[120.88977859589048,22.340450000000153],[121.56833222233168,21.924689276263475]],[[118.6425127475267,17.115276587092033],[120.35483897484683,16.830716773770945]],[[104.11414047991015,1.925884465105483],[104.87449000000015,1.892657008485546]],[[111.39672165701015,6.171501577529976],[113.00481498190783,5.487590881986131],[114.23759482610686,4.588647828574142]]]}},{"type":"Feature","properties":{"id":"trans-global-cable-system-tgcs","name":"Trans Global Cable System (TGCS)","color":"#939597","feature_id":"trans-global-cable-system-tgcs-0","coordinates":[108.3903467197402,-1.7671951438773597]},"geometry":{"type":"MultiLineString","coordinates":[[[104.0166370000003,1.066798000000349],[105.2644449676135,0.513354402238476],[107.00210046743302,-0.56027046929081],[109.26562183055219,-2.528148848261428],[107.30141322261214,-5.440604564272835],[107.11508624463684,-5.98327122197656]],[[109.96894164481395,-1.881623426349563],[109.59906885996025,-2.108947754569495],[109.26562183055219,-2.528148848261428]]]}},{"type":"Feature","properties":{"id":"tmx5","name":"TMX5","color":"#ae4b9c","feature_id":"tmx5-0","coordinates":[-108.06170450247926,23.137360575203054]},"geometry":{"type":"MultiLineString","coordinates":[[[-109.70490628441821,23.077049],[-106.4185027205403,23.197672150406124]]]}},{"type":"Feature","properties":{"id":"candle","name":"Candle","color":"#939597","feature_id":"candle-0","coordinates":[124.65330659048438,15.00880984412805]},"geometry":{"type":"MultiLineString","coordinates":[[[103.98701057056589,1.389451396800233],[104.79111328158616,1.389440999999453],[108.80828174287835,1.736802757529686],[109.83178358835691,2.732428351392954],[111.39530787136668,3.27891318606882],[114.51459277101094,5.678236514249047],[116.08153497084551,7.767275218496221],[116.69308743865093,8.771383773025718],[119.59989185340848,12.602955744095647],[120.18280159671971,13.135750445218061],[120.60765565086099,13.572496884445817],[121.2985234625819,13.6150212673131],[122.39559299790758,12.75504864040869],[123.44753124604064,12.714337493644035],[124.09520116888906,12.319162730128816],[124.5097019530358,12.899450499230491],[125.13103838718632,22.026048616021743],[125.82482710987084,23.605964017138188],[127.32790884999972,24.81255270367667],[136.02580082175842,29.171402207908102],[137.7369866617662,29.63361253688773],[138.99232836452646,30.3690092136078],[140.17497493467252,32.43331330641721],[139.9766352308324,34.98004652018856]],[[104.0166370000003,1.066798000000349],[104.43613306430733,1.371386341516657],[104.38095732214448,1.745889155817368],[104.1120592000004,1.934672203856039]],[[124.70338539586164,16.18456011212011],[123.51328398507665,15.788534760565602],[122.2022204659374,15.763215182399467],[121.56019319908759,15.761538854943385]],[[120.62298878548306,14.088232047424347],[120.60765565086099,13.572496884445817]],[[121.80145279439779,24.863504254255204],[123.10327422030367,23.605964017138188],[125.13103838718632,22.026048616021743]]]}},{"type":"Feature","properties":{"id":"mobily-red-sea-cable-mrsc","name":"Mobily Red Sea Cable (MRSC)","color":"#939597","feature_id":"mobily-red-sea-cable-mrsc-0","coordinates":[34.961760181479384,27.46848420506761]},"geometry":{"type":"MultiLineString","coordinates":[[[34.33400486710986,27.911373381384173],[35.11065407969618,27.363437709354482],[35.696548947573824,27.354010300438862]]]}},{"type":"Feature","properties":{"id":"challenger-one-bermuda","name":"Challenger One Bermuda","color":"#5bba46","feature_id":"challenger-one-bermuda-0","coordinates":[-64.68092713897882,32.24912552361271]},"geometry":{"type":"MultiLineString","coordinates":[[[-64.63356460651755,32.158659496218654],[-64.6570088672744,32.24208387315947],[-64.78869199999986,32.28085199999989]]]}},{"type":"Feature","properties":{"id":"americas-ii-west","name":"Americas-II West","color":"#398eb1","feature_id":"americas-ii-west-0","coordinates":[-65.35452056157669,18.419355498735765]},"geometry":{"type":"MultiLineString","coordinates":[[[-66.07866673623955,18.452866862397144],[-65.60705702237637,18.621328],[-65.40270453172457,18.548726193807486],[-65.27164314025264,18.196835237961572],[-64.81925984548825,17.773909269375704]]]}},{"type":"Feature","properties":{"id":"daraja","name":"Daraja","color":"#939597","feature_id":"daraja-0","coordinates":[49.658916247719006,5.203587902722357]},"geometry":{"type":"MultiLineString","coordinates":[[[39.700146111983855,-4.050300939496507],[42.22020300218812,-3.01236914670285],[44.91427872330646,-0.786868249870226],[46.8367502330365,1.665004650489933],[53.260583380047024,9.719551502406592],[54.52122701824514,12.571397298876226],[53.646667929443154,13.296749576590368],[54.05940130585225,16.535252410268676],[54.14808587692784,17.09582718672565]]]}},{"type":"Feature","properties":{"id":"ithaafushi-maafushi-hulhumale","name":"Ithaafushi-Maafushi-Hulhumale","color":"#41b878","feature_id":"ithaafushi-maafushi-hulhumale-0","coordinates":[73.50361650995771,4.011634139896173]},"geometry":{"type":"MultiLineString","coordinates":[[[73.39404966892153,4.014321541056545],[73.48945823632215,3.933495316217852],[73.54015885755544,4.213309579234831]]]}},{"type":"Feature","properties":{"id":"farewell-change-fogo","name":"Farewell-Change-Fogo","color":"#50429a","feature_id":"farewell-change-fogo-0","coordinates":[-54.38380954792905,49.583938690000025]},"geometry":{"type":"MultiLineString","coordinates":[[[-54.479165000000414,49.55587482999968],[-54.395227,49.583938690000025],[-54.28367199999955,49.583938690000025]]]}},{"type":"Feature","properties":{"id":"indonesia-tengah-cable-systems","name":"Indonesia Tengah Cable Systems","color":"#939597","feature_id":"indonesia-tengah-cable-systems-0","coordinates":[123.22932132084962,-5.491358706544018]},"geometry":{"type":"MultiLineString","coordinates":[[[122.78695014254146,-0.940804501478792],[122.76297382289759,-1.213473230420634],[122.40047407969566,-2.005575400762364],[122.76297382289759,-3.645746178185322],[122.51297744659723,-3.998469097245179],[123.10247726276904,-3.927446994857488],[123.41503418012888,-4.023933949248898],[123.4758656701322,-4.973780077887056],[123.01138846011035,-5.948872300057597],[120.33205136778386,-5.692006790344558],[119.85633775440675,-6.109439521000439],[118.90491052765273,-5.669034918116874],[118.8705806792643,-5.472545019730354],[119.4065814434764,-5.154202750052714]],[[122.59686238717225,-5.507088069175421],[122.55989952213733,-5.905596746400206]],[[123.53383740000034,-5.321967643999664],[123.35330168599957,-5.231234621895154]],[[122.52825891955072,-2.580131292673987],[122.16745769077058,-2.829852359000335]],[[119.85633775440675,-6.109439521000439],[119.9999892262257,-8.13516311328244],[119.87166900462891,-8.494707229308899],[118.9980915447055,-7.814880435472708],[117.86151417923745,-8.10811342968634],[117.33064176585751,-7.581597555461874],[116.67139658423207,-7.683674603934793],[116.02120326305703,-8.055915389179498],[115.77833341374131,-9.010937529173344],[115.33144100000015,-9.032546822866914],[115.25956289748402,-8.695101915602985]],[[120.46336470000011,-6.121299811069376],[120.14163242833332,-5.85913466197567]]]}},{"type":"Feature","properties":{"id":"manx-northern-ireland","name":"Manx-Northern Ireland","color":"#b99633","feature_id":"manx-northern-ireland-0","coordinates":[-5.125428499999894,54.2605979828592]},"geometry":{"type":"MultiLineString","coordinates":[[[-5.559439999999554,54.3021],[-4.691417000000235,54.21909596571838]]]}},{"type":"Feature","properties":{"id":"jako","name":"JAKO","color":"#939597","feature_id":"jako-0","coordinates":[129.9150381059809,34.54274037490852]},"geometry":{"type":"MultiLineString","coordinates":[[[128.999482851496,35.17030187110516],[130.12665587776488,34.39768832607582],[130.40164185819341,33.59022724332908]]]}},{"type":"Feature","properties":{"id":"taiwan-matsu-no-4","name":"Taiwan-Matsu No.4","color":"#939597","feature_id":"taiwan-matsu-no-4-0","coordinates":[120.7187179769595,26.10949725044253]},"geometry":{"type":"MultiLineString","coordinates":[[[121.46258819070279,25.168986122701572],[121.09588688738157,25.678720354955857],[120.49296999999949,26.36733136664364],[120.32426131491239,26.15516],[119.93434927272565,26.15516],[119.96093577202058,26.07423794659616],[119.96093577202058,26.032479263492196],[119.93907578761032,25.967044518254372]]]}},{"type":"Feature","properties":{"id":"sol","name":"Sol","color":"#939597","feature_id":"sol-0","coordinates":[-40.91502897305539,34.55949833109139]},"geometry":{"type":"MultiLineString","coordinates":[[[-81.22094335999995,29.5685423804529],[-73.34993072903801,30.97027945496055],[-64.02577349475438,31.895737453957153],[-61.85481927288275,31.74295976790169],[-50.00000020746783,33.13361886908059],[-39.99999508453427,34.70311221769831],[-25.456507920164015,37.35549021792695],[-16.199914288484827,44.694829089578164],[-9.899918750864702,45.646541495187385],[-5.810956804369809,45.16903272799839],[-4.871846141519585,44.66863115657076],[-3.833321486595139,43.45394044407752]],[[-64.02577349475438,31.895737453957153],[-64.65917995948635,32.36157723537831]],[[-25.676797574971758,37.74037606999961],[-25.456507920164015,37.35549021792695]]]}},{"type":"Feature","properties":{"id":"mjolner-east","name":"Mjolner East","color":"#939597","feature_id":"mjolner-east-0","coordinates":[20.521595305424217,58.26490685123449]},"geometry":{"type":"MultiLineString","coordinates":[[[19.05532073637588,57.863008],[19.816420197802014,58.14211813680734],[21.150303392895644,58.37438073506294],[22.03810158448305,58.36002823118932]],[[24.752496701038964,59.43639985926234],[25.182562822898138,59.67073492362169],[25.30326504260355,59.97654868297596],[24.932476573539617,60.171163188940554]]]}},{"type":"Feature","properties":{"id":"mjolner-west","name":"Mjolner West","color":"#939597","feature_id":"mjolner-west-0","coordinates":[19.917638194773204,60.52450057505016]},"geometry":{"type":"MultiLineString","coordinates":[[[18.370373721598764,60.25835727137014],[19.260073449326246,60.29099375678491],[20.7159775287011,60.80799764366938],[21.446235299999714,60.67946870000003]]]}},{"type":"Feature","properties":{"id":"rising-8","name":"RISING 8","color":"#939597","feature_id":"rising-8-0","coordinates":[106.6501894015858,-1.9342225399527513]},"geometry":{"type":"MultiLineString","coordinates":[[[107.12099835041957,-5.981154260263285],[106.4105991269382,-4.602669830685668],[106.97880809970745,-2.960568197938907],[106.51577713492394,-1.514424618967124],[105.40671542294676,0.176337305363287],[104.94099929758029,0.72341616711832],[104.53830626067602,0.869187053477447],[104.13320046700375,1.173685663377224],[103.98701057056589,1.375392999849927]]]}},{"type":"Feature","properties":{"id":"project-waterworth","name":"Project Waterworth","color":"#939597","feature_id":"project-waterworth-0","coordinates":[5.863543126433965,-27.98841784639247]},"geometry":{"type":"MultiLineString","coordinates":[[[-78.88266988343256,33.69355790837514],[-74.2367384090291,31.895737453957153],[-56.93747717996609,23.619667749347347],[-52.10709095043794,16.57776764106849],[-38.292979177102396,1.433940786388127],[-37.82901999752645,-1.243529466953686],[-38.54877540946694,-3.720856079754825],[-35.947227022705135,-3.059997375917503],[-34.024755512974004,-3.469028991515528],[-31.65109171014376,-4.861397229447735],[13.886634799254555,-32.93449308708872],[16.894583419921453,-34.322821702626335],[18.043255079695783,-34.230964637690946],[18.41612564231266,-33.92084632399508],[18.171981177101898,-34.845022128572865],[18.74576413017278,-35.773488145151255],[19.623400227197045,-35.98012665125699],[23.413985104279334,-35.98012665125699],[27.97749587157728,-35.20204261409993],[38.62374269486827,-29.15403653793159],[45.29070355814658,-29.16507618637023],[47.34269069793371,-28.66595753524189],[56.973482479608386,-23.083727842143475],[62.53039521042826,-14.511535911566813],[65.57437726936688,-10.963562200511431],[74.66037634553088,-5.454560702582534],[76.26954099999939,-2.786733343456576],[76.85773463235368,0.847399963023893],[76.05002036139189,4.093063841323328],[75.63713069157988,8.283573814251765],[73.9070597935403,11.529557268608945],[73.90074005700309,11.538453807348953],[73.89027785728716,11.55119668203278],[73.87584899910533,11.56762651126271],[73.85762928717041,11.587583913641023],[73.83579452619524,11.610909507770005],[73.81052052089265,11.637443912251937],[73.78198307597543,11.667027745689104],[73.75035799615642,11.699501626683789],[73.71582108614845,11.734706173838275],[73.67854815066434,11.772482005754842],[73.6387149944169,11.812669741035775],[73.59649742211894,11.855109998283359],[73.55207123848332,11.899643396099872],[73.50561224822283,11.946110553087603],[73.45729625605031,11.994352087848831],[73.40729906667858,12.04420861898584],[73.35579648482046,12.095520765100915],[73.30296431518876,12.148129144796334],[73.2489783624963,12.201874376674384],[73.19401443145593,12.256597079337348],[73.13824832678046,12.312137871387508],[73.08185585318269,12.368337371427147],[73.02501281537546,12.425036198058548],[72.9678950180716,12.482074969883993],[72.91067826598392,12.539294305505766],[72.85353836382525,12.596534823526152],[72.7966511163084,12.653637142547431],[72.74019232814621,12.710441881171887],[72.68433780405148,12.766789658001803],[72.62926334873704,12.822521091639462],[72.57514476691573,12.877476800687148],[72.52215786330034,12.93149740374714],[72.47047844260372,12.984423519421728],[72.42028230953868,13.036095766313188],[72.37174526881803,13.086354763023808],[72.32504312515462,13.13504112815587],[72.28035168326124,13.181995480311652],[72.23784674785075,13.227058438093444],[72.19770412363594,13.270070620103526],[72.16009961532964,13.31087264494418],[72.12520902764467,13.34930513121769],[72.09320816529387,13.38520869752634],[72.06427283299004,13.418423962472412],[72.03857883544602,13.448791544658189],[72.01630197737461,13.476152062685951],[71.99761806348866,13.500346135157987],[71.98270289850096,13.521214380676577],[71.97173228712435,13.538597417844004],[71.9623279440557,13.562270341534498],[71.9623279440557,13.562270341534498],[71.9623279440557,16.965102599435927],[72.87590260996693,19.07607425728523]],[[80.23727325388181,13.061502540930007],[81.52150148534334,11.831105221838026],[83.03675798088666,9.555359258837743],[83.42909910532154,7.869634986459248],[84.12223509182334,5.770024991079929],[90.77895616973278,-8.135430886528464],[94.72510478744105,-10.344819867074083],[123.06805345903014,-12.77887632491396],[129.87005785386256,-12.379679526898897],[130.84315500000028,-12.46750362874273],[131.6807426702204,-11.66969729826832],[131.72527388867405,-10.733304228874694],[132.75207980190007,-10.238639981123713],[138.5484135863478,-10.53440199037786],[142.13312784377766,-9.85125498416662],[144.89997158744055,-11.017428674932855],[147.26153671946165,-11.66969729826832],[153.48673308202717,-12.4774587840296],[160.98814378358247,-12.4774587840296],[170.00000263387216,-10.000034051545839],[179.99994672169302,3.097072904518241]],[[-179.9997982511102,3.097077087813343],[-169.96832199026696,16.17794031173706],[-161.61408917916899,23.0378173020752],[-147.45057458707294,30.255702942039875],[-127.79047240201581,33.529146127585946],[-122.38731473421552,33.477440406239914],[-120.6195929927509,33.45053234181001],[-118.90292030393906,33.82651440184361],[-118.24535355799169,34.053396879397056]],[[76.85773463235368,0.847399963023893],[80.97374466123836,1.869852037928076],[85.500013666928,-1.270676198168406],[87.29076711246154,-0.856753963754112]],[[87.72300880490236,-1.761851807844764],[90.09946213585373,-1.292098430153732],[92.73770385158454,3.460265883431271],[94.3164380847574,4.500452898358986],[95.47224819004255,5.742729330133516],[97.42500521915215,6.160631743962457],[99.6847692427181,5.86388465224445],[100.40982310408316,5.368393581488473]],[[33.490096931155925,-32.120588991728155],[31.980099654439186,-30.22719186196053],[30.88039235938437,-30.066210326831214]]]}},{"type":"Feature","properties":{"id":"qe-south","name":"Q&E South","color":"#939597","feature_id":"qe-south-0","coordinates":[0.7476257966814944,50.38235094939175]},"geometry":{"type":"MultiLineString","coordinates":[[[0.046277779436353,50.79292282976606],[1.150443761087762,50.14653973295111],[1.493433177977853,50.17917387330185]]]}},{"type":"Feature","properties":{"id":"qe-north","name":"Q&E North","color":"#939597","feature_id":"qe-north-0","coordinates":[2.1516856743276356,51.16076158020478]},"geometry":{"type":"MultiLineString","coordinates":[[[1.449096646290332,51.38258705839249],[1.799258657292271,51.16076158020478],[2.483934780389254,51.16076158020478],[2.91277217250473,51.2312205646267]]]}},{"type":"Feature","properties":{"id":"e2a","name":"E2A","color":"#939597","feature_id":"e2a-0","coordinates":[150.20483591605063,35.3395606281797]},"geometry":{"type":"MultiLineString","coordinates":[[[121.80144795065142,24.863504112487785],[122.87827437969564,25.925523115093068],[129.44637107969632,28.381232304422188],[135.05465206220015,28.81841452402809],[137.79450091450283,29.48716901807319],[143.5499725437925,32.81579602205538],[149.37742517921646,35.09752950393123],[172.73711131950242,41.930618376160524],[179.99992719256971,41.93061956425144]],[[-179.99981351108062,41.930618376160524],[-151.17708002193328,41.930618376160524],[-138.88446003691308,39.45836886627482],[-129.6106746966534,35.73186715949322],[-122.84983873608158,34.6306903658982],[-120.85160406826154,35.34417937462789]],[[139.97546699999984,35.005433000000174],[140.5353756278244,34.73523695239605],[142.46336029527683,32.882826065515154],[142.8306622911177,32.40623097592082]],[[130.1400211054319,33.59415838270747],[129.92752125596886,34.2343858923205],[128.99949285148878,35.17037876180022],[127.22759354455998,31.045550176016285],[129.44637107969632,28.381232304422188]],[[141.60319903282527,42.6503368858653],[142.46989931401754,41.2387523289666],[144.5591158016331,40.046427062568554],[149.37742517921646,35.09752950393123]]]}},{"type":"Feature","properties":{"id":"fibre-in-gulf-fig","name":"Fibre in Gulf (FIG)","color":"#939597","feature_id":"fibre-in-gulf-fig-0","coordinates":[53.05222387166369,25.82851006776677]},"geometry":{"type":"MultiLineString","coordinates":[[[47.97630977972595,29.371633383730508],[48.33207124645109,29.57074954758684],[48.5317798555716,29.92363278689715],[50.21426199999971,27.106120810916483],[51.739002426699074,26.463743320783486],[52.30775202379166,26.127977645442854],[53.55232247961318,25.627342865608828],[55.011461045958754,25.756582329883276],[56.279233634860766,26.60351617488129],[56.72184066493084,26.51048843368535],[57.18147788063447,24.202309555449286],[57.88605322832058,23.67872342575357]],[[53.55232247961318,25.627342865608828],[54.419075684956134,24.443964572625426]],[[51.46310743050667,26.56481484059615],[51.212749,26.146578371109786]],[[50.85003821329572,26.82364558689702],[50.65618835062081,26.25817095672994]],[[50.51253845238375,26.964304734562898],[50.214198663730556,26.28537535931817]]]}},{"type":"Feature","properties":{"id":"tanjung-pandan-sungai-kakap","name":"Tanjung Pandan-Sungai Kakap","color":"#a84c9c","feature_id":"tanjung-pandan-sungai-kakap-0","coordinates":[108.00543396858707,-1.1603546988114444]},"geometry":{"type":"MultiLineString","coordinates":[[[109.18222689022609,-0.061391357195038],[108.82736530508038,-0.061391357195038],[108.67782927819995,-0.179555522582304],[107.95935298091241,-1.227571400214666],[107.56616014384186,-2.148726042101377],[107.5300761850291,-2.495065217511268],[107.66288796654008,-2.767442755874634]]]}},{"type":"Feature","properties":{"id":"groote-eylandt","name":"Groote Eylandt","color":"#37b19b","feature_id":"groote-eylandt-0","coordinates":[136.0796653738572,-14.071035115468739]},"geometry":{"type":"MultiLineString","coordinates":[[[135.73435315710455,-14.277517519622181],[136.42497759060987,-13.864552711315296]]]}},{"type":"Feature","properties":{"id":"sovetskaya-gavan-ilyinskoye","name":"Sovetskaya Gavan-Ilyinskoye","color":"#05a5d7","feature_id":"sovetskaya-gavan-ilyinskoye-0","coordinates":[141.24153704911592,48.477331279270494]},"geometry":{"type":"MultiLineString","coordinates":[[[140.28181079589305,48.965235369445],[142.2012633023388,47.989427189095984]]]}},{"type":"Feature","properties":{"id":"tampnet-south","name":"Tampnet South","color":"#2fbfcc","feature_id":"tampnet-south-0","coordinates":[3.278736327545513,56.182247136335604]},"geometry":{"type":"MultiLineString","coordinates":[[[6.786929427419412,58.06444],[6.20773590285459,57.77370115124407],[3.784811151305202,57.05999766701889],[2.700072321395319,55.1785958685009],[2.17686178854155,52.71521693088626],[1.72927301090669,52.46882263773048]]]}},{"type":"Feature","properties":{"id":"tampnet-north","name":"Tampnet North","color":"#97489b","feature_id":"tampnet-north-0","coordinates":[1.9751572601198477,57.76223385607984]},"geometry":{"type":"MultiLineString","coordinates":[[[-2.105148882723821,57.154005529539894],[-1.019603539673328,57.50156765666057],[2.543382434183802,57.811692596339775],[2.812797000086261,58.320872572952],[4.015159331096359,59.00181694401486],[5.45632037003535,59.12232256855451],[5.524480322346307,59.27926758037185]],[[3.784811151305202,57.05999766701889],[2.543382434183802,57.811692596339775]],[[4.015159331096359,59.00181694401486],[4.010328581080518,60.35428947498098],[4.554407710970825,60.566745741422686],[4.830229999999557,60.58733799999982]]]}},{"type":"Feature","properties":{"id":"sx-tasman-express-sx-tx","name":"SX Tasman Express (SX-TX)","color":"#939597","feature_id":"sx-tasman-express-sx-tx-0","coordinates":[162.64509588520926,-37.07495892416717]},"geometry":{"type":"MultiLineString","coordinates":[[[151.2577588980582,-33.91397824558519],[152.0999664863006,-34.68386748625883],[159.32095060849176,-36.85622920274014],[167.40317777184825,-37.3880420895994],[174.62339053109176,-36.78757761230096]]]}},{"type":"Feature","properties":{"id":"padang-tua-pejat","name":"Padang-Tua Pejat","color":"#7c9e3e","feature_id":"padang-tua-pejat-0","coordinates":[99.9753256809168,-1.4860406865001516]},"geometry":{"type":"MultiLineString","coordinates":[[[100.3616870000004,-0.943890000000324],[99.58896436183319,-2.028191372999979]]]}},{"type":"Feature","properties":{"id":"gc-lnz-fu-ring","name":"GC-LNZ-FU Ring","color":"#939597","feature_id":"gc-lnz-fu-ring-0","coordinates":[-14.413379396579455,27.70820083301138]},"geometry":{"type":"MultiLineString","coordinates":[[[-15.417372599999679,27.994844240009535],[-14.470919920303668,27.661377989173356],[-13.577320177102004,28.38853301873347]],[[-13.547572416832688,28.959501465241424],[-13.577320177102004,28.38853301873347],[-13.862220631432923,28.496470563064396]],[[-15.439709999999666,28.128696187104918],[-13.931985677857744,28.811610908811506]],[[-13.82731596925565,28.863419398977502],[-13.931985677857744,28.811610908811506],[-13.862208425006578,28.715982337200522]]]}},{"type":"Feature","properties":{"id":"arctic-way","name":"Arctic Way","color":"#939597","feature_id":"arctic-way-0","coordinates":[4.312682926277877,72.38263793558237]},"geometry":{"type":"MultiLineString","coordinates":[[[15.642983075583015,78.21811207261818],[14.4195648266119,78.15976978593042],[13.471469882339086,78.17388694870507],[12.398621627831993,78.05318583195591],[7.014126102905231,77.0253238524219],[3.247651308331295,70.55228024514132],[-8.73434999999954,70.909133]],[[3.247651308331295,70.55228024514132],[7.617180079696026,66.90144145307879],[10.990984417977389,66.46843210694536],[14.400064034799321,67.28599754127343],[15.392073040652047,67.25991977910047]]]}},{"type":"Feature","properties":{"id":"thailand-domestic-submarine-cable-network-tdscn","name":"Thailand Domestic Submarine Cable Network (TDSCN)","color":"#8aaa3d","feature_id":"thailand-domestic-submarine-cable-network-tdscn-0","coordinates":[99.26496211226205,10.522187085392519]},"geometry":{"type":"MultiLineString","coordinates":[[[100.92475859867298,13.172386821382146],[99.95361807969611,13.09397],[100.18581878985192,12.990582067525851],[99.94765343208446,11.314651075812046],[99.53301158722864,10.607658452356665],[99.17812300000011,10.494497206007695],[99.53301158722864,10.411934558591756],[100.03047212281895,9.526934715081014],[100.23945639927652,9.472915419561902],[100.6804228837668,7.467212832965264],[100.5951029728293,7.198818071264419]]]}},{"type":"Feature","properties":{"id":"cat-submarine-network-csn","name":"CAT Submarine Network (CSN)","color":"#a1489b","feature_id":"cat-submarine-network-csn-0","coordinates":[101.10650859619668,9.957010188028015]},"geometry":{"type":"MultiLineString","coordinates":[[[100.93057273577533,13.174371211662239],[100.80955733311677,12.83342068040501],[100.65941348674991,12.213719908155483],[100.58029314875155,11.276355495242829],[101.58841254322141,8.748764185136569],[101.23954450670672,7.413817960800556],[100.5951029728293,7.198818071264419]]]}},{"type":"Feature","properties":{"id":"orca","name":"ORCA","color":"#939597","feature_id":"orca-0","coordinates":[150.25194174410652,30.598158525345113]},"geometry":{"type":"MultiLineString","coordinates":[[[179.99994672169302,33.31515395812905],[172.79992073307184,32.81233785755136],[149.39996839960057,30.5144959597591],[137.24997700676838,26.36108632539156],[132.74998019460836,24.32780311165181],[128.69998306366443,23.91710129093513],[125.7749851357603,23.81422051502533],[123.07498704846441,24.007054825363046],[121.8014540000001,24.863576000000307]],[[-123.687,38.96962000000024],[-124.64983746154178,38.651811712711336],[-129.5998339543217,36.87321951208928],[-139.00520928888923,37.327605702523414],[-151.1998186532859,35.90725015614043],[-163.79980972733406,34.43595690575565],[-179.9998012760825,33.31515395812905]],[[-129.5998339543217,36.87321951208928],[-122.3998390548656,33.565491482352044],[-120.59984033000157,33.51860823841915],[-118.39534899999987,33.86403999999962]]]}},{"type":"Feature","properties":{"id":"cyclades-a","name":"Cyclades A","color":"#4aba79","feature_id":"cyclades-a-0","coordinates":[24.67009331338145,37.682322894534245]},"geometry":{"type":"MultiLineString","coordinates":[[[24.939288762213785,37.43946272240985],[25.0125565162143,37.45843001852099],[25.237556356822154,37.48075214889993],[25.32858123375337,37.44688214068393]],[[24.939288762213785,37.43946272240985],[25.0125565162143,37.43610122304901],[25.238146034548574,37.1222334145154]],[[24.053807052931386,37.711082576559505],[24.11255715378215,37.7480973319106],[24.337556994390187,37.7480973319106],[24.78755667560626,37.659089081800325],[24.939288762213785,37.43946272240985],[24.984431536138228,37.45843001852099],[25.019204911285403,37.65232235984693]]]}},{"type":"Feature","properties":{"id":"cyclades-b","name":"Cyclades B","color":"#824198","feature_id":"cyclades-b-0","coordinates":[25.38123694997653,37.279695587530654]},"geometry":{"type":"MultiLineString","coordinates":[[[25.32858123375337,37.44688214068393],[25.293811316970697,37.41376576388587],[25.406106237419756,37.241557690912636],[25.349851589771077,37.151942480084415],[25.375974548484432,37.101840262262826]],[[25.238146034548574,37.1222334145154],[25.2938063169743,37.14522100249008],[25.375974548484432,37.101840262262826]]]}},{"type":"Feature","properties":{"id":"lake-tanganyika","name":"Lake Tanganyika","color":"#d77e27","feature_id":"lake-tanganyika-0","coordinates":[29.337553452345844,-4.70329064603402]},"geometry":{"type":"MultiLineString","coordinates":[[[29.149937199999965,-3.377679800000496],[29.225053532041734,-3.628980032547918],[29.281303492193878,-3.965738331112359],[29.337553452345844,-4.526691650331427],[29.337553452345844,-4.975144051242498],[29.45005337264977,-5.647241633339604],[29.337553452345844,-5.871105633944185],[29.203626000000206,-5.913414799999944]]]}},{"type":"Feature","properties":{"id":"lake-albert-1","name":"Lake Albert 1","color":"#bb8033","feature_id":"lake-albert-1-0","coordinates":[30.572324494999837,1.277493698500019]},"geometry":{"type":"MultiLineString","coordinates":[[[30.48106013999982,1.439963724999737],[30.66358884999985,1.115023672000301]]]}},{"type":"Feature","properties":{"id":"lake-albert-2","name":"Lake Albert 2","color":"#bb8033","feature_id":"lake-albert-2-0","coordinates":[30.5434221300001,1.2474964754996312]},"geometry":{"type":"MultiLineString","coordinates":[[[30.438399210000323,1.39338882199983],[30.648445049999875,1.101604128999433]]]}},{"type":"Feature","properties":{"id":"celia","name":"CELIA","color":"#939597","feature_id":"celia-0","coordinates":[-70.74409649460407,22.659466659834585]},"geometry":{"type":"MultiLineString","coordinates":[[[-62.04684985831844,17.063995828128565],[-61.987381852809214,16.590029438056266],[-61.76238201160518,15.452760959322058],[-61.649882091301166,15.018578573757472],[-61.48113221084519,14.801154224791581],[-60.988923800000265,14.627643199999655]],[[-65.69987922164921,19.316876111628712],[-66.09157894476144,18.678647022154717],[-66.10666899999953,18.46602999999961]],[[-67.49987794710923,20.58581909604039],[-67.94987762832521,18.678647022154717],[-68.28737738923726,17.82393441253792],[-68.62487715014923,15.23578178303578],[-69.29987667256924,13.92930384327183],[-69.87869399999971,12.414199000000362]],[[-80.088937,26.350505],[-78.86236989721739,26.86399017396059],[-77.84987061448132,27.264711877833996],[-77.39987093326533,27.164665812813517],[-76.83737133234132,26.562513149236715],[-76.49367157582148,26.116791262410242],[-76.24064175447413,25.90309444015418],[-73.3498738023213,24.12261698700344],[-70.19987599438629,22.35388551881099],[-67.49987794710923,20.58581909604039],[-65.69987922164921,19.316876111628712],[-64.06334834013599,19.182671634899826],[-62.54988145373323,18.251816319028222],[-62.09988177251724,17.395022634700517],[-62.04684985831844,17.063995828128565],[-61.84685007059131,17.01759004073784]],[[-69.44168277801548,13.558989085631117],[-68.84444328532055,12.823547587318025],[-68.89264695986267,12.09043961830498],[-68.58839717569401,12.33992421417949],[-68.26554560000007,12.144349099999685]]]}},{"type":"Feature","properties":{"id":"awashima-murakami","name":"Awashima-Murakami","color":"#61bb46","feature_id":"awashima-murakami-0","coordinates":[139.3170687086501,38.29678084080312]},"geometry":{"type":"MultiLineString","coordinates":[[[139.25283649999992,38.464632729999536],[139.337475527369,38.358355301718596],[139.3917254889377,38.27008535010159],[139.47574369999958,38.22191642000002]],[[139.25283649999992,38.464632729999536],[139.2792255686338,38.358355301718596],[139.3334755302025,38.27008535010159],[139.47574369999958,38.22191642000002]]]}},{"type":"Feature","properties":{"id":"tasman-ring-network","name":"Tasman Ring Network","color":"#939597","feature_id":"tasman-ring-network-0","coordinates":[163.1243917769377,-37.4755324388374]},"geometry":{"type":"MultiLineString","coordinates":[[[174.08333300000044,-39.06666699999982],[173.79995111323194,-38.91515016713438],[173.34995143201604,-38.73986711592515]],[[171.19999999999968,-42.46666700000018],[171.22495293798085,-41.454827610945536],[173.34995143201604,-38.73986711592515],[173.69995118407297,-37.04328040742407]],[[149.39996839960057,-38.99291515860618],[164.99995734782604,-45.7951245820919],[166.3249564091843,-46.33285525519881],[167.67495545283234,-46.487989748417824],[168.34750000000054,-46.413056000000275]],[[144.967148,-37.81753200000024],[144.8591016157972,-38.25898836030261],[144.8048516542285,-38.52352352301163],[145.34997126806056,-39.2547415615617],[146.24997063108842,-39.861878679516124],[149.39996839960057,-38.99291515860618],[150.72496746155474,-37.942453525696855],[151.7987929508469,-34.82698199055459],[151.20704000000026,-33.869695999999635]],[[151.7987929508469,-34.82698199055459],[159.29996138635258,-37.222658379725665],[167.39995564824065,-37.758235764746985],[173.69995118407297,-37.04328040742407],[174.77046042690606,-36.88418050095063]]]}},{"type":"Feature","properties":{"id":"okinawa-miyakojima-ishigaki","name":"Okinawa-Miyakojima-Ishigaki","color":"#c2237e","feature_id":"okinawa-miyakojima-ishigaki-0","coordinates":[126.07935132335385,24.963156259890788]},"geometry":{"type":"MultiLineString","coordinates":[[[124.2450912999995,24.388365950000072],[124.6499859327203,24.32780311165181],[125.09998561393637,24.417474698660392],[125.38733299999983,24.751502030000356],[125.99998497636834,24.915858493558826],[127.23748409911637,25.653336613276053],[127.72859249999958,26.134756180000213]]]}},{"type":"Feature","properties":{"id":"mishima-village","name":"Mishima Village","color":"#c41f45","feature_id":"mishima-village-0","coordinates":[130.28190022823586,31.26224749339653]},"geometry":{"type":"MultiLineString","coordinates":[[[129.93441360000014,30.832460340000207],[130.2983291000001,30.792985210000303],[130.42661539999997,30.81022492000007]],[[129.93441360000014,30.832460340000207],[130.04998210492832,30.985825421949194],[130.1624820270205,31.08222223094729],[130.29387193394277,31.28029507274315],[130.27498194672853,31.08222223094729],[130.44173182919724,30.889328974889438],[130.42661539999997,30.81022492000007]]]}},{"type":"Feature","properties":{"id":"talaylink","name":"TalayLink","color":"#939597","feature_id":"talaylink-0","coordinates":[110.26326781057011,-26.766476642591662]},"geometry":{"type":"MultiLineString","coordinates":[[[115.74022000000038,-32.537020895712736],[113.84999358353615,-32.042386559186966]],[[105.65751366522899,-10.4318730000001],[105.41361374274392,-10.565783729977944]],[[100.06611334816643,6.613518860854109],[96.09214483515096,5.72580268887697],[95.50021180355195,5.708127481172063],[94.39121736831608,4.30521657626247],[94.4988798791227,3.367668509872753],[99.97532568091725,-4.151795635971226],[101.90529164924416,-5.681554559384467],[102.59094710926541,-7.138190551514161],[105.41361374274392,-10.565783729977944],[105.94799918019127,-15.265358169534869],[107.9205544240356,-20.433922197637408],[110.4658162928233,-27.31398249584017],[112.85917397234627,-30.67295608460977],[113.84999358353615,-32.042386559186966],[113.60321110091725,-33.06182889373477],[113.60321110091725,-34.611299785451145],[115.44676979611502,-36.28083847394901],[119.55018485796727,-36.44205411672081],[127.09256232737636,-37.45923113608103],[134.85085761257704,-38.106390221946036],[140.39997477528055,-39.16757423638764],[142.19997350014447,-39.51559387611211],[143.9999722250084,-39.51559387611211],[144.57985181362045,-38.52352352301163],[144.75060169265976,-38.25898836030261],[144.967148,-37.81753200000024]]]}},{"type":"Feature","properties":{"id":"bosun","name":"Bosun","color":"#939597","feature_id":"bosun-0","coordinates":[118.40431639042593,-11.1110989624718]},"geometry":{"type":"MultiLineString","coordinates":[[[105.67351309999975,-10.431872499999805],[106.99999843554109,-10.472598924988871],[114.99999276827003,-11.1110989624718],[120.99998851781679,-11.1110989624718],[126.44998465698852,-11.331797193775092],[127.34998401942048,-11.331797193775092],[129.0374828245764,-11.613552343393454],[129.20623270503228,-11.66869424909462],[129.9375821869376,-11.88890717018829],[130.50000678851072,-11.999108453087024],[130.84315500000028,-12.467499999999752]]]}},{"type":"Feature","properties":{"id":"finland-estonia-connection-1-fec-1","name":"Finland Estonia Connection 1 (FEC-1)","color":"#d7c027","feature_id":"finland-estonia-connection-1-fec-1-0","coordinates":[25.018248699424372,59.7882269523489]},"geometry":{"type":"MultiLineString","coordinates":[[[24.752468999999603,59.436369],[24.975056543375437,59.679663707208995],[25.087556463679363,59.9624316341522],[24.932563000000318,60.1712]]]}},{"type":"Feature","properties":{"id":"eagle","name":"EAGLE","color":"#939597","feature_id":"eagle-0","coordinates":[23.01121591049636,35.00465866856632]},"geometry":{"type":"MultiLineString","coordinates":[[[19.376194000000407,40.78671299999985],[19.13753248864648,40.654656544939996],[19.07081072540277,40.38732029077508],[18.843810885615575,40.04369219283004],[19.070260725792245,39.48474996079946],[19.57506036759165,37.67887792909206],[22.050058614875596,35.26683686030974],[23.400057658523455,34.89859296336222],[25.200056383983473,34.405022750715936],[25.493852648905516,34.265677526524286],[28.28078582696705,32.39269578860082],[29.220633745914697,31.544507110396093],[29.67235321517045,31.047641997876443]],[[18.843810885615575,40.04369219283004],[18.59820559145064,40.3515155000001],[18.405157,40.40923169292791],[18.175016100000146,40.3515155000001]]]}},{"type":"Feature","properties":{"id":"e-finest","name":"E-FINEST","color":"#40b76f","feature_id":"e-finest-0","coordinates":[24.63755678246347,59.8087855367023]},"geometry":{"type":"MultiLineString","coordinates":[[[24.65578,60.20678],[24.63755678246347,59.9624316341522],[24.63755678246347,59.679663707208995],[24.752468999999603,59.436369]]]}},{"type":"Feature","properties":{"id":"finland-estonia-connection-2-fec-2","name":"Finland Estonia Connection 2 (FEC-2)","color":"#7fc241","feature_id":"finland-estonia-connection-2-fec-2-0","coordinates":[24.908663992912608,59.79555432607822]},"geometry":{"type":"MultiLineString","coordinates":[[[24.752468999999603,59.436369],[24.86255662307151,59.679663707208995],[24.975056543375437,59.9624316341522],[24.932563000000318,60.1712]]]}},{"type":"Feature","properties":{"id":"sealink-south","name":"SEALink South","color":"#b36036","feature_id":"sealink-south-0","coordinates":[-132.33798343598562,55.368163899494924]},"geometry":{"type":"MultiLineString","coordinates":[[[-132.829514,56.015201999999896],[-132.61350301648966,55.947482692254525],[-132.5077430946788,55.89266381393939],[-132.37160699136777,55.79765291550112],[-132.30412703917114,55.63377825521405],[-132.16174714003438,55.526742438171844],[-131.97551348456435,55.42022053018498]],[[-133.02193140000003,55.51384579999992],[-132.7067629486636,55.42022053018498],[-132.5380155112832,55.38828278020515],[-132.3692631962775,55.35631921417214],[-132.20051089997355,55.42022053018498],[-131.97551348456435,55.42022053018498],[-131.88157355408154,55.434270637976695],[-131.6604739746332,55.3430143091892]]]}},{"type":"Feature","properties":{"id":"isle-au-haut-cable","name":"Isle Au Haut Cable","color":"#b2ba34","feature_id":"isle-au-haut-cable-0","coordinates":[-68.64592281999978,44.10391449000002]},"geometry":{"type":"MultiLineString","coordinates":[[[-68.66687301,44.156331730000154],[-68.62497262999955,44.05149724999989]]]}},{"type":"Feature","properties":{"id":"hokkaido-rebun-rishiri","name":"Hokkaido-Rebun-Rishiri","color":"#ddc224","feature_id":"hokkaido-rebun-rishiri-0","coordinates":[141.3723112574356,45.292603245705564]},"geometry":{"type":"MultiLineString","coordinates":[[[141.04706180000002,45.30049874000004],[141.21947430000026,45.245035859999916],[141.69815370000035,45.39401505999994]]]}},{"type":"Feature","properties":{"id":"okinawa-remote-islands","name":"Okinawa Remote Islands","color":"#c31e53","feature_id":"okinawa-remote-islands-0","coordinates":[125.18272383462778,25.418254015357316]},"geometry":{"type":"MultiLineString","coordinates":[[[127.22899780000009,26.587226800000273],[127.74376439999966,26.402044689999958]],[[127.292292,26.230968589999883],[127.36352570000041,26.19683555999985]],[[126.77534649999994,26.353132190000167],[126.78748441790049,26.461843796188983],[127.22899780000009,26.587226800000273]],[[122.99077429999956,24.457215450000316],[123.74998656790437,24.73717827217609],[124.7002651000001,24.66870033000032],[125.2687354920083,25.55188275942587],[126.33105998708565,25.853235981933878],[127.23748409911637,25.957179978764344],[127.68810080000024,26.114886979999657]],[[122.99077429999956,24.457215450000316],[123.33748686191112,24.270840146005792],[123.77844360000016,24.06001916000044]]]}},{"type":"Feature","properties":{"id":"minamidaito-island","name":"Minamidaito Island","color":"#50b748","feature_id":"minamidaito-island-0","coordinates":[129.45897087497414,25.813527639618613]},"geometry":{"type":"MultiLineString","coordinates":[[[127.68810080000024,26.114886979999657],[128.0249835418403,26.005947168900573],[130.27498194374937,25.7040312290754],[131.24255189999982,25.842984359999694]]]}},{"type":"Feature","properties":{"id":"kitadaito-island","name":"Kitadaito Island","color":"#da4326","feature_id":"kitadaito-island-0","coordinates":[129.5127865249,25.9245160611662]},"geometry":{"type":"MultiLineString","coordinates":[[[127.72859249999958,26.134756180000213],[128.0249835418403,26.05828756029904],[130.27498194374937,25.855985466072205],[131.30497690000038,25.945848989999725]]]}},{"type":"Feature","properties":{"id":"daito-loop","name":"Daito Loop","color":"#63c5b9","feature_id":"daito-loop-0","coordinates":[131.2737644000001,25.89441667499971]},"geometry":{"type":"MultiLineString","coordinates":[[[131.24255189999982,25.842984359999694],[131.30497690000038,25.945848989999725]]]}},{"type":"Feature","properties":{"id":"vaka","name":"VAKA","color":"#939597","feature_id":"vaka-0","coordinates":[176.5280354041795,-11.184211973197916]},"geometry":{"type":"MultiLineString","coordinates":[[[173.69996478053517,-13.698987269610743],[178.3249479082778,-9.586362493293953],[179.19882450000034,-8.520204505999452]]]}},{"type":"Feature","properties":{"id":"konstanz-friedrichshafen","name":"Konstanz-Friedrichshafen","color":"#a8c638","feature_id":"konstanz-friedrichshafen-0","coordinates":[9.326624849999934,47.6698572000001]},"geometry":{"type":"MultiLineString","coordinates":[[[9.173238399999894,47.67794960000021],[9.480011299999973,47.661764799999965]]]}},{"type":"Feature","properties":{"id":"konstanz-meersburg","name":"Konstanz-Meersburg","color":"#458bca","feature_id":"konstanz-meersburg-0","coordinates":[9.223155108999947,47.68741076500023]},"geometry":{"type":"MultiLineString","coordinates":[[[9.173238399999894,47.67794960000021],[9.273071818,47.69687193000024]]]}},{"type":"Feature","properties":{"id":"muroran-hachinohe","name":"Muroran-Hachinohe","color":"#93489b","feature_id":"muroran-hachinohe-0","coordinates":[141.64683877069186,41.52176759892695]},"geometry":{"type":"MultiLineString","coordinates":[[[140.98140000000038,42.369090999999734],[141.52497397772464,41.85617959436374],[141.7499738189284,41.2387523289666],[141.50050400000035,40.476710999999796]]]}},{"type":"Feature","properties":{"id":"emc-west-1","name":"EMC West-1","color":"#939597","feature_id":"emc-west-1-0","coordinates":[14.457769271018831,35.558347195777266]},"geometry":{"type":"MultiLineString","coordinates":[[[22.950057977903466,35.78566189952622],[23.512557578827565,36.24065523321488],[23.736253999999892,37.97609300000014]],[[24.86255662247561,34.89090355336368],[25.200056383983473,34.75237116876541],[31.050052239791533,33.37780603565933],[33.750050327087614,32.62301664000789],[34.87197099999965,32.343948000000225],[34.76254960922761,31.510798430049064],[34.76254960922761,31.03001726298632],[34.95220799999987,29.283851999999897]],[[8.938900000000473,44.410338999999794],[9.450067540827328,44.05151922873524],[9.787567301739475,43.401144973153954],[10.237566980571401,42.41235450073586],[10.800066583283387,41.52013202089327],[11.925065787515376,39.6983233549332],[11.925065786919474,37.85673997565852],[12.487565389631278,37.23235432155614],[13.893814392835383,35.96797434759339],[14.400064034799321,35.55718204488721],[16.65006244087933,35.60261271664616],[19.350060528175415,35.96765819663267],[22.050058614875596,35.81607643769555],[22.950057977903466,35.78566189952622],[23.400057658523455,35.26683686030974],[24.86255662247561,34.89090355336368],[24.768278120000296,35.07140756999981]],[[34.87197099999965,32.343948000000225],[34.81879956937957,31.510798430049064],[34.81879956937957,31.03001726298632],[34.95220799999987,29.283851999999897]]]}},{"type":"Feature","properties":{"id":"emc-west-2","name":"EMC West-2","color":"#939597","feature_id":"emc-west-2-0","coordinates":[19.581027274957304,35.753055982897735]},"geometry":{"type":"MultiLineString","coordinates":[[[24.768278120000296,35.07140756999981],[24.412556941259535,34.729259795816326]],[[5.372506999999549,43.293579],[6.412569693215363,41.74435878948223],[7.987568578067343,38.651811712711336],[9.000067859611436,38.475881348138756],[10.348617229769278,38.475881348138756],[11.812565867211447,37.85673997565852],[12.37506546873145,37.23235432155614],[13.781314472531456,35.96797434759339],[14.400064034799321,35.46560712762324],[16.65006244087933,35.419780517080355],[19.350060528175415,35.78566189952622],[22.050058614875596,35.404499183513224],[23.400057658523455,34.99080971857576],[24.412556941259535,34.729259795816326],[25.200056383983473,34.47460850182435],[31.050052239791533,33.001218522654476],[33.750050327087614,32.243210016262736],[34.55601100000069,31.669509999999626],[34.53754976861957,31.03001726298632],[34.95220799999987,29.283851999999897]],[[34.55601100000069,31.669509999999626],[34.48129980846761,31.03001726298632],[34.95220799999987,29.283851999999897]]]}},{"type":"Feature","properties":{"id":"asia-connect-cable-1-acc-1","name":"Asia Connect Cable-1 (ACC-1)","color":"#939597","feature_id":"asia-connect-cable-1-acc-1-0","coordinates":[143.91185695233523,12.797443250456062]},"geometry":{"type":"MultiLineString","coordinates":[[[-179.99979825051412,19.95262290516439],[-172.79980335105813,21.635297384859552],[-163.799809726738,21.635297384859552],[-161.44056423563913,22.469372680574537],[-152.99981737755394,26.964304734562898],[-147.6030982105313,29.738014316088],[-138.60310458621126,31.288652857283093],[-127.79983522945767,33.189714664600466],[-122.3998390548656,33.37780603565933],[-120.59984033000157,33.37780603565933],[-118.79984160513764,33.89296086026743],[-118.39945344493313,33.862474868985494]],[[179.99992672230314,19.95262290516439],[151.1999671244645,16.10232559580297],[146.24997063108842,14.256644994553485],[145.34997126865645,13.601498202276586],[144.89997158744055,13.3827080361257],[144.77675167413477,13.490037504527912],[143.9999722250084,12.834868817846521],[137.24997700676838,9.967915186974132],[133.19997987582445,8.190543417795496],[128.24998338125656,5.061986954416114],[126.8999843382044,2.068137876964541],[126.44998465698852,0.118588418888312],[126.44998465698852,-1.231315750217412],[126.5744945687844,-1.751747198540771],[127.23748409911637,-2.580536704984131],[127.46248393972441,-3.254657364797681],[127.79998370063637,-4.676208028751072],[127.79998370063637,-6.467627592690688],[127.46248393972441,-8.252720521974979],[127.79998370063637,-9.586362493293953],[128.92498290367638,-10.620064860363238],[129.9375821869376,-12.108990934944092],[130.50000678851072,-12.219087219884248],[130.84314154543083,-12.467474336203543]],[[127.79998370063637,-4.676208028751072],[123.74998656969242,-6.914561059201749],[120.14998911996437,-6.914561059201749],[117.37991922838094,-5.945707155070644],[116.58310164737706,-5.525204085835],[114.46970572643436,-5.721872747834119],[112.0499948586722,-4.825692499217419],[109.34999676839627,-3.479268678970064],[107.77499788652415,-3.778666580061055],[106.9874984438013,-3.029995968008661],[106.87499852468808,-2.130918480960333],[106.4249988434722,-0.331409329660265],[105.29999964043219,0.793562652607196],[104.8499999592161,0.906050180869095],[104.62500011860807,1.074774789350549],[104.33505754105825,1.091076758263174],[103.85310700000069,1.293877684611663]],[[104.33505754105825,1.091076758263174],[104.11006048280018,1.091076758263174],[104.0166370000003,1.066798000000349]],[[126.8999843382044,2.068137876964541],[125.99998497636834,2.143087178471855],[124.87498577273243,1.805788280129153],[124.8396357983706,1.490779296094715]],[[123.74998656969242,-6.914561059201749],[125.32498545454442,-7.955717094334652],[125.58071527278696,-8.570689999999674]],[[117.37991922838094,-5.945707155070644],[118.79999007691224,-5.273944363641298],[119.41238964308275,-5.152180217334703]],[[107.77499788652415,-3.778666580061055],[107.77499788712005,-4.60145376483711],[107.32499820590417,-5.273944363641298],[106.82782855810404,-6.171876390816321]],[[128.24998338125656,5.061986954416114],[126.33748473549242,5.957818681088533],[125.61287587559997,7.079988883160643]]]}},{"type":"Feature","properties":{"id":"norte-conectado-infovia-00","name":"Norte Conectado (Infovia 00)","color":"#e24f25","feature_id":"norte-conectado-infovia-00-0","coordinates":[-52.89739681048513,-1.612459489745365]},"geometry":{"type":"MultiLineString","coordinates":[[[-54.73603937000024,-1.944987034999834],[-54.65613704573581,-2.249581620957358],[-54.700923,-2.450628999999634],[-54.09363744421581,-2.36199043618581],[-54.074540760000104,-2.000472714000541],[-53.86863760360777,-1.912304920519021],[-53.30613800208777,-1.856085375649633],[-52.9686382411758,-1.631189860051542],[-52.57775842999971,-1.528422080999817],[-52.29363871935178,-1.462501535045741],[-52.01238891859178,-1.462501535045741],[-51.899888998287764,-1.237564295253775],[-51.73113911783178,-1.068848655458825],[-51.67488915767982,-0.843880302674306],[-51.562389237375804,-0.618898938178571],[-51.33738939676776,-0.506404460360012],[-51.11238955615982,-0.281410081654682],[-51.06571400000015,0.03453499999984]]]}},{"type":"Feature","properties":{"id":"norte-conectado-infovia-01","name":"Norte Conectado (Infovia 01)","color":"#424099","feature_id":"norte-conectado-infovia-01-0","coordinates":[-57.25917508844483,-2.5066788396945507]},"geometry":{"type":"MultiLineString","coordinates":[[[-60.02064900000021,-3.123895999999749],[-59.66238349926387,-3.092406737044168],[-59.324883738351815,-3.204736816895265],[-59.131660679999904,-3.580329971999896],[-58.76238413683181,-3.429359557949267],[-58.43994919999979,-3.136558105000468],[-58.154744650000374,-3.129203635000459],[-58.087384615007885,-2.867711310952125],[-57.749884854095825,-2.755346826295107],[-57.52488501348779,-2.418191459838346],[-57.18738525257582,-2.53058646932234],[-56.73720395000047,-2.633793763999875],[-56.5123857307518,-2.474390155539895],[-56.4893347300004,-2.102144401000589],[-56.0952176299996,-2.161544228000772],[-55.8674756800002,-1.76242793600052],[-55.51623128999993,-1.913198033000186],[-55.12010550000016,-1.890297457000427],[-54.71238700588778,-2.249581620957358],[-54.700923,-2.450628999999634]]]}},{"type":"Feature","properties":{"id":"norte-conectado-infovia-02","name":"Norte Conectado (Infovia 02)","color":"#939597","feature_id":"norte-conectado-infovia-02-0","coordinates":[-67.6977989725601,-2.8322673756260865]},"geometry":{"type":"MultiLineString","coordinates":[[[-70.19319400999984,-4.366370093000299],[-70.0287800300003,-4.379887185000328],[-69.93849640000008,-4.231296995000054],[-69.74530427947822,-4.33726001338843],[-69.40779263399965,-4.168974905715668],[-69.52181003999964,-4.037852188000214],[-69.40779263399965,-3.720042369142968],[-69.29530063673994,-3.495488576036888],[-68.95252266000007,-3.465503202000312],[-68.62032036107237,-3.327036371619034],[-68.19721486000019,-3.362085687000277],[-67.94534099,-3.327036371619034],[-67.94534099,-3.102393505999825],[-67.77536821000035,-2.86172595099949],[-67.46237797367455,-2.742861220956931],[-67.34987805337053,-2.630484978114635],[-67.1248782127626,-2.630484978114635],[-66.77157420000049,-2.750610165999518],[-66.5623786112426,-2.630484978114635],[-66.5623786112426,-2.405702542751779],[-66.33737877063454,-2.574293032075275],[-66.09530680999958,-2.515185832000585],[-65.66237924881052,-2.630484978114635],[-65.32487948789857,-2.742861220956931],[-65.16004126000051,-2.982672618999626],[-64.81206968999977,-3.21455230799988],[-64.7189821999999,-3.352162610000246]]]}},{"type":"Feature","properties":{"id":"norte-conectado-infovia-03","name":"Norte Conectado (Infovia 03)","color":"#3655a3","feature_id":"norte-conectado-infovia-03-0","coordinates":[-50.76431370212824,-1.4274139454300376]},"geometry":{"type":"MultiLineString","coordinates":[[[-51.06571400000015,0.03453499999984],[-51.05613959600776,-0.281410081654682],[-50.94363967570374,-0.393908030300729],[-50.71863983509571,-0.281410081654682],[-50.388307302747435,-0.157127594999777],[-50.71863983509571,-0.337659218696507],[-50.77488979524776,-0.506404460358921],[-50.83113975539982,-0.73139103014119],[-50.77488979524776,-1.068848655458825],[-50.77488979524776,-1.406269203242825],[-50.66238987494374,-1.631189860051542],[-50.481980002747754,-1.683596191000075],[-50.20909264999976,-1.898843353],[-49.7980208799999,-1.812160472999909],[-49.52887972,-1.717382211000187],[-48.869821749999836,-1.392058164000314],[-48.510443,-1.458588999999789]]]}},{"type":"Feature","properties":{"id":"norte-conectado-infovia-04","name":"Norte Conectado (Infovia 04)","color":"#939597","feature_id":"norte-conectado-infovia-04-0","coordinates":[-61.56169848630277,0.695238523519392]},"geometry":{"type":"MultiLineString","coordinates":[[[-61.63505656999974,-1.458572190999756],[-61.90013191402189,-1.162310860215265],[-61.78764966000029,-0.508038674000514],[-61.78763199371787,0.07510851284884],[-61.67513207341395,0.525100864671433],[-61.45013223280591,0.862575391371345],[-61.13126438999985,1.823914555000229],[-61.00013255158993,2.099637008787492],[-60.88763263128591,2.32446903907433],[-60.675832999999756,2.8235100000003]]]}},{"type":"Feature","properties":{"id":"projeto-amaznia-conectada-pac-01","name":"Projeto Amazônia Conectada (PAC 01)","color":"#6d4a9d","feature_id":"projeto-amaznia-conectada-pac-01-0","coordinates":[-61.838197472101974,-3.890148121235068]},"geometry":{"type":"MultiLineString","coordinates":[[[-64.7189821999999,-3.352162610000246],[-64.49988007233594,-3.485507134028268],[-64.04988039111987,-3.710063389927138],[-63.599880709903886,-3.822320337335769],[-63.14769152,-4.091941809999533],[-62.36238158655986,-3.710063389927138],[-61.91238190534388,-3.934562592225933],[-61.349882303823875,-3.59779217905032],[-60.89988262260789,-3.59779217905032],[-60.63541789000012,-3.289115312000247],[-60.18773608000013,-3.279128219999502],[-60.02064900000021,-3.123895999999749],[-60.56238286169584,-2.980064758169332],[-60.674882781999855,-2.755346826295107],[-60.94388627000021,-2.623928290000122]]]}},{"type":"Feature","properties":{"id":"projeto-amaznia-conectada-pac-02","name":"Projeto Amazônia Conectada (PAC 02)","color":"#34af49","feature_id":"projeto-amaznia-conectada-pac-02-0","coordinates":[-63.54264465216806,-0.4873751459091228]},"geometry":{"type":"MultiLineString","coordinates":[[[-67.08799537999974,-0.119416257999669],[-66.51262864648594,-0.318639544201829],[-66.28762880587789,-0.318639544201829],[-65.61262928405397,-0.318639544201829],[-65.275129523142,-0.431137038307925],[-65.01571916999967,-0.414957876000345],[-64.6001300013179,-0.431137038307925],[-63.81263055918994,-0.318639544201829],[-63.362630877973956,-0.599880027513196],[-63.025131117061896,-0.712372553299487],[-62.925769709999486,-0.974081207000161],[-62.57513143584592,-1.049831919844808],[-61.90013191402189,-1.387254868233108],[-61.63505656999974,-1.458572190999756],[-61.45013223280591,-1.724629701463881],[-61.45013223280591,-1.837074983537492],[-61.05638251174189,-2.174352917686068],[-60.94388627000021,-2.623928290000122]]]}},{"type":"Feature","properties":{"id":"barat-timur-indonesia-2-bti-2","name":"Barat Timur Indonesia-2 (BTI-2)","color":"#939597","feature_id":"barat-timur-indonesia-2-bti-2-0","coordinates":[115.06424459648615,-6.058897973817234]},"geometry":{"type":"MultiLineString","coordinates":[[[104.0166370000003,1.066798000000349],[104.23671914307417,0.718701163999102],[107.92934120914595,0.125144305293085],[108.518987560062,-3.308304570300115],[107.9205544240356,-3.92832730414264],[107.8752206669785,-4.421462021257835],[108.028612707457,-5.57979326526674],[108.98720405963194,-6.200644751046103],[111.39530787136668,-5.881175967910585],[112.47060007836957,-6.110368702679498],[114.55431437305602,-6.017142230629707],[117.2863535895726,-6.240855834298438],[119.13788046195164,-6.723203526046124],[120.14039440019944,-6.656981736799576],[123.70604128832369,-6.656981736799576],[124.24565408402098,-5.020341303484515],[124.19467123697362,-4.066822301947936],[123.93724143733724,-1.188784534490496],[125.00091074855803,0.147235879809841],[125.14944846892539,0.786495458405646],[125.12195825402485,1.023645803504536],[124.8396357983706,1.490779296094715]],[[123.93724143733724,-1.188784534490496],[123.61629525815061,-0.887871730847793],[123.12391279505485,-1.0174889466678],[122.79275724839854,-0.938788738945164]],[[122.51297744659723,-3.998469097245179],[123.25408848349132,-3.577436557815043],[124.15161121973277,-3.585934037759635]],[[119.64813400673333,-5.679276766569264],[119.27840770615076,-5.945707155070644],[119.01248992577939,-6.690552320245617]],[[114.60399304939611,-3.327586828573115],[114.41254491570477,-3.739870959183627],[114.3103932573849,-4.424721793215856],[114.13221994550848,-4.838842846816813],[113.97217748075913,-6.048780990968187]],[[110.27177103540173,-6.048780990968187],[110.51371472880342,-6.689455323276428],[110.42549900000049,-6.971419999999669]],[[115.71425007478364,-6.112135256061153],[115.63417200651152,-6.971419999999669],[115.10901907969586,-7.785727122839621],[115.07594712344931,-8.115525289526817]],[[108.0255698871077,-5.556836232143262],[107.5700525897694,-5.716442612332668],[106.96892712282748,-5.790569544810861],[106.791457,-6.108548339473468]],[[107.92934120914595,0.125144305293085],[108.79413778955596,0.062743022966006],[109.33554678161278,-0.027021392288274]]]}},{"type":"Feature","properties":{"id":"trans-caspian-fiber-optic-cable-project","name":"Trans-Caspian Fiber Optic Cable Project","color":"#939597","feature_id":"trans-caspian-fiber-optic-cable-project-0","coordinates":[50.35170928810899,42.1662929008365]},"geometry":{"type":"MultiLineString","coordinates":[[[49.668611,40.589722],[49.77503897423938,41.00333982867244],[51.00003810643851,43.47375744845541],[51.20000099999987,43.6500019999995]]]}},{"type":"Feature","properties":{"id":"kangaroo-island-2","name":"Kangaroo Island 2","color":"#3a6bb4","feature_id":"kangaroo-island-2-0","coordinates":[137.86818235000004,-35.62775164499998]},"geometry":{"type":"MultiLineString","coordinates":[[[137.63263809999978,-35.65230045000021],[138.1037266000003,-35.60320283999976]]]}},{"type":"Feature","properties":{"id":"bangladesh-private-cable-system-bpcs","name":"Bangladesh Private Cable System (BPCS)","color":"#939597","feature_id":"bangladesh-private-cable-system-bpcs-0","coordinates":[93.18105844395623,17.212735087769786]},"geometry":{"type":"MultiLineString","coordinates":[[[96.44170591573067,14.833379080264882],[95.12500684789669,14.970281060426933],[94.00000764485668,15.621365811307367],[93.37500808761213,16.749771315644697],[92.92500840639624,17.82393441253792],[92.13750896307621,19.95262290516439],[91.99482906593992,21.42927456664916]]]}},{"type":"Feature","properties":{"id":"vstervik-visby","name":"Västervik-Visby","color":"#d96526","feature_id":"vstervik-visby-0","coordinates":[17.47347414283165,57.759223740408395]},"geometry":{"type":"MultiLineString","coordinates":[[[16.63697244955656,57.75601359999992],[17.32506196210754,57.7724256592012],[18.00006148393147,57.71238147125906],[18.3000312714301,57.63842]]]}},{"type":"Feature","properties":{"id":"oskarshamn-visby","name":"Oskarshamn-Visby","color":"#30b34a","feature_id":"oskarshamn-visby-0","coordinates":[17.354839182151327,57.53696966224951]},"geometry":{"type":"MultiLineString","coordinates":[[[18.3000312714301,57.63842],[18.00006148393147,57.652237533638846],[17.32506196210754,57.531650012322864],[16.662562431428412,57.29602797009688],[16.442539271429794,57.26520999999988]]]}},{"type":"Feature","properties":{"id":"globalconnect-6-gc6","name":"GlobalConnect 6 (GC6)","color":"#4cbb89","feature_id":"globalconnect-6-gc6-0","coordinates":[10.04430711986253,55.638189539999935]},"geometry":{"type":"MultiLineString","coordinates":[[[9.997211570862373,55.70859648999988],[10.09140266886269,55.56778259]]]}},{"type":"Feature","properties":{"id":"vietnam-singapore-cable-system-vts","name":"Vietnam-Singapore Cable System (VTS)","color":"#939597","feature_id":"vietnam-singapore-cable-system-vts-0","coordinates":[106.46282537917243,5.744681726150895]},"geometry":{"type":"MultiLineString","coordinates":[[[104.00411055785635,1.373499297382436],[104.20380041699,1.349315392074356],[104.23195039704844,1.468426767331968],[104.28750035710021,1.918228780215599],[104.56875015845611,2.817450442654169],[105.74999932164808,4.725718053703611],[107.5499980453204,7.29876275445952],[107.77499788712005,9.52441134501949],[107.07933837933602,10.342231999999626]],[[104.28750035710021,1.918228780215599],[104.11414047991015,1.925884465105483]],[[105.74999932164808,4.725718053703611],[105.04591744854648,5.887726394934779],[103.06289565101468,7.116812251931782],[101.70000219070414,7.131349142159139],[100.5951029728293,7.198819999999446]]]}},{"type":"Feature","properties":{"id":"lake-michigan-crossing-peninsula-and-island-connection","name":"Lake Michigan Crossing Peninsula and Island Connection","color":"#939597","feature_id":"lake-michigan-crossing-peninsula-and-island-connection-0","coordinates":[-85.6113839086376,45.710570899822265]},"geometry":{"type":"MultiLineString","coordinates":[[[-86.01215480000009,45.99314791],[-85.887364921241,45.84716709802962],[-85.7748650009369,45.76874730029829],[-85.53533779680885,45.740931669999895],[-85.66236508063297,45.690217095937825],[-85.66236508063297,45.57221476032958],[-85.43736524002493,45.4539639701049],[-85.25839776017807,45.31806299999966]]]}},{"type":"Feature","properties":{"id":"lake-michigan-chicago-crossing","name":"Lake Michigan Chicago Crossing","color":"#939597","feature_id":"lake-michigan-chicago-crossing-0","coordinates":[-87.04280710758722,41.996979072671245]},"geometry":{"type":"MultiLineString","coordinates":[[[-87.63241601842836,41.88414897363115],[-87.22486397374416,41.963158060817065],[-86.77486429252818,42.04675526247187],[-86.45448688264149,42.11623924570896]],[[-87.63241601842836,41.88414897363115],[-87.22486397374416,41.921318272948014],[-86.77486429252818,42.004970392069886],[-86.49025854999994,42.09494643278548]]]}},{"type":"Feature","properties":{"id":"nongsa-changi","name":"Nongsa-Changi","color":"#939597","feature_id":"nongsa-changi-0","coordinates":[104.0768804017202,1.295370025702409]},"geometry":{"type":"MultiLineString","coordinates":[[[104.00411055785635,1.373499297000449],[104.09355549449265,1.277466828746606],[104.1029504878371,1.196515539000407]]]}},{"type":"Feature","properties":{"id":"insica","name":"INSICA","color":"#939597","feature_id":"insica-0","coordinates":[103.85950463659297,1.19112580408122]},"geometry":{"type":"MultiLineString","coordinates":[[[103.64621000000031,1.338645835654649],[103.72500075558021,1.224744439668522],[103.95000059618825,1.168506749040978],[104.13311046647141,1.173205764381829]]]}},{"type":"Feature","properties":{"id":"ioema","name":"IOEMA","color":"#939597","feature_id":"ioema-0","coordinates":[6.183178399064579,53.852888017468835]},"geometry":{"type":"MultiLineString","coordinates":[[[1.43945857171798,51.35885],[2.025072801359273,51.86557165559819],[2.700072321395319,52.00429650272413],[3.037572083499449,52.356869357572975],[3.825071526223208,52.69150159464696],[4.275071206843381,53.23359531864929],[5.85007009109946,53.76891056666807],[7.425068975355357,54.16597178715178],[7.200069135343221,55.462350188098306],[7.200069135343221,55.77997032709834],[7.31256905505143,56.55247760361325],[8.10006849658357,57.932056586951404],[7.99484857171814,58.143805]],[[5.85007009109946,53.76891056666807],[6.52506961292339,53.635715156995076],[6.816016292361981,53.442799]],[[7.425068975355357,54.16597178715178],[7.987568576875359,53.90168472607427],[8.10006849717947,53.70236555668246],[8.10686849236221,53.532340299999966]],[[7.200069135343221,55.77997032709834],[7.650068816559295,55.716652093821786],[8.329197999999858,55.751672743003255]],[[3.037572083499449,52.356869357572975],[2.25007264196731,52.34923443600231],[1.574842,52.20525622625099]],[[2.700072321395319,52.00429650272413],[3.375071846795381,52.00429650272413],[4.317414999999649,52.08399199999982]]]}},{"type":"Feature","properties":{"id":"umoja","name":"Umoja","color":"#939597","feature_id":"umoja-0","coordinates":[73.3691361580669,-30.194272617562497]},"geometry":{"type":"MultiLineString","coordinates":[[[115.74022000000038,-32.537020895712736],[113.84999358353615,-31.85146566557725],[89.10001111606014,-30.568602759492247],[45.450041666655096,-29.52991296614913],[38.70004682046353,-29.52991296614913],[33.30005064587154,-30.115516969775936],[32.40005128343957,-30.115516969775936],[30.88039235938437,-30.05771707645661]]]}},{"type":"Feature","properties":{"id":"proa","name":"Proa","color":"#939597","feature_id":"proa-0","coordinates":[140.94409772480356,23.968318050907758]},"geometry":{"type":"MultiLineString","coordinates":[[[144.7053018828644,15.115146651945496],[145.49280132558852,15.06083578721253],[145.63747106498886,15.011508499399486]],[[136.90461113662792,34.41942780492772],[137.61780690371242,32.89632905753641],[138.18030650463632,31.372163079357342],[141.78030395555655,21.728227953239347],[144.03030236163656,17.060732189607368],[144.7053018828644,15.115146651945496],[144.8178018043603,14.026348564517429],[144.81683513662824,13.543635828763595]]]}},{"type":"Feature","properties":{"id":"taihei","name":"Taihei","color":"#939597","feature_id":"taihei-0","coordinates":[160.28836810524842,32.02917547451569]},"geometry":{"type":"MultiLineString","coordinates":[[[179.99994672169302,27.896238989528694],[149.39996839960057,34.31215165223547],[141.97497365894054,36.33133835588799],[141.07497429650857,36.542522130541435],[140.7182700000002,36.71307999999942]],[[-158.08325,21.33416151999962],[-158.3998135047771,21.216397899942],[-158.8498131859927,21.216397899942],[-161.44056418827003,21.84429407917369],[-179.9997982353205,27.896238989528694]]]}},{"type":"Feature","properties":{"id":"trapani-kelibia-2-keltra-2","name":"Trapani-Kelibia 2 (KELTRA-2)","color":"#d62426","feature_id":"trapani-kelibia-2-keltra-2-0","coordinates":[11.785949976402655,37.44708830538316]},"geometry":{"type":"MultiLineString","coordinates":[[[11.090886379052035,36.84993699396254],[11.4273661406866,37.029174246938645],[11.948015771257374,37.63596902815064],[12.150065626335346,37.76786242517874],[12.513618000000546,38.01825390905765]]]}},{"type":"Feature","properties":{"id":"new-cam-ring","name":"New CAM Ring","color":"#939597","feature_id":"new-cam-ring-0","coordinates":[-18.324026527522005,38.831939222700775]},"geometry":{"type":"MultiLineString","coordinates":[[[-9.337912400000349,38.6888354519986],[-10.799918113296759,38.38775473578444],[-13.94991588300081,38.53457265294195],[-23.399909187344846,39.17701458492114],[-25.199907914592835,39.17701458492114],[-26.77490679706085,38.94407095870691],[-27.25032671,38.66570093075493]],[[-27.250326460269058,38.66570477999988],[-27.224906478872917,38.475881348138756],[-26.99990663826488,38.03417390064187],[-25.87490743403289,37.589786573603064],[-25.668707580106854,37.739604626314694]],[[-25.676797574971758,37.74037606999961],[-25.76240751372887,37.589786573603064],[-25.537407673716917,36.78317048961605],[-24.749908230992887,35.96797434759339],[-18.449912693968844,32.62301664000789],[-17.125113632468874,32.46403024100134],[-16.908913786222914,32.646885]],[[-16.908910485023203,32.646885],[-16.818663851348777,32.62301664000789],[-16.763182235023585,32.71937525000007]],[[-16.763180749999524,32.719375367232246],[-16.312414208788844,32.8123187832876],[-13.949915881808826,34.31215165223547],[-11.924917316336764,36.51238821239364],[-9.449919069648717,37.76786242517874],[-9.112419308736753,37.899560460110116],[-8.86959948134856,37.957190000000196]]]}},{"type":"Feature","properties":{"id":"sto-hel-one","name":"STO-HEL-One","color":"#45449b","feature_id":"sto-hel-one-0","coordinates":[20.548612337285515,60.63045843072375]},"geometry":{"type":"MultiLineString","coordinates":[[[19.724814400000394,60.27263870000003],[19.96881008925148,60.317170739435596],[20.137559969707553,60.3728330141931],[20.25005989001148,60.483872905447065],[20.700059571227555,60.70481713232475],[21.150059252443448,60.70481713232475],[21.446235299999714,60.67946870000003]],[[18.56540150000036,60.032046400000056],[18.900060846363438,60.03743169846873],[19.57506036818755,60.03743169846873],[19.804952900000593,60.099262]]]}},{"type":"Feature","properties":{"id":"sovetskaya-gavan-uglegorsk","name":"Sovetskaya Gavan-Uglegorsk","color":"#a72f6a","feature_id":"sovetskaya-gavan-uglegorsk-0","coordinates":[141.13175739999988,49.015088600000205]},"geometry":{"type":"MultiLineString","coordinates":[[[140.28333299999971,48.9666670000001],[141.98018180000005,49.063510200000316]]]}},{"type":"Feature","properties":{"id":"rompin-tioman-island","name":"Rompin-Tioman Island","color":"#5ac6d1","feature_id":"rompin-tioman-island-0","coordinates":[103.82426093775084,2.808355910942874]},"geometry":{"type":"MultiLineString","coordinates":[[[103.49149565704641,2.800285607948548],[104.15702621845527,2.8164262139372]]]}},{"type":"Feature","properties":{"id":"besut-perhentian-islands","name":"Besut-Perhentian Islands","color":"#7ab542","feature_id":"besut-perhentian-islands-0","coordinates":[102.66026598075527,5.85894271864408]},"geometry":{"type":"MultiLineString","coordinates":[[[102.57171090634661,5.791616724837154],[102.72290646910318,5.906566563564761],[102.75470997931428,5.902883822383411]]]}},{"type":"Feature","properties":{"id":"lumut-pangkor-island","name":"Lumut-Pangkor Island","color":"#2961a5","feature_id":"lumut-pangkor-island-0","coordinates":[100.60442202612539,4.210781411319831]},"geometry":{"type":"MultiLineString","coordinates":[[[100.57962452963514,4.193025900179775],[100.62921952261566,4.228536922459888]]]}},{"type":"Feature","properties":{"id":"calvi-st-florent","name":"Calvi-St. Florent","color":"#bf3c95","feature_id":"calvi-st-florent-0","coordinates":[8.986297647562163,42.743713464436716]},"geometry":{"type":"MultiLineString","coordinates":[[[8.754733,42.56312],[8.775068019003399,42.661038814490205],[8.887567939307326,42.743713464436695],[9.22506770200754,42.743713464436695],[9.303703999999604,42.68184600000012]]]}},{"type":"Feature","properties":{"id":"romsar-2","name":"ROMSAR 2","color":"#6b4099","feature_id":"romsar-2-0","coordinates":[10.050807484296756,41.79429657532332]},"geometry":{"type":"MultiLineString","coordinates":[[[8.554313999999641,40.72681099999979],[8.606318138547326,40.9278295445864],[9.000067859611436,41.1823303464796],[9.225067700219473,41.2246513951782],[9.410541579024889,41.21939730561934],[9.900067222043404,41.68837522565799],[10.575066743867334,42.16268022756146],[10.91505260455934,42.35845318350371],[11.137566345387336,42.246014931829706],[11.475066106299483,42.16268022756146],[11.79157499999959,42.096099]]]}},{"type":"Feature","properties":{"id":"les-dhyres-cable","name":"Îles d'Hyères Cable","color":"#3858a7","feature_id":"les-dhyres-cable-0","coordinates":[6.36399143413161,43.00615564163939]},"geometry":{"type":"MultiLineString","coordinates":[[[6.154438781305733,43.030084136463124],[6.203652842011131,42.99989374688527],[6.387610296452823,43.00707805731055],[6.440445975376559,43.01927278405207],[6.366662207882666,43.13795969557663]]]}},{"type":"Feature","properties":{"id":"lic-lin-lamp","name":"Lic-Lin-Lamp","color":"#c4a32e","feature_id":"lic-lin-lamp-0","coordinates":[13.327132580251302,36.2515865480136]},"geometry":{"type":"MultiLineString","coordinates":[[[12.607558387730421,35.50152744443852],[12.860768932962662,35.8583596049076],[13.162564911455389,36.05897312258681],[13.781314472531456,36.78317048961605],[13.93822656670043,37.10250254786433]]]}},{"type":"Feature","properties":{"id":"ruppione-isolella","name":"Ruppione-Isolella","color":"#c41788","feature_id":"ruppione-isolella-0","coordinates":[8.601761054664493,41.7053077421622]},"geometry":{"type":"MultiLineString","coordinates":[[[8.785010999999724,41.832657],[8.583368154805507,41.793815103028855],[8.583368154805507,41.709886146904836],[8.752118035261399,41.667880521967],[8.90575,41.67415]]]}},{"type":"Feature","properties":{"id":"sarco","name":"SARCO","color":"#45b648","feature_id":"sarco-0","coordinates":[9.179701715141487,41.3151522514747]},"geometry":{"type":"MultiLineString","coordinates":[[[9.168977271135239,41.39044730433591],[9.190426159147735,41.239857198613485]]]}},{"type":"Feature","properties":{"id":"honomoana","name":"Honomoana","color":"#939597","feature_id":"honomoana-0","coordinates":[-140.3169059085619,12.492463424698956]},"geometry":{"type":"MultiLineString","coordinates":[[[152.0999664863006,-37.995035579083044],[152.023790917052,-34.82698199055459],[151.20704000000026,-33.869695999999635]],[[179.99994678470878,-29.790605028386853],[174.59995054710086,-31.340410556277746],[166.94995596702458,-32.80208389299158],[159.29996138635258,-34.67300361524697],[152.0999664863006,-37.995035579083044]],[[-149.84981960963796,-16.953454989809906],[-149.62481976783775,-17.168553094226155],[-149.45615089999998,-17.637813220000343]],[[-179.9997982511102,-29.790604899288688],[-163.79980972733406,-23.49392244589784],[-156.5998148272819,-21.414661827960504],[-150.97481881208188,-17.597998996155503],[-149.84981960963796,-16.953454989809906],[-149.22482005239348,-15.97048661114128]],[[-149.2132918,-17.804784610000148],[-149.28732000871403,-17.168553094226155],[-149.22482005239348,-15.97048661114128],[-148.949820247206,-13.698987269610743],[-148.04982088417785,-9.586362493293953],[-138.59982757864174,17.395022634700517],[-127.79983522945767,26.964304734562898],[-119.6998409675696,31.670513047087127],[-117.44984256148977,32.62301664000789],[-117.16576683559319,32.70640764608389]],[[174.59995054710086,-31.340410556277746],[175.28071006484436,-33.34801975644684],[174.82499288827472,-36.140033391295425],[174.93749280857884,-36.59297842795038],[174.77046042690606,-36.88418050095063]],[[152.0999664863006,-37.995035579083044],[149.39996839960057,-39.34180065396819],[147.59996967473646,-39.68895338487733],[146.24997063108842,-40.03436927637599],[145.23747134537265,-39.2547415615617],[144.69235173392437,-38.52352352301163],[144.8048516542285,-38.25898836030261],[144.96715153925368,-37.81753200000024]]]}},{"type":"Feature","properties":{"id":"tabua","name":"Tabua","color":"#939597","feature_id":"tabua-0","coordinates":[-158.6775256239548,19.76826141245578]},"geometry":{"type":"MultiLineString","coordinates":[[[-118.24533600000012,34.05348099999984],[-120.59984033000157,32.90681902852468],[-127.79983522945767,31.286738814391754],[-138.60310458621126,27.366657115363733],[-147.6030982105313,25.75672152517522],[-152.99981737755394,24.12261698700344],[-157.27481434970198,21.356164482330126],[-157.49981418971396,21.047336588294048],[-157.80225453681595,20.89386327374593],[-159.52481275518593,18.678647022154717],[-165.59980845160203,10.41081650540272],[-170.999804626194,2.367912558705407],[-175.4998014389502,-6.616650693475355],[-178.8747990480702,-15.006817032918805],[-179.54979856929816,-17.383402005942457],[-179.99979825051412,-17.81234135340585]],[[179.9999049246783,-17.81234135340585],[178.8749611145193,-18.240251410711533],[178.42494704980945,-18.559310645747416],[178.0874480765248,-18.77365906052572],[170.9999666932391,-22.665969967794794],[166.49996988107907,-25.94623071841455],[160.64997402527118,-30.697669744492647],[154.8000717646766,-32.61276000573574],[152.047927014282,-33.93716236175066],[151.20704000000026,-33.869695999999635]],[[-158.08325,21.33416151999962],[-157.90008311233407,21.128587431864354],[-157.80225453681595,20.89386327374593]],[[160.64997402527118,-30.697669744492647],[155.316576355408,-27.936291840641456],[153.96657731175995,-27.138263161649714],[153.5165776281601,-26.937854887542912],[153.40407770964404,-26.837516818959585],[153.08943909999996,-26.651840200000382]],[[178.0874480765248,-18.77365906052572],[177.52494847500478,-18.347065591453177],[177.31889862097242,-18.100389418495688]],[[178.42494704980945,-18.559310645747416],[178.43744782917764,-18.123810943537187]]]}},{"type":"Feature","properties":{"id":"hawaiian-islands-fiber-link-hifl","name":"Hawaiian Islands Fiber Link (HIFL)","color":"#939597","feature_id":"hawaiian-islands-fiber-link-hifl-0","coordinates":[-155.61158405092866,20.585795007256266]},"geometry":{"type":"MultiLineString","coordinates":[[[-156.467144,20.890755000000173],[-156.37481498727004,21.006499845176737],[-156.20606570789425,21.006499845176737],[-156.03731522635798,20.953979036599044],[-155.69981546484993,20.69109910743008],[-155.08106653327414,19.95262290516439],[-155.081864,19.719234999999824]],[[-156.896861,20.74346999999976],[-156.82559408025796,20.782151843998083],[-156.82481466788994,20.953979036599044]],[[-157.023889,21.093332999999788],[-157.10606504759033,20.90143978523765]],[[-159.368579,21.974924999999782],[-158.84981323336208,21.425997872385402],[-158.3998135521461,21.268825931479064],[-158.09434432251803,21.31382923952174]],[[-158.09434432251803,21.31382923952174],[-157.949813871526,21.187263163262585],[-157.72481403091797,21.134806167482292],[-157.49981418971396,20.994830150131428],[-157.1623144288019,20.90143978523765],[-157.10606504759033,20.90143978523765],[-156.93731458879003,20.953979036599044],[-156.82481466788994,20.953979036599044],[-156.712314748182,21.059002173328942],[-156.59981482787796,21.111485983488812],[-156.48731490757396,21.059002173328942],[-156.467144,20.890755000000173]]]}},{"type":"Feature","properties":{"id":"5-villages-6-islands","name":"5 Villages 6 Islands","color":"#694099","feature_id":"5-villages-6-islands-0","coordinates":[139.91550483423842,32.80032038085589]},"geometry":{"type":"MultiLineString","coordinates":[[[139.76561090792202,32.45723123281979],[139.72497525286053,32.55982671166815],[139.94997509346857,32.843830050378884],[139.94997509346857,33.03266260584946],[139.79680232984748,33.11229519278616]],[[139.49535639821372,34.09038084354649],[139.44372545210052,34.032921789964035],[139.4999754122525,33.93964008831966],[139.597023244309,33.88350991725466]],[[139.13964077948097,34.20695345476847],[139.16247565134051,34.032921789964035],[139.38747549194858,33.89296086026743],[139.597023244309,33.88350991725466]],[[139.23807809778313,34.35800775237148],[139.22402060774138,34.48560908145168],[139.27888824035568,34.529808064948476],[139.28026556789715,34.67086299423112],[139.36069278510652,34.750672522776554]],[[139.13964077948097,34.20695345476847],[139.21551869844038,34.3308542940764],[139.23807809778313,34.35800775237148],[139.258350283468,34.373853145806095]],[[139.28026556789715,34.67086299423112],[139.19589062766912,34.62458823084221],[139.18589063475318,34.55512767736998],[139.22402060774138,34.48560908145168]]]}},{"type":"Feature","properties":{"id":"izu-islands-cable-system","name":"Izu Islands Cable System","color":"#b72a79","feature_id":"izu-islands-cable-system-0","coordinates":[139.48631601387072,34.05581334749685]},"geometry":{"type":"MultiLineString","coordinates":[[[139.10482828483055,34.96320482218528],[139.22401560774517,34.80953220441419],[139.36069278510652,34.750672522776554],[139.3485155195481,34.67086299423112],[139.40476547970005,34.20695535766622],[139.49535639821372,34.09038084354649],[139.44372545210052,33.89296086026743],[139.61247533255641,33.22109136693108],[139.79680232984748,33.11229519278616]]]}},{"type":"Feature","properties":{"id":"hachijojima-mainland","name":"Hachijojima-Mainland","color":"#2492bd","feature_id":"hachijojima-mainland-0","coordinates":[139.8573967557421,34.13267917783602]},"geometry":{"type":"MultiLineString","coordinates":[[[139.6207699999995,35.144171],[139.83747517376037,34.381814625299135],[139.91306511961588,33.4365013965754],[139.79680232984748,33.11229519278616]]]}},{"type":"Feature","properties":{"id":"ogasawara-cable-network","name":"Ogasawara Cable Network","color":"#f5ae1a","feature_id":"ogasawara-cable-network-0","coordinates":[140.95181145231737,30.05826760234261]},"geometry":{"type":"MultiLineString","coordinates":[[[142.1599040123993,26.650916758530467],[142.14372353939643,26.797064317338755],[142.14372353939643,26.997723094443348],[142.20407854982358,27.06647506480611]],[[139.79680232984748,33.11229519278616],[140.06247501377248,33.03266260584946],[140.73747453559662,30.901396088515508],[141.2999741371166,28.68871408880043],[142.03122361909251,27.047832017492695],[142.20407854982358,27.06647506480611]],[[142.03122361909251,27.047832017492695],[142.03122361909251,26.797064317338755],[142.1599040123993,26.650916758530467]]]}},{"type":"Feature","properties":{"id":"bulikula","name":"Bulikula","color":"#939597","feature_id":"bulikula-0","coordinates":[159.83186028836406,-3.531667448778883]},"geometry":{"type":"MultiLineString","coordinates":[[[144.69470173285575,13.464772962370143],[146.24997063108842,11.735650161405832],[149.39996839960057,9.08033076823294],[152.99996584873273,2.367912558705407],[159.07496154574454,-3.029995968008661],[167.84995532945672,-8.846050186819125],[173.69996478053517,-13.698987269610743],[175.99994955532844,-15.657788279357506],[177.0749487937889,-18.026426383713453],[177.31889862097242,-18.100389418495688]],[[147.14996999352056,13.710817738179635],[146.92497015172074,12.615395567393394],[146.24997063108842,11.735650161405832]],[[179.99994675357007,17.395022634700517],[151.1999671244645,13.492128176464083],[148.49996903716843,13.492128176464083],[147.14996999352056,13.710817738179635],[146.24997063108842,14.692360031374392],[145.637512,15.011117]],[[-157.7973234078978,20.5096446349069],[-158.73731331305797,20.58581909604039],[-163.799809726738,19.104405475930452],[-172.79980335105813,19.104405475930452],[-179.99979825051412,17.395022634700517]],[[179.99994672169302,-18.220645596860603],[178.8749611145193,-18.34587912918448],[178.43744600000022,-18.123634]],[[-150.0748194496499,-17.168553094226155],[-150.07481944965008,-17.81234135340585],[-149.5123198481299,-17.86588684023115],[-149.2132918,-17.804784610000148]],[[-149.5123198481299,-17.168553094226155],[-149.17482008781394,-17.383402005942457],[-149.2132918,-17.804784610000148]],[[-158.08325,21.33416151999962],[-157.7973234078978,20.5096446349069],[-157.04981450969004,18.251816319028222],[-155.69981546544602,5.957818681088533],[-151.1998186532859,-13.698987269610743],[-149.5123198481299,-17.168553094226155],[-149.45615089999998,-17.637813220000343]],[[175.99994955532844,-15.657788279357506],[179.14994732384042,-16.090625820394795],[179.99994672169302,-16.054802405935337]],[[-149.45615764153175,-17.637813220000343],[-149.7373264308657,-17.27218929230482],[-150.0748261911816,-17.168553094226155],[-151.8748249160457,-16.953454989809906],[-156.59981974082785,-16.522522203974884],[-169.64981232407783,-16.522522203974884],[-177.99980191490647,-15.97048661114128],[-179.9997982511102,-16.054802311227153]],[[-179.99979733681937,-18.220645596860603],[-177.99980191490647,-15.97048661114128]]]}},{"type":"Feature","properties":{"id":"halaihai","name":"Halaihai","color":"#939597","feature_id":"halaihai-0","coordinates":[-126.5205259108391,-21.28245456603891]},"geometry":{"type":"MultiLineString","coordinates":[[[179.99994672169302,-5.117246731788138],[165.62299357428236,-0.075790545878564],[152.57300281961426,8.295072992234351],[149.4230050511021,10.514681069655714],[147.1730066450221,12.279111812101094],[146.27300728259013,12.937829392787588],[144.809541651502,13.549094363148988]],[[-149.43311424849836,-17.53712957379581],[-149.7142830378323,-17.06378750083079]],[[146.27300728259013,12.937829392787588],[146.27300728259013,13.92262683581919],[145.66050771649057,15.11350037881627]],[[-71.59739837597036,-32.95696151932608],[-72.87683746960369,-32.33383519609184],[-106.97681331345684,-24.721012464905712],[-143.9767871023282,-18.211166830631022],[-148.92678359570436,-17.282577603673964],[-149.7142830378323,-17.06378750083079],[-150.0517827981482,-16.852397237765768],[-156.57677634779455,-13.377553880887607],[-168.27676988739648,-9.186173588259289],[-179.9997982511102,-5.117246246169858]],[[-148.92678359570436,-17.282577603673964],[-149.19025514849838,-17.704194422751392]]]}},{"type":"Feature","properties":{"id":"coral-bridge","name":"Coral Bridge","color":"#43b549","feature_id":"coral-bridge-0","coordinates":[34.9509968,29.536753899999738]},"geometry":{"type":"MultiLineString","coordinates":[[[34.89689859999966,29.49247829999958],[35.005095000000225,29.581029499999897]]]}},{"type":"Feature","properties":{"id":"tamtam","name":"Tamtam","color":"#939597","feature_id":"tamtam-0","coordinates":[168.2640474112099,-19.500425717349525]},"geometry":{"type":"MultiLineString","coordinates":[[[167.26612318866503,-20.91651447004943],[168.19490432031384,-19.894761650039484],[168.38530051963824,-18.808896643598423],[168.32297999999963,-17.730419000000374]],[[168.32964248904594,-19.127069809852284],[169.01791817710196,-19.486257565978523],[169.26791799999978,-19.537939129035674]],[[168.35904735598615,-18.35539483853063],[168.03092357094425,-18.128176731173994],[167.70233787923036,-17.150350170777582],[167.97671305156445,-16.467430722196266],[167.67300232921588,-15.751013952628858],[167.42769909212595,-15.569669248869374],[167.20027400000015,-15.5064330000002]],[[167.404643144324,-16.08611306373696],[167.56992274973342,-15.99279751873166],[167.67300232921588,-15.751013952628858]]]}},{"type":"Feature","properties":{"id":"barat-timur-indonesia-1-bti-1","name":"Barat Timur Indonesia-1 (BTI-1)","color":"#939597","feature_id":"barat-timur-indonesia-1-bti-1-0","coordinates":[117.21952169436639,-3.838737451548527]},"geometry":{"type":"MultiLineString","coordinates":[[[104.10295358788379,1.196510763639271],[104.28790035741287,1.215596520932091],[104.62500011860807,1.271658270355313],[104.8499999592161,1.299726182129338],[105.29999964043219,0.68107206531244],[106.31249892316808,-0.331409329660265],[106.76649860035869,-2.130918480960333],[106.95937346312914,-3.029995968008661],[106.87499852468808,-4.60145376483711],[106.81874856394022,-5.273944363641298],[107.12089700000031,-5.981253715805995]],[[107.12089700000031,-5.981253715805995],[107.88749780742415,-5.273944363641298],[108.89999708956415,-5.124561675456293],[112.0499948586722,-5.273944363641298],[114.57481343375933,-5.385957847173066],[117.03310132740097,-4.179995582158629],[117.82499076880309,-2.730375485267853],[118.07689730311003,-1.51533365197483],[118.9912802772246,-0.810555324740758],[119.69998944053637,0.568578852526193],[119.92498928054833,1.018534216615524],[120.59998880177636,1.468426767331968],[121.49998816361243,1.730824054890829],[124.19998625150441,1.805788280129153],[124.83962579778171,1.490818000000369]],[[118.07689730311003,-1.51533365197483],[117.85190631946367,-1.627791237381674],[117.08124129449006,-1.459102642455618],[116.83129147155694,-1.265389667588013]],[[117.03310132740097,-4.179995582158629],[117.4499910332642,-4.264964252800808],[118.79999007691224,-4.825692499217419],[119.41238964308275,-5.152180217334703]],[[114.57481343375933,-5.385957847173066],[113.34469361088368,-5.945707155070644],[112.94999421991238,-6.393099497823911],[112.83749430080026,-6.616650693475355],[112.65238763118269,-7.165175579207309]]]}},{"type":"Feature","properties":{"id":"jeju-udo","name":"Jeju-Udo","color":"#3a55a5","feature_id":"jeju-udo-0","coordinates":[126.93252888037216,33.473453760532934]},"geometry":{"type":"MultiLineString","coordinates":[[[126.95575023131646,33.5053360138088],[126.90930752942786,33.441571507257066]]]}},{"type":"Feature","properties":{"id":"domestic-submarine-cable-of-maldives-dscom","name":"Domestic Submarine Cable of Maldives (DSCoM)","color":"#cc1b8d","feature_id":"domestic-submarine-cable-of-maldives-dscom-0","coordinates":[72.75232269632002,3.153668530783705]},"geometry":{"type":"MultiLineString","coordinates":[[[73.27506588125877,5.662178353024311],[73.04136330700179,5.629270004148423],[73.12502243289187,5.435413643888211],[73.12502243289187,5.211384397097438],[73.07078247131614,5.103263999999835]],[[73.54027213872524,4.211913999999877],[73.63127207425991,4.164912849976942],[73.63127207425991,4.0527020972683],[73.48945837504712,3.940895595045809]],[[72.95565310576072,3.608066999999686],[72.8363099155327,3.472925055920153],[72.75232269632002,3.276079687360005],[72.75232269632002,2.939076705497889],[72.8932954979876,2.67085379439719]]]}},{"type":"Feature","properties":{"id":"nome-to-homer-express-nthe","name":"Nome to Homer Express (NTHE)","color":"#939597","feature_id":"nome-to-homer-express-nthe-0","coordinates":[-163.38182001646643,59.01602584939292]},"geometry":{"type":"MultiLineString","coordinates":[[[-164.64552951609997,62.68469323703459],[-164.92480893037407,62.600431547112485],[-165.5998084521981,62.47071999993706],[-166.49980781463006,61.94619734289449],[-166.49980781463006,61.08784471959974],[-165.82480829280604,60.64972274466829],[-165.37480861159005,60.205560984023215],[-162.787310444598,58.66118687773907],[-162.11231092277407,58.36740335134618],[-161.774811161862,58.30835113805548],[-159.29981291517407,58.13060164022799],[-158.399813552742,58.24920018303555],[-157.61231411061405,58.6026268350673],[-157.01266562922953,58.73211899012127]],[[-155.886297,59.330386046597525],[-155.77637602254924,59.38733022969843],[-155.26495639776326,59.49092999474293],[-154.64737685086385,59.66780559050685],[-154.39200703822138,59.71199027213407],[-154.24512714598293,59.71592898705984],[-154.12325723539544,59.72616949287022],[-154.03575729959164,59.748218034367554],[-153.95450735920244,59.77182533874293],[-153.88049330000007,59.7760993091788]],[[-153.632155,59.682359410640956],[-153.56295197922012,59.63553746032112],[-153.5170020129323,59.6180780639796],[-152.99981737755394,59.62282176941042],[-152.09981801512188,59.651254777633675],[-151.54939912435796,59.6425]],[[-165.40639895727566,64.50111053726953],[-165.59980845398627,64.31739001144432],[-165.71230837250212,63.088206521564956],[-165.5998084521981,62.47071999993706]]]}},{"type":"Feature","properties":{"id":"mercator","name":"Mercator","color":"#b9bc33","feature_id":"mercator-0","coordinates":[2.1884910115073875,51.376675500182785]},"geometry":{"type":"MultiLineString","coordinates":[[[1.440972309852729,51.35859517845766],[1.800052055477241,51.376675500182785],[2.475051577301171,51.376675500182785],[2.912751267230734,51.2312443647522]]]}},{"type":"Feature","properties":{"id":"iceni","name":"Iceni","color":"#d52b90","feature_id":"iceni-0","coordinates":[3.201748854214138,52.7901509051969]},"geometry":{"type":"MultiLineString","coordinates":[[[1.693552130326814,52.71521693088626],[2.250051736098142,52.78234584700751],[3.150051098530109,52.78234584700751],[4.050050460962258,52.918223068392784],[4.7007499999996,52.836929]]]}},{"type":"Feature","properties":{"id":"tam-1","name":"TAM-1","color":"#939597","feature_id":"tam-1-0","coordinates":[-73.10356090767246,23.77858245135987]},"geometry":{"type":"MultiLineString","coordinates":[[[-80.3942513282677,27.638731771078668],[-79.64986933934534,27.264711877833996],[-79.19986965991733,26.86399017396059],[-78.86236989721739,26.763586569619914],[-77.84987061448132,27.164665812813517],[-77.39987093326533,27.064530023167972],[-76.94987125324133,26.562513149236715],[-76.6061714961255,26.116791262410242],[-76.24064175447413,25.70052610903888],[-73.3498738023213,23.91710129093513],[-70.19987599438629,22.145638852308416],[-68.84987695073825,21.309590385318035],[-67.49987794710923,20.375041253465433],[-66.48737866377725,19.104405475930452],[-65.92487906344923,18.838433217733183]],[[-65.92487906344923,18.838433217733183],[-65.32398553479186,18.62408068875806],[-65.19362958028117,18.251816319028222],[-64.8805299999999,17.753367672551875]],[[-66.10666893347558,18.46610423294742],[-66.03732898259665,18.678647022154717],[-65.92487906344923,18.838433217733183]],[[-85.94986487636929,21.635297384859552],[-86.51236447788929,21.268825931479064],[-86.767566,21.095663483773514]],[[-80.16016897784424,26.010548668010795],[-80.09986902056141,25.348717422116714],[-80.77486854238535,24.73717827217609],[-80.99986838299355,24.53265756616073],[-83.2498667890733,24.53265756616073],[-84.82486567332928,23.50508968095737],[-85.94986487636929,21.635297384859552],[-86.28736463787752,20.375041253465433],[-87.0748640794093,18.251816319028222],[-87.97486344184153,16.534196198259725],[-88.3123632027534,16.10232559580297],[-88.597165,15.727178884973721]],[[-87.97486344184153,16.534196198259725],[-87.86236352213342,16.10232559580297],[-87.9461557876504,15.844981598742601]],[[-70.19987599438629,22.145638852308416],[-71.5498749986113,21.425997872385402],[-73.3498738023213,20.796306105108872],[-74.13737324444934,19.95262290516439],[-74.69987284596934,19.316876111628712],[-75.14987252718532,18.251816319028222],[-75.93737196931336,17.395022634700517],[-78.29987029569729,11.955858207114732],[-79.64986933934534,9.967915186974132],[-79.7534792659471,9.437721984870015]],[[-78.29987029569729,11.955858207114732],[-75.37487236838926,11.33148066218366],[-74.77975278938153,10.940445615726643]],[[-79.64986933934534,9.967915186974132],[-82.34986742664132,10.07869800665097],[-83.03765938887457,9.988597517410145]]]}},{"type":"Feature","properties":{"id":"cross-sound-cable","name":"Cross Sound Cable","color":"#b91e73","feature_id":"cross-sound-cable-0","coordinates":[-72.87873601507873,41.13651601131971]},"geometry":{"type":"MultiLineString","coordinates":[[[-72.92498345896654,41.30713000000013],[-72.87702413788844,41.14638300214584],[-72.9092184589667,40.96082000000039]]]}},{"type":"Feature","properties":{"id":"tautira-teahupoo","name":"Tautira-Teahupo'o","color":"#8d4098","feature_id":"tautira-teahupoo-0","coordinates":[-149.1410734769528,-17.91763374953515]},"geometry":{"type":"MultiLineString","coordinates":[[[-149.1770351107709,-17.771815724224076],[-149.11257091218943,-17.83019163808816],[-149.12169512544816,-17.913887619740432],[-149.24225611848354,-17.937193891586627],[-149.24856523377207,-17.84943323159532]]]}},{"type":"Feature","properties":{"id":"whidbey-island-seattle","name":"Whidbey Island-Seattle","color":"#ed166f","feature_id":"whidbey-island-seattle-0","coordinates":[-122.42525525402746,47.76389238568781]},"geometry":{"type":"MultiLineString","coordinates":[[[-122.44329313719003,47.93851635072451],[-122.46918400633709,47.90307887010544],[-122.44105402626471,47.82760546971162],[-122.41293050095528,47.714189281151164],[-122.3294391053337,47.60355999999983]]]}},{"type":"Feature","properties":{"id":"whidbey-island-everett","name":"Whidbey Island-Everett","color":"#55b847","feature_id":"whidbey-island-everett-0","coordinates":[-122.281925,47.9780225]},"geometry":{"type":"MultiLineString","coordinates":[[[-122.357431,47.97946999999985],[-122.206419,47.976575]]]}},{"type":"Feature","properties":{"id":"whidbey-island-hat-island","name":"Whidbey Island-Hat Island","color":"#ac4a9b","feature_id":"whidbey-island-hat-island-0","coordinates":[-122.33861676704001,47.99652985730446]},"geometry":{"type":"MultiLineString","coordinates":[[[-122.31980253408008,48.013589714609076],[-122.357431,47.97946999999985]]]}},{"type":"Feature","properties":{"id":"whidbey-island-camano-island","name":"Whidbey Island-Camano Island","color":"#cc1d53","feature_id":"whidbey-island-camano-island-0","coordinates":[-122.5417180962798,48.12044240714399]},"geometry":{"type":"MultiLineString","coordinates":[[[-122.5097258233355,48.14223038529169],[-122.57371036922409,48.09865442899629]]]}},{"type":"Feature","properties":{"id":"israel-coasting-1-ic-1","name":"Israel Coasting 1 (IC-1)","color":"#3864af","feature_id":"israel-coasting-1-ic-1-0","coordinates":[34.8277487519171,32.306398809559404]},"geometry":{"type":"MultiLineString","coordinates":[[[35.10636158023618,33.03646299999986],[34.98754944983565,32.938296728497555],[34.998746580236514,32.811596],[34.81879956937957,32.62301664000789],[34.81879956937957,32.43331330641721],[34.87197458023602,32.343948000000225],[34.790674589303684,32.27492161975156],[34.844678580236696,32.16241300000017],[34.76254960922761,32.13213222598808],[34.76966458023627,32.04504300000013],[34.73442462915154,32.012970042085655],[34.78510959324601,31.97782943227114],[34.53754976861957,31.798087367585257],[34.55601458023597,31.669509999999626]],[[34.99874016776005,32.811596],[34.76254960982351,32.62301664000789],[34.650049689519584,32.243210016262736],[34.706299649075646,32.13213222598808],[34.7696581677598,32.04504300000013],[34.706299649075646,32.0368149002235],[34.53754976861957,31.989118978289795],[34.48129980846761,31.798087367585257],[34.55600816776041,31.669509999999626]]]}},{"type":"Feature","properties":{"id":"zayo-festoon","name":"Zayo Festoon","color":"#b58636","feature_id":"zayo-festoon-0","coordinates":[-119.92123784718275,34.25287625691128]},"geometry":{"type":"MultiLineString","coordinates":[[[-120.66255178515836,35.28542699999962],[-120.93734009150971,35.08292270029031],[-121.04984001181373,34.806272556890626],[-120.59984033059774,34.25018044028598],[-119.92484080877372,34.25018044028598],[-119.6988724921824,34.41925499999979],[-119.69984096816569,34.157137999942634],[-119.2498412869497,33.97074536407291],[-118.79984160513764,33.98629373718467],[-118.24535355799169,34.053396879397056]]]}},{"type":"Feature","properties":{"id":"fish-south","name":"FISH South","color":"#939597","feature_id":"fish-south-0","coordinates":[-140.05905152238464,59.5231653195042]},"geometry":{"type":"MultiLineString","coordinates":[[[-145.75467337578323,60.542748324277206],[-145.968573218851,60.483872905447065],[-146.02482231930196,60.428400366297495],[-145.79982247869393,60.205560984023215],[-144.5623233553499,59.69858962318886],[-142.64982471018186,59.75530323694996],[-141.52482550714194,59.52787100202252],[-140.06232654318987,59.52787100202234],[-139.4998269416698,58.71964879559121],[-137.69982821680577,58.1306016402278],[-136.5505601279829,58.13918563289863],[-136.4775601815409,58.21128116930611],[-136.3920702442625,58.24491118647017],[-136.2840603235063,58.25896032097751],[-136.11301044900063,58.27149460377823],[-135.9869905414579,58.29757462462181],[-135.88347061740748,58.31409797025878],[-135.76961070094336,58.330713352889795],[-135.63186080200646,58.32837695835557],[-135.54587086509488,58.29993407371451],[-135.4474909372734,58.25798705857819],[-135.35237100706019,58.19309178820427],[-135.27131106653158,58.1693557186385],[-135.17493000487138,58.14951957724301],[-135.0638912187096,58.157144083520514],[-134.9729690130812,58.365509963814],[-134.97219514981677,58.428990502797014],[-134.92327132187842,58.43604469305952],[-134.84319138063083,58.39745579138094],[-134.76047144132005,58.3314037410391],[-134.7013914846653,58.32476180540529],[-134.60315155674115,58.341681841606146],[-134.54221160145104,58.33366118989016],[-134.4068617007534,58.299576750775365]],[[-135.35237100706019,58.19309178820427],[-135.43380535882486,58.11192247006416]],[[-135.76961070094336,58.330713352889795],[-135.72082551682627,58.41785832955853]],[[-136.5505601279829,58.13918563289863],[-136.46232909346182,58.10089013194181],[-136.34982917315781,58.04139277961042],[-136.22857036481375,57.96074065944373]],[[-140.06232654318987,59.52787100202234],[-139.94982662288587,59.58487361294478],[-139.83732670258183,59.58487361294478],[-139.6734859591499,59.545761772458185]]]}},{"type":"Feature","properties":{"id":"patara-2","name":"Patara-2","color":"#9a54a1","feature_id":"patara-2-0","coordinates":[133.2583728887021,-0.04183100947288879]},"geometry":{"type":"MultiLineString","coordinates":[[[130.85760153518734,-0.383358280481206],[131.0467079746435,-0.499976465400673],[131.2759166074868,-0.474388956955962],[131.53216440438356,-0.224827326582978],[132.29998051339228,0.006088583243203],[134.5499789188764,-0.106411275875408],[135.44997828190446,-0.500154705903456],[135.63855236706684,-0.729535713493556]],[[134.5499789188764,-0.106411275875408],[134.4374789985725,-0.443906656918545],[134.32497907886446,-0.668895743818606],[134.06198926516882,-0.861458343462594]]]}},{"type":"Feature","properties":{"id":"nuvem","name":"Nuvem","color":"#939597","feature_id":"nuvem-0","coordinates":[-52.48579468172721,33.29578855556337]},"geometry":{"type":"MultiLineString","coordinates":[[[-64.65917995948635,32.36157723537831],[-64.5748800192052,32.243210016262736],[-64.46238009890118,32.052708023486204]],[[-26.22490718668583,37.569973991598665],[-25.87490743462888,38.29952060596925],[-25.199907914592835,38.827311095266374],[-23.399909187344846,38.827311095266374],[-13.94991588359671,38.29952060596925],[-10.349918432080775,38.122730108392204],[-8.869597215129223,37.95721527519206]],[[-26.22490718668583,37.569973991598665],[-25.87490743403289,37.50058844605323],[-25.668707580106854,37.739604626314694]],[[-78.88266988343256,33.69355790837514],[-78.26653698657397,33.47169957086474],[-69.29987667197325,32.17975358978957],[-64.46238009890118,32.052708023486204],[-62.09988177192116,32.052708023486204],[-50.39989006030513,33.565491482355505],[-39.54989774713741,35.215791334874076],[-26.22490718668583,37.569973991598665]]]}},{"type":"Feature","properties":{"id":"tko-connect","name":"TKO Connect","color":"#de542a","feature_id":"tko-connect-0","coordinates":[114.25484208234954,22.291691296870393]},"geometry":{"type":"MultiLineString","coordinates":[[[114.2586832940168,22.31829267897149],[114.24374330400435,22.296070300061047],[114.23935699999967,22.26885000000032]],[[114.2586832940168,22.31829267897149],[114.25781079403889,22.296070300061047],[114.23935699999967,22.26885000000032]]]}},{"type":"Feature","properties":{"id":"airraq","name":"Airraq","color":"#bc3e96","feature_id":"airraq-0","coordinates":[-160.4808504264838,58.32675711338343]},"geometry":{"type":"MultiLineString","coordinates":[[[-162.2248108430781,58.77801269228043],[-162.33731076338202,59.29889402722166],[-162.281061250843,59.52787100202234],[-162.33731076338202,59.755303236949786],[-162.281061250843,60.093570225923045],[-162.25293582315408,60.205560984023215],[-162.03248143321898,60.21970496867187]],[[-158.50879580810786,59.04498530367392],[-158.51231347304602,58.95251729542412],[-158.62481339335005,58.83627867169679],[-158.51231347304602,58.543968564154575],[-158.62481339335005,58.36740335134618],[-159.29981291517407,58.24920018303555],[-161.0998116400381,58.36740335134618],[-161.32481148064602,58.39689246177332],[-161.774811161862,58.42635692511553],[-162.11231092277407,58.48521196181824],[-162.33731076338202,58.66118687773907],[-162.2248108430781,58.77801269228043],[-161.99981100247004,58.89444683796138],[-161.71068878535152,58.98588663078411]],[[-161.32481148064602,58.39689246177332],[-161.2248115514869,58.65468505389476],[-161.05606214959,58.771532652193535],[-160.83106231466593,58.80068331378435],[-160.71856239720398,58.8589112423676],[-160.49356256227992,58.97507388617798],[-160.40216262933743,59.07217535000008]],[[-162.28609797914913,59.507429219589824],[-162.06110377956233,59.6971742699951],[-161.91244152128883,59.751830769999934]]]}},{"type":"Feature","properties":{"id":"javali","name":"JAVALI","color":"#3476bb","feature_id":"javali-0","coordinates":[114.59713560544341,-8.836169010231123]},"geometry":{"type":"MultiLineString","coordinates":[[[114.09716340843899,-8.615167135560569],[114.41249318505615,-8.836169010231123],[114.97499278657615,-8.836169010231123],[115.15985265561976,-8.783705413994815]]]}},{"type":"Feature","properties":{"id":"jalapati","name":"Jalapati","color":"#3f5fac","feature_id":"jalapati-0","coordinates":[114.43455937194622,-8.39017416643693]},"geometry":{"type":"MultiLineString","coordinates":[[[114.3103932573849,-8.44802754685532],[114.47074314379155,-8.3733148685123],[114.52729310373104,-8.294580235999058]]]}},{"type":"Feature","properties":{"id":"fish-north","name":"FISH North","color":"#4158a7","feature_id":"fish-north-0","coordinates":[-146.59925827081307,60.66661077053617]},"geometry":{"type":"MultiLineString","coordinates":[[[-146.35343293589426,61.13035646305128],[-146.55314278937283,61.09714043898838],[-146.6451427218751,61.06141701295124],[-146.77301262806066,60.95572329637335],[-146.8188725944145,60.849584758673764],[-146.77598736450295,60.690595783095596],[-146.47482200051795,60.64972274466811],[-146.02482231930196,60.6211590197015],[-145.79982247869393,60.6359344063654],[-145.77169749861793,60.6359344063654],[-145.75467386865427,60.542748324277206]]]}},{"type":"Feature","properties":{"id":"sydney-melbourne-adelaide-perth-smap","name":"Sydney-Melbourne-Adelaide-Perth (SMAP)","color":"#939597","feature_id":"sydney-melbourne-adelaide-perth-smap-0","coordinates":[132.4514961343884,-37.19272194034643]},"geometry":{"type":"MultiLineString","coordinates":[[[151.2070371188603,-33.869695999999635],[151.57379310964276,-34.82698199055459],[150.4999676203508,-37.942453525696855],[149.39996839960057,-38.81782397325074],[147.59996967473646,-39.34180065396819],[146.24997063108842,-39.68895338487733],[144.4499719056286,-39.51559387611211],[143.9999722250084,-39.34180065396819],[142.19997350014447,-39.34180065396819],[140.39997477528055,-38.99291515860618],[134.85085761257704,-37.39478470533192],[127.09256232737636,-36.741418127951896],[119.55018485796727,-36.260840217957096],[115.44676979611502,-36.09924960156447],[113.82821359836942,-34.611299785451145],[113.82821359836942,-33.06182889373477],[115.85731216153303,-31.953441330324313]],[[144.4499719056286,-39.51559387611211],[144.4499719056286,-38.81782397325074],[144.32691199280544,-38.329156471712544]],[[143.9999722250084,-39.34180065396819],[144.22497206502055,-38.81782397325074],[144.32691199280544,-38.329156471712544]],[[134.85085761257704,-37.39478470533192],[135.89997796252462,-36.018803301581926],[136.34997764374052,-35.653993397147275],[137.69997668738856,-35.28750958287694],[138.59988604988445,-34.926102000000434]]]}},{"type":"Feature","properties":{"id":"ulleung-mainland-2","name":"Ulleung-Mainland 2","color":"#aa422e","feature_id":"ulleung-mainland-2-0","coordinates":[130.12626832379473,37.2773307646333]},"geometry":{"type":"MultiLineString","coordinates":[[[129.33873337935873,37.17514326596579],[129.59998242550049,37.17261659294013],[130.49998178793246,37.35168786972502],[130.89327660932778,37.48932062641182]]]}},{"type":"Feature","properties":{"id":"jeju-mainland-3","name":"Jeju-Mainland 3","color":"#aa519f","feature_id":"jeju-mainland-3-0","coordinates":[127.53929599383478,34.00615714927147]},"geometry":{"type":"MultiLineString","coordinates":[[[128.0274354936125,34.72835854992582],[127.79998370123228,34.31215165223547],[127.34998401942048,33.783943282364],[127.12498417881244,33.50297483987678],[126.90930752942786,33.4415685056695]]]}},{"type":"Feature","properties":{"id":"jeju-mainland-2","name":"Jeju-Mainland 2","color":"#7a68ae","feature_id":"jeju-mainland-2-0","coordinates":[127.16999265328461,33.99757610853796]},"geometry":{"type":"MultiLineString","coordinates":[[[127.28599296347433,34.60940175186309],[127.26560907919244,34.4514196909948],[127.12498417881244,33.783943282364],[126.90930752942786,33.4415685056695]]]}},{"type":"Feature","properties":{"id":"yuza-tobishima","name":"Yuza-Tobishima","color":"#2e2f8a","feature_id":"yuza-tobishima-0","coordinates":[139.72538937049725,39.09503710375718]},"geometry":{"type":"MultiLineString","coordinates":[[[139.54899537752638,39.19500461500409],[139.83747517316465,39.03151487972872],[139.90963512204584,39.02099623671373]]]}},{"type":"Feature","properties":{"id":"yui","name":"YUI","color":"#632c8c","feature_id":"yui-0","coordinates":[126.37558620037001,24.501003998270562]},"geometry":{"type":"MultiLineString","coordinates":[[[124.24509621895214,24.38836538952259],[125.09998561393637,24.00705710909988],[125.7749851357603,24.01990020343248],[127.68748377735349,25.55188275942587],[127.79998370004047,25.855985466072205],[127.68080378506454,26.21236926863903]],[[125.29998547165854,24.7946200137949],[125.43748537425243,25.179151748812224],[126.3374847372803,26.260240971577822],[126.74738444630759,26.348765491642183]]]}},{"type":"Feature","properties":{"id":"beaufort","name":"Beaufort","color":"#939597","feature_id":"beaufort-0","coordinates":[-5.6057073862159355,51.45354505472745]},"geometry":{"type":"MultiLineString","coordinates":[[[-6.584105999999635,52.17489218348463],[-5.962421540820768,51.586833980054095],[-5.399921938704683,51.3766517753536],[-4.544402544762735,50.82820142743812]],[[-5.399921938704683,51.3766517753536],[-4.209462782633154,51.546886997611246]]]}},{"type":"Feature","properties":{"id":"tpu","name":"TPU","color":"#939597","feature_id":"tpu-0","coordinates":[150.1846675821809,16.566776310033482]},"geometry":{"type":"MultiLineString","coordinates":[[[120.91281524739219,22.43810872743653],[122.8730238593579,20.789874953693833],[131.42301780246206,18.352094170308565],[138.62301270191816,16.635422895611335],[144.47300855772602,14.68570566481149],[145.20625803709336,14.576853976609653],[145.5437577986014,14.46794848402804],[146.27300728259013,14.68570566481149],[151.22300377596605,17.066099764630273],[160.22299739969037,20.614481930232845],[179.99994672169302,25.579505481562034]],[[145.5437577986014,14.46794848402804],[145.37300792015816,13.758776740709814],[144.9230082389421,13.540131566252656],[144.809541651502,13.549094363148988]],[[-179.97676374562144,25.579505481562034],[-172.776773138787,28.781327858545534],[-163.77677307583247,32.648802835102664],[-151.17678200178437,36.35600267515968],[-124.13652256800194,40.88315727756635]],[[122.8730238593579,20.789874953693833],[121.74802465572199,19.34577048503237],[121.10541511095266,18.706298048590874]],[[145.20625803709336,14.576853976609653],[145.42186455104067,14.850056076332436],[145.63747106498886,15.011508499399486]]]}},{"type":"Feature","properties":{"id":"sihanoukville-hong-kong-shv-hk","name":"Sihanoukville-Hong Kong (SHV-HK)","color":"#939597","feature_id":"sihanoukville-hong-kong-shv-hk-0","coordinates":[110.01002679906081,12.571360602352751]},"geometry":{"type":"MultiLineString","coordinates":[[[103.506729,10.629948016412682],[103.83750067648003,9.52441134501949],[104.40000027621234,8.190543417795496],[105.29999964043219,7.967776882259704],[107.99999772772827,8.746907464137017],[109.12499693017237,9.967915186974132],[110.02499629260434,12.615395567393394],[112.72499438049614,18.251816319028222],[113.17499406171221,20.796306105108872],[114.2586832940168,22.31829267897149]]]}},{"type":"Feature","properties":{"id":"american-1","name":"AmeriCan-1","color":"#866baf","feature_id":"american-1-0","coordinates":[-122.59133453336406,48.01016245185861]},"geometry":{"type":"MultiLineString","coordinates":[[[-123.05676002859592,48.988680000000116],[-122.96233865698169,48.76606651214577],[-123.2435898915242,48.65471436482155],[-123.2435898915242,48.58034267150839],[-123.29983841789374,48.50586139322304],[-123.36725159811704,48.520397324657175]],[[-123.41463833656836,48.43012000000036],[-123.0748385772857,48.35656994073399],[-122.6471513365683,48.290598]],[[-122.6471513365683,48.290598],[-122.79358877712178,48.27551390305846],[-122.79358877712178,48.23806972100289],[-122.63793388679308,48.11006090495571],[-122.6098139067135,48.034889597039694],[-122.55355394656864,47.959608461901695],[-122.49730398641668,47.90307887010544],[-122.46918045968634,47.82760546971162],[-122.44105402626471,47.714189281151164],[-122.3294391053337,47.60355999999983]]]}},{"type":"Feature","properties":{"id":"anjana","name":"Anjana","color":"#939597","feature_id":"anjana-0","coordinates":[-40.72402000723348,38.72436239362389]},"geometry":{"type":"MultiLineString","coordinates":[[[-78.88266988343256,33.69355790837514],[-78.26653698657397,33.565491482352044],[-76.94987125204935,33.565491482352044],[-62.09988177192116,34.683017659857974],[-50.39989006030513,36.33133835588799],[-39.59989771112098,39.00237890905839],[-23.399909187344846,43.401144973153954],[-16.199914287888834,45.0138336439531],[-9.899918750864702,45.96024524125342],[-5.849921619920758,45.331071073324864],[-4.724922416284671,44.694829089578164],[-3.810013065606984,43.46149931727051]]]}},{"type":"Feature","properties":{"id":"sunoque-ii","name":"Sunoque II","color":"#9f4a23","feature_id":"sunoque-ii-0","coordinates":[-68.86272863550103,48.58908639575682]},"geometry":{"type":"MultiLineString","coordinates":[[[-69.08359854223662,48.73935708855762],[-68.84987699075727,48.58034267150839],[-68.70931121562639,48.37278902244084]]]}},{"type":"Feature","properties":{"id":"sunoque-i","name":"Sunoque I","color":"#2c286e","feature_id":"sunoque-i-0","coordinates":[-68.35435428558303,48.8800286426663]},"geometry":{"type":"MultiLineString","coordinates":[[[-68.15248212795328,49.22103999999976],[-68.39987730954128,48.803129141654416],[-68.45945262418255,48.503259270981985]]]}},{"type":"Feature","properties":{"id":"vancouver-bowen-island-vancouver-island","name":"Vancouver-Bowen Island-Vancouver Island","color":"#854e9f","feature_id":"vancouver-bowen-island-vancouver-island-0","coordinates":[-123.895399598297,49.41860561220368]},"geometry":{"type":"MultiLineString","coordinates":[[[-123.33800982225094,49.38007937128451],[-123.27652986735704,49.374816039003974]],[[-124.35616907525649,49.3421444206449],[-124.08733786002196,49.429010440658146],[-123.74983809910962,49.41071483308015],[-123.63733817880552,49.3924124042524],[-123.42890625369192,49.343283488381665]]]}},{"type":"Feature","properties":{"id":"fish-west","name":"FISH West","color":"#939597","feature_id":"fish-west-0","coordinates":[-147.61809727128994,60.21192204007282]},"geometry":{"type":"MultiLineString","coordinates":[[[-145.75467386865427,60.542748324277206],[-145.77169749861793,60.62214016805568],[-145.79982247869393,60.62214016805568],[-146.02482231930196,60.59453398451654],[-146.61544690089795,60.5392507415089],[-146.6118661862618,60.482480231842025],[-146.6716968610499,60.52541515489329],[-147.15173333746702,60.42722573091017],[-147.49861045536474,60.29714870250724],[-147.5898620287615,60.22409468936658],[-147.79578024891978,60.13532016318152],[-147.90828343249714,60.07925524504043],[-148.01078611085993,60.065087981861794],[-148.04982088477394,60.03743169846873],[-148.13419582500197,60.009326600079234],[-148.21857156809125,60.009326600079234],[-148.49982056598992,59.84019347070499],[-149.0623201675099,59.84019347070499],[-149.28732000811794,59.84961238502145],[-149.34357074271136,59.906069924574304],[-149.4476706657402,60.110049313261904]]]}},{"type":"Feature","properties":{"id":"sunoque-iii","name":"Sunoque III","color":"#939597","feature_id":"sunoque-iii-0","coordinates":[-66.47500823454705,49.679665839235454]},"geometry":{"type":"MultiLineString","coordinates":[[[-66.40694325248745,50.230913957960496],[-66.47487867322835,49.68443309948498],[-66.49011413130242,49.123838360364786]]]}},{"type":"Feature","properties":{"id":"t3","name":"T3","color":"#d02a3f","feature_id":"t3-0","coordinates":[45.03562963425208,-27.137914527598255]},"geometry":{"type":"MultiLineString","coordinates":[[[30.88039235938437,-30.05771707645661],[32.40005128343957,-30.018154882677354],[38.70004682046353,-28.348517239947288],[45.00004235748766,-27.15383128539156],[47.700040444783745,-25.94623071841455],[52.20003725575196,-23.08058350574764],[54.675035502439826,-21.623977662097285],[55.350035024859835,-21.79817640109735],[56.02503454668376,-21.79817640109735],[56.70003406791198,-20.995131543025785],[57.15003375031967,-20.679706953509093],[57.485483512684034,-20.473995660946287]]]}},{"type":"Feature","properties":{"id":"submarine-cable-in-the-philippines-scip","name":"Submarine Cable in the Philippines (SCiP)","color":"#224092","feature_id":"submarine-cable-in-the-philippines-scip-0","coordinates":[122.42603465916574,12.165698513473048]},"geometry":{"type":"MultiLineString","coordinates":[[[121.05018848285071,12.363012914770698],[120.71248872148438,12.175887185507976],[120.48748888087634,11.735650161405832],[120.03748919966027,11.073982781226615],[119.50037958074992,10.820000490489418]],[[120.48748888087634,11.735650161405832],[120.37498896116831,11.790718790556442],[120.2007690845874,12.005434247136186]],[[124.08471633256691,12.585260603443063],[124.2281112315803,12.505588131780646],[124.28238619313154,12.501390118608773]],[[124.61277595908027,11.006888020676206],[124.42498609211226,10.797840764398114],[124.31248617180833,10.742581675476407],[124.1999862503126,10.521444685552128],[124.14373629075637,10.41081650540272],[123.98834884332426,10.406420283136939]],[[124.84217394836772,10.178582995816972],[124.81873581258047,9.967915186974132],[125.15623557408834,9.746236973759974],[125.43748537425243,9.302441529883154],[125.54061530179015,8.947610463936108]],[[124.63191594552143,8.454147535358473],[124.59373597256834,8.635699417327467],[123.74998657028833,9.02478143560688],[123.5249867296803,9.08033076823294],[123.28143690221334,9.295503918747997],[123.5249867296803,9.413444258507564],[123.69373661073246,9.801670473167492],[123.8062365298444,10.07869800665097],[123.83892650668652,10.252013798268077]],[[123.98834884332426,10.406420283136939],[124.0551621347531,10.590420891923138],[124.24666621783989,10.93201162513164],[124.14373629075637,11.294709319565477],[124.01730638032068,11.246494999999399],[123.93298644005357,11.427191410552973],[122.96248712816029,11.955858207114732],[122.39998752664029,12.175887185507976],[122.17498768543635,12.065895273570327],[122.03325741497643,11.86790448093766],[121.83748792512029,12.175887185507976],[121.72498800422028,12.395734000022975],[121.52772251956107,12.586423162176041],[121.55623182195282,12.834868817846521],[121.46453818932153,13.045782550710932],[121.62498807506134,13.273238157547594],[121.47853039186327,13.620875075300322],[121.06600847164388,13.762418337904428]]]}},{"type":"Feature","properties":{"id":"taiwan-penghu-kinmen-matsu-no-2-tpkm2","name":"Taiwan Penghu Kinmen Matsu No.2 (TPKM2)","color":"#5a266c","feature_id":"taiwan-penghu-kinmen-matsu-no-2-tpkm2-0","coordinates":[119.54182501885121,24.361968609765935]},"geometry":{"type":"MultiLineString","coordinates":[[[120.17047428762343,23.362789999999872],[119.92498927935526,23.43629494132288],[119.65616428762347,23.573629999999714]],[[120.64548999999985,24.432462601860976],[120.26248904026849,24.361968609765935],[118.79999007631633,24.361968609765935],[118.43944000000037,24.43748260175769]],[[121.4626481900644,25.181604223404467],[121.27498832360028,25.742038029757644],[120.71248872148438,26.39468192538876],[120.49296999999949,26.36733136664364]]]}},{"type":"Feature","properties":{"id":"red2med","name":"Red2Med","color":"#7b231b","feature_id":"red2med-0","coordinates":[32.46450486346411,29.757919351074577]},"geometry":{"type":"MultiLineString","coordinates":[[[33.08276564295245,28.365936333863583],[32.85009096581918,28.95155473219332],[32.65318110412008,29.113614162980063],[32.597126143829826,29.344566989489813],[32.42805126360424,29.63833609362628],[32.52993119143143,29.972545436050364],[32.40005128284368,30.174689758498985],[32.28755136253957,30.3690092136078],[32.28755136253957,30.5144959597591],[32.2313014023876,30.837020582397155],[32.28445136473581,31.25927814644905]]]}},{"type":"Feature","properties":{"id":"r100-north","name":"R100 North","color":"#dc673d","feature_id":"r100-north-0","coordinates":[-1.9581204936924819,59.35416748980049]},"geometry":{"type":"MultiLineString","coordinates":[[[-1.518674688812714,59.50884868221247],[-1.799924488976633,59.3944892668567],[-2.137424251676663,59.308465664600874],[-2.419078999999785,59.28984460000003]],[[-6.177803799999942,56.29173430000007],[-6.159296401352733,56.24117122829028],[-6.187421381428714,56.178604166899405],[-6.215546361504696,56.11593496129503],[-6.180188800000331,56.10365919999993]],[[-6.394503299999858,56.32939380000002],[-6.368239099999712,56.32334989999989]],[[-5.442403500000316,56.55241430000003],[-5.412411499999956,56.55230859999992]],[[-6.160692900000444,56.91203019999982],[-6.187421381428714,56.938040549953584],[-6.131171421276659,56.95338085470123],[-6.018671500972732,56.92269393303196],[-5.850779599999858,56.94698189999991]],[[-3.085608400000125,58.83105969999991],[-2.991369399999906,58.82640279999986]],[[-3.172025800000238,58.82274489999989],[-3.145124800000123,58.829682499999926]],[[-2.836203799999726,59.025199800000166],[-2.817720399999507,58.97922209999994]],[[-3.110331300000158,59.11455549999981],[-3.078059000000208,59.143849]],[[-2.857578,59.255412999999855],[-2.783827099999826,59.22520800000012]],[[-2.766228299999957,59.19230079999994],[-2.701495800000123,59.206322700000094]],[[-2.701495800000123,59.206322700000094],[-2.653659599999727,59.14894449999988]],[[-1.024927000000176,60.341001],[-1.096799987672712,60.34501374899555],[-1.141477199999912,60.35144499999984]],[[-1.287109,59.869131],[-1.237424888052713,59.79305890746809],[-1.293674848204677,59.679663707208995],[-1.518674688812714,59.50884868221247],[-1.613773299999882,59.52620960000002]],[[-1.055590000000232,60.49937],[-1.124924967748694,60.47001365336859],[-1.182159999999831,60.459095000000126]],[[-0.966812000000357,60.687366],[-1.006829199999834,60.670131500000075]]]}},{"type":"Feature","properties":{"id":"piano-isole-minori","name":"Piano Isole Minori","color":"#3d71b7","feature_id":"piano-isole-minori-0","coordinates":[11.939137663311069,36.84014569717215]},"geometry":{"type":"MultiLineString","coordinates":[[[13.565445462699188,41.217935235484155],[12.958679203595063,40.895780106420354],[13.428232823149918,40.795367333718616],[13.454176273067278,40.78994446306511]],[[9.842201117068242,43.0482648142909],[10.316441790280782,42.812137578116904]],[[15.563363144424395,41.91869836444879],[15.488973558507714,42.115432900583684],[15.508871650161664,42.123036161233884]],[[8.336105703397353,41.08172628147518],[8.228993742790532,40.948085546349]],[[8.379527706426767,39.206570282821524],[8.302684658371321,39.145528666527596]],[[15.23351223762506,38.80580638197693],[15.18756347633146,38.681091555174525],[15.075900768577364,38.6367791699852],[15.075063556027533,38.54923800463281],[14.96361363497976,38.488436612601895]],[[14.954325432103394,38.46838692145895],[14.965563633598306,38.42411761228048],[14.91568866893015,38.38039815807658],[14.91568866893015,38.24955995501156],[14.968438937975494,38.14702208251733]],[[14.358271440312812,38.535852973600846],[14.521948947859018,38.51099232259914],[14.576656824541493,38.564906522723184],[14.69069882831491,38.57855582737546],[14.836638463843066,38.578318962419935],[14.91568866893015,38.55656955905981],[14.96361363497976,38.488436612601895]],[[13.197330511231112,38.70802384499234],[13.33131479131538,38.38775473578444],[13.247603185879443,38.18994270418196]],[[12.513618000000546,38.01825390905765],[12.341014787775245,37.988207019957805],[12.073620000000234,37.96852790831799]],[[12.607558387730421,35.50153147422573],[12.712565230239315,35.602930322906126],[12.860768932962662,35.858363616715195]],[[12.434917,37.80009191125516],[11.948015771257374,37.546826221282146],[11.756315907059484,37.23235432155614],[11.943459999999734,36.8308729620145],[12.543815349187524,36.24065523321488],[12.712565230239315,36.013486867197166],[12.860768932962662,35.858363616715195]],[[14.954325432103394,38.46838692145895],[14.965563633598306,38.42411761228048]]]}},{"type":"Feature","properties":{"id":"east-micronesia-cable-system-emcs","name":"East Micronesia Cable System (EMCS)","color":"#939597","feature_id":"east-micronesia-cable-system-emcs-0","coordinates":[165.1337907924087,2.3794925152748263]},"geometry":{"type":"MultiLineString","coordinates":[[[159.070334,7.786484021061388],[160.19996074818866,7.447522319872199],[163.12495867609277,5.659359572411489],[165.59995692278082,1.618372199773176],[166.94995596642866,0.943545268913197],[171.89995245980478,1.393450214463454],[172.97909999999962,1.329098003182317]],[[163.12495867609277,5.659359572411489],[163.01245875578866,5.435413643888211],[163.00810320261044,5.327899999999427]],[[166.94995596642866,0.943545268913197],[166.94995596642866,0.71856924087599],[166.92110320261116,0.546700000000151]]]}},{"type":"Feature","properties":{"id":"energy-bridge-cable","name":"Energy Bridge Cable","color":"#d96048","feature_id":"energy-bridge-cable-0","coordinates":[36.680986786561256,45.42731784547227]},"geometry":{"type":"MultiLineString","coordinates":[[[36.59112538599644,45.43102598053393],[36.77084818712606,45.423609710410595]]]}},{"type":"Feature","properties":{"id":"aurora","name":"Aurora","color":"#43499e","feature_id":"aurora-0","coordinates":[15.61283240960375,55.803751859339975]},"geometry":{"type":"MultiLineString","coordinates":[[[14.708333815822416,55.101017],[14.400064034799321,54.884127437867534],[13.648694566480692,54.51955]],[[14.240754147060123,55.433900600000094],[14.512563954507531,55.33458061322904],[14.708328999999699,55.18257019610301]],[[16.468076609305953,56.24572402102604],[16.08756283876343,56.03221697111693],[15.30006339723147,55.65323105219792],[14.836283725181318,55.24821367563846]],[[18.3000312714301,57.63842],[18.00006148393147,57.59199374701106],[17.32506196210754,57.47120623080026],[17.00849265102517,57.32475822033841]],[[18.250313736376413,58.936123],[18.22506132453951,58.758568944882946],[18.337561244843435,58.641677771385005],[18.787560926059513,58.05131589106027],[19.05532073637588,57.863008]],[[12.414885440522585,55.64730549999991],[12.487565389631278,55.526080187888724],[12.600065309935387,55.494228077820765],[12.916666440522198,55.533333]]]}},{"type":"Feature","properties":{"id":"tikal-amx3","name":"TIKAL-AMX3","color":"#939597","feature_id":"tikal-amx3-0","coordinates":[-85.04597081638113,22.475410567239763]},"geometry":{"type":"MultiLineString","coordinates":[[[-80.08893152830949,26.350585697437857],[-79.64986933994133,25.957179978764344],[-79.76236925964936,25.348717422116714],[-80.09986902056123,24.73717827217609],[-80.99986838299347,23.91710129093513],[-83.2498667890733,23.91710129093513],[-84.82486567332928,22.884654113882444],[-85.4998651951533,21.635297384859552],[-85.72486503635743,20.936468000149056],[-85.83736495666136,20.375041253465433],[-86.84986423880135,18.251816319028222],[-87.74986360123332,16.534196198259725],[-88.1998632824493,16.10232559580297],[-88.59713531004529,15.727236638721036]],[[-86.76758665233304,21.09572879236739],[-86.51236447788929,21.006499845176737],[-86.17486471697732,21.006499845176737],[-85.72486503635743,20.936468000149056]]]}},{"type":"Feature","properties":{"id":"connected-coast","name":"Connected Coast","color":"#939597","feature_id":"connected-coast-0","coordinates":[-126.8341387596477,50.54897851420423]},"geometry":{"type":"MultiLineString","coordinates":[[[-125.1861984662876,49.91955265840255],[-125.24816842082198,50.02461]],[[-126.08555943379818,50.49276847686975],[-125.95901354477375,50.38434418820626]],[[-130.3300698311155,54.313075],[-130.38751465023,54.287784565961346]],[[-128.72301587142533,52.26232295213411],[-128.58733467218178,52.26898811247549],[-128.40536610447592,52.271593097452715]],[[-127.49580677179371,50.720878251368546],[-127.43420048907284,50.76400091416258],[-127.406076837626,50.76400091416258],[-127.237326961433,50.692788787519305],[-127.1897520459016,50.612145899404105],[-127.15484280035233,50.63120042038277],[-127.06857708524,50.6125425495688],[-127.00083579607245,50.582552529196676],[-126.9263371154381,50.588350919856595],[-126.83111117990482,50.547685615900775],[-126.7310773328539,50.550039661516315],[-126.61857741539197,50.51428147959937],[-126.44982753919888,50.496396995063854],[-126.2248277042749,50.478499373021876],[-126.08555943379818,50.49276847686975]],[[-127.2105123320581,51.680119439312605],[-127.4623204691525,51.64504609032705],[-127.54671040936974,51.57518259747888],[-127.57483538944572,51.505211554524436],[-127.64481414210549,51.40557093447968]],[[-125.95901354477375,50.38434418820626],[-125.83107162474447,50.38892868152936],[-125.74671168450571,50.3709967557743],[-125.66233674427777,50.3709967557743],[-125.5467482017625,50.37733322590891],[-125.49358824076445,50.35305805049016],[-125.44561591823906,50.33139503733066],[-125.41540137561992,50.300060895732926],[-125.44214123814987,50.27742985648948],[-125.30261850538005,50.23583903062007]],[[-125.50343684333376,50.412765239933556],[-125.5467482017625,50.37733322590891]],[[-126.59874742994063,50.70054017294747],[-126.6411341485579,50.67033552833753],[-126.61076460042727,50.573835184307484],[-126.75261187443787,50.61394812106694],[-126.84948623675689,50.63365191110994],[-126.8917167994461,50.61138344935585],[-127.06857708524,50.6214683879521],[-127.15484702194624,50.63120042038277]],[[-126.75261187443787,50.61394812106694],[-126.75364097118762,50.67033552833753],[-126.72551479577369,50.72376904779043],[-126.6411341485579,50.75936358394642],[-126.49734715005867,50.74991847293468],[-126.52864183757086,50.79493410837472],[-126.55858745940486,50.848866325733916]],[[-126.20021409999995,50.94987262999997],[-126.33733626610179,50.92974809176575],[-126.50608749792255,50.92974809176575],[-126.55858745940486,50.848866325733916]],[[-123.51457759999998,49.40161704999969],[-123.74983809910962,49.429010440658146],[-124.08733786002196,49.538640877995576],[-124.08733786002196,49.68443309948498],[-123.97483793971757,49.79349119144722],[-124.17988590000003,49.783268039999825],[-124.36858766078123,49.75716572784409],[-124.55654750000006,49.75772627999974]],[[-124.48885459999974,49.67789231999991],[-124.928266,49.67351299999971]],[[-124.55654750000006,49.75772627999974],[-124.523809,49.8358899999998]],[[-125.0333616,48.88316308000008],[-125.02289129999988,48.96451208999974]],[[-125.04228309999998,49.02105635999993],[-125.02289129999988,48.96451208999974]],[[-125.14150869999993,48.83282788000024],[-125.0333616,48.88316308000008]],[[-125.54668160000001,48.941831000000334],[-125.43397849999991,48.95979785999976],[-125.380662,48.994208740000296],[-125.3642743999999,49.007252349999945]],[[-126.05634120000003,49.27631697999994],[-125.98733151404844,49.21309249677026],[-125.907312,49.173502830000245],[-125.90621339999997,49.15253760000007]],[[-125.98733151404844,49.21309249677026],[-126.09982143435957,49.21309249677026],[-126.24047133472175,49.249817465166835],[-126.2967212948737,49.34153001255497],[-126.27236909999998,49.36844717999986]],[[-126.65469529999984,49.92630485999983],[-126.63420105579999,49.833814436152586],[-126.60608107572041,49.68847019007453],[-126.61890059999992,49.59185601999993],[-126.60608107572041,49.396473691979516],[-126.49359115540928,49.35985139525818],[-126.27236909999998,49.36844717999986]],[[-126.74178759999992,49.87491869999993],[-126.63420105579999,49.833814436152586]],[[-126.84543780000006,49.98239051],[-126.80295093625597,49.90632321900464]],[[-126.91546085655288,49.870085656987875],[-126.94066089999995,49.989339350000236]],[[-127.3785471000001,50.03085274999997],[-127.28108692932756,49.92443018908899],[-127.11233571708493,49.815676682583636]],[[-127.65441779999993,50.535297809999925],[-127.488056,50.42666700000024]],[[-127.65441779999993,50.535297809999925],[-127.58161839999998,50.59938479000021]],[[-128.02407260000004,50.65227310999998],[-127.77483524776397,50.59966078659782],[-127.58161839999998,50.59938479000021]],[[-128.03325680000003,50.44270213000031],[-128.02796006844798,50.47452827438072],[-128.0281361,50.52390510999994]],[[-127.58161839999998,50.59938479000021],[-127.49580677179371,50.720878251368546]],[[-127.65441779999993,50.535297809999925],[-127.77483524776397,50.49242740022417],[-127.88733516806799,50.47452827438072],[-128.02796006844798,50.47452827438072]],[[-126.74178759999992,49.87491869999993],[-126.80295093625597,49.90632321900464],[-126.91546085655288,49.870085656987875],[-126.97171081670484,49.870085656987875],[-126.98495349999985,49.884975600000395]],[[-127.11233571708493,49.815676682583636],[-126.99983579678073,49.85195506115428],[-126.98495349999985,49.884975600000395]],[[-126.61890059999992,49.59185601999993],[-126.49358750709344,49.61563463415015],[-126.38108758963149,49.652066036289725],[-126.15608775470743,49.68847019007453],[-126.05444189999996,49.78104817000019]],[[-125.02289129999988,48.96451208999974],[-125.22483705420657,48.96341767073521],[-125.3642743999999,49.007252349999945]]]}},{"type":"Feature","properties":{"id":"connected-coast","name":"Connected Coast","color":"#ca1d5c","feature_id":"connected-coast-1","coordinates":[-128.63232255382107,53.132572192762204]},"geometry":{"type":"MultiLineString","coordinates":[[[-123.114034,49.260440000000195],[-123.58108821865311,49.2824545474234],[-123.8440093857028,49.17330175420969]],[[-125.1861984662876,49.91955265840255],[-125.10128500014197,50.01296739997825],[-124.98642332019199,50.06310304527359]],[[-123.8440093857028,49.17330175420969],[-123.93561821991443,49.254577397512264],[-124.12351458051008,49.25190624440014],[-124.20451007380892,49.35223052890989],[-124.44320399853738,49.34943591538804],[-124.68672835476215,49.44353722792704],[-124.71356875282478,49.49345629218801],[-124.69712882510406,49.5134805257975],[-124.928266,49.67351299999971],[-124.82908872828878,49.70666851761774],[-124.82908872828878,49.724853565783754],[-124.94158864575081,49.79752567334692],[-125.04910592224829,49.8242225821597],[-125.1861984662876,49.91955265840255]],[[-125.10181852819476,50.2314795270948],[-125.16070877237037,50.172390288106364],[-125.1959579476632,50.137057106101715],[-125.21592527387554,50.10125851949136],[-125.04927488516667,50.11121239715361],[-125.09983714275776,50.04700743933893],[-124.98642332019199,50.06310304527359],[-124.93253726127452,50.01296739997825],[-124.81641098708053,50.0357787156605],[-124.90103138578722,50.08449796406866],[-124.91752768258516,50.117380658006994]],[[-124.83905872097411,50.1237481500032],[-124.90155867511967,50.08366067969953]],[[-126.7527673169406,52.37214793561003],[-126.90461720553263,52.3382317218636],[-127.1296170404567,52.3382317218636],[-127.24086695883584,52.441217560480155],[-127.35336687629787,52.441217560480155],[-127.46586679375982,52.372587034211804],[-127.57836909607062,52.30385008818358],[-127.79057491184655,52.26794328251682]],[[-127.69350873628818,52.35335745745368],[-127.72202740406783,52.36014436999047],[-127.79057832269325,52.26794328251682],[-127.95147226786968,52.06367943450714],[-128.06345114247472,52.06571648865028],[-128.1109469081848,52.096441035551365],[-128.12231269840387,52.15394371648719],[-128.14470446316773,52.16114984088366],[-128.11186854799377,52.18517417339168],[-128.40536610447592,52.271593097452715],[-128.48841325672365,52.317033868530515],[-128.47483475187786,52.4407664729555],[-128.52487321432534,52.591778817615186],[-128.5238351398498,52.643028264222046],[-128.53108601223883,52.78232285776401],[-128.53108601223883,52.985979380441314],[-128.58733467218178,53.121219363028196],[-128.70071815397395,53.149832031963186],[-128.92483443309376,53.32328423459359],[-129.1498342737018,53.32328423459359],[-129.25478833578578,53.4252931058726],[-129.26233419400572,53.35686906888986],[-129.37483411430983,53.39042745806576],[-129.431085351935,53.45746493836576],[-129.6056764015309,53.54320510255879],[-129.82483379552582,53.72455893285006],[-130.04983363613377,53.857473440304716],[-130.25851536025976,53.94951651353444]],[[-130.32127803890825,54.223392151062704],[-130.49983331734975,54.250043061793065],[-130.4444201348533,54.33705541814711],[-130.49983331734975,54.44675601642588],[-130.43405684195642,54.55439491921293]],[[-130.25851447378096,53.94951651353444],[-130.27804473054493,54.14231329672858],[-130.32127803890825,54.223392151062704]],[[-131.9298034706778,53.56252710216991],[-131.73733244069396,53.524396750400264],[-130.83733307826125,53.524396750400264],[-130.6144988848554,53.49227407843297],[-130.61233323765376,53.65794367159378],[-130.49983331734975,53.72455893285006],[-130.43327104649978,53.794671349185506],[-130.33108469163105,53.857473440304716],[-130.25851447378096,53.94951651353444]],[[-124.81641098708053,50.0357787156605],[-124.82003734097059,50.01296739997825],[-124.76127877803908,49.98196414284952]],[[-127.95147226786968,52.06367943450714],[-127.91233015036129,51.958102539867376],[-127.94046013043375,51.853991886922685],[-127.94046013043375,51.71480209466681],[-127.86370028877155,51.60320130299947],[-127.85608650746674,51.540210523593544],[-127.74358659000471,51.40005320024472]],[[-127.64481414210549,51.40557093447968],[-127.74358659000471,51.40005320024472],[-127.85608650746674,51.32981301612922],[-127.83443834465406,51.24964662436544],[-127.85608650746674,51.18900930466248],[-127.79983523005376,51.04777407760083],[-127.72464476418678,50.97750112024519],[-127.61277278412608,50.860359994026965],[-127.51857675508803,50.83510482241375],[-127.46232046915242,50.76400091416258],[-127.49580677179371,50.720878251368546]],[[-123.114034,49.260440000000195],[-123.35608837804642,49.31913446298367],[-123.43478849999994,49.39615687999982]],[[-123.43478849999994,49.39615687999982],[-123.43887940000005,49.43963952000016],[-123.51457759999998,49.40161704999969]],[[-128.640889,54.059548420000134],[-128.64759510000002,53.97442960000009],[-128.7788345365215,53.850100347217946],[-128.8913344568256,53.74925378592342],[-129.11633429743335,53.68267763542396],[-129.22883421773747,53.54920902084056],[-129.25478833578578,53.4252931058726]],[[-124.523809,49.8358899999998],[-124.76127877803908,49.98196414284952]]]}},{"type":"Feature","properties":{"id":"haikou-beihai-cable","name":"Haikou-Beihai Cable","color":"#2cb2a0","feature_id":"haikou-beihai-cable-0","coordinates":[109.23507396949964,20.636748911764943]},"geometry":{"type":"MultiLineString","coordinates":[[[109.10530694412087,21.484500999999632],[109.2374968504763,20.620920527000067],[109.67761999999983,19.9048]]]}},{"type":"Feature","properties":{"id":"ningbo-zhoushan-cable","name":"Ningbo-Zhoushan Cable","color":"#1d9cbb","feature_id":"ningbo-zhoushan-cable-0","coordinates":[121.7635625900212,30.160824343859357]},"geometry":{"type":"MultiLineString","coordinates":[[[121.63985462881675,30.043111146660067],[121.72498800422028,30.158479119660896],[121.94569495659735,30.17189746945603]]]}},{"type":"Feature","properties":{"id":"dalian-yantai-cable","name":"Dalian-Yantai Cable","color":"#31549b","feature_id":"dalian-yantai-cable-0","coordinates":[121.60653576225305,38.220731410663035]},"geometry":{"type":"MultiLineString","coordinates":[[[121.58836700725392,38.9398441207995],[121.61248808391635,38.240638159471985],[121.4,37.53]]]}},{"type":"Feature","properties":{"id":"petropavlovsk-kamchatsky---anadyr","name":"Petropavlovsk-Kamchatsky - Anadyr","color":"#af2364","feature_id":"petropavlovsk-kamchatsky---anadyr-0","coordinates":[170.26763442158432,54.849677985687286]},"geometry":{"type":"MultiLineString","coordinates":[[[179.99994686005783,61.488538765323895],[179.5719170249131,60.8695351663294],[173.1249515920039,55.90629872677893],[168.29995501007673,54.1220405712827],[159.74996106697276,52.646019980375456],[158.65001067431297,53.01666789688405]],[[-179.99979746953576,63.596795905217576],[-179.07782904260674,62.78108049372607],[-179.9997982511102,61.488538765323895]],[[177.64027839330407,64.73783998148912],[177.57455843986074,64.72253629248193],[177.5021457567835,64.72719091772642],[178.64994767804478,64.28486693839773],[179.7749468810848,63.79239592397761],[179.99994764163228,63.596795905217576]]]}},{"type":"Feature","properties":{"id":"cadmos-2","name":"CADMOS-2","color":"#939597","feature_id":"cadmos-2-0","coordinates":[34.53155372323315,34.33003036030243]},"geometry":{"type":"MultiLineString","coordinates":[[[33.61060042587536,34.82728147271538],[33.97505016769547,34.59045588265237],[35.10004937013957,34.063992930126034],[35.48510909795581,33.89263712836985]]]}},{"type":"Feature","properties":{"id":"norfest","name":"Norfest","color":"#4db748","feature_id":"norfest-0","coordinates":[7.781000063263284,57.870716716344766]},"geometry":{"type":"MultiLineString","coordinates":[[[10.748015840098633,59.91860302672068],[10.575066743867334,59.755303236949786],[10.630839956892295,59.664530734634965],[10.603191723943405,59.52787100202252],[10.700836654770828,59.459167]],[[10.700836654770828,59.459167],[10.571156746637262,59.30270062107635],[10.575066743867334,59.06836550247494],[10.237566982955368,58.89444683796138],[9.900067222043404,58.71964879559121],[9.450067540827328,58.48521196181824],[9.112567779915365,58.36740335134618],[8.662568098699472,58.07115384928297],[8.325068337787325,57.932056586951404],[7.875068657167333,57.87227791046836],[6.883089359298951,57.85581468734289],[6.52506961292339,57.928738157570194],[5.962570011403388,58.26235337816782],[5.988709992885582,58.42414318521949],[5.512570330187313,58.48521196181824],[5.287570489579458,58.641677771385005],[5.287570489579458,58.81686755183246],[5.512570330783214,58.99117670269853],[5.730770176208511,58.9708214866686]],[[6.52506961292339,57.928738157570194],[6.786929427419412,58.06444]],[[8.325068337787325,57.932056586951404],[7.99484857171814,58.143805]],[[9.112567779915365,58.36740335134618],[8.758668030621324,58.470047]],[[10.237566982955368,58.89444683796138],[10.016367140251454,59.081110697513395]],[[10.575066743867334,59.06836550247494],[10.91256650477948,58.98151591602632],[11.118406358960579,58.96833839999996]]]}},{"type":"Feature","properties":{"id":"arimao","name":"ARIMAO","color":"#c45527","feature_id":"arimao-0","coordinates":[-71.54107005893853,16.92861162038969]},"geometry":{"type":"MultiLineString","coordinates":[[[-80.44896128872459,22.145949778587273],[-80.09986902115732,21.356164482330126],[-79.4248694987373,20.375041253465433],[-77.73737069477338,19.13983629420715],[-76.20409564349075,18.676174475359762],[-74.69987284596934,17.82393441253792],[-71.99987475867334,17.07267593989621],[-66.14987890286528,15.23578178303578],[-62.09988177251724,14.51091612787219],[-61.112138356061955,14.629000145423495]]]}},{"type":"Feature","properties":{"id":"eastern-light-sweden-finland-ii","name":"Eastern Light Sweden-Finland II","color":"#939597","feature_id":"eastern-light-sweden-finland-ii-0","coordinates":[21.31191197553788,59.90808366284734]},"geometry":{"type":"MultiLineString","coordinates":[[[24.65578212250267,60.206780251145716],[24.412558910536177,60.13094306980754],[23.850058953274402,60.074868164985745],[23.175059004559692,60.01869775526425],[22.966757966073065,59.82340864139905],[22.725058137295427,59.79305890746809],[21.37505899829746,59.9060700138268],[20.907999423920963,59.9209637334888],[20.250059226800076,59.62282165244601],[18.900059329371565,59.50884849264949],[18.33755937210979,59.42311514129758],[17.943566877498796,59.40288874272467]]]}},{"type":"Feature","properties":{"id":"timor-leste-south-submarine-cable-tlssc","name":"Timor-Leste South Submarine Cable (TLSSC)","color":"#f5ae1a","feature_id":"timor-leste-south-submarine-cable-tlssc-0","coordinates":[127.49410608780575,-9.106975524434176]},"geometry":{"type":"MultiLineString","coordinates":[[[125.57937980498313,-8.559447499464328],[125.77498513397242,-8.34548869989964],[126.44998465758441,-8.28983042135627],[126.8999843382044,-8.141369965795338],[127.34998401942048,-8.252720521974979],[127.57498386002852,-9.586362493293953],[127.23748409911637,-10.47259892498978],[126.44998465698852,-11.55232519729577]]]}},{"type":"Feature","properties":{"id":"medloop","name":"Medloop","color":"#939597","feature_id":"medloop-0","coordinates":[7.781855630424146,42.18150710942117]},"geometry":{"type":"MultiLineString","coordinates":[[[7.200069135343221,42.908732427720366],[7.425068975355357,42.329239699665536],[8.414618274945335,41.91950273465837],[8.738738045335882,41.91950273465837]]]}},{"type":"Feature","properties":{"id":"medloop","name":"Medloop","color":"#9d4529","feature_id":"medloop-1","coordinates":[7.308275093342992,42.98029988712455]},"geometry":{"type":"MultiLineString","coordinates":[[[8.938867903561944,44.41035752885385],[8.887567939307326,44.05151922873524],[8.437568258091433,43.72721479104982],[7.200069135343221,42.908732427720366],[6.412569692619463,42.661038814490205],[5.962570011403388,42.661038814490205],[5.372530429989069,43.29362778902908]],[[5.962570011403388,42.661038814490205],[4.050071366235344,41.68837522565799],[2.700072322587302,41.26694507168783],[2.168725042748786,41.38560270176812]]]}},{"type":"Feature","properties":{"id":"asia-link-cable-alc","name":"Asia Link Cable (ALC)","color":"#939597","feature_id":"asia-link-cable-alc-0","coordinates":[112.41647789726676,10.043700582907936]},"geometry":{"type":"MultiLineString","coordinates":[[[114.97499278657615,18.265170800514795],[116.99999135204831,18.038005439608753],[119.92498927995224,16.857467609772335],[120.35483897484683,16.830716773770945]],[[114.7499929453724,17.12195969020027],[116.99999135204831,16.857467609772335],[119.92498927995224,16.749771315644697],[120.35557897432248,16.51402516186766]],[[114.7499929459683,19.104405475930452],[113.84999358353615,18.678647022154717],[111.09966159348336,18.505614628693614],[110.0493762753333,18.389897000000236]],[[111.14999549624025,7.744889052551447],[112.94999422050827,5.883218793719735],[114.29999326475222,5.174038327226071],[114.88563284987971,4.926762452886689]],[[114.07499342354828,14.801154224791581],[109.79999645259221,15.994209911785974],[108.89999709016024,16.10232559580297],[108.19247759137373,16.043393005208348]],[[104.11414047991015,1.925884465105483],[104.32889093295785,2.027683761259223],[104.66056009282137,1.961481175550864]],[[114.20292333351767,22.22205041973683],[114.41249318505615,20.796306105108872],[114.7499929459683,19.104405475930452],[114.97499278657615,18.265170800514795],[114.7499929453724,17.12195969020027],[114.07499342354828,14.801154224791581],[113.39999390232026,12.615395567393394],[112.38749461958417,9.967915186974132],[111.14999549624025,7.744889052551447],[107.99999772772827,5.286069860821008],[107.09999836529612,4.725718053703611],[105.74999932164808,3.940475772228814],[104.90624991936826,2.803404866588448],[104.66056009282137,1.961481175550864],[104.47058772799548,1.468426767331968],[104.28790035741287,1.299801162778933],[104.19209042528557,1.26242303552454],[103.98701057056589,1.389451396800233]]]}},{"type":"Feature","properties":{"id":"manta","name":"MANTA","color":"#939597","feature_id":"manta-0","coordinates":[-80.95313552313398,16.0271021935348]},"geometry":{"type":"MultiLineString","coordinates":[[[-80.16222149909747,25.933097],[-80.21236894086552,25.348717422116714],[-80.99986838299365,24.73717827217609],[-83.2498667890733,24.73717827217609],[-84.82486567332928,24.94136317175375],[-86.01795306402694,25.56262900538288]],[[-84.82486567332928,24.94136317175375],[-84.82486567332928,23.91710129093513],[-86.17486471816949,21.635297384859552],[-86.51236447788929,21.32123529551186],[-86.76758665233304,21.09572879236739]],[[-86.01795306402694,25.56262900538288],[-88.19986328304547,23.848523186487938],[-91.79986073277334,23.022776999376084],[-94.49985882006952,21.356164482330126],[-96.14075765764133,19.195461502894283]],[[-86.01795306402694,25.56262900538288],[-84.59986583331732,27.896238989528694],[-84.11536453807635,29.953701800771658]],[[-75.50568227572235,10.387005000000206],[-76.0498718896173,10.963556857789316],[-78.74986997750936,12.542195817724492],[-79.64986934053732,13.92930384327183],[-80.99986838299328,16.10232559580297],[-82.34986742664132,17.395022634700517],[-85.04986551393732,19.104405475930452],[-86.0623647972694,20.375041253465433],[-86.17486471816949,21.635297384859552]],[[-78.74986997750936,12.542195817724492],[-79.4248694987373,9.967915186974132],[-79.7535392665006,9.437546999999826]]]}},{"type":"Feature","properties":{"id":"taiwan-penghu-kinmen-matsu-no-3-tpkm3","name":"Taiwan Penghu Kinmen Matsu No.3 (TPKM3)","color":"#4dbd9a","feature_id":"taiwan-penghu-kinmen-matsu-no-3-tpkm3-0","coordinates":[119.3265271405332,23.740064833138533]},"geometry":{"type":"MultiLineString","coordinates":[[[118.31584640000032,24.413418899999478],[118.46249031540438,24.310716907075776],[119.24998975753242,23.79706577881128],[119.51201957190797,23.60192],[119.57625399718488,23.573729363608198],[119.65615957190879,23.573629999999714],[119.69998943874958,23.43629494132288],[120.03748919965736,23.281375912673997],[120.18086199999966,22.99721]],[[119.93434927272565,26.15516],[120.26248903788452,25.75470426341523],[120.93748856149652,25.247006432636635],[121.31110780616903,24.98888889999996]],[[119.93434927272565,26.15516],[119.86873931920438,26.041442343792944],[119.93907506033597,25.973144107820715]],[[119.93434927272565,26.15516],[119.98859609999977,26.225407299999837],[120.14998911996437,26.243424884980424],[120.37498896057242,26.29386583975231],[120.4929700000004,26.367336499999546]]]}},{"type":"Feature","properties":{"id":"unitirreno","name":"Unitirreno","color":"#4cb96a","feature_id":"unitirreno-0","coordinates":[10.920768425783738,41.0550610962751]},"geometry":{"type":"MultiLineString","coordinates":[[[8.938867903561944,44.41035752885385],[9.337567620523402,44.05151922873524],[9.675067381435365,43.401144973153954],[9.787567301143575,43.073310783003215],[10.01256714115553,42.41235450073586],[10.575066743867334,41.52013202089327],[10.91256650477948,41.0693404382162],[11.700065946907337,39.6983233549332],[12.037565708415386,38.21117903702318],[12.150065626335346,37.85673997565852],[12.591375316091606,37.65058617278613]],[[10.91256650477948,41.0693404382162],[11.250066265691444,41.26694507168783],[11.58756602660341,41.435845950249096],[12.037565707819484,41.60430845897962],[12.226913000000101,41.769718]],[[10.809437174151219,41.20741868014851],[10.172944499999614,41.1440440429973],[9.496447507971368,40.92357574660862]]]}},{"type":"Feature","properties":{"id":"kochi-lakshadweep-islands-kli-sofc","name":"Kochi-Lakshadweep Islands (KLI-SOFC)","color":"#d32c26","feature_id":"kochi-lakshadweep-islands-kli-sofc-0","coordinates":[73.83756587224451,10.68287784672179]},"geometry":{"type":"MultiLineString","coordinates":[[[76.2695502058749,9.93838642489319],[75.60002068017582,10.07869800665097],[73.78659196423004,10.700352001209836],[73.67409366970386,10.810878179169102],[73.56159212362182,10.700352001209836],[72.72286795461697,11.124258631805647],[72.83034264164593,11.142200303720568],[72.77758267902168,11.22718249121781],[72.94284256195003,11.197384742384841],[73.12502243289187,11.33148066218366],[73.12502243289187,11.441766261214076],[73.00644623627844,11.485221714276152],[72.90002259228402,11.662208223864337],[72.7106427264425,11.689816748289662]],[[72.7106427264425,11.689816748289662],[72.39377295091597,11.496892979680824],[72.18593318270166,11.59843321448516],[72.28127303061187,11.33148066218366],[72.28722656010738,10.939880964178196],[72.39377295091597,10.889916637992194],[72.33752299076401,10.807049625860893],[72.1954430914147,10.862867747435985],[72.2250230704599,10.779422194820338],[72.45002291106793,10.55831195245637],[72.64071337472495,10.564728356964638],[72.67502275167598,10.337042550001563],[73.35002227350009,10.004846997649413],[73.64410775915098,10.069817641044942]],[[73.64411206516397,10.069817641044942],[73.80002195471599,9.894039027618803],[73.57502211410794,8.783969455379136],[73.23752235319706,8.338985426020157],[73.05120248518666,8.27464880388296]],[[73.05120248518666,8.27464880388296],[73.23752235319706,8.283326224170393],[75.65367042863204,9.650748624678508],[76.2695502058749,9.93838642489319]],[[72.72286795461697,11.124258631805647],[72.61877279152401,10.889916637992194],[72.33752299076401,10.779422194820338],[72.1954430914147,10.862867747435985]]]}},{"type":"Feature","properties":{"id":"sagres","name":"Sagres","color":"#4ab970","feature_id":"sagres-0","coordinates":[-9.158888038057059,37.493654833898496]},"geometry":{"type":"MultiLineString","coordinates":[[[-9.102749315587026,38.4430794831419],[-8.999919388432733,38.122730108392204],[-9.22491922904077,37.23235432155614],[-8.999919389028726,36.90321229501768],[-8.887419468724707,36.90321229501768],[-8.774539548689884,37.07338751531512]]]}},{"type":"Feature","properties":{"id":"continente-madeira","name":"Continente-Madeira","color":"#37b98e","feature_id":"continente-madeira-0","coordinates":[-13.43524752394084,36.04351783909424]},"geometry":{"type":"MultiLineString","coordinates":[[[-9.33155915349599,38.690161972355526],[-10.799918113296759,38.29952060596925],[-13.949915881808826,35.602930322906126],[-16.199914288484827,34.063992930126034],[-16.818663850156792,32.8123187832876],[-16.90889378564106,32.64727814970992]]]}},{"type":"Feature","properties":{"id":"almera-melilla-alme","name":"Almería-Melilla (ALME)","color":"#2dbdbd","feature_id":"almera-melilla-alme-0","coordinates":[-2.5439694167200337,36.00572179222326]},"geometry":{"type":"MultiLineString","coordinates":[[[-2.463794019281208,36.84186246473143],[-2.474924011396645,36.45208516840206],[-2.587423931700664,35.724797899806],[-2.938093683282961,35.292274112549194]]]}},{"type":"Feature","properties":{"id":"roquetas-melilla-cam","name":"Roquetas-Melilla (CAM)","color":"#2fa685","feature_id":"roquetas-melilla-cam-0","coordinates":[-2.6575606697573444,35.99866656586768]},"geometry":{"type":"MultiLineString","coordinates":[[[-2.612503913933816,36.76384217382935],[-2.587423931700664,36.45208516840206],[-2.699923852004682,35.724797899806],[-2.938093683282961,35.292274112549194]]]}},{"type":"Feature","properties":{"id":"n0r5ke-viking-2","name":"N0r5ke Viking 2","color":"#939597","feature_id":"n0r5ke-viking-2-0","coordinates":[6.417535987641441,58.09922915749401]},"geometry":{"type":"MultiLineString","coordinates":[[[5.332770458155255,60.39072370580963],[5.231320529427312,60.40062854872469],[5.175070569275348,60.3728330141931],[5.175070569275348,60.205560984023215],[5.287570489579458,60.14961328483288],[5.343820449731422,60.06551290255842],[5.343820449731422,60.009326600079234],[5.512570330187313,59.8966669924279],[5.512570330187313,59.78362399299559],[5.400070409883385,59.726958451496074],[5.287570489579458,59.64177978429336],[5.298330481956797,59.446388999999876],[5.287570489579458,59.38494185041051],[5.297950482226175,59.324351],[5.400070409883385,59.154995951571294],[5.686580206917137,59.10120559625946],[5.737570170795351,59.039440006437545],[5.730790175598385,58.970833]],[[5.730790175598385,58.970833],[5.681320210643387,58.95251729542412],[5.574100286598984,58.936182],[5.400070409883385,58.81686755183246],[5.400070409883385,58.641677771385005],[5.625070250491423,58.48521196181824],[5.988709992885582,58.42414318521949],[6.075069931707316,58.26235337816782],[6.52506961292339,58.04800849338324],[6.786929427419412,58.06444],[6.883089359298951,57.9156207228416],[7.762568737459487,57.932056586951404],[7.99484857171814,58.143805]],[[9.655517395284827,59.138556700000066],[10.016391030621515,59.081111],[10.237566982955368,59.01049014866176],[10.575066743867334,58.95251729542412],[10.91256650477948,58.923494273762294],[11.206673030621658,58.914588]],[[10.575066743867334,58.95251729542412],[10.687566664171444,59.06836550247494],[10.627410456786706,59.30270062107635],[10.432776844666934,59.420833],[10.575066743867334,59.52787100202252],[10.713429844666736,59.716322],[10.743816624323406,59.755303236949786],[10.749978844667112,59.91228]],[[9.655517395284827,59.138556700000066],[9.73131734158733,59.039440006437545],[9.787567301739475,59.01049014866176],[9.787567301739475,58.95251729542412],[9.675067381435365,58.83627867169679],[9.450067540827328,58.6026268350673],[9.112567779915365,58.48521196181824],[8.758668030621324,58.470047],[8.775068019003399,58.30835113805548],[8.662568098699472,58.18995038432827],[8.325068337787325,58.07115384928297],[7.99484857171814,58.143805]]]}},{"type":"Feature","properties":{"id":"philippine-domestic-submarine-cable-network-pdscn","name":"Philippine Domestic Submarine Cable Network (PDSCN)","color":"#5cc09d","feature_id":"philippine-domestic-submarine-cable-network-pdscn-0","coordinates":[121.84808636692303,7.691515447263923]},"geometry":{"type":"MultiLineString","coordinates":[[[121.07814990718249,13.635709425447143],[121.33123828375226,13.492128176464083],[121.49998816420832,13.273238157547594],[121.46453818932153,13.045782550710932],[121.61248493301056,12.834868817846521],[121.70270802000381,12.63448617650253],[121.88855788834606,12.19371648797362],[121.94481099999972,11.94924477236392]],[[121.61229277215068,13.935275186648868],[121.78123796437242,13.492128176464083],[121.88473418104378,13.42908313305217],[121.94998784542422,13.054150695298627],[122.06248776572832,12.72515592356304],[122.08171170471684,12.602955744095647]],[[122.08171170471684,12.602955744095647],[122.11873772528439,12.72515592356304],[122.7937372471085,13.273238157547594],[122.95067073028109,13.546888070207846]],[[122.08171170471684,12.602955744095647],[122.17498768484045,12.72515592356304],[122.34373756589243,12.651987602736225],[122.34373756589243,12.487282342244972],[122.39998752664029,12.065895273570327],[122.74998727869705,11.583202180445051]],[[121.94481206784093,11.949266020482876],[121.95077784426886,11.930929802473868]],[[122.56210741119708,10.720150965231593],[122.73748728695635,10.656603009743664],[122.968957,10.640738999999655]],[[123.41287743409951,10.483708779671767],[123.52498672908439,10.44769694727567],[123.63524055782547,10.376237129155873]],[[122.74998727869705,11.583202180445051],[122.96248712816029,11.625479959569855],[123.29998688907226,11.625479959569855],[123.86154340165245,11.940899155789175]],[[123.16748732333238,9.146219690585646],[123.18748696876833,8.969223547813163],[123.2437369283244,8.734552646542594],[123.33503132573671,8.564097021577123]],[[125.48676677437311,9.782713534000724],[125.09998561393637,9.85709470870232],[124.87498577273243,9.967915186974132],[124.84217394836772,10.178582995816972]],[[124.39720203273188,11.054861343165987],[124.30291617799185,10.93201162513164],[124.19998625090851,10.742581675476407],[124.0874863312003,10.521444685552128],[123.98834884332426,10.406420283136939]],[[124.60027596793546,12.069904662870776],[124.42498609092046,12.065895273570327],[124.08748633060439,12.175887185507976],[123.90123074088766,12.105980429992167]],[[123.90123074088766,12.105980429992167],[123.86248648880438,12.395734000022975],[123.89612042234297,12.683687101726003]],[[126.05252036481781,9.855207400456655],[125.88748505546852,9.801670473167492],[125.83123509531637,9.690794260828273],[125.86972003454434,9.498333545906151]],[[124.72892524992008,9.172531794727917],[124.76248585064045,9.08033076823294],[124.81649461015922,8.98280593341169]],[[123.86154340165245,11.940899155789175],[123.93298644005357,11.53744186728821],[124.19998625090851,11.294709319565477],[124.39720203273188,11.054861343165987]],[[123.91358645439269,9.6225370957098],[124.19998625150441,9.302441529883154],[124.6499859327203,8.635699417327467],[124.63191594552143,8.454147535358473]],[[123.33503132573671,8.564097021577123],[123.41248680878029,8.746907464137017],[123.6374866493885,8.858082310478219],[124.4812360522644,8.635699417327467],[124.63191594552143,8.454147535358473]],[[123.33503132573671,8.564097021577123],[123.18748696817244,8.635699417327467],[122.73748728695635,8.413185359560185],[122.67847669449624,8.07385810571817]],[[123.98834884332426,10.406420283136939],[124.19998625150441,10.300149066026464],[124.6499859327203,10.189442766507625],[124.84217394836772,10.178582995816972]],[[122.06327100000034,6.908093105542354],[121.9499878448285,6.889421762667733],[121.72498800422028,7.001096155427926],[121.72498800422028,7.447522319872199],[121.9499878448285,7.893494252945166],[122.41209034937056,8.21003992165092],[122.67847669449624,8.07385810571817]],[[123.83892650668652,10.252013798268077],[123.86248648999636,10.07869800665097],[123.8062365298444,9.801670473167492],[123.87820913570422,9.675391878909156]]]}},{"type":"Feature","properties":{"id":"groix-4","name":"Groix 4","color":"#d41f77","feature_id":"groix-4-0","coordinates":[-3.4801890371333166,47.68245464793438]},"geometry":{"type":"MultiLineString","coordinates":[[[-3.445566038986786,47.69743263943637],[-3.487423294132722,47.67932509111776],[-3.482272326026668,47.65023770959318]]]}},{"type":"Feature","properties":{"id":"thetis","name":"Thetis","color":"#69a0d5","feature_id":"thetis-0","coordinates":[26.938455885723943,35.69536322284784]},"geometry":{"type":"MultiLineString","coordinates":[[[19.858511225015423,39.641540074190374],[19.956685097840904,39.64097793514622],[20.02100005227961,39.683031225295494]],[[20.112871838702866,39.3853679909665],[20.192934930479296,39.42299840247995],[20.27741130974863,39.44998887609635]],[[26.10717013481262,35.20695416056713],[26.325055586427577,35.51140764168837],[26.88755518794758,35.63341465389536],[27.112555028555615,35.90725015614043],[27.562554709771508,35.99831894490682],[27.851088976705316,35.92785157537484]],[[28.12005578044264,36.41157986414525],[27.900054470683475,36.45208516840206],[27.337554869163473,36.632853424489355],[27.143445006672767,36.782900607708115]],[[26.995074014609276,36.7715804931329],[26.81661458617853,36.86493814541961],[26.550055427035613,36.87321951208928],[26.10005574581954,36.602754740329765],[25.762555984907575,36.45208516840206],[25.650056064603465,36.361542640041705],[25.464805590265694,36.34542571389266]],[[25.402346240083446,36.47742641187417],[25.537556144299536,36.632853424489355],[25.537556144299536,36.813198605777224],[25.40363623916962,36.97726017314542]],[[25.37597625876421,37.101840262262826],[25.36868126393224,37.127881937667],[25.289696105851924,37.125246849238955]],[[25.238146034548574,37.122232929128316],[25.42505622399561,37.26220545235354],[25.41956622788482,37.43906885207819]],[[25.32858123375337,37.43906885207819],[25.256431343450945,37.530333013360654],[25.20710136390685,37.538715782011]],[[25.16052574121449,37.538715782011],[25.031181503020274,37.530333013360654],[24.936036453932804,37.441582605440544]]]}},{"type":"Feature","properties":{"id":"juno","name":"JUNO","color":"#cd5628","feature_id":"juno-0","coordinates":[-149.7385266899431,42.4219179059235]},"geometry":{"type":"MultiLineString","coordinates":[[[179.99992719256971,42.740357181754895],[172.79992073307184,42.74369770072915],[160.19996074878455,39.00237890905839],[149.39996839960057,35.419780517080355],[143.09997286257644,33.93964008831966],[140.39997477528055,33.189714664600466],[138.59997605041642,32.43331330641721],[137.69997668798445,33.001218522654476],[136.87399727311598,34.33682825203173]],[[143.09997286257644,33.93964008831966],[141.2999741377125,34.683017659857974],[139.9609750856761,34.97407819999975]],[[-179.99981351108062,42.74035917563463],[-151.19983391146832,42.74035917563463],[-138.82500430186852,40.04369389177534],[-129.5998339543217,36.1498667868178],[-122.84983873608158,34.867831005273345],[-120.62152181466479,35.12094936772415]]]}},{"type":"Feature","properties":{"id":"vodafone-greece-domestic","name":"Vodafone Greece Domestic","color":"#aebf3c","feature_id":"vodafone-greece-domestic-0","coordinates":[24.542210608247157,37.79066938763414]},"geometry":{"type":"MultiLineString","coordinates":[[[24.011428,37.88817800000024],[24.300057020955606,37.88634204539163],[24.7500567021715,37.70855130533492],[24.936036453932804,37.441582605440544]]]}},{"type":"Feature","properties":{"id":"apocs-1","name":"APOCS 1","color":"#b81e4b","feature_id":"apocs-1-0","coordinates":[-59.642790827495816,46.86540582663917]},"geometry":{"type":"MultiLineString","coordinates":[[[-60.22412310132226,46.229284],[-59.84988336584115,46.5823550820958],[-59.399883684625166,47.19740739556967],[-59.284726,47.636672999999746]]]}},{"type":"Feature","properties":{"id":"apocs-2","name":"APOCS 2","color":"#b4287b","feature_id":"apocs-2-0","coordinates":[-59.9390278619882,47.40045395805622]},"geometry":{"type":"MultiLineString","coordinates":[[[-65.55582811045441,45.34801402608026],[-65.4433281901504,45.18376657583663],[-65.20759573903347,45.089458],[-64.94827799999959,45.089458]],[[-60.469312927627435,46.896659999999684],[-60.299883047653125,46.99317357497891],[-59.6248835258292,47.75501398838149],[-59.3919299999996,47.881568999999864]]]}},{"type":"Feature","properties":{"id":"unitel-north-submarine-cable-unsc","name":"Unitel North Submarine Cable (UNSC)","color":"#4bc2c8","feature_id":"unitel-north-submarine-cable-unsc-0","coordinates":[11.486300240357835,-6.713444862776547]},"geometry":{"type":"MultiLineString","coordinates":[[[12.140879999999665,-5.23096999999984],[11.700065946907337,-5.572600905016464],[11.475066106299483,-5.796494123130956],[11.475066106299483,-6.691145450676395],[11.812565867211447,-7.361072141347045],[12.37506546873145,-7.361072141347045],[12.866666670000518,-7.233333333000319]],[[12.189865599928956,-5.556576787346708],[11.925065787515376,-5.628582347656142],[11.812565867211447,-5.796494123130956],[11.812565867211447,-6.132166432128699],[12.150065628123413,-6.188091595867881],[12.352065485024923,-6.141015427762568]]]}},{"type":"Feature","properties":{"id":"romulo","name":"Romulo","color":"#e1b524","feature_id":"romulo-0","coordinates":[1.111940162118184,39.43815663804716]},"geometry":{"type":"MultiLineString","coordinates":[[[-0.21038561561685,39.662556],[0.450073917103194,39.43815663804716],[2.25007264196731,39.43815663804716],[2.462767999999868,39.49995262687443]]]}},{"type":"Feature","properties":{"id":"penbal-4","name":"Penbal-4","color":"#11b690","feature_id":"penbal-4-0","coordinates":[1.3218712573543778,38.92956479573844]},"geometry":{"type":"MultiLineString","coordinates":[[[-0.376975497007027,39.459646713436726],[0.450073917103194,39.264170198047665],[0.900073597723368,39.03151487972872],[1.41889,38.906115],[1.800072960155335,38.94407095870691],[2.475072482575348,39.264170198047665],[2.911560000000498,39.613535]]]}},{"type":"Feature","properties":{"id":"trans-adriatic-express-tae","name":"Trans Adriatic Express (TAE)","color":"#c072af","feature_id":"trans-adriatic-express-tae-0","coordinates":[18.84586566794027,40.62759182313714]},"geometry":{"type":"MultiLineString","coordinates":[[[18.405157,40.30200700000034],[18.575061076596455,40.501475542202705],[19.125060686971477,40.757615606267784],[19.376194000000407,40.78671299999985]]]}},{"type":"Feature","properties":{"id":"olisipo","name":"Olisipo","color":"#939597","feature_id":"olisipo-0","coordinates":[-9.289230839482649,38.27199840161146]},"geometry":{"type":"MultiLineString","coordinates":[[[-9.337909149593617,38.68884285232517],[-9.48588404476688,38.61206568189252],[-9.465249059384854,38.442695701551486],[-9.202699244781627,38.18808260253582],[-9.112419308736753,37.99143181402265],[-8.869597215129223,37.95721527519206]]]}},{"type":"Feature","properties":{"id":"carnival-submarine-network-1-csn-1","name":"Carnival Submarine Network-1 (CSN-1)","color":"#939597","feature_id":"carnival-submarine-network-1-csn-1-0","coordinates":[-84.65743758345529,17.93171527085171]},"geometry":{"type":"MultiLineString","coordinates":[[[-81.795793,26.13909399999979],[-83.2498667890733,24.94136317175375],[-84.82486567332928,23.7112581424843],[-86.0623647972694,21.635297384859552],[-85.94986487636929,20.375041253465433],[-85.27486535454535,19.104405475930452],[-84.37486599151723,17.395022634700517],[-83.02486694786917,16.10232559580297],[-81.67486790422123,13.92930384327183],[-79.87486917935739,11.955858207114732],[-79.76236925964936,9.967915186974132],[-79.90025916196684,9.350855245144121]],[[-79.87486917935739,11.955858207114732],[-79.64986933934534,12.175887185507976],[-78.74986997750936,12.322472159549646],[-75.37487236838926,11.441766261214076],[-74.77978300000017,10.940435797483545]],[[-79.53670942010494,8.964826000000414],[-79.43108296461367,8.233600823655475],[-79.07242834275593,7.259764560250654],[-79.28130421652729,5.101279317788816],[-81.56669126636031,2.380576362405495],[-82.2434797060103,0.553867752345412],[-82.12486758662938,-1.981015190984684],[-81.44986806420927,-2.468145972656139],[-80.85595099012787,-2.329805834324001]],[[-86.76758665233304,21.09572879236739],[-86.51756617710222,20.882904908328154],[-85.98811040620365,20.804669658960037]]]}},{"type":"Feature","properties":{"id":"sea-h2x","name":"SEA-H2X","color":"#939597","feature_id":"sea-h2x-0","coordinates":[111.3682205674424,10.244365025006168]},"geometry":{"type":"MultiLineString","coordinates":[[[103.64609081207688,1.338585852071497],[103.83750067588413,1.168506749040978],[104.14002046217226,1.15796892865923],[104.26002537715979,1.468426767331968],[104.62500011860807,2.817450442654169],[105.74999932164808,4.501447394015217],[107.09999836529612,5.174038327226071],[107.99999772772827,5.845915088460266],[110.02499629320025,7.744889052551447],[111.26249541654417,9.967915186974132],[112.27499469928026,12.615395567393394],[113.84999358294024,17.10851996079568],[114.07499342414418,18.251816319028222],[113.96249350384026,20.796306105108872],[114.2586832940168,22.31829267897149]],[[105.74999932164808,4.501447394015217],[104.8359699685594,5.398081130463647],[103.04883123518101,7.005165564456285],[101.70000219070414,7.075530930004602],[100.5951029728293,7.198818071264419]],[[114.07499342414418,18.251816319028222],[111.14999549564435,18.287425986594243],[110.0493762753333,18.389897000000236]],[[107.99999772772827,5.845915088460266],[109.12499693017237,4.164912849976942],[109.79999645259221,2.817450442654169],[110.36249605411221,1.918228780215599],[110.35370606033919,1.520169126642144]],[[113.84999358294024,17.10851996079568],[116.99999135204831,16.749771315644697],[119.92498927995224,16.642014062854003],[120.320938998862,16.61589414713403]]]}},{"type":"Feature","properties":{"id":"hronn","name":"Hronn","color":"#009f94","feature_id":"hronn-0","coordinates":[6.459455513741928,55.37596795937436]},"geometry":{"type":"MultiLineString","coordinates":[[[8.40694600000052,55.44848],[7.650068816559295,55.39851702575676],[5.400070409883385,55.355904228375444],[4.513130000000371,55.41867]],[[5.400070409883385,55.355904228375444],[5.287570489579458,55.323914500129355],[5.1805,55.32215]]]}},{"type":"Feature","properties":{"id":"cook-strait","name":"Cook Strait","color":"#951f63","feature_id":"cook-strait-0","coordinates":[174.43018359914944,-41.3702928803429]},"geometry":{"type":"MultiLineString","coordinates":[[[174.23597080494744,-41.33902762345182],[174.43120066664477,-41.37045661248348],[174.613370537594,-41.30162636085003]]]}},{"type":"Feature","properties":{"id":"minoas-east-and-west","name":"Minoas East and West","color":"#60489d","feature_id":"minoas-east-and-west-0","coordinates":[23.663634631214506,35.66560465416876]},"geometry":{"type":"MultiLineString","coordinates":[[[23.03574722000064,36.523222220000235],[23.28755773881543,36.24065523321488],[23.512557578231664,36.05897312258681],[23.722500000000338,35.51233333000031],[23.400057657331654,36.05897312258681],[23.175057819107582,36.24065523321488],[23.03574722000064,36.523222220000235]]]}},{"type":"Feature","properties":{"id":"apollo-east-and-west","name":"Apollo East and West","color":"#eb8d22","feature_id":"apollo-east-and-west-0","coordinates":[24.847191332389695,35.48642068436539]},"geometry":{"type":"MultiLineString","coordinates":[[[23.36323941535897,37.973952114992564],[23.34443269554493,37.76786242517874],[23.400552658172874,37.67887792909206],[23.512557579423465,37.589786573603064],[23.962557260043457,36.51238821239364],[24.412556941259535,35.90725015614043],[24.931606573560078,35.40468665209264],[24.52505686156346,35.90725015614043],[24.075057180943467,36.51238821239364],[23.625057499727394,37.589786573603064],[23.456672620800997,37.67887792909206],[23.400557656381388,37.76786242517874],[23.36323941535897,37.973952114992564]]]}},{"type":"Feature","properties":{"id":"seamewe-6","name":"SeaMeWe-6","color":"#939597","feature_id":"seamewe-6-0","coordinates":[67.71027967765008,15.063283874132395]},"geometry":{"type":"MultiLineString","coordinates":[[[35.88754881405568,24.12261698700344],[36.90004809500369,24.361968609765935],[37.80004745803156,24.32780311165181],[38.10697724059967,24.070648010417838]],[[5.372530429989069,43.29362778902908],[5.287570490175359,41.74435878948223],[5.850070091695361,38.651811712711336],[6.750069455319493,37.94551049545967],[9.000067860207336,37.411283634923244],[10.34861723036536,37.411283634923244],[11.137566345387336,37.23235432155614],[11.812565868412525,35.419780517080355],[14.400064035395404,33.565491482352044],[16.650062441475413,32.62301664000789],[19.350060528771497,33.001218522654476],[22.050058614875596,33.174022096718055],[25.200056384579554,32.43331330641721],[27.900054471875638,31.766210259727007],[31.050052239195633,31.798087367585257],[32.06255152193171,31.510798430049064],[32.28445136473581,31.25927814644905]],[[32.28445136473581,31.25927814644905],[32.06255152193171,30.837020582397155],[32.06255152193171,30.07738596207985],[32.23124640063861,29.63833609362628],[32.40005128284368,29.082725788288755],[33.08276564295245,28.365936333863583]],[[32.28445136473581,31.25927814644905],[32.006301561779566,30.837020582397155],[31.9500516016276,30.07738596207985],[32.17500143988696,29.63833609362628],[32.28755136253957,29.082725788288755],[33.08276564295245,28.365936333863583]],[[33.08276564295245,28.365936333863583],[33.35680060388853,28.161052262220792],[33.80630028723958,27.364667993860262],[34.42504984950763,26.562513149236715],[34.87504953131979,25.75470426341523],[35.88754881405568,24.12261698700344],[37.12504793739979,22.05298561667754],[37.91254737952765,20.375041253465433],[39.26254642257961,18.251816319028222],[40.27504570531769,16.534196198259725],[41.68129470911569,14.801154224791581],[42.18754435048373,13.92930384327183],[42.83441889223159,13.054150695298627],[43.14379367306766,12.834868817846521],[43.2281686132957,12.615395567393394],[43.50941841345962,12.395734000022975],[44.55004267627159,11.018774999640526],[45.450042037511935,10.963556857789316],[48.60003980661981,11.735650161405832],[52.65003693696785,12.72515592356304],[54.00003598299986,13.054150695298627],[55.35003502307177,13.273238157547594],[58.9500324733959,15.018578573757472],[60.30003151883183,15.669513225155248],[65.75491565746178,16.45525448044254],[70.2549124696218,13.25182881322956],[71.71468744134651,9.199470523025733],[73.90979788338855,6.525105259563287],[74.70002131714794,5.659359572411489],[78.3000187674719,3.491423322320592],[79.65001781052403,2.967259208499635],[81.00001685476798,2.592701464601932],[85.500013666928,1.018588540518982],[90.00001047908802,1.91828308225627],[92.70000856638391,4.389285926050993],[94.27500745064,5.385636447723476],[95.42107397406397,6.138771009008069],[97.42500521915215,5.510071711803246],[97.87500490036805,5.286069860821008],[98.66250434249609,4.613591578862773],[99.90000346584002,3.266814816815666],[101.25000250948806,2.03066189047467],[102.15000187192003,1.524657538529121],[102.68279723997186,1.41768673166663],[103.34065102845322,1.074949758433191],[103.50000091556807,1.144999057563372],[103.64609081207688,1.338585852071497]],[[44.55004267627159,11.018774999640526],[43.65004331383962,11.399928123027385],[43.14799366949638,11.594869371447825]],[[79.65001781052403,2.967259208499635],[79.87501765113208,3.191933974144809],[80.550017172956,5.435413643888211],[80.53985718074962,5.940820740520149]],[[101.25000250948806,2.03066189047467],[101.4750023495002,2.592701464601932],[101.44360237174425,2.751228763607222]],[[90.00001047908802,1.91828308225627],[90.90000984092408,5.061986954416114],[91.35000952273606,9.52441134501949],[92.70000856638391,14.801154224791581],[92.70000856578802,17.82393441253792],[92.02500904336819,19.95262290516439],[91.99482906593992,21.42927456664916]],[[74.70002131714794,5.659359572411489],[74.25002163652778,4.837826391986557],[73.54027200000027,4.211916943627916]],[[70.2549124696218,13.25182881322956],[71.1000238680158,16.965102599435927],[72.87590260996693,19.07607425728523]],[[65.75491565746178,16.45525448044254],[65.47502785162398,20.375041253465433],[66.15002737344808,23.298598065875897],[67.02854675228855,24.889731701235817]],[[85.500013666928,1.018588540518982],[83.70001494146798,5.510071711803246],[83.02501541964405,7.744876956882131],[82.57501573842798,9.52441134501949],[81.45001653598388,11.515266158038768],[80.24298739105474,13.06385310188338]],[[51.5193877385264,25.294536999999895],[52.20003725694376,26.05828756029904]],[[54.419075684956134,24.443964572625426],[54.34014573908288,24.872582666687173]],[[60.30003151883183,15.669513225155248],[60.30003151883183,19.104405475930452],[60.30003151883183,22.469443964829516],[59.85003183761575,23.09178547692239],[58.95003247518379,23.65974644119216],[58.61253271367574,23.81422051502533],[57.661372759507515,24.420846844473278],[57.15003374972377,25.348717422116714],[56.812533989407704,26.1593079707739],[56.58753414879967,26.512189502051797],[56.362534308191634,26.512189502051797],[55.80003470667163,26.1593079707739],[55.35003502545574,25.855985466072205],[54.34014573908288,24.872582666687173],[52.87503677817179,25.450342946923914],[52.20003725694376,26.05828756029904],[51.637537655423756,26.36108632539156],[51.187537974207686,26.461843796188983],[50.57595273852649,26.229438]],[[58.61253271367574,23.81422051502533],[58.40778285872253,23.58412999999983]]]}},{"type":"Feature","properties":{"id":"biznet-nusantara-cable-system-1-bncs-1","name":"Biznet Nusantara Cable System-1 (BNCS-1)","color":"#c46628","feature_id":"biznet-nusantara-cable-system-1-bncs-1-0","coordinates":[105.70661248093886,-5.950901469087846]},"geometry":{"type":"MultiLineString","coordinates":[[[105.16425973659172,-2.065142653434427],[104.96249987952004,-2.243336428755425],[104.87451166060187,-2.295346349312876]],[[105.88390920948316,-6.073698909871739],[105.72187434097629,-5.973680124487365],[105.58496037606339,-5.769333077764846]]]}},{"type":"Feature","properties":{"id":"hokkaido-akita-cable","name":"Hokkaido-Akita Cable","color":"#c38c2b","feature_id":"hokkaido-akita-cable-0","coordinates":[138.82497589102445,42.52538601559627]},"geometry":{"type":"MultiLineString","coordinates":[[[140.11667497537667,39.716671527453656],[139.4999754116566,40.04369219283004],[139.27497557104874,40.38732029077508],[138.82497589102445,42.07923561816413],[138.82497589102445,42.743713464436695],[139.04997573163232,43.073310783003215],[140.39997477528055,43.645862805594916],[140.84997445649643,43.645862805594916],[141.3543759999998,43.06209599999981]]]}},{"type":"Feature","properties":{"id":"topaz","name":"Topaz","color":"#939597","feature_id":"topaz-0","coordinates":[130.63216937446563,26.705892337472758]},"geometry":{"type":"MultiLineString","coordinates":[[[138.59997605041642,33.189714664600466],[138.3939703363819,31.721659545401526],[135.05290856319252,28.932934895626012],[128.07498350582398,25.417654892229358],[127.2086755257747,24.98888889999996],[125.82809661924175,23.865759963390698],[120.88201985138694,22.34569808206579]]]}},{"type":"Feature","properties":{"id":"topaz","name":"Topaz","color":"#33499e","feature_id":"topaz-1","coordinates":[-152.30356639946402,49.58728674004685]},"geometry":{"type":"MultiLineString","coordinates":[[[-124.8075373498257,49.23399525062965],[-124.81858873599236,49.098645310092806],[-125.09983714275776,48.951106020783016],[-125.54983682397375,48.803129141654416],[-138.59982757864174,49.000334389463426],[-151.1998186526899,49.58728674004685],[-179.99979825051412,49.58728674004685]],[[179.99994672169302,49.58728674004685],[172.7999518228328,49.58728674004685],[160.19996074878455,46.5823550820958],[149.39996839960057,40.04369219283004],[142.19997350014447,36.87321951208928],[141.07497429650857,36.632853424489355],[140.71827454919813,36.71305911738493]],[[142.19997350014447,36.87321951208928],[142.4249733401566,36.1498667868178],[142.19997350014447,35.05222991093673],[140.84997445649643,33.93964008831966],[138.59997605041642,33.189714664600466],[137.69997668798445,33.565491482352044],[136.87399727311598,34.33682825203173]],[[-124.8075373498257,49.23399525062965],[-123.97483793971784,49.355787080150606],[-123.114034,49.260440000000195]]]}},{"type":"Feature","properties":{"id":"medusa-submarine-cable-system","name":"Medusa Submarine Cable System","color":"#939597","feature_id":"medusa-submarine-cable-system-0","coordinates":[10.795352603090608,37.663721927047796]},"geometry":{"type":"MultiLineString","coordinates":[[[-9.33155915349599,38.690161972355526],[-9.449919069648717,38.122730108392204],[-9.674918909064772,37.23235432155614],[-9.562418989356745,36.69301553274438],[-8.999919388432733,36.33133835588799],[-5.843848196000099,36.13475690999979],[-5.565736128994312,36.28964443924729],[-5.250523729785807,36.37610614625084],[-4.499922576272716,35.78566189952622],[-2.249924170192707,35.876870570092834],[2.25007264196731,37.411283634923244],[4.725070888059456,38.651811712711336],[6.075069932303216,38.651811712711336],[8.10006849777537,38.21117903702318],[9.000067859611436,37.94551049545967],[10.348617229769278,37.94551049545967],[10.91256650537538,37.589786573603064],[11.812565866615547,37.23235432155614],[12.993815030999317,35.96797434759339],[14.400064034799321,35.236213400528236],[16.65006244087933,34.867831005273345],[19.350060528175415,35.236213400528236],[22.050058614875596,34.852445708846155],[23.400057658523455,34.52869067055088],[25.200056383983473,34.12610104005753],[27.000055108251505,33.87739543625145],[31.050052239791533,32.90681902852468],[31.9500516016276,31.798087367585257],[32.28755136253957,31.510798430049064],[32.28445136473581,31.25927814644905]],[[-4.499922576272716,35.78566189952622],[-4.724922416880753,35.602930322906126],[-5.391811944449903,35.565918934421326]],[[-2.249924170192707,35.876870570092834],[-2.474924011396645,35.450334489671505],[-2.933034999999599,35.170020000000214]],[[6.075069932303216,38.651811712711336],[5.400070410479286,41.74435878948223],[5.372530429989069,43.29362778902908],[5.062570649567322,41.74435878948223],[4.950070728667312,41.1823303464796],[4.837570808959284,40.04369219283004],[4.725070888059456,38.651811712711336]],[[4.950070728667312,41.1823303464796],[2.700072322587302,41.1823303464796],[2.168725042748786,41.38560270176812]],[[2.25007264196731,37.411283634923244],[2.812572243487313,37.23235432155614],[3.035712085413061,36.76212778211003]],[[8.10006849777537,38.21117903702318],[6.975069294139464,37.44106375352233],[6.557241714000397,37.00295935602444]],[[11.812565866615547,37.23235432155614],[12.060515691561484,37.546826221282146],[12.591375316091606,37.65058617278613]],[[9.000067859611436,37.94551049545967],[9.562567461131438,37.589786573603064],[9.867357245811593,37.276816253475154]],[[22.871347145070974,34.64858019596081],[22.871347145070974,35.25861099190071],[23.903670583009415,36.290409440352754],[23.903670583009415,37.09588010976437],[23.73052617441654,37.97446102487642]],[[31.050052239791533,32.90681902852468],[31.610642393614338,33.937310785298685],[32.46668800000052,34.76662920076821]],[[14.400064034799321,35.236213400528236],[15.28032546762509,33.68969836184677],[15.088930733703902,32.37254680331547]],[[20.060997550718024,32.114846773024304],[19.80006020819969,32.43331330641721],[19.687560287299494,32.8123187832876],[19.350060528175415,35.236213400528236]],[[14.350100000000367,35.95239999999964],[13.676479719196331,35.61357619443199]],[[32.047198751652715,34.36126232841875],[34.200050008303506,34.36126232841875],[35.89779880560243,34.89170328553848]]]}},{"type":"Feature","properties":{"id":"saudi-vision","name":"Saudi Vision","color":"#c76a28","feature_id":"saudi-vision-0","coordinates":[36.699929995878726,25.543088498954923]},"geometry":{"type":"MultiLineString","coordinates":[[[39.18286300000012,21.481542780852628],[38.81254674076764,22.05298561667754],[37.80004745743566,23.848523186487938],[36.56254833468763,25.75470426341523],[35.60629901210351,26.562513149236715],[34.46137982377105,27.916953178205596],[34.46137982377105,27.991483447904724],[34.48950480265532,28.115586087218034],[34.57387974288317,28.313851747133643],[34.76762960622493,29.08904402302273],[34.95220947487088,29.283851999999897]],[[35.60629901210351,26.562513149236715],[35.606299011507616,27.097918575215974],[35.69652894758787,27.354018999999465]],[[37.80004745743566,23.848523186487938],[37.96879733789173,23.91710129093513],[38.10701723997532,24.070616999999686]]]}},{"type":"Feature","properties":{"id":"scotland-northern-ireland-4","name":"Scotland-Northern Ireland 4","color":"#a4ab37","feature_id":"scotland-northern-ireland-4-0","coordinates":[-5.373545682142867,55.153340823728385]},"geometry":{"type":"MultiLineString","coordinates":[[[-5.809831648320849,54.85780200253145],[-5.624921779312721,55.04558670490423],[-5.174922098096737,55.23848220955442],[-4.854172325318898,55.24040360744587]]]}},{"type":"Feature","properties":{"id":"scotland-northern-ireland-3","name":"Scotland-Northern Ireland 3","color":"#44b649","feature_id":"scotland-northern-ireland-3-0","coordinates":[-5.295928196395849,54.68794520830256]},"geometry":{"type":"MultiLineString","coordinates":[[[-5.538691840398792,54.643037742698276],[-5.287422018400756,54.68951871778461],[-5.174922098096737,54.754492368816926],[-5.115502140190354,54.84402536873057]]]}},{"type":"Feature","properties":{"id":"hawaiki-nui-1","name":"Hawaiki Nui 1","color":"#939597","feature_id":"hawaiki-nui-1-0","coordinates":[115.5138499091612,-7.8829131023718695]},"geometry":{"type":"MultiLineString","coordinates":[[[129.0374828245764,-11.833858275879866],[127.34998401942048,-11.772679839911943],[126.44998465698852,-11.772679839911943],[120.99998851781679,-11.55232519729577],[118.68749015601242,-10.804297138907085],[116.54999167023632,-9.586362493293953],[115.8749921484124,-9.142360877005329],[115.81874218826026,-8.86457667788879],[115.8749921490083,-8.401139048122838],[115.76249222810829,-8.178490278944933],[115.19999262658828,-7.509810688339549],[114.46970572643436,-6.616650693475355],[112.0499948586722,-5.497950688314882],[109.34999676839627,-3.92832730414264],[108.89999708777627,-3.029995968008661],[106.08749908256004,-0.331409329660265],[104.8499999592161,0.624825760007394],[104.40000027800004,0.906050180869095],[104.2875003576961,1.018534216615524],[104.1778504347774,1.18452336036332],[103.85310700000069,1.293877684611663]],[[152.99996584932845,-16.30669306561827],[151.6499668050847,-14.571726491332546],[148.49996903716843,-12.383840433185572],[147.2624699138245,-11.062032109909483],[144.89997158684466,-10.47259892498978],[142.19997349954858,-9.586362493293953],[138.59997605041642,-9.955921616229002],[134.5499789188764,-9.586362493293953],[131.39998115036443,-9.142360877005329]],[[152.99996584932845,-16.30669306561827],[153.44996553054452,-18.880139975101173],[153.8999652117606,-25.540896076259312],[154.3499648929765,-27.553513996438145],[154.3499648929765,-28.743810281149894],[153.76076054297755,-31.85146566557725],[152.09996648689665,-33.17952345666914],[151.2070371188603,-33.869695999999635]],[[154.3499648929765,-27.553513996438145],[153.56246544906074,-27.353852936231306],[153.02282700000043,-27.46888410816531]],[[131.39998115036443,-9.142360877005329],[129.59998242550049,-9.586362493293953],[129.14998274428442,-10.620064860363238],[129.0374828245764,-11.833858275879866],[129.8751578176926,-12.194543031162238],[130.50000678851072,-12.329005823921618],[130.84315500000028,-12.46750362874273]],[[109.34999676839627,-3.92832730414264],[108.22499756833612,-4.60145376483711],[107.77499788712005,-5.273944363641298],[106.82786855747969,-6.171587999999717]],[[104.14960045538582,1.18452336036332],[104.0166370000003,1.066798000000349]],[[147.2624699138245,-11.062032109909483],[147.14996999352056,-10.17745743036107],[147.1885196909836,-9.479589292697288]],[[152.99996584932845,-16.30669306561827],[155.24996425540846,-15.441023659568087],[157.49996266089255,-11.943944931746815],[159.41246130606078,-10.17745743036107],[159.52496122636472,-9.73423534230066],[159.46876126617727,-9.29042430103552],[159.5249612269606,-9.123848597823338],[159.74996106756865,-9.123848597823338],[159.94976092602863,-9.42905364643845]],[[129.59998242550049,-9.586362493293953],[128.6999870359665,-8.920150538056042],[128.3038833436693,-7.801903059182855],[127.34998401942048,-7.385865390978736],[125.99998497577243,-7.385865390978736],[125.54998529396046,-7.992854458460687],[125.57937980498313,-8.559447499464328]]]}},{"type":"Feature","properties":{"id":"blue","name":"Blue","color":"#7c287d","feature_id":"blue-0","coordinates":[27.55492557568854,34.040429901230254]},"geometry":{"type":"MultiLineString","coordinates":[[[8.775068019003399,43.51003100840901],[7.200069135343221,43.15543467648206],[6.412569692619463,42.82627801037571],[5.962570011403388,42.82627801037571],[5.372530429989069,43.29362778902908]],[[22.050058614875596,35.679119358342746],[21.600058933659522,35.86167640626605],[18.00006148452737,37.05299936423364],[15.750063078447363,37.67887792909206],[15.525063237243426,38.06370455144154],[15.637563157547534,38.240638159471985],[15.30006339663557,38.50523297944307],[14.850063715419495,38.681091555174525]],[[34.76967960477271,32.04501185826483],[33.750050327087614,32.43331330641721],[31.050052239791533,33.189714664600466]],[[35.005095000000225,29.602772333573608],[34.65004968892368,31.03001726298632],[34.65004968892368,31.510798430049064],[34.76967960477271,32.04501185826483]],[[8.775068019003399,43.51003100840901],[9.000067859611436,44.05151922873524]],[[8.775068019003399,43.51003100840901],[9.337567620523402,43.401144973153954],[9.450067542019495,43.073310783003215],[9.562567461131438,42.908732427720366],[9.45088100000022,42.688338873384374]],[[11.137566345387336,41.0693404382162],[11.700065946907337,41.26694507168783],[12.262565548427522,41.52013202089327],[12.495760000000327,41.90311]],[[10.687566663575542,41.52013202089327],[9.900067222043404,41.1823303464796],[9.613157425293073,41.004773687597634]],[[24.012167225495322,35.512042558637575],[23.737557420031504,35.876870570092834],[22.950057977903466,36.05897312258681],[22.050058614875596,35.679119358342746]],[[32.466651236259686,34.76657169708598],[31.500051920411707,33.97074536407291],[31.050052239791533,33.189714664600466],[25.200056383983473,34.613606010190495],[23.400057658523455,35.17493178747529],[22.050058614875596,35.679119358342746]],[[14.850063715419495,38.681091555174525],[13.500064671771455,38.856519049872716],[12.150065628123413,39.6983233549332],[11.137566345387336,41.0693404382162],[10.687566663575542,41.52013202089327],[10.125067060863374,42.41235450073586],[9.90006722085142,43.073310783003215],[9.618817421283403,43.401144973153954],[9.000067859611436,44.05151922873524]],[[9.000067859611436,44.05151922873524],[8.938867903561944,44.41035752885385]],[[14.850063715419495,38.681091555174525],[13.950064352987528,38.417142223456445],[13.358654772543574,38.11612161658339]]]}},{"type":"Feature","properties":{"id":"raman","name":"Raman","color":"#939597","feature_id":"raman-0","coordinates":[50.51187004053453,14.013272070190846]},"geometry":{"type":"MultiLineString","coordinates":[[[72.87590260996693,19.07607425728523],[70.20002450558383,19.635064099942493],[66.60002705585578,20.163975031975873],[62.775029765519875,20.375041253465433],[58.95003247518379,17.609605913224996],[55.35003502545574,15.886035719079029],[48.60003980721571,13.273238157547594],[45.450042038703735,12.505588131780646],[44.55004267627159,12.285833556268383],[43.881959133154325,12.458770746871922],[43.42504347323158,12.615395567393394],[43.340668533003544,12.834868817846521],[43.256293592775506,13.054150695298627],[43.03129375216765,13.92930384327183],[42.52504411079961,14.801154224791581],[41.8500445889755,16.534196198259725],[39.487546262591565,20.375041253465433],[39.1500465010837,20.936468000149056],[38.70004682046353,22.05298561667754],[37.46254769711778,24.12261698700344],[36.45004841438352,25.75470426341523],[35.696548947573824,27.354010300438862]],[[35.696548947573824,27.354010300438862],[35.80902892030377,28.114238355828782],[35.386279636121465,29.113614162980063],[35.005095000000225,29.581033231032787],[35.522363857840276,29.189902084804793],[35.9938904007051,28.15020742874113],[35.696548947573824,27.354010300438862]],[[44.55004267627159,12.285833556268383],[43.63637028333588,11.94650543254203],[43.14799366949638,11.594869371447825]],[[55.35003502545574,15.886035719079029],[54.78753542333983,16.534196198259725],[54.14808587692784,17.09582718672565]],[[62.775029765519875,20.375041253465433],[62.775029765519875,22.469443964829516],[59.85003183761575,24.225251377403733],[58.50003279396771,23.89138876125301],[57.88605322832058,23.67872342575357]]]}},{"type":"Feature","properties":{"id":"kingisepp-kaliningrad-system-baltika","name":"Kingisepp-Kaliningrad System (Baltika)","color":"#12b5d6","feature_id":"kingisepp-kaliningrad-system-baltika-0","coordinates":[22.519340764201484,59.17716404654191]},"geometry":{"type":"MultiLineString","coordinates":[[[28.661263795515822,59.437858236904816],[27.37860937499232,59.765799560815175],[26.107282352704132,59.945326886122366],[23.847322966676572,59.53406190277903],[22.3808201432679,59.139936354360735],[21.30842106981249,58.59223846792796],[20.523738820942917,57.62501221189865],[19.311956893430537,56.41736400880456],[20.118319659027506,55.25597656488328],[20.60220704583015,54.970360488431474]]]}},{"type":"Feature","properties":{"id":"deep-blue-one","name":"Deep Blue One","color":"#ba742d","feature_id":"deep-blue-one-0","coordinates":[-56.18860702302827,7.655289169167652]},"geometry":{"type":"MultiLineString","coordinates":[[[-60.7498827288692,11.055581341549555],[-60.29988304705723,10.85308969074528],[-57.374885119153106,8.635699417327467],[-56.474885756721136,7.744889052551447],[-55.4556016603845,7.425872215398943],[-53.99988751062917,7.559058926010829],[-53.09458552894352,7.357410349749805],[-52.4248886263731,5.957818681088533],[-52.320928699423284,4.941547448310236]],[[-60.73534273916942,11.182456999999468],[-60.7498827288692,11.055581341549555]],[[-61.65081209004632,10.686261032786325],[-61.42488225009723,10.963556857789316],[-60.974882568881156,11.070057237753451],[-60.7498827288692,11.055581341549555]],[[-55.4556016603845,7.425872215398943],[-55.34988655427713,6.554242425496979],[-55.17385100000029,5.82403]],[[-57.374885119153106,8.635699417327467],[-57.487385038861134,8.190543417795496],[-57.824884800369176,7.29876275445952],[-58.154864566608346,6.804293299288684]]]}},{"type":"Feature","properties":{"id":"apricot","name":"Apricot","color":"#b63894","feature_id":"apricot-0","coordinates":[129.23798072152712,20.059088092174036]},"geometry":{"type":"MultiLineString","coordinates":[[[144.65753175859138,13.385305518498077],[143.9999722250084,13.601498202276586],[143.09997286257644,13.92930384327183],[138.59997605041642,15.669513225155248],[131.39998115096031,17.395022634700517],[126.44998465639262,18.251816319028222],[127.34998401942048,19.24608308700458],[130.94998146974444,20.796306105108872],[138.59997605041642,23.7112581424843],[139.72497525286053,24.94136317175375],[140.84997445649643,30.901396088515508],[141.07497429710446,32.43331330641721],[140.96247437680054,33.93964008831966],[140.56872465573645,34.405022750715936],[140.37184979460858,34.69072647741027],[140.1046624838865,34.852445708846155],[139.9609790000003,34.9740779999996]],[[127.34998401942048,19.24608308700458],[125.99998497636834,21.32123529551186],[124.14876628719325,23.757798070792454],[122.84998720785636,24.63495976683041],[121.80144795065142,24.863504112487785]]]}},{"type":"Feature","properties":{"id":"apricot","name":"Apricot","color":"#939597","feature_id":"apricot-1","coordinates":[119.96497042769207,1.1841040607806506]},"geometry":{"type":"MultiLineString","coordinates":[[[103.64609081207688,1.338585852071497],[103.83750067588413,1.182753739294242],[104.02421554420971,1.202092036773615],[104.14960045538582,1.18452336036332],[104.17500043679628,1.018409236452146],[104.2875003576961,0.962292662396848],[104.40000027800004,0.849806826211382],[104.8499999592161,0.68107206531244],[106.19999900286416,-0.331409329660265],[109.12499692898021,-3.029995968008661],[109.79999645080431,-3.479268678970064],[112.0499948586722,-4.60145376483711],[114.60984933620142,-5.273944363641298],[116.80810148738884,-4.179995582158629],[117.7124908479029,-2.730375485267853],[117.96440181128685,-1.51533365197483],[118.76628430204579,-0.810555324740758],[119.47498959873617,0.568578852526193],[119.69998943934421,1.018534216615524],[120.59998880177636,1.580886840914131],[124.19998625090851,3.41655961832325],[126.57346403647036,5.404676658378629],[128.24998338185245,7.447522319872199],[127.34998401942048,11.882475268284509],[125.54998529455636,16.24638821747719],[126.44998465639262,18.251816319028222]],[[125.54998529455636,16.24638821747719],[123.52498672908439,15.597287859114385],[121.94998784542422,15.669513225155248],[121.56018812156209,15.761539465842137]],[[126.57346403647036,5.404676658378629],[126.3374847366844,5.659359572411489],[126.11248489607637,5.957818681088533],[125.61287587559997,7.079988883160643]],[[109.12499692898021,-3.029995968008661],[107.88749780742415,-4.60145376483711],[107.43749812620827,-5.273944363641298],[107.12099835041957,-5.981154260263285]],[[104.17500043679628,1.018409236452146],[104.11887547655554,1.018409236452146],[104.0166370000003,1.066798000000349]]]}},{"type":"Feature","properties":{"id":"scylla","name":"Scylla","color":"#b82a3f","feature_id":"scylla-0","coordinates":[3.1697065756605403,52.62326141852725]},"geometry":{"type":"MultiLineString","coordinates":[[[1.72927301090669,52.46882263773048],[2.25007264196731,52.62326141852725],[3.825071526223208,52.62326141852725],[4.275071207439282,52.55491468334153],[4.61367096757211,52.458501705101135]]]}},{"type":"Feature","properties":{"id":"polar-express","name":"Polar Express","color":"#939597","feature_id":"polar-express-0","coordinates":[152.4078310345516,72.5953947457812]},"geometry":{"type":"MultiLineString","coordinates":[[[169.64995405372477,70.25039973315269],[159.74996106697276,72.13481545023681],[148.9499687177886,72.8123099710512],[141.7499738183325,73.07627580961623],[134.99089662947918,72.81107196658]],[[170.30700000000036,69.7029],[169.64995405372477,70.25039973315269]],[[179.99994824991296,70.1742290900765],[177.7499483156128,70.55228024514132],[171.89995245980478,70.70155250919251],[169.64995405372477,70.25039973315269]],[[-179.9997982511102,64.18706730674532],[-172.7998033516541,63.99042806051585],[-170.0998052643581,64.76870273314248],[-169.19980590192614,66.0785049157679],[-170.54980494557392,67.66857826204087],[-175.94980112016617,69.47590081058141],[-179.9997967228899,70.1742290900765]],[[177.5021457567835,64.72719091772642],[178.64994767804478,64.4794309101756],[179.99994672169302,64.18706730674532]],[[-179.9997982511102,60.528451203358905],[-179.52782592920985,60.8695351663294],[-172.7998033516541,63.99042806051585]],[[159.74996106697276,52.50929095964713],[161.99995947305277,52.78232285776401],[168.29995501007673,53.857473440304716],[174.02495095443587,55.90629872677893],[179.97797408100845,60.528451203358905]],[[158.65001067431297,53.01666789688405],[159.74996106697276,52.50929095964713]],[[142.72624265858042,46.94899298742814],[143.43747262348842,47.120911503379745],[145.12497142745252,47.04430546733287],[147.5999696729486,46.8394840627337],[153.44996552994863,48.35656994073399],[158.8499617045408,50.83511113795267],[159.74996106697276,52.50929095964713]],[[131.9107473900959,43.154615407914534],[132.2999805127964,42.35695685335537],[133.19997987522854,41.85617959436374],[134.5499789188764,41.74435878948223],[137.9249765279966,43.073310783003215],[140.84997445590054,45.59408578894718],[141.97497365894054,45.75130568537152],[143.09997286198055,45.75130568537152],[144.67497174444856,46.11643477220242],[145.12497142745252,47.04430546733287]],[[133.19997987522854,41.85617959436374],[133.19997987463265,42.329239699665536],[132.89127009392124,42.83453574149161]]]}},{"type":"Feature","properties":{"id":"polar-express","name":"Polar Express","color":"#cf3a26","feature_id":"polar-express-1","coordinates":[111.26438308594514,77.57968186211002]},"geometry":{"type":"MultiLineString","coordinates":[[[81.00001685417209,74.45913348599115],[78.75001844809208,73.96950097861448],[71.55002354863598,73.96950097861448],[65.70002769282792,72.8123099710512],[63.00002960553183,70.99679695986222],[60.75003119945182,70.40189838789871]],[[78.75001844809208,73.96950097861448],[80.10001749174012,73.59244957584484],[80.531,73.5077]],[[81.00001685417209,74.45913348599115],[86.85001270998013,75.9517011642262],[94.50000729065214,77.20425577257384],[99.45000378402823,77.49983814503032],[104.84999995862022,77.88351717807191],[112.94999422050827,77.49983814503032],[123.74998656969242,76.27568108311124],[130.94998146914853,74.09330000483304],[135.00070515758972,72.81107196658],[134.99997860009248,72.8123099710512],[131.39998115036443,71.9962481654091],[128.8645,71.6375]],[[61.663700000000304,69.7511999999999],[60.75003119945182,70.40189838789871],[59.40003215580378,70.55228024514132],[57.600033430939845,70.40189838789871],[53.100036618779825,69.94402310253807],[44.10004299445961,69.63309228121113],[35.55004905135565,69.47590081058141],[35.10004937013957,69.31754886651788],[35.114219360101536,69.18399665130558]]]}},{"type":"Feature","properties":{"id":"darwin-jakarta-singapore-cable-djsc","name":"Darwin-Jakarta-Singapore Cable (DJSC)","color":"#752222","feature_id":"darwin-jakarta-singapore-cable-djsc-0","coordinates":[114.16800775684534,-19.800107039490765]},"geometry":{"type":"MultiLineString","coordinates":[[[118.57724023471046,-20.31344226536242],[117.97499050962779,-20.048661670363725],[115.19999262658828,-19.800107039490765],[111.59999517686023,-19.800107039490765],[109.79999645199631,-20.433922197637408]]]}},{"type":"Feature","properties":{"id":"zeus","name":"Zeus","color":"#2181bf","feature_id":"zeus-0","coordinates":[3.1322239085293395,52.48646132010029]},"geometry":{"type":"MultiLineString","coordinates":[[[1.72927301090669,52.46882263773048],[2.25007264196731,52.48646132010029],[3.825071526223208,52.48646132010029],[4.524191030960483,52.36366638180829]]]}},{"type":"Feature","properties":{"id":"india-asia-xpress-iax","name":"India Asia Xpress (IAX)","color":"#939597","feature_id":"india-asia-xpress-iax-0","coordinates":[88.07615025767723,12.08036113105525]},"geometry":{"type":"MultiLineString","coordinates":[[[81.13951857354486,16.175049115204825],[82.01872118892727,15.044700164166894],[81.82223885811331,11.127589656424089]],[[87.50654093847432,21.613825026350842],[88.64575957688014,2.546897235759658]]]}},{"type":"Feature","properties":{"id":"india-asia-xpress-iax","name":"India Asia Xpress (IAX)","color":"#ced528","feature_id":"india-asia-xpress-iax-1","coordinates":[82.91557519892766,2.4346698251038394]},"geometry":{"type":"MultiLineString","coordinates":[[[72.87590151562159,19.066189490208128],[71.32502370862383,16.965102599435927],[71.04513590397801,13.492128176464083],[72.50491087570272,9.361978911833972],[74.70002131774494,6.688675551202349],[75.37502083897188,5.659359572411489],[78.3000187674719,3.715978119298069],[79.65001781052403,3.191933974144809],[81.00001685476798,2.817450442654169],[85.500013666928,1.918228780215599],[90.00001047908802,2.817450442654169],[92.70000856638391,4.613591578862773],[94.27500745064,5.609601184516913],[95.40635270497349,6.218295306499518],[97.42500521915215,5.622041180883233],[98.10000474097609,5.286069860821008],[98.88750418310413,4.613591578862773],[100.01250338614413,3.266814816815666],[101.25000250948806,2.143087178471855],[102.15000187192003,1.805788280129153],[102.68279723997186,1.558264177552469],[103.34065102845322,1.383978004154865],[103.50000091556807,1.327518855965853],[103.64609081207688,1.338585852071497]],[[79.65001781052403,3.191933974144809],[80.43751725265209,5.435413643888211],[80.53985718074962,5.940820740520149]],[[98.10000474097609,5.286069860821008],[99.00000410340806,5.957818681088533],[100.06611334816643,6.613518860854109]],[[101.25000250948806,2.143087178471855],[101.41875238934823,2.592701464601932],[101.44360237174425,2.751228763607222]],[[75.37502083897188,5.659359572411489],[74.25002163652778,4.613591578862773],[73.54035213926461,4.212345781871782]],[[85.500013666928,1.918228780215599],[83.92501477969205,5.510071711803246],[83.2500152596562,7.744876956882131],[82.80001557844011,9.52441134501949],[81.45001653598388,11.735650161405832],[80.24298739105474,13.06385310188338]]]}},{"type":"Feature","properties":{"id":"india-europe-xpress-iex","name":"India Europe Xpress (IEX)","color":"#939597","feature_id":"india-europe-xpress-iex-0","coordinates":[48.479840860047226,12.256429919428616]},"geometry":{"type":"MultiLineString","coordinates":[[[8.48375822596588,44.30574823054251],[8.662568098699472,44.21300917863173],[9.225067700219473,44.05151922873524],[9.562567461131438,43.401144973153954],[9.675067381435365,43.073310783003215],[9.900067221447502,42.41235450073586],[10.462566823563405,41.52013202089327],[11.475066106299483,39.6983233549332],[11.700065947503239,37.85673997565852],[12.26256554783162,37.23235432155614],[13.668814552823427,35.96797434759339],[14.400064034799321,35.37392782452342],[16.65006244087933,35.236213400528236],[19.350060528175415,35.602930322906126],[22.050058614875596,35.12894023821604],[23.400057658523455,34.806272556890626],[25.200056383983473,34.33537907402818],[27.900054471279375,32.52821504536491],[29.67235321517045,31.047641997876443]],[[29.67235321517045,31.047641997876443],[29.98130299690349,30.80481662242606],[30.375052717967602,30.683955675438124],[31.050052239791533,30.320465424761444],[31.72505176161546,30.07738596207985],[32.17505144283154,29.540203242583157],[32.65318110412008,29.113614162980063]],[[29.67235321517045,31.047641997876443],[29.98130299690349,30.853118511880677],[30.375052717967602,30.780656567294763],[31.050052239791533,30.41752891425909],[31.72505176161546,30.174689758498985],[32.17505144283154,29.58908594077863],[32.65318110412008,29.113614162980063]],[[32.65318110412008,29.113614162980063],[33.0750508052635,28.95155473219332],[33.53928797639455,28.161052262220792],[34.537549769215474,27.364667993860262],[35.521924071875475,26.562513149236715],[36.33754849407959,25.75470426341523],[37.35004777681367,24.12261698700344],[38.5875469001596,22.05298561667754],[39.03754658077959,20.936468000149056],[39.37504634228764,20.375041253465433],[41.737544668671575,16.534196198259725],[42.46879415064765,14.801154224791581],[42.975043792015505,13.92930384327183],[43.22816861269962,13.054150695298627],[43.3266060429656,12.834868817846521],[43.41098098319364,12.615395567393394],[43.87504315444765,12.395734000022975],[44.55004267627159,11.570378484364811],[45.450042038703735,11.515266158038768],[48.60003980781179,12.285833556268383],[52.65003693815965,13.273238157547594],[54.00003598419185,13.601498202276586],[55.35003502426394,13.710817738179635],[58.950032474587886,15.452760959322058],[60.97503103946396,16.534196198259725],[66.60002705585578,18.678647022154717],[70.20002450558383,18.891661584303154],[72.87590151562159,19.066189490208128]],[[54.00003598419185,13.601498202276586],[54.11253590151572,14.5835116451186],[54.17338394844891,16.5480364295225],[54.14808587692784,17.09582718672565]],[[44.55004267627159,11.570378484364811],[43.65004331383962,11.620598816364184],[43.14799366949638,11.594869371447825]],[[37.34051417738748,24.138084599448355],[37.80004745803156,24.12261698700344],[38.10697724059967,24.070648010417838]],[[35.521924071875475,26.562513149236715],[35.21254929044368,27.497802509202188],[35.223149282934465,28.050969101148137]],[[25.200056383983473,34.33537907402818],[24.975056542779534,34.867831005273345],[24.7684,35.07162999999965]],[[39.03754658077959,20.936468000149056],[39.18275647850768,21.481533475502996]],[[8.662568098699472,44.21300917863173],[8.325068338383225,43.8084564391072],[7.875068657167333,43.564400497117596],[7.200069135343221,43.237448352440914],[6.412569692619463,42.908732427720366],[5.962570011403388,42.908732427720366],[5.363546998256861,43.286507748630704]]]}},{"type":"Feature","properties":{"id":"firmina","name":"Firmina","color":"#939597","feature_id":"firmina-0","coordinates":[-34.347349102592155,1.407257351190094]},"geometry":{"type":"MultiLineString","coordinates":[[[-78.88266988343256,33.69355790837514],[-78.3790402402086,33.189714664600466],[-77.39987093386132,32.36998992114339],[-74.69987284596934,30.901396088515508],[-73.3498738023213,30.320465424761444],[-69.29987667137726,28.359233526108557],[-63.4498808155692,25.75470426341523],[-48.59989133544111,16.534196198259725],[-39.152117353648734,8.152046688785141],[-33.74990185531301,0.568578852526193],[-30.48740414473091,-3.816084200032395],[-27.89990599950586,-9.29042430103552],[-28.349731034615626,-13.697820288632505],[-30.14972975947965,-18.025284192896695],[-36.33740002230498,-24.41918455361521],[-39.59989771171697,-25.94623071841455],[-41.849896117201084,-27.95174728521976],[-44.09989452328109,-32.61276000573574],[-50.84988974152112,-35.95811819864919],[-53.99988751003309,-36.68325067019043],[-56.695445600474535,-36.47095527632105]],[[-50.84988974152112,-35.95811819864919],[-53.99988751003309,-35.40985639492525],[-54.949996837562736,-34.96666536873363]],[[-41.849896117201084,-27.95174728521976],[-45.22489372632109,-25.134186547061336],[-46.4124928850147,-24.00886839636483]]]}},{"type":"Feature","properties":{"id":"ionian","name":"Ionian","color":"#be432b","feature_id":"ionian-0","coordinates":[18.938710756916407,38.98126593490422]},"geometry":{"type":"MultiLineString","coordinates":[[[17.127152102308617,39.08066915816424],[17.775061643323433,39.03151487972872],[19.80006020879559,38.94407095870691],[20.751719534631118,38.95926674743761]]]}},{"type":"Feature","properties":{"id":"sape-labuan-bajo-ende-kupang","name":"Sape-Labuan Bajo-Ende-Kupang","color":"#2982bd","feature_id":"sape-labuan-bajo-ende-kupang-0","coordinates":[122.54750304427115,-9.609222459948315]},"geometry":{"type":"MultiLineString","coordinates":[[[121.6427380624869,-8.845694469062058],[121.72498800422028,-9.068306003874412],[122.84998720726047,-9.808147286237839],[123.29998688907226,-9.900514027809685],[123.58338668830928,-10.182939736570859]],[[118.97619995148753,-8.505601593938989],[119.24998975753242,-8.197049140471197],[119.58748951844439,-8.141369965795338],[119.86873931920438,-8.197049140471197],[119.90755929170408,-8.475554575636568]]]}},{"type":"Feature","properties":{"id":"kupang-alor-cable-systems","name":"Kupang-Alor Cable Systems","color":"#8fc73e","feature_id":"kupang-alor-cable-systems-0","coordinates":[123.6297513722392,-9.100974559926314]},"geometry":{"type":"MultiLineString","coordinates":[[[123.58333668774885,-10.183333435365899],[123.46873676893243,-9.697273227790047],[123.52498672908439,-9.142360877005329],[124.08748633060439,-8.920150538056042],[124.19998625090851,-8.697804852256501],[124.40375610655605,-8.320653715925028]]]}},{"type":"Feature","properties":{"id":"denpasar-waingapu-cable-systems","name":"Denpasar-Waingapu Cable Systems","color":"#73469b","feature_id":"denpasar-waingapu-cable-systems-0","coordinates":[116.69216573688443,-9.36443261124896]},"geometry":{"type":"MultiLineString","coordinates":[[[118.0822205847911,-8.84114915636186],[118.23749047479633,-9.142360877005329],[118.68749015601242,-9.253414272929316],[119.24998975812831,-9.179382545871277],[120.03748919966027,-9.179382545871277],[120.26248904026849,-9.36443261124896],[120.25301904757286,-9.645765890160455]],[[115.64999230780437,-9.142360877005329],[115.70624226795633,-8.86457667788879],[115.8749921490083,-8.679270038068276],[116.07252200848033,-8.561426607445732]],[[118.0822205847911,-8.84114915636186],[118.0124906341883,-9.142360877005329],[117.67499087327633,-9.36443261124896],[117.22499119206026,-9.36443261124896],[116.54999167023632,-9.36443261124896],[116.09999198902025,-9.253414272929316],[115.64999230780437,-9.142360877005329],[115.42499246719633,-8.920150538056042],[115.26057258367304,-8.657096255417823]]]}},{"type":"Feature","properties":{"id":"bifrost","name":"Bifrost","color":"#f5ae1a","feature_id":"bifrost-0","coordinates":[-152.38305336538838,37.36629963591011]},"geometry":{"type":"MultiLineString","coordinates":[[[144.5624718259325,13.92930384327183],[143.9999722250084,13.273238157547594],[137.24997700676838,11.735650161405832],[133.19997987582445,9.967915186974132],[129.59998242550049,7.893494252945166],[126.44998465698852,5.659359572411489],[124.19998625090851,3.865649782482034],[120.59998880177636,2.367912558705407],[119.24998975693651,1.018534216615524],[119.02498991513632,0.568578852526193],[118.31629235168833,-0.810555324740758],[117.85190631946367,-1.290401396141605],[117.73941082764067,-1.51533365197483],[117.48749100610306,-2.730375485267853],[116.58310164737706,-4.179995582158629],[114.67992114108486,-5.049857167366764],[112.0499948586722,-4.37714437553184],[110.02499629200844,-3.479268678970064],[109.34999677018433,-3.029995968008661],[106.31249892316808,-0.331409329660265],[105.63749940134416,0.118588418888312],[104.8499999592161,0.568578852526193],[104.17500043739219,0.685071778220519],[103.89375063663218,0.793562652607196],[103.89375063663218,1.018534216615524],[103.64609081207688,1.338585852071497]],[[179.99994672169302,27.097918575215974],[173.6999511846689,25.484199086872454],[160.19996074818866,22.191942630775465],[151.1999671244645,18.678647022154717],[145.79997094927663,16.24638821747719],[144.67497174623662,14.365653759228442],[144.5624718259325,13.92930384327183],[144.77675167413477,13.490037504527912]],[[124.19998625090851,3.865649782482034],[124.64998593331639,1.805788280129153],[124.8396357983706,1.490779296094715]],[[126.44998465698852,5.659359572411489],[126.2249848157844,5.957818681088533],[125.61287587559997,7.079988883160643]],[[109.34999677018433,-3.029995968008661],[107.99999772772827,-4.60145376483711],[107.5499980465122,-5.273944363641298],[106.82782855810404,-6.171876390816321]],[[-122.84983873608158,35.05222991093673],[-121.9498393748416,34.31215165223547],[-120.59984033000157,33.565491482352044],[-117.44984256148977,32.33831157801293],[-117.06378442547147,32.3575986746028]],[[-123.97340935607642,45.14659036063931],[-125.09983714216159,44.53466416326733],[-129.5998339543217,43.073310783003215],[-138.5998275786419,40.38732029077508],[-151.1998186532859,37.70855130533492],[-163.79980972733406,34.063992930126034],[-172.79980979028863,30.255702942039875],[-179.99980039712295,27.097918575215974]]]}},{"type":"Feature","properties":{"id":"bifrost","name":"Bifrost","color":"#939597","feature_id":"bifrost-1","coordinates":[-129.38335431852127,38.19537648671133]},"geometry":{"type":"MultiLineString","coordinates":[[[-120.62152181466479,35.12094936772415],[-122.84983873608158,35.05222991093673],[-129.5998339543217,38.29952060596925],[-138.5998275786419,40.38732029077508]]]}},{"type":"Feature","properties":{"id":"echo","name":"Echo","color":"#cd5628","feature_id":"echo-0","coordinates":[160.8618879884488,21.51860043560473]},"geometry":{"type":"MultiLineString","coordinates":[[[179.99994672169302,26.29386583975231],[173.6999511846689,24.669041914124236],[160.19996074818866,21.356164482330126],[151.1999671244645,17.82393441253792],[145.79997094927663,15.813887260619625],[143.88747230410857,13.965698203819535],[144.22497206442463,13.710817738179635],[144.69470173285575,13.464772962370143]]]}},{"type":"Feature","properties":{"id":"echo","name":"Echo","color":"#939597","feature_id":"echo-1","coordinates":[127.3711135694469,-4.619987344659122]},"geometry":{"type":"MultiLineString","coordinates":[[[143.88747230410857,13.965698203819535],[143.54997254319662,13.419186961310027],[137.24997700676838,9.52441134501949],[133.64997955704033,7.744889052551447],[133.19997987582445,7.29876275445952],[130.49998178793246,3.865649782482034],[129.59998242550049,1.618372199773176],[128.69998306306852,-1.981015190984684],[128.0249835412444,-2.880195580251407],[127.34998401942048,-4.676208028751072],[123.74998656969242,-6.467627592690688],[120.14998911996437,-6.467627592690688],[116.58310164737706,-4.852974874840906],[114.7499929459683,-4.825692499217419],[112.0499948586722,-4.152767748013638],[110.24999613321238,-3.479268678970064],[109.57499661138827,-3.029995968008661],[106.53749876377611,-0.331409329660265],[105.29999964043219,0.906050180869095],[104.8499999592161,1.187252773694101],[104.62500011860807,1.250557147797234],[104.28790035741287,1.299826156329162],[104.18975042694323,1.31015097035863],[103.98701057056589,1.389451396800233]],[[109.57499661138827,-3.029995968008661],[108.1124976480322,-4.60145376483711],[107.66249796681612,-5.273944363641298],[107.12099835041957,-5.981154260263285]],[[133.64997955704033,7.744889052551447],[134.09997923706462,7.521883237406507],[134.5609408257699,7.531746239289517]],[[144.65753175859138,13.385305518498077],[143.9999722250084,13.3827080361257],[143.54997254319662,13.419186961310027]]]}},{"type":"Feature","properties":{"id":"echo","name":"Echo","color":"#ce5728","feature_id":"echo-2","coordinates":[-152.8644210143957,36.507218716598956]},"geometry":{"type":"MultiLineString","coordinates":[[[-124.15955921950356,40.803253108521126],[-139.05018102509484,39.00237890905839],[-151.1998186532859,36.993119919861876],[-163.79980972733406,33.31515395812905],[-172.79980979028863,29.47523619496653],[-179.99980039712295,26.29386583975231]]]}},{"type":"Feature","properties":{"id":"jscfs","name":"JSCFS","color":"#3e60ac","feature_id":"jscfs-0","coordinates":[-77.84875084100726,17.982107130467057]},"geometry":{"type":"MultiLineString","coordinates":[[[-77.92138056441902,18.469357593227326],[-78.18737037598936,18.554264565049102],[-78.35612025644534,18.447578822532115],[-78.34826026201347,18.278671433780136],[-78.29987029629338,18.073658723981474],[-78.07487045568534,17.966677204124696],[-77.84837061613986,18.023694555394638],[-77.84987061507731,17.859630869537803],[-77.39987093326533,17.716802179008642],[-76.94987125204935,17.770376320836018],[-76.66665145328092,17.949944181323392]]]}},{"type":"Feature","properties":{"id":"confluence-1","name":"Confluence-1","color":"#939597","feature_id":"confluence-1-0","coordinates":[-76.76886657310834,33.088263944439525]},"geometry":{"type":"MultiLineString","coordinates":[[[-74.0628732978218,40.152905263922335],[-73.93747338606022,40.02845597599392],[-74.02487332354933,39.00237890905839],[-74.69987284596934,36.90321229501768],[-74.92487268717328,35.54192681258013],[-76.04987189021328,33.69038903934858],[-77.39987093386132,32.55982671166815],[-79.19986965812936,29.93125070442692],[-79.64986933934534,26.562513149236715],[-80.08893152830949,26.350585697437857]],[[-77.39987093386132,32.55982671166815],[-78.26653698657397,33.189714664600466],[-78.88266988343256,33.69355790837514]],[[-79.19986965812936,29.93125070442692],[-80.5498687017773,30.223305674181642],[-81.65572729397277,30.33203181846753]],[[-74.69987284596934,36.90321229501768],[-76.05919805488554,36.755008440642534]]]}},{"type":"Feature","properties":{"id":"kafos","name":"KAFOS","color":"#bc74b0","feature_id":"kafos-0","coordinates":[28.059768458575558,41.878465750291376]},"geometry":{"type":"MultiLineString","coordinates":[[[27.912854461615982,43.20837344983071],[28.35005415189955,43.182784815861005],[28.800053833115623,43.42838486264783],[28.800053833115623,43.67299239292199],[28.5827539870529,43.81718785722384]],[[28.988233700403125,41.04061756347715],[29.025053673723477,41.435845950249096],[28.35005415189955,41.85617959436374],[27.985354410256257,41.88417875345408],[28.23755423159562,42.19047067556428],[28.23755423159562,42.85377505385472],[28.35005415189955,43.182784815861005]]]}},{"type":"Feature","properties":{"id":"jakarta-bangka-batam-singapore-b2js","name":"Jakarta-Bangka-Batam-Singapore (B2JS)","color":"#31b34a","feature_id":"jakarta-bangka-batam-singapore-b2js-0","coordinates":[105.8468008350727,-1.8862984076575924]},"geometry":{"type":"MultiLineString","coordinates":[[[106.82782855810404,-6.171876390816321],[106.4249988434722,-5.273944363641298],[106.64999868408005,-4.60145376483711],[106.76249860378826,-3.479268678970064],[106.54745875672049,-3.079924874677993],[106.31249892257236,-2.580536704984131],[105.86249924135629,-1.906058394384765],[105.5827094401579,-1.553879793691628],[105.52499948104004,-1.231315750217412],[105.18749972012807,0.118588418888312],[104.8499999592161,0.345586247302374],[104.17500043739219,0.45608344784057],[103.72500075617612,0.793562652607196],[103.72500075617612,1.018534216615524],[103.87813064769749,1.134723598626614],[104.0166370000003,1.066798000000349],[103.92187561670808,1.185378176915766],[103.94648059927786,1.327258925921003]]]}},{"type":"Feature","properties":{"id":"maroc-telecom-west-africa","name":"Maroc Telecom West Africa","color":"#b27032","feature_id":"maroc-telecom-west-africa-0","coordinates":[-16.68894148387257,8.72531679447526]},"geometry":{"type":"MultiLineString","coordinates":[[[-7.631920358132023,33.60539511325584],[-8.999919389028726,33.31515395812905],[-12.1499171569448,30.126049846722832],[-13.022832188573513,28.161052262220792],[-14.399915563024809,26.964304734562898],[-16.199914288484827,23.848523186487938],[-16.64991396910482,22.884654113882444],[-17.99991301275286,19.95262290516439],[-17.999913013348852,16.534196198259725],[-17.99991301275286,11.735650161405832],[-16.64991396910482,8.635699417327467],[-15.299914926648759,7.744889052551447],[-14.399915564216792,6.852191098754328],[-12.1499171569448,5.061986954416114],[-10.799918113296759,3.715978119298069],[-6.299921301732732,3.715978119298069],[-3.712423134144677,3.266814816815666],[-0.899925126544665,3.279837005484997],[1.350073278939443,4.164912849976942],[2.25007264196731,4.0527020972683],[5.400070410479286,1.918228780215599],[7.200069135343221,0.793562652607196],[8.550068178991262,0.568578852526193],[9.454267538448212,0.394465191855477]],[[2.25007264196731,4.0527020972683],[2.25007264196731,5.061986954416114],[2.440112507341202,6.356673335458259]],[[1.350073278939443,4.164912849976942],[1.350073279535343,5.061986954416114],[1.227803366152544,6.126307297218732]],[[-3.712423134144677,3.266814816815666],[-4.026242911831877,5.323508791824841]],[[-16.199914288484827,23.848523186487938],[-16.08741436818081,23.745587983103654],[-15.934424476560302,23.72108197755363]]]}},{"type":"Feature","properties":{"id":"galapagos-cable-system","name":"Galapagos Cable System","color":"#939597","feature_id":"galapagos-cable-system-0","coordinates":[-85.17329128464071,-1.0813464460987963]},"geometry":{"type":"MultiLineString","coordinates":[[[-80.71613109211354,-0.949727245539768],[-81.44986806480536,-1.081346446098796],[-89.09986264547744,-1.081346446098796],[-89.61436228100114,-0.909172611722311]],[[-89.61436228100114,-0.909172611722311],[-89.88736208760548,-0.968864549257877],[-90.11236192821343,-0.91262217329094],[-90.31130405300458,-0.742410110897921],[-90.44986168912538,-0.91262217329094],[-90.67486152973343,-0.968864549257877],[-90.96211132624292,-0.961035665226681]]]}},{"type":"Feature","properties":{"id":"africa-1","name":"Africa-1","color":"#939597","feature_id":"africa-1-0","coordinates":[48.51132784674715,11.824040700245183]},"geometry":{"type":"MultiLineString","coordinates":[[[54.00003598359576,13.163718917913586],[55.350035024859835,13.92930384327183],[58.95003247518379,15.669513225155248],[60.97503104005986,16.749771315644697],[65.70002769282792,20.375041253465433],[66.26252729434792,23.298598065875897],[67.02854675169264,24.889731701235817]],[[45.45004203810783,11.073982781226615],[45.235802189877134,10.705441883254426],[45.01088234921274,10.43511874899288]],[[44.550042675675684,11.12918015324408],[43.65004331324372,11.489461709300535],[43.1479936689003,11.594869371447825]],[[39.6728961306921,-4.052924364763054],[42.30004427019158,-3.254657364797681],[45.225042198095515,-1.006358951224796],[47.70004044418784,1.468426767331968],[50.85003821329572,5.510071711803246],[53.55003630059162,9.52441134501949],[54.67503550363163,12.615395567393394],[54.00003598419185,13.163718917913586],[52.650036937563755,12.834868817846521],[48.60003980721571,11.84577637362577],[45.45004203810783,11.073982781226615],[44.550042675675684,11.12918015324408],[43.50941841286372,12.395734000022975],[43.25629359217961,12.615395567393394],[43.17191865195175,12.834868817846521],[42.89066885178765,13.054150695298627],[42.30004427019158,13.92930384327183],[41.79379462882354,14.801154224791581],[40.500045545329826,16.534196198259725],[39.487546262591565,18.251816319028222],[38.137547218943524,20.375041253465433],[37.35004777681549,22.05298561667754],[36.11254865347155,24.12261698700344],[35.100049370735476,25.75470426341523],[34.59379972877172,26.562513149236715],[33.91880020694761,27.364667993860262],[33.38487558519097,28.161052262220792],[33.08276564295245,28.365936333863583],[32.76239392030568,28.935360665915493],[32.26376314640125,29.08168448369012],[31.693903039329573,29.01833169208537],[31.051558993277148,29.238654773352504],[30.305753552092842,29.6569927221975],[29.894152676194512,30.192289804044524],[29.67235321517045,31.047641997876443]],[[32.28445136473581,31.25927814644905],[32.118801482083676,31.510798430049064],[31.275052079803668,31.798087367585257],[27.900054470683475,31.957307911004964],[25.200056383387572,32.7177179367584],[22.050058614875596,33.45605770170512],[19.35006052757951,33.37780603565933],[16.65006244028343,33.001218522654476],[14.400064034203421,33.846256070003854],[12.150065628130324,35.419780517080355],[11.925065787515376,35.78566189952622],[11.700065946907337,36.24065523321488],[11.36256618480339,37.23235432155614],[10.348617229173378,37.589786573603064],[9.000067859015536,37.589786573603064],[7.425068975355357,37.94551049545967],[6.750069453531427,38.651811712711336],[5.737570170795351,41.74435878948223],[5.372530429392986,43.29362778902908]],[[56.33993432360578,25.0514826710929],[56.92503390911573,24.711631506331194],[58.50003279396771,24.045587109255923],[59.850031837019856,23.40188413185254],[60.97503104005986,22.469443964829516],[60.97503104005986,16.749771315644697]],[[35.696548947573824,27.354010300438862],[35.10004937013957,27.097918575215974],[34.59379972936762,26.562513149236715]],[[33.08276564295245,28.365936333863583],[32.7320868096055,28.91576488954453],[32.27205088751434,29.03186417191496],[31.69565233922443,28.932934895626012],[31.027923586734694,29.1588987021242],[30.25379870692613,29.588964619793366],[29.80260641382583,30.14927191491723],[29.67235321517045,31.047641997876443]],[[7.425068975355357,37.94551049545967],[5.625070250491423,37.35168786972502],[5.055680653852241,36.75152814511764]],[[42.95452380595619,14.797809010241023],[42.75004395140765,14.637942589496117],[41.79379462882354,14.801154224791581]]]}},{"type":"Feature","properties":{"id":"natitua-sud","name":"Natitua Sud","color":"#56c5cd","feature_id":"natitua-sud-0","coordinates":[-148.949820247206,-20.460174471434488]},"geometry":{"type":"MultiLineString","coordinates":[[[-149.4859198674279,-23.34692100073201],[-149.17482008781394,-22.42353053825288],[-148.949820247206,-20.85502445095989],[-148.949820247206,-18.097730413625836],[-149.0623201675099,-17.776635424587234],[-149.3081207740364,-17.723342219804366]],[[-149.17482008781394,-22.42353053825288],[-150.74981897207002,-22.42353053825288],[-151.34291855191262,-22.451259432002136]]]}},{"type":"Feature","properties":{"id":"cogim","name":"COGIM","color":"#2da348","feature_id":"cogim-0","coordinates":[-63.098710670710354,47.999570815181464]},"geometry":{"type":"MultiLineString","coordinates":[[[-64.30898020757121,48.47179524319343],[-63.899880497381176,48.20683973227639],[-62.54988145373323,47.60352623014696],[-61.95568187466976,47.382579621269414]],[[-64.30898020757121,48.47179524319343],[-63.899880497381176,48.35656994073399],[-62.54988145373323,47.75501398838149],[-61.95568187466976,47.382579621269414]]]}},{"type":"Feature","properties":{"id":"au-aleutian","name":"AU-Aleutian","color":"#939597","feature_id":"au-aleutian-0","coordinates":[-163.1289861938159,54.7882234991309]},"geometry":{"type":"MultiLineString","coordinates":[[[-162.44981068368602,55.013438622906556],[-162.56231060399006,55.07789244004115],[-162.70401050360857,55.20414950086254]],[[-162.30441078668872,55.04920181729871],[-162.44981068368602,55.013438622906556]],[[-158.77211328900137,56.254983508649424],[-158.62481339335005,56.28291483663352],[-158.50721347665893,56.32136337173197],[-158.4088135463664,56.29451450143274]],[[-158.8498132339581,55.58979765418965],[-158.962313154262,55.7800604287217],[-159.14481302497742,55.917485764080254]],[[-162.899810364902,54.62453363273441],[-163.01231028520604,54.75458482702214],[-163.23731012581416,54.819454224251736],[-163.41500999992982,54.85095757670635]],[[-152.887317457846,57.93214153471666],[-152.88193814671197,57.869443262101676]]]}},{"type":"Feature","properties":{"id":"au-aleutian","name":"AU-Aleutian","color":"#2d56a6","feature_id":"au-aleutian-1","coordinates":[-159.80395885589576,55.39091486944688]},"geometry":{"type":"MultiLineString","coordinates":[[[-152.3951178065248,57.79442233367651],[-152.38106851418576,57.872362884096376],[-152.54981769693393,57.9022645808777],[-152.887317457846,57.93214153471666],[-152.99981737815003,57.961993642608505],[-153.1685679364199,57.99182080194843],[-153.449817059366,58.021623140679736],[-153.899816740582,57.99182080194843],[-154.12481658119003,57.81248509700269],[-153.91231673172686,57.66903988014617],[-153.98601667951715,57.537318647427895]],[[-158.4088135463664,56.29451450143274],[-158.17481371213407,56.345311717086744],[-158.04981380068514,56.27944535366361],[-157.95606442397002,56.0672110054515],[-158.399813552742,55.71674234179641],[-158.8498132339581,55.58979765418965],[-159.29981291517407,55.462441022744024],[-159.74981259639003,55.39860800738421],[-160.19981227760601,55.33467174196283],[-160.4963120675628,55.34155340050567],[-160.649811958822,55.43053741585332],[-160.87481179943003,55.39860800738421],[-161.0998116400381,55.33467174196283],[-161.32481148064602,55.3026648631947],[-161.54981132125405,55.3026648631947],[-161.774811161862,55.23857355917981],[-161.99981100247004,55.07789244004115],[-162.11231092277407,55.013438622906556],[-162.30441078668872,55.04920181729871]],[[-153.98601667951715,57.537318647427895],[-153.96856734948312,57.66903988014617],[-154.34981642179795,57.75250737948315],[-154.79981610301394,57.69243021067551],[-155.47481562483796,57.32986760241196],[-156.82481466848603,56.46979987684146],[-157.49981419031005,56.40760669952748],[-157.949813871526,56.41452207827388],[-158.4088135463664,56.29451450143274]],[[-166.53325505656073,53.884514690832766],[-166.49980781463006,54.034132352888065],[-166.2748079740221,54.29757945174354],[-165.93730821311007,54.36317932422044],[-165.5998084521981,54.42867459701121],[-165.14980877098202,54.29757945174354],[-164.24980940855005,54.29757945174354],[-163.34981004611808,54.42867459701121],[-162.899810364902,54.62453363273441],[-162.56231060399006,54.819454224251736],[-162.44981068368602,54.981172806674145],[-162.30441078668872,55.04920181729871]],[[-165.93730821311007,54.36317932422044],[-165.82480829280604,54.29757945174354],[-165.77305051697178,54.135639094836016]],[[-152.54981769693393,57.9022645808777],[-152.5017384256537,57.925201701620985]]]}},{"type":"Feature","properties":{"id":"sealink","name":"SEALink","color":"#b08c34","feature_id":"sealink-0","coordinates":[-133.5728855655221,57.16786004279634]},"geometry":{"type":"MultiLineString","coordinates":[[[-132.82953166696961,56.01522795549929],[-132.9112827986132,56.12703903462036],[-132.89728280888457,56.19050811225174],[-132.7297129312297,56.26414244600623],[-132.68226177043357,56.29090165693316],[-132.64808299171534,56.35047071868664],[-132.64808299171534,56.381643336006654],[-132.64808299171534,56.47504144368907],[-132.6481329902577,56.537179473172735],[-132.70453414081942,56.5991056130363],[-132.78895907958972,56.661040335003094],[-132.83120037858563,56.722763716398205],[-132.969852755046,56.807791226083026],[-132.84488284732893,56.91336556277725],[-132.95198276875277,57.02097743143672],[-133.23294256202445,57.081745867261986],[-133.51633117983843,57.12497316683114],[-133.573282312923,57.16816090817339],[-133.60188111983013,57.22966820564022],[-133.57424231079764,57.31396150003885],[-133.60852228564744,57.37303699157905],[-133.63518109624007,57.558771470979615],[-133.74631218455488,57.707894171078024],[-133.7684821697104,57.82371236991844],[-133.93518088371746,57.98640326883048],[-134.06858194953577,58.101249458975765],[-134.19278185841375,58.165130914395746],[-134.29583062763373,58.2074722872719],[-134.5799004335419,58.24259543893794],[-134.65548037809106,58.291672685647676],[-134.78693028165,58.41219086765235],[-134.782919144335,58.4981259553756],[-134.7472914509898,58.551049268231914]]]}},{"type":"Feature","properties":{"id":"colombian-festoon","name":"Colombian Festoon","color":"#34b44a","feature_id":"colombian-festoon-0","coordinates":[-75.57206688193693,10.345771350329175]},"geometry":{"type":"MultiLineString","coordinates":[[[-74.95280235488768,11.00516315415223],[-75.14987252778131,11.073982781226615],[-75.26237244808533,10.963556857789316],[-75.50573165009133,10.386791448721706],[-75.82487204960533,10.189442766507625],[-75.82487205020132,9.894039027618803],[-75.55866223759485,9.49638197998378]],[[-74.20529311880517,11.241938042153873],[-74.36237308565327,11.33148066218366],[-74.47487300595729,11.221152507138276],[-74.61193345573741,11.012160871628799]]]}},{"type":"Feature","properties":{"id":"iris","name":"IRIS","color":"#94479b","feature_id":"iris-0","coordinates":[-14.888230240416295,59.70271872991365]},"geometry":{"type":"MultiLineString","coordinates":[[[-9.048319354741794,53.273886331643226],[-9.899918751460783,53.05365242709034],[-10.799918113892751,53.25603519350496],[-11.249917795108734,53.52439675040044],[-12.599916838160784,54.94878902385559],[-12.599916838756776,55.67438282806264],[-12.374916998148738,56.4282604688234],[-12.599916838756776,58.83627867169679],[-13.949915882404726,59.52787100202234],[-21.14991078186083,60.8695351663294],[-21.82491030368485,62.15710017959811],[-22.724909666116908,62.986189024595745],[-22.27490998490083,63.492719682582305],[-21.37812062019483,63.856257493182085]]]}},{"type":"Feature","properties":{"id":"amitie","name":"Amitie","color":"#4bb748","feature_id":"amitie-0","coordinates":[-35.6110060180745,47.22990849577579]},"geometry":{"type":"MultiLineString","coordinates":[[[-70.95027550281526,42.46364601310954],[-69.29987667137726,42.41235450073586],[-61.19988240948919,41.74435878948223],[-50.39989006030513,43.564400497117596],[-39.99999508453427,46.17414676370763],[-23.399909187344846,50.167261162927154],[-16.199914287888834,50.167261162927154],[-10.799918113296759,49.44120372312804],[-5.849921620516659,46.99317357497891],[-3.149923533220666,45.1197757996118],[-1.211824906187882,44.89381187034425]],[[-10.799918113296759,49.44120372312804],[-7.199920663568708,50.88245364291024],[-5.399921938704683,51.02419288763878],[-4.544402544762735,50.82820142743812]]]}},{"type":"Feature","properties":{"id":"gulf-of-mexico-fiber-optic-network","name":"Gulf of Mexico Fiber Optic Network","color":"#b09330","feature_id":"gulf-of-mexico-fiber-optic-network-0","coordinates":[-91.16801373144628,28.055198789470996]},"geometry":{"type":"MultiLineString","coordinates":[[[-95.34435822181815,28.94954214664031],[-94.04985913885344,28.094910059657696],[-91.79986073277352,27.896238989528694],[-88.64986296426146,28.68871408880043],[-88.19986328304547,29.47523619496653],[-88.55616303063934,30.365773710175556]]]}},{"type":"Feature","properties":{"id":"havsil","name":"Havsil","color":"#2b2f83","feature_id":"havsil-0","coordinates":[8.401890178570817,57.66162794333638]},"geometry":{"type":"MultiLineString","coordinates":[[[8.616168131569658,57.1116502612406],[8.662568098699472,57.28927362171987],[8.212568416291413,57.932056586951404],[7.996258571315225,58.15106571642484]]]}},{"type":"Feature","properties":{"id":"grace-hopper","name":"Grace Hopper","color":"#28b08c","feature_id":"grace-hopper-0","coordinates":[-38.34293272068773,42.07147147019439]},"geometry":{"type":"MultiLineString","coordinates":[[[-72.93786409419282,40.75584308487114],[-71.09987539624129,39.78482855593699],[-68.39987730894529,39.524987333511675],[-61.19988240948919,38.651811712711336],[-50.39989006030513,39.00237890905839],[-39.59989771112098,41.74435878948223],[-23.399909187344846,45.96024524125342],[-16.199914287888834,46.272182853813646],[-10.799918113296759,49.29468421942562],[-8.099920026000767,49.73293362369082],[-4.544402544762735,50.82820142743812]],[[-16.199914287888834,46.272182853813646],[-9.899918750864702,46.890762878622326],[-5.849921619920758,45.96024524125342],[-4.27492273626067,44.694829089578164],[-2.949193674823563,43.274220252000646]]]}},{"type":"Feature","properties":{"id":"asia-direct-cable-adc","name":"Asia Direct Cable (ADC)","color":"#ed1b2c","feature_id":"asia-direct-cable-adc-0","coordinates":[120.88988111749366,20.05833455139623]},"geometry":{"type":"MultiLineString","coordinates":[[[139.97546699999984,35.005433000000174],[140.1749749340766,34.89859296336222],[140.3437248145325,34.69072647741027],[140.51247469558447,34.405022750715936],[140.84997445649643,33.93964008831966],[140.84997445649643,32.43331330641721],[140.39997477528055,30.901396088515508],[138.82497589102445,28.161052262220792],[137.24997700676838,26.562513149236715],[132.74998019460836,23.50508968095737],[130.94998146974444,22.677206196582915],[125.99998497636834,21.111485983488812],[122.84998720785636,20.269544035929588],[121.04998848299225,20.05833455139623],[120.14998912056028,20.05833455139623],[116.99999135204831,19.316876111628712],[115.19999262718419,18.251816319028222],[114.29999326356042,14.801154224791581],[113.17499406171221,12.615395567393394],[112.16249477897614,9.967915186974132],[110.92499565563222,7.744889052551447],[107.99999772772827,5.398081130463647],[105.74999932164808,4.389285926050993],[104.68125007876004,2.817450442654169],[104.3161753373827,1.468426767331968],[104.20615041532531,1.341894944206377],[103.91973061822782,1.228443589211858],[103.64609081207688,1.338585852071497]],[[107.99999772772827,5.398081130463647],[105.29999964043219,6.405200795356032],[103.27500107496003,7.744889052551447],[100.80000282767627,9.52441134501949],[100.12500330585216,11.294709319565477],[100.23750322675217,12.175887185507976],[100.57500298647233,12.834868817846521],[100.93057273577533,13.174371211662239]],[[113.17499406171221,12.615395567393394],[112.94999422110418,12.834868817846521],[111.59999517745614,13.492128176464083],[110.02499629320025,13.820086409698062],[109.21959686375268,13.782910441432074]],[[115.19999262718419,18.251816319028222],[114.52499310536027,20.796306105108872],[114.20292333351767,22.22205041973683]],[[116.99999135204831,19.316876111628712],[117.22499119206026,19.95262290516439],[117.22499119206026,20.796306105108872],[116.99999135204831,22.469443964829516],[116.67753158048176,23.355006811273547]],[[114.29999326356042,14.801154224791581],[116.99999135204831,13.710817738179635],[118.79999007691224,13.492128176464083],[120.14998912056028,13.492128176464083],[121.06600847164388,13.762418337904428]]]}},{"type":"Feature","properties":{"id":"eaufon-3","name":"EAUFON 3","color":"#939597","feature_id":"eaufon-3-0","coordinates":[-68.97503762005128,60.34035186226439]},"geometry":{"type":"MultiLineString","coordinates":[[[-71.95388901309511,61.59626],[-71.99987475926925,61.62709041825654],[-71.99987475926925,61.73382762714022],[-71.7748749186613,61.78705797378999],[-71.32487523744531,61.73382762714022],[-70.64987571562129,61.51998376783514],[-69.29987667197325,61.304658531911585],[-68.84987699075727,61.08784471959974],[-68.84987699075727,60.64972274466829],[-69.07487683136522,60.093570225923045],[-69.03737685793054,59.51719275174019],[-68.39987730954128,59.29889402722166],[-67.94987762832521,58.95251729542412],[-66.59987858467726,59.06836550247494],[-66.14987890346119,58.95251729542412],[-65.99511901309455,58.710155]],[[-67.94987762832521,58.95251729542412],[-68.17487746893325,58.543968564154575],[-68.17487746893325,58.42635692511553],[-68.28737738923726,58.24920018303555],[-68.41883729610984,58.102996]],[[-68.39987730954128,59.29889402722166],[-69.07487683136522,58.95251729542412],[-69.41237659227727,58.89444683796138],[-69.52487651258129,58.80715791514554],[-69.6373764328853,58.77801269228043],[-69.93739622034846,58.697407]],[[-69.03737685793054,59.51719275174019],[-69.48737653914662,59.40286331928215],[-69.59748899999985,59.30574]],[[-69.07487683136522,60.093570225923045],[-69.52487651258129,60.009326600079234],[-69.86237627349325,60.009326600079234],[-70.02623500000018,60.023415]],[[-69.29987667197325,61.304658531911585],[-69.6373764328853,61.14218811914619],[-69.65026704875343,61.03999773543458]]]}},{"type":"Feature","properties":{"id":"eaufon-2","name":"EAUFON 2","color":"#c11a7d","feature_id":"eaufon-2-0","coordinates":[-78.5378171210829,62.76322167039172]},"geometry":{"type":"MultiLineString","coordinates":[[[-78.52487013690133,59.755303236949786],[-78.97486981811731,60.64972274466829],[-78.97486981811731,61.08784471959974],[-78.74986997750936,61.51998376783514],[-78.74986997750936,62.47071999993706],[-78.52487013690133,62.78108049372607],[-77.39987093386132,62.986189024595745],[-76.04987189021328,62.78108049372607],[-75.6468521757162,62.2012833728858]],[[-75.6468521757162,62.2012833728858],[-75.48733080434768,62.39551416272755],[-74.81233128252366,62.34334021969972],[-74.6308190654326,62.11332960460878]],[[-78.97486981811731,61.08784471959974],[-78.52487013690133,60.97887735694231],[-78.14424040654293,60.81768303986588]],[[-78.74986997750936,62.47071999993706],[-78.29987029629338,62.47071999993706],[-77.94346054877738,62.41502369498159]]]}},{"type":"Feature","properties":{"id":"eaufon-1","name":"EAUFON 1","color":"#5b6cb3","feature_id":"eaufon-1-0","coordinates":[-77.22173960190058,56.73124145046774]},"geometry":{"type":"MultiLineString","coordinates":[[[-77.26920696392492,60.03711467356478],[-78.52487013690133,59.755303236949786],[-79.19986965872535,59.06836550247494],[-79.19986965872535,58.6026268350673],[-78.74986997750936,58.13060164022799],[-77.39987093386132,57.41066230395049],[-77.17487109325337,56.55247760361361],[-77.39987093386132,56.178604166899405],[-77.84987061507731,55.927313564321764],[-78.29987029629338,55.67438282806264],[-79.19986965872535,55.41980621446019],[-80.09986902115732,54.90569284432047],[-80.09986902115732,54.3849293997443],[-79.64986933994133,53.857473440304716],[-78.89347339140434,53.77361440535734]],[[-77.17487109325337,56.55247760361361],[-76.7248714120373,56.55247760361325],[-76.54713716294596,56.55156255991272]],[[-78.74986997750936,58.13060164022799],[-78.29987029629338,58.36740335134618],[-78.10507043429136,58.455132585224064]],[[-78.29987029629338,55.67438282806264],[-78.07487045568534,55.41980621446019],[-77.76378669168498,55.27457419138992]]]}},{"type":"Feature","properties":{"id":"2africa","name":"2Africa","color":"#939597","feature_id":"2africa-0","coordinates":[60.525031359439865,18.27768420760363]},"geometry":{"type":"MultiLineString","coordinates":[[[44.55004267627159,11.23954347159687],[43.31254355292765,12.615395567393394],[43.21410612266167,12.834868817846521],[42.975043792015505,13.054150695298627],[42.35629423034354,13.92930384327183],[41.8500445889755,14.801154224791581],[40.61254546563375,16.534196198259725],[39.60004618289549,18.251816319028222],[38.250047139247634,20.375041253465433]],[[54.00003598419185,13.273238157547594],[54.11253590389969,13.601498202276586],[54.28861598981067,14.58954800244684],[54.28861598981067,16.58202090261546],[54.14808587692784,17.09582718672565]],[[44.55004267627159,11.23954347159687],[43.65004331383962,11.455111997387936],[43.14799366949638,11.594869371447825]],[[38.250047139247634,20.375041253465433],[37.58923757400337,19.919043448151303],[37.21967786917097,19.61556659454616]],[[48.5317798555716,29.92363278689715],[49.16253940873571,28.75448641587171],[50.175038691471606,28.06182365971013],[50.62503837268768,27.364667993860262],[51.30003789451161,26.964304734562898],[52.20003725694376,26.562513149236715],[53.55003630059162,26.260240971577822],[55.35003502545574,26.260240971577822],[55.80003470667163,26.36108632539156],[56.2500343878877,26.713351447732887],[56.86878394955967,26.562513149236715],[57.03753383001575,26.1593079707739],[57.03753383001575,25.348717422116714],[56.33993432360578,25.0514826710929]],[[49.16253940873571,28.75448641587171],[47.974840250113054,29.37410420420039]],[[50.62503837268768,27.364667993860262],[50.287538611775716,26.964304734562898],[50.214198663730556,26.28537535931817]],[[52.20003725694376,26.562513149236715],[51.862537496031614,26.05828756029904],[51.519277739200085,25.294608758024626]],[[51.30003789451161,26.964304734562898],[50.962538133599644,26.562513149236715],[50.57601840741415,26.229494838391265]],[[57.03753383001575,25.348717422116714],[57.38011358673322,24.420846844473278],[57.712533351839674,24.12261698700344],[58.50003279396771,23.89138876125301],[59.85003183761575,23.19523175611711],[60.525031359439865,22.469443964829516],[60.525031359439865,19.104405475930452],[60.525031359439865,16.10232559580297],[58.95003247399199,15.23578178303578],[55.35003502366785,13.492128176464083],[54.90003534423966,12.615395567393394]],[[58.50003279396771,23.89138876125301],[58.162533033055745,23.865671119262494],[57.88605322832058,23.67872342575357]],[[60.525031359439865,22.469443964829516],[65.25002801161183,24.259444485784776],[67.02854675228855,24.889731701235817]],[[60.525031359439865,16.10232559580297],[60.975031038867876,16.318380026359527],[66.60002705585578,18.251816319028222],[70.20002450558383,18.785187974742005],[72.87590260996693,19.07607425728523]],[[55.35003502545574,26.260240971577822],[54.90003534423966,25.75470426341523],[54.45003566183197,24.83931282559271],[54.419075684956134,24.443964572625426]]]}},{"type":"Feature","properties":{"id":"2africa","name":"2Africa","color":"#b5258f","feature_id":"2africa-1","coordinates":[-21.144285882711586,16.45536331058273]},"geometry":{"type":"MultiLineString","coordinates":[[[3.825071526223208,4.164912849976942],[5.400070410479286,2.367912558705407],[7.200069135343221,1.243490076978041],[8.550068178991262,1.018534216615524],[9.454267538448212,0.394465191855477]],[[-16.199914287888834,39.6983233549332],[-13.949915882404726,38.76885922455357],[-10.799918113296759,38.475881348138756],[-9.337919149586595,38.68882888776792]],[[-21.140924849904593,14.365653759228442],[-18.449912693968844,14.365653759228442],[-17.445713405352947,14.686594841994992]],[[-3.599923213840749,-2.580536704984131],[-4.374922664823806,1.468426767331968],[-4.499922576272716,2.367912558705407],[-4.274922735664679,3.266814816815666],[-4.026242911831877,5.323508791824841]],[[-0.204315619320974,5.558285889905858],[0.450073917103194,3.279837005484997],[0.450073917103194,-0.631398185258107]],[[3.423511810692114,6.439066911484626],[3.825071526223208,4.164912849976942],[2.25007264196731,1.131014326431719],[1.575073120143199,0.34358628488916],[0.450073917103194,-0.631398185258107],[-3.599923213840749,-2.580536704984131],[-10.799918113296759,-2.580536704984131],[-15.299914925456777,1.918228780215599],[-19.799911737616885,8.635699417327467],[-21.149910781264836,11.735650161405832],[-21.140924849904593,14.365653759228442],[-21.149910781264836,19.95262290516439],[-20.299911384604414,22.884654113882444],[-18.67491253517278,31.286738814391754],[-17.0999136503208,35.05222991093673],[-16.246065123519113,39.467127190190496],[-16.199914287888834,39.6983233549332],[-16.199914288484827,44.694829089578164],[-11.249917795108734,48.70423463096067],[-7.199920663568708,50.740281893948165],[-5.399921938704683,50.95337730033451],[-4.544402544762735,50.82820142743812]],[[-3.599923213840749,-2.580536704984131],[0.00007423529131,-6.467627592690688],[1.800072960155335,-11.356308769000893],[6.750069454127329,-18.026426383713453],[8.10006849777537,-23.49392244589784],[13.05006499115128,-30.30995334464681],[15.30006339723147,-32.61276000573574],[16.65006244087933,-33.74264465652956],[17.55006180331148,-33.929536992458964],[18.15553137439108,-33.348058456467676]],[[0.00007423529131,-6.467627592690688],[10.80006658447537,-5.124561675456293],[11.863635831629022,-4.77878776891936]],[[10.80006658447537,-5.124561675456293],[11.250066265691444,-5.572600905016464],[12.349965487108514,-5.933373731471328]],[[27.000055108251505,-34.73466151270862],[31.500051920411707,-31.340410556277746],[32.40005128343957,-29.8231438437626]],[[18.44992116524676,-33.69332014378617],[17.775061643323433,-34.11602012163193],[17.32506196210754,-34.85783936223576],[18.00006148393147,-36.3215277599179],[19.80006020939149,-36.68325067019043],[23.40005765911936,-36.68325067019043],[26.10005574581954,-35.83660803749216],[27.000055108251505,-34.73466151270862]],[[32.65318110412008,29.113614162980063],[32.62520112394137,29.344566989489813],[32.45622624364466,29.63833609362628],[32.52993119143143,29.972545436050364],[32.85005096405957,30.255702942039875],[32.73755104375564,30.837020582397155],[32.28445136473581,31.25927814644905]],[[37.4625476971196,22.05298561667754],[36.22504857377548,24.12261698700344],[35.212549291039586,25.75470426341523],[34.67817466959547,26.562513149236715],[33.918800207543505,27.364667993860262],[33.412950565897326,28.161052262220792],[32.906450924701375,28.95155473219332],[32.65318110412008,29.113614162980063],[32.56895616318991,29.344566989489813],[32.399981282893194,29.63833609362628],[32.52993119143143,29.972545436050364],[32.73755104375564,30.255702942039875],[32.62505112345171,30.837020582397155],[32.28445136473581,31.25927814644905]],[[38.252175044777196,20.36041343679164],[37.4625476971196,22.05298561667754]],[[41.17504506655567,-4.900422453147402],[42.30004427019158,-3.479268678970064],[45.450042038703735,-1.231315750217412],[48.60003980721571,1.468426767331968],[51.75003757632377,5.510071711803246],[54.00003598180769,9.52441134501949],[54.90003534423966,12.615395567393394],[54.00003598419185,13.273238157547594],[52.65003693815965,12.944533868662969],[48.60003980781179,11.955858207114732],[45.450042038703735,11.184367066436712],[44.55004267627159,11.23954347159687]],[[30.88039235938437,-30.05771707645661],[32.40005128343957,-29.8231438437626],[32.850050964655466,-29.431979622206125],[33.750050327087614,-28.348517239947288],[35.32504921134351,-26.551620801657084],[41.17504506715157,-23.287413403488653],[42.30004427019158,-20.574419057276128],[42.30004427019158,-15.224032284647373],[42.30004426959567,-14.861883917661954],[41.8500445889755,-11.943944931746815],[41.40004490775961,-9.29042430103552],[40.387545625023535,-6.169450529574503],[40.95004522654354,-5.273944363641298],[41.17504506655567,-4.900422453147402]],[[35.32504921134351,-26.551620801657084],[33.750050327087614,-26.350174904573713],[32.58062115552212,-25.968268155407962]],[[42.30004427019158,-15.224032284647373],[41.40004490716371,-14.64430197712377],[40.68539697592696,-14.565582936090415]],[[42.30004426959567,-14.861883917661954],[45.00004235689176,-15.29638776062193],[46.315441425050686,-15.713729798684179]],[[39.269676416932725,-6.823132108349236],[39.82504602290763,-6.467627592690688],[40.387545625023535,-6.169450529574503]],[[40.95004522654354,-5.273944363641298],[40.50004554473174,-4.676208028751072],[39.67289769319088,-4.053024114605376]],[[41.17504506655567,-4.900422453147402],[40.7250453853396,-4.676208028751072],[39.7450660795661,-3.946125186873873]],[[48.60003980721571,1.468426767331968],[46.80004108235159,1.918228780215599],[45.344182113695986,2.041205223228781]],[[36.22504857377548,24.12261698700344],[36.90004809500369,24.259444485784776],[37.80004745803156,24.225251377401914],[38.10697724059967,24.070648010417838]],[[33.412950565897326,28.161052262220792],[33.30005064527564,28.29321405801615],[33.08276564295245,28.365936333863583]],[[32.28445136473581,31.25927814644905],[32.175051442235635,31.510798430049064],[31.72505176101956,31.798087367585257],[30.60005255857546,32.243210016262736],[28.800053833711523,32.90681902852468],[25.200056383983473,34.265677526524286],[23.400057658523455,34.71384862261538],[22.050058614875596,34.99080971857576],[19.350060528175415,35.419780517080355],[16.65006244087933,35.05222991093673],[14.400064034799321,35.328049064490706],[13.162564911455389,35.96797434759339],[12.487565389631278,36.87321951208928],[11.587566027795393,37.85673997565852],[11.250066265691444,39.6983233549332],[10.350066903259478,41.52013202089327],[9.787567301739475,42.41235450073586],[9.562567461727339,43.073310783003215],[9.450067540827328,43.401144973153954],[9.112567779915365,44.05151922873524],[8.938867903561944,44.41035752885385]],[[8.938867903561944,44.41035752885385],[8.775068019003399,44.05151922873524],[8.325068338383225,43.72721479104982],[7.200069135343221,43.073310783003215],[6.412569692619463,42.743713464436695],[5.962570011403388,42.743713464436695],[5.175070569275348,42.85377505385472]],[[5.372530429989069,43.29362778902908],[5.175070569275348,42.85377505385472],[4.050071366235344,41.85617959436374],[2.700072322587302,41.35145028689676],[2.168725042748786,41.38560270176812]],[[27.000055108251505,-34.73466151270862],[25.875055905211504,-34.36402589452668],[25.6232560835889,-33.9624706667599]],[[1.800072960155335,-11.356308769000893],[11.700065947503239,-8.846050186819125],[12.600065309935387,-8.830631937053012],[13.235024860124117,-8.812561807472813]],[[5.400070410479286,2.367912558705407],[7.200069135343221,3.154491498099848],[7.650068816559295,3.491423322320592],[7.999287318573677,4.541590692825097]],[[55.45302495190069,-4.565748350533772],[55.350035024859835,-3.92832730414264],[54.900035343643765,-1.081346446098796],[54.000035981211795,1.168506749040978],[51.75003757632377,5.510071711803246]],[[-18.67491253517278,31.286738814391754],[-16.64991396970081,30.255702942039875],[-15.862414526976778,28.95155473219332],[-15.596564715903398,27.957733682885266]],[[41.8500445889755,-11.943944931746815],[42.07504442958354,-12.053986862571566],[42.75004395140765,-12.053986862571566],[43.24330360197787,-11.700589282272533]],[[37.4625476971196,22.05298561667754],[39.18275647850768,21.481533475502996]],[[34.67817466959547,26.562513149236715],[35.21254929044368,27.097918575215974],[35.696548947573824,27.354010300438862]],[[45.450042038703735,11.184367066436712],[45.17955222972517,10.705441883254426],[45.01088234980864,10.43511874899288]],[[25.200056383983473,34.265677526524286],[24.943931564828763,34.867831005273345],[24.7684,35.07162999999965]]]}},{"type":"Feature","properties":{"id":"sir-abu-nuayr-cable","name":"Sir Abu Nu’ayr Cable","color":"#9f54a0","feature_id":"sir-abu-nuayr-cable-0","coordinates":[54.79394747078389,25.50678054735888]},"geometry":{"type":"MultiLineString","coordinates":[[[55.39223499496503,25.352332240929165],[55.06878522469574,25.55188275942587],[54.45003566183197,25.450342946923914],[54.221135824582554,25.240855834799852]]]}},{"type":"Feature","properties":{"id":"senegal-horn-of-africa-regional-express-share-cable","name":"Senegal Horn of Africa Regional Express (SHARE) Cable","color":"#c43292","feature_id":"senegal-horn-of-africa-regional-express-share-cable-0","coordinates":[-20.508343703945016,14.5835116451186]},"geometry":{"type":"MultiLineString","coordinates":[[[-17.445713405352947,14.686594841994992],[-18.449912693968844,14.5835116451186],[-22.499909824912876,14.5835116451186],[-23.521209101414858,14.923035560171673]]]}},{"type":"Feature","properties":{"id":"gondwana-2picot-2","name":"Gondwana-2/Picot-2","color":"#d41f26","feature_id":"gondwana-2picot-2-0","coordinates":[172.09451180875496,-20.012932052485873]},"geometry":{"type":"MultiLineString","coordinates":[[[178.43744782917764,-18.123810943537187],[178.0874480765248,-18.66711083815884],[167.83395603859978,-20.96971618702363],[167.39995564764476,-21.484467259778356],[167.17495580703672,-22.319497901243935],[166.94995596642866,-22.527485286666927],[166.61245620492082,-22.45819078937813],[166.4392563288091,-22.303308064620133]],[[166.61245620492082,-22.45819078937813],[166.61245620492082,-22.394640384279256],[166.56362811510715,-22.26537024294595]],[[166.94995596642866,-22.527485286666927],[167.17495580703672,-22.73515998441261],[167.49425558084164,-22.671917357180646]],[[167.83395603859978,-20.96971618702363],[167.60895480118282,-20.86464932352463],[167.26050262143457,-20.937043135848924]],[[167.39995564764476,-21.484467259778356],[167.6249554882528,-21.589112646479006],[167.87954515164915,-21.548062859078772]],[[167.17495580703672,-22.319497901243935],[167.11970584617637,-22.215387660513336],[166.94507315738784,-22.15896231236258]]]}},{"type":"Feature","properties":{"id":"java-kalimantan-sulawesi-jakasusi","name":"Java-Kalimantan-Sulawesi (JAKASUSI)","color":"#30a7df","feature_id":"java-kalimantan-sulawesi-jakasusi-0","coordinates":[114.81725815914595,-4.37814319767695]},"geometry":{"type":"MultiLineString","coordinates":[[[112.71972094673198,-7.271863121195997],[112.78124434005238,-6.616650693475355],[112.83749430020436,-6.393099497823911],[113.96249350384026,-4.825692499217419],[114.1874933444483,-4.37714437553184],[114.66674769244001,-3.882827780552574],[114.52499310476436,-4.252498775658176],[114.7499929459683,-4.37714437553184],[116.58310164737706,-4.404364233745155],[118.79999007691224,-5.049857167366764],[119.38468966210971,-5.343245784035753]]]}},{"type":"Feature","properties":{"id":"ixchel","name":"Ixchel","color":"#6ac5ae","feature_id":"ixchel-0","coordinates":[-87.00221007388254,20.505433864493895]},"geometry":{"type":"MultiLineString","coordinates":[[[-87.07026408326409,20.62971801801737],[-87.01848911994192,20.515592009193874],[-86.87916421864098,20.42865309996276]]]}},{"type":"Feature","properties":{"id":"dos-continentes-l-ll","name":"DOS CONTINENTES l & ll","color":"#31b2ac","feature_id":"dos-continentes-l-ll-0","coordinates":[-5.317170136013667,35.89240754440477]},"geometry":{"type":"MultiLineString","coordinates":[[[-5.604462809431057,36.014276783199975],[-5.399921938704683,35.96797434759339],[-5.334736438007709,35.89388140994366],[-5.323321992968752,35.88983711718341],[-5.313642702950791,35.89388140994366],[-5.287422018400756,36.05897312258681],[-5.325166132287443,36.210325320952975]]]}},{"type":"Feature","properties":{"id":"crosschannel-fibre","name":"CrossChannel Fibre","color":"#547cbe","feature_id":"crosschannel-fibre-0","coordinates":[0.258988428209393,50.266982125134184]},"geometry":{"type":"MultiLineString","coordinates":[[[-0.134425668831707,50.828479351442525],[0.000074235887302,50.59767719905356],[0.450073917103194,50.022920456254944],[0.797773670193642,49.877306991163124]]]}},{"type":"Feature","properties":{"id":"x-link-submarine-cable","name":"X-Link Submarine Cable","color":"#bb3726","feature_id":"x-link-submarine-cable-0","coordinates":[-58.42683684776049,10.006678144787097]},"geometry":{"type":"MultiLineString","coordinates":[[[-58.154864566608346,6.804293299288684],[-57.9373847206732,7.29876275445952],[-58.4998843221932,10.41081650540272],[-59.174883844017224,12.615395567393394],[-59.53638358792734,13.062625710060136]]]}},{"type":"Feature","properties":{"id":"maldives-sri-lanka-cable-msc","name":"Maldives Sri Lanka Cable (MSC)","color":"#4bb648","feature_id":"maldives-sri-lanka-cable-msc-0","coordinates":[76.75325063896734,5.408740500220179]},"geometry":{"type":"MultiLineString","coordinates":[[[73.54035213926461,4.212345781871782],[74.25002163652778,4.389285926050993],[79.20001812990387,6.405200795356032],[79.86681765753703,6.833088156653168]]]}},{"type":"Feature","properties":{"id":"eviny-digital","name":"Eviny Digital","color":"#ca3693","feature_id":"eviny-digital-0","coordinates":[5.510618083927828,59.58239554653866]},"geometry":{"type":"MultiLineString","coordinates":[[[5.332770458155255,60.39072370580963],[5.400120410443995,60.35426474321958],[5.468820361776232,60.29859553499128],[5.512570330783214,60.18692233975928],[5.45632037063125,60.07486799642317],[5.456325370031929,59.948350184903155],[5.568820290339459,59.8966669924279],[5.568820290339459,59.78362399299559],[5.540705310256183,59.684391141142534],[5.512575330183894,59.64177978429336],[5.498517840142164,59.61333874687536],[5.512575330183894,59.58487361294478],[5.484445350111423,59.54925828942217],[5.45632037003535,59.52787100202234],[5.484445350111423,59.470771844605345],[5.568825290335857,59.41356840141763],[5.59694527041535,59.37061568324753],[5.59694527041535,59.327600860309545],[5.568820290339459,59.29889402722166],[5.524480322346307,59.27926758037185],[5.850070091695361,59.106894957190725],[5.850070091695361,59.0490845446616],[5.730770176208511,58.9708214866686]]]}},{"type":"Feature","properties":{"id":"tokelau-submarine-cable","name":"Tokelau Submarine Cable","color":"#7f6fb2","feature_id":"tokelau-submarine-cable-0","coordinates":[-172.04578978025606,-9.178183024905389]},"geometry":{"type":"MultiLineString","coordinates":[[[-172.5109035557175,-8.559092899828055],[-172.46230359014606,-8.846050186819125],[-172.34980366984206,-9.068306003874412],[-172.1248038292341,-9.179382545871277],[-171.8115040511786,-9.174626307490298],[-171.674804148018,-9.34593200663971],[-171.44980430741026,-9.34593200663971],[-171.26150444080346,-9.383770856814218]]]}},{"type":"Feature","properties":{"id":"mist","name":"MIST","color":"#a84c9c","feature_id":"mist-0","coordinates":[83.14307882739791,2.9351430719707547]},"geometry":{"type":"MultiLineString","coordinates":[[[81.00001685476798,3.042156042425856],[82.57501573902388,5.510047410567148],[82.80001557963192,7.744876956882131],[82.35001589841602,9.52441134501949],[81.45001653598388,11.294709319565477],[80.24298739105474,13.06385310188338]],[[72.87590260996693,19.07607425728523],[71.55002354923187,16.965102599435927],[71.49513558519409,13.492128176464083],[72.900022592881,9.443204702286902],[75.09513303492322,6.770440250104527],[78.69513048465001,3.940475772228814],[79.65001781052403,3.41655961832325],[81.00001685476798,3.042156042425856],[85.500013666928,2.817450442654169],[90.00001047908802,3.715978119298069],[92.70000856638391,4.837826391986557],[94.27500745064,5.833479966632704],[95.40635270497349,6.311016478693225],[97.42500521915215,5.845915088460266],[98.32500458158412,5.286069860821008],[99.11250402371216,4.613591578862773],[100.12500330644806,3.266814816815666],[101.25000250948806,2.19929675402769],[102.15000187192003,1.918228780215599],[102.68279723997186,1.614492576237963],[103.34065102845322,1.412069619499378],[103.50000091556807,1.38372807724675],[103.64609081207688,1.338585852071497]],[[97.42500521915215,5.845915088460266],[99.00000410340806,6.405200795356032],[100.06611334816643,6.613518860854109]],[[101.25000250948806,2.19929675402769],[101.36250242919627,2.592701464601932],[101.44360237174425,2.751228763607222]]]}},{"type":"Feature","properties":{"id":"la-gomera-el-hierro","name":"La Gomera-El Hierro","color":"#82489c","feature_id":"la-gomera-el-hierro-0","coordinates":[-17.406432376030878,27.77980822711221]},"geometry":{"type":"MultiLineString","coordinates":[[[-17.108213644440983,28.087940705571693],[-17.088513658396682,27.94625026257154],[-17.437413411232768,27.76358852605777],[-17.77491317214482,27.76358852605777],[-17.890913089969356,27.820205885436238]]]}},{"type":"Feature","properties":{"id":"tegopa","name":"TEGOPA","color":"#397cbf","feature_id":"tegopa-0","coordinates":[-17.104673858647466,28.17764371571822]},"geometry":{"type":"MultiLineString","coordinates":[[[-17.765193179030568,28.66327103532559],[-17.58982330326418,28.66327103532559],[-17.36480346267046,28.564511099301093],[-17.212413570624822,28.359233526108557],[-17.093756322531753,28.159242763304327],[-17.108213644440983,28.087940705571693],[-16.976044634742816,28.045558044929223],[-16.762413889408837,27.912808890463392],[-16.537414048800798,27.912808890463392],[-16.481164088648836,27.962503359972466],[-16.518014062543934,28.059088061264806]]]}},{"type":"Feature","properties":{"id":"tenerife-la-palma","name":"Tenerife-La Palma","color":"#cd1875","feature_id":"tenerife-la-palma-0","coordinates":[-17.153280432843193,28.689992052050073]},"geometry":{"type":"MultiLineString","coordinates":[[[-16.586414014088735,28.39759398562128],[-16.762413889408837,28.55704546571133],[-17.36480346267046,28.761938001939967],[-17.58982330326418,28.761938001939967],[-17.765193179030568,28.66327103532559]]]}},{"type":"Feature","properties":{"id":"tenerife-gran-canaria","name":"Tenerife-Gran Canaria","color":"#368ccb","feature_id":"tenerife-gran-canaria-0","coordinates":[-16.1149665335125,28.154948775403614]},"geometry":{"type":"MultiLineString","coordinates":[[[-15.694014646272848,28.149943225326865],[-15.862414526976778,28.210588049735243],[-16.312414208192763,28.111449436442268],[-16.538584047971963,28.046741704209165]]]}},{"type":"Feature","properties":{"id":"candalta","name":"CANDALTA","color":"#f0a51f","feature_id":"candalta-0","coordinates":[-16.008811791029554,28.225523187644907]},"geometry":{"type":"MultiLineString","coordinates":[[[-16.371744166162888,28.355537519986957],[-16.199914287888834,28.309722768786948],[-15.974914447280797,28.210588049735243],[-15.74991460667276,28.061779537707014],[-15.699964642057815,27.99976141196043]],[[-16.371744166162888,28.355537519986957],[-16.199914287888834,28.26014491451124],[-15.974914447280797,28.111449436442268],[-15.74991460667276,28.012130816282585],[-15.699964642057815,27.99976141196043]]]}},{"type":"Feature","properties":{"id":"transcan-2","name":"TRANSCAN-2","color":"#7fae40","feature_id":"transcan-2-0","coordinates":[-14.851191350219695,27.85463851681449]},"geometry":{"type":"MultiLineString","coordinates":[[[-13.547572416832688,28.959501465241424],[-13.61241612089677,28.65581241773305],[-13.862220631432923,28.496470563064396]],[[-14.358415593019709,28.048718628683098],[-14.512415483924729,27.896238989528694],[-15.187415005748841,27.813351446514346],[-15.390735173618879,27.895462213161103]]]}},{"type":"Feature","properties":{"id":"converge-domestic-submarine-cable-network-cdscn","name":"Converge Domestic Submarine Cable Network (CDSCN)","color":"#a24a9c","feature_id":"converge-domestic-submarine-cable-network-cdscn-0","coordinates":[120.91814381110434,11.955858207114732]},"geometry":{"type":"MultiLineString","coordinates":[[[121.44881632545895,13.809939475818108],[121.61248808451225,13.492128176464083],[121.72498800481618,13.054150695298627],[121.72498800481618,12.72515592356304],[121.52772251956107,12.586423162176041],[121.49998816420832,12.395734000022975],[121.3874882439044,12.175838310301005],[120.93748856268832,11.955858207114732],[120.37498896116831,11.955858207114732],[120.2007690845874,12.005434247136186],[120.37498896116831,11.735650161405832],[119.92498927995224,11.073982781226615],[119.50037958074992,10.820000490489418]],[[121.52772251956107,12.586423162176041],[121.61248808451225,12.395734000022975],[121.7864179618947,12.158056685859203],[121.94481206784093,11.949266020482876],[122.06248776572832,11.955858207114732],[122.39998752664029,11.84577637362577],[122.74998727869705,11.583202180445051],[122.96248712816029,11.735650161405832],[123.29998688907226,11.735650161405832],[123.46873676952833,11.955858207114732],[123.50906877220675,12.218076875737703],[123.5249867296803,11.955858207114732],[123.82048402610735,11.53744186728821],[123.93631456329179,11.083374737000288]],[[123.61668588346967,12.366641586121998],[123.52503672964518,12.505588131780646],[123.35623684922422,12.615395567393394],[123.01873708831225,12.834868817846521],[122.84998720785636,13.054150695298627],[122.84998720785636,13.273238157547594],[122.95067073028109,13.546888070207846]],[[124.00760357528993,11.04590095392508],[124.13413629815311,10.93201162513164],[124.42498609211226,10.85308969074528],[124.61277595908027,11.006888020676206]],[[122.58944051743003,10.78661708283731],[122.84998720785636,10.742581675476407],[122.96776056192463,10.739415483408349]],[[123.41287743409951,10.483708779671767],[123.5249867296803,10.41081650540272],[123.63524055782547,10.376237129155873]],[[123.69139286179656,10.238265933645913],[123.74998657028833,10.07869800665097],[123.74998657028833,9.801670473167492],[123.87820913570422,9.675391878909156]],[[123.91358645439269,9.6225370957098],[124.19998625150441,9.52441134501949],[124.6499859327203,9.52441134501949],[125.09998561393637,9.413444258507564],[125.32498545454442,9.246926926415409],[125.42068851174743,8.968934161508152]],[[123.29490876766967,9.246348603115566],[123.46873676952833,9.08033076823294],[123.74998657028833,8.913657155780559],[124.53748601241637,8.635699417327467],[124.63191594552143,8.454147535358473]]]}},{"type":"Feature","properties":{"id":"guadeloupe-cable-des-iles-du-sud-gcis","name":"Guadeloupe Cable des Iles du Sud (GCIS)","color":"#31ac49","feature_id":"guadeloupe-cable-des-iles-du-sud-gcis-0","coordinates":[-61.350442672762156,16.113973083936536]},"geometry":{"type":"MultiLineString","coordinates":[[[-61.57883721915904,15.87297951222124],[-61.498262198114126,15.988768942769152],[-61.368632289945175,16.10232559580297],[-61.19988240948919,16.21038241802069],[-61.073857108141695,16.303571371032675]],[[-61.368632289945175,16.10232559580297],[-61.31912060626973,15.953463637141443]],[[-61.19988240948919,16.21038241802069],[-61.27444290354486,16.251075987819267]],[[-61.498262198114126,15.988768942769152],[-61.56467707294022,16.042456277649617]]]}},{"type":"Feature","properties":{"id":"rockabill","name":"Rockabill","color":"#35b44a","feature_id":"rockabill-0","coordinates":[-4.571461647951477,53.76256062499529]},"geometry":{"type":"MultiLineString","coordinates":[[[-6.114621432404921,53.49317504982973],[-5.624921779312721,53.669053535347864],[-4.499922576272716,53.76891056666807],[-3.599923213840749,53.76891056666807],[-3.006373634316763,53.647933398832954]]]}},{"type":"Feature","properties":{"id":"oman-australia-cable-oac","name":"Oman Australia Cable (OAC)","color":"#939597","feature_id":"oman-australia-cable-oac-0","coordinates":[57.39295929250654,16.597281615381004]},"geometry":{"type":"MultiLineString","coordinates":[[[60.75003120004772,16.534196198259725],[55.01253526394787,16.642014062854003],[54.14808587692784,17.09582718672565]]]}},{"type":"Feature","properties":{"id":"oman-australia-cable-oac","name":"Oman Australia Cable (OAC)","color":"#41b878","feature_id":"oman-australia-cable-oac-1","coordinates":[77.83897821383535,-11.776885531285858]},"geometry":{"type":"MultiLineString","coordinates":[[[115.85731216153303,-31.953441330324313],[113.84999358353615,-31.468437004267024],[111.59999517745614,-30.30995334464681],[89.10001111784821,-19.729525450021],[72.11252315015598,-7.732812885336029],[65.25002801220774,4.164912849976942],[61.20003088126379,15.669513225155248],[60.75003120004772,16.534196198259725],[60.75003120004772,19.104405475930452],[60.75003120004772,22.469443964829516],[59.85003183761575,23.298598065875897],[58.95003247518379,23.7112581424843],[58.162533033055745,23.91710129093513],[57.88605322832058,23.67872342575357]],[[89.10001111784821,-19.729525450021],[95.4000066530841,-13.99027116703797],[96.83231563842303,-12.19311300919909]],[[72.11252315015598,-7.732812885336029],[72.33752299076401,-7.509800774121521],[72.41667293469345,-7.333331811022597]]]}},{"type":"Feature","properties":{"id":"tanjung-pandan-sungai-kakap","name":"Tanjung Pandan-Sungai Kakap","color":"#939597","feature_id":"tanjung-pandan-sungai-kakap-1","coordinates":[108.11568048125844,-1.226769228473004]},"geometry":{"type":"MultiLineString","coordinates":[[[107.66288796654008,-2.767442755874634],[107.60664800638092,-2.517708985005663],[107.64799797708825,-2.237970538313992],[108.1124976480322,-1.231315750217412],[108.89999709016024,-0.106411275875408],[109.18222689022609,-0.061391357195038]]]}},{"type":"Feature","properties":{"id":"hainan-to-hong-kong-express-h2he","name":"Hainan to Hong Kong Express (H2HE)","color":"#64c3c7","feature_id":"hainan-to-hong-kong-express-h2he-0","coordinates":[112.54647871523952,20.71152982690533]},"geometry":{"type":"MultiLineString","coordinates":[[[113.57675158960345,22.270716559591268],[113.73749366323221,22.105110548108275],[114.07499342414418,22.105110548108275],[114.20292333351767,22.22205041973683]],[[110.76181217748294,20.045307961909145],[111.14999549564435,20.304717766641158],[112.83749430080026,20.796306105108872],[113.56874378277615,21.635297384859552],[113.68124370308026,21.84429407917369],[113.73749366323221,22.105110548108275]]]}},{"type":"Feature","properties":{"id":"ketchcan1-submarine-fiber-cable-system","name":"KetchCan1 Submarine Fiber Cable System","color":"#bf1e5a","feature_id":"ketchcan1-submarine-fiber-cable-system-0","coordinates":[-130.9696404829953,54.78868595868135]},"geometry":{"type":"MultiLineString","coordinates":[[[-130.32633343966273,54.31306449550539],[-130.443584608497,54.29748595281839],[-130.55608452595894,54.33029904361132],[-130.61233323705767,54.55925876578231],[-130.7248331573617,54.68951871778461],[-130.94983299796965,54.754492368816926],[-131.0623841545014,54.94878902385559],[-131.11868411319568,55.07780072164767],[-131.23108403073104,55.14215100378178],[-131.6478325035003,55.342087835087305]]]}},{"type":"Feature","properties":{"id":"equiano","name":"Equiano","color":"#7e2c18","feature_id":"equiano-0","coordinates":[-20.69991110004885,12.483262075762921]},"geometry":{"type":"MultiLineString","coordinates":[[[1.575073120143199,0.793562652607196],[0.225074076495339,-0.093411304877754],[-3.599923213840749,-1.681168935904995],[-10.799918113296759,-1.681168935904995],[-14.849915244240792,2.367912558705407],[-19.34991205640081,8.635699417327467],[-20.69991110004885,11.735650161405832],[-20.69991110004885,19.95262290516439],[-19.849911702196447,22.884654113882444],[-19.124912215792865,27.76358852605777],[-17.999913013348852,30.255702942039875],[-17.0999136503208,31.670513047087127],[-16.312414208788844,32.43331330641721],[-13.949915881808826,33.93964008831966],[-11.699917475728817,36.51238821239364],[-10.349918432080775,38.03417390064187],[-9.562418989952736,38.29952060596925],[-9.102749315587026,38.4430794831419]],[[18.445861168718828,-33.72721819637743],[17.55006180331148,-33.74264465652956],[16.65006244087933,-33.55534420877606],[15.525063237839326,-32.61276000573574],[13.500064672367355,-30.30995334464681],[9.000067860207336,-23.49392244589784],[7.650068816559295,-18.026426383713453],[7.650068815963395,-13.99027116703797],[8.325068338383225,-10.620064860363238],[7.650068816559295,-6.616650693475355],[7.200069135343221,-4.825692499217419],[4.050071366831244,-1.231315750217412],[2.25007264196731,1.580886840914131]],[[9.000067860207336,-23.49392244589784],[13.05006499115128,-22.665969967794794],[14.533463940297649,-22.68542973223072]],[[1.575073120143199,0.793562652607196],[2.25007264196731,1.580886840914131],[3.600071685615352,4.164912849976942],[3.423511810692114,6.439066911484626]],[[7.650068815963395,-13.99027116703797],[-3.599923214436649,-15.29638776062193],[-5.711521717964474,-15.918564131427317]],[[1.575073120143199,0.793562652607196],[1.687573039851407,3.82823430332105],[1.575073120143199,5.061986954416114],[1.227803366152544,6.126307297218732]]]}},{"type":"Feature","properties":{"id":"south-pacific-cable-system-spcsmistral","name":"South Pacific Cable System (SPCS)/Mistral","color":"#d18e29","feature_id":"south-pacific-cable-system-spcsmistral-0","coordinates":[-83.51924740133119,-9.940940045674754]},"geometry":{"type":"MultiLineString","coordinates":[[[-90.8222236775613,13.934797333208856],[-91.57486089156932,13.054150695298627],[-92.24986041339342,9.52441134501949],[-90.89986137034147,3.715978119298069],[-87.74986360123404,0.568578852526193],[-86.39986455818145,-2.430680261964474],[-85.04986551393732,-6.616650693475355],[-82.79986710785731,-11.503333845984299],[-79.19986965812936,-15.441023659568087],[-76.49987157083336,-18.45381377577717],[-74.02487332414532,-27.15383128539156],[-72.22487459928129,-31.85146566557725],[-71.62043502747198,-33.04554123247811]],[[-82.79986710785731,-11.503333845984299],[-79.19986965812936,-12.60351210497128],[-76.87428130559793,-12.278420041799619]],[[-76.49987157083336,-18.45381377577717],[-72.89987412110531,-19.305384072361306],[-70.30675595809456,-18.473543073651214]],[[-86.39986455818145,-2.430680261964474],[-84.14986615150535,-2.580536704984131],[-81.00906837647595,-2.228447783119022]]]}},{"type":"Feature","properties":{"id":"n0r5ke-viking","name":"N0r5ke Viking","color":"#dabb26","feature_id":"n0r5ke-viking-0","coordinates":[5.65190035143196,62.38446699850115]},"geometry":{"type":"MultiLineString","coordinates":[[[9.663153327971374,63.68839703736199],[9.618817421879303,63.676230956459754],[9.637665064777563,63.61946636684095],[9.112567779915365,63.54288560128821],[8.97524787719417,63.50462478492522],[8.69395807646262,63.40817512674497],[8.380908298229913,63.36151643295419],[8.09337850191876,63.301036488500856],[7.875068656571432,63.172946959532915],[7.729668760170019,63.11078338854437],[7.762568738651288,63.020234537564136],[7.650068815963395,62.917978785251854],[7.425068974759457,62.81536487879325],[7.16071369447304,62.73722946432863],[6.862569374431255,62.76392332890585],[6.637569533823219,62.73816885430014],[6.465792311761914,62.613002975496656],[6.300069772911254,62.66077045886663],[6.187569852607326,62.6090589082033],[6.174386268196741,62.47148731681593],[5.962570011999289,62.50536500000049],[5.850070091695361,62.45338241250811],[5.400070410479286,62.29689073879045],[5.287570488983557,62.24454516169996],[5.343820449731422,62.13958079203442],[5.522335948269336,62.03599154304595],[5.45632037003535,61.981449485650025],[5.498507840149368,61.935540246038215],[5.06257064837552,61.981449485650025],[4.837570808959284,61.92855599360773],[4.725070888655357,61.8224937418921],[4.725070888655357,61.71606370959994],[4.975851960403757,61.59630726299676],[4.725070888655357,61.555727089493594],[4.725070888655357,61.448373642843436],[4.950070729263212,61.23255301306618],[5.293429861024427,61.17224436826955],[4.837570808959284,61.01524141470424],[4.725070888655357,60.796431695350094],[4.855539546230003,60.62197249344922],[5.062570649567322,60.57611674038769],[5.287570490175359,60.46539258705817],[5.332770458155255,60.39072370580963]],[[7.16071369447304,62.73722946432863],[7.425068975951258,62.66077045886663],[7.68161176296393,62.5639562408968]],[[9.637665064777563,63.61946636684095],[9.815742282376158,63.57628059164396],[9.900067222639304,63.48851288982234],[10.293816943703233,63.46341549544048],[10.39205906160764,63.431002047425466]]]}},{"type":"Feature","properties":{"id":"okinawa-cellular-cable","name":"Okinawa Cellular Cable","color":"#504a9f","feature_id":"okinawa-cellular-cable-0","coordinates":[128.68334289984085,29.271695336749033]},"geometry":{"type":"MultiLineString","coordinates":[[[127.97728357563153,26.591578968461],[127.79998370123228,26.562513149236715],[127.57498386062441,26.763586569619914],[127.68748378092835,27.164665812813517],[128.4749832230562,28.359233526108557],[128.69998306366443,29.344566989489813],[129.1499827448803,30.5144959597591],[129.5999824260964,31.286738814391754],[130.04998210731227,31.574717129337174],[130.5570817480782,31.596524688905284]]]}},{"type":"Feature","properties":{"id":"cabo-verde-telecom-domestic-submarine-cable-phase-2","name":"Cabo Verde Telecom Domestic Submarine Cable Phase 2","color":"#364da0","feature_id":"cabo-verde-telecom-domestic-submarine-cable-phase-2-0","coordinates":[-24.567590671386295,16.084567433773092]},"geometry":{"type":"MultiLineString","coordinates":[[[-25.05940801174037,17.019177136056],[-25.031158031752888,16.911292789233343],[-25.060796682631576,16.823445181003198],[-25.07740799898894,16.724585511421417],[-25.07740799898894,16.61681385037869],[-23.962408788864845,15.452760959322058],[-23.751080813571605,15.276859352491847]]]}},{"type":"Feature","properties":{"id":"cabo-verde-telecom-domestic-submarine-cable-phase-3","name":"Cabo Verde Telecom Domestic Submarine Cable Phase 3","color":"#21b595","feature_id":"cabo-verde-telecom-domestic-submarine-cable-phase-3-0","coordinates":[-24.55289713811713,15.100182190335259]},"geometry":{"type":"MultiLineString","coordinates":[[[-22.91905015298992,16.174753793884737],[-23.06240942643288,15.994209911785974],[-23.231109306924335,15.669513225155248],[-23.287409267040825,15.23578178303578],[-23.209772603289398,15.129919208617387],[-23.287409267040825,15.018578573757472],[-23.521209101414858,14.923035560171673],[-23.512409107648864,14.801154224791581],[-23.62490902795288,14.692360031374392],[-23.849908868560828,14.5835116451186],[-24.2999085497769,14.692360031374392],[-24.41240847008083,14.746763925028056],[-24.500933797993653,14.897153768217688],[-24.581158350536903,14.855530885060197],[-24.695416082095644,14.872992709661734],[-24.63740831068887,15.018578573757472],[-24.524908390384848,15.127208002058548],[-24.2999085497769,15.23578178303578],[-24.074908709168866,15.344299555517528],[-23.751080813571605,15.276859352491847],[-24.074908709168866,15.452760959322058],[-25.189907919292956,16.61681385037869],[-25.189907919292956,16.724585511421417],[-25.060796682631576,16.823445181003198],[-25.08740799190485,16.911292789233343],[-25.05940801174037,17.019177136056]]]}},{"type":"Feature","properties":{"id":"cabo-verde-telecom-domestic-submarine-cable-phase-1","name":"Cabo Verde Telecom Domestic Submarine Cable Phase 1","color":"#b32c57","feature_id":"cabo-verde-telecom-domestic-submarine-cable-phase-1-0","coordinates":[-23.099947086292776,16.626458747530958]},"geometry":{"type":"MultiLineString","coordinates":[[[-25.060796682631576,16.823445181003198],[-24.974908071600833,16.642014062854003],[-24.63740831068887,16.534196198259725],[-24.357135071737105,16.56583652167652],[-24.187408629472884,16.426318069774343],[-23.399909187344846,16.534196198259725],[-22.935309907096357,16.67709796429937],[-22.94990950612886,16.426318069774343],[-22.91905015298992,16.174753793884737],[-23.1749093467369,15.994209911785974],[-23.34365922719288,15.669513225155248],[-23.399909187344846,15.23578178303578],[-23.399909187344846,15.018578573757472],[-23.521209101414858,14.923035560171673]]]}},{"type":"Feature","properties":{"id":"miyazaki-okinawa-cable-moc","name":"Miyazaki-Okinawa Cable (MOC)","color":"#b0d135","feature_id":"miyazaki-okinawa-cable-moc-0","coordinates":[130.61608243107943,28.675897447067076]},"geometry":{"type":"MultiLineString","coordinates":[[[131.29461013185616,32.09764082018105],[131.7374809118723,31.670513047087127],[131.8499808321764,31.286738814391754],[131.39998115096031,29.540507745394493],[128.69998306366443,26.562513149236715],[128.0249835418403,26.1593079707739],[127.68084378563216,26.212414126750428]]]}},{"type":"Feature","properties":{"id":"japan-information-highway-jih","name":"Japan Information Highway (JIH)","color":"#6ebe44","feature_id":"japan-information-highway-jih-0","coordinates":[138.9944928525868,34.302577135868496]},"geometry":{"type":"MultiLineString","coordinates":[[[139.94997509406446,41.2387523289666],[139.6124753331525,41.576261830098154],[139.27497557224055,42.07923561816413],[139.27497557224055,42.743713464436695],[139.49997541284839,43.073310783003215],[140.39997477528055,43.48282788090373],[140.84997445649643,43.48282788090373],[141.31540412678189,43.17117526599852]],[[139.83747517376037,41.15395052136787],[139.38746049255508,41.576261830098154],[139.04997573163232,42.07923561816413],[139.04997573163232,42.743713464436695],[139.27497557224055,43.073310783003215],[140.39997477528055,43.564400497117596],[140.84997445649643,43.564400497117596],[141.31540412678189,43.17117526599852]],[[127.68084378563216,26.212414126750428],[128.0249835418403,26.1106045816533],[128.69998306366443,26.36108632539156],[131.39998115096031,29.344566989489813],[132.74998019460836,31.09426282763951],[134.99997860068837,31.478822672736147],[136.57497748494447,32.243210016262736],[136.7999773255523,32.8123187832876],[136.87399727311598,34.33682825203173],[137.69997668798445,34.032921789964035],[138.59997605041642,34.12610104005753],[139.4999754122525,34.52869067055088],[139.9218501133925,34.736964305118654],[139.99216256358258,34.852445708846155],[139.95485509060742,34.97657002902234],[140.06247501377248,34.99080971857576],[140.3437248151284,35.0061690919714],[140.73747453559662,35.17493178747529],[140.84997445649643,35.419780517080355],[140.96247437680054,35.78566189952622],[140.84997445649643,36.05897312258681],[140.26989674242887,36.34219436916461],[140.84997445649643,36.1498667868178],[141.2999741377125,36.42191605012598],[141.7499738189284,36.87321951208928],[141.7499738189284,37.589786573603064],[141.2999741377125,38.03417390064187],[140.86667444407013,38.26667008785156],[141.2999741377125,38.122730108392204],[141.7499738189284,38.21117903702318],[142.19997350014447,38.651811712711336],[142.42497334075233,40.04369219283004],[142.19997350014447,41.0693404382162],[141.7499738189284,41.40772623743595],[141.2999741377125,41.576261830098154],[140.84997445649643,41.576261830098154],[140.39997477528055,41.40772623743595],[139.94997509406446,41.2387523289666],[139.83747517376037,41.15395052136787],[139.6124753331525,40.89949091487166],[139.49997541284839,40.38732029077508],[139.72497525345642,40.04369219283004],[140.11667497537667,39.716671527453656]],[[132.74998019460836,31.09426282763951],[132.07498067278445,31.670513047087127],[131.29461013185616,32.09764082018105]],[[139.4999754122525,34.52869067055088],[139.55622537240447,34.71384862261538],[139.44372545210052,35.08292270029031],[139.25540558550816,35.29948913088429]]]}},{"type":"Feature","properties":{"id":"lazaro-cardenas-manzanillo-santiago-submarine-cable-system-lcmsscs","name":"Lazaro Cardenas-Manzanillo Santiago Submarine Cable System (LCMSSCS)","color":"#4cb748","feature_id":"lazaro-cardenas-manzanillo-santiago-submarine-cable-system-lcmsscs-0","coordinates":[-103.53391735089953,18.05407066325259]},"geometry":{"type":"MultiLineString","coordinates":[[[-104.30815187118658,19.085789444146922],[-104.28735188592151,18.678647022154717],[-103.94985212500946,18.251816319028222],[-103.04985276257749,17.82393441253792],[-102.5998530813615,17.82393441253792],[-102.19435336862122,17.956784079019002]],[[-102.19435336862122,17.956784079019002],[-102.14985340014543,17.716802179008642],[-101.81235363923346,17.609605913224996],[-101.61055378219034,17.664117239853347]]]}},{"type":"Feature","properties":{"id":"fibra-optica-al-pacfico","name":"Fibra Optica al Pacífico","color":"#7e3a96","feature_id":"fibra-optica-al-pacfico-0","coordinates":[-75.43774084545812,-15.911012613247081]},"geometry":{"type":"MultiLineString","coordinates":[[[-76.87428130559793,-12.278420041799619],[-77.39987093329535,-12.82299562562977],[-77.39987093326533,-13.698987269610743],[-76.0498718896173,-15.441023659568087],[-73.79987348353728,-17.168553094226155],[-72.44987443988924,-17.597998996155503],[-71.3236752376994,-17.641173816666658]]]}},{"type":"Feature","properties":{"id":"batam-sarawak-internet-cable-system-basics","name":"Batam Sarawak Internet Cable System (BaSICS)","color":"#3660ac","feature_id":"batam-sarawak-internet-cable-system-basics-0","coordinates":[107.2351874760423,2.21204623238842]},"geometry":{"type":"MultiLineString","coordinates":[[[110.29359610292168,1.675048741325009],[110.02499629320025,1.918228780215599],[109.57499661198416,2.367912558705407],[106.19999900286416,2.143087178471855],[104.73750003891219,1.468426767331968],[104.62500011860807,1.36999455336686],[104.28790035741287,1.173518198634015],[104.1331004670747,1.173205764381829]]]}},{"type":"Feature","properties":{"id":"eastern-light-sweden-finland-i","name":"Eastern Light Sweden-Finland I","color":"#944098","feature_id":"eastern-light-sweden-finland-i-0","coordinates":[22.618551659190324,59.73193250520986]},"geometry":{"type":"MultiLineString","coordinates":[[[18.062756752613492,59.3323082610704],[18.900060846959338,59.45171731890513],[20.250059890607382,59.56588346342965],[21.375059093647387,59.679663707208995],[22.725058137295427,59.7364093840784],[22.96675718482349,59.823409426815196],[23.1750578185115,59.7364093840784],[23.850057340335432,59.7364093840784],[24.412556941855435,59.84961238502145],[24.93247844853827,60.171163460961736],[25.200056383983473,60.07486799642317],[26.10005574641544,60.07486799642317],[26.77505526823937,60.29859553499128],[26.883648941310707,60.50114056075507]]]}},{"type":"Feature","properties":{"id":"sorsogon-samar-submarine-fiber-optical-interconnection-project-sssfoip","name":"Sorsogon-Samar Submarine Fiber Optical Interconnection Project (SSSFOIP)","color":"#bf3b28","feature_id":"sorsogon-samar-submarine-fiber-optical-interconnection-project-sssfoip-0","coordinates":[124.22831176599175,12.61497434156621]},"geometry":{"type":"MultiLineString","coordinates":[[[124.10708631731555,12.645255061583402],[124.2281112315803,12.615395567393394],[124.28238619313154,12.501390118608773]]]}},{"type":"Feature","properties":{"id":"manatua","name":"Manatua","color":"#de1f29","feature_id":"manatua-0","coordinates":[-162.21224462541846,-20.995131543025785]},"geometry":{"type":"MultiLineString","coordinates":[[[-171.76669408292247,-13.83348925575777],[-171.4498043074101,-13.698987269610743],[-170.77480478558607,-13.698987269610743],[-170.09980526376205,-14.135775375064666],[-169.64980558254607,-15.006817032918805],[-169.64980558254607,-18.026426383713453],[-166.49980781522615,-18.880139975101173],[-163.79980972733406,-20.995131543025785],[-160.19981227700995,-20.995131543025785],[-159.86231251669406,-20.995131543025785],[-156.5998148272819,-20.574419057276128],[-151.87481817451393,-17.383402005942457],[-150.97481881208188,-17.383402005942457],[-150.07481944965008,-17.919416202114704],[-149.5123198481299,-17.919416202114704],[-149.3233199820192,-17.750446541031426]],[[-169.64980558254607,-18.026426383713453],[-169.874805423154,-18.45381377577717],[-169.91670539347166,-19.016714981418577]],[[-159.86231251669406,-20.995131543025785],[-159.86231251669406,-21.100125697241445],[-159.76971258169664,-21.224342830512246]],[[-160.19981227700995,-20.995131543025785],[-160.19981227700995,-20.152543786018732],[-160.08731235670592,-19.305384072361306],[-159.77491257801287,-18.825724826039032]],[[-151.87481817451393,-17.383402005942457],[-151.8748181745141,-16.953454989809906],[-151.7495182632776,-16.506319370588315]]]}},{"type":"Feature","properties":{"id":"malta-italy-interconnector","name":"Malta-Italy Interconnector","color":"#bf3b28","feature_id":"malta-italy-interconnector-0","coordinates":[14.565709909689183,36.36381505046774]},"geometry":{"type":"MultiLineString","coordinates":[[[14.550263928396484,36.78477228631661],[14.625063875407358,36.602754740329765],[14.512563955103431,36.1498667868178],[14.458763993215827,35.934055517066064]]]}},{"type":"Feature","properties":{"id":"caribbean-regional-communications-infrastructure-program-carcip","name":"Caribbean Regional Communications Infrastructure Program (CARCIP)","color":"#a69b33","feature_id":"caribbean-regional-communications-infrastructure-program-carcip-0","coordinates":[-61.55578124830755,12.669103176552872]},"geometry":{"type":"MultiLineString","coordinates":[[[-61.14278674681129,13.373112400304855],[-61.19988240948919,13.437424347113865],[-61.25613236964116,13.410019112156675],[-61.24088238044438,13.290916333074358]],[[-61.593083479793904,12.17091083195906],[-61.47777843801405,12.27722282982045],[-61.53338217323483,12.342743321766598],[-61.58963213338679,12.395734000022975],[-61.593582130588594,12.55854557069944],[-61.53533217185347,12.728911329121086],[-61.44885723311303,12.842620056913413],[-61.39063227436026,12.956082453284033],[-61.32304895989583,13.054150695298627],[-61.20828240353852,13.145460938850311]],[[-61.593582130588594,12.55854557069944],[-61.53733217043663,12.531043226499392],[-61.4785931495479,12.473742852030648]],[[-61.53533217185347,12.728911329121086],[-61.47898221177227,12.67413543381157],[-61.44319278400084,12.61139452071088]],[[-61.44885723311303,12.842620056913413],[-61.39265727292568,12.787673734344635],[-61.33898231094949,12.699625618648897]],[[-61.39063227436026,12.956082453284033],[-61.27833235391453,12.928720698349425],[-61.187182418485925,12.877712443605427]],[[-61.32304895989583,13.054150695298627],[-61.266857362043524,13.026751030444132],[-61.24468237775242,12.997540410506817]]]}},{"type":"Feature","properties":{"id":"energinet-laeso-varberg","name":"Energinet Laeso-Varberg","color":"#373996","feature_id":"energinet-laeso-varberg-0","coordinates":[11.55720309355262,57.10544915061779]},"geometry":{"type":"MultiLineString","coordinates":[[[10.88716652336902,57.25699774827183],[11.025066425679308,57.177647750456146],[11.700065947503239,57.086065975868046],[12.251265557028358,57.10529657756181]]]}},{"type":"Feature","properties":{"id":"energinet-lyngsa-laeso","name":"Energinet Lyngsa-Laeso","color":"#a74234","feature_id":"energinet-lyngsa-laeso-0","coordinates":[10.701035387650101,57.22562397392146]},"geometry":{"type":"MultiLineString","coordinates":[[[10.514616787286652,57.24491811100573],[10.687566664767344,57.22335372144124],[10.88716652336902,57.25699774827183]]]}},{"type":"Feature","properties":{"id":"skagenfiber-west","name":"Skagenfiber West","color":"#463d98","feature_id":"skagenfiber-west-0","coordinates":[9.900067222639304,58.33642328784846]},"geometry":{"type":"MultiLineString","coordinates":[[[10.016367140251454,59.081110697513395],[10.012567142943231,58.93317132635278],[9.900067222639304,58.347730245248535],[9.900067222639304,57.8123997505166],[9.959267180701625,57.58818834277179]]]}},{"type":"Feature","properties":{"id":"pasuli","name":"PASULI","color":"#939597","feature_id":"pasuli-0","coordinates":[105.00346543425965,-2.162360358870599]},"geometry":{"type":"MultiLineString","coordinates":[[[104.87451166060187,-2.295346349312876],[104.96249987952004,-2.187128507840693],[105.16425973659172,-2.065142653434427]]]}},{"type":"Feature","properties":{"id":"sjjk","name":"SJJK","color":"#32499f","feature_id":"sjjk-0","coordinates":[113.71861943422607,-4.956509167211942]},"geometry":{"type":"MultiLineString","coordinates":[[[105.58496037606339,-5.769333077764846],[105.69374936149612,-6.001651664913787],[105.88389922679222,-6.07371060106276]],[[114.66674769244001,-3.882827780552574],[114.07499342414418,-4.37714437553184],[113.84999358353615,-4.825692499217419],[112.94999422110418,-5.721872747834119],[112.83749430080026,-5.721872747834119],[112.5855944792486,-5.793509456683532]],[[112.59819447032245,-6.953337827181953],[112.49999453988829,-6.641483533889484],[112.49999453988829,-6.393099497823911],[112.89374426095222,-5.833801119425265],[112.83749430080026,-5.777839699209677],[112.5855944792486,-5.793509456683532]]]}},{"type":"Feature","properties":{"id":"link-5-phase-2","name":"Link 5 Phase-2","color":"#c82026","feature_id":"link-5-phase-2-0","coordinates":[105.08998569596574,-0.9538590250877906]},"geometry":{"type":"MultiLineString","coordinates":[[[103.48820092392755,-0.823472321509497],[103.95000059678415,-0.781386636225587],[104.40000027800004,-0.781386636225587],[105.74999932164808,-1.118839506905277],[106.08749908256004,-1.343787247896323],[106.19999900286416,-1.681168935904995],[106.1325185819179,-1.85149793733644]]]}},{"type":"Feature","properties":{"id":"link-4-phase-2","name":"Link 4 Phase-2","color":"#32499f","feature_id":"link-4-phase-2-0","coordinates":[106.90314473056048,-2.5758925784700093]},"geometry":{"type":"MultiLineString","coordinates":[[[106.1325185819179,-1.85149793733644],[106.31249892316808,-1.793617120354896],[106.71024864020762,-2.130918480960333],[107.09999836529612,-3.029995968008661],[107.32499820590417,-3.254657364797681],[107.5499980465122,-3.254657364797681],[107.69638466156057,-3.19596146783133]]]}},{"type":"Feature","properties":{"id":"link-3-phase-2","name":"Link 3 Phase-2","color":"#32499f","feature_id":"link-3-phase-2-0","coordinates":[107.36856158675505,-4.704474226031846]},"geometry":{"type":"MultiLineString","coordinates":[[[107.69638466156057,-3.19596146783133],[107.66249796681612,-3.479268678970064],[107.43749812620827,-4.60145376483711],[106.9874984449922,-5.273944363641298],[106.83339855415794,-6.1289648492105]],[[106.9874984449922,-5.273944363641298],[107.12099835041957,-5.981154260263285]]]}},{"type":"Feature","properties":{"id":"link-2-phase-2","name":"Link 2 Phase-2","color":"#4a499e","feature_id":"link-2-phase-2-0","coordinates":[114.2440928296999,-8.929573210379264]},"geometry":{"type":"MultiLineString","coordinates":[[[113.45679386208275,-8.374087789277754],[113.6249937429283,-8.623660111129297],[114.29999326475222,-8.957195081743112],[114.97499278657615,-8.957195081743112],[115.15985265561976,-8.783705413994815]]]}},{"type":"Feature","properties":{"id":"link-1-phase-2","name":"Link 1 Phase-2","color":"#5a9f43","feature_id":"link-1-phase-2-0","coordinates":[115.6726818504907,-8.623660111129297]},"geometry":{"type":"MultiLineString","coordinates":[[[115.25956289748402,-8.695101915602985],[115.53749238809615,-8.623660111129297],[115.8749921490083,-8.623660111129297],[116.04726202697084,-8.485465010786871]]]}},{"type":"Feature","properties":{"id":"link-3-phase-1","name":"Link 3 Phase-1","color":"#5a9f43","feature_id":"link-3-phase-1-0","coordinates":[116.81114646890252,-7.955717094334652]},"geometry":{"type":"MultiLineString","coordinates":[[[116.04726202697084,-8.485465010786871],[116.0437420294642,-8.401139048122838],[116.09999198961616,-8.178490278944933],[116.54999167083223,-7.955717094334652],[117.4499910332642,-7.955717094334652],[117.86151417923745,-8.10811342968634]]]}},{"type":"Feature","properties":{"id":"link-2-phase-1","name":"Link 2 Phase-1","color":"#5a9f43","feature_id":"link-2-phase-1-0","coordinates":[118.62829725367511,-0.13306273837288116]},"geometry":{"type":"MultiLineString","coordinates":[[[117.53549097269546,0.324543653400007],[117.89999071448027,0.231087797340656],[119.24998975812831,-0.443906656918545],[119.66610274459994,-0.711958991791553]]]}},{"type":"Feature","properties":{"id":"link-1-phase-1","name":"Link 1 Phase-1","color":"#37b19b","feature_id":"link-1-phase-1-0","coordinates":[119.05234026140174,-7.035983789471109]},"geometry":{"type":"MultiLineString","coordinates":[[[117.86151417923745,-8.10811342968634],[118.34999039569617,-7.732822794391767],[119.47498959873617,-6.616650693475355],[119.58748951904028,-6.169450529574503],[119.80928936191533,-5.669034918116874]]]}},{"type":"Feature","properties":{"id":"prat","name":"Prat","color":"#18b199","feature_id":"prat-0","coordinates":[-71.99642151806115,-30.333612085632726]},"geometry":{"type":"MultiLineString","coordinates":[[[-72.95341123369901,-41.46037424479032],[-73.3498738023213,-41.60922308603503],[-73.79987348353728,-41.64192625954094],[-74.24987316475335,-41.398592454124454],[-74.24987316475335,-40.549228298069146],[-73.79336348874507,-39.15216193718986],[-73.39677376969291,-38.78712693980819],[-73.57487364352532,-38.700856737399654],[-73.79987348413327,-38.348806011171334],[-73.79987348353728,-37.04328040742407],[-73.57487364352532,-36.86347758137082],[-73.10897397357299,-36.83168432227461],[-73.12487396171335,-35.95811819864919],[-72.8998741217013,-35.47096027456558],[-72.41606446443656,-35.3183747616349],[-72.44987444048532,-34.73466151270862],[-72.22487459987728,-33.991743556435],[-71.60009504247698,-33.54688527485856],[-71.77487491806531,-33.3676367639474],[-71.62043502747198,-33.04554123247811],[-71.77487491806531,-31.85146566557725],[-71.99987475867334,-30.30995334464681],[-71.54987507745727,-29.920697111268968],[-71.25021708661278,-29.90630723159051],[-71.54987507745727,-29.52991296614913],[-71.7748749186613,-28.743810281149894],[-71.54987507805326,-27.42044677643357],[-70.81235560051893,-27.06199460208447],[-71.09987539683728,-26.21568228509315],[-71.09987539624129,-24.316706749469176],[-70.76237563532924,-23.905969261790265],[-70.39997354830837,-23.65002363065001],[-70.76237563532924,-23.49392244589784],[-70.76237563592531,-22.942519740316364],[-70.64987571562129,-22.527485286666927],[-70.1976560359779,-22.08837968438005],[-70.64987571562129,-21.27495094110979],[-70.64987571562129,-20.85502445095989],[-70.14221005903543,-20.21670486485299],[-70.64987571562129,-19.588268775768473],[-70.6498757150253,-19.305384072361306],[-70.30675595809456,-18.473543073651214]]]}},{"type":"Feature","properties":{"id":"ultramar-ge","name":"Ultramar GE","color":"#41b87b","feature_id":"ultramar-ge-0","coordinates":[6.4013500837250605,-0.6166957139393918]},"geometry":{"type":"MultiLineString","coordinates":[[[6.733269466028674,0.333286471885964],[6.875069365576238,0.118588418888312],[6.750069454127329,-0.331409329660265],[5.650070233377106,-1.231315750217412],[5.636870242728203,-1.435160298356025]]]}},{"type":"Feature","properties":{"id":"umo","name":"UMO","color":"#34a496","feature_id":"umo-0","coordinates":[97.38392862392826,7.594235564906584]},"geometry":{"type":"MultiLineString","coordinates":[[[96.24820605280762,16.758772278045903],[96.44170591573067,14.833379080264882],[96.75000569732805,11.294709319565477],[97.42500521915215,7.354454266299978],[99.67500362523216,5.286069860821008],[100.46250306736002,4.613591578862773],[100.80000282827217,3.266814816815666],[101.25000250948806,2.817450442654169],[102.15000187192003,2.19929675402769],[102.68279723997186,1.783118248527948],[103.34065102845322,1.383978004153773],[103.50000091556807,1.285317359462976],[103.6471008113613,1.338165966949597]]]}},{"type":"Feature","properties":{"id":"polar-circle-cable","name":"Polar Circle Cable","color":"#51b847","feature_id":"polar-circle-cable-0","coordinates":[13.003013674051955,66.31205420184413]},"geometry":{"type":"MultiLineString","coordinates":[[[10.39205906160764,63.431002047425466],[10.293816943703233,63.475978110242565],[9.900067222639304,63.501086792509675],[9.84381726248734,63.57628059164396],[9.675067382031267,63.67620878431391],[9.506323501571089,63.676230956459754],[9.450067541423229,63.72607438667935],[9.450067541423229,63.82549832634276],[9.618817421879303,63.9245724837926],[9.900067222639304,64.12167519439019],[10.237566983551268,64.31739001144432],[10.462566824159307,64.46326983353534],[10.80006658507127,64.53591955943627],[11.025066425679308,64.70468599661157],[11.237370962780789,64.86207950442952],[11.475066106895383,64.96778031868575],[11.700065947503239,65.01533984410804],[11.925065788111276,65.13386892664593],[12.207765587844191,65.4735570531501],[12.178190608795386,65.55616358500053],[12.262565549023423,65.62596082080819],[12.543815349783424,65.83404693088374],[12.631465287691332,66.02153874887068],[12.76881519039128,66.15441959094389],[13.018265013678757,66.19806628775785],[12.993815030999317,66.38080449917227],[13.05006499115128,66.44832399589347],[12.937565070847352,66.56045213116762],[13.05006499115128,66.71658546201289],[13.500064672367355,66.9599547424863],[13.950064353583429,67.13543790495432],[14.400064034799321,67.28600553539499],[14.512563955103431,67.4394931950777],[14.90631367616736,67.61151472096566],[15.131313516775398,67.65432532704786],[15.356313357383435,67.78229156045444],[15.581313197991472,67.90956187665087],[15.887562981041073,67.97293705344723],[16.143812799511473,68.0571685658069],[16.17188777962302,68.1411682869287],[16.127072811370326,68.22074777678259],[16.06516285522785,68.28972225489511],[16.152422793411976,68.36187412811805],[16.312562679967364,68.38642806305175],[16.668812427596713,68.41735740826869],[17.073261876822006,68.4078545509302],[17.560022733754895,68.42054850557746]]]}},{"type":"Feature","properties":{"id":"tverrlinken","name":"Tverrlinken","color":"#1bbaae","feature_id":"tverrlinken-0","coordinates":[13.563638846080835,66.26218454683203]},"geometry":{"type":"MultiLineString","coordinates":[[[13.018229076204191,66.19806628775785],[13.05006499115128,66.24521776261776],[13.218814871607353,66.2904946262551],[13.387564752063426,66.31310255415397],[13.563638846080835,66.29296835834754],[13.563638846080835,66.24773433541888],[13.624478646731358,66.22416306028732],[13.781314473127356,66.256524477016],[13.950064353583429,66.256524477016],[14.141958748893504,66.31371064643902]]]}},{"type":"Feature","properties":{"id":"national-digital-transmission-network-ndtn","name":"National Digital Transmission Network (NDTN)","color":"#a3492e","feature_id":"national-digital-transmission-network-ndtn-0","coordinates":[122.2201301190194,10.321516903939438]},"geometry":{"type":"MultiLineString","coordinates":[[[121.61229277215068,13.935275186648868],[121.72498800481618,13.492128176464083],[121.83748792512029,13.054150695298627],[121.83748792512029,12.72515592356304],[121.72498800481618,12.395734000022975],[121.3874882439044,12.230866087669199],[121.05018848285071,12.363012914770698]],[[121.05018848285071,12.363012914770698],[121.04998848299225,11.735650161405832],[121.27498832360028,11.294709319565477],[121.49998816420832,10.41081650540272],[121.94998784542422,10.189442766507625],[122.43363382518383,10.425900114515422],[122.28748760633636,9.967915186974132],[122.28748760633636,9.52441134501949],[122.84998720785636,8.858082310478219],[123.18748696876833,8.858082310478219],[123.29998688907226,9.08033076823294],[123.28143690221334,9.295503918747997]],[[122.43363382518383,10.425900114515422],[122.56210741119708,10.720150965231593]]]}},{"type":"Feature","properties":{"id":"rnne-rdvig","name":"Rønne-Rødvig","color":"#2aaf8a","feature_id":"rnne-rdvig-0","coordinates":[13.533267873522265,55.11321571659684]},"geometry":{"type":"MultiLineString","coordinates":[[[12.37186547159436,55.256124793285025],[12.825065150543425,55.14215100378178],[14.400064034799321,55.07780072164767],[14.708363816396886,55.10103597610051]]]}},{"type":"Feature","properties":{"id":"chennai-andaman-nicobar-islands-cable-cani","name":"Chennai-Andaman & Nicobar Islands Cable (CANI)","color":"#b0c435","feature_id":"chennai-andaman-nicobar-islands-cable-cani-0","coordinates":[86.72745832143364,12.314949113858496]},"geometry":{"type":"MultiLineString","coordinates":[[[92.91387560237854,12.506922878216733],[92.9236029863318,12.45624046231183],[92.93506699361643,12.39649703607185],[92.9875083627165,12.175887185507976],[92.98760601889738,11.97601592593175],[92.87075844542325,11.735650161405832],[92.72647339138615,11.623375589234106],[92.86875844683999,11.294709319565477],[92.75625852653606,10.85308969074528],[92.59717660798114,10.709095116736076],[92.70000856638391,10.41081650540272],[92.92500840699213,9.52441134501949],[92.81641473392081,9.177521361414106],[93.03750832729607,9.08033076823294],[93.31875812805606,8.301880168760913],[93.48145332530132,8.172274246767333],[93.4063830659818,8.07917551824101],[93.45958302829436,7.967776882259704],[93.54375796866411,7.856347922592446],[93.93750768972804,7.29876275445952],[93.92744910310356,7.031336722040295]],[[92.72647339138615,11.623375589234106],[92.81250848668803,11.294709319565477],[92.25000888516803,11.073982781226615],[89.10001111665605,11.735650161405832],[83.70001494206389,13.054150695298627],[81.45001653598388,12.834868817846521],[80.24298739105474,13.06385310188338]]]}},{"type":"Feature","properties":{"id":"dunant","name":"Dunant","color":"#ab7a2b","feature_id":"dunant-0","coordinates":[-38.8058960245367,39.91168430026707]},"geometry":{"type":"MultiLineString","coordinates":[[[-1.968324369680654,46.69399663348963],[-2.699923851408691,46.5823550820958],[-5.399921938704683,46.5823550820958],[-9.899918750864702,46.272182853813646],[-16.199914287888834,45.331071073324864],[-23.399909187344846,44.05151922873524],[-39.59989771112098,39.6983233549332],[-50.39989006030513,37.411283634923244],[-61.19988240948919,37.589786573603064],[-72.44987443988924,37.23235432155614],[-74.69987284596934,36.723078949445465],[-76.05919805488554,36.755008440642534]]]}},{"type":"Feature","properties":{"id":"gulf-of-california-cable","name":"Gulf of California Cable","color":"#863f98","feature_id":"gulf-of-california-cable-0","coordinates":[-109.82192082171105,24.990270773742214]},"geometry":{"type":"MultiLineString","coordinates":[[[-110.30544762264607,24.10253662184481],[-110.13734774172947,24.53265756616073],[-109.57484814020947,25.348717422116714],[-109.05034851177005,25.60068153474025]]]}},{"type":"Feature","properties":{"id":"coral-sea-cable-system-cs","name":"Coral Sea Cable System (CS²)","color":"#3c4b9f","feature_id":"coral-sea-cable-system-cs-0","coordinates":[155.4046202241159,-21.16930979043273]},"geometry":{"type":"MultiLineString","coordinates":[[[151.20699836948359,-33.86955536437545],[152.09996648689665,-33.3676367639474],[154.2803625585856,-31.85146566557725],[155.69996393662453,-28.743810281149894],[155.69996393662453,-25.540896076259312],[155.24996425540846,-18.880139975101173],[154.79996457419256,-15.441023659568087],[157.04996298027257,-11.943944931746815],[159.18746146604866,-10.17745743036107],[159.29996138635258,-9.73423534230066],[159.24376142616532,-9.29042430103552],[159.5249612269606,-9.012754814881783],[159.74996106756865,-9.012754814881783],[159.94976092602863,-9.42905364643845]],[[154.79996457419256,-15.441023659568087],[152.09996648689665,-14.135775375064666],[148.49996903716843,-11.503333845984299],[147.2624699138245,-10.17745743036107],[147.1885196909836,-9.479589292697288]],[[160.70132758111177,-8.7746372976857],[160.64996043000062,-9.179382545871277],[160.5374605096967,-9.29042430103552],[160.19996074878455,-9.29042430103552],[159.94976092602863,-9.42905364643845]],[[156.3958618811439,-6.708991470564002],[156.59996329905667,-7.28668409428739],[157.38746274118455,-7.732822794391767],[158.3999620239206,-8.067119032529211],[159.74996106756865,-8.901626855396449],[159.94976092602863,-9.42905364643845]],[[157.19068553683346,-8.24208943896252],[157.2749628208806,-7.955717094334652],[157.38746274118455,-7.732822794391767]]]}},{"type":"Feature","properties":{"id":"malbec","name":"Malbec","color":"#939597","feature_id":"malbec-0","coordinates":[-50.43830221621102,-31.718498826133224]},"geometry":{"type":"MultiLineString","coordinates":[[[-48.59989133544111,-32.61276000573574],[-50.97802017710233,-31.45596294505899],[-51.22802000000015,-30.034259]]]}},{"type":"Feature","properties":{"id":"malbec","name":"Malbec","color":"#b13885","feature_id":"malbec-1","coordinates":[-49.047903581810445,-32.875181128087604]},"geometry":{"type":"MultiLineString","coordinates":[[[-46.12489308875306,-25.134186547061336],[-46.3498929293611,-27.95174728521976],[-48.59989133544111,-32.61276000573574],[-53.99988751003309,-35.77578304431546],[-56.695442241101844,-36.47097855291435]],[[-46.412490541266344,-24.008866255391673],[-46.12489308875306,-25.134186547061336]],[[-43.20956515399876,-22.903486555497956],[-43.64989484206502,-23.905969261790265],[-44.99989388571306,-24.52157920760467],[-46.12489308875306,-25.134186547061336]]]}},{"type":"Feature","properties":{"id":"st-pierre-and-miquelon-cable","name":"St. Pierre and Miquelon Cable","color":"#63ae45","feature_id":"st-pierre-and-miquelon-cable-0","coordinates":[-56.18893456436545,47.06659226124524]},"geometry":{"type":"MultiLineString","coordinates":[[[-55.835899770663836,47.07196443816744],[-56.137385995809176,47.120911503379745],[-56.193635955961135,47.120877480719855],[-56.349885845272226,47.08368621678281],[-56.193635955961135,47.08262225469689],[-56.13733599584455,46.89066036987498],[-56.180585965205815,46.77583029845104],[-55.9686361153531,46.81382628533593],[-55.80328623248849,46.86895827528439]]]}},{"type":"Feature","properties":{"id":"sea2shore","name":"sea2shore","color":"#50c2bc","feature_id":"sea2shore-0","coordinates":[-71.50328331663805,41.308290790740486]},"geometry":{"type":"MultiLineString","coordinates":[[[-71.59367504642896,41.191963141608895],[-71.49162511872214,41.32329405220932],[-71.44277515332787,41.44139346840826]]]}},{"type":"Feature","properties":{"id":"nordbalt","name":"NordBalt","color":"#7b439a","feature_id":"nordbalt-0","coordinates":[18.28746856397312,55.716652093821786]},"geometry":{"type":"MultiLineString","coordinates":[[[15.908662966093944,56.74380010331269],[16.08756283935933,56.593792978418996],[16.20006275966344,56.34522292681988],[16.312562679967364,56.03221697111693],[17.325061962703444,55.716652093821786],[20.475059731215417,55.716652093821786],[21.149959253110477,55.695748609598844]]]}},{"type":"Feature","properties":{"id":"southeast-asia-japan-cable-2-sjc2","name":"Southeast Asia-Japan Cable 2 (SJC2)","color":"#cfc12a","feature_id":"southeast-asia-japan-cable-2-sjc2-0","coordinates":[120.24622973092585,20.280825284596364]},"geometry":{"type":"MultiLineString","coordinates":[[[103.9870122893147,1.389451396800233],[104.19209042528557,1.276482074072847],[104.28790035741287,1.327893755020881],[104.45655023793971,1.468426767331968],[104.8499999592161,2.817450442654169],[105.74999932164808,4.0527020972683],[107.09999836529612,4.837826391986557],[107.99999772772827,5.510071711803246],[110.69999581502417,7.744889052551447],[111.93749493836829,9.967915186974132],[112.94999422110418,12.615395567393394],[114.52499310476436,17.10851996079568],[114.7499929459683,18.251816319028222],[116.99999135204831,19.529070924350908],[120.14998912056028,20.269544035929588],[121.04998848299225,20.375041253465433],[122.84998720785636,20.90143978523765],[124.87498577332833,22.05298561667754],[125.7749851357603,24.12261698700344],[126.22498481697637,25.55188275942587],[126.67498449819227,26.1593079707739],[128.24998338244836,28.359233526108557],[129.5999824260964,28.75448641587171],[131.39998115096031,29.049948644465697],[134.99997860068837,30.126049846722832],[136.7999773255523,30.901396088515508],[138.59997605041642,32.052708023486204],[139.04997573163232,32.43331330641721],[139.72497525345642,33.93964008831966],[140.23122489482446,34.405022750715936],[140.2030999141525,34.69072647741027],[140.0343500336966,34.852445708846155],[139.9546750907351,34.97672776066548]],[[114.7499929459683,18.251816319028222],[114.29999326475222,20.796306105108872],[114.20309333339704,22.22214038855274]],[[120.14998912056028,20.269544035929588],[120.48748888147225,21.635297384859552],[120.66208875778415,22.249168196123776]],[[112.94999422110418,12.615395567393394],[111.59999517745614,13.273238157547594],[110.02499629320025,13.710817738179635],[109.21959686375268,13.782910441432074]],[[100.5951029728293,7.198818071264419],[101.70000219070414,7.410337121715135],[103.04883123518101,7.563123274145237],[105.29999964043219,6.18155703253704],[107.09999836529612,4.837826391986557]],[[138.59997605041642,32.052708023486204],[137.69997668798445,33.09551711711581],[136.87399727311598,34.33682825203173]],[[129.5999824260964,28.75448641587171],[129.37498258548837,30.901396088515508],[129.1499827448803,31.670513047087127],[129.26248266518442,32.8123187832876],[129.37498258548837,34.31215165223547],[128.999482851496,35.17030187110516]],[[128.24998338244836,28.359233526108557],[127.12498417940834,29.540507745394493],[125.7749851357603,30.320465424761444],[124.6499859327203,30.901396088515508],[122.84998720785636,31.286738814391754],[122.17498768603225,31.142418511463656],[121.89608788360773,30.935661747314708]],[[126.22498481697637,25.55188275942587],[123.74998657028833,25.653336613276053],[122.84998720785636,25.75470426341523],[121.94998784542422,25.653336613276053],[121.4876881729218,25.18162628187736]]]}},{"type":"Feature","properties":{"id":"no-uk","name":"NO-UK","color":"#ac84bb","feature_id":"no-uk-0","coordinates":[2.4897036299592137,56.52964381322203]},"geometry":{"type":"MultiLineString","coordinates":[[[-1.617778330734679,54.97824921450541],[-0.899925126544665,55.33458061322904],[0.900073598319269,55.84318584148108],[2.534972440141771,56.54919247234551],[3.375071845007315,58.05131589106027],[4.050071366831244,58.641677771385005],[5.17507056987125,58.90413203005045],[5.512570330783214,59.020142808923744],[5.730770176208511,58.9708214866686]]]}},{"type":"Feature","properties":{"id":"sakhalin-kuril-islands-cable","name":"Sakhalin-Kuril Islands Cable","color":"#c1b230","feature_id":"sakhalin-kuril-islands-cable-0","coordinates":[147.16528656863258,45.47093386616253]},"geometry":{"type":"MultiLineString","coordinates":[[[143.14553369999982,46.859505191720295],[143.454021634475,46.93117918037432],[145.57497110926448,46.272182853813646],[147.59996967473646,45.251927381214465],[147.88151244403892,45.08668996809888],[147.59996967473646,45.172673246984274],[147.05997005727735,45.037723080177784],[146.75634527236804,44.694829089578164],[146.6999703123045,44.37405751055857],[146.24997063108842,44.05151922873524],[145.85856465836412,44.034531551008044],[146.24997063108842,43.88958773629964],[146.7472359038211,43.7936197418982]]]}},{"type":"Feature","properties":{"id":"meltingpot-indianoceanic-submarine-system-metiss","name":"Meltingpot Indianoceanic Submarine System (METISS)","color":"#69bd45","feature_id":"meltingpot-indianoceanic-submarine-system-metiss-0","coordinates":[44.94191055148978,-26.361278448096385]},"geometry":{"type":"MultiLineString","coordinates":[[[30.901552344990282,-30.021618287456878],[32.40005128343957,-29.920697111268968],[38.70004682046353,-27.553513996438145],[45.00004235748766,-26.350174904573713],[47.700040444783745,-25.540896076259312],[49.0500394884316,-24.72611802920699],[51.75003757572769,-23.08058350574764],[53.55003630059162,-20.995131543025785],[54.00003598180769,-20.574419057276128],[54.90003534423966,-20.152543786018732],[55.80003470667163,-20.152543786018732],[56.70003406910378,-20.046895587328667],[57.495133505847896,-20.122124416034133]],[[46.98534095108351,-25.022510261484957],[47.25004076356767,-25.33771218660113],[47.700040444783745,-25.540896076259312]],[[54.00003598180769,-20.574419057276128],[54.45003566302377,-20.679706953509093],[54.90003534423966,-20.890063499753193],[55.303235058609346,-20.944427907259005]]]}},{"type":"Feature","properties":{"id":"java-bali-cable-system-jbcs","name":"Java Bali Cable System (JBCS)","color":"#9b3794","feature_id":"java-bali-cable-system-jbcs-0","coordinates":[114.42270747463041,-8.376206040827705]},"geometry":{"type":"MultiLineString","coordinates":[[[114.3103932573849,-8.44802754685532],[114.47074314379155,-8.34548869989964],[114.52729310373104,-8.294580235999058]]]}},{"type":"Feature","properties":{"id":"jakarta-surabaya-cable-system-jayabaya","name":"Jakarta Surabaya Cable System (JAYABAYA)","color":"#4b3c97","feature_id":"jakarta-surabaya-cable-system-jayabaya-0","coordinates":[110.10010340348813,-6.867191961808391]},"geometry":{"type":"MultiLineString","coordinates":[[[107.12099835041957,-5.981154260263285],[107.32499820590417,-5.833801119425265],[107.5499980465122,-5.833801119425265],[108.44999740894416,-6.169450529574503],[108.6749972495522,-6.393099497823911],[108.6749972495522,-6.560772244563566],[108.55734425507973,-6.716653419880512],[108.78749716985612,-6.616650693475355],[109.79999645259221,-6.616650693475355],[110.02499629320025,-6.730871350313617],[110.18788680280707,-7.026520241492837],[110.24999613380828,-6.730871350313617],[110.69999581502417,-6.281287025048582],[111.37499533684829,-6.169450529574503],[112.16249477897614,-6.393099497823911],[112.61249446019221,-6.616650693475355],[112.71972094673198,-7.271863121195997]]]}},{"type":"Feature","properties":{"id":"southern-cross-next","name":"Southern Cross NEXT","color":"#b61e51","feature_id":"southern-cross-next-0","coordinates":[-152.82053849664103,11.385632586825267]},"geometry":{"type":"MultiLineString","coordinates":[[[151.19625712709274,-33.913571605570375],[152.0920653907799,-34.2066327368011],[154.8286447337135,-33.129107644117234],[160.64996043000062,-31.468437004267024],[166.49995628580868,-27.95174728521976],[173.69995118526478,-24.72611802920699],[178.1999479974248,-20.574419057276128],[179.09994735985677,-19.729525450021],[179.54994704107284,-19.305384072361306],[179.9999467222889,-18.880139975101173]],[[173.69995118526478,-24.72611802920699],[175.04995022891282,-30.30995334464681],[175.5057116247971,-33.34801975644684],[174.9374503086087,-36.140033391295425],[174.99370026876068,-36.59297842795038],[174.77324144056075,-36.78413802465393]],[[-179.99979825051412,-18.880139975101173],[-177.29980016321815,-16.738110438702464],[-175.49980143835413,-14.571726491332546],[-173.92480255409802,-11.943944931746815],[-172.79980335105822,-11.062032109909483],[-171.674804148018,-10.620064860363238],[-171.44980430741026,-9.29042430103552],[-169.19980590133008,-4.825692499217419],[-162.89981036430612,2.367912558705407],[-160.19981227700995,5.061986954416114],[-159.29981291457798,5.957818681088533],[-138.59982757864174,23.298598065875897],[-127.79983522945767,28.55704546571133],[-120.59984033000157,33.09551711711581],[-118.79984160513764,33.799525734581415],[-118.39955344545581,33.86223405937197]],[[-157.42781424071939,1.872154031030243],[-157.724814030322,2.817450442654169],[-158.849813233362,4.164912849976942],[-160.19981227700995,5.061986954416114]],[[-171.44980430741026,-9.29042430103552],[-171.674804148018,-9.29042430103552],[-171.8115040511786,-9.174626307490298]],[[178.4382278286252,-18.123069640992355],[178.6499476786407,-18.880139975101173],[179.09994735985677,-19.729525450021]],[[179.34974953238174,-16.80801177359569],[179.09994735985677,-17.168553094226155],[179.09994735985677,-17.70520217268605],[179.3249472004648,-18.880139975101173],[179.54994704107284,-19.305384072361306]]]}},{"type":"Feature","properties":{"id":"havfrueaec-2","name":"Havfrue/AEC-2","color":"#30aa9f","feature_id":"havfrueaec-2-0","coordinates":[-32.13340756110129,50.01928620984207]},"geometry":{"type":"MultiLineString","coordinates":[[[-16.199914287888834,55.07780072164767],[-12.599916838160784,54.81936191424915],[-10.799918113296759,54.16597178715178],[-10.349918432080775,53.90168472607427],[-9.696518894955075,53.77080186175808]],[[-74.06286329723282,40.15283384719588],[-71.09987539624129,40.55848045058698],[-68.39987730894529,41.2387523289666],[-61.19988240948919,42.41235450073586],[-50.39989006030513,44.694829089578164],[-39.59989771112098,47.50228998113266],[-23.399909187344846,52.96339810559356],[-16.199914287888834,55.07780072164767],[-8.999919388432733,58.99117670269853],[-5.399921938704683,59.679663707208995],[-1.799924488976633,59.45171731890513],[1.800072960751236,58.52439396084473],[4.500071048047319,57.57189027900508],[5.400070410479286,56.717468482041156],[6.300069772911254,56.22032688484507],[7.200069135343221,55.84318584148108],[7.650068816559295,55.77997032709834],[8.329168335478972,55.75165023178103]],[[7.996258571315225,58.15106571642484],[7.987568576875359,57.932056586951404],[6.300069772911254,57.57189027900508],[4.500071048047319,57.57189027900508]]]}},{"type":"Feature","properties":{"id":"damai-cable-system","name":"DAMAI Cable System","color":"#813e97","feature_id":"damai-cable-system-0","coordinates":[100.32496780231568,2.472045411688132]},"geometry":{"type":"MultiLineString","coordinates":[[[98.67598433294692,3.752031394331533],[98.88750418310413,3.82823430332105],[99.45000378402823,3.641132700076536],[99.7875035455361,3.266814816815666],[100.3500031470561,2.817450442654169],[100.31333654252124,2.452337743745469],[100.46250306736002,2.705081160335761],[100.57500298766413,2.705081160335761],[101.25000250948806,2.425986659073406],[101.75825214943936,2.143087178471855],[101.87075206974329,1.976445096071173],[101.72812717078021,1.805588379583273],[101.44766236946417,1.665522797277061]]]}},{"type":"Feature","properties":{"id":"japan-guam-australia-south-jga-s","name":"Japan-Guam-Australia South (JGA-S)","color":"#944b9d","feature_id":"japan-guam-australia-south-jga-s-0","coordinates":[162.3847454355713,-8.271835486920656]},"geometry":{"type":"MultiLineString","coordinates":[[[144.69469829535797,13.464777824933044],[144.81564664717723,13.273238157547594],[145.34997126865645,12.834868817846521],[146.24997063108842,12.175887185507976],[147.14996999352056,11.735650161405832],[149.39996839960057,10.85308969074528],[151.1999671244645,9.52441134501949],[152.99996584932845,8.190543417795496],[155.24996425540846,4.164912849976942],[156.59996329905667,-0.331409329660265],[157.72496250209667,-3.029995968008661],[159.29996138635258,-4.825692499217419],[161.5499597924326,-6.616650693475355],[162.44995915486456,-8.401139048122838],[162.89995883608063,-10.17745743036107],[161.99995947364866,-13.698987269610743],[159.29996138635258,-18.880139975101173],[157.94996234270454,-25.540896076259312],[156.59996329905667,-28.743810281149894],[154.60976590078016,-31.85146566557725],[152.09996648689665,-33.46154129054857],[151.273987072028,-33.76116106060912]],[[157.94996234270454,-25.540896076259312],[153.8999652117606,-26.551620801657084],[153.08946578592602,-26.651853879370247]]]}},{"type":"Feature","properties":{"id":"japan-guam-australia-north-jga-n","name":"Japan-Guam-Australia North (JGA-N)","color":"#5bba4d","feature_id":"japan-guam-australia-north-jga-n-0","coordinates":[139.81213538696954,23.813815905960602]},"geometry":{"type":"MultiLineString","coordinates":[[[144.69469829535797,13.489135592728266],[144.34997197706537,14.042738209008009],[143.77497238440057,15.23578178303578],[143.09997286257644,16.965102599435927],[140.84997445649643,21.635297384859552],[139.27497557224055,24.94136317175375],[139.04997573163232,27.76358852605777],[139.27497557224055,30.901396088515508],[139.6124753331525,32.43331330641721],[139.6124753331525,33.93964008831966],[139.7812252136084,34.405022750715936],[139.94997509346857,34.69072647741027],[140.00622505362054,34.852445708846155],[139.96102617184783,34.97406980589044]]]}},{"type":"Feature","properties":{"id":"tonga-domestic-cable-extension-tdce","name":"Tonga Domestic Cable Extension (TDCE)","color":"#e11e25","feature_id":"tonga-domestic-cable-extension-tdce-0","coordinates":[-174.933639039813,-19.72491637033035]},"geometry":{"type":"MultiLineString","coordinates":[[[-173.98368251298305,-18.647678684858587],[-174.2623023150101,-18.880139975101173],[-174.93730183683402,-19.729525450021],[-175.16230167744206,-20.574419057276128],[-175.20000165073512,-21.133465659292966]],[[-174.93730183683402,-19.729525450021],[-174.59980207592204,-19.729525450021],[-174.35214225136664,-19.81379624685291]]]}},{"type":"Feature","properties":{"id":"paniolo-cable-network","name":"Paniolo Cable Network","color":"#eb2d24","feature_id":"paniolo-cable-network-0","coordinates":[-159.02444356405402,21.43722512798135]},"geometry":{"type":"MultiLineString","coordinates":[[[-159.72421261392932,21.97028767315785],[-159.63731267548994,21.84429407917369],[-158.849813233362,21.32123529551186],[-158.3998135521461,21.32123529551186],[-158.22066328843266,21.4634468234482]],[[-157.66890586680282,21.272011061319994],[-157.49981418971396,21.111485983488812],[-157.1623144288019,21.006499845176737],[-157.02388510728736,21.09335900500387],[-156.82481466788994,21.006499845176737],[-156.6844827360524,20.900892404445475]],[[-156.44000048737092,20.66359808291422],[-156.26231506636992,20.480466375975812],[-155.98106587237422,20.269544035929588],[-155.83140598217537,20.03998810568653]]]}},{"type":"Feature","properties":{"id":"bodo-rost-cable","name":"Bodo-Rost Cable","color":"#395eab","feature_id":"bodo-rost-cable-0","coordinates":[13.22363413547445,67.33614800460244]},"geometry":{"type":"MultiLineString","coordinates":[[[12.070165685321246,67.50255969476507],[12.150065628719313,67.4394931950777],[13.950064353583429,67.26621947299489],[14.400064034799321,67.28599754127343]]]}},{"type":"Feature","properties":{"id":"malta-gozo-cable","name":"Malta-Gozo Cable","color":"#af2724","feature_id":"malta-gozo-cable-0","coordinates":[14.307914119340358,35.9795875702152]},"geometry":{"type":"MultiLineString","coordinates":[[[14.27136412597179,36.02048335169237],[14.307914100079453,35.979587591777396],[14.344464074186934,35.938670628992085]]]}},{"type":"Feature","properties":{"id":"mauritius-and-rodrigues-submarine-cable-system-mars","name":"Mauritius and Rodrigues Submarine Cable System (MARS)","color":"#2fb34a","feature_id":"mauritius-and-rodrigues-submarine-cable-system-mars-0","coordinates":[60.4985560459923,-20.368445549627328]},"geometry":{"type":"MultiLineString","coordinates":[[[57.485483512684034,-20.473995660946287],[57.712533351839674,-20.679706953509093],[58.050033112751635,-20.679706953509093],[59.40003215639986,-20.574419057276128],[61.65003056247987,-20.152543786018732],[62.775029765519875,-19.683016915799705],[63.33752936703988,-19.570614456630153],[63.44822928861902,-19.674355986985844]]]}},{"type":"Feature","properties":{"id":"palapa-ring-east","name":"Palapa Ring East","color":"#cfc12a","feature_id":"palapa-ring-east-0","coordinates":[135.66860583860853,-1.155981803828361]},"geometry":{"type":"MultiLineString","coordinates":[[[120.25301904757286,-9.645765890160455],[120.59998880177636,-9.62333678834261],[121.61248808451225,-10.28816791219512],[121.87987070759588,-10.457810041841308],[122.06248776572832,-10.398839577127402],[122.84998720785636,-10.620064860363238],[123.03524098287089,-10.733304228874694],[123.07498704846441,-10.509472025995617],[123.29998688907226,-10.343606996551372],[123.58338668830928,-10.182939736570859]],[[124.45350169691167,-8.160824687115921],[124.6499859327203,-8.067119032529211],[125.32498545454442,-8.067119032529211],[125.66248521486048,-8.085683049445153],[126.44998465758441,-8.122808517987155],[126.89998433880031,-7.955717094334652],[127.34998402001638,-7.955717094334652],[127.691487684342,-8.15809386700378],[127.78788370920816,-8.150378145203275],[127.91248362153638,-8.0114218741176],[128.92498290427227,-8.0114218741176],[129.5999824260964,-8.178490278944933],[130.04998210731227,-8.178490278944933],[130.83748154944033,-8.067119032529211],[131.21306722087195,-8.052228527722814],[130.83748154944033,-7.844284877164334],[130.7249816291364,-7.621331259953079],[130.83748154944033,-7.175078903541251],[131.62498099156835,-6.281287025048582],[132.29998051339228,-5.777839699209677],[132.7521286305864,-5.626541451914346],[132.6937302344564,-5.385957847173066],[132.86453011346026,-5.128744870833412],[133.20212987430145,-5.128744870833412],[134.0999792382564,-5.945707155070644],[134.2687291187123,-6.05759043242458],[134.55017423183406,-6.19469348259935],[134.77497876008033,-6.169450529574503],[135.2249784412964,-5.833801119425265],[136.01247788342445,-5.609922463067976],[136.46247756464035,-5.385957847173066],[136.6874774052484,-5.049857167366764],[136.88962726204366,-4.55024753995573]],[[139.39645986117984,-7.102404536072552],[139.04997573163232,-7.175078903541251],[138.59997605041642,-7.063446338991064],[138.26247628950446,-6.616650693475355],[138.14997636920054,-5.945707155070644],[138.12136310822024,-5.535665777980563],[137.8124766082884,-5.497950688314882],[137.24997700676838,-5.273944363641298],[137.02497716616034,-5.049857167366764],[136.88962726204366,-4.55024753995573],[136.74372736540036,-4.37714437553184],[135.78747804281642,-3.591554479962115],[135.50163840155787,-3.372233814094874]],[[135.50163840155787,-3.372233814094874],[135.56247820220838,-3.029995968008661],[135.89997796312053,-2.580536704984131],[136.12497780372837,-2.130918480960333],[136.15700903103723,-1.756135138662891],[136.3499776443364,-2.018492325403296],[136.7999773255523,-2.018492325403296],[136.96882720593763,-1.906058394384765],[137.02502716612523,-1.681168935904995],[136.7999773255523,-1.456253566768442],[136.3499776443364,-1.456253566768442],[135.89997796312053,-1.343787247896323],[135.6749781225123,-1.175078194688512],[135.61872816236036,-1.006508927977932],[135.63855236706684,-0.729535713493556],[135.44997828190446,-0.837630979982873],[134.99997860068837,-0.837630979982873],[134.32497907886446,-0.781386636225587],[134.06198926516882,-0.861458343462594],[134.21247915856034,-1.006358951224796],[134.32497907886446,-1.231315750217412],[134.32497907886446,-1.681168935904995],[134.6624788397764,-2.130918480960333],[135.1124785209923,-3.029995968008661],[135.50163840155787,-3.372233814094874]],[[134.32497907886446,-1.681168935904995],[133.87497939764836,-2.018492325403296],[133.48208167334195,-2.202398136007225]]]}},{"type":"Feature","properties":{"id":"palapa-ring-west","name":"Palapa Ring West","color":"#34ae9c","feature_id":"palapa-ring-west-0","coordinates":[106.61227488662865,3.243198841572958]},"geometry":{"type":"MultiLineString","coordinates":[[[108.98720405963194,0.906050180869095],[108.78749716985612,1.243490076978041],[108.44999740834827,2.967259208499635],[108.44999740834827,3.41655961832325],[108.3774974597078,3.940874826243066],[108.14286872026702,3.945639298144141],[107.99999772772827,3.840706312160841],[107.32499820590417,3.491423322320592],[106.87499852468808,3.266814816815666],[106.21112899497969,3.207140223034204],[105.97499916225611,2.592701464601932],[105.74999932164808,2.143087178471855],[104.86707219123916,1.476960063746916],[104.62500011860807,1.299726182129338],[104.28790035741287,1.117266982599433],[104.0166370000003,1.066798000000349],[104.0347505367464,0.906050180869095],[104.06010051878842,0.793562652607196],[104.17500043739219,0.341586322057578],[104.40000027800004,0.006088583243203],[104.63544932995589,-0.162758734486483]],[[104.63544932995589,-0.162758734486483],[104.40000027800004,-0.218910724747347],[104.11875047724004,-0.331409329660265],[103.46670093915829,-0.816543192375546]],[[104.0166370000003,1.066798000000349],[103.87813064769749,1.106743972267005],[103.50000091556807,0.962392648220919],[103.40488379485406,0.769834409364204]],[[103.40488379485406,0.769834409364204],[103.27500107436413,0.962392648220919],[103.05000123375628,1.168506749040978],[102.73892645412447,1.22086532601435],[102.66702150506266,0.932556749588586]],[[102.66702150506266,0.932556749588586],[102.57030157358,1.22086532601435],[102.08008004585756,1.489220605654429]],[[102.08008004585756,1.489220605654429],[101.72812717078021,1.637214579479584],[101.44766236946417,1.665522797277061]]]}},{"type":"Feature","properties":{"id":"palapa-ring-middle","name":"Palapa Ring Middle","color":"#9d402f","feature_id":"palapa-ring-middle-0","coordinates":[126.30923830411119,4.037101645710124]},"geometry":{"type":"MultiLineString","coordinates":[[[124.8396357983706,1.490779296094715],[124.76248585302442,1.805788280129153],[125.2124855342403,2.592701464601932],[125.37293463932662,2.754634201538373],[125.2124855342403,2.817450442654169],[125.2124855342403,3.266814816815666],[125.48553221581143,3.600934571622902],[125.32498545454442,3.715978119298069],[125.43748537484834,3.940475772228814],[126.44998465758441,4.0527020972683],[126.71726962448717,4.042960839837272],[126.89998433880031,3.940475772228814],[127.12498417940834,3.715978119298069],[127.34998402001638,3.266814816815666],[127.91248362153638,2.367912558705407],[128.13748346214442,2.255504211923801],[128.40076452563352,2.365668382611338],[128.1937334222964,2.143087178471855],[128.13748346214442,1.918228780215599],[127.967854676061,1.714620455999489]],[[127.36131213699137,0.79580852872504],[127.37810900009228,0.739317546778545],[127.4040855441903,0.674041311933418],[127.46248394032031,0.68107206531244],[127.56141387023752,0.734977909038259]],[[122.79275724839854,-0.938788738945164],[123.07498704846441,-1.118839506905277],[123.29998688907226,-1.146958988943708],[123.3402212355698,-1.301025233929911],[123.41248680937636,-1.203172122899323],[123.63748664998441,-1.231315750217412],[123.69373661013618,-1.400021081752668],[123.52752579038167,-1.600245159061262],[123.69378661010107,-1.62499236735134],[124.03128637101304,-1.849888622119433],[124.31248617180833,-2.130918480960333],[124.53748601241637,-2.130918480960333],[124.6499859327203,-2.018492325403296],[124.77400928236096,-1.826803651259445],[124.98748569363227,-2.018492325403296],[125.66248521545637,-2.35574573664619],[125.8874850560655,-2.580536704984131],[126.11248489667227,-2.580536704984131],[126.16873485682441,-2.468145972656139],[126.11248489667227,-2.299542189691302],[125.93699502099096,-2.201477967855649]],[[122.51297744659723,-3.998469097245179],[122.79378724766912,-3.984542968792977],[122.90623716800833,-4.040605164746417],[122.96248712816029,-4.096713358263508],[123.12391279505485,-4.136112209115025],[122.96248712816029,-4.37714437553184],[122.84998720785636,-4.489307688629284],[122.7937372477044,-4.657520205679877],[122.72108104917469,-4.8324068568668],[122.79173724912133,-4.825692499217419],[122.85611022178392,-4.792242310211541]],[[122.55994835082258,-5.389069040125302],[122.57073740567944,-5.439965887100162],[122.59686238717225,-5.507088069175421]]]}},{"type":"Feature","properties":{"id":"ulysses-2","name":"Ulysses 2","color":"#b96528","feature_id":"ulysses-2-0","coordinates":[3.171539084277287,52.55491468334153]},"geometry":{"type":"MultiLineString","coordinates":[[[1.72927301090669,52.46882263773048],[2.25007264196731,52.55491468334153],[3.825071526223208,52.55491468334153],[4.275071207439282,52.52070133452007],[4.61367096757211,52.458501705101135]]]}},{"type":"Feature","properties":{"id":"natitua","name":"Natitua","color":"#b75c28","feature_id":"natitua-0","coordinates":[-144.18136825994162,-13.277979584961974]},"geometry":{"type":"MultiLineString","coordinates":[[[-140.14211164232327,-8.860524087732477],[-139.72482678168183,-9.29042430103552],[-139.38732702076976,-9.845097085826806],[-139.4998269410738,-10.17745743036107],[-140.39982630350576,-11.062032109909483],[-144.89982311566578,-13.698987269610743],[-145.34982279688185,-14.135775375064666],[-145.46232271718586,-14.571726491332546],[-145.91232239840184,-14.78938079853903],[-146.6998218405299,-14.78938079853903],[-147.14982152174588,-15.224032284647373],[-147.14982152174588,-15.549434493126443],[-147.37482136235383,-15.874323281689833],[-148.72482040600187,-17.383402005942457],[-149.3081207740364,-17.723342219804366]],[[-145.91232239840184,-14.78938079853903],[-145.91232239840184,-14.571726491332546],[-145.9581231472061,-14.402858435885177]],[[-145.34982279688185,-14.135775375064666],[-145.124822956274,-14.244842547315455],[-144.97218634565303,-14.449768610910688]],[[-139.38732702076976,-9.845097085826806],[-139.04982725985772,-9.845097085826806],[-139.0211163426968,-9.754639758300325]],[[-147.14982152174588,-15.224032284647373],[-147.37482136235383,-15.115452462868513],[-147.6512860102537,-15.116206681570365]],[[-147.14982152174588,-15.549434493126443],[-146.92482168113784,-15.549434493126443],[-146.58732192022578,-15.549434493126443],[-145.79982247809784,-15.657788279357506],[-145.34982279688202,-15.982503786245708],[-145.12482295627382,-16.30669306561827],[-144.5623233547538,-16.738110438702464],[-143.9998237532338,-16.953454989809906],[-143.77482391262578,-16.84581334779892],[-143.77482391262578,-16.738110438702464],[-143.86720665968102,-16.539560977104614]],[[-146.58732192022578,-15.549434493126443],[-146.643572723027,-15.441023659568087],[-146.7861499043742,-15.28988809315997]],[[-146.92482168113784,-15.549434493126443],[-146.86857255795098,-15.657788279357506],[-146.67384529643186,-15.741365822737356]],[[-145.79982247809784,-15.657788279357506],[-145.79982247809784,-15.874323281689833],[-145.74357338333084,-16.090625820394795],[-145.62457846161735,-16.31034841245573]],[[-143.9998237532338,-16.953454989809906],[-143.4373241517138,-17.061035019881565],[-142.42482486897782,-17.49073216111125],[-141.18732574563379,-18.026426383713453],[-140.90812594342157,-18.215746090119286]]]}},{"type":"Feature","properties":{"id":"curie","name":"Curie","color":"#b2692e","feature_id":"curie-0","coordinates":[-88.45134022131478,6.516022999881251]},"geometry":{"type":"MultiLineString","coordinates":[[[-71.62043502747198,-33.04554123247811],[-72.67487428049728,-31.85146566557725],[-79.64986933934534,-18.45381377577717],[-84.14986615150535,-11.503333845984299],[-85.4998651951533,-6.616650693475355],[-85.94986487636929,0.568578852526193],[-87.06475643430802,5.534179292238662],[-92.69986009460932,9.52441134501949],[-98.09985626920157,12.615395567393394],[-104.39985180622544,16.10232559580297],[-108.44984893716946,18.678647022154717],[-114.29984479297735,22.469443964829516],[-118.79984160513736,27.76358852605777],[-119.6998409675696,31.286738814391754],[-119.24984128635361,32.8123187832876],[-118.79984160513764,33.75276987113061],[-118.41596126184768,33.91992001851462]],[[-85.94986487636929,0.568578852526193],[-82.34986742664142,2.367912558705407],[-80.09986902056123,5.061986954416114],[-79.4248694987373,7.29876275445952],[-79.64986933934534,8.190543417795496],[-79.56661939832034,8.950317108800572]]]}},{"type":"Feature","properties":{"id":"venezuelan-festoon","name":"Venezuelan Festoon","color":"#3cac48","feature_id":"venezuelan-festoon-0","coordinates":[-67.69388910487412,10.642183589613301]},"geometry":{"type":"MultiLineString","coordinates":[[[-71.45109585055839,10.395928632135224],[-71.54987507745727,10.46810224505713],[-71.59841019932456,10.688176132322466],[-71.54987507745727,11.073982781226615],[-71.09987539624129,11.405009147532946],[-70.42487587441727,11.625479959569855],[-70.20431603066386,11.708782466419155],[-69.86237627289727,11.625479959569855],[-69.67780609114853,11.402807398422437],[-69.5248765119853,11.515266158038768],[-69.29987667137726,11.625479959569855],[-68.73737706985726,11.570378484364811],[-68.39987730894529,11.294709319565477],[-68.28449653130708,10.928560854771279],[-68.06237754803324,10.742581675476407],[-68.00959438230036,10.478139223021115],[-67.72487778712127,10.632033208117836],[-67.38737802620922,10.742581675476407],[-67.16237818560127,10.742581675476407],[-66.87829838684601,10.606498174462322],[-66.59987858408127,10.742581675476407],[-66.26237882316921,10.742581675476407],[-66.14987890286528,10.632033208117836],[-66.09812112703091,10.480347852296964],[-65.69987922164921,10.41081650540272],[-65.24987954043323,10.41081650540272],[-64.64489950025676,10.20251427017952],[-64.40813013673639,10.41081650540272],[-64.18210685935342,10.454323438205055],[-64.40613013815323,10.632033208117836],[-64.34988017800117,10.797840764398114],[-64.0123804170892,10.742581675476407],[-63.84763444004678,10.955215720095854],[-63.4498808155692,10.85308969074528],[-63.25051572242629,10.666775863121776]]]}},{"type":"Feature","properties":{"id":"fibra-optica-austral","name":"Fibra Optica Austral","color":"#23a94c","feature_id":"fibra-optica-austral-0","coordinates":[-75.75521397094204,-50.417207326968]},"geometry":{"type":"MultiLineString","coordinates":[[[-72.95341123369901,-41.46037424479032],[-73.3498738023213,-41.651266976593426],[-73.79987348353728,-41.73527248346828],[-74.24987316475335,-41.9029542709986],[-75.14987252718532,-43.5555762736133],[-76.0498718896173,-46.728433187427136],[-76.0498718896173,-47.94815800395535],[-75.59987220840131,-51.71887427473293],[-75.14987252718532,-52.273010201565995],[-74.13737324444934,-52.82030430480513],[-72.89987412110531,-53.158904291412945],[-71.54987507745727,-53.36079173838141],[-71.09987539624129,-53.89450965719368],[-70.19987603380923,-54.421497115405664],[-68.84987699016128,-54.94179496686143],[-68.17487746833726,-55.00636393692013],[-67.6058614267886,-54.935200597435504]],[[-76.0498718896173,-47.94815800395535],[-75.14987252718532,-47.872750313091395],[-74.69987284596934,-47.797232730668014],[-74.24987316475335,-47.797232730668014],[-73.53688749863097,-47.80121466459952]],[[-71.54987507745727,-53.36079173838141],[-71.2123753165453,-53.29360179765786],[-70.93333257525876,-53.16665547850187]]]}},{"type":"Feature","properties":{"id":"djibouti-africa-regional-express-1-dare-1","name":"Djibouti Africa Regional Express 1 (DARE 1)","color":"#939597","feature_id":"djibouti-africa-regional-express-1-dare-1-0","coordinates":[42.07504442898764,-19.10292196513146]},"geometry":{"type":"MultiLineString","coordinates":[[[39.700143767639595,-4.050296575426323],[39.999997070845666,-5.506103863356715],[41.17504506655567,-9.326623361059807],[42.07504442898764,-14.80743804437604],[42.07504442898764,-20.51958710633204],[40.971261031231286,-23.12506902069823],[35.223462368650665,-26.361156827463727],[33.30005064527564,-28.51533365786017],[31.757961738301827,-28.950559666538012]],[[32.58062115552212,-25.968268155407962],[33.79140784557965,-26.192699081406893],[35.223462368650665,-26.361156827463727]],[[43.66314330455965,-23.354724804059778],[42.72697695458166,-23.42087339119824],[40.971261031231286,-23.12506902069823]],[[46.315441425050686,-15.713729798684179],[45.04301107645226,-15.039497695402932],[42.025949814008925,-14.511535911566813]],[[40.68539697592696,-14.565582936090415],[41.5379072360397,-14.45669497006816],[42.07504442898764,-14.80743804437604]],[[40.174850462603004,-10.25488665702017],[41.28961680273296,-10.030240541416509]],[[39.269676416932725,-6.823132108349236],[39.63257107969674,-6.306132261225844],[40.12927201041025,-5.927942387053361]],[[34.833447996502606,-19.82010899999981],[42.025949814008925,-19.82010899999981]]]}},{"type":"Feature","properties":{"id":"djibouti-africa-regional-express-1-dare-1","name":"Djibouti Africa Regional Express 1 (DARE 1)","color":"#a8b737","feature_id":"djibouti-africa-regional-express-1-dare-1-1","coordinates":[53.05571545731233,6.4148119949100915]},"geometry":{"type":"MultiLineString","coordinates":[[[44.55004267627159,11.349864561231485],[43.65004331383962,11.510285103755812],[43.14799366949638,11.594869371447825]],[[44.55004267627159,11.349864561231485],[45.450042038703735,11.294709319565477],[48.60003980781179,12.065895273570327],[52.65003693815965,13.054150695298627],[54.00003598419185,13.3827080361257],[55.1250351848477,12.615395567393394],[54.45003566302377,9.52441134501949],[52.65003693875573,5.510071711803246],[49.50003916964768,1.468426767331968],[45.67504187931159,-1.456253566768442],[42.30004427019158,-3.70382647066824],[39.672896131288006,-4.052924364763054]],[[49.187929390749275,11.275556936623216],[48.82503964782375,11.735650161405832],[48.60003980781179,12.065895273570327]],[[45.344182113695986,2.041205223228781],[46.80004108235159,2.03066189047467],[49.50003916964768,1.468426767331968]]]}},{"type":"Feature","properties":{"id":"peace-cable","name":"PEACE Cable","color":"#0090c5","feature_id":"peace-cable-0","coordinates":[81.18159047227279,2.025087667012478]},"geometry":{"type":"MultiLineString","coordinates":[[[9.450067540827328,37.50058844605323],[9.675067381435365,37.35168786972502],[9.867357245811593,37.276816253475154]],[[55.35003502545574,14.147583506948735],[48.60003980781179,12.395734000022975],[45.450042038703735,11.625479959569855],[44.55004267627159,11.680570534838436]],[[67.02854675228855,24.889731701235817],[65.25002801220774,22.884654113882444],[64.35002864977577,20.375041253465433],[60.97503104065576,16.965102599435927],[58.95003247279982,14.801154224791581],[57.00003385598526,13.248904801212989],[55.35003502545574,14.147583506948735]],[[58.000033147576175,12.273619553801206],[57.00003385598526,11.294709319565477],[54.90003534423966,9.52441134501949],[53.55003630118789,5.510071711803246],[50.40003853207964,1.468426767331968],[45.90004171991963,-1.681168935904995],[42.30004427019158,-3.92832730414264],[39.672896131288006,-4.052924364763054]],[[29.70093319552029,31.072270031660306],[27.900054471279375,31.86180860227073],[25.200056383983473,32.575628370353215],[22.050058614875596,33.31515395812905],[19.350060528175415,33.189714664600466],[16.65006244087933,32.8123187832876],[14.400064034799321,33.70598849685854],[11.981315748271243,35.419780517080355],[11.812565867807347,35.78566189952622],[11.250066265095544,37.23235432155614],[10.348617229769278,37.50058844605323],[9.450067540827328,37.50058844605323],[9.000067859611436,37.50058844605323],[7.200069135343221,37.94551049545967],[6.525069613519291,38.651811712711336],[5.625070251087323,41.74435878948223],[5.372530429989069,43.29362778902908]],[[44.55004267627159,11.680570534838436],[43.24223110273756,12.615395567393394],[43.15785616250971,12.834868817846521],[42.86254387230766,13.054150695298627],[42.24379431063569,13.92930384327183],[41.737544669267656,14.801154224791581],[40.38754562562161,16.534196198259725],[39.37504634288372,18.251816319028222],[38.02504729923568,20.375041253465433],[37.237547857107636,22.05298561667754],[36.00004873376353,24.12261698700344],[34.98754945102763,25.75470426341523],[34.50942478913958,26.562513149236715],[33.862550247391546,27.364667993860262],[33.37083809573128,28.161052262220792],[32.65318110412008,29.113614162980063],[31.72505176161546,29.09911045097482],[31.050052239791533,29.344566989489813],[30.375052717967602,29.711645057947855],[29.98130299690349,30.223089656566962],[29.70093319552029,31.072270031660306]],[[50.40003853207964,1.468426767331968],[52.20003725694376,-2.130918480960333],[54.00003598180769,-3.92832730414264],[55.44505495814286,-4.617611322442136]],[[27.900054471279375,31.86180860227073],[31.500051920411707,34.25018044028598],[32.466651236259686,34.76657169708598]],[[11.981315748271243,35.419780517080355],[13.668814552823427,35.69434844652369],[14.350104069595588,35.95240101739213]],[[73.07152247079179,6.622458884185137],[73.11252244174707,6.790133539110704],[73.22502236205099,7.013502779332166]],[[57.00003385598526,13.248904801212989],[58.000033147576175,12.273619553801206],[63.00002960553183,11.735650161405832],[67.00002677189639,10.85308969074528],[71.3195757241684,9.11818824277241],[73.22502236205099,7.013502779332166],[73.59708669534523,6.355510376771536],[74.25002163593187,5.659359572411489],[78.3000187674719,3.042156042425856],[79.65001781052403,2.51777609524721],[81.00001685476798,2.143087178471855],[85.500013666928,-0.781332308789108],[90.00001047908802,0.118642751260435],[92.70000856638391,3.940475772228814],[94.27500745064,4.937462677928599],[95.45328225426326,5.909674833582863],[97.42500521915215,5.286069860821008],[97.65000505976,5.286069860821008],[98.21250466128001,4.613591578862773],[99.7875035455361,3.266814816815666],[101.25000250948806,1.974446286104158],[102.15000187192003,1.74956539407541],[102.68279723997186,1.502034277710852],[103.34065102845322,1.159233669139442],[103.6462108113958,1.338645835654649]],[[37.237547857107636,22.05298561667754],[39.18275647850768,21.481533475502996]],[[32.65318110412008,29.113614162980063],[31.72505176161546,29.197363639715668],[31.050052239791533,29.442584645837396],[30.375052717967602,29.809307127152845],[29.98130299690349,30.32033594247502],[29.70093319552029,31.072270031660306]],[[45.450042038703735,11.625479959569855],[45.33754211780372,11.073982781226615],[45.01088234980864,10.43511874899288]],[[65.25002801220774,22.884654113882444],[63.22180684167684,23.91710129093513],[60.00000731671387,24.85391243144087],[58.71855992030421,25.0514826710929],[56.33993432360578,25.0514826710929]]]}},{"type":"Feature","properties":{"id":"jupiter","name":"JUPITER","color":"#63c5b9","feature_id":"jupiter-0","coordinates":[-148.28713581206685,43.443614889598344]},"geometry":{"type":"MultiLineString","coordinates":[[[-118.39955344545581,33.86223405937197],[-120.59984033000157,33.70598849685854],[-122.3998390548656,33.93964008831966],[-129.5998339543217,37.589786573603064],[-138.4872392170287,41.40772623743595],[-151.19983391146832,44.048716071048425],[-179.99981350929238,44.048716071048425]],[[179.99992719256971,44.051501534601925],[172.79992073307184,44.051501534601925],[160.19996074878455,40.72920412488655],[149.39996839960057,35.78566189952622],[143.09997286257644,34.59045588265237],[141.97497365953643,34.405022750715936],[140.84997445649643,34.405022750715936],[140.39997477468464,34.69072647741027],[140.18903742411456,34.89859296336222],[139.97546699999984,35.005433000000174]],[[140.84997445649643,34.405022750715936],[138.59997605041642,33.75276987113061],[137.69997668798445,33.846256070003854],[136.87399727311598,34.33682825203173]],[[141.97497365953643,34.405022750715936],[141.07497429710446,32.052708023486204],[140.6249746158884,30.901396088515508],[138.82497589102445,27.76358852605777],[137.24997700676838,26.1593079707739],[133.19997987582445,22.469443964829516],[127.79998370123228,17.395022634700517],[124.6499859327203,15.23578178303578],[123.5249867296803,14.256644994553485],[122.95008713694455,14.11652289884896]],[[-123.95633795222729,45.231085444465165],[-125.09983714216159,44.694829089578164],[-129.5998339543217,43.401144973153954],[-138.4872392170287,41.40772623743595]]]}},{"type":"Feature","properties":{"id":"crosslake-fibre","name":"Crosslake Fibre","color":"#bfa82f","feature_id":"crosslake-fibre-0","coordinates":[-78.9963389306078,43.35045980757371]},"geometry":{"type":"MultiLineString","coordinates":[[[-79.3853197287618,43.64462540417898],[-79.08736973782534,43.48282788090373],[-78.91861985736935,43.237448352440914],[-78.86236989721739,43.073310783003215],[-78.87846124652506,42.88543648440376]]]}},{"type":"Feature","properties":{"id":"gtmo-pr","name":"GTMO-PR","color":"#28a557","feature_id":"gtmo-pr-0","coordinates":[-71.51216307611269,17.219005195339932]},"geometry":{"type":"MultiLineString","coordinates":[[[-66.18575110065406,18.47299398772983],[-66.48737866377725,18.73192589597656],[-67.04987826529725,18.891661584303154],[-67.72487778712127,18.465364393137126],[-68.06237754922522,18.251816319028222],[-68.51237722924922,17.82393441253792],[-69.29987667137726,17.716802179008642],[-70.6498757150253,17.287636299591117],[-71.99987475867334,17.180187287481317],[-74.69987284596934,17.395022634700517],[-75.14987252718532,18.251816319028222],[-75.26237244808533,19.670379638527955],[-75.15816600409605,19.939616197540328]]]}},{"type":"Feature","properties":{"id":"gtmo-1","name":"GTMO-1","color":"#8bc63f","feature_id":"gtmo-1-0","coordinates":[-74.9568908684134,24.46418028699889]},"geometry":{"type":"MultiLineString","coordinates":[[[-80.14394128072,26.052316286427192],[-79.64986933934534,25.957179978764344],[-78.41237021600132,26.1593079707739],[-77.51237085356935,25.653336613276053],[-76.83117133613746,25.81335888205563],[-76.24064175447413,25.599112345903762],[-75.59987220840131,25.14511970405223],[-74.92487268657729,24.430271928048704],[-74.69987284596934,24.01990020343248],[-74.58737292566532,23.7112581424843],[-74.58737292566532,23.298598065875897],[-74.69987284596934,22.884654113882444],[-74.4748730053613,22.05298561667754],[-74.02487332414532,21.216397899942],[-73.9123734038413,20.796306105108872],[-74.0248233241807,20.375041253465433],[-74.09132327707148,20.183174721867772],[-74.30612312490531,19.95262290516439],[-74.69987284596934,19.740987365524937],[-74.92487268657729,19.793828591740887],[-75.15816600409605,19.939616197540328]]]}},{"type":"Feature","properties":{"id":"havhingstennorth-sea-connect-nsc","name":"Havhingsten/North Sea Connect (NSC)","color":"#3075bb","feature_id":"havhingstennorth-sea-connect-nsc-0","coordinates":[3.300835253210443,55.468042370708176]},"geometry":{"type":"MultiLineString","coordinates":[[[8.19316843122662,55.76461454471694],[7.650068816559295,55.526080187888724],[7.200069135343221,55.526080187888724],[5.400070409883385,55.5473006113407],[3.150072004399278,55.462350188098306],[-0.899925126544665,55.07780072164767],[-1.617778330734679,54.97824921450541]]]}},{"type":"Feature","properties":{"id":"havhingstenceltixconnect-2-cc-2","name":"Havhingsten/CeltixConnect-2 (CC-2)","color":"#3567b1","feature_id":"havhingstenceltixconnect-2-cc-2-0","coordinates":[-4.583345454615775,53.829194264925974]},"geometry":{"type":"MultiLineString","coordinates":[[[-6.085471453650949,53.54969026481899],[-5.624921779312721,53.605364702574946],[-4.9499222574887,53.802143574575446],[-4.499922576272716,53.83535026397589],[-3.599923213840749,53.83535026397589],[-3.050753602877637,53.80897597127547]],[[-4.9499222574887,53.802143574575446],[-4.9499222574887,53.967914030873956],[-4.760422391732262,54.087213966599236]],[[-4.499922576272716,53.83535026397589],[-4.499722576414336,53.967914030873956],[-4.566622529021879,54.10028616652028]]]}},{"type":"Feature","properties":{"id":"cobracable","name":"COBRAcable","color":"#59af46","feature_id":"cobracable-0","coordinates":[7.423990331415976,54.89909630966168]},"geometry":{"type":"MultiLineString","coordinates":[[[8.718388991504327,55.523118197393856],[8.383735292984237,55.37363200351445],[7.644797667750507,55.32458575844352],[6.975069294735365,54.03403825672422],[6.816022175096963,53.4428046914825]]]}},{"type":"Feature","properties":{"id":"kanawa","name":"Kanawa","color":"#6fc6b1","feature_id":"kanawa-0","coordinates":[-56.10276500157915,10.704754187067367]},"geometry":{"type":"MultiLineString","coordinates":[[[-61.112138356061955,14.629000145423495],[-61.199832409524575,14.474609432068906],[-61.087382489185174,14.365653759228442],[-60.974882568881156,14.365653759228442],[-59.84988336584115,13.92930384327183],[-58.04988464097722,12.615395567393394],[-54.89988687246515,9.52441134501949],[-52.874888306993086,5.957818681088533],[-52.645554998399945,5.16829853939239]]]}},{"type":"Feature","properties":{"id":"fly-lion3","name":"FLY-LION3","color":"#c7b22e","feature_id":"fly-lion3-0","coordinates":[44.02971817153865,-12.264700887720027]},"geometry":{"type":"MultiLineString","coordinates":[[[43.24330360197787,-11.700589282272533],[43.14379367247158,-11.943944931746815],[43.31254355292765,-12.163983680780412],[43.53754339353551,-12.163983680780412],[43.87504315444765,-12.163983680780412],[44.55004267627159,-12.60351210497128],[45.00004235748766,-12.82299562562977],[45.16576224009028,-12.817096445533013]]]}},{"type":"Feature","properties":{"id":"nelson-levin","name":"Nelson-Levin","color":"#403c97","feature_id":"nelson-levin-0","coordinates":[174.11213952095224,-40.549228298069146]},"geometry":{"type":"MultiLineString","coordinates":[[[173.283950056072,-41.27226426152262],[173.47495134465674,-40.89028605009858],[173.92495102587282,-40.549228298069146],[174.82495038830479,-40.549228298069146],[175.28531582578577,-40.62527463191151]]]}},{"type":"Feature","properties":{"id":"png-lng","name":"PNG LNG","color":"#cf297b","feature_id":"png-lng-0","coordinates":[145.57756247984867,-8.667132033694022]},"geometry":{"type":"MultiLineString","coordinates":[[[144.2402063499095,-7.413515958216514],[144.6038691823073,-7.965739195577203],[145.80511425847098,-8.831047291434265],[146.6233536581765,-9.428179774498066],[147.1885196909836,-9.479589292697288]]]}},{"type":"Feature","properties":{"id":"kumul-domestic-submarine-cable-system","name":"Kumul Domestic Submarine Cable System","color":"#a76c35","feature_id":"kumul-domestic-submarine-cable-system-0","coordinates":[150.11278431665937,-9.549626145302685]},"geometry":{"type":"MultiLineString","coordinates":[[[143.20993372217916,-9.07814229926296],[143.9999722250084,-8.846050186819125],[144.72573462481674,-8.332313959673433],[145.57497110926448,-8.401139048122838],[146.6999703123045,-9.29042430103552],[147.1885196909836,-9.479589292697288]],[[145.77174829265064,-7.96374446783501],[145.57497110926448,-8.401139048122838]],[[147.1885196909836,-9.479589292697288],[147.37496983412842,-10.17745743036107],[149.39996839960057,-11.062032109909483],[150.74996744324844,-11.062032109909483],[151.1999671244645,-10.841130095525688],[151.1999671244645,-10.398839577127402],[150.97496728385667,-10.17745743036107],[150.29996776203254,-9.73423534230066],[149.84996808081647,-9.29042430103552],[149.39996839960057,-8.846050186819125],[149.39996839960057,-8.401139048122838],[148.04996935595253,-7.063446338991064],[148.04996935595253,-6.393099497823911],[147.59996967473646,-5.945707155070644],[146.90630490718817,-5.585376278266893],[146.02497079048055,-5.273944363641298],[145.7847409606618,-5.23368424614946]],[[150.45875671204502,-10.315743405478468],[150.74996744324844,-10.398839577127402],[151.1999671244645,-10.398839577127402]],[[148.2935238709162,-8.604445748342727],[148.9499687183845,-8.401139048122838],[149.39996839960057,-8.401139048122838]],[[146.99293885476283,-6.739375698979019],[147.37496983412842,-7.063446338991064],[148.04996935595253,-7.062898168976873]],[[145.7847409606618,-5.23368424614946],[146.02497079048055,-5.049857167366764],[146.24997063108842,-4.825692499217419],[146.24997063108842,-4.37714437553184],[145.34997126865645,-3.479268678970064],[144.44997190622448,-2.580536704984131],[142.19997350014447,-2.130918480960333],[141.2999741377125,-2.130918480960333],[140.84997445649643,-2.35574573664619],[140.66911458461954,-2.591565453841556]],[[141.2999741377125,-2.130918480960333],[141.29987648153164,-2.689698402599833]],[[143.6583709045021,-3.580053610579215],[143.9999722250084,-3.479268678970064],[144.44997190622448,-3.029995968008661],[144.44997190622448,-2.580536704984131]],[[146.24997063108842,-4.825692499217419],[146.6999703123045,-4.60145376483711],[149.39996839960057,-4.60145376483711],[150.52496760264057,-4.825692499217419],[151.1999671244645,-3.92832730414264],[149.84996808081647,-2.580536704984131],[149.84996808081647,-2.130918480960333],[150.52496760264057,-2.130918480960333],[150.8085611517402,-2.578139138209948]],[[150.52496760264057,-4.825692499217419],[150.41246768233648,-5.273944363641298],[150.13873740749958,-5.551120494376105]],[[151.1999671244645,-3.92832730414264],[152.2124664072006,-3.92832730414264],[152.43746624780846,-4.152767748013638],[152.54996616811255,-4.37714437553184],[152.6624660884165,-4.825692499217419],[152.99996584932845,-5.049857167366764],[153.8999652117606,-4.825692499217419],[154.79996457419256,-4.825692499217419],[155.24996425540846,-5.273944363641298],[155.47496409601666,-5.721872747834119],[155.56783512397587,-6.225662994684213]],[[152.2124664072006,-3.92832730414264],[152.2124664072006,-4.040555289062099],[152.2745757382018,-4.342382000415339]],[[145.34997126865645,-3.479268678970064],[145.79997094987252,-2.130918480960333],[146.6999703123045,-1.681168935904995],[147.14996999352056,-1.793617120354896],[147.27907146456383,-2.034985895581449]]]}},{"type":"Feature","properties":{"id":"strategic-evolution-underwater-link-seul","name":"Strategic Evolution Underwater Link (SEUL)","color":"#bd254b","feature_id":"strategic-evolution-underwater-link-seul-0","coordinates":[-88.13645167977192,17.91429586339428]},"geometry":{"type":"MultiLineString","coordinates":[[[-88.29445457772991,17.92560748967649],[-88.09086335966589,17.91103213819346],[-87.97834227065086,17.91103213819346]]]}},{"type":"Feature","properties":{"id":"labuan-brunei-submarine-cable","name":"Labuan-Brunei Submarine Cable","color":"#ce3894","feature_id":"labuan-brunei-submarine-cable-0","coordinates":[114.98447468523977,5.126623341957663]},"geometry":{"type":"MultiLineString","coordinates":[[[114.88563284987971,4.926762452886689],[114.97499278657615,5.120007115780449],[115.1689426491803,5.255340365327962]]]}},{"type":"Feature","properties":{"id":"marea","name":"MAREA","color":"#288da3","feature_id":"marea-0","coordinates":[-38.88408857801272,40.57765092802222]},"geometry":{"type":"MultiLineString","coordinates":[[[-76.05920188300784,36.75500543613895],[-74.69987284596934,36.813198605777224],[-72.44987443988924,37.411283634923244],[-61.19988240948919,37.94551049545967],[-50.39989006030513,37.94551049545967],[-39.59989771112098,40.38732029077508],[-23.399909187344846,44.694829089578164],[-16.199914287888834,45.646541495187385],[-9.899918750864702,46.5823550820958],[-5.849921619920758,45.646541495187385],[-4.499922576272716,44.694829089578164],[-2.949193674823563,43.274220252000646]]]}},{"type":"Feature","properties":{"id":"brazilian-festoon","name":"Brazilian Festoon","color":"#a36929","feature_id":"brazilian-festoon-0","coordinates":[-39.02320379163805,-14.833362589631536]},"geometry":{"type":"MultiLineString","coordinates":[[[-35.2111808201292,-5.794842609646916],[-34.87490105835302,-6.169450529574503],[-34.762401138049,-6.616650693475355],[-34.86103106817858,-7.115296841294636],[-34.64990121774498,-7.398261494591065],[-34.64990121774498,-7.732822794391767],[-34.87197106042861,-8.055326726100345],[-34.762401138049,-8.401139048122838],[-35.09990089896096,-9.29042430103552],[-35.73496044907884,-9.652223223190266],[-35.887400341089005,-10.17745743036107],[-36.44989994260901,-10.730617682527702],[-37.074799499924225,-10.909607685768835],[-37.01239954412901,-11.17242092112813],[-37.18114942458499,-11.558448514962395],[-37.57050914875899,-11.84666975659916],[-37.68739906595303,-12.273934999624556],[-38.02489882686499,-12.82299562562977],[-38.50448848711925,-12.96997203534297],[-38.474898508080976,-13.480286782092163],[-38.69989834868901,-14.244842547315455],[-39.047698102304416,-14.787969163702355],[-38.812398268993036,-15.224032284647373],[-38.69989834868901,-15.874323281689833],[-38.812398268993036,-16.30669306561827],[-39.06468809026847,-16.451075727726543],[-38.92489818929696,-16.953454989809906],[-38.92489818929696,-17.597998996155503],[-39.37489787051303,-18.45381377577717],[-39.85912752748022,-18.71669015877287],[-39.59989771112098,-18.98655325687288],[-39.59989771112098,-19.729525450021],[-39.824897551729016,-20.152543786018732],[-40.30165721398802,-20.294028446525463],[-40.499897073553036,-20.995131543025785],[-41.0272366999807,-21.631144867598383],[-40.94989675476902,-21.832990805202066],[-41.17489659537697,-22.250099679090077],[-41.399896435985006,-22.35418408364256],[-41.78563616272339,-22.37179211083922],[-41.849896117201084,-22.665969967794794],[-42.29989579841706,-23.08058350574764],[-42.74989547963305,-23.08058350574764],[-43.20956515399876,-22.903486555497956]]]}},{"type":"Feature","properties":{"id":"nationwide-submarine-cable-ooredoo-maldives-nascom","name":"Nationwide Submarine Cable Ooredoo Maldives (NaSCOM)","color":"#ef7422","feature_id":"nationwide-submarine-cable-ooredoo-maldives-nascom-0","coordinates":[73.49377676750908,3.077143914807335]},"geometry":{"type":"MultiLineString","coordinates":[[[73.08918245887737,-0.605519711481727],[72.9976825236969,0.281087260908386],[72.9976825236969,0.531080606428773],[72.9976825236969,0.781063841597784],[73.42463222124155,2.776778986293406],[73.53957213981704,3.276079687360005],[73.54035213926461,3.962982118955539],[73.54035213926461,4.212345781871782],[73.49035217468504,4.461629537081651],[73.27082233020184,4.854566321205027],[73.07082247188359,5.10362255380632],[73.18127239304401,5.211384397097438],[73.18127239304401,5.435413643888211],[73.17082240104271,5.601440402765338],[73.17150240056122,6.125830038600339],[73.0715024714021,6.622826415013278]]]}},{"type":"Feature","properties":{"id":"saba-statia-cable-system-sscs","name":"Saba, Statia Cable System (SSCS)","color":"#c01f38","feature_id":"saba-statia-cable-system-sscs-0","coordinates":[-63.31664742849068,17.616040674676444]},"geometry":{"type":"MultiLineString","coordinates":[[[-62.729761325708566,17.298635546518675],[-62.82976125486769,17.298635546518675],[-62.98704114344916,17.386453128638113],[-62.98552567577273,17.47589364460112],[-63.08709107257282,17.481859236798428],[-63.25164095600418,17.52116155688655],[-63.244216898763455,17.615097550173317],[-63.35169088512793,17.6164969787067],[-63.35169088512793,17.71178205639041],[-63.15506102442229,17.929327757796422],[-63.055056876516154,18.024447867032936],[-62.943631174201165,17.984511967477506],[-62.85054881207158,17.897915647022234]]]}},{"type":"Feature","properties":{"id":"east-west-submarine-cable-system","name":"East-West Submarine Cable System","color":"#ed164f","feature_id":"east-west-submarine-cable-system-0","coordinates":[107.46181632127374,3.3123415341325986]},"geometry":{"type":"MultiLineString","coordinates":[[[103.85068066714335,2.29570245694968],[104.40000027800004,2.480311786858737],[105.74999932164808,2.592701464601932],[105.97499916225611,2.817450442654169],[106.21112899497969,3.207140223034204],[106.87499852468808,3.154491498099848],[107.32499820590417,3.266814816815666],[107.99999772772827,3.491423322320592],[108.29022752212678,3.666760478526919],[108.89999709016024,3.266814816815666],[109.79999645259221,2.592701464601932],[110.24999613380828,1.918228780215599],[110.35370606033919,1.520169126642144]]]}},{"type":"Feature","properties":{"id":"smpcs-packet-1","name":"SMPCS Packet-1","color":"#a43a95","feature_id":"smpcs-packet-1-0","coordinates":[127.32155160528426,-3.169471179209577]},"geometry":{"type":"MultiLineString","coordinates":[[[124.8396357983706,1.490779296094715],[125.09998561393637,1.243490076978041],[125.99998497636834,1.018534216615524],[126.89998433880031,1.018534216615524],[126.89998433880031,0.568578852526193],[126.89998433880031,0.118588418888312],[126.67498449819227,-1.231315750217412],[126.68699448968442,-2.201477967855649],[127.12498417940834,-2.580536704984131],[127.34998402001638,-3.254657364797681],[127.57498386062441,-3.70382647066824],[127.79998370123228,-3.92832730414264],[128.0249835418403,-3.92832730414264],[128.19070342444294,-3.645746178185322],[128.4749832230562,-3.816084221750208],[129.1499827448803,-3.92832730414264],[129.82498226670444,-4.152767748013638],[130.49998178852834,-4.152767748013638],[131.39998115096031,-3.92832730414264],[132.29998051339228,-3.479268678970064],[132.74998019460836,-3.36696949729516],[133.00857001142109,-3.111418827305981]],[[129.87205223335977,-4.520172025984261],[129.82498226670444,-4.152767748013638]],[[129.1499827448803,-3.92832730414264],[129.1499827448803,-3.70382647066824],[128.9552528828288,-3.297846628598047]],[[127.34998402001638,-3.254657364797681],[127.08582420714977,-3.233401495651599]],[[126.68699448968442,-2.201477967855649],[125.93699502099096,-2.201477967855649]],[[126.67498449819227,-1.231315750217412],[127.34998402001638,-1.006358951224796],[127.79998370123228,-1.006358951224796],[128.24998338244836,-1.231315750217412],[129.1499827448803,-1.231315750217412],[129.5999824260964,-0.781386636225587],[130.39002186642506,-0.590190556941542],[130.89012151214973,-0.590190556941542],[131.29539122505298,-0.882055944384682]],[[127.4804539275903,-0.626808465190233],[127.34998402001638,-0.781386636225587],[127.34998402001638,-1.006358951224796]],[[128.19070342444294,-3.645746178185322],[128.0249835418403,-4.040555289062099],[127.79998370123228,-4.152767748013638],[126.44998465758441,-4.152767748013638],[125.09998561393637,-3.816084221750208],[123.07498704846441,-3.816084221750208],[122.51297744659723,-3.998469097245179]],[[127.34998402001638,1.018534216615524],[127.34998402001638,0.906050180869095],[127.37537400202994,0.789853009413946]],[[126.89998433880031,1.018534216615524],[127.34998402001638,1.018534216615524],[127.46248394032031,0.906050180869095],[127.56141387023752,0.734977909038259]]]}},{"type":"Feature","properties":{"id":"smpcs-packet-2","name":"SMPCS Packet-2","color":"#33b44a","feature_id":"smpcs-packet-2-0","coordinates":[131.74814831666004,-2.928285746232012]},"geometry":{"type":"MultiLineString","coordinates":[[[130.85760153518734,-0.383358280481206],[130.89012151214973,-0.490195084933698],[131.29539122505298,-0.882055944384682]],[[140.66911458461954,-2.591565453841556],[140.39997477528055,-2.130918480960333],[139.49997541284839,-1.681168935904995],[137.69997668798445,-1.231315750217412],[136.7999773255523,-1.231315750217412],[136.3499776443364,-1.400021081752668],[135.89997796312053,-1.400021081752668],[135.44997828190446,-1.175078194688512],[134.99997860068837,-0.781386636225587],[134.5499789194723,-0.556402272850583],[133.64997955704033,-0.331409329660265],[132.29998051339228,-0.106411275875408],[131.62498099156835,-0.331409329660265],[131.29539122505298,-0.882055944384682],[131.04539140215516,-0.757068845388253],[130.89012151214973,-0.690184231258295],[130.39002186642506,-0.690184231258295],[130.2749819479203,-1.231315750217412],[130.7249816291364,-1.681168935904995],[131.39998115096031,-2.580536704984131],[131.8499808321764,-3.029995968008661],[132.29998051339228,-3.254657364797681],[132.74998019460836,-3.254657364797681],[133.00857001142109,-3.111418827305981],[132.74998019460836,-3.479268678970064],[132.60535029706563,-3.679267550854838],[132.60535029706563,-4.178090661818759],[132.85535011996345,-4.427385673631093],[133.64997955704033,-4.37714437553184],[134.0999792382564,-4.152767748013638],[134.99997860068837,-4.713582177229249],[135.89997796312053,-4.825692499217419],[136.57497748494447,-5.049857167366764],[136.7999773255523,-6.169450529574503],[136.7999773255523,-8.401139048122838],[137.24997700676838,-8.846050186819125],[138.59997605041642,-9.068306003874412],[139.94997509406446,-8.846050186819125],[140.4050547716818,-8.499084023854886]],[[139.49997541284839,-1.681168935904995],[139.35329551675807,-2.175227145407149]],[[136.0532978545072,-1.187185613026736],[135.89997796312053,-1.400021081752668]],[[134.06198926516882,-0.861458343462594],[134.32497907886446,-0.725141539440154],[134.5499789194723,-0.556402272850583]],[[132.75203019315614,-5.635389324566357],[132.63753027426915,-5.385957847173066],[132.63963027278146,-5.128744870833412],[132.85535011996345,-4.427385673631093]],[[134.0999792382564,-4.152767748013638],[133.79147945680054,-3.679367344669026]],[[136.57497748494447,-5.049857167366764],[136.88962726204366,-4.55024753995573]]]}},{"type":"Feature","properties":{"id":"luwuk-tutuyan-cable-system-ltcs","name":"Luwuk Tutuyan Cable System (LTCS)","color":"#554b9f","feature_id":"luwuk-tutuyan-cable-system-ltcs-0","coordinates":[124.09471861195692,-0.5754637530094492]},"geometry":{"type":"MultiLineString","coordinates":[[[124.57498598585096,0.761435633994905],[124.6499859327203,0.568578852526193],[124.6499859327203,0.118588418888312],[123.74998657028833,-1.006358951224796],[123.29998688907226,-1.090719755343313],[123.07498704846441,-1.062599741028637],[122.79275724839854,-0.938788738945164]]]}},{"type":"Feature","properties":{"id":"tarakan-selor-cable-system-tscs","name":"Tarakan Selor Cable System (TSCS)","color":"#3db557","feature_id":"tarakan-selor-cable-system-tscs-0","coordinates":[117.89999071448027,3.0414281890356527]},"geometry":{"type":"MultiLineString","coordinates":[[[117.57851094221976,3.327354396392341],[117.61874091372027,3.154491498099848],[117.89999071448027,3.042156042425856],[117.89999071448027,2.817450442654169],[117.81942077155685,2.572022500481419]]]}},{"type":"Feature","properties":{"id":"sumatera-bangka-cable-system-sbcs","name":"Sumatera Bangka Cable System (SBCS)","color":"#bab034","feature_id":"sumatera-bangka-cable-system-sbcs-0","coordinates":[104.74267069755895,-2.4578134016738415]},"geometry":{"type":"MultiLineString","coordinates":[[[105.16425973659172,-2.065142653434427],[104.96249987952004,-2.130918480960333],[104.8499999592161,-2.243336428755425],[104.73750003891219,-2.468145972656139],[104.62500011860807,-2.636728372378821],[104.62500011860807,-2.805287932307917],[104.75732002487149,-2.990989901799851]]]}},{"type":"Feature","properties":{"id":"s-u-b-cable-system","name":"S-U-B Cable System","color":"#cd5628","feature_id":"s-u-b-cable-system-0","coordinates":[116.59214014239069,-6.350116358827736]},"geometry":{"type":"MultiLineString","coordinates":[[[114.60399304939611,-3.327586828573115],[114.1874933444483,-3.70382647066824],[113.96249350384026,-4.37714437553184],[113.73749366323221,-4.825692499217419],[113.17499406171221,-5.721872747834119],[112.72499438049614,-6.393099497823911],[112.72499438049614,-6.616650693475355],[112.7466743651381,-7.258591948594758],[112.8937442615483,-6.616650693475355],[113.06249413962023,-6.393099497823911],[114.46970572643436,-6.169450529574503],[116.07081354037958,-6.321671514079873],[117.37991922838094,-6.393099497823911],[118.79999007691224,-5.385957847173066],[119.41238964308275,-5.152180217334703],[118.79999007691224,-5.161910662113067],[116.58310164737706,-4.628705101793735],[114.7499929459683,-4.60145376483711],[114.41249318505615,-4.37714437553184],[114.29999326475222,-3.70382647066824],[114.60399304939611,-3.327586828573115]]]}},{"type":"Feature","properties":{"id":"indigo-west","name":"INDIGO-West","color":"#a47c2c","feature_id":"indigo-west-0","coordinates":[108.38521825497028,-19.08116413897757]},"geometry":{"type":"MultiLineString","coordinates":[[[106.64999868408005,-5.174359836387044],[106.4039414503484,-5.525635172789983],[105.74999932164808,-6.001651664913787],[105.29999964043219,-6.05759043242458],[104.8499999592161,-6.616650693475355],[104.8499999592161,-7.509810688339549],[105.29999964043219,-11.943944931746815],[106.99999843554109,-15.441023659568087],[108.89999708956415,-20.433922197637408],[111.14999549624025,-27.15383128539156],[112.94999422110418,-30.30995334464681],[113.84999358353615,-31.083834718767243],[115.85731216153303,-31.953441330324313]],[[103.64609081207688,1.338585852071497],[103.83750067648003,1.018534216615524],[103.83750067648003,0.793562652607196],[104.17500043739219,0.627825578639147],[104.8499999592161,0.512331397069603],[105.52499948104004,0.118588418888312],[106.19999900286416,-0.781386636225587],[106.98549844640912,-2.130918480960333],[107.09999836529612,-3.029995968008661],[107.09999836529612,-4.60145376483711],[106.64999868408005,-5.174359836387044],[106.82782855810404,-6.171876390816321]]]}},{"type":"Feature","properties":{"id":"junior","name":"Junior","color":"#902c8c","feature_id":"junior-0","coordinates":[-44.597985661368206,-23.927642902465102]},"geometry":{"type":"MultiLineString","coordinates":[[[-46.328062944825646,-23.961842897597087],[-44.99989388571306,-24.111502734257467],[-43.64989484206502,-23.49392244589784],[-43.20956515399876,-22.903486555497956]]]}},{"type":"Feature","properties":{"id":"skagerrak-4","name":"Skagerrak 4","color":"#60bb46","feature_id":"skagerrak-4-0","coordinates":[8.933237151289148,57.3728338797843]},"geometry":{"type":"MultiLineString","coordinates":[[[7.996258571315225,58.15106571642484],[8.437568258687335,57.932056586951404],[8.7750680195993,57.57189027900508],[9.112567780511265,57.14714566273729],[9.112567780511265,56.840738642145496],[9.225067700815375,56.717468482041156],[9.281317660967337,56.593792978418996],[9.337567621119302,56.56281067903171],[9.611687426930285,56.51079153038908]]]}},{"type":"Feature","properties":{"id":"atisa","name":"Atisa","color":"#56be8d","feature_id":"atisa-0","coordinates":[144.8895850351337,14.530318141710831]},"geometry":{"type":"MultiLineString","coordinates":[[[144.69470173285575,13.464772962370143],[144.6749717468325,13.92930384327183],[144.78747166713646,14.365653759228442],[145.1249714280484,14.909893860289706],[145.34997126865645,15.127208002058548],[145.69958102098963,15.151813296858078]],[[144.78747166713646,14.365653759228442],[145.0124715077445,14.365653759228442],[145.1343514214037,14.144936296053853]],[[145.6292710707981,14.958774985807883],[145.46247118896056,14.909893860289706],[145.1249714280484,14.909893860289706]]]}},{"type":"Feature","properties":{"id":"tui-samoa","name":"Tui-Samoa","color":"#ba8c34","feature_id":"tui-samoa-0","coordinates":[-176.2024591526966,-14.475800138388372]},"geometry":{"type":"MultiLineString","coordinates":[[[178.43744782917764,-18.123810943537187],[178.8749611145193,-18.134559477606608],[179.9999326211189,-17.597998996155503]],[[-171.89980398862608,-13.480286782092163],[-172.01230390893008,-13.589662250512088],[-172.1781610251332,-13.670594022635894]],[[-171.76669408292247,-13.83348925575777],[-171.89980398862608,-13.480286782092163],[-172.2373037495382,-13.261386000117003],[-172.79980335105813,-13.261386000117003],[-175.49980143835413,-14.135775375064666],[-177.29980016321815,-15.006817032918805],[-179.54979856929816,-17.168553094226155],[-179.99979825051412,-17.597998996155503]],[[179.34974953238174,-16.80801177359569],[179.54994704107284,-17.168553094226155],[179.9999326211189,-17.597998996155503]],[[-178.15810822841175,-14.29683787673267],[-177.74979984443414,-14.571726491332546],[-177.29980016321815,-15.006817032918805]],[[-176.1750003498167,-13.282000575147258],[-175.72480127896205,-13.698987269610743],[-175.49980143835413,-14.135775375064666]]]}},{"type":"Feature","properties":{"id":"bugio","name":"BUGIO","color":"#d33894","feature_id":"bugio-0","coordinates":[-9.356394683523343,38.45053529435123]},"geometry":{"type":"MultiLineString","coordinates":[[[-9.33155915349599,38.690161972355526],[-9.4315090826905,38.61206568189252],[-9.352749138484844,38.442695701551486],[-9.202699244781627,38.36471443161005],[-9.102749315587026,38.4430794831419]]]}},{"type":"Feature","properties":{"id":"quintillion-subsea-cable-network","name":"Quintillion Subsea Cable Network","color":"#baab31","feature_id":"quintillion-subsea-cable-network-0","coordinates":[-161.52224884780216,71.14473871313697]},"geometry":{"type":"MultiLineString","coordinates":[[[-148.45676558455187,70.29404593604013],[-148.38732064687807,70.50227660282815],[-148.49982056539383,70.80045445855767],[-155.24981578363395,71.6688723488942],[-158.39981355214593,71.6688723488942],[-161.11856210313948,71.24562930009421],[-162.89981036430603,70.80045445855767],[-166.94980749525007,69.10459505606217],[-167.84980685768204,68.61776755908163],[-168.29980653889803,68.12014116880253],[-168.52480637950606,66.01760945207982],[-168.07480669829008,64.8962822062034],[-166.94980749525007,64.31739001144432],[-166.04980813281801,64.31739001144432],[-165.40639895727566,64.50111053726953]],[[-156.78863527988352,71.29055679250226],[-156.5998148272819,71.38369549089968],[-156.5998148272819,71.6688723488942]],[[-167.84980685768204,68.61776755908163],[-167.39980717646606,68.45310188361516],[-166.80805792891846,68.34777271452049]],[[-168.29980653889803,68.12014116880253],[-166.94980749525007,67.4394931950777],[-165.59980845160203,67.09168603590066],[-163.799809726738,66.91588535690667],[-162.59668101868567,66.89834123906643]],[[-161.11856210313948,71.24562930009421],[-160.64981195822594,70.80045445855767],[-160.03834289566558,70.63694726997778]]]}},{"type":"Feature","properties":{"id":"arsat-submarine-fiber-optic-cable","name":"ARSAT Submarine Fiber Optic Cable","color":"#e12825","feature_id":"arsat-submarine-fiber-optic-cable-0","coordinates":[-68.46704320378183,-52.554235898639284]},"geometry":{"type":"MultiLineString","coordinates":[[[-68.41974729486913,-52.390217164282035],[-68.45812726768042,-52.54751033211774],[-68.6057871630767,-52.65889476082415]]]}},{"type":"Feature","properties":{"id":"balok","name":"BALOK","color":"#35bba4","feature_id":"balok-0","coordinates":[115.91094573738215,-8.399360919774615]},"geometry":{"type":"MultiLineString","coordinates":[[[115.74667223991158,-8.386260161311364],[115.93324210774352,-8.401139048122838],[116.04726202697084,-8.485465010786871]]]}},{"type":"Feature","properties":{"id":"pencan-9","name":"Pencan-9","color":"#86c440","feature_id":"pencan-9-0","coordinates":[-11.5406090461846,33.07476541382141]},"geometry":{"type":"MultiLineString","coordinates":[[[-6.42888120978034,36.73129423644183],[-7.199920663568708,36.42191605012598],[-8.774919547824696,35.419780517080355],[-10.799918113296759,33.93964008831966],[-14.399915563024809,29.73606949729215],[-14.849915244240792,28.95155473219332],[-15.450744818607559,28.096374448680937]]]}},{"type":"Feature","properties":{"id":"taba-aqaba","name":"Taba-Aqaba","color":"#98a439","feature_id":"taba-aqaba-0","coordinates":[34.95962525751561,29.523248443865263]},"geometry":{"type":"MultiLineString","coordinates":[[[34.89486951608693,29.492576422604227],[34.95097447634171,29.512254681383798],[35.005095000000225,29.581033231032787]]]}},{"type":"Feature","properties":{"id":"tannat","name":"Tannat","color":"#34a8a7","feature_id":"tannat-0","coordinates":[-47.401374671328654,-31.994362705245855]},"geometry":{"type":"MultiLineString","coordinates":[[[-53.549887828817106,-35.22626671976625],[-53.99988751003309,-35.59302880961419],[-56.695442241101844,-36.47097855291435]],[[-46.328062944825646,-23.961842897597087],[-45.89989324814503,-25.134186547061336],[-45.44989356692904,-27.95174728521976],[-47.69989197300905,-32.61276000573574],[-50.84988974152112,-34.48775447869505],[-53.549887828817106,-35.22626671976625],[-54.950186836832145,-34.90041602705144]]]}},{"type":"Feature","properties":{"id":"south-atlantic-inter-link-sail","name":"South Atlantic Inter Link (SAIL)","color":"#c62a26","feature_id":"south-atlantic-inter-link-sail-0","coordinates":[-13.859932011165128,-2.614200652258127]},"geometry":{"type":"MultiLineString","coordinates":[[[-38.542968459859594,-3.718735129291092],[-35.99990026139302,-2.580536704984131],[-34.199901536529,-2.130918480960333],[-25.19990791220887,-1.906058394384765],[-10.799918113296759,-2.805287932307917],[-3.599923213840749,-2.805287932307917],[0.450073917103194,-1.081346446098796],[6.300069772911254,1.918228780215599],[8.10006849777537,2.480311786858737],[9.000067860207336,2.592701464601932],[9.91022721544214,2.933124533518602]]]}},{"type":"Feature","properties":{"id":"brusa","name":"BRUSA","color":"#c86d28","feature_id":"brusa-0","coordinates":[-42.08907980888387,8.420314618733522]},"geometry":{"type":"MultiLineString","coordinates":[[[-43.209563122750176,-22.903495209373933],[-42.29989579841706,-23.905969261790265],[-40.94989675476902,-24.316706749469176],[-37.34989930504097,-23.49392244589784],[-32.84990249288096,-18.026426383713453],[-31.04990376801693,-13.698987269610743],[-29.924904564976924,-9.29042430103552],[-31.49990343679494,-4.825692486823558],[-35.549900580176946,0.568578852526193],[-41.399896435985006,7.744889052551447],[-48.59989133544111,14.801154224791581],[-57.59988495976114,21.216397899942],[-63.4498808155692,24.94136317175375],[-69.29987667137726,29.73606949729215],[-73.3498738023213,33.565491482352044],[-75.59987220840131,35.78566189952622],[-75.82487204900934,36.1498667868178],[-76.05919805488554,36.755008440642534]],[[-35.549900580176946,0.568578852526193],[-36.899899623824986,-0.781386636225587],[-38.542964866112136,-3.718736532579084]],[[-63.4498808155692,24.94136317175375],[-65.69987922164921,20.375041253465433],[-66.14987890286528,19.104405475930452],[-66.1066660428526,18.46610541858561]]]}},{"type":"Feature","properties":{"id":"ellalink","name":"EllaLink","color":"#939597","feature_id":"ellalink-0","coordinates":[-43.610379345466555,3.254808699454053]},"geometry":{"type":"MultiLineString","coordinates":[[[-35.09990089896096,0.568578852526193],[-37.34989930504097,1.468426767331968],[-46.79989261057708,4.164912849976942],[-51.2998894227371,5.061986954416114],[-52.320928699423284,4.941547448310236]]]}},{"type":"Feature","properties":{"id":"ellalink","name":"EllaLink","color":"#c22c75","feature_id":"ellalink-1","coordinates":[-22.96085384459116,17.041489374928894]},"geometry":{"type":"MultiLineString","coordinates":[[[-16.08741436818081,32.43331330641721],[-16.64991396910482,32.52821504536491],[-16.908898160637996,32.647276965637694]],[[-23.521209101414858,14.923035560171673],[-23.849908868560828,14.801154224791581],[-24.074908709168866,14.801154224791581]],[[-38.542964866112136,-3.718736532579084],[-36.44989994260901,-0.781386636225587],[-35.09990089896096,0.568578852526193],[-31.04990376801693,3.715978119298069],[-27.85950075469042,7.716632622882817],[-25.649907593424945,11.294709319565477],[-24.2999085497769,14.365653759228442],[-24.074908709168866,14.801154224791581],[-23.399909187344846,16.10232559580297],[-21.59991046248082,19.95262290516439],[-19.59991187929863,22.884654113882444],[-18.449912693968844,24.94136317175375],[-17.662413251840803,27.76358852605777],[-17.47038457928175,28.275606244082983],[-17.0999136503208,28.95155473219332],[-17.19854858529393,29.690264251007914],[-16.64991396970081,31.670513047087127],[-16.08741436818081,32.43331330641721],[-13.72491604179678,33.93964008831966],[-11.47491763512078,36.51238821239364],[-9.449919069648717,37.67887792909206],[-9.112419308736753,37.85358171958824],[-8.869597215129223,37.95721527519206]],[[-13.72491604179678,33.93964008831966],[-12.374916998148738,33.93964008831966],[-9.899918750864702,33.93964008831966],[-7.631920358132023,33.60539511325584]],[[-20.430336961483764,21.674407102148695],[-17.035703999999548,20.947172000000176]]]}},{"type":"Feature","properties":{"id":"gulf2africa-g2a","name":"Gulf2Africa (G2A)","color":"#d1b32a","feature_id":"gulf2africa-g2a-0","coordinates":[49.05989894450997,13.82400971178645]},"geometry":{"type":"MultiLineString","coordinates":[[[54.14808587692784,17.09582718672565],[53.923864160172634,16.534196198259725],[52.65003693815965,15.56116563526334],[48.60003980721571,13.601498202276586],[45.450042038703735,12.834868817846521],[45.12334227014084,10.815963534652534],[45.01088234980864,10.43511874899288]],[[49.187929390749275,11.275556936623216],[48.60003980721571,13.601498202276586]]]}},{"type":"Feature","properties":{"id":"nigeria-cameroon-submarine-cable-system-ncscs","name":"Nigeria Cameroon Submarine Cable System (NCSCS)","color":"#36b449","feature_id":"nigeria-cameroon-submarine-cable-system-ncscs-0","coordinates":[5.613815221846451,2.9887897201358737]},"geometry":{"type":"MultiLineString","coordinates":[[[3.423511810692114,6.439066911484626],[4.050071366831244,4.164912849976942],[5.400070410479286,3.042156042425856],[6.300069772911254,2.817450442654169],[8.10006849777537,2.705081160335761],[9.000067860207336,2.817450442654169],[9.91022721544214,2.933124533518602]]]}},{"type":"Feature","properties":{"id":"san-andres-isla-tolu-submarine-cable-sait","name":"San Andres Isla Tolu Submarine Cable (SAIT)","color":"#c28c2b","feature_id":"san-andres-isla-tolu-submarine-cable-sait-0","coordinates":[-78.7757374397324,10.715014824972707]},"geometry":{"type":"MultiLineString","coordinates":[[[-81.70055036987328,12.584703013279052],[-78.29987029569729,10.41081650540272],[-76.49987157083336,9.967915186974132],[-75.55866223759485,9.49638197998378]]]}},{"type":"Feature","properties":{"id":"lynn-canal-fiber","name":"Lynn Canal Fiber","color":"#45bfb2","feature_id":"lynn-canal-fiber-0","coordinates":[-135.23193605581747,58.90140007130889]},"geometry":{"type":"MultiLineString","coordinates":[[[-135.31389103529182,59.458344430799],[-135.3850809830618,59.30041545947947],[-135.44500093910025,59.23583778263841],[-135.34998100881364,59.082616168069485],[-135.2515410810363,58.961407643090816],[-135.20169111760976,58.80882557674542],[-135.07029121401408,58.68451584667102],[-135.02390124804913,58.59201827449459],[-134.7472914509898,58.551049268231914]]]}},{"type":"Feature","properties":{"id":"hawaii-island-fibre-network-hifn","name":"Hawaii Island Fibre Network (HIFN)","color":"#d2da26","feature_id":"hawaii-island-fibre-network-hifn-0","coordinates":[-156.82008100946626,20.60383337709774]},"geometry":{"type":"MultiLineString","coordinates":[[[-159.36856338706372,21.974943031077807],[-159.1796035256981,21.924671936671945],[-158.84981323336208,21.635297384859552],[-158.45606405653842,21.460269198877302],[-158.22066328843266,21.4634468234482]],[[-157.69600461417235,21.277557463680488],[-157.7460045774889,21.173966491361547],[-157.7460045774889,20.89386327374593],[-157.55220471967434,20.732751122479872],[-157.34732486998877,20.562320024196303],[-156.89688520046363,20.562320024196303],[-156.75972530109388,20.636456177868624],[-156.46309551872272,20.782151843998083],[-156.48731490697796,20.58581909604039],[-156.37481498667395,20.480466375975812],[-156.09356518591377,20.269544035929588],[-155.83140598217537,20.03998810568653]],[[-156.75972530109388,20.636456177868624],[-156.89688520046363,20.746451785321774]],[[-157.55220471967434,20.732751122479872],[-157.1623144288019,20.90143978523765],[-157.02388510728736,21.09335900500387]]]}},{"type":"Feature","properties":{"id":"hawaii-inter-island-cable-system-hics","name":"Hawaii Inter-Island Cable System (HICS)","color":"#c92c40","feature_id":"hawaii-inter-island-cable-system-hics-0","coordinates":[-156.70771642607983,20.782151843998083]},"geometry":{"type":"MultiLineString","coordinates":[[[-159.36856338706372,21.974943031077807],[-159.2684634605042,21.882085293697696],[-158.84981323336208,21.53068533396254],[-158.3998135521461,21.32123529551186],[-158.1194943034701,21.339697571516076]],[[-157.69600461417235,21.277557463680488],[-157.69600461417235,21.173966491361547],[-157.49981418971396,21.053169495094362],[-157.1623144288019,20.953979036599044],[-157.00733511942963,21.01151286594859],[-156.82481466788994,20.953979036599044],[-156.75357530560598,20.88018534755951],[-156.713095335305,20.782151843998083],[-156.46309551872272,20.782151843998083],[-156.43106554222223,20.58581909604039],[-156.3185656247602,20.480466375975812],[-156.0373152257619,20.269544035929588],[-155.83140598217537,20.03998810568653]]]}},{"type":"Feature","properties":{"id":"unisur","name":"Unisur","color":"#af5b39","feature_id":"unisur-0","coordinates":[-55.66534984935991,-35.8489674395507]},"geometry":{"type":"MultiLineString","coordinates":[[[-54.950186836832145,-34.90041602705144],[-55.34988655368113,-35.59302880961419],[-55.79988623489721,-35.95811819864919],[-56.695445600474535,-36.47095527632105]]]}},{"type":"Feature","properties":{"id":"transcan-3","name":"TRANSCAN-3","color":"#d82729","feature_id":"transcan-3-0","coordinates":[-14.846845214219398,28.531593363654576]},"geometry":{"type":"MultiLineString","coordinates":[[[-15.699964642057815,27.99976141196043],[-15.637414686964824,28.194108049098066],[-15.524914766660805,28.29321405801615],[-14.399915563620802,28.68871408880043],[-13.82731596925565,28.863419398977502]]]}},{"type":"Feature","properties":{"id":"northern-lights","name":"Northern Lights","color":"#b3c134","feature_id":"northern-lights-0","coordinates":[-3.5999232138407486,58.881066344862234]},"geometry":{"type":"MultiLineString","coordinates":[[[-3.354957518022662,59.05145739475544],[-3.599923213840749,58.99117670269853],[-3.599923213840749,58.758568944882946],[-3.376583372056712,58.67032899288871]]]}},{"type":"Feature","properties":{"id":"jambi-batam-cable-system-jiba","name":"Jambi-Batam Cable System (JIBA)","color":"#574099","feature_id":"jambi-batam-cable-system-jiba-0","coordinates":[104.00625055693611,0.0067668853559623]},"geometry":{"type":"MultiLineString","coordinates":[[[104.01373700205465,1.065588210350364],[104.00657555670598,0.906050180869095],[104.034625536835,0.793562652607196],[104.03437553701218,0.627825578639147],[104.00625055693611,0.118588418888312],[104.00625055693611,-0.331409329660265],[103.46670093915829,-0.816543192375546]]]}},{"type":"Feature","properties":{"id":"segunda-fos-canal-de-chacao","name":"Segunda FOS Canal de Chacao","color":"#9fa237","feature_id":"segunda-fos-canal-de-chacao-0","coordinates":[-73.32529820833416,-41.906663419836335]},"geometry":{"type":"MultiLineString","coordinates":[[[-73.15529394016345,-41.80693750006321],[-73.2526538711928,-41.87241674679864],[-73.50446369280839,-41.99112728870596]]]}},{"type":"Feature","properties":{"id":"sistem-kabel-rakyat-1malaysia-skr1m","name":"Sistem Kabel Rakyat 1Malaysia (SKR1M)","color":"#b64d26","feature_id":"sistem-kabel-rakyat-1malaysia-skr1m-0","coordinates":[114.34252735258569,5.2141555581242445]},"geometry":{"type":"MultiLineString","coordinates":[[[103.85068066714335,2.29570245694968],[104.40000027800004,2.536507846510839],[105.74999932164808,3.210654701478254],[106.32358289909516,3.381150770036383],[108.17773197622363,4.302952044869047],[108.55236915756022,4.421744870603452],[108.9686493846804,4.24500432398029],[109.79999645259221,2.367912558705407],[110.13749621350416,1.918228780215599],[110.35370606033919,1.520169126642144],[110.47499597441613,1.918228780215599],[110.69999581502417,2.367912558705407],[111.59999517745614,3.042156042425856],[112.49999453988829,3.266814816815666],[113.0683541372569,3.197006076977989],[113.17499406171221,3.715978119298069],[113.39999390232026,4.164912849976942],[114.00741347201858,4.425309062538615],[114.07499342414418,5.061986954416114],[115.64999230840026,5.957818681088533],[116.07431200780823,5.981320525191568],[115.64999230840026,6.069699469736006],[110.69999581502417,5.510071711803246],[107.99999772772827,5.398081130463647],[105.29999964043219,4.613591578862773],[103.72500075617612,4.389285926050993],[103.39521098980225,4.116310078259609]]]}},{"type":"Feature","properties":{"id":"aqualink","name":"Aqualink","color":"#25b35d","feature_id":"aqualink-0","coordinates":[174.71131198496258,-37.8808058594003]},"geometry":{"type":"MultiLineString","coordinates":[[[174.08333091307907,-39.06666625662341],[174.14995086648068,-38.81782397325074],[174.2624507867848,-38.46634757786361],[174.59995054769675,-37.93590700435146],[174.87183035509463,-37.8013820822191],[174.59995054769675,-37.669239697851395],[174.48745062739283,-37.401610748143824],[174.59995054769675,-37.04328040742407],[174.77046042690606,-36.88418050095063]],[[175.04999905640736,-39.93333487547841],[174.8249503877089,-40.09176946134487],[174.8249503877089,-40.60619258328438],[175.051960226893,-40.862785929902145]],[[174.99747026549426,-40.9191122320591],[174.8249503877089,-40.946959050836256],[174.83800037846402,-41.10579929869014]],[[173.50613132197253,-42.51606222990869],[173.6999511846689,-42.789833156754874],[173.24995150345282,-43.44677235710084],[172.63622193822457,-43.53205498106837]],[[173.68300119667646,-42.402698756238465],[174.14995086588476,-42.29250328834085],[174.71245046740478,-41.623240762949166],[174.7671204286762,-41.280512963558316]]]}},{"type":"Feature","properties":{"id":"bt-highlands-and-islands-submarine-cable-system","name":"BT Highlands and Islands Submarine Cable System","color":"#48c1c4","feature_id":"bt-highlands-and-islands-submarine-cable-system-0","coordinates":[-5.759517843572668,58.04737095177362]},"geometry":{"type":"MultiLineString","coordinates":[[[-6.310761293457632,58.20906859046718],[-6.119061429259559,58.136377321810116],[-5.4750718854677,57.97695528254647],[-5.196212083014646,57.934079351013466]],[[-7.008370799264489,57.769407150859685],[-6.914620865677761,57.67895376060992],[-7.02233078937509,57.58568513448433],[-7.16335068947518,57.599372952628904]],[[-7.274140610990581,57.36669044941733],[-7.038200778132691,57.36669044941733],[-6.79299095184156,57.54483225323761],[-6.692891022753288,57.54483225323761],[-6.581661101549578,57.436323634745634]],[[-7.328140572736474,57.10553553922759],[-7.320160578389681,57.09353395774454],[-7.312180584042707,57.08152849001067],[-7.307195587574218,57.07757471896536],[-7.302210591105547,57.073620526498054],[-7.316690580847828,57.049459549954655],[-7.331170570590109,57.02528284471743],[-7.390325528684119,57.02528284471743],[-7.449480486778219,57.02528284471743]],[[-5.906461579867268,57.059207937300336],[-5.850171619743573,57.02861361880749],[-5.82043164081172,57.00848292332873]],[[-5.24538204818221,56.7225332657585],[-5.2395220523335,56.72205041221932],[-5.233662056484701,56.721567552480295]],[[-6.808510940847017,56.50378880151487],[-6.715941006424432,56.46609865687899],[-6.356171261288705,56.58212458237215],[-6.272381320646342,56.58212458237215]],[[-6.093961447040632,56.688570261687744],[-6.070281463815686,56.652798872259105],[-6.070281463815686,56.62185065462556]],[[-5.669351747838122,56.45584417362218],[-5.556821827555351,56.45584417362218],[-5.475801884950647,56.43776501147151]],[[-5.365791962882719,56.01942001909654],[-5.343501978673182,56.01385285953845],[-5.321211994463555,56.00828489747791]],[[-5.614571786644756,55.891682498449455],[-5.85822161404094,55.833673555352036],[-5.953831546309956,55.833673555352036]],[[-6.089911449909595,55.84675094531904],[-6.098116444097212,55.847244981157914],[-6.106321438284648,55.84773901071764]],[[-6.125411424761195,55.63612077746136],[-5.962421540224685,55.58970711313177],[-5.697581727839737,55.56550143569579]],[[-5.533421844132155,55.47654965962819],[-5.420921923828137,55.47654965962819],[-5.332101986748973,55.50194290033225]],[[-5.019992207850481,55.86941398754465],[-5.041792192407182,55.86400198851978],[-5.063592176963883,55.85858923536828]],[[-5.141422121828393,55.643559473635335],[-5.000747221483866,55.65942265518204],[-4.889372300382791,55.69304893491437]],[[-4.867302316017409,55.79334124050899],[-4.88388230427209,55.78675194417497],[-4.900462292526589,55.780161533136045]],[[-5.028092202112465,55.751841576756895],[-4.985982231943436,55.74730813687772],[-4.943872261774498,55.74277417016672]],[[-2.939523681673928,59.279768213277244],[-3.093673573068702,59.241407618692065],[-3.149923533220666,59.18382413213874],[-3.095073572076908,59.1180398759642]]]}},{"type":"Feature","properties":{"id":"c-lion1","name":"C-Lion1","color":"#7dc042","feature_id":"c-lion1-0","coordinates":[18.85106239888476,56.37434079541672]},"geometry":{"type":"MultiLineString","coordinates":[[[23.1750578185115,59.45171731890513],[22.950057977903466,59.7364093840784],[22.966757966073065,59.82340864139905]],[[24.932476573539617,60.171163188940554],[24.412556941855435,59.7364093840784],[23.850057340335432,59.62282176941042],[23.1750578185115,59.45171731890513],[22.275058456079353,59.222223914844314],[21.15005925303935,58.641677771385005],[20.250059890607382,57.57189027900508],[19.125060687567377,56.469711376547025],[17.325061962703444,55.84318584148108],[15.30006339723147,55.58970711313177],[14.400064034799321,55.33458061322904],[13.500064672367355,54.94878902385559],[12.600065309935387,54.68951871778461],[12.150065628719313,54.29748595281839],[12.037565708415386,54.16597178715178],[12.132485641173277,54.0791774165702]]]}},{"type":"Feature","properties":{"id":"far-east-submarine-cable-system","name":"Far East Submarine Cable System","color":"#a8346b","feature_id":"far-east-submarine-cable-system-0","coordinates":[144.4188789214346,53.58378795809737]},"geometry":{"type":"MultiLineString","coordinates":[[[151.2833670653833,59.58333960684468],[151.1999671244645,59.33716441962142],[150.86246736355255,58.99117670269853],[147.59996967473646,56.09502251152735],[143.5499725437925,53.76891056666807],[142.94245297416512,53.57753961709902],[143.5499725437925,53.635715156995076],[154.79996457419256,52.96339810559356],[156.28707352071072,52.8124095088162]]]}},{"type":"Feature","properties":{"id":"isles-of-scilly-cable","name":"Isles of Scilly Cable","color":"#804399","feature_id":"isles-of-scilly-cable-0","coordinates":[-5.992594434900541,50.0268815119827]},"geometry":{"type":"MultiLineString","coordinates":[[[-6.308171295292306,49.919910522553906],[-6.074921460528704,50.022920456254944],[-5.654511758350902,50.043147911894295]]]}},{"type":"Feature","properties":{"id":"new-cross-pacific-ncp-cable-system","name":"New Cross Pacific (NCP) Cable System","color":"#22ad97","feature_id":"new-cross-pacific-ncp-cable-system-0","coordinates":[150.1828860661257,39.04324962041344]},"geometry":{"type":"MultiLineString","coordinates":[[[-123.96253794783507,45.202232100184204],[-125.09983714216159,45.646541495187385],[-129.5998339543217,45.96024524125342],[-138.5998275786419,46.5823550820958],[-151.1998186526899,47.19740739556967],[-179.99979825051412,47.19740739556967]],[[179.99995828233068,47.19740739556967],[172.7999518228328,47.19740739556967],[160.19996074878455,44.05151922873524],[149.39996839960057,38.651811712711336],[143.09997286257644,34.683017659857974],[140.39997477528055,33.565491482352044],[138.59997605041642,32.8123187832876],[136.7999773255523,31.670513047087127],[134.99997860068837,30.901396088515508],[132.74998019460836,30.5144959597591],[131.39998115096031,29.1482487910328],[130.49998178852834,29.344566989489813],[128.24998338244836,30.5144959597591],[125.99998497636834,30.901396088515508],[122.17498768603225,30.853118511880677],[121.92508786246776,30.86475026744717],[121.89393788393856,30.89950835597767],[121.89607788361477,30.935660541111577]],[[125.99998497636834,30.901396088515508],[124.19998625150441,31.670513047087127],[122.84998720785636,31.86180860227073],[121.94998784542422,31.718374001887323],[121.39529823837174,31.619800328867754]],[[128.24998338244836,30.5144959597591],[128.24998338244836,31.670513047087127],[128.4749832230562,32.8123187832876],[128.92498290427227,34.31215165223547],[128.99949285148878,35.17037876180022]],[[140.39997477528055,33.565491482352044],[140.17497493467252,33.93964008831966],[140.11872497452055,34.405022750715936],[140.14684995400054,34.69072647741027],[140.14684995400054,34.89859296336222],[139.97546699999984,35.005433000000174]],[[131.39998115096031,29.1482487910328],[128.69998306366443,26.1593079707739],[127.34998402001638,25.55188275942587],[125.99998497636834,25.348717422116714],[124.19998625150441,24.94136317175375],[122.84998720785636,25.043329056612176],[122.17498768603225,24.99235668767365],[121.80144795065142,24.863504112487785]],[[121.89607788361477,30.935660541111577],[121.61248808391635,30.933567621605523],[121.27498832300438,31.12636933176112],[121.27498832300438,31.510798430049064],[121.39529823837174,31.619800328867754]]]}},{"type":"Feature","properties":{"id":"monet","name":"Monet","color":"#5bba46","feature_id":"monet-0","coordinates":[-40.0931941836019,7.245978255095368]},"geometry":{"type":"MultiLineString","coordinates":[[[-46.328062944825646,-23.961842897597087],[-44.54989420449707,-25.179443898921058],[-41.399896435985006,-25.33771218660113],[-37.01239954412901,-23.803079640835886],[-31.949728484343677,-18.025284192896695],[-30.14972975947965,-13.697820288632505],[-29.249905043153905,-9.29042430103552],[-31.162403672774964,-4.489307673128955],[-34.64990121774498,0.568578852526193],[-40.499897073553036,7.744889052551447],[-48.59989133544111,15.669513225155248],[-57.59988495976114,20.796306105108872],[-69.29987667137726,25.75470426341523],[-73.3498738023213,27.76358852605777],[-76.94987125204935,27.962503359972466],[-77.84987061448132,27.76358852605777],[-78.74986997691337,27.264711877833996],[-79.64986933934534,26.763586569619914],[-80.08893155227202,26.350584577319996]],[[-34.64990121774498,0.568578852526193],[-35.99990026139302,-0.781386636225587],[-38.542968459859594,-3.718735129291092]]]}},{"type":"Feature","properties":{"id":"sea-us","name":"SEA-US","color":"#4eb748","feature_id":"sea-us-0","coordinates":[-150.242675675476,27.388951488009898]},"geometry":{"type":"MultiLineString","coordinates":[[[-118.39945344493313,33.862474868985494],[-120.59984033000157,33.189714664600466],[-122.3998390548656,33.189714664600466],[-127.79983522945767,32.8123187832876],[-138.60310458621126,30.516425505901374],[-147.6030982105313,28.953514579902283],[-152.99981737755394,25.75470426341523],[-157.94981387092994,22.677206196582915],[-158.39981355214593,22.05298561667754],[-158.45606295953138,21.73983373091106],[-158.456063506614,21.635297384859552],[-158.22066328843266,21.4634468234482],[-158.3998135521461,21.268825931479064],[-158.849813233362,21.006499845176737],[-163.799809726738,19.95262290516439],[-172.79980335105813,19.95262290516439],[-179.99979825051412,18.251816319028222]],[[137.24997700676838,10.85308969074528],[137.69997668798445,9.967915186974132],[138.06149986937797,9.443922836169385]],[[133.19997987582445,9.08033076823294],[133.64997955704033,8.190543417795496],[134.5609408257699,7.531746239289517]],[[125.61287587559997,7.079988883160643],[125.99998497636834,5.957818681088533],[126.44998465758441,5.061986954416114]],[[125.06561063828791,1.378623021535939],[125.99998497636834,1.918228780215599],[126.44998465758441,3.266814816815666],[126.44998465758441,5.061986954416114],[133.19997987582445,9.08033076823294],[137.24997700676838,10.85308969074528],[143.9999722250084,13.054150695298627],[144.69469829535797,13.464777824933044]],[[144.69470173285575,13.464772962370143],[144.89997158744055,13.273238157547594],[145.34997126865645,13.492128176464083],[146.24997063108842,14.038469666260218],[147.14996999352056,14.147583506948735],[151.1999671244645,14.365653759228442],[160.19996074878455,15.669513225155248],[179.99992672230314,18.251816319028222]]]}},{"type":"Feature","properties":{"id":"faster","name":"FASTER","color":"#4bb748","feature_id":"faster-0","coordinates":[-152.11059071043016,45.96024524125342]},"geometry":{"type":"MultiLineString","coordinates":[[[136.87399727311598,34.33682825203173],[137.69997668798445,33.189714664600466],[138.59997605041642,32.243210016262736]],[[138.59997605041642,32.243210016262736],[140.39997477528055,33.001218522654476],[143.09997286257644,34.31215165223547],[149.39996839960057,37.589786573603064],[160.19996074878455,42.743713464436695],[172.7999518228328,45.96024524125342],[179.99995828233068,45.96024524125342]],[[140.39997477528055,33.001218522654476],[140.0624750143684,33.93964008831966],[140.39997477528055,34.405022750715936],[140.28747485438052,34.69072647741027],[140.0765375038106,34.852445708846155],[139.95485509060742,34.97657002902234]],[[-124.40833763202653,43.118664098550106],[-125.99983650459365,43.073310783003215],[-129.5998339543217,43.72721479104982],[-138.59982757864174,45.0138336439531],[-151.19981865269,45.96024524125342],[-179.99979825051412,45.96024524125342]],[[138.59997605041642,32.243210016262736],[136.7999773255523,31.09426282763951],[134.99997860068837,30.320465424761444],[131.39998115096031,29.246454972180413],[129.5999824260964,28.95155473219332],[128.24998338244836,28.55704546571133],[122.84998720785636,26.461843796188983],[121.94998784542422,25.75470426341523],[121.46258819070279,25.181712818924378]]]}},{"type":"Feature","properties":{"id":"kerch-strait-cable","name":"Kerch Strait Cable","color":"#36ada8","feature_id":"kerch-strait-cable-0","coordinates":[36.630544281826964,45.36862437776394]},"geometry":{"type":"MultiLineString","coordinates":[[[36.47738839501581,45.356985070824315],[36.67504825499155,45.37200652267839],[36.77084818712606,45.423609710410595]]]}},{"type":"Feature","properties":{"id":"north-west-cable-system","name":"North-West Cable System","color":"#4f479c","feature_id":"north-west-cable-system-0","coordinates":[122.92762263112891,-13.957634157608846]},"geometry":{"type":"MultiLineString","coordinates":[[[118.57724023471046,-20.31344226536242],[118.5749902363042,-18.880139975101173],[118.12499055508832,-18.240251410711533],[118.23749047539224,-17.597998996155503],[120.59998880177636,-15.224032284647373],[121.61248808451225,-14.898126357061642],[122.28748760633636,-14.026655889819525],[123.29998688907226,-13.91748446246452],[124.42498609211226,-13.589662250512088],[126.44998465698852,-11.55232519729577],[127.34998401942048,-11.55232519729577],[129.0374828245764,-11.723727283361146],[129.20623270503228,-11.77884724450649],[129.9375821869376,-11.998971512032105],[130.50000678851072,-12.109122931112692],[130.84314154543083,-12.467474336203543]],[[130.50000678851072,-12.109122931112692],[130.5001317884223,-11.999142688339566],[130.63208169494754,-11.762596324883583]]]}},{"type":"Feature","properties":{"id":"terra-sw","name":"TERRA SW","color":"#63bb45","feature_id":"terra-sw-0","coordinates":[-155.87811075842004,59.33341963679675]},"geometry":{"type":"MultiLineString","coordinates":[[[-151.54424912754024,59.646565622018684],[-152.09981801512188,59.62282176941042],[-152.99981737755394,59.56588346342965],[-153.5451276595526,59.6180780639796],[-153.59107762584054,59.63553746032112],[-153.63213759571596,59.68237455442974]],[[-153.88047741351608,59.776104342979096],[-153.95294736034697,59.779688216156124],[-154.04356729386163,59.75687472421307],[-154.0935672571782,59.76710773022677],[-154.10611659384108,59.787212090872806],[-154.1201272376918,59.766317201269565],[-154.16866720207946,59.74738423416073],[-154.3154370943986,59.760813191165234],[-154.47325697861058,59.755303236949786],[-154.64644685154616,59.70689346193012],[-154.8216867229776,59.716718187238634],[-154.9061160271141,59.75471391016683]],[[-154.84668670463583,59.70804857154889],[-154.9061160271141,59.75471391016683]],[[-153.88047741351608,59.776104342979096],[-153.95606735805794,59.76396060900041],[-154.03575729959164,59.73404564131673],[-154.12325723539544,59.711987750401136],[-154.24512714598293,59.70174290294215],[-154.39200703822138,59.69780251824729],[-154.64737685086385,59.65359910967581],[-154.69512681583097,59.6180780639796],[-154.7545067722656,59.521531420879995],[-154.75274677355685,59.44218602760369]],[[-154.85884669571442,59.949183973331124],[-154.85256574446106,59.96241174485079],[-154.84771606848523,59.972621618602275],[-154.70811616737902,60.06032279981195],[-154.55811627364042,60.12887602174943],[-154.51763694605032,60.14198209826014],[-154.4295070107087,60.17915062699834],[-154.39043703937318,60.19468724270353],[-154.3369270786319,60.20036807902232]],[[-154.84668670463583,59.70804857154889],[-154.79200674475294,59.52787100202234],[-154.75274677355685,59.44218602760369]],[[-154.8997666656926,59.718120043251695],[-154.8763766828532,59.695436046444215],[-154.9435666335578,59.633090759199945],[-155.0451265590462,59.59752775931568],[-155.79356600993748,59.400852717370704],[-155.89473593571194,59.327774303483444],[-155.7591860351611,59.373802346117806],[-155.26495639776326,59.46236377142176],[-154.79356674360832,59.46997939044741],[-154.75274677355685,59.44218602760369]]]}},{"type":"Feature","properties":{"id":"alaska-united-turnagain-arm-auta","name":"Alaska United Turnagain Arm (AUTA)","color":"#3e4b9f","feature_id":"alaska-united-turnagain-arm-auta-0","coordinates":[-149.35712107079758,60.921895945669604]},"geometry":{"type":"MultiLineString","coordinates":[[[-149.7321904569961,61.01569206774275],[-149.63141053093545,60.9696391075957],[-149.48371063929864,60.95361622164202],[-149.35138073638538,60.92045755707341],[-149.1951308510214,60.92197391282362],[-149.0873201492036,60.889302842643474],[-148.98981100165872,60.82829123683605]]]}},{"type":"Feature","properties":{"id":"acs-alaska-oregon-network-akorn","name":"ACS Alaska-Oregon Network (AKORN)","color":"#4c4fa1","feature_id":"acs-alaska-oregon-network-akorn-0","coordinates":[-137.94389964435555,54.73163712377136]},"geometry":{"type":"MultiLineString","coordinates":[[[-151.2916693128509,60.68992891270401],[-151.2916693128509,60.84842055964986],[-150.23341008926553,61.157291052768],[-149.8584103643921,61.217558335568484]],[[-151.2916693128509,60.68992891270401],[-151.0416187647601,60.567312045135864],[-150.91661885331126,60.44422832764756],[-151.0416187647601,60.258724767959094],[-151.41919921928584,60.02342960326287],[-151.54424912754024,59.835530750006015],[-151.54424912754024,59.646565622018684],[-152.09981801512188,59.50884868221247],[-152.09981801512188,59.106894957190725],[-151.1998186526899,58.87506831089772],[-146.24982215931382,58.52439396084473],[-139.94982662228978,56.840738642145496],[-139.04982725985772,55.84318584148108],[-137.24982853499378,54.03403825672422],[-134.09983076648172,50.740281893948165],[-127.79983522945767,45.331071073324864],[-125.09983714216159,44.05151922873524],[-124.09993785049973,43.98220183349397]]]}},{"type":"Feature","properties":{"id":"adria-1","name":"Adria-1","color":"#65b545","feature_id":"adria-1-0","coordinates":[19.252270468971375,41.166242985291646]},"geometry":{"type":"MultiLineString","coordinates":[[[19.91959012471538,39.61953623629301],[19.57506036878345,39.78482855593699],[19.012560767263448,40.04369219283004],[18.900060846959338,40.38732029077508],[19.125060687567377,41.0693404382162],[19.45006045733454,41.31691028026608],[19.125060687567377,41.40772623743595],[18.11256140483148,42.41235450073586],[18.10651140911737,42.64207795542642]]]}},{"type":"Feature","properties":{"id":"africa-coast-to-europe-ace","name":"Africa Coast to Europe (ACE)","color":"#8cc63f","feature_id":"africa-coast-to-europe-ace-0","coordinates":[-15.412392583191341,7.410105237917242]},"geometry":{"type":"MultiLineString","coordinates":[[[-16.518014062543934,28.059088061264806],[-16.199914287888834,27.564309487941923],[-15.299914925456777,26.964304734562898],[-17.0999136503208,22.884654113882444],[-18.449912693968844,19.95262290516439],[-18.449912693968844,16.534196198259725],[-17.99991301275286,15.23578178303578],[-17.445713405352947,14.686594841994992],[-17.99991301275286,13.92930384327183],[-17.99991301275286,13.492128176464083],[-18.449912693968844,11.735650161405832],[-17.0999136503208,8.635699417327467],[-15.74991460667276,7.744889052551447],[-14.849915244240792,6.852191098754328],[-12.599916838160784,4.613591578862773],[-10.799918113296759,2.817450442654169],[-5.399921938704683,2.817450442654169],[-4.387422655968697,3.266814816815666],[-4.026242911831877,5.323508791824841],[-3.824923054448695,3.266814816815666],[-3.149923532624674,2.817450442654169],[-0.674925285936628,2.830478071896748],[1.575073120143199,3.82823430332105],[2.25007264196731,3.82823430332105]],[[2.475072482575348,4.164912849976942],[5.400070410479286,2.592701464601932],[6.300069772911254,2.367912558705407],[7.200069135343221,1.468426767331968],[7.200069135343221,1.018534216615524],[6.733269466028674,0.333286471885964]],[[-9.33155915349599,38.690161972355526],[-9.899918750864702,38.29952060596925]],[[-4.338542690595681,47.81102015174913],[-5.849921619920758,47.65407102366078],[-7.649920344784692,46.890762878622326],[-10.799918113296759,43.401144973153954],[-10.799918113296759,39.6983233549332],[-10.349918432080775,39.00237890905839],[-9.899918750864702,38.29952060596925],[-11.249917794512742,36.51238821239364],[-11.699917475728817,35.419780517080355],[-13.274916359984806,32.052708023486204],[-14.174915722416772,29.73606949729215],[-14.737415323936775,28.161052262220792],[-15.299914925456777,26.964304734562898]],[[-15.978284444893506,18.08386849170685],[-17.0999136503208,18.251816319028222],[-18.449912693968844,18.251816319028222]],[[-17.99991301275286,13.492128176464083],[-17.0999136503208,13.492128176464083],[-16.58136401766616,13.456136894896968]],[[-15.74991460667276,7.744889052551447],[-13.703826056141079,9.51343460136282]],[[-13.238096386068461,8.485442435793914],[-14.849915244240792,6.852191098754328]],[[-10.797188115230739,6.300378530564464],[-12.599916838160784,4.613591578862773]],[[-0.204315619320974,5.558285889905858],[-0.674925285936628,3.279837005484997],[-0.674925285936628,2.830478071896748]],[[2.440112507341202,6.356673335458259],[1.800072960751236,5.061986954416114],[1.800072960751236,3.82823430332105]],[[3.423511810692114,6.439066911484626],[2.475072482575348,4.164912849976942],[2.25007264196731,3.82823430332105]],[[7.200069135343221,1.468426767331968],[9.000067860207336,1.468426767331968],[9.768227316036105,1.860150409321811]],[[7.200069135343221,1.018534216615524],[8.550068178991262,0.793562652607196],[9.454267538448212,0.394465191855477]],[[18.449961165814393,-33.69332014378617],[17.55006180331148,-33.55534420877606],[16.20006275966344,-32.61276000573574],[14.850063716015397,-30.30995334464681],[11.700065947503239,-23.49392244589784],[10.350066903855378,-18.026426383713453],[10.350066903855378,-10.620064860363238],[9.000067860207336,-6.616650693475355],[8.550068178991262,-4.825692499217419],[6.987569285284264,0.118588418888312],[6.733269466028674,0.333286471885964]],[[-17.445713405352947,14.686594841994992],[-17.54991333213278,13.92930384327183],[-17.099913650916793,12.175887185507976],[-16.64991396910482,11.735650161405832],[-16.199914287888834,11.680570534838436],[-15.791145687384237,11.774131923775238]]]}},{"type":"Feature","properties":{"id":"alaska-united-east-au-east","name":"Alaska United East (AU-East)","color":"#d88227","feature_id":"alaska-united-east-au-east-0","coordinates":[-135.49180070038943,55.29638226769841]},"geometry":{"type":"MultiLineString","coordinates":[[[-122.31576057165005,47.82410083595476],[-122.44105402626471,47.87477916178281],[-122.46918400633709,47.9219300503356],[-122.56761643660668,47.959608461901695],[-122.62387139675504,48.034889597039694],[-122.65199137683463,48.11006090495571],[-122.84983873608158,48.23806972100289],[-123.74983809851364,48.29423569031507],[-124.6498374609456,48.5555277566182],[-125.09983714216186,48.5555277566182],[-131.39983267918572,50.740281893948165],[-134.5498304476977,54.03403825672422],[-135.89982949134574,55.84318584148108],[-138.14982789742575,57.086065975868046],[-144.89982311566578,58.52439396084473],[-147.59982120296186,59.45171731890513],[-147.98004174249772,59.84949183975676],[-147.8607118300467,59.95730042115305],[-147.76134190295159,60.01588909309458],[-147.72926192648777,60.10213978192451],[-147.61520201017026,60.23766915823624],[-147.53332125007117,60.3044763487988],[-147.45905212473298,60.47899019642459],[-147.47692129002536,60.59031613489887],[-147.63376199655332,60.62019746535941],[-147.86442101551697,60.620776363754125],[-148.0361017013681,60.665011707179026],[-148.0361017013681,60.69939513380331],[-148.08639166447182,60.75819549198801],[-148.1958207807503,60.78210822700621],[-148.32580148882363,60.780941570583],[-148.41354142445135,60.78620827245595],[-148.51818134767996,60.77224156315422],[-148.68473122548716,60.77302768780248]],[[-135.89982949134574,55.84318584148108],[-134.46653050670835,56.03358023911155],[-134.41545169445124,56.466474307352364],[-134.52473046547877,56.84950003985658],[-134.61379219497496,57.01007188259318],[-134.74495930054152,57.23773670488086],[-134.76370924947742,57.495568897564894],[-134.8090152646848,57.594609061615635],[-134.8591152291936,57.734649479402336],[-134.88098521370068,58.012978928938054],[-134.95979129568082,58.1072095322198],[-134.96702128978026,58.197824452869256],[-134.98333014060265,58.28982779937171],[-135.00109512861368,58.365509963814],[-135.00032126534904,58.428990502797014],[-134.92327132187842,58.45076363488806],[-134.84319138063083,58.41219086765235],[-134.76047144132005,58.3461664188903],[-134.7013914846653,58.339527257678945],[-134.60315155674115,58.35644022577092],[-134.54221160145104,58.348422924730585],[-134.4068617007534,58.299576750775365]],[[-146.35343293589426,61.13035646305128],[-146.56554278027536,61.12116226806669],[-146.6739427007454,61.073511490289796],[-146.82907258693103,60.97605861690985],[-146.89923253545666,60.86794329208306],[-147.08317240050522,60.811007238095044],[-147.5216020788418,60.7882580984503],[-147.88996180858678,60.779652829598795],[-148.0316617046257,60.77038115412248],[-148.21178157247675,60.79373829860351],[-148.3284314868941,60.79373829860351],[-148.39855143544906,60.7967634749319],[-148.52155134520754,60.781322325046894],[-148.68473122548716,60.77302768780248]]]}},{"type":"Feature","properties":{"id":"alaska-united-west-au-west","name":"Alaska United West (AU-West)","color":"#30b995","feature_id":"alaska-united-west-au-west-0","coordinates":[-136.29325737407103,53.97488465252328]},"geometry":{"type":"MultiLineString","coordinates":[[[-149.4476706657402,60.110049313261904],[-149.37169494834598,59.906069924574304],[-148.04982088417785,59.45171731890513],[-145.79982247809784,58.52439396084473],[-139.4998269410738,56.840738642145496],[-138.59982757864174,55.84318584148108],[-136.34982917256173,54.03403825672422],[-133.19983140404966,50.740281893948165],[-127.79983522945767,47.50228998113266],[-125.09983714216159,46.42748958617894],[-123.92376939189971,46.16522099122738]],[[-131.64788372493697,55.34196841314809],[-131.62786373962516,55.18053443385063],[-131.74758365179002,54.99362892777975],[-131.62483251979376,54.81936191424915],[-131.8498323604017,54.55925876578231],[-133.19983140404966,54.55925876578231],[-134.99983012891377,54.428581396018195],[-136.34982917256173,54.03403825672422]]]}},{"type":"Feature","properties":{"id":"alba-1","name":"ALBA-1","color":"#e11e25","feature_id":"alba-1-0","coordinates":[-71.07810156992379,15.64840215387195]},"geometry":{"type":"MultiLineString","coordinates":[[[-75.71237212870533,19.96373301197173],[-75.59987220840131,19.104405475930452],[-75.14987252718532,18.251816319028222],[-71.09987539624129,15.669513225155248],[-67.94987762772922,12.615395567393394],[-67.04987826529725,11.294709319565477],[-66.88962837881974,10.603529760437182]],[[-75.82892204614029,20.029343742363547],[-76.0498718896173,19.529070924350908],[-76.72487141144131,18.678647022154717],[-77.10324114340065,18.398661383088054]]]}},{"type":"Feature","properties":{"id":"aletar","name":"Aletar","color":"#57b947","feature_id":"aletar-0","coordinates":[32.773344583317,33.17888888608089]},"geometry":{"type":"MultiLineString","coordinates":[[[29.8935130590948,31.191465077638455],[30.150052877359567,31.670513047087127],[35.55004905195155,34.775476075756664],[35.89779880560243,34.89170328553848]]]}},{"type":"Feature","properties":{"id":"alonso-de-ojeda","name":"Alonso de Ojeda","color":"#95ad3b","feature_id":"alonso-de-ojeda-0","coordinates":[-69.40457564357696,12.14518035609816]},"geometry":{"type":"MultiLineString","coordinates":[[[-69.87858626141403,12.414622452999883],[-69.74987635259325,12.285833556268383],[-69.07487683076923,12.010882360458767],[-68.89264695986267,12.09043961830498]]]}},{"type":"Feature","properties":{"id":"alpal-2","name":"Alpal-2","color":"#b55927","feature_id":"alpal-2-0","coordinates":[2.700072323183203,38.08046938787106]},"geometry":{"type":"MultiLineString","coordinates":[[[2.901442180531094,36.80021989664316],[2.700072323183203,37.23235432155614],[2.700072323183203,38.651811712711336],[2.970972131275422,39.35484412819336]]]}},{"type":"Feature","properties":{"id":"americas-i-north","name":"Americas-I North","color":"#9b4d9d","feature_id":"americas-i-north-0","coordinates":[-70.13277581463565,27.17302359454685]},"geometry":{"type":"MultiLineString","coordinates":[[[-64.93708976201641,18.372992194090898],[-64.79987985921724,18.891661584303154],[-65.02487969982519,20.796306105108872],[-66.82487842468922,23.7112581424843],[-69.29987667137726,26.763586569619914],[-73.3498738023213,28.75448641587171],[-76.0498718896173,28.55704546571133],[-77.39987093326533,28.359233526108557],[-79.64986933934534,27.76358852605777],[-80.3942513282677,27.638731771078668]]]}},{"type":"Feature","properties":{"id":"amerigo-vespucci","name":"Amerigo Vespucci","color":"#b59a30","feature_id":"amerigo-vespucci-0","coordinates":[-68.57712870433576,12.070074629429218]},"geometry":{"type":"MultiLineString","coordinates":[[[-68.27663739624963,12.151067198563975],[-68.51237722924922,12.065895273570327],[-68.89264695986267,12.09043961830498]]]}},{"type":"Feature","properties":{"id":"antillas-1","name":"Antillas 1","color":"#3e60ac","feature_id":"antillas-1-0","coordinates":[-68.42888898863059,18.59865513032633]},"geometry":{"type":"MultiLineString","coordinates":[[[-70.16178606079258,18.6993638412382],[-69.69362639244127,18.144943564296213],[-69.29987667137726,17.931002277731235],[-68.51237722924922,17.931002277731235],[-68.28737738864127,18.251816319028222],[-68.43815728182744,18.621371316104735],[-68.17487746833726,18.785187974742005],[-67.49987794651324,19.104405475930452],[-67.04987826529725,18.998067525948983],[-66.48737866377725,18.785187974742005],[-66.01686899768664,18.44203883389064]]]}},{"type":"Feature","properties":{"id":"seax-1","name":"SEAX-1","color":"#61a042","feature_id":"seax-1-0","coordinates":[104.39998912954832,1.918183966993337]},"geometry":{"type":"MultiLineString","coordinates":[[[104.01663465625194,1.066798937337329],[104.0156255502948,1.223182297279849],[104.13781784654564,1.263407170707374]],[[103.94648497427453,1.327257988673153],[104.13781784654564,1.263407170707374]],[[103.8506842608908,2.295706828435153],[104.32889093295785,2.140106066877365],[104.40000027800004,1.918228780215599],[104.28810035727116,1.468426767331968],[104.17793012281672,1.263407170707374],[104.13781784654564,1.263407170707374]]]}},{"type":"Feature","properties":{"id":"apollo","name":"Apollo","color":"#d36f27","feature_id":"apollo-0","coordinates":[-38.468446590652974,41.36699126032328]},"geometry":{"type":"MultiLineString","coordinates":[[[-74.04709330840446,40.12349265823708],[-71.09987539624129,39.6983233549332],[-68.39987730894529,39.35121757117122],[-61.19988240948919,38.29952060596925],[-50.39989006030513,38.475881348138756],[-39.59989771112098,41.0693404382162],[-23.399909187344846,45.331071073324864],[-16.199914287888834,45.96024524125342],[-5.399921938704683,49.14772788577412],[-4.499922576272716,49.000334389463426],[-3.459883313046331,48.73055297916871]],[[-72.87218414072115,40.800580995045266],[-71.09987539624129,40.215724060833985],[-68.39987730894529,40.55848045058698],[-61.19988240948919,40.72920412488655],[-50.39989006030513,42.07923561816413],[-39.59989771112098,45.0138336439531],[-23.399909187344846,49.000334389463426],[-16.199914287888834,49.58728674004685],[-10.799918113296759,50.167261162927154],[-8.099920026000767,50.454639125893955],[-5.399921938704683,50.88245364291024],[-4.544402544762735,50.82820142743812]]]}},{"type":"Feature","properties":{"id":"arcos","name":"ARCOS","color":"#51489d","feature_id":"arcos-0","coordinates":[-75.07080241424389,11.272769612255498]},"geometry":{"type":"MultiLineString","coordinates":[[[-83.77681884657423,15.26131021390879],[-83.69986647028928,15.669513225155248],[-84.14986615150535,16.10232559580297],[-85.0498655139375,16.10232559580297],[-85.95469724872682,15.915243688695657],[-86.39986455758554,16.10232559580297],[-86.84986423880153,16.10232559580297],[-87.9461557876504,15.844981598742601],[-88.19986328244948,15.886035719079029],[-88.59713531004529,15.727236638721036],[-88.42486312305734,16.10232559580297],[-88.08736336214537,16.534196198259725],[-87.97486344184135,16.965102599435927],[-88.1821856144821,17.49638520483909],[-87.7498636012335,17.82393441253792],[-87.52486376062537,18.251816319028222],[-87.29986392001751,19.529070924350908],[-87.29986392001751,19.95262290516439],[-87.46353614173474,20.212733353176567],[-87.18736399971331,20.05833455139623],[-86.84986423880153,20.163975031975873],[-86.62486439819331,20.375041253465433],[-86.62486439819331,20.796306105108872],[-86.76758665233304,21.09572879236739],[-86.51236447788929,21.216397899942],[-86.17486471697732,21.425997872385402],[-85.83736495666136,21.635297384859552],[-84.82486567332928,23.298598065875897],[-83.2498667890733,24.32780311165181],[-80.99986838299347,24.32780311165181],[-80.5498687017773,24.73717827217609],[-79.9873691002574,25.348717422116714],[-80.16222149850138,25.933206978469332]],[[-72.11940467399724,21.85108832553302],[-71.99987475867334,21.635297384859552],[-70.76237563532924,20.375041253465433],[-70.6911856857609,19.799436355797177],[-70.19987603380923,19.95262290516439],[-69.29987667137726,19.740987365524937],[-68.62487714955324,19.104405475930452],[-68.51237722924922,18.891661584303154],[-68.43815728182744,18.621371316104735],[-68.17487746833726,18.891661584303154],[-67.49987794651324,19.210675111642853],[-67.04987826529725,19.104405475930452],[-66.48737866377725,18.838433217733183],[-66.01686899709074,18.441839618642867]],[[-66.01686899709074,18.441839618642867],[-66.48737866377725,18.625351394064932],[-67.04987826529725,18.678647022154717],[-67.38737802620922,18.465364393137126],[-67.61237786681725,17.82393441253792],[-68.39987730894529,13.92930384327183],[-68.89264695986267,12.09043961830498],[-69.07487683076923,11.955858207114732],[-69.74987635259325,12.175887185507976],[-70.12819608458797,12.355002950040006],[-70.42487587441727,12.175887185507976],[-70.20431603066386,11.708782466419155],[-70.42487587441727,11.735650161405832],[-70.6498757150253,12.175887185507976],[-71.32487523684924,12.615395567393394],[-71.99987475867334,12.615395567393394],[-72.67487428049728,12.175887185507976],[-72.89987412110531,11.735650161405832],[-72.95270408368005,11.483114759293935],[-73.3498738023213,11.735650161405832],[-73.79987348353728,11.735650161405832],[-74.69987284596934,11.515266158038768],[-75.37487236779336,11.073982781226615],[-75.50573227509088,10.38680745163333],[-76.0498718896173,10.41081650540272],[-77.41987091969315,9.67231131777662],[-77.93346055586147,9.125435434149097],[-78.29987029629338,9.67231131777662],[-79.08736973782534,9.746236973759974],[-79.75352926591172,9.437623338483982],[-80.09986902056123,9.746236973759974],[-82.34986742664132,9.967915186974132],[-83.03765938887449,9.988597517410145],[-83.2498667890733,11.294709319565477],[-83.47486662968133,11.735650161405832],[-83.77154885044065,11.991681428073646],[-83.47486662968133,12.175887185507976],[-83.24986678907348,12.615395567393394],[-83.24986678907348,13.492128176464083],[-83.39644912564061,14.016107024140217],[-83.02486694846526,14.365653759228442],[-82.91236702816133,15.018578573757472],[-83.2498667890733,15.452760959322058],[-83.47486662968133,15.452760959322058],[-83.77681884657423,15.26131021390879]],[[-72.11940467399724,21.85108832553302],[-72.44987443988924,22.469443964829516],[-73.3498738023213,22.884654113882444],[-73.79987348353728,22.884654113882444],[-74.19504320359538,22.62989167911265]],[[-74.19504320359538,22.62989167911265],[-74.4748730053613,22.677206196582915],[-74.58737292566532,22.884654113882444],[-74.4748730053613,23.298598065875897],[-74.4748730053613,23.7112581424843],[-74.92487268657729,24.225251377401914],[-75.52590226080234,24.403328403350237],[-75.77889084339483,24.410331177006306],[-76.49987157083336,24.53265756616073],[-76.94987125204935,24.94136317175375],[-77.34020097553613,25.067217613601834],[-77.84987061448132,25.145210227401346],[-79.19986965812936,25.75470426341523],[-79.64986933934534,25.75470426341523],[-80.16222149850138,25.933206978469332]]]}},{"type":"Feature","properties":{"id":"asia-america-gateway-aag-cable-system","name":"Asia-America Gateway (AAG) Cable System","color":"#69479c","feature_id":"asia-america-gateway-aag-cable-system-0","coordinates":[132.01583161303728,16.38596071822629]},"geometry":{"type":"MultiLineString","coordinates":[[[107.07919838003114,10.342138429683002],[107.77499788712005,9.746236973759974],[108.6749972495522,9.52441134501949],[110.24999613380828,9.746236973759974],[110.69999581502417,9.967915186974132]],[[109.34999677137613,7.29876275445952],[112.94999422050827,5.659359572411489],[114.29999326475222,5.061986954416114],[114.88563284987971,4.926762452886689]],[[179.99992672230314,19.104405475930452],[160.19996074878455,16.534196198259725],[151.1999671244645,15.23578178303578],[147.14996999352056,14.365653759228442],[146.24997063108842,14.147583506948735],[145.34997126865645,13.54681947716878],[144.89997158744055,13.327979290563553],[144.809541651502,13.549094363148988],[143.9999722250084,13.492128176464083],[143.09997286257644,13.710817738179635],[138.59997605041642,14.801154224791581],[131.39998115096031,16.534196198259725],[125.99998497636834,18.251816319028222],[122.39998752664029,18.891661584303154],[121.09574530038999,19.01914871362034],[120.42074577856589,18.59317365379659],[120.14998912056028,16.965102599435927],[120.38973895071938,16.582592330693284],[119.92498927995224,16.965102599435927],[116.99999135204831,18.251816319028222],[115.3124925474883,19.529070924350908],[114.52499310536027,20.375041253465433],[113.79374362338419,21.635297384859552],[113.94911351331866,22.271493895850078],[113.73749366323221,21.635297384859552],[113.51249382262418,20.796306105108872],[113.39999390232026,18.251816319028222],[111.14999549624025,12.615395567393394],[110.69999581502417,9.967915186974132],[109.34999677137613,7.29876275445952],[107.99999772772827,5.957818681088533],[106.64999868408005,5.061986954416114],[105.74999932164808,4.613591578862773],[103.85068066714335,2.29570245694968],[104.40000027800004,2.255504211923801],[104.77306230253555,1.961481175550864],[104.68115007883117,1.468426767331968],[104.28790035741287,1.215621515287768],[104.15439045199251,1.197800481146747],[103.98701057056589,1.389451396800233]],[[100.93057273577533,13.174371211662239],[100.68750290737216,12.834868817846521],[100.46250306616822,12.175887185507976],[100.3500031464602,11.294709319565477],[101.02500266828413,9.52441134501949],[103.27500107496003,7.967776882259704],[105.29999964043219,6.628746603597807],[107.99999772772827,5.957818681088533]],[[-179.99979825051412,19.104405475930452],[-172.79980335105813,20.796306105108872],[-163.799809726738,20.796306105108872],[-158.849813233362,21.111485983488812],[-158.3998135521461,21.478351011075993],[-158.24204999203212,21.54876173571662],[-158.34356194506245,21.73983373091106],[-158.2873136318419,22.05298561667754],[-157.94981387092994,22.469443964829516],[-152.99981737755394,24.94136317175375],[-147.6030982105313,28.1630268819071],[-138.60310458621126,29.738014316088],[-127.79983522945767,32.43331330641721],[-122.39983905486586,34.867831005273345],[-120.8472016490899,35.367078251717096]]]}},{"type":"Feature","properties":{"id":"atlantic-crossing-1-ac-1","name":"Atlantic Crossing-1 (AC-1)","color":"#50429a","feature_id":"atlantic-crossing-1-ac-1-0","coordinates":[-31.533730162850908,50.793170984366824]},"geometry":{"type":"MultiLineString","coordinates":[[[8.383368297083154,54.89841940447099],[8.10006849777537,54.81936191424915],[7.200069135343221,54.55925876578231],[5.400070410479286,53.76891056666807],[4.500071048047319,53.23359531864929],[4.275071207439282,52.827662548128515],[4.38757112774321,52.62326141852725],[4.656810937011429,52.48640042521352],[4.275071207439282,52.48646132010029],[3.600071685615352,52.356869357572975],[3.150072004399278,52.14259270367212],[2.475072482575348,51.586833980054095],[1.46147320061859,50.9810239706491],[0.900073598319269,50.5262123614087],[0.000074235887302,50.167261162927154],[-2.249924170192707,50.022920456254944],[-3.599923213840749,49.87814473780419],[-4.499922576272716,49.805593628808026],[-5.399921938704683,49.87814473780419],[-5.698461727216356,50.07870033214287]],[[-5.698461727216356,50.07870033214287],[-6.299921301136742,50.167261162927154],[-8.099920026000767,50.167261162927154],[-10.799918113296759,49.87814473780419],[-16.199914287888834,49.000334389463426],[-23.399909187344846,47.80541217589291],[-39.59989771112098,43.72721479104982],[-50.39989006030513,41.0693404382162],[-61.19988240948919,40.04369219283004],[-68.39987730894529,40.215724060833985],[-71.09987539624129,40.04369219283004],[-72.91227411232106,40.77352073429003]],[[-72.91227411232106,40.77352073429003],[-71.09987539624129,40.64389687373837],[-68.39987730894529,41.40772623743595],[-61.19988240948919,42.743713464436695],[-50.39989006030513,45.172673246984274],[-39.59989771112098,48.10677570919628],[-23.399909187344846,53.50209788266426],[-16.199914287888834,55.58970711313177],[-8.999919388432733,59.222223914844314],[-5.399921938704683,59.79305890746809],[-1.799924488976633,59.56588346342965],[1.800072960751236,58.641677771385005],[5.400070410479286,56.469711376547025],[7.650068816559295,55.07780072164767],[8.10006849777537,54.94878902385559],[8.383368297083154,54.89841940447099]]]}},{"type":"Feature","properties":{"id":"atlas-offshore","name":"Atlas Offshore","color":"#5ab946","feature_id":"atlas-offshore-0","coordinates":[1.2484415384738352,37.703808759606545]},"geometry":{"type":"MultiLineString","coordinates":[[[-6.035761488270031,35.470615921385686],[-5.849921619920758,35.78566189952622],[-5.624921779312721,35.83127933955618],[-4.499922576272716,36.1498667868178],[-2.249924170192707,36.24065523321488],[2.25007264196731,38.122730108392204],[4.050071366831244,39.00237890905839],[5.062570648971421,40.04369219283004],[5.17507056987125,41.74435878948223],[5.372530429989069,43.29362778902908]]]}},{"type":"Feature","properties":{"id":"australia-japan-cable-ajc","name":"Australia-Japan Cable (AJC)","color":"#46bfb2","feature_id":"australia-japan-cable-ajc-0","coordinates":[157.2557199362583,0.48206827081266507]},"geometry":{"type":"MultiLineString","coordinates":[[[139.97546699999984,35.005433000000174],[140.16091244403847,34.89859296336222],[140.1749749340766,34.69072647741027],[140.17497493467252,34.405022750715936],[140.28747485497644,33.93964008831966],[142.19997350014447,32.8123187832876],[143.5499725437925,30.901396088515508],[147.59996967473646,23.298598065875897],[148.9499687183845,16.534196198259725],[148.49996903716843,13.710817738179635],[148.49996903716843,13.273238157547594],[151.1999671244645,9.967915186974132],[153.44996553054452,8.190543417795496],[156.1499636178406,4.164912849976942],[157.49996266148847,-0.331409329660265],[158.3999620239206,-3.029995968008661],[159.97496090817668,-4.825692499217419],[161.99995947364866,-6.616650693475355],[162.89995883608063,-8.401139048122838],[163.3499585172967,-10.17745743036107],[162.89995883608063,-13.698987269610743],[160.19996074878455,-18.880139975101173],[158.3999620239206,-25.540896076259312],[157.04996298027257,-28.743810281149894],[154.8695669085838,-31.85146566557725],[151.97926731750607,-33.691323153004866],[151.22848710426095,-33.882038650285516]],[[136.87399727311598,34.33682825203173],[137.69997668798445,33.28381101905092],[138.59997605041642,32.62301664000789],[140.84997445649643,31.670513047087127],[143.5499725437925,30.901396088515508]],[[148.49996903716843,13.710817738179635],[147.14996999352056,13.92930384327183],[146.24997063108842,13.92930384327183],[145.34997126865645,13.710817738179635],[144.8081264058416,13.543635828763595],[144.8081264058416,13.543635828763595],[144.8081264058416,13.543635828763595],[145.34997126865645,13.054150695298627],[146.24997063108842,12.615395567393394],[147.14996999352056,12.615395567393394],[148.49996903716843,13.273238157547594]],[[151.22848710426095,-33.882038650285516],[151.2450870925013,-33.80891358201858],[151.2450870925013,-33.737123050977864],[151.94493746911763,-33.62464858335051],[154.8695669085838,-31.85146566557725]],[[136.87399727311598,34.33682825203173],[137.69997668798445,34.12610104005753],[138.59997605041642,34.21917770495358],[139.4999754122525,34.57501887961886],[139.9218501133925,34.76007352296522],[140.06247501377248,34.89859296336222],[140.10823235209364,34.96779987634099],[140.06247501377248,35.01384769751837],[139.97546699999984,35.005433000000174]]]}},{"type":"Feature","properties":{"id":"apcn-2","name":"APCN-2","color":"#29b14a","feature_id":"apcn-2-0","coordinates":[124.31383246990738,23.34960115952398]},"geometry":{"type":"MultiLineString","coordinates":[[[114.7499929459683,14.801154224791581],[116.99999135204831,13.92930384327183],[118.79999007691224,13.710817738179635],[120.14998912056028,13.601498202276586],[121.06600847164388,13.762418337904428],[120.14998912056028,13.3827080361257],[118.79999007691224,13.273238157547594],[116.99999135204831,13.054150695298627],[115.64999230840026,12.944533868662969],[114.7499929459683,12.944533868662969],[114.29999326475222,13.054150695298627]],[[128.58279314668286,31.15762634730359],[128.4749832230562,31.670513047087127],[128.69998306366443,32.8123187832876],[129.0374828245764,34.31215165223547],[128.99949285148878,35.17037876180022],[129.26248266518442,34.31215165223547],[129.1499827448803,32.8123187832876],[128.92498290427227,31.670513047087127],[129.1499827448803,30.901396088515508]],[[103.89502063573258,1.309493642625741],[104.19697042182851,1.315741998126226],[104.28790035741287,1.36999455336686],[104.34425031749407,1.468426767331968],[104.62500011860807,2.367912558705407],[103.95000059678415,3.491423322320592],[103.38789161939158,4.128192842398844],[103.95000059678415,4.0527020972683],[107.99999772772827,7.075530930004602],[108.44999740894416,7.744889052551447],[109.79999645259221,9.967915186974132],[110.69999581502417,12.615395567393394],[113.17499406171221,18.251816319028222],[113.39999390232026,20.796306105108872],[113.68124370308026,21.635297384859552],[113.94911351331866,22.271493895850078],[114.24379330456497,21.84429407917369],[115.19999262718419,21.32123529551186],[116.54999167083223,21.111485983488812],[118.79999007691224,21.425997872385402],[121.02775554672928,21.24799675992802],[122.84998720785636,21.425997872385402],[123.74998657028833,22.05298561667754],[124.6499859327203,24.12261698700344],[124.81873581317637,25.55188275942587],[124.98748569363227,26.1593079707739],[124.6499859327203,30.5144959597591],[122.84998720785636,31.670513047087127],[121.94998784542422,31.622627415989587],[121.39529823837174,31.619800328867754],[121.94998784542422,31.81402180002269],[122.84998720785636,32.052708023486204],[124.19998625150441,32.052708023486204],[128.58279314668286,31.15762634730359],[129.1499827448803,30.901396088515508],[130.49998178852834,29.73606949729215],[132.74998019460836,29.540507745394493],[134.99997860068837,29.540507745394493],[137.69997668798445,30.320465424761444],[139.04997573163232,31.09426282763951],[140.39997477528055,32.43331330641721],[142.4249733401566,35.05222991093673],[142.42497334075233,35.78566189952622],[141.97497365894054,36.51238821239364],[140.75095452664317,36.801861372486805]],[[140.75095452664317,36.79060054148884],[141.1874742174084,35.77425344590863],[141.07497429710446,35.408319788563304],[140.96247437620465,35.163436337416215],[140.3437248151284,34.97160644824122],[140.06247501377248,34.96776525378359],[139.95485509060742,34.965046603583694],[140.04841252373456,34.84090484813936],[140.23122489422857,34.679162981906806],[140.28747485497644,34.393419492403375],[139.83747517376037,33.927972678693564],[139.72497525345642,32.421443555350706],[139.04997573163232,30.889328974889438],[137.69997668798445,29.91906305661853],[134.99997860068837,29.135966407362506],[128.69998306366443,25.944535413791687],[127.34998402001638,25.33600821718872],[125.99998497636834,25.132479722461383],[125.09998561393637,25.132479722461383],[123.74998657028833,25.43764443864878],[122.84998720785636,25.640659590796915],[121.94998784542422,25.539194978687103],[121.46258819070279,25.168986122701572],[121.94998784542422,25.2342865621001],[122.28748760633636,24.928611492263457],[122.62498736724832,24.007054825363046],[122.17498768603225,22.45644844059945],[121.02775554672928,21.758259099599318],[120.12788799225589,21.758259099599318],[118.79999007691224,22.19628282803044],[118.34999039569617,22.40445416506672],[117.2812411528083,22.87169786996185],[116.67753158048176,23.342095886292725],[116.77499151144026,22.45644844059945],[116.54999167083223,20.783159233732995],[115.64999230840026,18.238460810952724],[114.7499929459683,14.787557926772921],[114.29999326475222,13.040451242220165],[113.39999390232026,9.954064678408844],[111.59999517745614,7.730954611330002],[109.12499693076828,5.943831970446426],[108.44999740894416,5.496074035021858],[107.99999772772827,5.160032981867319],[107.09999836529612,4.599574515521482],[105.74999932164808,3.814203076255083],[104.96249987952004,2.803404866588448],[104.48462521805108,1.454368851373345],[104.28790035741287,1.285767245880394],[104.15918044859939,1.197018152576039],[103.89502063573258,1.295434785911349]]]}},{"type":"Feature","properties":{"id":"australia-singapore-cable-asc","name":"Australia-Singapore Cable (ASC)","color":"#c82026","feature_id":"australia-singapore-cable-asc-0","coordinates":[107.80274089446834,-15.057788569383632]},"geometry":{"type":"MultiLineString","coordinates":[[[103.94648059927786,1.327258925921003],[103.78125071632807,1.018534216615524],[103.78125071632807,0.793562652607196],[104.17500043739219,0.51333135630492],[104.8499999592161,0.400835033925938],[105.29999964043219,0.118588418888312],[105.97499916225611,-0.781386636225587],[106.76449860296741,-2.130918480960333],[107.01562342447218,-3.029995968008661],[106.9874984449922,-4.60145376483711],[106.53749876377611,-5.161910662113067],[106.31249892316808,-5.497950688314882],[105.74999932164808,-5.945707155070644],[105.29999964043219,-6.169450529574503],[105.07499979982416,-6.616650693475355],[105.29999964043219,-7.509810688339549],[105.97499916225611,-10.398839577127402],[106.19999900286416,-11.943944931746815],[107.99999772713237,-15.441023659568087],[109.79999645199631,-20.433922197637408],[112.0499948586722,-27.15383128539156],[113.39999390232026,-30.30995334464681],[113.84999358353615,-30.890946871471986],[115.85731216153303,-31.953441330324313]],[[105.74999932164808,-5.945707155070644],[105.88390920948316,-6.073698909871739]],[[105.97499916225611,-10.398839577127402],[105.8624992419522,-10.398839577127402],[105.697069844276,-10.437358645528692]]]}},{"type":"Feature","properties":{"id":"azores-fiber-optic-system-afos","name":"Azores Fiber Optic System (AFOS)","color":"#514da0","feature_id":"azores-fiber-optic-system-afos-0","coordinates":[-25.715648220560862,37.094110840016604]},"geometry":{"type":"MultiLineString","coordinates":[[[-27.96338595453508,39.01140845880094],[-28.1249058401129,38.827311095266374],[-28.349905680720937,38.827311095266374],[-28.349905680720937,38.739615313825674],[-28.213135777609992,38.68429209182289],[-28.462405601024955,38.651811712711336],[-28.647515469891374,38.52540431175965],[-28.574905521328883,38.475881348138756],[-28.46123560185379,38.43266966366395],[-28.349905680720937,38.29952060596925],[-25.29692784347907,36.90250859939618],[-25.141217953785343,36.95715362213741],[-25.0722680026302,36.87595534218079],[-24.918608111484343,36.95684197844165],[-24.974908071600833,37.23235432155614],[-25.537407673120835,37.589786573603064],[-25.668707580106854,37.739604626314694],[-25.87490743403289,37.67887792909206],[-26.77490679646495,38.03417390064187],[-27.112406557972907,38.475881348138756],[-27.21577648414866,38.65890243710884],[-27.44990631828888,38.651811712711336],[-27.674906158896917,38.651811712711336],[-27.96338595453508,39.01140845880094]]]}},{"type":"Feature","properties":{"id":"bahamas-2","name":"Bahamas 2","color":"#85459a","feature_id":"bahamas-2-0","coordinates":[-78.90737857187821,26.356018247442297]},"geometry":{"type":"MultiLineString","coordinates":[[[-80.3942513282677,27.638731771078668],[-79.64986933934534,27.364667993860262],[-78.91536058293026,26.360123107509146],[-78.52487013630534,26.1593079707739],[-77.7373706941773,25.348717422116714],[-77.34020097553613,25.067217613601834]],[[-78.91536058293026,26.360123107509146],[-78.81193048042013,26.536259889701554]]]}},{"type":"Feature","properties":{"id":"bahamas-domestic-submarine-network-bdsni","name":"Bahamas Domestic Submarine Network (BDSNi)","color":"#2aae5d","feature_id":"bahamas-domestic-submarine-network-bdsni-0","coordinates":[-73.74030475770795,19.303948558193134]},"geometry":{"type":"MultiLineString","coordinates":[[[-73.68298356634324,20.950514348762308],[-73.84592345091504,20.14477297921714],[-73.9123734038413,19.95262290516439],[-73.68737356323335,19.104405475930452],[-72.8343441675273,18.611932473123947],[-72.51027439710138,18.611932473123947],[-72.34313451550487,18.542878440695226]],[[-73.68298356634324,20.950514348762308],[-73.57487364292933,21.425997872385402],[-73.3498738023213,22.05298561667754],[-73.06413400474204,22.401747618851097]],[[-73.06413400474204,22.401747618851097],[-73.3498738023213,22.780969584994615],[-73.79987348353728,22.780969584994615]],[[-74.52570296935295,24.05263241523427],[-74.92487268657729,24.32780311165181],[-75.52590226080234,24.403328403350237]],[[-74.52570296935295,24.05263241523427],[-74.81237276627336,23.298598065875897],[-74.96618265731288,23.0983440404772]],[[-74.96618265731288,23.0983440404772],[-74.86662272784216,23.298598065875897],[-74.84049274635285,23.652235483825752]],[[-73.68298356634324,20.950514348762308],[-73.79987348353728,21.216397899942],[-75.14987252718532,21.84429407917369],[-75.73337211382878,22.183257204024997]],[[-75.73337211382878,22.183257204024997],[-75.59987220840131,22.469443964829516],[-75.59987220840131,23.298598065875897],[-75.14987252718532,23.7112581424843],[-74.84049274635285,23.652235483825752]],[[-75.52590226080234,24.403328403350237],[-75.82487204900934,24.53265756616073],[-75.82487204900934,24.94136317175375],[-76.24064175447413,25.196209451872253]],[[-75.78014208069642,23.516551982193732],[-75.71237212870533,23.7112581424843],[-76.04987189021328,24.259444485784776],[-76.57445151859616,24.424991122673976],[-76.04987189021328,24.53265756616073],[-75.93737196931336,24.94136317175375],[-76.24064175447413,25.196209451872253]],[[-76.24064175447413,25.196209451872253],[-76.94987125204935,25.043329056612176],[-77.34020097553613,25.067217613601834]],[[-76.24064175447413,25.196209451872253],[-76.24064175447413,25.29442793346618],[-76.83117133613746,25.712041257302072],[-77.28737101296132,25.75470426341523],[-77.39953093350617,26.02529561689914]],[[-77.34020097553613,25.067217613601834],[-77.39987093326533,24.94136317175375],[-77.84460061821467,24.6897138806255]],[[-77.34020097553613,25.067217613601834],[-77.39987093326533,25.348717422116714],[-78.18737037539337,26.1593079707739],[-78.73673998621477,26.516994685777217]],[[-77.39953093350617,26.02529561689914],[-77.96237053478534,26.1593079707739],[-78.73673998621477,26.516994685777217]],[[-73.79987348353728,22.780969584994615],[-74.02487332652929,22.884654113882444],[-74.36237308505729,23.298598065875897],[-74.36237308505729,23.7112581424843],[-74.52570296935295,24.05263241523427]],[[-75.78014208069642,23.516551982193732],[-75.59987220840131,23.7112581424843],[-75.14987252718532,23.81422051502533],[-74.84049274635285,23.652235483825752]]]}},{"type":"Feature","properties":{"id":"bahamas-internet-cable-system-bics","name":"Bahamas Internet Cable System (BICS)","color":"#209fb8","feature_id":"bahamas-internet-cable-system-bics-0","coordinates":[-77.21820661216559,26.620578361420524]},"geometry":{"type":"MultiLineString","coordinates":[[[-78.73673998621477,26.516994685777217],[-78.29987029569729,26.1593079707739],[-77.51237085356935,25.348717422116714],[-77.4529908956347,25.068449509985566],[-77.39987093326533,25.348717422116714],[-77.1748710926573,25.450342946923914],[-76.7876613669604,25.40783324180655]],[[-76.7876613669604,25.40783324180655],[-77.1748710926573,25.55188275942587],[-77.39987093326533,25.75470426341523],[-77.39953093350617,26.02529561689914],[-77.39987093326533,26.1593079707739],[-77.1748710926573,26.260240971577822],[-77.1748710926573,26.562513149236715],[-77.39987093326533,26.86399017396059],[-77.82194063426722,26.912500052614206],[-77.96237053538132,26.847150890638396],[-78.07487045568534,26.797064317338755],[-78.15172040124409,26.703096291958364],[-78.29987029629338,26.6715791371628],[-78.52487013690133,26.596273524044868],[-78.73673998621477,26.516994685777217]],[[-78.73673998621477,26.516994685777217],[-79.19986965812936,26.36108632539156],[-79.64986933934534,26.36108632539156],[-80.08893155227202,26.350584577319996]]]}},{"type":"Feature","properties":{"id":"balalink","name":"Balalink","color":"#3a4ea1","feature_id":"balalink-0","coordinates":[1.1368861410635613,39.35121757117122]},"geometry":{"type":"MultiLineString","coordinates":[[[-0.376975497007027,39.459646713436726],[0.450073917103194,39.35121757117122],[2.25007264196731,39.35121757117122],[2.605252390354729,39.55232618527277]]]}},{"type":"Feature","properties":{"id":"baltica","name":"Baltica","color":"#5f459b","feature_id":"baltica-0","coordinates":[13.460776938063091,54.872791322530695]},"geometry":{"type":"MultiLineString","coordinates":[[[13.828284439853466,55.431331650943825],[13.950064353583429,55.33458061322904],[14.400064034799321,55.0133467567921],[14.850063716015397,55.0133467567921],[14.992153615357688,55.031146415148456],[15.075063556623434,54.884127437867534],[15.412563317535398,54.55925876578231],[15.57506320241889,54.172633544902844]],[[11.94000577752764,54.577236267283396],[12.600065309935387,54.62444087776703],[13.500064672367355,54.884127437867534],[14.400064034799321,54.94878902385559],[14.850063716015397,54.94878902385559],[14.992153615357688,55.031146415148456]]]}},{"type":"Feature","properties":{"id":"bass-strait-1","name":"Bass Strait-1","color":"#66439a","feature_id":"bass-strait-1-0","coordinates":[145.906182578485,-39.89701571127955]},"geometry":{"type":"MultiLineString","coordinates":[[[146.11970072337314,-38.81988087960539],[146.02497079048055,-39.16757423638764],[145.79997094987252,-40.549228298069146],[145.62380107467308,-40.95050138814584]]]}},{"type":"Feature","properties":{"id":"bass-strait-2","name":"Bass Strait-2","color":"#5da344","feature_id":"bass-strait-2-0","coordinates":[145.48965518221675,-39.69147281699588]},"geometry":{"type":"MultiLineString","coordinates":[[[145.72966099968082,-38.63330288478132],[145.57497110926448,-39.16757423638764],[145.34997126865645,-40.549228298069146],[145.29411130822828,-40.76023099257806]]]}},{"type":"Feature","properties":{"id":"basslink","name":"Basslink","color":"#9dc93b","feature_id":"basslink-0","coordinates":[146.9775193196672,-39.740482351060734]},"geometry":{"type":"MultiLineString","coordinates":[[[147.07126004927943,-38.44340281744178],[147.03747007321647,-38.81782397325074],[146.92497015291252,-40.549228298069146],[146.85017020590166,-41.033054194589326]]]}},{"type":"Feature","properties":{"id":"batam-singapore-cable-system-bscs","name":"Batam Singapore Cable System (BSCS)","color":"#ac3781","feature_id":"batam-singapore-cable-system-bscs-0","coordinates":[103.95635868349486,1.2203224601666376]},"geometry":{"type":"MultiLineString","coordinates":[[[103.98701057056589,1.389451396800233],[103.9500255967665,1.185378176915766],[104.0166370000003,1.066798000000349]]]}},{"type":"Feature","properties":{"id":"sweden-latvia","name":"Sweden-Latvia","color":"#42b985","feature_id":"sweden-latvia-0","coordinates":[19.342613473857593,57.755093445120046]},"geometry":{"type":"MultiLineString","coordinates":[[[21.570078955493642,57.389720408424935],[21.15005925303935,57.39045872350101],[19.350060528175415,57.75242200704209],[19.041860746507265,57.86298070545193],[18.900060846959338,58.05131589106027],[18.562561086047374,58.641677771385005],[18.562561086047374,59.106894957190725],[18.337561245439336,59.27974267096037],[18.062761440110208,59.33230901818709]]]}},{"type":"Feature","properties":{"id":"bcs-east-west-interlink","name":"BCS East-West Interlink","color":"#348bcb","feature_id":"bcs-east-west-interlink-0","coordinates":[19.948215743265383,56.717219875603234]},"geometry":{"type":"MultiLineString","coordinates":[[[21.08267930077222,56.028506850473754],[20.700059571823456,56.15772578558828],[19.125060687567377,57.32978111682831],[18.829450896980287,57.43973589319185]]]}},{"type":"Feature","properties":{"id":"bcs-north---phase-1","name":"BCS North - Phase 1","color":"#38b77b","feature_id":"bcs-north---phase-1-0","coordinates":[21.80795969667985,59.99407662071264]},"geometry":{"type":"MultiLineString","coordinates":[[[24.932476573539617,60.171163188940554],[24.412556941855435,59.906069924574304],[23.850057340335432,59.79305890746809],[22.966757966073065,59.82340864139905],[22.6125582169915,59.906069924574304],[22.275058456079353,60.01869762196877],[22.298598439403605,60.30575443116903],[22.050058615471496,60.07486799642317],[21.375059093647387,59.84961238502145],[20.250059890607382,59.7364093840784],[19.912560129695414,59.9624316341522],[19.937760111843488,60.11403575461426],[19.68756028908738,59.9624316341522],[19.125060687567377,59.62282176941042],[18.688360996929806,59.292766765691574]]]}},{"type":"Feature","properties":{"id":"bcs-north---phase-2","name":"BCS North - Phase 2","color":"#b1d235","feature_id":"bcs-north---phase-2-0","coordinates":[25.96166923248304,60.13094286609847]},"geometry":{"type":"MultiLineString","coordinates":[[[26.88364519131359,60.50114125320089],[26.77505526823937,60.35428947498098],[26.10005574641544,60.13094286609847],[25.200056383983473,60.13094286609847],[24.932476573539617,60.171163188940554]],[[26.88364519131359,60.50114125320089],[27.00005510884741,60.35428947498098],[27.16880498930348,60.13094286609847],[27.450054790063483,59.9624316341522],[28.12505431188741,59.84961238502145],[28.49995404630509,59.79998620949299]]]}},{"type":"Feature","properties":{"id":"berytar","name":"BERYTAR","color":"#c02028","feature_id":"berytar-0","coordinates":[35.54357374187809,34.270320907751945]},"geometry":{"type":"MultiLineString","coordinates":[[[35.89779880560243,34.89170328553848],[35.77504889255959,34.683017659857974],[35.859228832925915,34.43974246013246],[35.55004905195155,34.31215165223547],[35.48510909795581,33.89263712836985],[35.32504921134351,33.565491482352044],[35.38637916789689,33.45019231608542]]]}},{"type":"Feature","properties":{"id":"bharat-lanka-cable-system","name":"Bharat Lanka Cable System","color":"#26ae4a","feature_id":"bharat-lanka-cable-system-0","coordinates":[78.76007118215172,7.506418248635959]},"geometry":{"type":"MultiLineString","coordinates":[[[78.14522887712651,8.8024990629144],[78.3000187674719,8.190543417795496],[79.20001812990387,6.852191098754328],[79.86681765753703,6.833088156653168]]]}},{"type":"Feature","properties":{"id":"bicentenario","name":"Bicentenario","color":"#52b847","feature_id":"bicentenario-0","coordinates":[-55.765452161876794,-35.74763678504997]},"geometry":{"type":"MultiLineString","coordinates":[[[-54.950186836832145,-34.90041602705144],[-55.57488639428917,-35.59302880961419],[-56.02488607550515,-35.95811819864919],[-56.695445600474535,-36.47095527632105]]]}},{"type":"Feature","properties":{"id":"botnia","name":"Botnia","color":"#dba926","feature_id":"botnia-0","coordinates":[20.810619391542982,63.2306735554952]},"geometry":{"type":"MultiLineString","coordinates":[[[21.616368922701422,63.095388681301685],[21.375059093647387,63.09669186983899],[21.15005925303935,63.09669186983899],[20.700059571823456,63.27431307074413],[20.475059731215417,63.526173423791214],[20.39430309845523,63.70251569426438],[20.26304988140538,63.82595266205344]]]}},{"type":"Feature","properties":{"id":"bt-mt-1","name":"BT-MT-1","color":"#d12c7d","feature_id":"bt-mt-1-0","coordinates":[-3.888293248358948,54.16597178715178]},"geometry":{"type":"MultiLineString","coordinates":[[[-3.357053385891947,54.21091491340431],[-3.599923213840749,54.16597178715178],[-4.274922735664679,54.16597178715178],[-4.423852630161441,54.167289003599166]]]}},{"type":"Feature","properties":{"id":"cadmos","name":"CADMOS","color":"#5d4a9e","feature_id":"cadmos-0","coordinates":[34.571766536489555,34.40408323892183]},"geometry":{"type":"MultiLineString","coordinates":[[[33.61060042587536,34.82728147271538],[33.97505016769547,34.683017659857974],[35.10004937013957,34.157137999942634],[35.48510909795581,33.89263712836985]],[[35.48510909795581,33.89263712836985],[35.521924071279756,33.90074250722234],[35.56303904215347,33.891898350976184]]]}},{"type":"Feature","properties":{"id":"cam-ring","name":"CAM Ring","color":"#a85137","feature_id":"cam-ring-0","coordinates":[-21.401088824823148,34.375763066019836]},"geometry":{"type":"MultiLineString","coordinates":[[[-16.347664183221365,33.047315908721245],[-16.42491412849678,32.8123187832876],[-16.64991396910482,32.62301664000789],[-16.90889378564106,32.64727814970992],[-17.125113632468874,32.55889959458764],[-18.449912693968844,32.8123187832876],[-24.749908230992887,36.1498667868178],[-25.424907754008892,36.78317048961605],[-25.7061575541729,37.589786573603064],[-25.668707580106854,37.739604626314694]]]}},{"type":"Feature","properties":{"id":"canalink","name":"Canalink","color":"#5a4a9e","feature_id":"canalink-0","coordinates":[-11.928272506517882,32.88482972146169]},"geometry":{"type":"MultiLineString","coordinates":[[[-6.363011256443268,36.626272849358266],[-7.199920663568708,36.51238821239364],[-8.999919388432733,35.419780517080355],[-11.024917953904795,33.93964008831966],[-14.624915403632755,29.73606949729215],[-15.074915084848831,28.95155473219332],[-15.412414845760795,28.55704546571133],[-15.524914766064814,28.359233526108557],[-16.03116440743276,28.292958703176758],[-16.518014062543934,28.059088061264806]],[[-15.412414845760795,28.55704546571133],[-15.299914925456777,28.458185766004554],[-15.243664965304813,28.161052262220792],[-15.299914925456777,28.06182365971013],[-15.39874485544474,27.959394261046018]],[[-16.518014062543934,28.059088061264806],[-16.42491412849678,27.962503359972466],[-16.537414048800798,27.86309157699361],[-16.762413889408837,27.86309157699361],[-16.981213734409,28.159242763304327],[-17.0999136503208,28.359233526108557],[-17.36480346267046,28.66327103532559],[-17.58982330326418,28.712616152639622],[-17.765193179030568,28.66327103532559]],[[-15.412414845760795,28.55704546571133],[-15.74991460667276,28.50762719638052],[-16.03116440743276,28.34246846660667],[-16.412564137245653,28.310585553629316]],[[-15.524914766064814,28.359233526108557],[-15.487704792424687,28.1343621820334]],[[-7.199920663568708,36.51238821239364],[-6.299921301136742,35.602930322906126],[-6.035761488270031,35.470615921385686]],[[-16.412574137834532,28.310603161410516],[-16.03116440743276,28.391963961389006],[-15.74991460667276,28.55704546571133],[-14.962415164544812,28.95155473219332],[-13.724916040604796,29.73606949729215],[-10.124918591472738,33.93964008831966],[-8.324919866608713,35.419780517080355],[-6.749920982352725,36.1498667868178],[-6.087131451879048,36.27672346192373]]]}},{"type":"Feature","properties":{"id":"cantat-3","name":"CANTAT-3","color":"#a6469a","feature_id":"cantat-3-0","coordinates":[6.084804786293195,55.88334262356366]},"geometry":{"type":"MultiLineString","coordinates":[[[7.200069135343221,55.58970711313177],[6.300069772911254,55.84318584148108],[4.950070729263212,56.09502251152735]],[[4.228491240437136,56.07851317418121],[4.500071048047319,55.969309073153674],[4.5643310025251,55.80429094977468],[4.612570968351247,55.74726873782232],[4.748510872050192,55.715908549839966]],[[4.500071048047319,55.969309073153674],[4.950070729263212,56.09502251152735]],[[7.199969135414349,55.58982013593335],[7.650068816559295,55.58970711313177],[8.329168335478972,55.75165023178103]]]}},{"type":"Feature","properties":{"id":"caribbean-bermuda-u-s-cbus","name":"Caribbean-Bermuda U.S. (CBUS)","color":"#b2355e","feature_id":"caribbean-bermuda-u-s-cbus-0","coordinates":[-64.34988017800117,25.345999229975213]},"geometry":{"type":"MultiLineString","coordinates":[[[-64.7674091796907,32.31223462116531],[-64.5748800186092,31.286738814391754],[-64.34988017800117,28.161052262220792],[-64.34988017800117,24.94136317175375],[-64.34988017800117,20.796306105108872],[-64.34988017800117,18.891661584303154],[-64.59715000283296,18.41441211576626]]]}},{"type":"Feature","properties":{"id":"caucasus-cable-system","name":"Caucasus Cable System","color":"#d02c91","feature_id":"caucasus-cable-system-0","coordinates":[34.888472643936545,42.49061443096962]},"geometry":{"type":"MultiLineString","coordinates":[[[41.66752471827441,42.14675635811542],[41.40004490775961,42.07923561816413],[35.55004905195155,42.41235450073586],[28.575053993103488,43.237448352440914],[28.167434281865123,43.41462116994649]]]}},{"type":"Feature","properties":{"id":"cayman-jamaica-fiber-system-cjfs","name":"Cayman-Jamaica Fiber System (CJFS)","color":"#85489c","feature_id":"cayman-jamaica-fiber-system-cjfs-0","coordinates":[-79.06738928977619,19.27827498069823]},"geometry":{"type":"MultiLineString","coordinates":[[[-77.06237117294936,18.607582468591097],[-77.73737069477338,18.607582468591097],[-77.92138056441902,18.469357593227326],[-78.18737037598936,18.660883672360413],[-79.4248694987373,19.529070924350908],[-79.87764917798393,19.690425985630736],[-80.09986902056141,19.529070924350908],[-80.54986870177748,19.316876111628712],[-81.16676076149955,19.28295591266266]],[[-77.10324114340065,18.398661383088054],[-77.06237117294936,18.607582468591097]],[[-76.49987157202534,18.572039052566783],[-77.06237117294936,18.607582468591097]],[[-76.44608160953463,18.17632819876962],[-76.49987157202534,18.572039052566783]],[[-76.66662145270618,17.949772942659727],[-76.38737165112533,17.966677204124696],[-76.1623718105173,18.180575095602762],[-76.20409564349075,18.462886138237728],[-76.49987157202534,18.572039052566783]]]}},{"type":"Feature","properties":{"id":"ceiba-2","name":"Ceiba-2","color":"#4fbe9c","feature_id":"ceiba-2-0","coordinates":[9.276676998154315,2.945927882878702]},"geometry":{"type":"MultiLineString","coordinates":[[[9.768227316036105,1.860150409321811],[9.250947682481774,3.002760948286253],[9.052797822853012,3.809525949617104],[8.832297979057298,3.809525949617104],[8.782298014477735,3.750863902152215]],[[9.250947682481774,3.002760948286253],[9.91022721544214,2.933124533518602]]]}},{"type":"Feature","properties":{"id":"ceiba-1","name":"Ceiba-1","color":"#3366b1","feature_id":"ceiba-1-0","coordinates":[9.38913799528723,3.0532848096506484]},"geometry":{"type":"MultiLineString","coordinates":[[[9.768227316036105,1.860150409321811],[9.42506755913363,2.96715934263804],[9.052797822853012,3.859513756649545],[8.782298014477735,3.859513756649545],[8.782298014477735,3.750863902152215]]]}},{"type":"Feature","properties":{"id":"celtixconnect-1-cc-1","name":"CeltixConnect-1 (CC-1)","color":"#aa4f9e","feature_id":"celtixconnect-1-cc-1-0","coordinates":[-5.440452195766118,53.30189162385005]},"geometry":{"type":"MultiLineString","coordinates":[[[-6.248311337697704,53.34812463259513],[-5.624921779312721,53.300879648739986],[-4.630392483846553,53.30633550153628]]]}},{"type":"Feature","properties":{"id":"columbus-ii-b","name":"Columbus-II b","color":"#69c6b6","feature_id":"columbus-ii-b-0","coordinates":[-70.42061125399957,26.512102658953292]},"geometry":{"type":"MultiLineString","coordinates":[[[-64.93708976201641,18.372992194090898],[-64.91237977952126,18.891661584303154],[-65.24987954043323,20.796306105108872],[-67.27487810590529,23.7112581424843],[-69.29987667137726,25.957179978764344],[-73.3498738023213,27.962503359972466],[-76.94987125204935,28.06182365971013],[-77.84987061448132,27.86309157699361],[-79.64986933934534,26.964304734562898],[-80.05334157838347,26.715271953494682]]]}},{"type":"Feature","properties":{"id":"columbus-iii-azores-portugal","name":"Columbus-III Azores-Portugal","color":"#c35437","feature_id":"columbus-iii-azores-portugal-0","coordinates":[-17.422228036962636,36.993119919861876]},"geometry":{"type":"MultiLineString","coordinates":[[[-9.33155915349599,38.690161972355526],[-10.349918432080775,38.21117903702318],[-13.949915882404726,36.993119919861876],[-23.399909187940835,36.993119919861876],[-24.749908230992887,37.23235432155614],[-25.424907752816907,37.589786573603064],[-25.668707580106854,37.739604626314694]]]}},{"type":"Feature","properties":{"id":"comoros-domestic-cable-system","name":"Comoros Domestic Cable System","color":"#5cc4c1","feature_id":"comoros-domestic-cable-system-0","coordinates":[44.38348080171161,-12.159732244027781]},"geometry":{"type":"MultiLineString","coordinates":[[[43.489203427780126,-11.92311506302003],[44.02904304535267,-11.808811027678104],[44.40004278253289,-12.166271132902045],[44.02904304535267,-12.01979553436257],[43.73969325033078,-12.287868671415305],[43.547493386487076,-12.045379517998398],[43.489203427780126,-11.92311506302003]]]}},{"type":"Feature","properties":{"id":"corse-continent-4-cc4","name":"Corse-Continent 4 (CC4)","color":"#34bfc8","feature_id":"corse-continent-4-cc4-0","coordinates":[7.949098709147479,43.0363775719495]},"geometry":{"type":"MultiLineString","coordinates":[[[7.017449264712893,43.553008713117315],[7.200069135343221,43.401144973153954],[8.550068178991262,42.743713464436695],[8.93717790475919,42.63668577913145]]]}},{"type":"Feature","properties":{"id":"corse-continent-5-cc5","name":"Corse-Continent 5 (CC5)","color":"#32c0cf","feature_id":"corse-continent-5-cc5-0","coordinates":[7.18548843666949,42.251181098201755]},"geometry":{"type":"MultiLineString","coordinates":[[[5.878200071767832,43.103194733127566],[6.075069932303216,42.743713464436695],[6.300069772911254,42.578254086072846],[7.425068975355357,42.16268022756146],[8.414618274945335,41.835738425062964],[8.738738045335882,41.91950273465837]]]}},{"type":"Feature","properties":{"id":"danica-north","name":"Danica North","color":"#3c61ad","feature_id":"danica-north-0","coordinates":[12.745254304550503,55.77884269856118]},"geometry":{"type":"MultiLineString","coordinates":[[[12.955535058117352,55.77057758158515],[12.716565227405825,55.77997032709834],[12.545575348536659,55.720775126306044]]]}},{"type":"Feature","properties":{"id":"danice","name":"DANICE","color":"#5bba46","feature_id":"danice-0","coordinates":[-5.161393410267752,62.67977190838902]},"geometry":{"type":"MultiLineString","coordinates":[[[-20.141851495383488,63.642367197144765],[-20.699911101240836,63.32486213107881],[-20.699911100644844,62.986189024595745],[-19.799911737616885,62.50536500000049],[-14.399915563024809,62.29689073879045],[-10.799918113296759,62.712391894030624],[-7.424920504176746,62.81536487879325],[-2.249924170192707,62.50536500000049],[0.450073917103194,62.08696177092747],[2.025072801359273,61.23255301306618],[2.92507216379124,60.35428947498098],[3.825071526223208,58.05131589106027],[5.17507056987125,56.22032688484507],[6.300069772911254,55.969309073153674],[7.200069135343221,55.716652093821786],[7.650068816559295,55.65323105219792],[8.329168335478972,55.75165023178103]]]}},{"type":"Feature","properties":{"id":"denmark-sweden-17","name":"Denmark-Sweden 17","color":"#34a89e","feature_id":"denmark-sweden-17-0","coordinates":[12.562760303895889,56.105017254001815]},"geometry":{"type":"MultiLineString","coordinates":[[[12.536885354692695,56.07508097728979],[12.562760336362697,56.105017291564636],[12.588635318032516,56.13493034275471]]]}},{"type":"Feature","properties":{"id":"denmark-sweden-18","name":"Denmark-Sweden 18","color":"#373e98","feature_id":"denmark-sweden-18-0","coordinates":[12.643960278839915,56.03727869909543]},"geometry":{"type":"MultiLineString","coordinates":[[[12.592155315538987,56.03063574342069],[12.643960278839915,56.03727869909543],[12.695765242140661,56.043920511503316]]]}},{"type":"Feature","properties":{"id":"dhiraagu-cable-network","name":"Dhiraagu Cable Network","color":"#b91f32","feature_id":"dhiraagu-cable-network-0","coordinates":[73.0551725675944,3.0898789230643287]},"geometry":{"type":"MultiLineString","coordinates":[[[73.0715024714021,6.622826415013278],[72.97150254224297,6.125830038600339],[72.97082254272446,5.601440402765338],[73.07082247188359,5.10362255380632],[73.07082247188359,4.854566321205027],[73.29035231636679,4.461629537081651],[73.54035213926461,4.212345781871782],[73.12502243348777,3.82823430332105],[72.9556825534498,3.608488161798881],[72.97732253811986,3.276079687360005],[73.54475213614752,1.918908398538504],[73.57502211470384,1.018534216615524],[73.4555921993092,0.289287153514825],[73.46652219156643,0.060338533670424],[73.42688221964781,-0.297909859053726],[73.3500222740958,-0.443906656918545],[73.08918245887737,-0.605519711481727]]]}},{"type":"Feature","properties":{"id":"dhiraagu-slt-submarine-cable-network","name":"Dhiraagu-SLT Submarine Cable Network","color":"#a44399","feature_id":"dhiraagu-slt-submarine-cable-network-0","coordinates":[76.84260880413497,5.221141038488133]},"geometry":{"type":"MultiLineString","coordinates":[[[79.87208765380376,6.927036656836354],[79.20001812990387,6.18155703253704],[74.25002163652778,4.164912849976942],[73.5000221678345,4.16666819886197]]]}},{"type":"Feature","properties":{"id":"east-west-cable-ewc","name":"East-West Cable (EWC)","color":"#30ace2","feature_id":"east-west-cable-ewc-0","coordinates":[-70.67175258722071,17.388055736679384]},"geometry":{"type":"MultiLineString","coordinates":[[[-70.16178606079258,18.6993638412382],[-69.91862623304922,18.144943564296213],[-69.86237627289727,17.82393441253792]],[[-76.71676141718653,17.949953694577594],[-76.27487173022533,17.395022634700517],[-75.37487236779336,17.180187287481317],[-74.69987284596934,16.965102599435927],[-71.99987475867334,16.965102599435927],[-70.6498757150253,17.395022634700517],[-69.86237627289727,17.82393441253792],[-69.29987667137726,17.716802179008642],[-67.49987794651324,17.395022634700517],[-66.14987890286528,17.609605913224996],[-64.59715000283296,18.41441211576626]]]}},{"type":"Feature","properties":{"id":"eastern-caribbean-fiber-system-ecfs","name":"Eastern Caribbean Fiber System (ECFS)","color":"#b4d333","feature_id":"eastern-caribbean-fiber-system-ecfs-0","coordinates":[-61.19988240948919,14.328134935181945]},"geometry":{"type":"MultiLineString","coordinates":[[[-61.53738217040116,15.23578178303578],[-61.370682288492866,15.252077499919823]],[[-61.65081209004632,10.686261032786325],[-61.53738217040116,11.073982781226615],[-61.53738217040116,11.294709319565477],[-61.53738217040116,11.625479959569855],[-61.64988209070518,11.84577637362577],[-61.790581991032056,12.008779421336667],[-61.87307193259536,12.191526789551752],[-61.81853197123201,12.395734000022975],[-61.48103221032005,13.054150695298627],[-61.20828240353852,13.145460938850311],[-61.19988240948919,13.054150695298627],[-59.84988336584115,13.054150695298627],[-59.60740353761622,13.084493800663104],[-59.84988336584115,13.163718917913586],[-60.74988272827321,14.038469666260218],[-60.974882568881156,14.147583506948735],[-61.00681254626166,13.994508755694142],[-61.19988240948919,14.147583506948735],[-61.19988240948919,14.365653759228442],[-61.09417248437512,14.615455776713933],[-61.368632289945175,14.801154224791581],[-61.53738217040116,15.018578573757472],[-61.53738217040116,15.23578178303578],[-61.64988209070518,15.452760959322058],[-61.607792120522106,15.676204818171286],[-61.439157239984645,15.892797111330715],[-61.413872257896806,15.988768942769152],[-61.53303217348278,16.241328600285367],[-61.762382011009194,16.590029438056266],[-61.85794194331368,17.051481714044815],[-62.09988177192116,16.965102599435927],[-62.54988145313723,16.965102599435927],[-62.729761325708566,17.090876005712886],[-62.6687472252876,17.29043440784797],[-62.729761325708566,17.40237987467555],[-63.07366108208678,18.031045075146537],[-63.05813109308835,18.144943564296213],[-63.05716109377556,18.217681162500718],[-63.22488097496117,18.305228078976267],[-63.4498808155692,18.358623372153332],[-64.12488033739322,18.358623372153332],[-64.34988017800117,18.358623372153332],[-64.59715000283296,18.41441211576626]]]}},{"type":"Feature","properties":{"id":"ec-link","name":"EC Link","color":"#663c97","feature_id":"ec-link-0","coordinates":[-65.19034678537975,11.278035921849968]},"geometry":{"type":"MultiLineString","coordinates":[[[-61.64929209112319,10.687695708022828],[-62.09988177192116,11.294709319565477],[-63.89988049678528,11.515266158038768],[-65.69987922164921,11.184367066436712],[-68.39987730894529,11.735650161405832],[-68.89264695986267,12.09043961830498]]]}},{"type":"Feature","properties":{"id":"elektra-globalconnect-1-gc1","name":"Elektra-GlobalConnect 1 (GC1)","color":"#c88129","feature_id":"elektra-globalconnect-1-gc1-0","coordinates":[11.931558055236268,54.290652868559256]},"geometry":{"type":"MultiLineString","coordinates":[[[11.94000577752764,54.577236267283396],[11.925065788111276,54.49397229925472],[11.925065788111276,54.29748595281839],[12.132485641173277,54.0791774165702]]]}},{"type":"Feature","properties":{"id":"emerald-bridge-fibres","name":"Emerald Bridge Fibres","color":"#ec1c24","feature_id":"emerald-bridge-fibres-0","coordinates":[-5.393337381404399,53.54170333979512]},"geometry":{"type":"MultiLineString","coordinates":[[[-6.197971373358976,53.41013850693277],[-5.624921779312721,53.54157968163075],[-5.090742157730591,53.54186491531681],[-4.630392483846553,53.30633550153628]]]}},{"type":"Feature","properties":{"id":"aec-1","name":"AEC-1","color":"#923c96","feature_id":"aec-1-0","coordinates":[-40.62232040192537,46.63726305614672]},"geometry":{"type":"MultiLineString","coordinates":[[[-9.232149223918933,54.20711352067886],[-9.562418989952736,54.55925876578231],[-10.799918113296759,55.07780072164767],[-12.599916838160784,55.07780072164767],[-16.199914287888834,54.29748595281839],[-23.399909187344846,52.41790126031551],[-39.59989771112098,46.890762878622326],[-50.39989006030513,44.21300917863173],[-61.19988240948919,42.07923561816413],[-68.39987730894529,41.0693404382162],[-71.09987539624129,40.47295490579834],[-72.87218414072115,40.800580995045266]]]}},{"type":"Feature","properties":{"id":"esat-2","name":"ESAT-2","color":"#27baad","feature_id":"esat-2-0","coordinates":[-4.615319758346711,53.53513602111024]},"geometry":{"type":"MultiLineString","coordinates":[[[-6.215401361011431,53.3317112890075],[-5.624921779312721,53.435130856225875],[-3.599923213840749,53.635715156995076],[-3.006373634316763,53.647933398832954]]]}},{"type":"Feature","properties":{"id":"est-tet","name":"Est-Tet","color":"#2ca887","feature_id":"est-tet-0","coordinates":[-5.174922098096691,35.953047318525535]},"geometry":{"type":"MultiLineString","coordinates":[[[-5.391811944449903,35.565918934421326],[-5.174922098096737,35.78566189952622],[-5.174922098096737,36.24065523321488],[-5.145872118676008,36.42741977174601]]]}},{"type":"Feature","properties":{"id":"farice-1","name":"FARICE-1","color":"#62bd60","feature_id":"farice-1-0","coordinates":[-6.5567326130043195,63.179471746966534]},"geometry":{"type":"MultiLineString","coordinates":[[[-3.346213393571057,58.61541952086163],[-3.824923054448695,58.758568944882946],[-3.824923054448695,59.45171731890513],[-3.149923532624674,60.35428947498098],[-2.699923851408691,61.87557078357141],[-2.699923851408691,62.08696177092747],[-5.399921938704683,62.917978785251854],[-7.199920663568708,63.32486213107881],[-10.799918113296759,64.70468599661176],[-10.799918113296759,65.08652060243699],[-12.1499171569448,65.27540872550333],[-13.499916200592752,65.27540872550333],[-14.018075833523609,65.25084981890578]],[[-6.927950856234652,62.244112059037015],[-6.897247538141233,62.405946169383654],[-7.199920663568708,62.712391894030624],[-7.199920663568708,63.32486213107881]]]}},{"type":"Feature","properties":{"id":"fehmarn-blt","name":"Fehmarn Bält","color":"#af4298","feature_id":"fehmarn-blt-0","coordinates":[11.260634989711626,54.59984452443255]},"geometry":{"type":"MultiLineString","coordinates":[[[11.212566292852763,54.49618504236196],[11.250066266287346,54.593021637497834],[11.358566189425126,54.66306630403638]]]}},{"type":"Feature","properties":{"id":"fiber-optic-gulf-fog","name":"Fiber Optic Gulf (FOG)","color":"#2859a8","feature_id":"fiber-optic-gulf-fog-0","coordinates":[51.619007540901144,27.175477152805563]},"geometry":{"type":"MultiLineString","coordinates":[[[47.974840250113054,29.37410420420039],[48.60003980721571,28.95155473219332],[49.16253940873571,28.55704546571133],[50.175038691471606,27.86309157699361],[51.30003789451161,27.364667993860262],[51.975037416335724,26.964304734562898],[52.65003693815965,26.562513149236715],[53.55003630059162,25.75470426341523],[54.90003534423966,25.55188275942587],[55.30853505485483,25.269353998130182]],[[52.65003693815965,26.562513149236715],[52.08753733663965,26.05828756029904],[51.519277739200085,25.294608758024626]],[[51.30003789451161,27.364667993860262],[50.85003821329572,26.562513149236715],[50.57601840741415,26.229494838391265]]]}},{"type":"Feature","properties":{"id":"fibralink","name":"Fibralink","color":"#c83a26","feature_id":"fibralink-0","coordinates":[-74.9004746672672,18.15122965650188]},"geometry":{"type":"MultiLineString","coordinates":[[[-73.79987348353728,19.95262290516439],[-73.3498738023213,20.375041253465433],[-71.54987507745727,20.375041253465433],[-70.98737547593727,20.163975031975873],[-70.6911856857609,19.799436355797177]],[[-76.66662145270618,17.949772942659727],[-76.27487173022533,17.609605913224996],[-75.37487236779336,17.609605913224996],[-74.81237276627336,18.251816319028222],[-74.36237308505729,19.316876111628712],[-73.79987348353728,19.95262290516439]],[[-73.79987348353728,19.95262290516439],[-73.57487364292933,19.104405475930452],[-72.80265418997682,18.864770359655843],[-72.60866432740104,18.86504478169787]],[[-77.92138056441902,18.469357593227326],[-77.73737069477338,18.500930002688044],[-77.2873710135573,18.500930002688044],[-77.10324114340065,18.398661383088054]]]}},{"type":"Feature","properties":{"id":"finland-estonia-2-eesf-2","name":"Finland-Estonia 2 (EESF-2)","color":"#b91e4e","feature_id":"finland-estonia-2-eesf-2-0","coordinates":[25.1302486264449,59.78697022199855]},"geometry":{"type":"MultiLineString","coordinates":[[[24.752496701038964,59.43639985926234],[25.087556463679363,59.679663707208995],[25.200056383983473,59.9624316341522],[25.087556463679363,60.07486799642317],[24.932476573539617,60.171163188940554]]]}},{"type":"Feature","properties":{"id":"flag-atlantic-1-fa-1","name":"FLAG Atlantic-1 (FA-1)","color":"#80479b","feature_id":"flag-atlantic-1-fa-1-0","coordinates":[-37.9308101244673,42.84199037340771]},"geometry":{"type":"MultiLineString","coordinates":[[[-73.65597358547731,40.60068600870845],[-71.09987539624129,39.87122513561614],[-68.39987730894529,39.6983233549332],[-61.19988240948919,39.00237890905839],[-50.39989006030513,39.524987333511675],[-39.59989771112098,42.41235450073586],[-23.399909187344846,46.5823550820958],[-16.199914287888834,46.5823550820958],[-5.399921938704683,49.29468421942562],[-4.499922576272716,49.29468421942562],[-3.599923213840749,49.14772788577412],[-2.812423771712709,48.852503408348504],[-2.767793803328928,48.534938683234785]],[[-5.674921743892284,50.06393817056209],[-6.299921301136742,50.09514516168246],[-8.099920026000767,49.87814473780419],[-10.799918113296759,49.58728674004685],[-16.199914287888834,48.40638249553803],[-23.399909187344846,47.19740739556967],[-39.59989771112098,43.073310783003215],[-50.39989006030513,40.04369219283004],[-61.19988240948919,39.35121757117122],[-68.39987730894529,39.87122513561614],[-71.6786849862071,41.09701835105607],[-72.1656446412403,41.217596026703156],[-72.36584449941695,41.217596026703156],[-72.82077417714041,41.14638300214584],[-73.19440391245767,41.022498134754876],[-73.34411380640174,40.909452382988626]]]}},{"type":"Feature","properties":{"id":"flag-north-asia-loopreach-north-asia-loop","name":"FLAG North Asia Loop/REACH North Asia Loop","color":"#c6b12e","feature_id":"flag-north-asia-loopreach-north-asia-loop-0","coordinates":[139.60237302402345,30.689317362291842]},"geometry":{"type":"MultiLineString","coordinates":[[[121.02775554672928,20.93310564677346],[118.79999007691224,21.111485983488812],[116.54999167083223,20.90143978523765],[115.19999262718419,21.111485983488812],[114.13129338426104,21.84429407917369],[113.93202352542546,22.227650940807052],[114.30004326471693,21.84429407917369],[115.19999262718419,21.42595132790301],[116.54999167083223,21.216397899942],[118.79999007691224,21.635297384859552],[120.12788799225589,21.66680565299917],[121.02775554672928,21.66680565299917],[121.94998784542422,22.469443964829516],[122.39998752664029,24.01990020343248],[122.17498768603225,24.53265756616073],[121.80144795065142,24.863504112487785],[122.17498768603225,24.94136317175375],[122.84998720785636,24.94136317175375],[125.99998497636834,24.32780311165181],[128.69998306366443,24.12261698700344],[132.74998019460836,24.53265756616073],[137.24997700676838,27.76358852605777],[138.82497589102445,29.344566989489813],[139.72497525345642,30.901396088515508],[140.17497493467252,32.43331330641721],[140.6249746158884,33.93964008831966],[140.4562247348366,34.69072647741027],[140.1785441559671,34.96779987634099],[140.09059999384857,35.01384769751837],[140.01677504674288,35.036003087164836],[140.0765375038106,35.01384769751837],[140.16448179519193,34.96779987634099],[140.42809975476052,34.69072647741027],[140.51247469558447,33.93964008831966],[139.94997509406446,32.43331330641721],[139.04997573163232,31.478822672736147],[137.69997668798445,30.708139993541643],[134.99997860068837,29.93125070442692],[132.74998019460836,29.93125070442692],[130.49998178852834,30.126049846722832],[129.5999824260964,30.901396088515508],[129.37498258548837,31.670513047087127],[129.37498258548837,32.8123187832876],[129.4874825057923,34.31215165223547],[128.99949285148878,35.17037876180022],[128.81248298396835,34.31215165223547],[128.24998338244836,32.8123187832876],[128.0249835418403,31.670513047087127],[127.34998402001638,29.540507745394493],[127.12498417940834,28.55704546571133],[125.99998497636834,26.1593079707739],[125.66248521545637,25.55188275942587],[125.32498545454442,24.12261698700344],[124.42498609211226,22.05298561667754],[122.84998720785636,21.111485983488812],[121.02775554672928,20.93310564677346]]]}},{"type":"Feature","properties":{"id":"flores-corvo-cable-system","name":"Flores-Corvo Cable System","color":"#425daa","feature_id":"flores-corvo-cable-system-0","coordinates":[-30.995581748343383,39.63909968175782]},"geometry":{"type":"MultiLineString","coordinates":[[[-28.647515469891374,38.52540431175965],[-28.799905361936922,38.38775473578444],[-29.69990472436889,38.651811712711336],[-30.599904086800947,39.35121757117122],[-31.128423712392745,39.46251104100099],[-31.108153726752057,39.5668202756421],[-31.110503725087366,39.67224286345835],[-30.599904086800947,39.524987333511675],[-28.799905361936922,39.00237890905839],[-28.1249058401129,38.91489898424152],[-27.96338595453508,39.01140845880094]]]}},{"type":"Feature","properties":{"id":"gemini-bermuda","name":"Gemini Bermuda","color":"#d73426","feature_id":"gemini-bermuda-0","coordinates":[-67.46657311302958,37.61607122842686]},"geometry":{"type":"MultiLineString","coordinates":[[[-74.04709330840446,40.12349265823708],[-71.09987539624129,39.524987333511675],[-67.41654467221427,37.589786573603064],[-64.79987985921724,34.683017659857974],[-64.12488033739322,33.565491482352044],[-63.89988049678528,32.8123187832876],[-64.23738025769724,32.43331330641721],[-64.7674091796907,32.31223462116531]]]}},{"type":"Feature","properties":{"id":"geo-eirgrid","name":"Geo-Eirgrid","color":"#50c3c9","feature_id":"geo-eirgrid-0","coordinates":[-4.56554667549013,53.586722425341584]},"geometry":{"type":"MultiLineString","coordinates":[[[-6.165841396120176,53.52654276893218],[-5.624921779312721,53.70236555668246],[-3.330833404466384,53.45193909085112],[-3.027563619305618,53.20172844509667]]]}},{"type":"Feature","properties":{"id":"georgia-russia","name":"Georgia-Russia","color":"#ee7122","feature_id":"georgia-russia-0","coordinates":[39.98809262417004,43.24234193818626]},"geometry":{"type":"MultiLineString","coordinates":[[[38.660832300000735,44.32520489999982],[38.67787891632564,43.88277492065505],[39.37504634228764,43.564400497117596],[39.72611609358651,43.61550655406996],[39.82504602350353,43.401144973153954],[40.500045545327644,42.743713464436695],[41.40004490775961,42.246014931829706],[41.66752471827441,42.14675635811542]]]}},{"type":"Feature","properties":{"id":"germany-denmark-3","name":"Germany-Denmark 3","color":"#e61d2d","feature_id":"germany-denmark-3-0","coordinates":[12.037565708415386,54.38254333816774]},"geometry":{"type":"MultiLineString","coordinates":[[[11.94000577752764,54.577236267283396],[12.037565708415386,54.49397229925472],[12.037565708415386,54.29748595281839],[12.149575629066478,54.19157680986677]]]}},{"type":"Feature","properties":{"id":"glo-1","name":"Glo-1","color":"#3665b0","feature_id":"glo-1-0","coordinates":[-18.744837688538002,20.289425974077716]},"geometry":{"type":"MultiLineString","coordinates":[[[-4.544402544762735,50.82820142743812],[-6.074881460557064,50.38295743994838],[-7.649920344784692,49.73293362369082],[-9.899918750864702,48.70423463096067],[-14.399915563024809,44.05151922873524],[-14.399915563024809,39.6983233549332],[-13.499916200592752,35.78566189952622],[-12.82491667876882,33.189714664600466],[-12.599916838160784,30.126049846722832],[-13.24783202918155,28.161052262220792],[-14.624915403632755,26.964304734562898],[-17.549913331536874,22.884654113882444],[-18.899912375184826,19.95262290516439],[-18.899912375184826,14.801154224791581],[-18.899912375184826,11.735650161405832],[-17.549913331536874,8.635699417327467],[-13.049916519376767,4.164912849976942],[-10.799918113296759,1.918228780215599],[-3.599923213840749,1.918228780215599],[-0.449925445328682,2.156121468705662],[1.575073120143199,3.266814816815666],[2.25007264196731,3.379125568249918],[2.700072323183203,4.164912849976942],[3.423511810692114,6.439066911484626]],[[-0.204315619320974,5.558285889905858],[-0.449925445328682,3.279837005484997],[-0.449925445328682,2.156121468705662]]]}},{"type":"Feature","properties":{"id":"southern-caribbean-fiber","name":"Southern Caribbean Fiber","color":"#44c0bb","feature_id":"southern-caribbean-fiber-0","coordinates":[-63.106187025004914,16.267778494508416]},"geometry":{"type":"MultiLineString","coordinates":[[[-61.790581991032056,12.008779421336667],[-61.81483197385321,11.861435172630657],[-61.64988209070518,11.294709319565477],[-61.59363213055321,11.073982781226615],[-61.64929209112319,10.687695708022828]],[[-61.09417248437512,14.615455776713933],[-61.14363244933723,14.365653759228442],[-61.14363244933723,14.147583506948735],[-60.986602560578575,14.032406258622672]],[[-62.729761325708566,17.298635546518675],[-63.05613109450518,17.180187287481317]],[[-61.715942043907745,16.028938259198707],[-61.87488193131321,15.994221447251933],[-62.09988177192116,16.534196198259725],[-62.043631812365184,16.803627104178766],[-61.98738185161723,16.965102599435927],[-61.84688195174463,17.159549933438978]],[[-61.09417248437512,14.615455776713933],[-61.31238232979321,14.801154224791581],[-61.42488225009723,15.018578573757472],[-61.39251227302841,15.308036758161125],[-61.53738217040116,15.452760959322058],[-61.72028204083323,15.838661138431222],[-61.715942043907745,16.028938259198707]],[[-61.31238232979321,14.147583506948735],[-60.974882568881156,14.256644994553485],[-60.74988272827321,14.147583506948735],[-59.84988336584115,13.273238157547594],[-59.60740353761622,13.084493800663104]],[[-61.31238232979321,14.147583506948735],[-61.19988240948919,14.038469666260218],[-60.986602560578575,14.032406258622672]],[[-64.81925984548825,17.773909269375704],[-64.68737993891322,18.091435124357137]],[[-60.986602560578575,14.032406258622672],[-61.19988240948919,13.92930384327183],[-61.42488225009723,13.492128176464083],[-61.42488225009723,13.273238157547594],[-61.20828240353852,13.145460938850311]],[[-61.20828240353852,13.145460938850311],[-61.59343213069483,13.054150695298627],[-61.9309318916068,12.395734000022975],[-61.93133189132347,12.191526789551752],[-61.790581991032056,12.008779421336667]],[[-61.57168214610275,16.244479667932865],[-61.47013221804175,15.988768942769152],[-61.49527220023222,15.892797111330715],[-61.607792120522106,15.838661138431222],[-61.715942043907745,16.028938259198707]],[[-66.10666893347558,18.46610423294742],[-65.69987922164921,18.518710038590605],[-65.36237946073724,18.465364393137126],[-65.08112965997725,18.251816319028222],[-64.68737993891322,18.091435124357137],[-64.12488033739322,18.144943564296213],[-63.4498808155692,18.251816319028222],[-63.22488097496117,18.198340634646357],[-63.078144550164644,18.08992880691782]],[[-61.715942043907745,16.028938259198707],[-61.87488193131321,15.886035719079029],[-61.98738185161723,15.940130106909258],[-63.05613109450518,17.180187287481317],[-63.17071434666634,17.422853263294805],[-63.112381054657234,17.77032870649733],[-63.22488097496117,17.877428826408096],[-63.22488097496117,18.037957897069543],[-63.078144550164644,18.08992880691782]],[[-64.81925984548825,17.773909269375704],[-64.5748800186092,17.984511967477506],[-64.12488033739322,17.931002277731235],[-63.924880479074965,17.180187287481317],[-62.37488157710876,15.452760959322058],[-61.762382011009194,14.692360031374392],[-61.31238232979321,14.147583506948735]],[[-62.85054881207158,17.897915647022234],[-63.056031094576035,17.877381240665],[-63.1686310148092,17.930954706360694],[-63.1686310148092,18.037957897069543],[-63.078144550164644,18.08992880691782]],[[-62.22175668617986,16.77071704667729],[-62.212331692856644,16.830453553562553],[-62.156081732704585,16.857419758344317],[-62.043631812365184,16.803627104178766]]]}},{"type":"Feature","properties":{"id":"globalconnect-2-gc2","name":"GlobalConnect 2 (GC2)","color":"#3880c2","feature_id":"globalconnect-2-gc2-0","coordinates":[11.284722502689345,57.483603421231344]},"geometry":{"type":"MultiLineString","coordinates":[[[10.516666785834435,57.331044232885546],[10.80006658507127,57.45103605396468],[11.700065947503239,57.51151320633319],[12.076235681021313,57.4876890992735]]]}},{"type":"Feature","properties":{"id":"globalconnect-3-gc3","name":"GlobalConnect 3 (GC3)","color":"#d13829","feature_id":"globalconnect-3-gc3-0","coordinates":[10.974669081828283,55.33230861812641]},"geometry":{"type":"MultiLineString","coordinates":[[[11.149386337610071,55.32605364145998],[11.025066425679308,55.33458061322904],[10.799576585418436,55.32441516057587]]]}},{"type":"Feature","properties":{"id":"globalconnect-kpn","name":"GlobalConnect-KPN","color":"#e03225","feature_id":"globalconnect-kpn-0","coordinates":[11.828189393563592,54.286824689205325]},"geometry":{"type":"MultiLineString","coordinates":[[[11.94000577752764,54.577236267283396],[11.812565867807347,54.49397229925472],[11.812565867807347,54.29748595281839],[12.132485641173277,54.0791774165702]]]}},{"type":"Feature","properties":{"id":"globenet","name":"GlobeNet","color":"#7b489c","feature_id":"globenet-0","coordinates":[-39.35235425663465,2.18873777337913]},"geometry":{"type":"MultiLineString","coordinates":[[[-68.39987730894529,15.23578178303578],[-70.19987603380923,14.801154224791581],[-71.54987507745727,14.147583506948735],[-74.24987316475335,12.834868817846521],[-74.92487268657729,11.735650161405832],[-74.77975278938153,10.940445615726643]],[[-80.08893155227202,26.350584577319996],[-79.64986933934534,26.663094151095223],[-77.84987061448132,27.364667993860262],[-76.94987125204935,27.164665812813517],[-73.3498738023213,24.53265756616073],[-69.29987667137726,22.469443964829516],[-67.94987762772922,20.58581909604039],[-67.83737770742529,18.678647022154717],[-68.17487746833726,17.82393441253792],[-68.39987730894529,15.23578178303578],[-67.49987794651324,12.615395567393394],[-67.27487810590529,11.294709319565477],[-66.96042832866438,10.599588212552636],[-66.71237850438528,10.85308969074528],[-65.69987922164921,11.405009147532946],[-63.89988049678528,11.735650161405832],[-62.99988113435322,11.735650161405832],[-61.19988240948919,11.735650161405832],[-59.399883684625166,11.294709319565477],[-54.89988687246515,9.08033076823294],[-51.2998894227371,6.852191098754328],[-46.79989261057708,5.061986954416114],[-40.94989675476902,1.468426767331968],[-39.149898029904996,-1.231315750217412],[-38.542968459859594,-3.718735129291092]],[[-80.08893155227202,26.350584577319996],[-79.64986933934534,26.813799487940788],[-77.84987061448132,28.95155473219332],[-76.49987157083336,30.901396088515508],[-75.14987252718532,33.565491482352044],[-74.47487300595729,35.54192681258013],[-73.91237340443729,39.00237890905839],[-74.33781310245581,39.60388206573738],[-73.3498738023213,39.35121757117122],[-71.09987539624129,39.00237890905839],[-68.31654403464633,37.589786573603064],[-65.69987922164921,34.683017659857974],[-64.5748800186092,33.56549148238552],[-64.12488033739322,32.8123187832876],[-64.23738025769724,32.52821504536491],[-64.7674091796907,32.31223462116531],[-64.34988017800117,31.286738814391754],[-57.59988495976114,22.05298561667754],[-52.19988878516916,15.669513225155248],[-46.79989261057708,10.41081650540272],[-38.69989834868901,1.468426767331968],[-38.02489882686499,-1.231315750217412],[-38.542968459859594,-3.718735129291092],[-35.99990026139302,-3.479268678970064],[-34.199901536529,-4.152767748013638],[-32.174902964837905,-5.4979506821245],[-31.274903608624967,-9.29042430103552],[-32.84990249288096,-13.698987269610743],[-34.64990121774498,-18.026426383713453],[-38.02489882686499,-22.873434953546333],[-40.94989675476902,-23.905969261790265],[-42.29989579841706,-23.700175468198225],[-43.20956515399876,-22.903486555497956],[-42.29989579841706,-23.59715656726005],[-40.94989675476902,-23.70010845220312],[-38.36239858777696,-22.56211951183571],[-35.549900580176946,-18.026426383713453],[-33.74990185531301,-13.698987269610743],[-31.949903130448895,-9.29042430103552],[-32.51240272885988,-5.83380111633244],[-34.199901536529,-4.489307688629284],[-35.99990026139302,-3.70382647066824],[-38.542968459859594,-3.718735129291092]]]}},{"type":"Feature","properties":{"id":"go-1-mediterranean-cable-system","name":"GO-1 Mediterranean Cable System","color":"#49b751","feature_id":"go-1-mediterranean-cable-system-0","coordinates":[13.336982061256101,36.6424823724163]},"geometry":{"type":"MultiLineString","coordinates":[[[12.591375316091606,37.65058617278613],[12.712565230239315,37.23235432155614],[13.05006499115128,36.87321951208928],[14.062564273887357,36.05897312258681],[14.411884026425975,35.95058770661474]]]}},{"type":"Feature","properties":{"id":"gondwana-1","name":"Gondwana-1","color":"#583c97","feature_id":"gondwana-1-0","coordinates":[158.55470339591537,-27.95238431854148]},"geometry":{"type":"MultiLineString","coordinates":[[[151.20699711948467,-33.86955173177822],[152.01359716589357,-33.76115742340548],[154.79996457419256,-32.23291009389603],[157.49996266148847,-28.743810281149894],[165.5999569233767,-22.665969967794794],[166.4392563288091,-22.303308064620133]]]}},{"type":"Feature","properties":{"id":"oteglobe-kokkini-bari","name":"OTEGLOBE Kokkini-Bari","color":"#eb8d22","feature_id":"oteglobe-kokkini-bari-0","coordinates":[19.37472434465879,39.9858158704024]},"geometry":{"type":"MultiLineString","coordinates":[[[16.868812285914967,41.12570905852263],[18.00006148452737,41.154101109901234],[18.675061006351484,40.89949091487166],[19.125060687567377,40.38732029077508],[19.237560607871487,40.04369219283004],[19.923990121598468,39.75405284068902],[19.125060687567377,40.04369219283004],[19.012560767263448,40.38732029077508],[18.562561086047374,40.81440215469873],[18.00006148452737,41.0693404382162],[16.868812285914967,41.12570905852263]]]}},{"type":"Feature","properties":{"id":"greenland-connect-north","name":"Greenland Connect North","color":"#b1542d","feature_id":"greenland-connect-north-0","coordinates":[-53.99988751003309,66.40121848608965]},"geometry":{"type":"MultiLineString","coordinates":[[[-51.72996911806463,64.1812188111394],[-52.19988878516916,64.12167519439019],[-52.874888306993086,64.31739001144432],[-53.09988814760113,64.70468599661157],[-53.09988814760113,65.08652060243699],[-52.905722331173045,65.40767479539332],[-53.549887828817106,65.5562256550423],[-53.99988751003309,65.83404693088374],[-53.99988751003309,66.38080449917227],[-53.99988751003309,66.56045213116762],[-53.67338536157225,66.93947156321055],[-54.224887350641126,67.0039445723606],[-54.44988719124917,67.26621947299489],[-54.44988719124917,68.12014116880253],[-53.549887828817106,68.61776755908163],[-52.859115918877464,68.70697594519784]]]}},{"type":"Feature","properties":{"id":"greenland-connect","name":"Greenland Connect","color":"#f47920","feature_id":"greenland-connect-0","coordinates":[-47.191261354260966,59.826345721780775]},"geometry":{"type":"MultiLineString","coordinates":[[[-46.03539315215566,60.71902841019273],[-46.3498929293611,60.35428947498098],[-46.79989261057708,59.679663707208995]],[[-53.96027753868921,48.20755282132369],[-53.549887828817106,48.0692013806548],[-53.09988814760113,48.256798568947445],[-52.19988878516916,48.70423463096067],[-49.49989069787308,50.740281893948165],[-49.04989101665709,51.86557165559819],[-50.84988974152112,60.13094286609847],[-51.74988910395308,61.23255301306618],[-52.64988846638514,62.712391894030624],[-52.64988846638514,63.526173423791214],[-51.97488894456112,63.82549832634276],[-51.72996911806463,64.1812188111394],[-51.862389024257105,63.82549832634276],[-52.4248886257771,63.526173423791214],[-52.19988878516916,62.712391894030624],[-50.39989006030513,61.23255301306618],[-48.59989133544111,60.35428947498098],[-46.79989261057708,59.679663707208995],[-45.89989324814503,59.45171731890513],[-43.19989516084903,59.222223914844314],[-27.899905999504952,61.01524141470424],[-22.499909824912876,62.08696177092747],[-20.92491094065689,63.32486213107881],[-20.141851495383488,63.642367197144765]]]}},{"type":"Feature","properties":{"id":"guam-okinawa-kyushu-incheon-goki","name":"Guam Okinawa Kyushu Incheon (GOKI)","color":"#48b64e","feature_id":"guam-okinawa-kyushu-incheon-goki-0","coordinates":[131.4448329418809,21.902304279180385]},"geometry":{"type":"MultiLineString","coordinates":[[[127.12498417940834,25.957179978764344],[127.46248394032031,26.1593079707739],[127.68084378563216,26.212414126750428]],[[144.80036165800522,13.5136853395933],[144.44997190622448,13.92930384327183],[138.59997605041642,17.395022634700517],[131.8499808321764,21.635297384859552],[128.69998306366443,23.7112581424843],[127.57498386062441,24.94136317175375],[127.12498417940834,25.957179978764344],[127.12498417940834,26.562513149236715],[128.0249835418403,28.55704546571133],[128.24998338244836,29.540507745394493],[128.92498290427227,30.5144959597591],[129.0374828245764,31.670513047087127],[129.5999824260964,33.565491482352044],[130.04998210731227,33.93964008831966],[130.45721181882723,34.03316211713014],[131.0319114117045,33.83952016970107]]]}},{"type":"Feature","properties":{"id":"guernsey-jersey-4","name":"Guernsey-Jersey-4","color":"#3d7abe","feature_id":"guernsey-jersey-4-0","coordinates":[-2.389985809619759,49.31280629009675]},"geometry":{"type":"MultiLineString","coordinates":[[[-2.201384204578854,49.24804518533205],[-2.362424090496635,49.29468421942562],[-2.55807395189651,49.423325709430145]]]}},{"type":"Feature","properties":{"id":"gulf-bridge-international-cable-systemmiddle-east-north-africa-cable-system-gbicsmena","name":"Gulf Bridge International Cable System/Middle East North Africa Cable System (GBICS/MENA)","color":"#b8d432","feature_id":"gulf-bridge-international-cable-systemmiddle-east-north-africa-cable-system-gbicsmena-0","coordinates":[64.40630096558134,22.116248705748358]},"geometry":{"type":"MultiLineString","coordinates":[[[56.33372432860119,25.121690004958644],[56.92503390971164,24.762719791019048],[58.50003279396771,24.12261698700344],[59.85003265596724,24.73717420686441],[63.90002896855988,22.469443964829516],[66.60002705585578,20.58581909604039],[70.20002450558383,19.8468404054317],[72.87590260996693,19.07607425728523]],[[50.84271821848139,28.970348642858344],[50.40003853207964,28.55704546571133]],[[50.214198663730556,26.28537535931817],[50.23128865162375,26.461843796188983],[50.40003853207964,26.964304734562898],[50.85003821329572,27.364667993860262]],[[50.65618835062081,26.241586178580675],[51.075038053903754,26.562513149236715],[51.525037735119646,26.964304734562898]],[[58.1762030233719,23.68487753168473],[58.331282913511636,23.91710129093513],[58.50003279396771,24.12261698700344]],[[56.33372432860119,25.121690004958644],[57.093783790167706,25.348717422116714],[57.093783790167706,26.1593079707739],[56.92503390971164,26.562513149236715],[56.2500343878877,26.763586569619914],[55.80003470667163,26.512189502051797],[55.35003502545574,26.512189502051797],[53.55003630059162,26.461843796188983],[53.10003661937573,26.562513149236715],[52.425037097551616,26.964304734562898],[51.75003757572769,27.364667993860262],[50.40003853207964,28.55704546571133],[49.27503932903964,28.95155473219332],[48.82503964782375,29.540507745394493],[48.5317798555716,29.92363278689715]],[[56.33372432860119,25.121690004958644],[56.70003406910378,25.348717422116714],[56.86878394955967,26.1593079707739],[56.70003406910378,26.562513149236715],[56.2500343878877,26.562513149236715],[55.80003470667163,26.20978541224739],[55.35003502545574,25.957179978764344],[53.55003630059162,25.957179978764344],[52.20003725694376,25.855985466072205],[51.45198778686903,25.538926547132757],[51.75003757572769,26.1593079707739],[51.75003757572769,26.562513149236715],[51.525037735119646,26.964304734562898],[50.85003821329572,27.364667993860262],[50.175038691471606,28.161052262220792],[49.16253940873571,28.853067255226264],[48.60003980721571,29.049948644465697],[47.974840250113054,29.37410420420039]],[[49.27503932903964,28.95155473219332],[49.16253940873571,28.853067255226264]]]}},{"type":"Feature","properties":{"id":"hantru1-cable-system","name":"HANTRU1 Cable System","color":"#45469c","feature_id":"hantru1-cable-system-0","coordinates":[155.96659845524127,9.858506753455973]},"geometry":{"type":"MultiLineString","coordinates":[[[144.69470173285575,13.464772962370143],[144.8437216272884,13.273238157547594],[145.34997126865645,12.944533868662969],[146.24997063108842,12.395734000022975],[147.14996999352056,11.955858207114732],[149.39996839960057,11.294709319565477],[151.1999671244645,10.41081650540272],[158.84996170513668,9.52441134501949],[163.7999581985126,9.08033076823294],[167.39995564824065,8.635699417327467],[167.40581564408944,9.186487475823204]],[[167.39995564824065,8.635699417327467],[169.64995405432066,7.744889052551447],[171.19624295891518,7.07776378844695]],[[159.0702615490743,7.786404758723782],[158.84996170513668,8.635699417327467],[158.84996170513668,9.52441134501949]]]}},{"type":"Feature","properties":{"id":"hawaiki","name":"Hawaiki","color":"#3851a3","feature_id":"hawaiki-0","coordinates":[-169.40629188424063,-1.1960056555101188]},"geometry":{"type":"MultiLineString","coordinates":[[[174.57446056575412,-36.1261499070875],[174.59995054769675,-35.59302880961419],[174.14995086648068,-34.85783936223576],[172.7999518228328,-34.11602012163193],[165.5999569233767,-34.11602012163193],[159.29996138635258,-35.042260722865336],[155.69996393662453,-34.85783936223576],[152.09996648689665,-34.30209296887181],[151.20699711948467,-33.86955173177822]],[[-158.05689434939785,21.335422205733376],[-158.2873136318419,20.796306105108872],[-159.07481307396995,18.678647022154717],[-161.99981100187398,13.054150695298627],[-167.39980717646606,2.367912558705407],[-171.4498043074101,-4.825692499217419],[-172.79980335105813,-10.17745743036107],[-173.6998027134901,-11.943944931746815],[-176.39980080078607,-18.45381377577717],[-179.99979825051412,-23.905969261790265]],[[-158.05689434939785,21.335422205733376],[-157.8373139506259,21.134806167482292],[-157.61231411001796,21.134806167482292],[-157.49981418971396,21.169779563880702],[-157.38731427000602,21.356164482330126],[-152.99981737755394,25.348717422116714],[-147.6030982105313,30.516425505901374],[-138.60310458621126,35.05406343239751],[-128.24983491067374,42.743713464436695],[-125.09983714216159,45.172673246984274],[-124.19983777972962,45.80361417369449],[-123.52483825790569,45.80361417369449],[-122.98980067585488,45.522898824562965]],[[-173.6998027134901,-11.943944931746815],[-172.07822386223177,-12.894213639363048],[-170.999804626194,-13.698987269610743],[-170.69570484162125,-14.276544564158804]],[[179.9999467222889,-23.905969261790265],[173.69995118526478,-28.150316035845893],[170.9999530979687,-29.137613161609917],[166.94995596702458,-32.61276000573574],[165.5999569233767,-34.11602012163193]]]}},{"type":"Feature","properties":{"id":"hawaiki","name":"Hawaiki","color":"#939597","feature_id":"hawaiki-1","coordinates":[-175.19044619757412,-18.535728879049135]},"geometry":{"type":"MultiLineString","coordinates":[[[-176.39980080078607,-18.45381377577717],[-174.8248019135497,-18.560495634149003],[-173.98368251298305,-18.647678684858587]]]}},{"type":"Feature","properties":{"id":"hawk","name":"Hawk","color":"#ad4599","feature_id":"hawk-0","coordinates":[15.651651932929958,34.373226284127846]},"geometry":{"type":"MultiLineString","coordinates":[[[5.372530429989069,43.29362778902908],[5.850070091695361,41.74435878948223],[6.975069294735365,38.651811712711336],[7.650068816559295,37.94551049545967],[9.000067859611436,37.67887792909206],[10.348617229769278,37.67887792909206],[10.91256650537538,37.32187222983504],[11.475066105703581,37.23235432155614],[12.262565549023423,36.24065523321488],[12.600065309935387,35.78566189952622],[13.162564911457572,35.419780517080355],[14.400064034799321,34.683017659857974],[16.65006244087933,34.12610104005753],[19.350060528175415,34.49779087043369],[22.050058614875596,34.29666313310539],[25.200056383983473,33.565491482352044],[28.800053833711523,32.33831157801293],[29.8935130590948,31.191465077638455]],[[32.466651236259686,34.76657169708598],[31.500051920411707,34.157137999942634],[29.645663234673997,32.841864074406956],[28.800053833711523,32.33831157801293]]]}},{"type":"Feature","properties":{"id":"exa-north-and-south","name":"EXA North and South","color":"#4cb96a","feature_id":"exa-north-and-south-0","coordinates":[-5.766007052696966,55.067817143282014]},"geometry":{"type":"MultiLineString","coordinates":[[[-6.676191034583779,55.130930058159635],[-6.676191034583779,55.415759313161054]],[[-70.95027550281526,42.46364601310954],[-69.29987667137726,42.578254086072846],[-65.69987922164921,42.743713464436695],[-63.89988049678528,44.05151922873524],[-63.572490728711166,44.65322870491472],[-61.19988240948919,44.53466416326733],[-50.39989006030513,46.73677946695437],[-39.59989771112098,49.87814473780419],[-23.399909187344846,54.03403825672422],[-16.199914287888834,54.81936191424915],[-12.599916838160784,55.33458061322904],[-8.999919388432733,55.58970711313177],[-6.676191034583779,55.415759313161054],[-6.074921460528704,55.33458061322904],[-4.9499222574887,54.36308597431902],[-4.9499222574887,54.10005748241058],[-4.837372337220154,53.967914030873956],[-3.599923213840749,53.70236555668246],[-3.006373634316763,53.647933398832954],[-3.599923213840749,53.56895929103051],[-5.624921779312721,53.368058136502164],[-6.248311337697704,53.34812463259513],[-5.624921779312721,53.23359531864929],[-5.399921938704683,52.69150159464696],[-5.624921779312721,52.14259270367212],[-7.199920663568708,51.44682015166956],[-10.799918113296759,50.740281893948165],[-16.199914287888834,50.740281893948165],[-23.399909187344846,51.30637567738274],[-39.59989771112098,49.29468421942562],[-50.39989006030513,46.11643477220242],[-61.19988240948919,44.37405751055857],[-63.572490728711166,44.65322870491472]]]}},{"type":"Feature","properties":{"id":"exa-express","name":"EXA Express","color":"#b03d2b","feature_id":"exa-express-0","coordinates":[-33.54104515583307,49.62883332888279]},"geometry":{"type":"MultiLineString","coordinates":[[[-8.472719761905951,51.89860855381128],[-7.649920344784692,51.586833980054095],[-7.199920663568708,51.30637567738274]],[[-3.010863631136018,51.293645748996475],[-4.049922895056732,51.44682015166956],[-5.399921938704683,51.30637567738274],[-7.199920663568708,51.30637567738274],[-10.799918113296759,50.59767719905356],[-16.199914287888834,50.454639125893955],[-23.399909187344846,50.740281893948165],[-39.59500294438848,48.96533145710388],[-50.394995293572634,45.766420912645],[-61.19988240948919,44.21300917863173],[-63.57581072635926,44.646178570763084]]]}},{"type":"Feature","properties":{"id":"high-capacity-undersea-guernsey-optical-fibre-hugo","name":"High-capacity Undersea Guernsey Optical-fibre (HUGO)","color":"#57bf9b","feature_id":"high-capacity-undersea-guernsey-optical-fibre-hugo-0","coordinates":[-4.1418581135246875,49.674988056927326]},"geometry":{"type":"MultiLineString","coordinates":[[[-2.558223951790181,49.42685110088373],[-2.924923692016728,49.29468421942562],[-3.459883313046331,48.873416834450104],[-3.459883313046331,48.73055297916871]],[[-2.537573966418871,49.50954697267552],[-3.599923213840749,49.58728674004685],[-4.499922576272716,49.73293362369082],[-5.399921938704683,49.805593628808026],[-5.654511758350902,50.043147911894295]]]}},{"type":"Feature","properties":{"id":"hokkaido-sakhalin-cable-system-hscs","name":"Hokkaido-Sakhalin Cable System (HSCS)","color":"#adc136","feature_id":"hokkaido-sakhalin-cable-system-hscs-0","coordinates":[141.2325731513499,44.94776832877892]},"geometry":{"type":"MultiLineString","coordinates":[[[141.31540412678189,43.17117526599852],[141.07497429710446,43.564400497117596],[141.07497429710446,44.05151922873524],[141.2999741377125,45.331071073324864],[141.63747389862448,46.272182853813646],[141.8593537414428,46.68485289093097]]]}},{"type":"Feature","properties":{"id":"honotua","name":"Honotua","color":"#ba8c34","feature_id":"honotua-0","coordinates":[-153.67016881187493,1.0233135003440712]},"geometry":{"type":"MultiLineString","coordinates":[[[-151.36856925702767,-16.68232030577119],[-151.4810691744897,-16.52060477977994],[-151.649818334502,-16.4666694364009],[-151.749478976969,-16.50585915193275]],[[-151.03106950464166,-16.84581334779892],[-151.19981865269,-16.736195168143745],[-151.36856925702767,-16.68232030577119],[-151.4429792018391,-16.730535431149917]],[[-149.73731968933393,-17.379584661169034],[-150.0748194496499,-17.27600887012678],[-150.749818971474,-16.953454989809906],[-151.03106950464166,-16.84581334779892],[-150.99996952686269,-16.729596913676634]],[[-155.85635596387024,20.003739828700425],[-156.262315066966,19.740987365524937],[-156.5998148272819,19.104405475930452],[-156.5998148272819,18.251816319028222],[-154.79981610241796,5.957818681088533],[-150.29981929025786,-13.698987269610743],[-149.39981992842198,-17.168553094226155],[-149.44108067057505,-17.51215245847912],[-149.56857057763543,-17.433258753637784],[-149.73731968933393,-17.379584661169034],[-149.8295103855952,-17.538318795988708]]]}},{"type":"Feature","properties":{"id":"i2i-cable-network-i2icn","name":"i2i Cable Network (i2icn)","color":"#d86526","feature_id":"i2i-cable-network-i2icn-0","coordinates":[92.97396523862938,8.79416724317216]},"geometry":{"type":"MultiLineString","coordinates":[[[80.24298739105474,13.06385310188338],[81.45001653598388,12.395734000022975],[83.70001494206389,11.294709319565477],[89.10001111665605,9.967915186974132],[93.1500082476,8.740828945067943],[95.40000665368001,7.744889052551447],[97.42500521915215,6.740481724921185],[99.45000378462413,5.286069860821008],[100.23750322675217,4.613591578862773],[100.68750290796805,3.266814816815666],[101.25000250948806,2.705081160335761],[102.15000187192003,2.143087178471855],[102.68279723997186,1.755006673795484],[103.34065102845322,1.355886056053319],[103.50000091556807,1.257299278085184],[103.64609081207688,1.338585852071497]]]}},{"type":"Feature","properties":{"id":"ingrid","name":"INGRID","color":"#41b549","feature_id":"ingrid-0","coordinates":[-2.370277513057691,49.34057361627847]},"geometry":{"type":"MultiLineString","coordinates":[[[-2.529413972199473,49.4510471803252],[-2.304174131761598,49.29468421942562],[-2.201384204578854,49.24804518533205]],[[-2.021314332142032,49.22527732021623],[-1.916424406447071,49.256680777352365],[-1.650024595167177,49.283282999655135]]]}},{"type":"Feature","properties":{"id":"interchange-cable-network-1-icn1","name":"Interchange Cable Network 1 (ICN1)","color":"#cd2b42","feature_id":"interchange-cable-network-1-icn1-0","coordinates":[173.4391172213758,-18.356908573044453]},"geometry":{"type":"MultiLineString","coordinates":[[[168.32300499434405,-17.73050232618011],[168.63745477158477,-17.919416202114704],[169.19995437310476,-18.133371564207607],[177.29994863499283,-18.560495634149003],[178.0874480765248,-18.560495634149003],[178.43744782917764,-18.123810943537187]]]}},{"type":"Feature","properties":{"id":"globalconnect-denmark-sweden","name":"GlobalConnect Denmark-Sweden","color":"#3465b0","feature_id":"globalconnect-denmark-sweden-0","coordinates":[12.642148694188203,55.526080187888766]},"geometry":{"type":"MultiLineString","coordinates":[[[12.415005441033497,55.647554790974716],[12.487565389631278,55.55790652931397],[12.600065309935387,55.526080187888724],[12.712565230239315,55.526080187888724],[12.916665085653134,55.533568060627864]]]}},{"type":"Feature","properties":{"id":"italy-albania","name":"Italy-Albania","color":"#a8842d","feature_id":"italy-albania-0","coordinates":[18.16083930162342,41.2387523289666]},"geometry":{"type":"MultiLineString","coordinates":[[[16.868812285914967,41.12570905852263],[18.00006148452737,41.2387523289666],[19.125060687567377,41.2387523289666],[19.45006045733454,41.31691028026608]]]}},{"type":"Feature","properties":{"id":"italy-croatia","name":"Italy-Croatia","color":"#c23f2f","feature_id":"italy-croatia-0","coordinates":[12.863045445529735,45.331071073324864]},"geometry":{"type":"MultiLineString","coordinates":[[[12.25740911740422,45.49608242788426],[12.376065468618977,45.331071073324864],[13.275064831759318,45.331071073324864],[13.533478213425028,45.433276695186386]]]}},{"type":"Feature","properties":{"id":"italy-greece-1-ig-1","name":"Italy-Greece 1 (IG-1)","color":"#55b847","feature_id":"italy-greece-1-ig-1-0","coordinates":[19.282686617727276,39.9505406592124]},"geometry":{"type":"MultiLineString","coordinates":[[[18.48570114049574,40.14820187022187],[18.675061006351484,40.04369219283004],[19.80006020939149,39.87122513561614],[19.96881008984738,39.78479013330389],[19.96881008984738,39.74158952932109],[20.01546005680019,39.68362591227569]]]}},{"type":"Feature","properties":{"id":"italy-libya","name":"Italy-Libya","color":"#2767b2","feature_id":"italy-libya-0","coordinates":[12.848474053890431,35.265103678646945]},"geometry":{"type":"MultiLineString","coordinates":[[[12.591375316091606,37.65058617278613],[12.543815349783424,37.23235432155614],[12.600065309935387,36.87321951208928],[12.825065150547426,35.419780517080355],[13.162564911455389,33.189714664600466],[13.187364893886881,32.87762290319534]]]}},{"type":"Feature","properties":{"id":"italy-monaco","name":"Italy-Monaco","color":"#51459c","feature_id":"italy-monaco-0","coordinates":[8.098609573186144,43.8078748413408]},"geometry":{"type":"MultiLineString","coordinates":[[[7.42672897477544,43.7382556879356],[7.875068657167333,43.72721479104982],[8.325068338383225,43.88958773629964],[8.48375822596588,44.30574823054251]]]}},{"type":"Feature","properties":{"id":"jaka2ladema","name":"JaKa2LaDeMa","color":"#4a499e","feature_id":"jaka2ladema-0","coordinates":[109.588195991442,-2.6068289429743916]},"geometry":{"type":"MultiLineString","coordinates":[[[116.0815320026935,-8.585998888200503],[115.8749921490083,-8.566064314426075],[115.53749238809615,-8.595852074426833],[115.33144253346806,-8.536714291275516]],[[114.09716340843899,-8.615167135560569],[114.41249318505615,-8.893723131858618],[114.97499278657615,-8.893723131858618],[115.22152261133631,-8.656661268449556]],[[109.33554678161278,-0.027021392288274],[108.89999709016024,-0.443906656918545],[109.12499693076828,-1.681168935904995],[109.34999677137613,-2.130918480960333],[109.79999645259221,-3.029995968008661],[110.47499597441613,-3.479268678970064],[111.14999549624025,-3.479268678970064],[111.59999517745614,-3.254657364797681],[111.75292506911941,-2.849274293012173]],[[109.97196633076723,-1.858723986693225],[109.57499661198416,-1.906058394384765],[109.34999677137613,-2.130918480960333]],[[111.14999549624025,-3.479268678970064],[112.0499948586722,-3.92832730414264],[112.94999422110418,-3.92832730414264],[114.07499342414418,-3.70382647066824],[114.60399304939611,-3.327586828573115]],[[117.55106096166544,0.327276576000234],[117.89999071448027,0.118588418888312],[119.24998975812831,-1.006358951224796],[119.38846966002788,-1.142074966989317]]]}},{"type":"Feature","properties":{"id":"jakabare","name":"JAKABARE","color":"#33af85","feature_id":"jakabare-0","coordinates":[107.05910632420758,-1.5220792479338443]},"geometry":{"type":"MultiLineString","coordinates":[[[109.18222689022609,-0.061391357195038],[108.89999709016024,0.006088583243203],[106.87499852468808,-0.331409329660265]],[[104.13320046700375,1.173685663377224],[104.28790035741287,1.201587148227037],[104.62500011860807,1.299726182129338],[104.8499999592161,1.35596103499925],[105.29999964043219,1.243490076978041],[106.87499852468808,-0.331409329660265],[107.15324832757338,-2.130918480960333],[107.269748245044,-3.029995968008661],[107.5499980465122,-4.60145376483711],[107.09999836529612,-5.273944363641298],[107.12099835041957,-5.981154260263285]],[[104.8499999592161,1.35596103499925],[104.62500011860807,1.35596103499925],[104.20156041857692,1.328818505780107],[103.98701057056589,1.389451396800233]]]}},{"type":"Feature","properties":{"id":"janna","name":"Janna","color":"#ba9f39","feature_id":"janna-0","coordinates":[10.71286797642036,38.11928817607388]},"geometry":{"type":"MultiLineString","coordinates":[[[9.496447507971368,40.92357574660862],[9.900067222043404,41.09760615413818],[10.575066743867334,41.435845950249096],[11.250066266287346,41.74435878948223],[11.796845878943602,42.091414250473434]],[[9.109447782721553,39.215608817480685],[9.450067541423229,38.739615313825674],[11.700065947503239,37.6343456033406],[12.591375316091606,37.65058617278613]]]}},{"type":"Feature","properties":{"id":"chi","name":"CHI","color":"#ae4b9c","feature_id":"chi-0","coordinates":[-139.97348724589224,28.71189196953591]},"geometry":{"type":"MultiLineString","coordinates":[[[-158.22066328843266,21.4634468234482],[-158.39981354930393,21.635297384859552],[-158.39981245229697,21.73983373091106],[-158.17481371153798,22.05298561667754],[-157.94981387092994,22.261369678340607],[-152.99981737755394,24.53265756616073],[-147.6030982105313,27.366657115363733],[-138.60310458621126,28.953514579902283],[-127.79983522945767,32.052708023486204],[-122.39983905486586,34.683017659857974],[-120.8472016490899,35.367078251717096]]]}},{"type":"Feature","properties":{"id":"jerry-newton","name":"Jerry Newton","color":"#4365af","feature_id":"jerry-newton-0","coordinates":[-68.5905922192149,12.158312080067464]},"geometry":{"type":"MultiLineString","coordinates":[[[-68.28414739152544,12.163814736152432],[-68.51237722924922,12.175887185507976],[-68.89264695986267,12.09043961830498]]]}},{"type":"Feature","properties":{"id":"jonah","name":"Jonah","color":"#43b549","feature_id":"jonah-0","coordinates":[24.558244455904642,34.874805345756286]},"geometry":{"type":"MultiLineString","coordinates":[[[34.76967960477271,32.04501185826483],[33.750050327087614,32.52821504536491],[31.050052239791533,33.28381101905092],[25.200056383983473,34.683017659857974],[23.400057658523455,35.22089733312799],[22.050058615471496,35.876870570092834],[19.80006020939149,37.67887792909206],[19.182760646692255,39.48474996079946],[18.900060846959338,40.04369219283004],[18.787560926655413,40.38732029077508],[18.00006148452737,40.98447035812857],[16.868812285914967,41.12570905852263]]]}},{"type":"Feature","properties":{"id":"kattegat-2","name":"Kattegat 2","color":"#7fc241","feature_id":"kattegat-2-0","coordinates":[11.91479473627233,56.93701515888509]},"geometry":{"type":"MultiLineString","coordinates":[[[11.199286302260399,57.31375088583712],[11.700065947503239,57.02488552657649],[12.150065628719313,56.840738642145496],[12.683365250924915,56.68336908123943]],[[10.514616787286652,57.24491811100573],[10.687566664767344,57.238576475009886],[10.966956466845055,57.27264724087425]]]}},{"type":"Feature","properties":{"id":"kodiak-kenai-fiber-link-kkfl","name":"Kodiak Kenai Fiber Link (KKFL)","color":"#36bcac","feature_id":"kodiak-kenai-fiber-link-kkfl-0","coordinates":[-151.6498183339059,58.40126734741937]},"geometry":{"type":"MultiLineString","coordinates":[[[-149.4476706657402,60.110049313261904],[-149.3998199278259,59.906069924574304],[-149.3998199278259,59.679663707208995],[-149.84981960904187,59.45171731890513],[-151.1998186526899,58.99117670269853],[-151.42481849329786,58.52439396084473],[-151.42481849329786,58.05131589106027],[-151.87481817451393,57.57189027900508],[-152.32906855174062,57.42449129028307],[-152.09981801512188,57.57189027900508],[-152.09981801512188,57.692344580260766],[-152.39517850323762,57.7944222178726],[-152.09981801512188,57.8123997505166],[-151.6498183339059,58.05131589106027],[-151.6498183339059,58.52439396084473],[-152.32481785572992,59.106894957190725],[-152.32481785572992,59.50884868221247],[-152.09981801512188,59.56588346342965],[-151.54424912754024,59.646565622018684],[-152.09981801512188,59.679663707208995],[-152.09981801512188,59.906069924574304],[-151.6498183339059,60.35428947498098],[-151.26871860388053,60.553102538800644],[-151.59185909261012,60.70906894445236],[-151.59185909261012,60.867466201165044],[-150.23341008926553,61.217558335568484],[-149.8584103643921,61.217558335568484]]]}},{"type":"Feature","properties":{"id":"korea-japan-cable-network-kjcn","name":"Korea-Japan Cable Network (KJCN)","color":"#b32d25","feature_id":"korea-japan-cable-network-kjcn-0","coordinates":[129.12059853162657,35.10111055511714]},"geometry":{"type":"MultiLineString","coordinates":[[[131.0319114117045,33.83952016970107],[130.49998178852834,34.31215165223547],[128.99949285148878,35.17037876180022],[130.04998210731227,34.31215165223547],[130.40164185819341,33.59022724332908]]]}},{"type":"Feature","properties":{"id":"kuwait-iran","name":"Kuwait-Iran","color":"#c72379","feature_id":"kuwait-iran-0","coordinates":[49.260052533795346,29.344566989489813]},"geometry":{"type":"MultiLineString","coordinates":[[[47.974840250113054,29.37410420420039],[48.4875398869116,29.344566989489813],[49.50003916964768,29.344566989489813],[49.95003885086375,29.442584645837396],[50.52112844629867,29.570753352727138]],[[49.50003916964768,29.344566989489813],[49.478069185211346,29.07273641911172]],[[49.95003885086375,29.442584645837396],[50.312048594412666,29.24602743373373]]]}},{"type":"Feature","properties":{"id":"lanis-1","name":"Lanis-1","color":"#53bc84","feature_id":"lanis-1-0","coordinates":[-3.8299893271551118,53.89757264331379]},"geometry":{"type":"MultiLineString","coordinates":[[[-3.050753602877637,53.80897597127547],[-3.599923213840749,53.86853064456715],[-4.387222656110318,53.967914030873956],[-4.566622529021879,54.10028616652028]]]}},{"type":"Feature","properties":{"id":"lanis-2","name":"Lanis-2","color":"#324ca0","feature_id":"lanis-2-0","coordinates":[-5.08061726461315,54.408690786113524]},"geometry":{"type":"MultiLineString","coordinates":[[[-4.691422440612409,54.222445613689885],[-4.791422369771537,54.28083898900154],[-5.384291949777194,54.54294403514443],[-5.484341878900849,54.54294403514443]]]}},{"type":"Feature","properties":{"id":"latvia-sweden-1-lv-se-1","name":"Latvia-Sweden 1 (LV-SE 1)","color":"#43b549","feature_id":"latvia-sweden-1-lv-se-1-0","coordinates":[19.65951853992938,58.019126320817584]},"geometry":{"type":"MultiLineString","coordinates":[[[21.570078955493642,57.389720408424935],[21.15005925303935,57.45103605396468],[19.57506036878345,58.05131589106027],[18.337561244843435,58.40671668748663],[18.00006148452737,58.758568944882946],[17.946541522441517,58.903099072193214]]]}},{"type":"Feature","properties":{"id":"lfon-libyan-fiber-optic-network","name":"LFON (Libyan Fiber Optic Network)","color":"#36af8a","feature_id":"lfon-libyan-fiber-optic-network-0","coordinates":[18.627869380450015,30.686408759572995]},"geometry":{"type":"MultiLineString","coordinates":[[[12.083365675970331,32.933546411839394],[12.37506546932735,33.001218522654476],[12.529855359672913,32.77488568686176],[12.642240573027069,32.96372252942492],[13.05006499115128,33.001218522654476],[13.187364893886881,32.87762290319534],[13.500064672367355,33.001218522654476],[14.175064194191284,32.8123187832876],[14.264514130824159,32.6499893709631],[14.400064034799321,32.8123187832876],[14.850063716015397,32.8123187832876],[15.075063556623434,32.62301664000789],[15.09498354251198,32.37460980808494],[15.30006339723147,32.33831157801293],[16.425062600271474,31.478822672736147],[16.588442484531583,31.20558735149696],[16.875062281487367,31.286738814391754],[17.55006180331148,31.286738814391754],[18.22506132513541,30.901396088515508],[18.411971192726792,30.586989976235927],[18.675061006351484,30.708139993541643],[19.350060528175415,30.708139993541643],[19.57652036774916,30.378447643853725],[19.57506036878345,30.708139993541643],[19.80006020939149,31.86180860227073],[20.066760020458908,32.116913544436095],[20.025060049999343,32.43331330641721],[20.250059890607382,32.7177179367584],[20.700059571823456,32.8123187832876],[20.94995939479222,32.71697755177159],[21.15005925303935,33.001218522654476],[21.600058934255422,33.001218522654476],[21.74175883387393,32.88214108067166],[22.050058615471496,33.001218522654476],[22.50005829668739,33.001218522654476],[22.639118198176227,32.76363502668908],[22.950057977903466,32.8123187832876],[23.850057340335432,32.43331330641721],[23.960407262162704,32.07985133144836]]]}},{"type":"Feature","properties":{"id":"libreville-port-gentil-cable","name":"Libreville-Port Gentil Cable","color":"#48c2c9","feature_id":"libreville-port-gentil-cable-0","coordinates":[8.909768729244552,-0.0033774997582103725]},"geometry":{"type":"MultiLineString","coordinates":[[[8.781608014966428,-0.720651899991483],[8.775049269612806,-0.331409329660265],[9.020377845819665,0.265947444189533],[9.454267538448212,0.394465191855477]]]}},{"type":"Feature","properties":{"id":"lower-indian-ocean-network-lion","name":"Lower Indian Ocean Network (LION)","color":"#c64d2b","feature_id":"lower-indian-ocean-network-lion-0","coordinates":[53.697537671430275,-19.685595665633734]},"geometry":{"type":"MultiLineString","coordinates":[[[57.51009349525022,-20.077536205633212],[56.70003406910378,-20.152543786018732],[55.80003470667163,-20.46905848545731],[55.575034866063774,-20.574419057276128],[55.54778488536792,-20.89726651520503],[55.35003502545574,-20.574419057276128],[52.20003725694376,-18.880139975101173],[49.40023924034702,-18.146086570227464]]]}},{"type":"Feature","properties":{"id":"lower-indian-ocean-network-2-lion2","name":"Lower Indian Ocean Network 2 (LION2)","color":"#973a95","feature_id":"lower-indian-ocean-network-2-lion2-0","coordinates":[46.24740801883432,-11.943944931746815]},"geometry":{"type":"MultiLineString","coordinates":[[[39.700146111983855,-4.050300939496507],[42.30004427019158,-5.049857167366764],[43.20004363262354,-5.721872747834119],[45.450042038703735,-11.943944931746815],[47.25004076356767,-11.943944931746815],[48.60003980721571,-11.503333845984299],[49.50003916964768,-11.503333845984299],[50.40003853207964,-12.383840433185572],[51.75003757572769,-17.168553094226155],[52.20003725694376,-18.880139975101173]],[[45.450042038703735,-11.943944931746815],[45.16576224009028,-12.817096445533013]]]}},{"type":"Feature","properties":{"id":"mainone","name":"MainOne","color":"#603f98","feature_id":"mainone-0","coordinates":[-19.280078359401543,10.541976821281889]},"geometry":{"type":"MultiLineString","coordinates":[[[-9.107439312264662,38.642658330346336],[-9.449919069648717,38.21117903702318],[-9.787418830560773,37.23235432155614],[-10.124918591472738,35.78566189952622],[-10.574918272688812,33.93964008831966],[-13.949915881808826,29.73606949729215],[-14.399915563024809,28.95155473219332],[-14.624915403632755,28.161052262220792],[-15.074915084848831,26.964304734562898],[-18.449912693968844,22.884654113882444],[-19.799911737616885,19.95262290516439],[-19.804120576691208,15.23578178303578],[-19.799911737616885,11.735650161405832],[-18.449912693968844,8.635699417327467],[-13.949915881808826,3.266814816815666],[-10.799918113296759,0.118588418888312],[-3.599923213840749,0.118588418888312],[0.000074235887302,0.806604849908682],[1.575073120143199,1.918228780215599],[2.25007264196731,2.480311786858737],[3.150072004399278,4.164912849976942],[3.423511810692114,6.439066911484626]],[[-0.204315619320974,5.558285889905858],[0.000074235887302,3.279837005484997],[0.000074235887302,0.806604849908682]],[[-19.803965545293075,15.23578178303578],[-18.449912693968844,15.23578178303578],[-17.445713405352947,14.686594841994992]],[[-3.599923213840749,0.118588418888312],[-3.924922983607823,1.468426767331968],[-4.049922895056732,2.367912558705407],[-4.049922895056732,3.266814816815666],[-4.026242911831877,5.323508791824841]]]}},{"type":"Feature","properties":{"id":"malaysia-cambodia-thailand-mct-cable","name":"Malaysia-Cambodia-Thailand (MCT) Cable","color":"#b7922e","feature_id":"malaysia-cambodia-thailand-mct-cable-0","coordinates":[103.64806477790879,7.308585029265784]},"geometry":{"type":"MultiLineString","coordinates":[[[103.50674091079348,10.63040170321047],[103.61250083587218,9.52441134501949],[103.50000091556807,8.190543417795496],[103.95000059678415,5.510071711803246],[103.72500075617612,4.613591578862773],[103.39521098980225,4.116310078259609]],[[103.50000091556807,8.190543417795496],[101.92500203131218,9.52441134501949],[101.25000250948806,11.294709319565477],[101.13750258918414,12.175887185507976],[101.27734249012036,12.670662156670582]]]}},{"type":"Feature","properties":{"id":"mariana-guam-cable","name":"Mariana-Guam Cable","color":"#5e479c","feature_id":"mariana-guam-cable-0","coordinates":[145.40516004909406,14.362316164146604]},"geometry":{"type":"MultiLineString","coordinates":[[[145.75114098446412,15.178201201864436],[145.79997094987252,15.070969022342206],[145.63747106498886,15.011508499399486],[145.68747102956843,14.801154224791581],[145.40822122739166,14.365653759228442],[145.21247136606274,14.152228172439347],[145.1249714280484,13.92930384327183],[145.0124715077445,13.710817738179635],[144.809541651502,13.549094363148988]]]}},{"type":"Feature","properties":{"id":"mataram-kupang-cable-system-mkcs","name":"Mataram Kupang Cable System (MKCS)","color":"#d4254a","feature_id":"mataram-kupang-cable-system-mkcs-0","coordinates":[121.44644788312534,-9.068306003874412]},"geometry":{"type":"MultiLineString","coordinates":[[[123.58338668830928,-10.182939736570859],[123.29998688907226,-10.011319800856482],[122.84998720726047,-9.918984265715721],[121.72498800422028,-9.179382545871277],[121.6427380624869,-8.845694469062058],[121.49998816420832,-9.068306003874412],[120.59998880177636,-9.068306003874412],[119.24998975812831,-9.068306003874412],[118.71181013937976,-8.796444010194145]],[[120.25301904757286,-9.645765890160455],[120.59998880177636,-9.29042430103552],[120.59998880177636,-9.068306003874412]],[[116.65492159590312,-8.537099969976568],[116.54999167023632,-8.252720521974979],[116.54999167023632,-8.029988442955133],[116.77499151084437,-7.807134147544001],[117.22499119265616,-7.732822794391767],[118.23749047539224,-7.732822794391767],[118.5749902363042,-7.955717094334652],[118.76036010439051,-8.319317925115401],[118.77186509624045,-8.391864542982665],[118.74490011534256,-8.46426902855707]],[[118.0822205847911,-8.84114915636186],[117.89999071388438,-8.5865822751457],[117.56249095297241,-8.364039699657951],[117.33749111236438,-8.178490278944933],[117.22499119265616,-7.732822794391767]]]}},{"type":"Feature","properties":{"id":"matrix-cable-system","name":"Matrix Cable System","color":"#b0c335","feature_id":"matrix-cable-system-0","coordinates":[107.11651447578886,-2.275052062007308]},"geometry":{"type":"MultiLineString","coordinates":[[[106.83339855415794,-6.171588071824116],[106.87499852468808,-5.273944363641298],[107.32499820590417,-4.60145376483711],[107.21349828489204,-3.029995968008661],[107.09799836671304,-2.130918480960333],[106.76249860438416,-0.331409329660265],[105.29999964043219,1.131014326431719]],[[105.29999964043219,1.131014326431719],[104.8499999592161,1.243490076978041],[104.62500011860807,1.229455855593917],[104.19443042362789,1.295159856377344],[103.98701057056589,1.389451396800233]],[[104.0166370000003,1.066798000000349],[104.01992054725226,1.212637187567842],[104.16611044369012,1.230603090362567],[104.28790035741287,1.187552709061783],[104.62500011860807,1.187252773694101],[104.8499999592161,1.131014326431719],[105.29999964043219,1.131014326431719]]]}},{"type":"Feature","properties":{"id":"maya-1-2","name":"Maya-1.2","color":"#934499","feature_id":"maya-1-2-0","coordinates":[-83.4494432417556,23.580227942199585]},"geometry":{"type":"MultiLineString","coordinates":[[[-84.82486567332928,19.104405475930452],[-81.16676076149955,19.28295591266266]],[[-87.9461557876504,15.844981598742601],[-87.29986392001751,16.534196198259725],[-85.49986519515349,18.251816319028222],[-84.82486567332928,19.104405475930452]],[[-80.16016897784432,26.010548668010795],[-79.64986933934534,25.348717422116714],[-79.87486917995338,24.73717827217609],[-80.99986838299347,23.7112581424843],[-83.2498667890733,23.7112581424843],[-84.82486567332928,22.677206196582915],[-85.27486535454535,21.635297384859552],[-85.4998651951533,20.375041253465433],[-84.82486567332928,19.104405475930452]]]}},{"type":"Feature","properties":{"id":"med-cable-network","name":"Med Cable Network","color":"#3d80bf","feature_id":"med-cable-network-0","coordinates":[2.922309022542395,36.90995314592755]},"geometry":{"type":"MultiLineString","coordinates":[[[6.300069772911254,38.651811712711336],[5.400070410479286,38.21117903702318],[3.150072004399278,37.05299936423364],[3.035712085413061,36.76212778211003],[2.812572243487313,37.05299936423364],[1.800072960751236,37.05299936423364],[0.000074235887302,36.33133835588799],[-0.642015309250446,35.701641134808355]],[[7.755438741914387,36.90282046530194],[7.650068816559295,37.23235432155614],[6.300069772911254,38.651811712711336],[5.512570330783214,41.74435878948223],[5.372530429989069,43.29362778902908]]]}},{"type":"Feature","properties":{"id":"melita-1","name":"Melita 1","color":"#824c9e","feature_id":"melita-1-0","coordinates":[14.70602567553396,36.31282967547101]},"geometry":{"type":"MultiLineString","coordinates":[[[14.45875399322285,35.934136486264116],[14.625063875407358,36.1498667868178],[14.850063716015397,36.602754740329765],[14.853873713316414,36.733882871591575]]]}},{"type":"Feature","properties":{"id":"mid-atlantic-crossing-mac","name":"Mid-Atlantic Crossing (MAC)","color":"#5bba46","feature_id":"mid-atlantic-crossing-mac-0","coordinates":[-65.34523376495947,19.101212129833563]},"geometry":{"type":"MultiLineString","coordinates":[[[-74.24987316475335,33.565491482352044],[-75.59987220840131,30.901396088515508],[-77.39987093326533,28.95155473219332],[-79.19986965812936,26.964304734562898],[-79.64986933934534,26.36108632539156],[-80.16016897784432,26.010548668010795],[-79.64986933934534,26.260240971577822],[-78.97486981752132,26.964304734562898],[-77.84987061448132,27.663994423747],[-76.94987125204935,27.76358852605777],[-73.3498738023213,26.964304734562898],[-69.29987667137726,24.94136317175375],[-67.04987826529725,22.05298561667754],[-65.92487906225725,20.375041253465433],[-65.24987954043323,18.891661584303154],[-65.13737962012921,18.251816319028222],[-64.81925984548825,17.773909269375704],[-64.5748800186092,18.091482652425203],[-64.23738025769724,18.358623372153332],[-64.23738025769724,18.891661584303154],[-64.59988000089899,20.913116766319394],[-65.24987954043323,24.94136317175375],[-66.59987858408127,28.161052262220792],[-72.44987443988924,38.651811712711336],[-72.91227411232106,40.77352073429003],[-72.67487428049728,38.651811712711336],[-74.24987316475335,33.565491482352044]]]}},{"type":"Feature","properties":{"id":"moratelindo-international-cable-system-1-mic-1","name":"Moratelindo International Cable System-1 (MIC-1)","color":"#344fa2","feature_id":"moratelindo-international-cable-system-1-mic-1-0","coordinates":[103.97984515580926,1.2248739857589248]},"geometry":{"type":"MultiLineString","coordinates":[[[103.98701057056589,1.389451396800233],[103.97812557686022,1.185378176915766],[104.0166370000003,1.066798000000349]]]}},{"type":"Feature","properties":{"id":"northstar","name":"NorthStar","color":"#df2e91","feature_id":"northstar-0","coordinates":[-135.94523095080618,54.365984335263164]},"geometry":{"type":"MultiLineString","coordinates":[[[-134.7472914509898,58.551049268231914],[-134.8954413422964,58.4981259553756],[-134.98381127746194,58.4916930701227],[-135.0388912370513,58.46024329775657],[-135.05266122694871,58.413573683867476],[-135.02922124414596,58.365509963814],[-135.0147712547475,58.30573481193794],[-135.0147712547475,58.23884255426299],[-135.0638912187096,58.18680870274748],[-135.17493000487138,58.1791905533587],[-135.27131106653158,58.199010155391555],[-135.35237100706019,58.222726429171566],[-135.4474909372734,58.28756755123463],[-135.54587086509488,58.329479545806485],[-135.63186080200646,58.357898675160854],[-135.76961070094336,58.36023311803261],[-135.88347061740748,58.34363161367785],[-135.9869905414579,58.3271220669816],[-136.11301044900063,58.30106382103442],[-136.2840603235063,58.28854000125966],[-136.3920702442625,58.274502592624366],[-136.4775601815409,58.240900636965534],[-136.5505601279829,58.198520069813526],[-136.87126989268734,58.14519214129456],[-139.04982725985772,56.840738642145496]],[[-146.35343293589426,61.13035646305128],[-146.55934278482408,61.1091536349257],[-146.6595427113103,61.06746482894028],[-146.80104260749584,60.96589258192077],[-146.82008259352665,60.91922303467996],[-146.85905256493558,60.858765344213495],[-147.06984241028502,60.78766268631922],[-147.32995221944986,60.77870088536695],[-147.53078207210677,60.76640117651843],[-147.68332114380976,60.76501417254222],[-147.7874718837808,60.764633224329316],[-147.88996180858678,60.76589815031962],[-148.02824170713478,60.75652479185255],[-148.2104615734452,60.788165371058966],[-148.32238149133278,60.78737961765462],[-148.38688144401104,60.79100075570164],[-148.47316138070988,60.783470086004485],[-148.51818134767996,60.77741693630959],[-148.68473122548716,60.77302768780248]],[[-148.68473122548716,60.77302768780248],[-148.52126134542036,60.769023622187255],[-148.41354142445135,60.78620827245595],[-148.31398149749563,60.77712889504656],[-148.19748158296832,60.77634287097309],[-148.09962084889904,60.75242094849553],[-148.08273166715708,60.72557180082256],[-148.06081168323905,60.69119678436089],[-148.05646168643062,60.65876959082504],[-147.875701819049,60.60642348854199],[-147.6338519964873,60.60251643847571],[-147.5057320904852,60.574485694378566],[-147.49523209818878,60.4850257284821],[-147.56803204477748,60.31180235217173],[-147.64054199157903,60.251238005321],[-147.76252108770373,60.11873415270099],[-147.78878188281965,60.02401418328774],[-147.89425180543932,59.962286469292955],[-148.00929172103787,59.87790332473849],[-148.08131166819882,59.839334333097796],[-147.8248210435698,59.45171731890513],[-145.34982279688185,58.52439396084473],[-139.04982725985772,56.840738642145496],[-138.14982789742575,55.84318584148108],[-135.44982981012976,54.03403825672422],[-132.2998320416177,50.740281893948165],[-127.79983522945767,47.80541217589291],[-125.09983714216159,46.5823550820958],[-124.19983777972962,46.0383951936514],[-123.52483825790569,45.88198490450073],[-122.98980067585488,45.522898824562965]]]}},{"type":"Feature","properties":{"id":"oran-valencia-orval","name":"Oran-Valencia (ORVAL)","color":"#2cb56f","feature_id":"oran-valencia-orval-0","coordinates":[0.7108684280158478,37.75874153644595]},"geometry":{"type":"MultiLineString","coordinates":[[[-0.376975497007027,39.459646713436726],[0.450073917103194,39.17701458492114],[0.900073598319269,38.651811712711336],[0.675073757711232,37.589786573603064],[0.000074235887302,36.51238821239364],[-0.642015309250446,35.701641134808355]],[[0.675073757711232,37.589786573603064],[2.812572243487313,37.14273000200076],[3.035712085413061,36.76212778211003]]]}},{"type":"Feature","properties":{"id":"pacific-caribbean-cable-system-pccs","name":"Pacific Caribbean Cable System (PCCS)","color":"#44b97a","feature_id":"pacific-caribbean-cable-system-pccs-0","coordinates":[-64.74486945319327,20.485860645421145]},"geometry":{"type":"MultiLineString","coordinates":[[[-79.56661939832034,8.950317108800572],[-79.31236957843338,8.190543417795496],[-78.74986997691337,7.29876275445952],[-78.74986997691337,5.061986954416114],[-80.99986838299328,2.367912558705407],[-81.1215619945354,0.553867752345412],[-80.88736846268927,-0.331409329660265],[-80.71613109211354,-0.949727245539768]],[[-81.65563040282981,30.332100867462632],[-80.5498687017773,30.320465424761444],[-79.19986965812936,30.126049846722832],[-73.3498738023213,29.540507745394493],[-69.29987667137726,27.564309487941923],[-66.37487874347323,23.7112581424843],[-64.79987985921724,20.796306105108872],[-64.46238009830519,18.891661584303154],[-64.59715000283296,18.41441211576626],[-64.34988017800117,18.305228078976267],[-64.23738025769724,18.251816319028222],[-64.23738025769724,17.82393441253792],[-66.14987890286528,16.10232559580297],[-69.74987635259325,13.92930384327183],[-71.54987507745727,13.92930384327183],[-74.24987316475335,12.615395567393394],[-76.0498718896173,10.85308969074528],[-78.29987029569729,10.85308969074528],[-79.31236957843338,9.967915186974132],[-79.7534792659471,9.437721984870015]],[[-70.04992614003515,12.616917900441425],[-69.29987667137726,12.615395567393394],[-68.9623769104653,12.395734000022975],[-68.95697691429066,12.168849063090073]],[[-70.04992614003515,12.616917900441425],[-69.74987635259325,13.92930384327183]],[[-76.0498718896173,10.85308969074528],[-75.50573227509088,10.38680745163333]],[[-66.10666893347558,18.46610423294742],[-65.69987922164921,18.572039052566783],[-64.83573983381369,18.521952917911317],[-64.59715000283296,18.41441211576626]]]}},{"type":"Feature","properties":{"id":"pacific-crossing-1-pc-1","name":"Pacific Crossing-1 (PC-1)","color":"#05a5d7","feature_id":"pacific-crossing-1-pc-1-0","coordinates":[-126.73328748926824,39.94054027582586]},"geometry":{"type":"MultiLineString","coordinates":[[[179.99992719256971,45.3310537658534],[172.79992073307184,45.3310537658534],[160.19996074878455,42.07923561816413],[143.09997286257644,34.12610104005753],[138.59997605041642,33.001218522654476],[137.69997668798445,33.47169957086474],[136.87399727311598,34.33682825203173],[137.69997668798445,33.75276987113061],[138.59997605041642,33.565491482352044],[140.84997445649643,34.31215165223547],[141.52497397832056,35.05222991093673],[141.52497397832056,35.78566189952622],[140.6124746247436,36.383483735312474],[142.19997350014447,36.1498667868178],[149.39996839960057,39.6983233549332],[160.19996074878455,45.96024524125342],[172.7999518228328,49.000334389463426],[179.99995828233068,49.000334389463426]],[[-179.99979825051412,49.000334389463426],[-151.1998186526899,49.000334389463426],[-138.59982757864174,48.40638249553803],[-125.09983714216186,48.40638249553803],[-124.6498374609456,48.48100994210292],[-123.74983809851364,48.256798568947445],[-122.84983873608158,48.21933401420262],[-122.66605031524853,48.11006090495571],[-122.63793033587935,48.034889597039694],[-122.58168037714837,47.959608461901695],[-122.48324649637513,47.9219300503356],[-122.44105402626471,47.90307887010544],[-122.30218058161331,47.88632320620285],[-122.44105402626471,47.89364735216821],[-122.49731043904814,47.9219300503356],[-122.59574141668266,47.959608461901695],[-122.65198637683804,48.034889597039694],[-122.6801063569178,48.11006090495571],[-122.84983873608158,48.20059144803915],[-123.74983809851364,48.21933401420262],[-124.6498374609456,48.40638249553803],[-125.09983714216186,48.10677570919628],[-127.34983554824169,42.743713464436695],[-126.44983618580963,38.651811712711336],[-122.84983873608158,35.602930322906126],[-120.62152181466479,35.12094936772415],[-122.84983873608158,35.419780517080355],[-129.5998339543217,38.651811712711336],[-138.5998275786419,42.743713464436695],[-151.19983391146832,45.32832917028219],[-179.99981350929238,45.32832917028219]]]}},{"type":"Feature","properties":{"id":"palawan-iloilo-cable-system","name":"Palawan-Iloilo Cable System","color":"#4db74a","feature_id":"palawan-iloilo-cable-system-0","coordinates":[120.74805537239742,10.999877242154085]},"geometry":{"type":"MultiLineString","coordinates":[[[119.50037958074992,10.820000490489418],[120.14998912056028,10.85308969074528],[121.04998848299225,11.073982781226615],[121.49998816420832,10.85308969074528],[121.94129785158043,10.749301721782881]]]}},{"type":"Feature","properties":{"id":"pan-american-crossing-pac","name":"Pan-American Crossing (PAC)","color":"#36a9b7","feature_id":"pan-american-crossing-pac-0","coordinates":[-102.8012887970355,16.23046473604046]},"geometry":{"type":"MultiLineString","coordinates":[[[-79.54654941253821,8.934106573765973],[-79.76236925964936,8.190543417795496],[-79.64986933934534,7.29876275445952],[-80.09986902056141,6.852191098754328],[-80.99986838299347,6.852191098754328],[-85.04986551393732,7.744889052551447],[-87.29986392001733,9.52441134501949],[-98.09985626920157,14.365653759228442],[-102.5998530813615,16.10232559580297],[-106.64985021230544,18.678647022154717],[-107.99984925595349,20.375041253465433],[-111.59984670568154,22.469443964829516],[-116.09984351784173,27.76358852605777],[-118.79984160513764,31.286738814391754],[-120.59984033000175,32.8123187832876],[-121.04984001121782,34.31215165223547],[-120.62152181466479,35.12094936772415]],[[-106.42187223254336,23.199717218127276],[-107.54984957473768,22.05298561667754],[-107.99984925595349,20.375041253465433]],[[-118.79984160513764,31.286738814391754],[-117.44984256148977,32.43331330641721],[-117.03822444362804,32.5310141805925]],[[-85.04986551393732,7.744889052551447],[-84.59986583272143,8.635699417327467],[-84.4522083510602,9.525851215144279]]]}},{"type":"Feature","properties":{"id":"penbal-5","name":"Penbal-5","color":"#a82990","feature_id":"penbal-5-0","coordinates":[1.868359990621193,39.8335274431769]},"geometry":{"type":"MultiLineString","coordinates":[[[2.001342818169891,41.30436622489069],[1.800072960751236,40.72920412488655],[1.800072960751236,40.04369219283004],[2.025072801359273,39.35121757117122],[2.475072482575348,39.17701458492114],[2.970972131275422,39.35484412819336]]]}},{"type":"Feature","properties":{"id":"pencan-8","name":"Pencan-8","color":"#a52d5f","feature_id":"pencan-8-0","coordinates":[-11.862998867675099,33.223770256451914]},"geometry":{"type":"MultiLineString","coordinates":[[[-6.087131451879048,36.27672346192373],[-6.749920982352725,36.24065523321488],[-8.54991970721675,35.876870570092834],[-9.449919069648717,35.419780517080355],[-11.249917794512742,33.93964008831966],[-14.849915244240792,29.73606949729215],[-15.187415005152758,28.95155473219332],[-16.03116440743276,28.441436356005738],[-16.371744166162888,28.355537519986957]]]}},{"type":"Feature","properties":{"id":"persona","name":"Persona","color":"#8fac3c","feature_id":"persona-0","coordinates":[-59.26685070080062,46.764182420836406]},"geometry":{"type":"MultiLineString","coordinates":[[[-58.686504189989854,47.61768369957318],[-59.174883844017224,47.19740739556967],[-59.6248835252332,46.5823550820958],[-60.135383163590475,46.2514557753114]],[[-60.135383163590475,46.2514557753114],[-59.399883684625166,46.5823550820958],[-58.94988400340918,47.19740739556967],[-58.686504189989854,47.61768369957318]]]}},{"type":"Feature","properties":{"id":"picot-1","name":"Picot-1","color":"#c83994","feature_id":"picot-1-0","coordinates":[166.26398838974941,-20.98014509134995]},"geometry":{"type":"MultiLineString","coordinates":[[[165.34155710642966,-20.945613989738906],[165.85776674074188,-21.024574704206884],[166.16186652531465,-20.97707442993219],[166.68042615796216,-20.99266679172066],[166.98120594488694,-20.848996911268983],[167.15151582423795,-20.780630532391687]],[[166.48901629355882,-20.705648050304482],[166.46636630960435,-20.827183610422622],[166.16186652531465,-20.97707442993219]]]}},{"type":"Feature","properties":{"id":"pipe-pacific-cable-1-ppc-1","name":"PIPE Pacific Cable-1 (PPC-1)","color":"#8ac53f","feature_id":"pipe-pacific-cable-1-ppc-1-0","coordinates":[153.65779191562947,-9.050310915716352]},"geometry":{"type":"MultiLineString","coordinates":[[[151.20699711948467,-33.86955173177822],[152.09996648689665,-33.27363077142247],[154.02056155078103,-31.85146566557725],[154.79996457419256,-28.743810281149894],[154.79996457419256,-25.540896076259312],[154.3499648929765,-18.880139975101173],[153.8999652117606,-15.441023659568087],[154.79996457419256,-11.943944931746815],[154.79996457419256,-10.17745743036107],[152.99996584932845,-8.401139048122838],[149.39996839960057,-7.063446338991064],[148.04996935595253,-6.225371753845629],[147.59996967473646,-5.777839699209677],[147.31871987397665,-5.273944363641298],[146.90776526532372,-4.838945214956163],[145.89997087903166,-2.3307666592315],[146.65530736297208,1.519276295401345],[146.24997063108842,7.744889052551447],[145.34997126865645,12.175887185507976],[145.1249714280484,12.615395567393394],[144.78747166713646,13.273238157547594],[144.69470173285575,13.464772962370143]],[[145.7847409606618,-5.23368424614946],[146.02497079048055,-5.161910662113067],[146.6749703300147,-5.137011581064615],[146.90776526532372,-4.838945214956163]]]}},{"type":"Feature","properties":{"id":"poseidon","name":"POSEIDON","color":"#a66d2c","feature_id":"poseidon-0","coordinates":[33.05448156025135,34.49779087043369]},"geometry":{"type":"MultiLineString","coordinates":[[[32.466651236259686,34.76657169708598],[32.73755104435154,34.49779087043369],[33.30005064587154,34.49779087043369],[33.61060042587536,34.82728147271538]]]}},{"type":"Feature","properties":{"id":"qatar-u-a-e-submarine-cable-system","name":"Qatar-U.A.E. Submarine Cable System","color":"#d01c59","feature_id":"qatar-u-a-e-submarine-cable-system-0","coordinates":[52.885280159010605,25.14206926382244]},"geometry":{"type":"MultiLineString","coordinates":[[[51.519277739200085,25.294608758024626],[52.20003725694376,25.653336613276053],[52.42024710094511,25.66892130710525],[52.65003693815965,25.450342946923914],[52.86595678520031,25.152632913545087],[54.00003598180769,24.53265756616073],[54.419075684956134,24.443964572625426]]]}},{"type":"Feature","properties":{"id":"fos-quellon-chacabuco","name":"FOS Quellon-Chacabuco","color":"#299bb8","feature_id":"fos-quellon-chacabuco-0","coordinates":[-73.56565434043569,-44.64949313906572]},"geometry":{"type":"MultiLineString","coordinates":[[[-73.4794637105186,-43.11747634819604],[-73.50998368889793,-44.121619058974865],[-73.57072364586915,-44.69756074503905],[-73.63151360280499,-45.13405788619885],[-73.56061365303121,-45.339262517439224],[-73.42174375140804,-45.35326269275853],[-73.28009385175406,-45.28212252426292],[-73.19831390968776,-45.281848113377116],[-73.04762401643794,-45.417940386955635],[-72.80509418824829,-45.45830094135324]]]}},{"type":"Feature","properties":{"id":"russia-japan-cable-network-rjcn","name":"Russia-Japan Cable Network (RJCN)","color":"#eb8c22","feature_id":"russia-japan-cable-network-rjcn-0","coordinates":[137.98067090045404,37.46501436672875]},"geometry":{"type":"MultiLineString","coordinates":[[[132.89127009392124,42.83453574149161],[133.64997955704033,42.329239699665536],[134.77497876008033,41.40772623743595],[137.02497716616034,38.651811712711336],[138.23607630761057,37.147847424552424],[137.24997700676838,38.651811712711336],[134.99997860068837,41.40772623743595],[133.64997955704033,42.41235450073586],[132.89127009392124,42.83453574149161]]]}},{"type":"Feature","properties":{"id":"safe","name":"SAFE","color":"#a9499b","feature_id":"safe-0","coordinates":[76.9722034007659,-2.8525966839583687]},"geometry":{"type":"MultiLineString","coordinates":[[[18.445861168718828,-33.72721819637743],[18.00006148452737,-34.11602012163193],[17.775061643919337,-34.85783936223576],[18.450061165743445,-36.3215277599179],[19.80006020939149,-37.401610748143824],[23.40005765911936,-37.401610748143824],[26.10005574641544,-36.3215277599179],[33.30005064587154,-30.30995334464681],[38.70004682046353,-28.743810281149894],[41.40004490775961,-28.743810281149894],[45.450041666655096,-28.743810607354384],[47.25004076356767,-27.95174728521976],[52.65003693815965,-23.08058350574764],[54.00003598180769,-20.995131543025785],[54.45003566302377,-20.574419057276128],[54.90003534423966,-20.36362554441106],[55.80003470667163,-20.36362554441106],[56.70003406910378,-20.574419057276128],[57.15003375031967,-20.574419057276128],[57.485483512684034,-20.473995660946287],[57.60003343153574,-20.679706953509093],[57.825033272143784,-20.995131543025785]],[[54.45003566302377,-20.574419057276128],[54.90003534423966,-20.784921868991052],[55.27923507561119,-21.000051587769306],[55.35003502545574,-20.784921868991052],[55.80003470667163,-20.574419057276128],[56.70003406910378,-20.784921868991052],[57.15003375031967,-20.995131543025785],[57.825033272143784,-20.995131543025785],[58.275032953359855,-20.574419057276128],[60.30003151883183,-18.880139975101173],[63.00002960612773,-14.571726491332546],[65.70002769342382,-11.943944931746815],[72.90002259287992,-8.401139048122838],[75.15002099895992,-6.616650693475355],[76.95001972382386,-3.029995968008661],[77.40001940503993,0.568578852526193],[81.00001685476798,1.468426767331968],[85.500013666928,-1.68111462680301],[90.00001047908802,-0.781332308789108],[92.70000856638391,3.715978119298069],[94.27500745064,4.713260444333049],[95.45328225426326,5.818423043687448],[97.42500521915215,5.733989114150127],[99.67500362523216,5.398081130463647],[100.40982310408316,5.368393581488473]],[[76.2695502058749,9.93838642489319],[76.05002036139189,9.52441134501949],[76.50002004260797,4.164912849976942],[77.40001940503993,0.568578852526193]],[[31.757961738301827,-28.950559666538012],[32.62505112404761,-29.52991296614913],[33.30005064587154,-30.30995334464681]]]}},{"type":"Feature","properties":{"id":"sint-maarten-puerto-rico-network-one-smpr-1","name":"Sint Maarten Puerto Rico Network One (SMPR-1)","color":"#3fb449","feature_id":"sint-maarten-puerto-rico-network-one-smpr-1-0","coordinates":[-64.49579046440812,18.772674867159726]},"geometry":{"type":"MultiLineString","coordinates":[[[-66.01686899709074,18.441839618642867],[-65.69987922164921,18.678647022154717],[-65.24987954043323,18.891661584303154],[-63.89988049678528,18.678647022154717],[-63.1456810310672,18.05968349844699],[-63.115555384550404,18.047704731939064],[-63.07366108208678,18.031045075146537]]]}},{"type":"Feature","properties":{"id":"samoa-american-samoa-sas","name":"Samoa-American Samoa (SAS)","color":"#924d9e","feature_id":"samoa-american-samoa-sas-0","coordinates":[-171.15046441269976,-13.880916628765299]},"geometry":{"type":"MultiLineString","coordinates":[[[-171.76669408292247,-13.83348925575777],[-171.4498043074101,-13.808261464814914],[-170.999804626194,-13.91748446246452],[-170.69570484162125,-14.276544564158804]]]}},{"type":"Feature","properties":{"id":"sat-3wasc","name":"SAT-3/WASC","color":"#04a6c7","feature_id":"sat-3wasc-0","coordinates":[-19.078983202670177,11.113526682380897]},"geometry":{"type":"MultiLineString","coordinates":[[[-9.102749315587026,38.4430794831419],[-9.22491922904077,38.122730108392204],[-9.562418989952736,37.23235432155614],[-9.899918750864702,35.78566189952622],[-10.349918432080775,33.93964008831966],[-12.82491667876882,30.126049846722832],[-13.472831869789587,28.161052262220792],[-14.849915244240792,26.964304734562898],[-17.99991301275286,22.884654113882444],[-19.34991205640081,19.95262290516439],[-19.34991205640081,15.018578573757472],[-19.34991205640081,11.735650161405832],[-17.99991301275286,8.635699417327467],[-13.499916200592752,3.715978119298069],[-10.799918113296759,1.018534216615524],[-3.599923213840749,1.018534216615524],[-0.224925604720645,1.481465914707545],[1.575073120143199,2.592701464601932],[2.25007264196731,2.929808880350098],[2.92507216379124,4.164912849976942],[3.423511810692114,6.439066911484626]],[[2.92507216379124,4.164912849976942],[5.400070410479286,2.817450442654169],[6.300069772911254,2.592701464601932],[7.425068975951258,1.468426767331968],[7.650068816559295,0.34358628488916],[8.10006849777537,-4.825692499217419],[8.550068178991262,-6.616650693475355],[9.675067382031267,-10.620064860363238],[9.450067541423229,-18.026426383713453],[10.80006658507127,-23.49392244589784],[14.400064034799321,-30.30995334464681],[15.9750629190554,-32.61276000573574],[17.55006180331148,-33.64904537742418],[18.445861168718828,-33.72721819637743]],[[-17.445713405352947,14.686594841994992],[-18.449912693968844,15.018578573757472],[-19.34991205640081,15.018578573757472]],[[-4.026242911831877,5.323508791824841],[-3.937422974752714,3.266814816815666],[-3.824923054448695,2.367912558705407],[-3.699923142999785,1.468426767331968],[-3.599923213840749,1.018534216615524]],[[-0.204315619320974,5.558285889905858],[-0.224925604720645,3.279837005484997],[-0.224925604720645,1.481465914707545]],[[9.706417359822684,4.047345511205705],[9.225067700815375,3.266814816815666],[8.10006849777537,2.817450442654169],[6.300069772911254,2.592701464601932]],[[13.372424762788773,-8.776668856663386],[12.600065309935387,-9.014619375760487],[11.700065947503239,-9.512401827330285],[9.675067382031267,-10.620064860363238]],[[-14.849915244240792,26.964304734562898],[-15.862464526941396,27.807911809695806],[-15.862464526941396,27.94275199342137],[-15.699964642057815,27.99976141196043]],[[2.25007264196731,2.929808880350098],[2.025072801359273,3.82823430332105],[2.025072801359273,5.061986954416114],[2.440112507341202,6.356673335458259]],[[7.650068816559295,0.34358628488916],[8.550068178991262,0.34358628488916],[9.454267538448212,0.394465191855477]],[[-10.349918432080775,33.93964008831966],[-8.54991970721675,35.419780517080355],[-7.199920663568708,36.33133835588799],[-6.42888120978034,36.73129423644183]]]}},{"type":"Feature","properties":{"id":"scandinavian-ring-north","name":"Scandinavian Ring North","color":"#79c042","feature_id":"scandinavian-ring-north-0","coordinates":[12.676008213613354,56.0141827832678]},"geometry":{"type":"MultiLineString","coordinates":[[[12.640495281294552,56.07069401721377],[12.668130261717424,56.05730958788612],[12.695765242140661,56.043920511503316],[12.683020251169262,56.02474048982951],[12.670275260198045,56.00555093561895],[12.64176028039837,56.019473115650584],[12.613245300598697,56.03339027842579],[12.626870290946533,56.05204665760568],[12.640495281294552,56.07069401721377]]]}},{"type":"Feature","properties":{"id":"scandinavian-ring-south","name":"Scandinavian Ring South","color":"#84489c","feature_id":"scandinavian-ring-south-0","coordinates":[12.869610233307684,55.57406747813322]},"geometry":{"type":"MultiLineString","coordinates":[[[12.670275260198045,55.593736174976776],[12.825115150508134,55.5896788573804],[12.918815084129971,55.556803647196006],[12.76881519039128,55.55790652931397],[12.670275260198045,55.593736174976776]]]}},{"type":"Feature","properties":{"id":"seabras-1","name":"Seabras-1","color":"#a9812d","feature_id":"seabras-1-0","coordinates":[-39.41007171898076,7.4926203176150326]},"geometry":{"type":"MultiLineString","coordinates":[[[-46.4124928850147,-24.00886839636483],[-44.54989420449707,-25.360305087215615],[-41.399896435985006,-25.540896076259312],[-36.67489978321695,-24.111502734257467],[-31.04972912191171,-18.025284192896695],[-29.249730397047685,-13.697820288632505],[-28.574905521329885,-9.29042430103552],[-30.824903908752983,-4.152767729405749],[-34.199901536529,0.568578852526193],[-39.59989771112098,7.744889052551447],[-48.75732182969582,17.469778422735878],[-61.80549550139919,29.53533695395528],[-65.04599822690268,31.435859958063894],[-65.52234028551544,32.609814615316346],[-68.05679741739422,34.636546883011455],[-73.13341895565995,39.63896955471727],[-74.06286329723282,40.15283384719588]],[[-65.55639941646365,32.63736524178349],[-65.68543019646702,32.41623974317996],[-65.4999949252911,31.977134139206512],[-65.09715278445925,31.895737453957153],[-64.93362657765282,31.938695789532012],[-64.63356460651755,32.158659496218654]]]}},{"type":"Feature","properties":{"id":"seychelles-to-east-africa-system-seas","name":"Seychelles to East Africa System (SEAS)","color":"#bb5235","feature_id":"seychelles-to-east-africa-system-seas-0","coordinates":[47.3606694306057,-5.742847166250923]},"geometry":{"type":"MultiLineString","coordinates":[[[39.269676416932725,-6.823132108349236],[40.500045545327644,-6.840100757612254],[42.75004395140765,-6.616650693475355],[52.20003725694376,-4.825692499217419],[54.00003598180769,-4.60145376483711],[55.44505495814286,-4.617611322442136]]]}},{"type":"Feature","properties":{"id":"shefa-2","name":"SHEFA-2","color":"#ca2026","feature_id":"shefa-2-0","coordinates":[-2.382888265556671,60.19259809540274]},"geometry":{"type":"MultiLineString","coordinates":[[[-2.523653976279908,57.666863027464274],[-2.699923851408691,57.932056586951404],[-2.699923851408691,58.52439396084473],[-2.900023709656091,58.8333702842968],[-2.474924010800744,58.87506831089772],[-1.687424569268697,59.3944892668567],[-1.181174929092641,59.679663707208995],[-1.124924967748694,59.79305890746809],[-1.238544886663268,59.99607269143399],[-1.267374866239842,60.00407766752495],[-1.323654826370558,60.00407766752495],[-1.799924488976633,60.01869762196877],[-2.924923692016728,60.35428947498098],[-6.299921301136742,61.87557078357159],[-6.771840966824436,62.017646249832836]],[[-4.33332269429366,60.33336464881493],[-4.049922895056732,60.24280652634953],[-3.599923213840749,60.24280652634953],[-2.924923692016728,60.35428947498098],[-2.699923851408691,60.40988845578937],[-2.294924138314315,60.4410317796407]]]}},{"type":"Feature","properties":{"id":"silphium","name":"Silphium","color":"#ce1e4c","feature_id":"silphium-0","coordinates":[23.073870143659388,34.58586349997977]},"geometry":{"type":"MultiLineString","coordinates":[[[22.639118198176227,32.76363502668908],[22.950057977903466,33.565491482352044],[23.1750578185115,35.419780517080355],[23.512557579423465,35.740018263868535],[23.737557420031504,35.740018263868535],[24.012167225495322,35.512042558637575]]]}},{"type":"Feature","properties":{"id":"sirius-south","name":"Sirius South","color":"#8f4a9c","feature_id":"sirius-south-0","coordinates":[-4.658602719796309,53.64527816721521]},"geometry":{"type":"MultiLineString","coordinates":[[[-3.050753602877637,53.80897597127547],[-3.599923213840749,53.802143574575446],[-5.624921779312721,53.50209788266426],[-6.248311337697704,53.34812463259513]]]}},{"type":"Feature","properties":{"id":"sirius-north","name":"Sirius North","color":"#208ecb","feature_id":"sirius-north-0","coordinates":[-5.224192910352568,55.0695197378531]},"geometry":{"type":"MultiLineString","coordinates":[[[-5.808121649532141,54.71395525212919],[-5.592111802555592,54.71395525212919],[-4.9499222574887,55.33458061322904],[-4.783462375410428,55.637881810393246]]]}},{"type":"Feature","properties":{"id":"south-america-1-sam-1","name":"South America-1 (SAm-1)","color":"#35ad49","feature_id":"south-america-1-sam-1-0","coordinates":[-44.93080040956221,6.775101791950589]},"geometry":{"type":"MultiLineString","coordinates":[[[-67.49987794651324,18.465364393137126],[-68.06237754803324,18.678647022154717],[-68.43815728182744,18.621371316104735]],[[-71.62043502747198,-33.04554123247811],[-71.99987475867334,-31.85146566557725],[-73.79987348353728,-27.15383128539156],[-72.89987412110531,-19.517593878237122],[-70.30675595809456,-18.473543073651214],[-72.89987412110531,-19.092898581968107],[-75.59987220840131,-18.45381377577717],[-77.84987061448132,-15.441023659568087],[-78.07487045508935,-13.698987269610743],[-77.84987061449132,-12.82299562562977],[-76.87428130559793,-12.278420041799619],[-79.19986965812936,-12.383840433185572],[-81.89986774542525,-11.503333845984299],[-84.14986615150535,-6.616650693475355],[-84.14986615150535,-3.029995968008661],[-86.39986455758554,0.568578852526193],[-90.4498616885295,3.715978119298069],[-91.79986073217735,9.52441134501949],[-91.34986105096155,13.054150695298627],[-90.8222236775613,13.934797333208856]],[[-80.08893155227202,26.350584577319996],[-79.87486917995338,25.348717422116714],[-80.32486886116926,24.73717827217609],[-80.99986838299347,24.12261698700344],[-83.2498667890733,24.12261698700344],[-84.82486567332928,23.09178547692239],[-85.72486503576134,21.635297384859552],[-86.17486471697732,20.375041253465433],[-86.96236415910536,18.251816319028222],[-87.86236352153733,16.534196198259725],[-88.31236320275332,16.10232559580297],[-88.59713531004529,15.727236638721036]],[[-38.50448848711925,-12.96997203534297],[-37.34989930504097,-13.698987269610743],[-36.44989994260901,-13.698987269610743]],[[-66.10666893347558,18.46610423294742],[-66.48737866377725,18.678647022154717],[-67.04987826529725,18.785187974742005],[-67.49987794651324,18.465364393137126],[-67.72487778712127,17.82393441253792],[-70.19987603380923,14.5835116451186],[-71.54987507745727,13.710817738179635],[-74.14709323756358,12.416907726166608],[-74.81237276627336,11.735650161405832],[-74.77975278938153,10.940445615726643]],[[-81.05378084438966,-4.153834937264977],[-81.44986806420927,-3.479268678970064],[-81.44986806420927,-2.580536704984131]],[[-80.91442094663405,-2.272903429731061],[-81.44986806420927,-2.580536704984131],[-84.14986615150535,-3.029995968008661]],[[-44.54989420449707,-24.81691653965546],[-43.42489500145707,-23.905969261790265],[-43.20956515399876,-22.903486555497956]],[[-46.328062944825646,-23.961842897597087],[-45.44989356692904,-25.134186547061336],[-43.64989484206502,-27.95174728521976],[-45.89989324814503,-32.61276000573574],[-50.84988974152112,-35.22626671976625],[-53.99988751003309,-36.3215277599179],[-56.695445600474535,-36.47095527632105]],[[-46.328062944825646,-23.961842897597087],[-44.54989420449707,-24.81691653965546],[-42.74989547963305,-24.72611802920699],[-40.94989675476902,-23.287413403488653],[-39.03739810960098,-21.937383135692397],[-37.34989930504097,-18.026426383713453],[-36.44989994260901,-13.698987269610743],[-33.74990185531301,-8.401139048122838],[-33.74990185531301,-6.616650693475355],[-34.64990121774498,-4.825692499217419],[-35.99990026139302,-4.152767748013638],[-38.542968459859594,-3.718735129291092],[-38.474898508080976,-1.231315750217412],[-39.59989771112098,1.468426767331968],[-46.79989261057708,8.635699417327467],[-52.19988878516916,13.92930384327183],[-57.59988495976114,16.534196198259725],[-59.399883684625166,17.82393441253792],[-62.09988177192116,19.104405475930452],[-65.24987954043323,18.998067525948983],[-65.69987922164921,18.785187974742005],[-65.98107902244469,18.678647022154717],[-66.10666893347558,18.46610423294742],[-66.37487874347323,19.104405475930452],[-66.37487874347323,20.375041253465433],[-67.49987794651324,22.05298561667754],[-69.29987667137726,23.298598065875897],[-73.3498738023213,25.348717422116714],[-76.94987125204935,27.364667993860262],[-77.84987061448132,27.464533937820843],[-79.64986933934534,26.713351447732887],[-80.08893155227202,26.350584577319996]]]}},{"type":"Feature","properties":{"id":"south-american-crossing-sac","name":"South American Crossing (SAC)","color":"#276fac","feature_id":"south-american-crossing-sac-0","coordinates":[-36.76679294468002,-3.394605574712276]},"geometry":{"type":"MultiLineString","coordinates":[[[-71.62043502747198,-33.04554123247811],[-72.44987443988924,-31.85146566557725],[-74.24987316475335,-27.15383128539156],[-78.29987029569729,-15.441023659568087],[-78.29987029569729,-13.698987269610743],[-78.07487045508935,-12.82299562562977],[-76.87428130559793,-12.278420041799619],[-79.19986965812936,-12.163983680780412],[-80.99986838299328,-11.503333845984299],[-83.2498667890733,-6.616650693475355],[-83.2498667890733,-3.029995968008661],[-82.79986710785731,0.568578852526193],[-81.89986774542544,2.367912558705407],[-79.64986933934534,5.061986954416114],[-79.19986965812936,7.29876275445952],[-79.53736941904133,8.190543417795496],[-79.54654941253821,8.934106573765973]],[[-67.0339082766105,10.608129806992471],[-67.38737802620922,11.294709319565477],[-67.72487778712127,12.615395567393394],[-68.84987699016128,13.92930384327183],[-70.19987603380923,15.23578178303578]],[[-56.695445600474535,-36.47095527632105],[-53.99988751003309,-36.140033391295425],[-50.84988974152112,-34.85783936223576],[-46.79989261057708,-32.61276000573574],[-44.54989420449707,-27.95174728521976],[-45.67489340753708,-25.134186547061336],[-46.328062944825646,-23.961842897597087],[-44.99989388571306,-24.316706749469176],[-43.64989484206502,-23.70010845220312],[-43.20956515399876,-22.903486555497956],[-42.29989579841706,-23.803113122349238],[-40.94989675476902,-24.111502734257467],[-37.68739906595303,-23.18403842445051],[-33.74990185531301,-18.026426383713453],[-31.949903130448895,-13.698987269610743],[-30.599904086800947,-9.29042430103552],[-31.837403200816922,-5.161910652822947],[-34.199901536529,-3.816084221750208],[-35.99990026139302,-3.254657364797681],[-38.542968459859594,-3.718735129291092],[-38.69989834868901,-1.231315750217412],[-40.049897392336966,1.468426767331968],[-46.79989261057708,6.852191098754328],[-51.2998894227371,8.635699417327467],[-54.89988687246515,10.85308969074528],[-57.149885278545156,13.054150695298627],[-60.29988304705723,16.534196198259725],[-61.64988209070518,17.395022634700517],[-63.4498808155692,17.82393441253792],[-64.12488033739322,18.038005439608753],[-64.5748800186092,18.038005439608753],[-64.81925984548825,17.773909269375704],[-64.99142972352155,17.83127418857191],[-66.14987890286528,16.965102599435927],[-68.39987730894529,15.886035719079029],[-70.19987603380923,15.23578178303578],[-71.54987507745727,14.5835116451186],[-74.24987316475335,13.273238157547594],[-78.29987029569729,11.515266158038768],[-79.87486917995338,9.967915186974132],[-79.90025916196684,9.353803196949398]],[[-77.04030118798798,3.883678466264191],[-78.29987029569729,4.164912849976942],[-79.64986933934534,5.061986954416114]]]}},{"type":"Feature","properties":{"id":"south-atlantic-cable-system-sacs","name":"South Atlantic Cable System (SACS)","color":"#28ab4a","feature_id":"south-atlantic-cable-system-sacs-0","coordinates":[-13.018183576553122,-7.457062047705449]},"geometry":{"type":"MultiLineString","coordinates":[[[-38.542968459859594,-3.718735129291092],[-35.99990026139302,-2.805287932307917],[-34.199901536529,-2.580536704984131],[-29.812404616687857,-3.142332683643806],[-19.799911737616885,-6.616650693475355],[-5.399921938704683,-8.401139048122838],[7.200069135343221,-9.068306003874412],[11.700065947503239,-9.068306003874412],[12.600065309935387,-9.198513131358409],[13.201440382068753,-9.49008098880628]]]}},{"type":"Feature","properties":{"id":"southern-cross-cable-network-sccn","name":"Southern Cross Cable Network (SCCN)","color":"#2f85a6","feature_id":"southern-cross-cable-network-sccn-0","coordinates":[-161.29002940426548,9.817948446509446]},"geometry":{"type":"MultiLineString","coordinates":[[[174.76792042870525,-36.78780986154051],[175.04990772894308,-36.59297842795038],[175.04990772894308,-36.140033391295425],[175.49990741015898,-35.77578304431546],[178.64990517867093,-36.140033391295425],[179.99990422231897,-35.59302880961419]],[[151.19625712709274,-33.913571605570375],[152.09996648689665,-34.39497520616068],[155.69996393662453,-35.22626671976625],[159.29996138635258,-35.77578304431546],[167.39995564824065,-36.3215277599179],[172.7999518228328,-36.68325067019043],[174.62339053109176,-36.78757761230096]],[[179.9999603175593,-17.383402005942457],[178.8749611145193,-18.028803650699082],[178.43744782917764,-18.123810943537187],[178.0874480765248,-18.880139975101173],[173.69996478053517,-23.905969261790265],[166.49996988107907,-27.15383128539156],[160.64997402527118,-31.083834718767243],[154.79996457419256,-33.00992295652956],[152.0920653907799,-34.11735567246567],[151.591830457126,-33.96305435950763],[151.273987072028,-33.76116106060912]],[[-179.99979825051412,-35.59302880961419],[-175.49980143835413,-34.11602012163193],[-172.79980335105813,-25.540896076259312],[-169.19980590133008,-15.441023659568087],[-168.29980653889803,-10.17745743036107],[-163.799809726738,2.367912558705407],[-160.19981227700995,13.054150695298627],[-157.49981418971396,18.678647022154717],[-156.48731490757396,19.740987365524937],[-155.85635596387024,20.003739828700425],[-155.92481530545814,20.269544035929588],[-155.69981546484993,20.58581909604039],[-152.99981737755394,23.298598065875897],[-147.6030982105313,26.564516489168216],[-138.60310458621126,28.1630268819071],[-127.79983522945767,31.670513047087127],[-122.39983905486586,34.49779087043369],[-120.8472016490899,35.367078251717096]],[[-122.98980067585488,45.522898824562965],[-123.52483825790569,45.823217206338505],[-124.19983777972962,45.862402571602836],[-125.09983714216159,45.331071073324864],[-128.69983459188964,42.743713464436695],[-138.60310458621126,35.78747880468752],[-147.6030982105313,31.288652857283093],[-152.99981737755394,26.562513149236715],[-157.94981387092994,22.90768438285807],[-158.51231347244993,22.05298561667754],[-158.51231237260097,21.73983373091106],[-158.51231346392387,21.635297384859552],[-158.13057429534098,21.35406896575391],[-158.73731331305797,20.796306105108872],[-159.97481243640226,18.678647022154717],[-162.89981036430603,14.801154224791581],[-166.499807814034,10.41081650540272],[-171.89980398862608,2.367912558705407],[-176.39980080078607,-6.616650693475355],[-179.0997988904665,-15.006817032918805],[-179.54979856929816,-16.953454989809906],[-179.99979825051412,-17.383402005942457]],[[-155.8220359890499,20.0232931385683],[-156.14981514606572,20.269544035929588],[-156.89688520046363,20.45695112375976],[-157.34732486998877,20.5096446349069],[-157.85851393560765,20.89386327374593],[-158.13057429534098,21.35406896575391]]]}},{"type":"Feature","properties":{"id":"st-thomas-st-croix-system","name":"St. Thomas-St. Croix System","color":"#ba742d","feature_id":"st-thomas-st-croix-system-0","coordinates":[-64.84149598647817,17.80134199888686]},"geometry":{"type":"MultiLineString","coordinates":[[[-64.99386972179303,18.32460417009705],[-64.98456972838113,18.333503533793607],[-64.97526973496932,18.342402439472306],[-64.96164474462148,18.339065996785507],[-64.94801975427346,18.335729489696146],[-64.94592475575756,18.33197524897088],[-64.94382975724173,18.328220926737288],[-64.95080975229708,18.320574387618265],[-64.95778974735234,18.312927510584466],[-64.92753976878178,18.3027738725405],[-64.89728979021113,18.292619639280012],[-64.87021480939131,18.307544590090053],[-64.84313982857148,18.322468254678206]],[[-64.84313982857148,18.322468254678206],[-64.68454994091798,17.782279507518794],[-64.70319992770621,17.746662811447646],[-64.70319992770621,17.782279507518794],[-64.72174991456521,17.80134199888686],[-64.90007978823466,17.80134199888686],[-64.90007978823466,17.748053328852414],[-64.88146980141806,17.712429822041987],[-64.91862977509366,17.748053328852414],[-64.99386972179303,18.32460417009705]]]}},{"type":"Feature","properties":{"id":"subcan-link-1","name":"Subcan Link 1","color":"#92c83d","feature_id":"subcan-link-1-0","coordinates":[-15.492297959982858,28.386459207827542]},"geometry":{"type":"MultiLineString","coordinates":[[[-16.36231417284318,28.37762364282845],[-15.288094933830214,28.38853301873347],[-15.180095010338338,28.38853301873347],[-15.180095010338338,28.060358799524476],[-15.40875484835354,28.040969582855265]]]}},{"type":"Feature","properties":{"id":"subcan-link-2","name":"Subcan Link 2","color":"#8dc63e","feature_id":"subcan-link-2-0","coordinates":[-15.33872947909735,28.479089831322458]},"geometry":{"type":"MultiLineString","coordinates":[[[-16.36231417284318,28.37762364282845],[-15.288094933830214,28.484109146622668],[-15.072085086853575,28.484109146622668],[-15.072085086853575,28.060358799524476],[-15.40875484835354,28.040969582855265]]]}},{"type":"Feature","properties":{"id":"suriname-guyana-submarine-cable-system-sg-scs","name":"Suriname-Guyana Submarine Cable System (SG-SCS)","color":"#63bb45","feature_id":"suriname-guyana-submarine-cable-system-sg-scs-0","coordinates":[-58.380112289273406,9.029604481528242]},"geometry":{"type":"MultiLineString","coordinates":[[[-58.154864566608346,6.804293299288684],[-57.71238488006516,7.29876275445952],[-57.374885119153106,8.190543417795496]],[[-56.37537582721494,5.886620767353748],[-56.36238583641721,6.405200795356032],[-56.474885756721136,7.29876275445952],[-57.374885119153106,8.190543417795496],[-60.29988304705723,10.632033208117836],[-60.74988272827321,10.85308969074528],[-60.974882568881156,10.963556857789316],[-61.42488225009723,10.85308969074528],[-61.65081209004632,10.686261032786325]]]}},{"type":"Feature","properties":{"id":"svalbard-undersea-cable-system","name":"Svalbard Undersea Cable System","color":"#944c9d","feature_id":"svalbard-undersea-cable-system-0","coordinates":[16.713776718677188,69.2557616582708]},"geometry":{"type":"MultiLineString","coordinates":[[[15.642983075583015,78.21811207261818],[14.42937335472244,78.13693925913735],[13.507532008541482,78.15076892267464],[12.76375065562616,78.04851931491818],[11.700065947503239,77.07068952519339],[12.600065309935387,73.42211510500317],[16.20006275966344,69.73724550802271],[16.875062281487367,69.10459505606217],[16.875062281487367,68.8625186415942],[16.553187508910593,68.73431026184308],[16.987562201791476,68.8625186415942],[17.100062122095405,69.10459505606217],[16.65006244087933,69.73724550802271],[13.05006499115128,73.42211510500317],[12.150065628719313,77.07068952519339],[13.05006499115128,78.03993864842269],[13.525563071642315,78.12018490851229],[14.452259920314427,78.1154120692674],[15.642983075583015,78.21811207261818]]]}},{"type":"Feature","properties":{"id":"sweden-estonia-ee-s-1","name":"Sweden-Estonia (EE-S 1)","color":"#e23825","feature_id":"sweden-estonia-ee-s-1-0","coordinates":[21.772402244209484,59.133557347737586]},"geometry":{"type":"MultiLineString","coordinates":[[[24.752496701038964,59.43639985926234],[24.412556941855435,59.50884868221247],[23.850057340335432,59.45171731890513],[23.1750578185115,59.222223914844314],[22.736388129269244,59.000082835046605],[22.050058615471496,59.106894957190725],[20.250059890607382,59.27974267096037],[18.900060846959338,59.27974267096037],[18.688360996929806,59.292766765691574]]]}},{"type":"Feature","properties":{"id":"taino-carib","name":"Taino-Carib","color":"#44bb90","feature_id":"taino-carib-0","coordinates":[-65.5099799574482,18.442367991692414]},"geometry":{"type":"MultiLineString","coordinates":[[[-66.08278895039234,18.453602408781496],[-66.030607739014,18.44429123321624],[-66.01686899709074,18.441839618642867],[-65.69987922164921,18.465364393137126],[-64.93708976201641,18.372992194090898]]]}},{"type":"Feature","properties":{"id":"taiwan-strait-express-1-tse-1","name":"Taiwan Strait Express-1 (TSE-1)","color":"#63b545","feature_id":"taiwan-strait-express-1-tse-1-0","coordinates":[120.58041644458821,25.620763286564546]},"geometry":{"type":"MultiLineString","coordinates":[[[119.60781373392035,25.780770721177067],[119.92498927876045,25.805355685864413],[120.82498864178831,25.55188275942587],[121.27498832419637,25.348717422116714],[121.46258819070279,25.181712818924378]]]}},{"type":"Feature","properties":{"id":"exelera-north","name":"Exelera North","color":"#d41577","feature_id":"exelera-north-0","coordinates":[33.555646127767034,33.53991473376938]},"geometry":{"type":"MultiLineString","coordinates":[[[32.466651236259686,34.76657169708598],[32.62505112404761,34.49779087043369],[33.52505048647958,33.565491482352044],[34.200050008303506,33.001218522654476],[34.97190100000024,32.76170000000019]]]}},{"type":"Feature","properties":{"id":"tangerine","name":"Tangerine","color":"#25ae73","feature_id":"tangerine-0","coordinates":[2.1668424991066217,51.235991798271144]},"geometry":{"type":"MultiLineString","coordinates":[[[1.440993215126906,51.35857144425905],[1.800072960751236,51.235991798271144],[2.475072482575348,51.235991798271144],[2.91277217250473,51.2312205646267]]]}},{"type":"Feature","properties":{"id":"tasman-global-access-tga-cable","name":"Tasman Global Access (TGA) Cable","color":"#cc3b95","feature_id":"tasman-global-access-tga-cable-0","coordinates":[162.8033858922768,-36.73645635191931]},"geometry":{"type":"MultiLineString","coordinates":[[[174.87183035509463,-37.8013820822191],[174.59995054769675,-37.758235764746985],[172.7999518228328,-37.401610748143824],[167.39995564824065,-37.04328040742407],[159.29996138635258,-36.50260046108511],[155.69996393662453,-35.59302880961419],[152.09996648689665,-34.48775447869505],[151.2450870925013,-33.737123050977864]]]}},{"type":"Feature","properties":{"id":"tata-tgn-atlantic-south","name":"Tata TGN-Atlantic South","color":"#b86d35","feature_id":"tata-tgn-atlantic-south-0","coordinates":[-38.53944305109249,45.90450350872602]},"geometry":{"type":"MultiLineString","coordinates":[[[-2.974873656631674,51.22244696599781],[-4.049922895056732,51.3766517753536],[-5.399921938704683,51.16550007909214],[-7.199920663568708,51.02419288763878],[-10.799918113296759,50.31116725161073],[-16.199914287888834,49.87814473780419],[-23.399909187344846,49.58728674004685],[-39.59989771112098,45.646541495187385],[-50.39989006030513,42.578254086072846],[-61.19988240948919,41.0693404382162],[-68.39987730894529,40.72920412488655],[-71.09987539624129,40.30157665795881],[-74.06286329723282,40.15283384719588]]]}},{"type":"Feature","properties":{"id":"tata-tgn-gulf","name":"Tata TGN-Gulf","color":"#63bb45","feature_id":"tata-tgn-gulf-0","coordinates":[56.99062106024924,25.017799724127464]},"geometry":{"type":"MultiLineString","coordinates":[[[51.519277739200085,25.294608758024626],[51.975037416335724,26.05828756029904],[52.425037097551616,26.562513149236715]],[[50.57601840741415,26.229494838391265],[51.187537974207686,26.562513149236715],[51.75003757572769,26.964304734562898]],[[55.30853505485483,25.269353998130182],[55.18128514499966,25.55188275942587],[55.23753510515163,25.855985466072205],[55.35003502545574,26.05828756029904]],[[61.87503040308773,18.678647022154717],[61.87503040308773,22.469443964829516],[60.75003119945182,22.884654113882444],[59.62503199581592,22.884654113882444],[59.366662179443516,22.700003992423866],[59.40003215580378,22.884654113882444],[59.17503231400394,23.608214441359472],[58.50003279396771,24.430271928048704],[56.92503390971164,25.043329056612176],[56.33372432860119,25.121690004958644],[56.812533989407704,25.348717422116714],[56.92503390971164,26.1593079707739],[56.756284029255745,26.562513149236715],[56.2500343878877,26.61281470860391],[55.80003470667163,26.260240971577822],[55.35003502545574,26.05828756029904],[53.55003630059162,26.05828756029904],[52.425037097551616,26.562513149236715],[51.75003757572769,26.964304734562898],[51.075038053903754,27.364667993860262],[50.51253845238375,26.964304734562898],[50.287538611775716,26.461843796188983],[50.214198663730556,26.28537535931817]]]}},{"type":"Feature","properties":{"id":"tata-tgn-pacific","name":"Tata TGN-Pacific","color":"#b86637","feature_id":"tata-tgn-pacific-0","coordinates":[-141.98468279027563,47.36074149138619]},"geometry":{"type":"MultiLineString","coordinates":[[[179.99995828233068,47.80541217589291],[172.7999518228328,47.80541217589291],[160.19996074878455,44.694829089578164],[149.39996839960057,39.00237890905839],[143.09997286257644,35.05222991093673],[138.59997605041642,34.032921789964035],[137.69997668798445,34.31215165223547],[137.39157690645783,34.76914289401008],[137.58747676768036,34.31215165223547],[138.59997605041642,24.12261698700344],[139.94997509406446,21.635297384859552],[142.19997350014447,16.965102599435927],[143.32497270318447,15.23578178303578],[144.22497206561644,13.92930384327183],[144.69470173285575,13.464772962370143]],[[179.99995828233068,48.40638249553803],[172.7999518228328,48.40638249553803],[160.19996074878455,45.331071073324864],[149.39996839960057,39.35121757117122],[141.2999741377125,35.05222991093673],[140.3437248151284,35.05222991093673],[140.06110501533894,35.062298417794814]],[[-122.98980067585488,45.522898824562965],[-123.52483825790569,45.862402571602836],[-124.19983777972962,45.97979307748938],[-125.09983714216159,46.19436398821858],[-129.5998339543217,46.5823550820958],[-138.59982757864174,47.80541217589291],[-151.1998186526899,48.40638249553803],[-179.99979825051412,48.40638249553803]],[[-118.24535355799169,34.053396879397056],[-118.79984160513764,33.93964008831966],[-119.2498412869497,33.87739543625145],[-120.59984033000157,33.93964008831966],[-122.3998390548656,34.31215165223547],[-123.29983841789374,35.05222991093673],[-125.54983682337766,38.651811712711336],[-126.44983618580963,42.743713464436695],[-125.09983714216159,45.0138336439531],[-124.19983777972962,45.74476367411526],[-123.52483825790569,45.76438740771229],[-122.98980067585488,45.522898824562965],[-123.52483825790569,45.8428133389289],[-124.19983777972962,45.92112887154667],[-125.09983714216159,46.0383951936514],[-129.5998339543217,46.272182853813646],[-138.59982757864174,47.19740739556967],[-151.19981865269,47.80541217589291],[-179.99979825051412,47.80541217589291]]]}},{"type":"Feature","properties":{"id":"tata-tgn-tata-indicom","name":"Tata TGN-Tata Indicom","color":"#94a139","feature_id":"tata-tgn-tata-indicom-0","coordinates":[92.85399911501484,8.55949483400084]},"geometry":{"type":"MultiLineString","coordinates":[[[103.91973061822782,1.172215972443871],[103.98701057056589,1.389451396800233]],[[80.24298739105474,13.06385310188338],[81.45001653598388,12.175887185507976],[83.70001494206389,10.41081650540272],[89.10001111665605,9.08033076823294],[93.1500082476,8.518425924569478],[95.40000665368001,7.521883237406507],[97.42500521915215,6.5728695185769],[99.2250039440161,5.286069860821008],[100.01250338614413,4.613591578862773],[100.57500298766413,3.266814816815666],[101.25000250948806,2.592701464601932],[102.15000187192003,2.086875539277345],[102.68279723997186,1.726894676446862],[103.34065102845322,1.327793781945009],[103.50000091556807,1.243290124212273],[103.91973061822782,1.172215972443871]]]}},{"type":"Feature","properties":{"id":"tata-tgn-western-europe","name":"Tata TGN-Western Europe","color":"#613e98","feature_id":"tata-tgn-western-europe-0","coordinates":[-9.123172630879672,49.586130053554875]},"geometry":{"type":"MultiLineString","coordinates":[[[-2.949193674823563,43.274220252000646],[-4.049922895056732,44.694829089578164],[-5.849921619920758,46.272182853813646],[-6.974920822960762,48.10677570919628],[-6.974920822960762,50.454639125893955],[-5.399921938704683,51.02419288763878],[-4.049922895056732,51.27119721416558],[-2.974873656631674,51.22244696599781],[-4.049922895056732,51.30637567738274],[-5.399921938704683,51.09490046148111],[-7.199920663568708,50.59767719905356],[-10.799918113296759,48.70423463096067],[-15.299914925456777,44.694829089578164],[-15.299914925456777,39.6983233549332],[-13.949915881808826,39.00237890905839],[-10.799918113296759,38.651811712711336],[-9.107439312264662,38.642658330346336]]]}},{"type":"Feature","properties":{"id":"te-northtgn-eurasiaseacomalexandrosmedex","name":"TE North/TGN-Eurasia/SEACOM/Alexandros/Medex","color":"#3c54a4","feature_id":"te-northtgn-eurasiaseacomalexandrosmedex-0","coordinates":[15.326417266767319,33.977401942308276]},"geometry":{"type":"MultiLineString","coordinates":[[[5.372530429989069,43.29362778902908],[6.075069932303216,41.74435878948223],[7.425068975951258,38.651811712711336],[8.10006849777537,37.94551049545967],[9.000067859611436,37.85673997565852],[10.348617229769278,37.85673997565852],[10.91256650537538,37.50058844605323],[11.700065946311437,37.23235432155614],[11.812565867807347,36.24065523321488],[12.262565549023423,35.78566189952622],[12.656315270092444,35.419780517080355],[14.400064034799321,34.265677526524286],[16.65006244087933,33.565491482352044],[19.350060528175415,33.93964008831966],[22.050058614875596,33.87739543625145],[25.200056383983473,33.14262853088797],[27.900054471279375,32.243210016262736],[29.70093319552029,31.072270031660306]],[[27.900054471279375,32.243210016262736],[30.60005255857546,32.43331330641721],[32.40005128343957,33.189714664600466],[33.41255056617547,34.49779087043369],[33.61060042587536,34.82728147271538]],[[8.10006849777537,37.94551049545967],[7.987568577471261,37.23235432155614],[7.755438741914387,36.90282046530194]]]}},{"type":"Feature","properties":{"id":"telstra-endeavour","name":"Telstra Endeavour","color":"#393d98","feature_id":"telstra-endeavour-0","coordinates":[168.29156866934412,-24.230720295320193]},"geometry":{"type":"MultiLineString","coordinates":[[[-179.99979825051412,-8.401139048122838],[-176.39980080078607,-3.029995968008661],[-172.79980335105813,2.367912558705407],[-167.39980717646606,10.41081650540272],[-163.799809726738,14.801154224791581],[-160.1998122770105,18.678647022154717],[-158.849813233362,20.796306105108872],[-158.3998135521461,21.425997872385402],[-158.24204999203212,21.54876173571662]],[[151.22848710426095,-33.882038650285516],[152.0332142221154,-33.848958880365075],[154.80001816943468,-32.423034994424306],[160.64997402527118,-30.30995334464681],[166.49996988107907,-25.540896076259312],[170.9999666932391,-22.250099679090077],[173.69996478053517,-17.168553094226155],[179.9999603175593,-8.401139048122838]]]}},{"type":"Feature","properties":{"id":"thailand-indonesia-singapore-tis","name":"Thailand-Indonesia-Singapore (TIS)","color":"#79c042","feature_id":"thailand-indonesia-singapore-tis-0","coordinates":[104.3997228574189,5.24143602573144]},"geometry":{"type":"MultiLineString","coordinates":[[[104.0166370000003,1.066798000000349],[104.03350053763215,1.290748482962603],[103.98701057056589,1.389451396800233],[104.19209042528557,1.302655424517022],[104.28790035741287,1.35596103499925],[104.37232529760543,1.468426767331968],[104.65208343275526,2.367912558705407],[104.38596586214548,5.398081130463647],[103.03476681934734,6.893492138282953],[101.70000219070414,7.019705967385337],[100.5951029728293,7.198818071264419]]]}},{"type":"Feature","properties":{"id":"the-east-african-marine-system-teams","name":"The East African Marine System (TEAMS)","color":"#73bf43","feature_id":"the-east-african-marine-system-teams-0","coordinates":[57.17435522397155,8.258620151746948]},"geometry":{"type":"MultiLineString","coordinates":[[[39.672896131288006,-4.052924364763054],[42.30004427019158,-4.60145376483711],[46.57504124174374,-2.35574573664619],[53.10003661937573,1.468426767331968],[56.25003438848378,5.510071711803246],[57.60003343153574,9.52441134501949],[62.10003024369576,15.669513225155248],[63.00002960612773,20.796306105108872],[63.00002960612773,22.469443964829516],[59.85003183761575,24.32780311165363],[58.50003279396771,24.660522249648846],[56.92503390971164,24.915858493558826],[56.33372432860119,25.121690004958644]]]}},{"type":"Feature","properties":{"id":"tobrok-emasaed-cable-system","name":"Tobrok-Emasaed Cable System","color":"#65ac44","feature_id":"tobrok-emasaed-cable-system-0","coordinates":[24.606382917057203,32.1567570757107]},"geometry":{"type":"MultiLineString","coordinates":[[[23.960407262162704,32.07985133144836],[24.300057021551506,32.243210016262736],[24.975056543375437,32.052708023486204],[25.087356463821255,31.761227937714725]]]}},{"type":"Feature","properties":{"id":"tonga-cable","name":"Tonga Cable","color":"#42b549","feature_id":"tonga-cable-0","coordinates":[-177.62282479346842,-20.297109438335415]},"geometry":{"type":"MultiLineString","coordinates":[[[179.9999603175593,-19.305384072361306],[179.09996095512733,-18.66711083815884],[178.43744782917764,-18.123810943537187]],[[-179.99979825051412,-19.305384072361306],[-175.9498011195701,-20.995131543025785],[-175.20000165073512,-21.133465659292966]]]}},{"type":"Feature","properties":{"id":"trans-pacific-express-tpe-cable-system","name":"Trans-Pacific Express (TPE) Cable System","color":"#a2c539","feature_id":"trans-pacific-express-tpe-cable-system-0","coordinates":[-151.72507890815905,46.5823550820958]},"geometry":{"type":"MultiLineString","coordinates":[[[-123.94016937986751,45.64401775142813],[-125.09983714216159,44.85455225626708],[-129.5998339543217,44.05151922873524],[-138.59982757864174,45.646541495187385],[-151.1998186526899,46.5823550820958],[-179.99979825051412,46.5823550820958]],[[134.99997860068837,30.708139993541643],[132.74998019460836,30.320465424761444],[130.49998178852834,30.5144959597591],[124.19998625150441,31.86180860227073],[122.84998720785636,31.957307911004964],[121.94998784542422,31.766210259727007],[121.39529823837174,31.619800328867754]],[[121.40818822924032,31.61896581082966],[122.17498768603225,31.19054975154414],[123.5249867296803,30.126049846722832],[123.74998657028833,28.161052262220792],[121.94998784542422,26.562513149236715],[121.49998816420832,25.75470426341523],[121.46258819070279,25.181712818924378],[121.61248808451225,25.75470426341523],[121.94998784542422,26.36108632539156],[125.26189309097956,27.59034732929116],[130.49998178852834,30.708139993541643]],[[139.97546699999984,35.005433000000174],[140.1046624838865,34.89859296336222],[140.06247501377248,34.69072647741027],[139.94997509406446,34.405022750715936],[139.04997573163232,33.93964008831966],[136.7999773255523,31.86180860227073],[134.99997860068837,31.09426282763951],[132.74998019460836,30.708139993541643]],[[132.74998019460836,30.320465424761444],[132.74998019460836,30.708139993541643],[130.49998178852834,30.708139993541643],[128.92498290427227,31.670513047087127],[127.85028816476637,33.19758857732108],[128.36248330275228,34.31215165223547],[128.62078311977027,34.88072781981967],[127.91248362153638,34.31215165223547],[127.12498417940834,33.189714664600466],[125.99998497636834,32.8123187832876],[124.19998625150441,33.189714664600466],[122.84998720785636,33.93964008831966],[120.8249886423844,35.419780517080355],[120.34246898420574,36.08731090741939],[120.59998880177636,35.419780517080355],[122.39998752664029,33.93964008831966],[123.74998657028833,33.001218522654476],[124.19998625150441,31.86180860227073]],[[134.99997860068837,30.708139993541643],[136.7999773255523,31.478822672736147],[138.59997605041642,32.62301664000789],[140.39997477528055,33.37780603565933],[143.09997286257644,34.49779087043369],[149.39996839960057,37.94551049545967],[160.19996074878455,43.401144973153954],[172.7999518228328,46.5823550820958],[179.99995828233068,46.5823550820958]]]}},{"type":"Feature","properties":{"id":"transworld-tw1","name":"Transworld (TW1)","color":"#61bb46","feature_id":"transworld-tw1-0","coordinates":[61.67200213645002,24.665565595204]},"geometry":{"type":"MultiLineString","coordinates":[[[56.33372432860119,25.121690004958644],[56.92503390971164,25.068807301098893],[58.72503263457575,25.145210227401346],[59.85003265596724,24.941359113142855],[62.55002992491184,24.53265756616073],[67.02854675228855,24.889731701235817]],[[58.1762030233719,23.68487753168473],[58.275082953324564,23.91710129093513],[58.72503263457575,24.225251377401914],[59.85003265596724,24.941359113142855]]]}},{"type":"Feature","properties":{"id":"tt1","name":"TT1","color":"#4abb84","feature_id":"tt1-0","coordinates":[-60.970520635231445,11.021638590788555]},"geometry":{"type":"MultiLineString","coordinates":[[[-60.949592586796825,10.831511900511618],[-60.974882568881156,11.016811865272118],[-60.84026266424723,11.165776379841363]]]}},{"type":"Feature","properties":{"id":"turcyos-1","name":"Turcyos-1","color":"#97b93c","feature_id":"turcyos-1-0","coordinates":[33.03347658614124,35.676931641711754]},"geometry":{"type":"MultiLineString","coordinates":[[[32.96670088142377,36.099997364015636],[32.96255088436367,35.90725015614043],[33.075050804667605,35.54192681258013],[33.23628069104678,35.29912186607703]]]}},{"type":"Feature","properties":{"id":"turcyos-2","name":"Turcyos-2","color":"#81c341","feature_id":"turcyos-2-0","coordinates":[35.01639266681694,35.49639512283798]},"geometry":{"type":"MultiLineString","coordinates":[[[35.9993687336493,36.082340813673596],[35.55004905195155,35.78566189952622],[34.87504953012762,35.419780517080355],[34.200050008303506,35.236213400528236],[33.91945020708308,35.28438909238445]]]}},{"type":"Feature","properties":{"id":"uae-iran","name":"UAE-Iran","color":"#d41f26","feature_id":"uae-iran-0","coordinates":[57.212944337494406,25.153681018757986]},"geometry":{"type":"MultiLineString","coordinates":[[[56.33372432860119,25.121690004958644],[56.92503390971164,25.094280247013153],[57.60364342897847,25.234288823528676],[57.79730329178807,25.681322707882497]]]}},{"type":"Feature","properties":{"id":"ugarit","name":"UGARIT","color":"#267cb5","feature_id":"ugarit-0","coordinates":[34.7527429480767,34.82107849658103]},"geometry":{"type":"MultiLineString","coordinates":[[[35.89779880560243,34.89170328553848],[35.55004905195155,34.867831005273345],[33.97505016769547,34.775476075756664],[33.61060042587536,34.82728147271538]]]}},{"type":"Feature","properties":{"id":"uk-channel-islands-8","name":"UK-Channel Islands-8","color":"#8fc73e","feature_id":"uk-channel-islands-8-0","coordinates":[-3.7568240359353537,49.46667101388998]},"geometry":{"type":"MultiLineString","coordinates":[[[-2.225994187144854,49.21570868235566],[-2.420674049231854,49.2186480515311],[-3.599923213840749,49.44120372312804],[-4.499922576272716,49.58728674004685],[-5.172972099478099,50.02254139117193]]]}},{"type":"Feature","properties":{"id":"unityeac-pacific","name":"Unity/EAC-Pacific","color":"#77459a","feature_id":"unityeac-pacific-0","coordinates":[-148.18220341164763,44.06630066756654]},"geometry":{"type":"MultiLineString","coordinates":[[[139.95485509060742,34.97657002902234],[140.06247501377248,34.96776525378359],[140.3437248151284,34.960082324548345],[141.2999741377125,34.867831005273345],[143.09997286257644,34.867831005273345],[149.39996839960057,36.1498667868178],[160.19996074878455,41.40772623743595],[172.79992073307184,44.694811588752586],[179.99992719256971,44.694811588752586]],[[-118.38802345391501,33.84447040216643],[-120.59984033000157,33.799525734581415],[-122.3998390548656,34.12610104005753],[-129.5998339543217,37.94551049545967],[-138.5998275786419,42.07923561816413],[-151.19983391146832,44.69205655553234],[-179.99981350929238,44.69205655553234]]]}},{"type":"Feature","properties":{"id":"west-africa-cable-system-wacs","name":"West Africa Cable System (WACS)","color":"#394da1","feature_id":"west-africa-cable-system-wacs-0","coordinates":[-6.06677813221715,-0.7813866362255875]},"geometry":{"type":"MultiLineString","coordinates":[[[-16.199914287888834,27.76358852605777],[-15.74991460667276,27.564309487941923],[-15.46866480591276,27.564309487941923],[-15.299914925456777,27.663994423747],[-15.299914925456777,27.813351446514346],[-15.39874485544474,27.959394261046018]],[[-23.521209101414858,14.923035560171673],[-22.499909824912876,15.018578573757472],[-20.24991141883287,15.018578573757472]],[[-4.026242911831877,5.323508791824841],[-4.162422815360751,3.266814816815666],[-4.274922735664679,2.367912558705407],[-4.14992282421577,1.468426767331968],[-3.599923213840749,-0.781386636225587]],[[-0.204315619320974,5.558285889905858],[0.225074076495339,3.279837005484997],[0.225074076495339,0.13163185678509]],[[-9.107439312264662,38.642658330346336],[-9.449919069648717,38.29952060596925],[-11.024917953904795,36.51238821239364],[-11.924917316336764,35.419780517080355],[-13.499916200592752,32.052708023486204],[-14.624915403632755,30.5144959597591],[-15.299914925456777,28.95155473219332],[-16.199914287888834,27.76358852605777],[-17.99991301275286,24.94136317175375],[-18.899912375184826,22.884654113882444],[-20.24991141883287,19.95262290516439],[-20.24991141883287,15.23578178303578],[-20.24991141883287,11.735650161405832],[-18.899912375184826,8.635699417327467],[-14.399915563024809,2.817450442654169],[-10.799918113296759,-0.781386636225587],[-3.599923213840749,-0.781386636225587],[0.225074076495339,0.13163185678509],[1.575073120143199,1.35596103499925],[2.25007264196731,2.03066189047467],[4.500071048047319,0.118588418888312],[6.300069772911254,-1.231315750217412],[7.650068816559295,-4.825692499217419],[8.10006849777537,-6.616650693475355],[9.000067860207336,-10.620064860363238],[8.550068178991262,-18.026426383713453],[9.900067222639304,-23.49392244589784],[13.950064353583429,-30.30995334464681],[15.750063078447363,-32.61276000573574],[16.65006244087933,-33.27363077142247],[18.15553137439108,-33.348058456467676]],[[1.227803366152544,6.126307297218732],[1.46257319924337,5.061986954416114],[3.375071845007315,4.164912849976942]],[[3.423511810692114,6.439066911484626],[3.375071845007315,4.164912849976942],[2.25007264196731,2.03066189047467]],[[4.500071048047319,0.118588418888312],[6.300069772911254,1.693340822791726],[8.10006849777537,2.367912558705407],[9.000067860207336,3.266814816815666],[9.208657712440504,4.014706479784149]],[[10.80006658507127,-5.945707155070644],[11.250066265691444,-5.236602021940458],[11.863635831629022,-4.77878776891936]],[[9.000067860207336,-10.620064860363238],[11.700065947503239,-9.29042430103552],[12.600065309935387,-9.29042430103552],[13.201440382068753,-9.49008098880628]],[[12.349965487108514,-5.933373731471328],[10.80006658507127,-5.945707155070644],[8.10006849777537,-6.616650693475355]],[[14.533463940297649,-22.68542973223072],[13.05006499115128,-22.873434953546333],[9.900067222639304,-23.49392244589784]]]}},{"type":"Feature","properties":{"id":"cross-straits-cable-network-cscn","name":"Cross-Straits Cable Network (CSCN)","color":"#1e92d1","feature_id":"cross-straits-cable-network-cscn-0","coordinates":[118.23949047409472,24.47471752631132]},"geometry":{"type":"MultiLineString","coordinates":[[[118.18905050970763,24.493915539480593],[118.2394904739755,24.474717526356702],[118.28993043824354,24.455516584685828]],[[118.30692042620751,24.476050879560166],[118.30389542835046,24.5163951946322],[118.3008704304934,24.55672655773242]]]}},{"type":"Feature","properties":{"id":"yellow","name":"Yellow","color":"#b99633","feature_id":"yellow-0","coordinates":[-38.70797214670583,44.59606592327159]},"geometry":{"type":"MultiLineString","coordinates":[[[-72.93786409419282,40.75584308487114],[-71.09987539624129,40.12976255393115],[-68.39987730894529,40.38732029077508],[-61.19988240948919,40.38732029077508],[-50.39989006030513,41.576261830098154],[-39.59989771112098,44.37405751055857],[-23.399909187344846,48.40638249553803],[-16.199914287888834,49.29468421942562],[-10.799918113296759,50.022920456254944],[-8.099920026000767,50.31116725161073],[-5.399921938704683,50.811421859282945],[-4.544402544762735,50.82820142743812]]]}},{"type":"Feature","properties":{"id":"baltic-sea-submarine-cable","name":"Baltic Sea Submarine Cable","color":"#2fbfcc","feature_id":"baltic-sea-submarine-cable-0","coordinates":[20.749830524880952,59.55948018659495]},"geometry":{"type":"MultiLineString","coordinates":[[[21.375059093647387,59.5373781389088],[20.250059890607382,59.45171731890513],[18.900060846959338,59.33716441962142],[18.062761440110208,59.33230901818709],[18.900060846959338,59.3944892668567],[20.250059890607382,59.50884868221247],[21.375059093647387,59.62282176941042],[23.850057340335432,59.679663707208995],[24.412556941855435,59.79305890746809],[24.932476573539617,60.171163188940554],[24.91880658322347,60.07486799642317],[24.86255662307151,59.9624316341522],[24.7500567027674,59.679663707208995],[24.752496701038964,59.43639985926234],[24.412556941855435,59.56588346342965],[23.850057340335432,59.56588346342965],[21.375059093647387,59.5373781389088]]]}},{"type":"Feature","properties":{"id":"solas","name":"Solas","color":"#af3143","feature_id":"solas-0","coordinates":[-5.483009534617182,51.47267219749354]},"geometry":{"type":"MultiLineString","coordinates":[[[-4.169502810345242,51.55809471609609],[-5.399921938704683,51.44682015166956],[-5.849921620516659,51.586833980054095],[-6.585031099162287,52.17164795778553]]]}},{"type":"Feature","properties":{"id":"pan-european-crossing-uk-ireland","name":"Pan European Crossing (UK-Ireland)","color":"#59b947","feature_id":"pan-european-crossing-uk-ireland-0","coordinates":[-5.446351911625827,51.64417593731623]},"geometry":{"type":"MultiLineString","coordinates":[[[-4.544402544762735,50.82820142743812],[-5.399921938704683,51.586833980054095],[-5.849921619920758,52.14259270367212],[-6.364861255132669,52.40176042819693]],[[-6.558811117736723,52.184255568368805],[-6.074921461124696,51.586833980054095],[-5.737421700212731,50.740281893948165],[-5.698461727216356,50.07870033214287]]]}},{"type":"Feature","properties":{"id":"alaska-united-southeast-au-se","name":"Alaska United Southeast (AU-SE)","color":"#7e439a","feature_id":"alaska-united-southeast-au-se-0","coordinates":[-133.68772704901258,57.532965301974784]},"geometry":{"type":"MultiLineString","coordinates":[[[-135.3344010202442,57.05301929960622],[-135.47615091624638,57.14285437334868],[-135.56560085061952,57.25632706167504],[-135.62014081060508,57.29294273759133],[-135.68474076320985,57.32790795452416],[-135.70291074987912,57.35154490127678],[-135.65237078695884,57.37664384009415],[-135.61692081296755,57.39764169906029],[-135.5723408456746,57.43153840198825],[-135.52976087691425,57.4657963369845],[-135.53240087497736,57.50700103750678],[-135.53240087497736,57.54920559971693],[-135.50540089478653,57.568538621060895],[-135.41858095848386,57.58377679148179],[-135.325321026906,57.56374387311736],[-135.1631111459147,57.4973463454386],[-135.05979122171766,57.44987929314743],[-134.96975128777734,57.43046177296257],[-134.8572513703154,57.41589725419255],[-134.77170143308095,57.4255734788795],[-134.58225157207488,57.501940298718075],[-134.69651534438088,57.594609061615635],[-134.76067779892762,57.734649479402336],[-134.8106777635072,58.012978928938054],[-134.81292411835489,58.07747851820177],[-134.74172145507632,58.12820451017112],[-134.63616153252264,58.21297738638939],[-134.71174147707188,58.291672685647676],[-134.7013914846653,58.30999281015943],[-134.60315155674115,58.326917289684346],[-134.54221160145104,58.318893286425045],[-134.4068617007534,58.299576750775365],[-134.29583062763373,58.23709493249001],[-134.13655189907203,58.165130914395746],[-134.1248307487715,58.101249458975765],[-133.99143084327335,57.98640326883048],[-133.82473096136496,57.82371236991844],[-133.80256214411085,57.707894171078024],[-133.69143105579607,57.558771470979615],[-133.66477224520332,57.37303699157905],[-133.6304922703536,57.31396150003885],[-133.658131079386,57.22966820564022],[-133.62948227169065,57.13766184463267],[-133.51633117983843,57.09442219161841],[-133.23294256202445,57.05115924380715],[-133.00823153978092,57.02097743143672],[-132.97024275475985,56.98329605125804],[-132.9011028054859,56.91336556277725],[-132.969852755046,56.807791226083026],[-132.8874491482218,56.722763716398205],[-132.84510784787435,56.661040335003094],[-132.76073290764631,56.59916066233438],[-132.70438295040975,56.537179473172735],[-132.5355830742533,56.47504144368907],[-132.3838131850065,56.470788497078864],[-132.45901312983446,56.3862218123596],[-132.51975308527136,56.368054145955455],[-132.59182303239558,56.35047071868664],[-132.59719302845576,56.31317190855833],[-132.6260030073188,56.25964372839669],[-132.7297129312297,56.232895991831526],[-132.84104284955015,56.19050811225174],[-132.85501283930074,56.12703903462036],[-132.74182292234497,56.066792217170224],[-132.61350301648966,56.00634486676592],[-132.45149313535165,55.89266381393939],[-132.34348321459547,55.79765291550112],[-132.27600326410357,55.63377825521405],[-132.1336233685637,55.526742438171844],[-131.97551348456435,55.45213247504737],[-131.91331353019876,55.466199572077606],[-131.8253235947544,55.434270637976695],[-131.64788372493697,55.34196841314809]]]}},{"type":"Feature","properties":{"id":"america-movil-submarine-cable-system-1-amx-1","name":"America Movil Submarine Cable System-1 (AMX-1)","color":"#3e60ac","feature_id":"america-movil-submarine-cable-system-1-amx-1-0","coordinates":[-45.68283490232327,8.348070685535115]},"geometry":{"type":"MultiLineString","coordinates":[[[-65.69987922164921,19.529070924350908],[-66.03737898256126,19.104405475930452],[-66.10666893347558,18.46610423294742]],[[-43.20956515399876,-22.903486555497956],[-42.29989579841706,-23.494056688715368],[-40.94989675476902,-23.49392244589784],[-38.69989834868901,-22.250099679090077],[-36.44989994260901,-18.026426383713453],[-35.549900580176946,-13.698987269610743],[-33.299902174096935,-8.401139048122838],[-33.299902174096935,-6.616650693475355],[-34.199901536529,-4.825692499217419],[-35.99990026139302,-3.92832730414264],[-38.542968459859594,-3.718735129291092],[-38.24989866747303,-1.231315750217412],[-39.149898029904996,1.468426767331968],[-46.79989261057708,9.52441134501949],[-52.19988878516916,14.801154224791581],[-57.59988495976114,17.395022634700517],[-59.399883684625166,18.251816319028222],[-62.09988177192116,19.316876111628712],[-63.89988049678528,19.316876111628712],[-65.69987922164921,19.529070924350908],[-67.49987794651324,21.216397899942],[-69.29987667137726,22.261369678340607],[-73.3498738023213,24.32780311165181],[-76.94987125204935,26.964304734562898],[-79.19986965812936,29.1482487910328],[-80.5498687017773,30.126049846722832],[-81.65563040282981,30.332100867462632]],[[-38.50448848711925,-12.96997203534297],[-37.34989930504097,-13.455974309054534],[-35.99990026139302,-13.504596782344024],[-35.549900580176946,-13.698987269610743]],[[-88.59713531004529,15.727236638721036],[-88.19986328244948,16.10232559580297],[-87.6373636809293,16.534196198259725],[-86.73736431849733,18.251816319028222],[-85.27486535454535,20.375041253465433]],[[-75.50573227509088,10.38680745163333],[-76.0498718896173,11.515266158038768],[-79.19986965872535,14.946128218512863],[-80.5498687017773,16.10232559580297],[-81.89986774542525,17.395022634700517],[-84.59986583272125,19.104405475930452],[-85.27486535454535,20.375041253465433],[-85.04986551393732,21.635297384859552],[-84.82486567332928,22.469443964829516],[-83.2498667890733,23.50508968095737],[-80.99986838299347,23.50508968095737],[-79.64986933934534,24.73717827217609],[-79.53736941904133,25.348717422116714],[-80.16016897784432,26.010548668010795]],[[-69.29987667137726,22.261369678340607],[-70.6498757150253,21.216397899942],[-73.3498738023213,20.58581909604039],[-73.9123734038413,19.95262290516439],[-74.4748730053613,19.316876111628712],[-74.92487268657729,18.251816319028222],[-75.26237244748934,17.395022634700517],[-75.26237244748934,11.735650161405832],[-74.77975278938153,10.940445615726643]],[[-70.6911856857609,19.799436355797177],[-70.6498757150253,20.375041253465433],[-70.6498757150253,21.216397899942]],[[-70.68356569115905,19.803388017159396],[-70.19987603380923,20.05833455139623],[-69.29987667137726,19.95262290516439],[-67.04987826529725,19.210675111642853],[-66.48737866377725,18.891661584303154],[-66.10666893347558,18.46610423294742]],[[-81.73000034886279,12.55112730403864],[-81.89986774602134,12.871429169544808],[-82.2373675069334,12.981078187892704]],[[-83.03765938887449,9.988597517410145],[-83.02486694906135,10.41081650540272],[-82.12486758662938,11.662208223864337],[-82.2373675069334,12.981078187892704],[-82.12486758662938,13.419186961310027],[-81.89986774602134,14.074846740630262],[-80.5498687017773,16.10232559580297]]]}},{"type":"Feature","properties":{"id":"america-movil-submarine-cable-system-1-amx-1","name":"America Movil Submarine Cable System-1 (AMX-1)","color":"#939597","feature_id":"america-movil-submarine-cable-system-1-amx-1-1","coordinates":[-70.80240522628625,17.16804012828196]},"geometry":{"type":"MultiLineString","coordinates":[[[-66.62663637762577,17.98265426367754],[-66.82487842647718,17.609605913224996],[-67.49987794651324,17.287636299591117],[-69.86237627289727,17.395022634700517],[-70.6498757150253,17.180187287481317],[-71.99987475867334,17.07267593989621],[-74.69987284596934,17.180187287481317],[-75.26237244748934,17.395022634700517]],[[-69.86237627289727,17.395022634700517],[-69.74987635318924,17.82393441253792],[-69.8061263133412,18.144943564296213],[-69.94053621812404,18.486808968061684]]]}},{"type":"Feature","properties":{"id":"avassa","name":"Avassa","color":"#bf6b28","feature_id":"avassa-0","coordinates":[44.85531830960453,-12.13911156845713]},"geometry":{"type":"MultiLineString","coordinates":[[[43.489203584029966,-11.923115980293565],[43.489203584029966,-12.045379517998398],[43.31254355292765,-12.053986862571566],[43.20004363262354,-11.943944931746815],[43.24330532072668,-11.700586528216206]],[[44.40004278253289,-12.1662747986749],[44.40004278253289,-11.921730511641545],[44.65004260543071,-11.921730511641545],[45.233294145374835,-12.539377089217226],[45.233294145374835,-12.783297381732545]]]}},{"type":"Feature","properties":{"id":"indigo-central","name":"INDIGO-Central","color":"#a4bb39","feature_id":"indigo-central-0","coordinates":[132.59494981581025,-36.48458466517816]},"geometry":{"type":"MultiLineString","coordinates":[[[151.19625712709274,-33.913571605570375],[151.34879326843867,-34.82698199055459],[150.27496777914686,-37.942453525696855],[149.39996839960057,-38.642301250436695],[147.59996967473646,-39.16757423638764],[146.24997063108842,-39.51559387611211],[144.4499719056286,-39.34180065396819],[143.9999722250084,-39.16757423638764],[142.19997350014447,-39.16757423638764],[140.39997477528055,-38.81782397325074],[134.85085761257704,-36.676358605808446],[127.09256232737636,-36.016828610146156],[119.55018485796727,-36.07920493188053],[115.44676979611502,-35.917240097009994],[114.05321609582157,-34.611299785451145],[114.05321609582157,-33.06182889373477],[115.85731638027985,-31.95343894398052]]]}},{"type":"Feature","properties":{"id":"eastern-africa-submarine-system-eassy","name":"Eastern Africa Submarine System (EASSy)","color":"#28a27f","feature_id":"eastern-africa-submarine-system-eassy-0","coordinates":[55.41741010630586,10.449991164659412]},"geometry":{"type":"MultiLineString","coordinates":[[[43.16164365982675,11.573660387694234],[43.65004331383962,11.56544739308108],[44.55004267627159,11.460143029456674]],[[37.21967786917097,19.61556659454616],[37.80004745803156,19.529070924350908],[39.150046502275686,18.251816319028222],[40.16254578501358,16.534196198259725],[41.62504474896373,14.801154224791581],[42.13129439033177,13.92930384327183],[42.806293912155695,13.054150695298627],[43.1297311836258,12.834868817846521],[43.21410612385384,12.615395567393394],[43.453168453307654,12.395734000022975],[44.55004267627159,11.460143029456674],[45.450042038703735,11.405009147532946],[48.60003980781179,12.175887185507976],[52.65003693815965,13.163718917913586],[54.00003598419185,13.492128176464083],[55.575034866063774,12.615395567393394],[55.35003502545574,9.52441134501949],[54.45003566361985,5.510071711803246],[51.30003789451161,1.468426767331968],[46.12504156052766,-1.906058394384765],[42.30004427019158,-4.152767748013638],[40.95004522654354,-5.497950688314882],[40.500045545327644,-6.169450529574503],[39.82504602290763,-6.523516459651954],[39.269676416932725,-6.823132108349236]],[[40.500045545327644,-6.169450529574503],[41.62504474836765,-9.29042430103552],[42.07504442958354,-11.943944931746815],[42.525044110203716,-14.861883917661954],[42.52504411079961,-15.224032284647373],[42.52504411079961,-20.574419057276128],[41.40004490775961,-23.49392244589784],[35.55004905195155,-26.752713396100123],[33.750050327087614,-28.54634921047574],[32.850050964655466,-28.940898806450146],[31.757961738301827,-28.950559666538012]],[[39.672896131288006,-4.052924364763054],[42.30004427019158,-4.152767748013638]],[[32.58062115552212,-25.968268155407962],[33.750050327087614,-26.450941899317534],[35.55004905195155,-26.752713396100123]],[[43.66314330455965,-23.354724804059778],[42.75004395140765,-23.59705596407509],[41.40004490775961,-23.49392244589784]],[[43.24330360197787,-11.700589282272533],[42.75004395140765,-11.943944931746815],[42.07504442958354,-11.943944931746815]],[[51.30003789451161,1.468426767331968],[46.80004108235159,2.255504211923801],[45.344182113695986,2.041205223228781]]]}},{"type":"Feature","properties":{"id":"dumai-melaka-cable-system-dmcs","name":"Dumai-Melaka Cable System (DMCS)","color":"#6bbd44","feature_id":"dumai-melaka-cable-system-dmcs-0","coordinates":[101.58210930555742,2.1897261952501026]},"geometry":{"type":"MultiLineString","coordinates":[[[101.44766236946417,1.665522797277061],[101.41875238994413,2.143087178471855],[101.81250211100806,2.255504211923801],[102.220901821694,2.273260323566543]]]}},{"type":"Feature","properties":{"id":"aden-djibouti","name":"Aden-Djibouti","color":"#08acdb","feature_id":"aden-djibouti-0","coordinates":[44.0777530184267,12.217167550728814]},"geometry":{"type":"MultiLineString","coordinates":[[[43.147992888246804,11.59488132936423],[43.65004331383962,11.955956037776811],[45.03353842750859,12.800886250786704]]]}},{"type":"Feature","properties":{"id":"chuuk-pohnpei-cable","name":"Chuuk-Pohnpei Cable","color":"#5abb5d","feature_id":"chuuk-pohnpei-cable-0","coordinates":[155.4447110271217,7.744889052551447]},"geometry":{"type":"MultiLineString","coordinates":[[[151.85848716577576,7.439993539799818],[152.99996584932845,7.744889052551447],[157.94996234270454,7.744889052551447],[159.0702615490743,7.786404758723782]]]}},{"type":"Feature","properties":{"id":"pacific-light-cable-network-plcn","name":"Pacific Light Cable Network (PLCN)","color":"#bc3e96","feature_id":"pacific-light-cable-network-plcn-0","coordinates":[-148.39382924040027,42.803904611812975]},"geometry":{"type":"MultiLineString","coordinates":[[[125.99998497636834,21.006499845176737],[124.6499859321244,19.24608308700458],[123.29998688907226,17.82393441253792],[122.39998752664029,16.10232559580297],[122.17498768603225,15.886035719079029],[121.94998784542422,15.777803371817374],[121.56018812156209,15.761539465842137]],[[125.99998497636834,21.006499845176737],[124.14876558466459,23.55170502223485],[122.84998720785636,24.53265756616073],[122.17498768603225,24.788256058863375],[121.80145279439779,24.863504254255204]],[[125.99998497636834,21.006499845176737],[130.94998146974444,22.469443964829516],[132.74998019460836,23.298598065875897],[138.59997605041642,25.75470426341523],[160.19996074878455,40.04369219283004],[172.79992073307184,43.4011270858577],[179.99992719256971,43.4011270858577]],[[-118.41596126184768,33.873229987687004],[-120.59984033000157,33.612349285068355],[-122.3998390548656,33.75276987113061],[-129.5998339543217,37.23235432155614],[-138.5998275786419,40.72920412488655],[-151.19983391146832,43.39831121479671],[-179.99981350929238,43.39831121479671]]]}},{"type":"Feature","properties":{"id":"colombia-florida-express-cfx-1","name":"Colombia-Florida Express (CFX-1)","color":"#ca2e32","feature_id":"colombia-florida-express-cfx-1-0","coordinates":[-74.14918274409902,19.812126245383148]},"geometry":{"type":"MultiLineString","coordinates":[[[-76.68936118269113,17.942952785882994],[-76.49987157083336,17.82393441253792],[-76.27487173022533,17.82393441253792],[-76.18434445060568,17.918365683338788],[-76.0498718896173,17.82393441253792],[-75.59987220840131,17.395022634700517]],[[-80.08893152830949,26.350585697437857],[-79.4248694987373,25.957179978764344],[-79.31236957843338,25.348717422116714],[-78.97486981752132,24.73717827217609],[-78.97486981752132,23.7112581424843],[-78.29987029569729,22.884654113882444],[-76.0498718896173,21.635297384859552],[-74.69987284596934,21.006499845176737],[-73.96862336399336,20.375041253465433],[-73.9123734038413,20.163975031975873],[-74.02487332414532,19.95262290516439],[-74.58737292566532,19.316876111628712],[-75.0373726068813,18.251816319028222],[-75.59987220840131,17.395022634700517],[-75.59987220840131,11.073982781226615],[-75.50573165009133,10.386791448721706]]]}},{"type":"Feature","properties":{"id":"imewe","name":"IMEWE","color":"#2966b1","feature_id":"imewe-0","coordinates":[48.42281650540944,12.901288725928946]},"geometry":{"type":"MultiLineString","coordinates":[[[38.137547218943524,22.05298561667754],[39.18275647850768,21.481533475502996]],[[67.02854675228855,24.889731701235817],[66.48752713555186,23.298598065875897],[66.15002737463989,20.375041253465433],[66.60002705585578,19.529070924350908]],[[32.52993119143143,29.972545436050364],[32.56877616391329,29.63833609362628],[32.73755104435154,29.344566989489813],[33.01880084511154,28.95155473219332],[33.511262996247716,28.161052262220792],[34.312549928607616,27.364667993860262],[35.184424310963514,26.562513149236715],[35.887548812863514,25.75470426341523],[36.900048095598684,24.12261698700344],[38.137547218943524,22.05298561667754],[38.92504666107156,20.375041253465433],[40.2750457047196,18.251816319028222],[41.28754498745659,16.534196198259725],[42.24379431003961,14.801154224791581],[42.75004395140765,13.92930384327183],[43.11566869239569,13.054150695298627],[43.270356082813635,12.834868817846521],[43.35473102304167,12.615395567393394],[43.734418254067656,12.395734000022975],[44.55004267627159,12.010882360458767],[45.450042038703735,12.175887185507976],[48.60003980721571,12.944533868662969],[55.35003502545574,15.23578178303578],[58.95003247518379,16.965102599435927],[62.10003024369576,19.104405475930452],[66.60002705585578,19.529070924350908],[70.20002450558383,19.316876111628712],[72.87590260996693,19.07607425728523]],[[5.372530429989069,43.29362778902908],[5.962570011999289,41.74435878948223],[7.200069135343221,38.651811712711336],[7.875068657167333,37.94551049545967],[9.000067859611436,37.76786242517874],[10.348617229769278,37.76786242517874],[10.91256650537538,37.411283634923244],[11.58756602600751,37.23235432155614],[12.712565230239315,35.96797434759339],[13.668814552822518,35.419780517080355],[14.400064034799321,35.098264766303075],[16.65006244087933,34.683017659857974],[19.350060528175415,35.05222991093673],[22.050058614875596,34.71384862261538],[25.200056383983473,33.98629373718467],[28.800053833711523,32.62301664000789],[29.8935130590948,31.191465077638455],[30.825052399183495,31.118343723670264],[31.387552000703497,30.99783535602406],[31.9500516022235,30.780635088989538],[32.287551363135464,30.41752891425909],[32.52993119143143,29.972545436050364]],[[15.06744356202158,37.51344748573393],[15.750063078447363,37.411283634923244],[16.2563127198154,36.87321951208928],[16.425062600271474,35.78566189952622],[16.65006244087933,34.683017659857974]],[[35.859228832925915,34.43974246013246],[35.55004905195155,34.405022750715936],[32.40005128343957,33.28381101905092],[30.60005255857546,32.62301664000789],[28.800053833711523,32.62301664000789]],[[56.33372432860119,25.121690004958644],[56.92503390971164,24.864833316508726],[58.50003279396771,24.507068968791266],[59.85003183761575,23.91710129093604],[62.10003024369576,22.469443964829516],[62.10003024369576,19.104405475930452]],[[32.52993119143143,29.972545436050364],[32.287551363135464,30.466024503890644],[31.9500516022235,30.877238883975348],[31.387552000703497,31.094220011720186],[30.825052399183495,31.19054975154414],[29.8935130590948,31.191465077638455]]]}},{"type":"Feature","properties":{"id":"seacomtata-tgn-eurasia","name":"SEACOM/Tata TGN-Eurasia","color":"#2f95b3","feature_id":"seacomtata-tgn-eurasia-0","coordinates":[48.786506248119146,12.89857027648199]},"geometry":{"type":"MultiLineString","coordinates":[[[38.057480598067585,22.066172634374052],[39.21518977793548,21.494773132194442]],[[43.180426968924365,11.608807305452311],[43.68247661326779,11.804597919620866],[44.58247597569975,11.9697778362716]],[[31.790395037729812,-28.938108319513333],[32.88248426408364,-29.026862190178257],[33.7824836265156,-28.731334165606242],[35.807482191987575,-26.940767064044092],[41.65747804779581,-23.68707917734726],[42.78247725083582,-20.561097385973355],[42.78247725083582,-15.210302528564313],[42.78247725023955,-14.84813082933909],[42.33247756961974,-11.930023974236281],[41.88247788840367,-9.276382018590148],[40.64497876505974,-6.155304105140356]],[[55.3824683248839,15.032320749690347],[55.832468006099795,12.629280326722103],[55.832468006099795,9.538443559786772],[55.382468325479806,5.524234439321599],[52.232470556371744,1.482650691528387],[46.382474700563684,-2.116699612616721],[42.33247756961974,-4.362957099929003],[40.9824785259717,-5.707714823439762],[40.64497876505974,-6.155304105140356],[39.857479322335614,-6.565263959445051],[39.30210971636071,-6.809004030362613]],[[42.33247756961974,-4.362957099929003],[39.70532943071599,-4.038731181161849]],[[32.6130544549501,-25.955475388503118],[33.7824836265156,-26.53889211948715],[35.807482191987575,-26.940767064044092]],[[72.90833590939474,19.08952099458581],[70.232457805012,19.224110884087814],[66.63246035528395,19.330303180397426],[61.90746370251589,18.692125701453875],[58.98246577461195,16.763395784717346],[55.3824683248839,15.032320749690347],[48.632473106643694,12.848741565417857],[45.48247533813172,12.07980921311095],[44.58247597569975,11.9697778362716],[43.73872657341975,12.409630577439529],[43.37310183243171,12.629280326722103],[43.28872689220368,12.848741565417857],[43.119977011747785,13.068011238315085],[42.726227290683674,13.94311365796037],[42.219977649315815,14.81491029925734],[41.20747836658065,16.54783600485381],[40.19497908384366,18.26532858133193],[38.8449800401958,20.38837908615011],[38.057480598067585,22.066172634374052],[36.81998147472256,24.13560242446459],[35.807482191987575,25.767518777408142],[35.13248267016364,26.575239208943042],[34.288733267883636,27.3773037156237],[33.47345884543558,28.1735958463088],[32.64989110585475,29.116661999999696]]]}},{"type":"Feature","properties":{"id":"middle-east-north-africa-mena-cable-systemgulf-bridge-international","name":"Middle East North Africa (MENA) Cable System/Gulf Bridge International","color":"#51b847","feature_id":"middle-east-north-africa-mena-cable-systemgulf-bridge-international-0","coordinates":[45.405295722056806,12.593544017219301]},"geometry":{"type":"MultiLineString","coordinates":[[[37.68754753772763,22.05298561667754],[39.18275647850768,21.481533475502996]],[[12.591375316091606,37.65058617278613],[12.173015612461494,37.27875778048947],[12.037565708415386,36.87321951208928],[12.150065628719313,35.78566189952622],[12.487565389636371,35.419780517080355],[14.400064034799321,34.12610104005753],[16.65006244087933,33.37780603565933],[19.350060528175415,33.75276987113061],[22.050058614875596,33.73717891339029],[25.200056383983473,33.001218522654476],[27.900054471279375,32.148008778540614],[29.70093319552029,31.072270031660306]],[[29.70093319552029,31.072270031660306],[29.98130299690349,30.65976529855482],[30.375052717967602,30.393272076386282],[31.050052239791533,30.02869812787728],[31.72505176161546,29.784900534878602],[32.17505144283154,29.4423669335806],[32.65318110412008,29.113614162980063]],[[29.70093319552029,31.072270031660306],[29.98130299690349,30.61136637963382],[30.375052717967602,30.296184504419557],[31.050052239791533,29.93125070442692],[31.72505176161546,29.687214675556923],[32.17505144283154,29.393413371278484],[32.65318110412008,29.113614162980063]],[[32.65318110412008,29.113614162980063],[33.42698805595291,28.161052262220792],[34.031300127847615,27.364667993860262],[34.84692455005155,26.562513149236715],[35.43754913164762,25.75470426341523],[36.45004841438352,24.12261698700344],[37.68754753772763,22.05298561667754],[38.47504697985549,20.375041253465433],[39.82504602350353,18.251816319028222],[40.83754530624179,16.534196198259725],[41.96254450927961,14.801154224791581],[42.46879415064765,13.92930384327183],[42.975043792015505,13.054150695298627],[43.20004363262354,12.834868817846521],[43.28441857285158,12.615395567393394],[43.59379335368765,12.395734000022975],[44.55004267627159,12.175887185507976],[45.450042038703735,12.615395567393394],[48.60003980721571,13.3827080361257],[55.35003502545574,16.10232559580297],[58.95003247518379,17.82393441253792],[60.075031678223795,19.104405475930452],[60.187531597931816,22.469443964829516],[59.85003183761575,22.988259503893058],[58.50003279396771,24.12261698700344]],[[58.50003279396771,24.12261698700344],[58.331282913511636,23.91710129093513],[58.1762030233719,23.68487753168473]]]}},{"type":"Feature","properties":{"id":"saudi-arabia-sudan-2-sas-2","name":"Saudi Arabia-Sudan-2 (SAS-2)","color":"#3c67b1","feature_id":"saudi-arabia-sudan-2-sas-2-0","coordinates":[38.03315212988427,20.72293228270063]},"geometry":{"type":"MultiLineString","coordinates":[[[37.21967786917097,19.61556659454616],[37.58923757400337,20.236025167233485],[38.10004724550894,20.796306105108872],[39.18275647850768,21.481533475502996]]]}},{"type":"Feature","properties":{"id":"flag-europe-asia-fea","name":"FLAG Europe-Asia (FEA)","color":"#c52159","feature_id":"flag-europe-asia-fea-0","coordinates":[25.96765985162516,33.434624446465044]},"geometry":{"type":"MultiLineString","coordinates":[[[39.18783647490891,21.48407381384701],[37.80004745803156,22.05298561667754]],[[35.005095000000225,29.581033231032787],[34.71137964666887,29.08904402302273],[34.51762978332711,28.313851747133643],[34.46137982257925,28.115586087218034],[34.44732233253752,27.991483447904724],[34.44732233253752,27.916953178205596],[34.76254960982351,26.964304734562898],[35.55004905195155,25.75470426341523]],[[139.62087532720193,35.14403650833241],[139.44372545269644,34.405022750715936],[139.38747549254447,33.93964008831966],[139.49997541284839,32.43331330641721],[139.04997573163232,31.286738814391754],[137.69997668798445,30.5144959597591],[134.99997860068837,29.73606949729215],[132.74998019460836,29.73606949729215],[130.49998178852834,29.93125070442692],[129.37498258548837,30.901396088515508],[128.0249835418403,32.43331330641721],[127.63763431592815,33.19758857732108],[128.24998338244836,34.31215165223547],[128.62078311977027,34.88072781981967]],[[121.92508786246776,30.86475026744717],[122.17498768603225,30.997878215321894],[122.84998720785636,31.19054975154414],[124.6499859327203,30.80481662242606],[125.66248521545637,30.223305674181642],[126.44998465758441,29.540507745394493]],[[72.87590260996693,19.07607425728523],[72.67502275227187,16.965102599435927],[73.74513399127409,13.492128176464083],[75.09513303492214,9.52441134501949],[77.29024347696435,6.852191098754328],[78.69513048465001,5.061986954416114],[81.00001685476798,4.389285926050993],[85.500013666928,5.510071711803246],[90.00001047908802,6.405200795356032],[92.70000856638391,6.405200795356032],[94.27500745064,6.628746603597807],[95.40000665368001,6.628746603597807],[97.42500521915215,6.908035999527216],[99.00000410340806,6.852191098754328],[99.67500362523216,6.740481724921185],[100.06611334816643,6.613518860854109]],[[100.40982310408316,5.368393581488473],[99.67500362523216,5.510071711803246],[97.42500521915215,6.908035999527216]],[[100.06611334816643,6.613518860854109],[100.29385318683336,6.908035999527216],[100.5951029728293,7.198818071264419],[101.70000219070414,7.29876275445952],[103.04883123518101,7.451587710516595],[105.29999964043219,7.521883237406507],[107.99999772772827,8.413185359560185],[109.34999677137613,9.967915186974132],[110.24999613380828,12.615395567393394],[112.94999422110418,18.251816319028222],[113.28749398201614,20.796306105108872],[113.6249937429283,21.635297384859552],[113.94911351331866,22.271493895850078],[114.187543344413,21.84429407917369],[115.19999262718419,21.216397899942],[116.54999167083223,21.006499845176737],[118.79999007691224,21.32123529551186],[121.02775554672928,21.143107267679202],[122.84998720785636,21.32123529551186],[123.97498641089636,22.05298561667754],[124.87498577332833,24.12261698700344],[125.09998561393637,25.55188275942587],[125.32498545454442,26.1593079707739],[126.22498481697637,28.55704546571133],[126.44998465758441,29.540507745394493],[127.4942221984908,32.45077824619232],[127.39849288377935,33.21981973693083],[128.13748346214442,34.31215165223547],[128.62078311977027,34.88072781981967]],[[56.33372432860119,25.121690004958644],[56.92503390971164,24.83931282559271],[58.50003279396771,24.353428099494565],[59.85003183761575,23.71125814248539],[61.65003056247987,22.469443964829516],[61.65003056247987,18.251816319028222]],[[-5.145872118676008,36.42741977174601],[-4.724922416880753,36.24065523321488],[-4.499922576272716,36.05897312258681]],[[-5.654511758350902,50.043147911894295],[-6.074921460528704,49.95058699772799],[-6.299921301136742,49.805593628808026],[-7.499920451046092,49.34357257392165],[-8.54991970721675,48.70423463096067],[-12.599916838160784,44.05151922873524],[-12.599916838160784,39.6983233549332],[-11.024917953904795,37.94551049545967],[-9.787418830560773,36.51238821239364],[-8.999919388432733,36.1498667868178],[-6.299921301136742,35.96797434759339],[-5.624921779312721,35.96793388034115],[-5.34758197578279,36.15601951413446],[-4.499922576272716,36.05897312258681],[-2.249924170192707,36.1498667868178],[2.25007264196731,37.94551049545967],[5.400070410479286,38.29952060596925],[9.000067859611436,38.29952060596925],[10.348617229769278,38.29952060596925],[12.150065628719313,38.29952060596925],[12.600065309935387,38.43179231910092],[13.05006499115128,38.34365110568998],[13.358654772543574,38.11612161658339],[13.05006499115128,38.299481366908616],[12.600065309935387,38.343611890509024],[12.150065628719313,38.21117903702318],[12.150065628719313,36.87321951208928],[12.37506546932735,36.24065523321488],[12.712565230239315,35.78566189952622],[13.331314791912373,35.419780517080355],[14.400064034799321,34.821666484082456],[16.65006244087933,34.31215165223547],[19.350060528175415,34.683017659857974],[22.050058614875596,34.43595690575565],[25.200056383983473,33.70598849685854],[28.800053833711523,32.43331330641721],[29.8935130590948,31.191465077638455],[30.825052399183495,30.683955675438124],[31.387552000703497,30.41748579633928],[31.9500516022235,30.198979110224172],[32.287551363135464,30.126049846722832],[32.52993119143143,29.972545436050364],[32.48440122368525,29.63833609362628],[32.653276104052736,29.344566989489813],[32.65318110412008,29.113614162980063],[33.412950565897326,28.161052262220792],[34.14380004815155,27.364667993860262],[34.931299490279585,26.562513149236715],[35.55004905195155,25.75470426341523],[36.56254833468653,24.12261698700344],[37.80004745803156,22.05298561667754],[38.5875469001596,20.375041253465433],[39.93754594380764,18.251816319028222],[40.950045226544624,16.534196198259725],[42.07504442958354,14.801154224791581],[42.58129407095158,13.92930384327183],[43.03129375216765,13.054150695298627],[43.22816861269962,12.834868817846521],[43.31254355292765,12.615395567393394],[43.65004331383962,12.395734000022975],[44.55004267627159,11.900822861999712],[45.450042038703735,11.955858207114732],[48.60003980721571,12.72515592356304],[55.35003502545574,14.801154224791581],[58.95003247518379,16.534196198259725],[61.65003056247987,18.251816319028222],[66.60002705585578,19.104405475930452],[70.20002450558383,19.104405475930452],[72.87590260996693,19.07607425728523]],[[29.8935130590948,31.191465077638455],[30.825052399183495,30.75649044225256],[31.387552000703497,30.514452884743953],[31.9500516022235,30.29616291870379],[32.287551363135464,30.174689758498985],[32.52993119143143,29.972545436050364]],[[29.8935130590948,31.191465077638455],[30.150052877359567,31.574717129337174],[31.050052239195633,31.702423093375145],[32.006301561779566,31.510798430049064],[32.28445136473581,31.25927814644905]],[[32.28445136473581,31.25927814644905],[32.5125512031476,30.837020582397155],[32.5125512031476,30.255702942039875],[32.52993119143143,29.972545436050364]]]}},{"type":"Feature","properties":{"id":"eac-c2c","name":"EAC-C2C","color":"#3a56a6","feature_id":"eac-c2c-0","coordinates":[124.22374543987205,20.728817443467022]},"geometry":{"type":"MultiLineString","coordinates":[[[120.12788799225589,21.457551614722192],[120.37498896116831,21.635297384859552],[120.54373884162439,22.05298561667754],[120.66219875770618,22.24925482135663]],[[125.09998561393637,24.109781889526705],[123.74998657028833,25.2342865621001],[122.84998720785636,25.539194978687103],[121.94998784542422,25.43764443864878],[121.3832882468796,25.137250705417735],[121.94998784542422,25.33600821718872],[122.39998752664029,24.928611492263457],[122.84998720785636,24.007054825363046],[122.39998752664029,22.45644844059945],[121.04998848299225,19.72775079133148],[119.76865992030457,18.516117231747454],[118.93597086667044,17.741878907400448],[118.79999007691224,15.222213115980855],[119.36248967843224,14.569901804622967],[120.14998912056028,14.352030559547492],[120.82019864577752,14.273021484821282],[120.14998912056028,14.024826773554539],[118.79999007691224,13.040451242220165],[115.64999230840026,9.06644423990714],[113.84999358353615,7.730954611330002],[110.47499597441613,5.943831970446426],[109.79999645259221,5.496074035021858],[107.99999772772827,4.711703227447191],[107.09999836529612,4.150887372137655],[105.74999932164808,3.365087426296303],[105.18749972012807,2.803404866588448],[104.5969251384969,1.454368851373345],[104.28790035741287,1.229630815177437],[104.1731404387099,1.23626927314036],[103.98701057056589,1.375392999849927]],[[114.2586832940168,22.31829267897149],[114.97499278657615,22.313417380769337],[115.64999230840026,22.000841467910675],[117.4499910332642,21.73983373091106],[118.79999007691224,21.216397899942],[121.02775554672928,21.038143463338198],[122.84998720785636,21.216397899942],[124.19998625150441,22.05298561667754],[125.09998561393637,24.12261698700344],[125.38123541469638,25.55188275942587],[125.66248521545637,26.1593079707739],[126.67498449819227,28.55704546571133],[126.89998433880031,29.540507745394493],[127.34998402001638,30.708139993541643],[125.09998561393637,33.189714664600466],[124.6499859327203,34.31215165223547],[124.6499859327203,35.419780517080355],[125.54998529515227,36.1498667868178],[126.39158469895561,36.57633045558579],[125.54998529515227,35.96797434759339],[125.09998561393637,35.419780517080355],[125.09998561393637,34.31215165223547],[125.32498545454442,33.189714664600466],[128.24998338244836,30.708139993541643],[130.49998178852834,29.540507745394493],[132.74998019460836,29.344566989489813],[134.99997860068837,29.344566989489813],[137.69997668798445,30.126049846722832],[139.27497557224055,30.901396088515508],[141.2999741377125,32.052708023486204],[142.19997350014447,34.405022750715936],[142.19997350014447,35.05222991093673],[141.97497365953643,35.78566189952622],[140.6124746247436,36.383483735312474],[141.7499738189284,35.78566189952622],[141.7499738189284,35.05222991093673],[140.84997445649643,34.12610104005753],[138.59997605041642,33.37780603565933],[137.69997668798445,33.659181629050494],[136.87399727311598,34.33682825203173],[137.4749768473764,32.8123187832876],[137.69997668798445,31.286738814391754],[137.24997700676838,29.344566989489813],[132.74998019460836,26.1593079707739]],[[114.2586832940168,22.31829267897149],[114.97499278657615,22.261369678340607],[115.64999230840026,21.948678137927157],[117.4499910332642,21.635297384859552],[118.79999007691224,21.006499845176737],[121.02775554672928,20.827994119059056],[122.84998720785636,20.796306105108872],[125.99998497636834,22.05298561667754],[128.69998306366443,23.298598065875897],[132.74998019460836,26.1593079707739]],[[121.3832882468796,25.149980712893985],[121.72498800481618,25.75470426341523],[121.94998784542422,26.05828756029904],[122.84998720785636,26.260240971577822],[128.69998306366443,25.75470426341523],[131.39998115096031,26.1593079707739],[132.74998019460836,26.1593079707739]],[[103.98701057056589,1.389451396800233],[104.18740042860793,1.289568782938401],[104.28790035741287,1.341927435270103],[104.4004002777168,1.468426767331968],[104.73750003891219,2.817450442654169],[105.74999932164808,4.277107602190303],[107.09999836529612,5.061986954416114],[107.99999772772827,5.733989114150127],[110.24999613380828,7.744889052551447],[111.4874952571522,9.967915186974132],[112.49999453988829,12.615395567393394],[114.07499342354828,17.10851996079568],[114.29999326475222,18.251816319028222],[114.07499342414418,20.796306105108872],[114.20292333351767,22.22205041973683]],[[124.6499859327203,35.419780517080355],[121.49998816420832,35.78566189952622],[120.34246898420574,36.08731090741939]],[[121.46258819070279,25.168986122701572],[121.3874882439044,25.742038029757644],[121.94998784542422,26.751029869608292],[123.5249867296803,28.148653708881287],[123.29998688907226,30.113886122109243],[122.17498768603225,30.74440510438189],[121.92508786246776,30.852678537714777],[122.17498768603225,31.034033881789767],[122.84998720785636,31.46682894547825],[128.0249835418403,32.80049917601868],[128.69998306366443,34.30053553069045],[128.99949285148878,35.158882668071676],[129.5999824260964,34.30053553069045],[129.5999824260964,32.80049917601868],[129.5999824260964,31.6585439519066],[130.49998178852834,30.889328974889438],[132.74998019460836,30.889328974889438],[134.99997860068837,31.274720539181796],[136.7999773255523,32.04078843974209],[137.02497716616034,32.80049917601868],[136.87399727311598,34.32521554537484],[137.69997668798445,33.927972678693564],[138.59997605041642,33.927972678693564],[139.27497557224055,34.114459241194844],[139.8937251339125,34.393419492403375],[140.0343500336966,34.679162981906806],[140.02028754365847,34.84090484813936],[139.95485509060742,34.965046603583694],[140.09059999384857,34.84090484813936],[140.3155998344566,34.679162981906806],[140.4562247354325,34.393419492403375],[140.7374745361925,33.927972678693564],[140.6249746158884,32.421443555350706],[140.17497493467252,30.889328974889438],[138.82497589102445,28.544693071144245],[137.24997700676838,26.95177029188127],[132.74998019460836,23.698382121989734],[130.94998146974444,22.87169786996185],[125.99998497636834,21.203287979625483],[122.84998720785636,20.361858037652684],[121.1125534913859,19.189430262932493],[120.24308317969574,18.601843893825777],[119.1190633914067,17.549363428561175],[119.02498991752027,15.222213115980855],[119.58748951904028,14.569901804622967],[120.14998912056028,13.915654478895796],[121.06600847164388,13.748759175855872],[120.14998912056028,13.2595509442811],[118.79999007691224,12.601672204746537],[116.09999198961616,9.06644423990714],[114.07499342414418,7.730954611330002],[110.69999581502417,5.943831970446426],[110.02499629320025,5.496074035021858],[107.99999772772827,4.599574515521482],[107.09999836529612,4.038674649466248],[105.74999932164808,3.252775080426739],[105.24374968028005,2.803404866588448],[104.62500011860807,1.454368851373345],[104.28790035741287,1.215596520932091],[104.16377044534778,1.210005277728221],[103.98701057056589,1.375392999849927],[104.17793043531677,1.249346186877429],[104.28790035741287,1.299801162778933],[104.42847525782817,1.454368851373345],[104.79374999906415,2.803404866588448],[105.74999932164808,4.150887372137655],[107.09999836529612,4.935905975277167],[107.99999772772827,5.6080461663058],[110.47499597441613,7.730954611330002],[111.71249509776025,9.954064678408844],[112.72499438049614,12.601672204746537],[114.29999326415633,17.095079260964887],[114.52499310536027,18.238460810952724],[114.1874933444483,20.783159233732995],[114.2586832940168,22.305283024668398]],[[114.20292333351767,22.22205041973683],[114.63749302566418,20.796306105108872],[115.42499246779222,18.238460810952724],[116.99999135204831,16.10232559580297],[119.24998975812831,14.365653759228442],[120.14998912056028,13.820086409698062],[121.06600847164388,13.762418337904428]],[[120.12788799225589,21.457551614722192],[118.79999007691224,21.53068533396254],[117.4499910332642,21.84429407917369],[115.64999230840026,22.05298561667754],[114.97499278657615,22.365445686418724],[114.2586832940168,22.31829267897149]]]}},{"type":"Feature","properties":{"id":"asia-submarine-cable-express-asecahaya-malaysia","name":"Asia Submarine-cable Express (ASE)/Cahaya Malaysia","color":"#1ab4dd","feature_id":"asia-submarine-cable-express-asecahaya-malaysia-0","coordinates":[115.69140756853133,11.342080245282876]},"geometry":{"type":"MultiLineString","coordinates":[[[119.44922323177477,18.712816516277243],[118.34999039569617,20.163975031975873],[116.99999135204831,21.111485983488812],[115.64999230840026,21.79207342302722],[114.97499278657615,22.157216226160177],[114.2586832940168,22.31829267897149]],[[139.97546699999984,35.005433000000174],[140.1327874639626,34.89859296336222],[140.11872497392463,34.69072647741027],[140.0624750143684,34.405022750715936],[139.72497525345642,33.93964008831966],[139.94997509406446,32.43331330641721],[139.49997541284839,30.901396088515508],[138.82497589102445,29.73606949729215],[137.24997700676838,28.55704546571133],[132.74998019460836,25.348717422116714],[130.94998146974444,23.91710129093513],[126.85721436909918,19.479734448240144],[124.19998625150441,15.23578178303578],[123.5249867296803,14.365653759228442],[122.95008713694455,14.11652289884896]],[[126.85721436909918,19.466476359726585],[125.54998529515227,19.303604751105766],[120.96988207969743,19.75148716830613],[120.32093903559266,19.44697378735033],[119.44922323177477,18.712816516277243],[118.55016876097629,17.89128877075377],[117.56329095300164,15.439678695520064],[116.21249190992026,12.601672204746537],[114.7499929459683,9.06644423990714],[112.94999422110418,7.730954611330002],[110.02499629320025,5.943831970446426],[109.34999677137613,5.496074035021858],[107.99999772772827,4.935905975277167],[107.09999836529612,4.375264548610226],[105.74999932164808,3.589672857320649],[105.07499979982416,2.803404866588448],[104.54077517827399,1.454368851373345],[104.28790035741287,1.271733251829275],[104.19932042016363,1.308321449396243],[103.98594057132397,1.371284183356795]],[[105.07499979982416,2.803404866588448],[104.79751925424193,2.51340691774082],[104.40000027800004,2.42411333960119],[103.85068066714335,2.29570245694968]],[[130.90187508980648,23.865759963390698],[128.69998306366443,25.55188275942587],[128.0249835418403,25.957179978764344],[127.70078377150666,26.087749635462092]]]}},{"type":"Feature","properties":{"id":"pldt-domestic-fiber-optic-network-dfon","name":"PLDT Domestic Fiber Optic Network (DFON)","color":"#57b947","feature_id":"pldt-domestic-fiber-optic-network-dfon-0","coordinates":[122.75657360976507,11.591340541816004]},"geometry":{"type":"MultiLineString","coordinates":[[[120.62298878548306,14.088232047424347],[120.48748888147225,13.92930384327183],[120.59998880177636,13.710817738179635],[121.04998848299225,13.54681947716878],[121.45623819460526,13.492128176464083],[121.56873811490937,13.273238157547594],[121.46453818932153,13.045782550710932],[121.66873804406832,12.834868817846521],[121.78123796437242,12.615395567393394],[121.94998784542422,12.175887185507976],[122.06248776572832,12.065895273570327],[122.39998752664029,11.955858207114732],[122.74998727869705,11.583202180445051],[122.96248712816029,11.84577637362577],[123.29998688907226,11.84577637362577],[123.41248680937636,11.955858207114732],[123.64617664382837,12.366734534392018],[123.5813366897615,12.505588131780646],[123.5249867296803,12.615395567393394],[123.5249867296803,12.834868817846521],[123.63748664998441,13.054150695298627],[123.74373657471575,13.138994853951246],[123.97498641089636,13.163718917913586],[124.19998625150441,13.054150695298627],[124.19871625240401,12.617971818017347],[124.19998625150441,12.285833556268383],[124.42498609092046,12.120896898039394],[124.60027596793546,12.069904662870776]],[[124.61277595908027,11.006888020676206],[124.53748601241637,10.742581675476407],[124.42498609211226,10.521444685552128],[124.19998625150441,10.41081650540272],[123.89461646783124,10.309948397086673],[124.0874863312003,10.41081650540272],[124.190416258284,10.93201162513164],[124.0874863312003,11.294709319565477],[123.74998657028833,11.294709319565477],[123.29998688907226,10.950037883763155]],[[124.61277595908027,11.006888020676206],[124.6499859327203,10.742581675476407],[124.6499859327203,10.41081650540272],[124.76248585302442,9.967915186974132],[125.0437356531885,9.746236973759974],[125.38123541410047,9.302441529883154],[125.54061530179015,8.947610463936108],[125.32498545454442,9.302441529883154],[125.09998561393637,9.52441134501949],[124.6499859327203,9.413444258507564],[124.53748601241637,9.08033076823294],[124.70623589287226,8.635699417327467],[124.63191594552143,8.454147535358473],[124.42498609211226,8.635699417327467],[124.19998625150441,8.635699417327467],[124.0576568198609,8.325649123940481],[123.84998649944745,8.149939793212361],[123.97498641089636,8.413185359560185],[123.74998657028833,8.8024990629144],[123.41248680937636,9.08033076823294],[123.28143690221334,9.295503918747997]]]}},{"type":"Feature","properties":{"id":"tata-tgn-intra-asia-tgn-ia","name":"Tata TGN-Intra Asia (TGN-IA)","color":"#3eb65c","feature_id":"tata-tgn-intra-asia-tgn-ia-0","coordinates":[118.52909995594489,16.566185059272104]},"geometry":{"type":"MultiLineString","coordinates":[[[107.07919838003114,10.342138429683002],[107.77499788712005,9.635342384764561],[108.6749972495522,9.302441529883154],[111.59999517745614,9.302441529883154],[113.39999390232026,9.08033076823294],[114.97499278657615,9.08033076823294]],[[103.98701057056589,1.375392999849927],[104.18262043199422,1.262333057432152],[104.28790035741287,1.243665035636787],[104.56885015838535,1.454368851373345],[105.13124975997611,2.803404866588448],[105.74999932164808,3.477386828549033],[107.09999836529612,4.263084147817874],[107.99999772772827,4.82381385611519],[109.57499661198416,5.496074035021858],[110.24999613380828,5.943831970446426],[113.17499406171221,7.730954611330002],[114.97499278657615,9.06644423990714],[116.54999167083223,12.601672204746537],[118.01327063423176,15.439678695520064],[119.02751712903839,17.654665480331733],[120.01268452973224,18.601843893825777],[121.04998848299225,19.515816877621507],[122.84998720785636,19.72775079133148],[125.99998497636834,20.361858037652684],[130.94998146974444,21.203287979625483],[138.59997605041642,24.109781889526705]],[[114.18397334694201,22.24961578335278],[114.7499929459683,21.896495662923588],[115.64999230840026,21.635250907408707],[116.99999135204831,21.32123529551186],[118.34999039569617,20.58581909604039],[120.14998912056028,19.740987365524937],[121.04998848299225,19.529070924350908]],[[120.22031319973036,18.785187974742005],[120.59998880058437,18.785187974742005],[121.04998848299225,18.785187974742005],[121.5130781549352,18.418302135034143]]]}},{"type":"Feature","properties":{"id":"asia-pacific-gateway-apg","name":"Asia Pacific Gateway (APG)","color":"#b63894","feature_id":"asia-pacific-gateway-apg-0","coordinates":[121.3880914113467,20.6648377037875]},"geometry":{"type":"MultiLineString","coordinates":[[[103.98701057056589,1.389451396800233],[104.18740042860793,1.302655424517022],[104.48462521805108,1.468426767331968],[105.29999964043219,2.367912558705407],[106.19999900286416,4.613591578862773],[106.19999900286416,5.510071711803246]],[[103.38789161939158,4.128192842398844],[103.95000059678415,3.940475772228814],[106.19999900286416,5.510071711803246],[107.99999772772827,6.852191098754328],[111.03749557593613,9.967915186974132],[112.0499948586722,12.615395567393394],[113.84999358353615,18.251816319028222],[116.99999135204831,19.740987365524937],[120.14998912056028,20.375041253465433],[121.04998848299225,20.58581909604039],[122.84998720785636,21.006499845176737],[124.6499859327203,22.05298561667754],[125.54998529515227,24.12261698700344],[125.9437350162162,25.55188275942587],[126.3374847372803,26.1593079707739],[127.57498386062441,28.55704546571133],[127.79998370123228,29.540507745394493],[128.69998306366443,30.5144959597591],[130.49998178852834,30.320465424761444],[132.74998019460836,30.126049846722832],[134.99997860068837,30.5144959597591],[136.7999773255523,31.286738814391754],[138.59997605041642,32.43331330641721],[139.49997541284839,33.93964008831966],[140.00622505421643,34.405022750715936],[140.09059999384857,34.69072647741027],[140.11872497392463,34.89859296336222],[139.97546699999984,35.005433000000174]],[[128.69998306366443,30.5144959597591],[128.69998306366443,31.670513047087127],[128.92498290427227,32.8123187832876],[129.1499827448803,34.31215165223547],[128.99949285148878,35.17037876180022]],[[113.84999358353615,18.251816319028222],[113.84999358353615,20.796306105108872],[114.2586832940168,22.31829267897149]],[[107.99999772772827,6.852191098754328],[105.29999964043219,7.075530930004602],[103.04883123518101,7.228431783286316],[101.70000219070414,7.187160551695455],[100.5951029728293,7.198818071264419]],[[112.0499948586722,12.615395567393394],[111.59999517745614,13.054150695298627],[110.69999581502417,14.801154224791581],[109.79999645259221,15.56116563526334],[108.89999709016024,15.886035719079029],[108.19247759137373,16.043393005208348]],[[136.7999773255523,31.286738814391754],[137.24997700676838,32.8123187832876],[136.87399727311598,34.33682825203173]],[[127.79998370123228,29.540507745394493],[125.99998497636834,30.708139993541643],[124.19998625150441,31.478822672736147],[122.84998720785636,31.766210259727007],[121.94998784542422,31.670513047087127],[121.39529823837174,31.619800328867754]],[[121.92508786246776,30.86475026744717],[122.17498768603225,30.80481662242606],[124.19998625150441,30.126049846722832],[125.54998529515227,29.540507745394493],[127.57498386062441,28.55704546571133]],[[125.54998529515227,24.12261698700344],[122.84998720785636,24.83931282559271],[122.17498768603225,24.89034854048814],[121.80144795065142,24.863504112487785]]]}},{"type":"Feature","properties":{"id":"seamewe-4","name":"SeaMeWe-4","color":"#187bb6","feature_id":"seamewe-4-0","coordinates":[80.36459874739873,12.86336844112738]},"geometry":{"type":"MultiLineString","coordinates":[[[101.25000250948806,2.480311786858737],[101.70000219070414,2.367912558705407],[102.220901821694,2.273260323566543]],[[66.60002705585578,19.95262290516439],[66.37502721524774,20.375041253465433],[66.60002705585578,23.298598065875897],[67.02854675228855,24.889731701235817]],[[91.99482906593992,21.42927456664916],[91.80000920395213,19.95262290516439],[88.20001175422391,13.492079936238724]],[[79.87208765380376,6.927036656836354],[79.4250179705119,5.957818681088533],[78.46194979910258,4.807252410277582]],[[103.64609081207688,1.338585852071497],[103.50000091556807,1.229280895999666],[103.34065102845322,1.299701188578927],[102.68279723997186,1.698782263242901],[102.15000187192003,2.03066189047467],[101.25000250948806,2.480311786858737],[100.46250306736002,3.266814816815666],[99.7875035455361,4.613591578862773],[99.00000410340806,5.286069860821008],[97.42500521915215,6.405200795356032],[95.40000665368001,8.190543417795496],[93.14492825119876,9.52441134501949],[91.80000920395213,10.41081650540272],[90.00001047908802,12.395734000022975],[88.20001175422391,13.492079936238724],[83.70001494206389,13.929255692764443],[81.45001653598388,13.273238157547594],[80.24298739105474,13.06385310188338],[81.45001653598388,11.073982781226615],[82.1250160560201,9.52441134501949],[82.575015737236,7.744852765542954],[82.35001589841602,5.51002310933014],[81.00001685476798,4.613591578862773],[78.69513048465001,4.613591578862773],[76.39024411453221,6.852191098754328],[74.19513367248999,9.52441134501949],[72.84513462884213,13.492128176464083],[72.2250230710558,16.965102599435927],[72.87590260996693,19.07607425728523]],[[7.755438741914387,36.90282046530194],[7.875068657167333,37.23235432155614],[7.987568577471261,37.94551049545967],[8.10006849777537,38.38775473578444]],[[9.867357245811593,37.276816253475154],[9.675067382031267,37.589786573603064],[9.000067859611436,38.38775473578444]],[[13.358654772543574,38.11612161658339],[13.05006499115128,38.38775473578444],[12.600065309935387,38.51986519151931],[12.150065628719313,38.38775473578444],[10.348617229769278,38.38775473578444],[9.000067859611436,38.38775473578444],[8.10006849777537,38.38775473578444],[7.650068816559295,38.651811712711336],[6.187569852607326,41.74435878948223],[5.372530429989069,43.29362778902908]],[[39.18275647850768,21.481533475502996],[39.26254642198353,20.375041253465433],[40.61254546563157,18.251816319028222],[41.62504474836765,16.534196198259725],[42.4125441904955,14.801154224791581],[42.918793831863546,13.92930384327183],[43.20004363262354,13.054150695298627],[43.31254355292765,12.834868817846521],[43.39691849315569,12.615395567393394],[43.81879319429551,12.395734000022975],[44.55004267627159,12.120896898039394],[45.450042038703735,12.395734000022975],[48.60003980721571,13.163718917913586],[55.35003502545574,15.669513225155248],[58.95003247518379,17.395022634700517],[62.55002992491184,19.95262290516439],[66.60002705585578,19.95262290516439],[70.20002450558383,19.529070924350908],[72.87590260996693,19.07607425728523]],[[62.55002992491184,19.95262290516439],[62.55002992491184,22.469443964829516],[59.85003183761575,24.12261698700435],[58.50003279396771,24.583819112323365],[56.92503390971164,24.89034854048814],[56.33372432860119,25.121690004958644]],[[100.06611334816643,6.613518860854109],[100.0579133539753,6.354447103901104],[99.99248340032653,6.205536229871337],[99.67350362629472,5.956366566270154],[99.00000410340806,5.845915088460266],[97.42500521915215,6.405200795356032]],[[29.8935130590948,31.191465077638455],[30.825052399183495,30.901396088515508],[31.387552000703497,30.708097004575936],[31.9500516022235,30.49024170709155],[32.287551363135464,30.271897570624173],[32.52993119143143,29.972545436050364]],[[12.150065628719313,38.38775473578444],[12.262565549023423,38.21117903702318],[12.262565549023423,36.87321951208928],[12.487565389631278,36.24065523321488],[12.712565230239315,35.876870570092834],[13.500064672367355,35.419780517080355],[14.400064034799321,34.960082324548345],[16.65006244087933,34.49779087043369],[19.350060528175415,34.867831005273345],[22.050058614875596,34.57501887961886],[25.200056383983473,33.846256070003854],[28.800053833711523,32.52821504536491],[29.8935130590948,31.191465077638455],[30.825052399183495,30.828970604876503],[31.387552000703497,30.61132334760356],[31.9500516022235,30.393250512072004],[32.287551363135464,30.223305674181642],[32.54282118230001,29.95909144105574],[32.59687614400701,29.63833609362628],[32.76567602442743,29.344566989489813],[33.04692582518743,28.95155473219332],[33.52527548632113,28.161052262220792],[34.48129980906351,27.364667993860262],[35.43754913164762,26.562513149236715],[36.22504857377548,25.75470426341523],[37.237547856509735,24.12261698700344],[38.47504697985549,22.05298561667754],[39.18275647850768,21.481533475502996]]]}},{"type":"Feature","properties":{"id":"batam-rengit-cable-system-brcs","name":"Batam-Rengit Cable System (BRCS)","color":"#9f2f87","feature_id":"batam-rengit-cable-system-brcs-0","coordinates":[103.52451154276334,1.2427621972838077]},"geometry":{"type":"MultiLineString","coordinates":[[[103.14775116510523,1.67742772118393],[103.34065102845322,1.383978004154865],[103.50000091556807,1.271308356781118],[103.66875079602414,1.074774789350549],[103.78125071632807,1.018534216615524],[104.01375700144472,1.065548217296064]]]}},{"type":"Feature","properties":{"id":"southeast-asia-japan-cable-sjc","name":"Southeast Asia-Japan Cable (SJC)","color":"#6bbd44","feature_id":"southeast-asia-japan-cable-sjc-0","coordinates":[120.61553906063712,20.15077387441527]},"geometry":{"type":"MultiLineString","coordinates":[[[114.57069307298597,4.703623084487131],[114.29999326475222,5.061986954416114],[111.82499501806417,7.744889052551447]],[[103.64609081207688,1.324527159471344],[104.16846044202524,1.223182297279849],[104.28790035741287,1.25769918146722],[104.51270019816243,1.454368851373345],[105.01874983967218,2.803404866588448],[105.74999932164808,3.701945083003531],[107.09999836529612,4.487428146931985],[107.99999772772827,5.047979159038785],[108.6749972495522,5.496074035021858],[109.34999677137613,5.943831970446426],[111.82499501806417,7.730954611330002],[113.73750165524976,9.954064678408844],[114.7499929459683,13.040451242220165],[115.19999262718419,14.787557926772921],[116.09999198961616,18.238460810952724],[120.14998912056028,20.15077387441527],[121.04998848299225,20.15077387441527],[122.84998720785636,20.572653976019456],[125.99998497636834,21.41290665207303],[130.94998146974444,23.28568165987353],[132.74998019460836,24.109781889526705],[137.24997700676838,27.352178406079517],[138.82497589102445,28.939248910255287],[139.94997509406446,30.889328974889438],[140.39997477528055,32.421443555350706],[139.94997509406446,33.927972678693564],[140.3437248151284,34.393419492403375],[140.25934987430446,34.679162981906806],[140.06247501377248,34.84090484813936],[139.95485509060742,34.965046603583694]],[[116.09999198961616,18.251816319028222],[115.19999262718419,19.529070924350908],[114.7499929459683,20.796306105108872],[114.29999326475222,21.635297384859552],[114.20292333351767,22.22205041973683]],[[115.19999262718419,14.801154224791581],[116.99999135204831,14.147583506948735],[118.79999007691224,13.92930384327183],[120.14998912056028,14.147583506948735],[120.62298878548306,14.088232047424347]],[[116.67753158048176,23.355006811273547],[117.1687412325042,22.884654113882444],[118.34999039569617,22.105110548108275],[120.14998912056028,20.163975031975873]]]}},{"type":"Feature","properties":{"id":"europe-india-gateway-eig","name":"Europe India Gateway (EIG)","color":"#a35e29","feature_id":"europe-india-gateway-eig-0","coordinates":[2.5644083518901257,37.80327438205269]},"geometry":{"type":"MultiLineString","coordinates":[[[56.33372432860119,25.121690004958644],[56.92503390971164,24.660522249648846],[57.88604869707389,23.67872371194431],[58.162533033055745,23.968510996734643],[58.72503263457575,24.32780311165181],[59.85003183761575,24.019900203433572],[62.3250300843038,22.469443964829516],[62.3250300843038,19.529070924350908]],[[38.250047139247634,22.05298561667754],[39.18275647850768,21.481533475502996]],[[44.55004267627159,12.065895273570327],[43.65004331383962,11.818224495851856],[43.16164365982675,11.573660387694234]],[[7.42672897477544,43.7382556879356],[7.31256905564733,43.401144973153954],[7.425068975951258,41.74435878948223],[7.969108590548607,39.42351982330401],[8.325068338383225,38.651811712711336],[8.437568258687335,38.38775473578444],[9.000067859611436,38.03417390064187]],[[14.400064034799321,33.98629373718467],[13.500064672367355,33.09551711711581],[13.187364893886881,32.87762290319534]],[[-11.249917794512742,37.94551049545967],[-10.349918432080775,37.94551049545967],[-9.337419149344699,38.122730108392204],[-9.102749315587026,38.4430794831419]],[[32.65318110412008,29.113614162980063],[32.17505144283154,29.295435552938983],[31.72505176161546,29.4915580274035],[31.050052239791533,29.73606949729215],[30.375052717967602,30.101720899554778],[29.98130299690349,30.5144959597591],[29.70093319552029,31.072270031660306],[27.900054471279375,32.052708023486204],[25.200056383983473,32.85958149046064],[22.050058614875596,33.59673284538102],[19.350060528175415,33.565491482352044],[16.65006244087933,33.189714664600466],[14.400064034799321,33.98629373718467],[12.31881550918157,35.419780517080355],[12.037565708415386,35.78566189952622],[11.925065788111276,36.24065523321488],[11.925065786919474,37.23235432155614],[10.91256650537538,37.67887792909206],[10.348617229769278,38.03417390064187],[9.000067859611436,38.03417390064187],[5.400070410479286,38.122730108392204],[2.25007264196731,37.76786242517874],[-2.249924170192707,36.05897312258681],[-4.499922576272716,35.96797434759339],[-5.624921779312721,35.92243557734424],[-6.299921301136742,35.876870570092834],[-8.999919388432733,36.05897312258681],[-9.899918750864702,36.42191605012598],[-11.249917794512742,37.94551049545967],[-13.499916200592752,39.6983233549332],[-13.499916200592752,44.05151922873524],[-9.449919069648717,48.70423463096067],[-7.649920344784692,49.58728674004685],[-6.074881460557064,50.31116725161073],[-4.544402544762735,50.82820142743812]],[[32.65318110412008,29.113614162980063],[33.455063036063365,28.161052262220792],[34.36879988875958,27.364667993860262],[35.26879925119155,26.562513149236715],[36.000048733167624,25.75470426341523],[37.01254801590261,24.12261698700344],[38.250047139247634,22.05298561667754],[39.037546581375494,20.375041253465433],[40.387545625023535,18.251816319028222],[41.4000449077607,16.534196198259725],[42.30004427019158,14.801154224791581],[42.806293911559614,13.92930384327183],[43.14379367247158,13.054150695298627],[43.28441857285158,12.834868817846521],[43.368793513079616,12.615395567393394],[43.76254323414373,12.395734000022975],[44.55004267627159,12.065895273570327],[45.450042038703735,12.285833556268383],[48.60003980721571,13.054150695298627],[55.35003502545574,15.452760959322058],[58.95003247518379,17.180187287481317],[62.3250300843038,19.529070924350908],[66.60002705585578,19.740987365524937],[70.20002450558383,19.42300815558179],[72.87590260996693,19.07607425728523]],[[-5.624921779312721,35.92243557734424],[-5.34758197578279,36.15601951413446]],[[29.70093319552029,31.072270031660306],[29.98130299690349,30.56294325920113],[30.375052717967602,30.199000717300507],[31.050052239791533,29.833707764444064],[31.72505176161546,29.589433775533966],[32.17505144283154,29.344436236246633],[32.65318110412008,29.113614162980063]]]}},{"type":"Feature","properties":{"id":"asia-africa-europe-1-aae-1","name":"Asia Africa Europe-1 (AAE-1)","color":"#a1489b","feature_id":"asia-africa-europe-1-aae-1-0","coordinates":[50.2473894305509,13.095718171129844]},"geometry":{"type":"MultiLineString","coordinates":[[[67.02854675228855,24.889731701235817],[65.25002801161183,24.1568376182792],[61.20003088126379,22.469443964829516]],[[39.18275647850768,21.481533475502996],[37.57504761742352,22.05298561667754]],[[43.14799366949638,11.594869371447825],[43.65004331383962,11.735552251813294],[44.55004267627159,11.790718790556442]],[[5.372530429989069,43.29362778902908],[6.300069772911254,41.74435878948223],[7.875068657167333,38.651811712711336],[8.212568418079297,38.38775473578444],[9.000067859611436,38.122730108392204],[10.348617229769278,38.122730108392204],[10.91256650537538,37.76786242517874],[12.037565707223584,37.23235432155614],[12.037565708415386,36.24065523321488],[12.37506546932735,35.78566189952622],[12.825065150547426,35.419780517080355],[14.400064034799321,34.405022750715936],[16.65006244087933,33.75276987113061],[19.350060528175415,34.12610104005753],[22.050058614875596,34.017381950753595],[25.200056383983473,33.28381101905092],[27.900054471279375,32.33831157801293],[29.70093319552029,31.072270031660306],[29.98130299690349,30.708139993541643],[30.375052717967602,30.49026324996359],[31.050052239791533,30.126049846722832],[31.72505176161546,29.88249116219725],[32.17505144283154,29.49129689877981],[32.65318110412008,29.113614162980063],[33.39891307584246,28.161052262220792],[33.97505016769547,27.364667993860262],[34.76254960982351,26.562513149236715],[35.32504921134351,25.75470426341523],[36.33754849407959,24.12261698700344],[37.57504761742352,22.05298561667754],[38.36254705955156,20.375041253465433],[39.7125461031996,18.251816319028222],[40.72504538593768,16.534196198259725],[41.90629454912765,14.801154224791581],[42.4125441904955,13.92930384327183],[42.94691881193962,13.054150695298627],[43.1859811425856,12.834868817846521],[43.270356082813635,12.615395567393394],[43.56566837361158,12.395734000022975],[44.55004267627159,11.790718790556442],[45.450042038703735,11.84577637362577],[48.60003980781179,12.615395567393394],[55.35003502545574,14.5835116451186],[58.95003247518379,16.10232559580297],[61.20003088126379,17.395022634700517],[66.60002705585578,18.465364393137126],[71.77502338983992,16.965102599435927],[71.94513526640998,13.492128176464083],[73.29513431005802,9.52441134501949],[75.49024475210024,6.852191098754328],[78.69513048465001,4.164912849976942],[79.65001781052403,3.641132700076536],[81.00001685476798,3.266814816815666],[85.500013666928,3.715978119298069],[90.00001047908802,4.613591578862773],[92.70000856638391,5.061986954416114],[94.27500745064,6.05726941024067],[95.38036761595671,6.386923570693734],[95.38114147367385,6.387150045560412],[95.3819171164044,6.387368581851069],[95.38269448296793,6.387579177173141],[95.38347351218404,6.387781829134064],[95.38425414287228,6.387976535341275],[95.38503631385225,6.388163293402209],[95.3858199639435,6.388342100924303],[95.38660503196563,6.388512955514994],[95.38739145673821,6.388675854781716],[95.3881791770808,6.388830796331908],[95.388968131813,6.388977777773004],[95.38975825975437,6.38911679671244],[95.3905494997245,6.389247850757656],[95.39134179054295,6.389370937516083],[95.3921350710293,6.389486054595161],[95.39292928000313,6.389593199602325],[95.39372435628403,6.389692370145011],[95.39452023869154,6.389783563830655],[95.39531686604526,6.389866778266694],[95.39611417716478,6.389942011060564],[95.39691211086965,6.390009259819701],[95.39771060597947,6.390068522151541],[95.39850960131379,6.390119795663521],[95.3993090356922,6.390163077963077],[95.40010884793428,6.390198366657645],[95.4009089768596,6.390225659354662],[95.40170936128773,6.390244953661562],[95.40250994003826,6.390256247185783],[95.40331065193077,6.390259537534762],[95.40411143578481,6.390254822315933],[95.40491223041998,6.390242099136734],[95.40571297465584,6.3902213656046],[95.40651360731198,6.390192619326969],[95.40731406720798,6.390155857911275],[95.4081142931634,6.390111078964956],[95.40891422399781,6.390058280095448],[95.40971379853082,6.389997458910186],[95.41051295558196,6.389928613016608],[95.41131163397085,6.389851740022148],[95.41210977251704,6.389766837534244],[95.41290731004013,6.389673903160332],[95.41370418535966,6.389572934507847],[95.41450033729524,6.389463929184227],[95.41529570466642,6.389346884796907],[95.4160902262928,6.389221798953324],[95.41688384099393,6.389088669260913],[95.41767648758942,6.388947493327112],[95.4184681048988,6.388798268759357],[95.42004800693766,6.388475664151725],[95.42004800693766,6.388475664151725],[97.42500521915215,5.957818681088533],[99.67500362523216,5.733989114150127],[100.40982310408316,5.368393581488473]],[[72.87590260996693,19.07607425728523],[71.77502338983992,16.965102599435927]],[[61.20003088126379,17.395022634700517],[61.20003088126379,22.469443964829516],[59.85003183761575,23.50508968095737],[59.04197976437238,23.912037834470418],[58.50003279396771,24.199600518565422],[56.92503390971164,24.788256058863375],[56.33372432860119,25.121690004958644],[56.92503390971164,25.348717422116714],[56.98128386986378,26.1593079707739],[56.812533989407704,26.562513149236715],[56.2500343878877,26.663094151095223],[55.80003470667163,26.31067461914043],[55.35003502545574,26.1593079707739],[53.55003630059162,26.1593079707739],[52.20003725694376,25.957179978764344],[51.519277739200085,25.294608758024626]],[[58.60608271884114,23.576084864537346],[58.95003247518379,23.60821444135838],[59.85003183761575,23.50508968095737]],[[97.42500521915215,5.957818681088533],[99.00000410340806,6.069699469736006],[100.06611334816643,6.613518860854109],[100.23765322664612,6.908035999527216],[100.5951029728293,7.198818071264419],[101.70000219070414,7.24296510649207],[103.04883123518101,7.340023741610628],[105.29999964043219,7.29876275445952],[107.99999772772827,8.07917551824101],[110.24999613380828,9.967915186974132],[111.59999517745614,12.615395567393394],[113.6249937429283,18.251816319028222],[113.73749366323221,20.796306105108872],[114.26024329291157,22.20803425139447]],[[103.50674091079348,10.63040170321047],[103.72500075617612,9.52441134501949],[103.72500075617612,8.190543417795496],[103.04883123518101,7.340023741610628]],[[22.57920153405357,33.894592669629816],[22.950057977903466,35.419780517080355],[23.512557579423465,35.78566189952622],[23.737557420031504,35.78566189952622],[24.012167225495322,35.512042558637575]],[[45.033542333756095,12.800877546853616],[45.450042038703735,11.84577637362577]],[[19.350060528175415,34.12610104005753],[19.350060528175415,37.67887792909206],[18.957770806077196,39.48474996079946],[18.787560926655413,40.04369219283004],[18.675061006351484,40.38732029077508],[18.00006148452737,40.89949091487166],[16.868812285914967,41.12570905852263]],[[100.06611334816643,6.613518860854109],[100.3500531470208,6.908035999527216],[100.5951029728293,7.198818071264419]],[[94.39121736831608,16.85803225570866],[93.60000792881607,16.749771315644697],[92.25000888516803,14.801154224791581],[90.90000984151999,9.52441134501949],[90.4500101603039,6.181561339870244],[90.00001047908802,4.613591578862773]],[[107.07919838003114,10.342138429683002],[107.77499788712005,9.85709470870232],[108.6749972495522,9.746236973759974],[110.24999613380828,9.967915186974132]],[[29.70093319552029,31.072270031660306],[29.98130299690349,30.75649044225256],[30.375052717967602,30.587157843202064],[31.050052239791533,30.223305674181642],[31.72505176161546,29.979986367503503],[32.17505144283154,29.49129689877981],[32.65318110412008,29.113614162980063]]]}},{"type":"Feature","properties":{"id":"pishgaman-oman-iran-poi-network","name":"Pishgaman Oman Iran (POI) Network","color":"#90499c","feature_id":"pishgaman-oman-iran-poi-network-0","coordinates":[59.01149514547928,24.853239702978335]},"geometry":{"type":"MultiLineString","coordinates":[[[60.63284128306634,25.2959287691298],[60.30003151883183,25.145210227401346],[58.95003247518379,24.83931282559271],[58.050033112751635,24.12261698700344],[57.88604869707389,23.679602601492906]],[[57.79730329178807,25.681322707882497],[57.95931317701873,25.190816399183323],[57.93753319244771,24.12261698700344],[57.88604869707389,23.67872371194431]]]}},{"type":"Feature","properties":{"id":"omranepeg","name":"OMRAN/EPEG","color":"#3e67b1","feature_id":"omranepeg-0","coordinates":[59.06870617296035,24.768569858738083]},"geometry":{"type":"MultiLineString","coordinates":[[[57.88605322832058,23.67872342575357],[58.106283072903786,24.12261698700344],[58.95003247518379,24.73717827217609],[60.30003151883183,25.094280247013153],[60.63284378306454,25.29593018180523]],[[57.79729891679104,25.6813269323404],[57.79729891679104,25.19081696474251],[57.32386175217833,24.420846844473278]],[[56.246811733920715,26.181306905191125],[56.362534308191634,26.411476060868516],[56.58753414879967,26.411476060868516],[56.70003406910378,26.1593079707739],[56.570834941879646,25.790848374316596],[56.257944538534126,25.616974601949348]],[[56.257944538534126,25.616974601949348],[56.58753414879967,25.348717422116714],[57.32386175217833,24.420846844473278],[57.88605322832058,23.67872342575357]]]}},{"type":"Feature","properties":{"id":"bay-of-bengal-gateway-bbg","name":"Bay of Bengal Gateway (BBG)","color":"#a4332b","feature_id":"bay-of-bengal-gateway-bbg-0","coordinates":[73.99739965125022,9.217315450838143]},"geometry":{"type":"MultiLineString","coordinates":[[[58.72503263457575,24.94136317175375],[58.162533033055745,24.01990020343248],[57.88605322832058,23.67872342575357]],[[97.42500521915215,7.131299528983607],[95.40000665368001,6.504566780877437],[94.27500745064,6.380356264139964],[92.70000856638391,5.510071711803246],[90.00001047908802,5.510071711803246],[85.500013666928,4.613591578862773],[81.00001685476798,3.491423322320592],[79.65001781052403,3.865649782482034],[78.69513048465001,4.389285926050993],[75.94024443331631,6.852191098754328],[73.74513399127409,9.52441134501949],[72.39513494762605,13.492128176464083],[72.00002323044777,16.965102599435927],[66.82502689646383,20.58581909604039],[62.662529845215765,23.91710129093513],[59.85003265596724,24.839308763626327],[58.72503263457575,24.94136317175375],[56.92503390971164,25.017845517489846],[56.33372432860119,25.121690004958644]],[[72.00002323044777,16.965102599435927],[72.87590260996693,19.07607425728523]],[[80.24298739105474,13.06385310188338],[81.45001653598388,12.615395567393394],[83.70001494206389,12.175887185507976],[89.10001111665605,10.85308969074528],[93.1500082476,9.296323017976968],[95.40000665368001,7.967776882259704],[97.42500521915215,7.131299528983607],[99.67500362523216,5.622041180883233],[100.40982310408316,5.368393581488473]],[[79.88937764155526,6.82077608459704],[79.53751789081602,5.957818681088533],[78.45083428096896,4.580645089775046]]]}},{"type":"Feature","properties":{"id":"jasuka","name":"JaSuKa","color":"#32499f","feature_id":"jasuka-0","coordinates":[108.86108464351302,0.12453318129583016]},"geometry":{"type":"MultiLineString","coordinates":[[[105.25409967294819,-5.409293894312003],[105.35624960058415,-5.721872747834119],[105.88026922936372,-5.868190997616806],[106.4249988434722,-5.721872747834119],[106.83339855415794,-6.171588071824116]],[[107.12099835041957,-5.981154260263285],[107.21249828560005,-5.273944363641298],[107.66249796681612,-4.60145376483711],[107.32599820519597,-3.029995968008661],[107.30493637508928,-2.783713633979089],[107.41104629497711,-2.62823853632246],[107.66288796654008,-2.767442755874634],[107.66288796654008,-2.517708985005663],[107.76054789735689,-2.237970538313992],[108.22499756833612,-1.231315750217412],[108.89999709016024,-0.218910724747347],[109.33554678161278,-0.027021392288274],[108.89999709016024,0.118588418888312],[104.8499999592161,0.737317714243841],[104.40000027800004,0.793562652607196],[104.2875003576961,0.906050180869095],[104.17500043739219,0.962292662396848],[104.0166370000003,1.066798000000349],[103.88438064327008,1.159128690702065],[103.50000091556807,1.15908369994202],[103.34065102845322,1.131139301987234],[102.68279723997186,1.473918781768247],[102.15000187192003,1.637114620370998],[101.72812717078021,1.693340822791726],[101.44766236946417,1.665522797277061]]]}},{"type":"Feature","properties":{"id":"indonesia-global-gateway-igg-system","name":"Indonesia Global Gateway (IGG) System","color":"#5a9f43","feature_id":"indonesia-global-gateway-igg-system-0","coordinates":[113.43510331475238,-5.562666118998097]},"geometry":{"type":"MultiLineString","coordinates":[[[101.44766236946417,1.665522797277061],[101.72812717078021,1.749465440761394],[102.15000187192003,1.74956539407541],[102.68279723997186,1.530149411893926],[103.34065102845322,1.215421560433802],[103.50000091556807,1.187252773694101],[103.89883584657089,1.191468968816909],[104.1378104637381,1.238391276726424],[104.28790035741287,1.173518198634015],[104.62500011860807,1.131014326431719],[104.8499999592161,1.018534216615524],[105.29999964043219,1.018534216615524],[106.64999868408005,-0.331409329660265],[107.0366484101739,-2.130918480960333],[107.15309832767961,-3.029995968008661],[107.21249828560005,-4.60145376483711],[112.0499948586722,-5.049857167366764],[114.46970572643436,-5.945707155070644],[116.58310164737706,-5.749115659923423],[117.4499910332642,-4.37714437553184],[117.59999092700289,-2.730375485267853],[117.85190631946367,-1.51533365197483],[118.54128832686715,-0.810555324740758],[119.24998975693651,0.568578852526193],[119.47498959814027,1.018534216615524],[120.59998880177636,1.918228780215599],[124.19998625150441,1.918228780215599],[124.8396357983706,1.490779296094715]],[[107.21249828560005,-4.60145376483711],[106.76249860438416,-5.273944363641298],[106.83339855415794,-6.1289648492105]],[[103.64609081207688,1.338585852071497],[103.89883584657089,1.191468968816909]],[[104.0166370000003,1.066798000000349],[103.89883584657089,1.191468968816909]],[[114.46970572643436,-5.945707155070644],[113.39999390232026,-6.616650693475355],[113.28349007860268,-6.902248768835711]],[[116.58310164737706,-5.749115659923423],[116.54999167083223,-6.616650693475355],[115.8749921490083,-8.178490278944933],[115.84442576381582,-8.371311450744278],[115.69240000000046,-8.405888110965844]],[[117.4499910332642,-4.37714437553184],[118.79999007691224,-4.937784304559489],[119.41238964308275,-5.152180217334703]],[[120.59998880177636,1.918228780215599],[118.79999007691224,2.817450442654169],[117.89999071448027,3.154491498099848],[117.67499087387223,3.154491498099848],[117.57851094221976,3.327354396392341]],[[116.83129147155694,-1.265389667588013],[117.08124129449006,-1.402870227222247],[117.85190631946367,-1.51533365197483]]]}},{"type":"Feature","properties":{"id":"global-caribbean-network-gcn","name":"Global Caribbean Network (GCN)","color":"#5dba46","feature_id":"global-caribbean-network-gcn-0","coordinates":[-62.5965537030894,16.77751655453056]},"geometry":{"type":"MultiLineString","coordinates":[[[-64.5748800186092,18.144943564296213],[-64.83573983381369,18.415247054142775],[-65.36237946073724,18.572039052566783],[-65.53112934119322,18.572039052566783],[-65.69987922164921,18.678647022154717],[-65.86862910210529,18.678647022154717],[-66.10666893347558,18.46610423294742]],[[-63.08246600000027,18.06751899999977],[-63.464190806027844,17.71178205639041],[-63.464190806027844,17.6164969787067],[-63.28321093423575,17.422853263294805],[-62.943581174236634,17.180187287481317],[-61.87488193131321,15.940130106909258],[-61.746192023074286,16.020844700000207],[-61.607792120522106,15.892797111330715],[-61.5263921781866,15.988768942769152],[-61.57168214610275,16.244479667932865]],[[-64.81925984548825,17.773909269375704],[-64.5748800186092,18.144943564296213],[-64.12488033739322,18.251816319028222],[-63.4498808155692,18.144943564296213],[-63.08246600000027,18.06751899999977],[-62.99988113435322,17.986414235906896],[-62.85055124073587,17.897908]]]}},{"type":"Feature","properties":{"id":"jakarta-bangka-bintan-batam-singapore-b3js","name":"Jakarta-Bangka-Bintan-Batam-Singapore (B3JS)","color":"#1ebab0","feature_id":"jakarta-bangka-bintan-batam-singapore-b3js-0","coordinates":[105.62504582957571,-1.6430063460614153]},"geometry":{"type":"MultiLineString","coordinates":[[[106.82782855810404,-6.171876390816321],[106.31249892316808,-5.273944363641298],[106.53749876377611,-4.60145376483711],[106.53749876377611,-3.479268678970064],[106.54745875672049,-3.079924874677993],[106.19999900286416,-2.580536704984131],[105.74999932164808,-1.906058394384765],[105.5827094401579,-1.553879793691628],[105.4124995613322,-1.231315750217412],[105.07499980042023,0.118588418888312],[104.8499999592161,0.290337139339023],[104.17500043739219,0.398835084041189],[103.66875079602414,0.793562652607196],[103.66875079602414,1.018534216615524],[103.87813064769749,1.190951974272981],[104.0166370000003,1.066798000000349],[103.97815057624668,1.185378176915766],[103.94648059927786,1.327258925921003],[104.01133055333753,1.233727365552387],[104.14960045538582,1.18452336036332],[104.34415031696894,1.201587148227037],[104.42580025912726,1.136713206281876]]]}},{"type":"Feature","properties":{"id":"seamewe-5","name":"SeaMeWe-5","color":"#c71f8e","feature_id":"seamewe-5-0","coordinates":[41.37831144972093,16.761911867249623]},"geometry":{"type":"MultiLineString","coordinates":[[[59.366662179443516,22.700003992423866],[59.51253207610789,22.884654113882444],[59.85003183761575,24.430271928050523]],[[43.16164365982675,11.573660387694234],[43.65004331383962,11.70798932055409],[44.55004267627159,11.735650161405832]],[[80.53985718074962,5.940820740520149],[81.00001685476798,5.510071711803246],[82.80001557963192,5.510071711803246],[85.500013666928,6.405200795356032],[90.00001047908802,7.29876275445952],[92.70000856638391,6.852191098754328],[94.27500745064,6.852191098754328],[95.40000665368001,6.740481724921185],[97.42500521915215,6.237476972533139],[98.77500426280001,5.286069860821008],[99.56250370492806,4.613591578862773],[100.3500031470561,3.266814816815666],[101.25000250948806,2.367912558705407],[102.15000187192003,1.974446286104158],[102.68279723997186,1.670619462234952],[103.34065102845322,1.271608282704793],[103.50000091556807,1.215271594284278],[103.64609081207688,1.338585852071497]],[[5.930340034831254,43.125291587014985],[6.300069772911254,42.743713464436695],[6.525069613519291,41.74435878948223],[8.10006849777537,38.651811712711336],[8.325068338383225,38.38775473578444],[9.000067859611436,38.21117903702318],[10.348617229769278,38.21117903702318],[10.91256650537538,37.85673997565852],[12.150065627527512,37.23235432155614],[12.150065628719313,36.24065523321488],[12.487565389631278,35.78566189952622],[12.99381503100241,35.419780517080355],[14.400064034799321,34.54413627297858],[16.65006244087933,33.93964008831966],[19.350060528175415,34.31215165223547],[22.050058614875596,34.157137999942634],[25.200056383983473,33.42476549736121],[27.900054471279375,32.43331330641721],[29.70093319552029,31.072270031660306],[29.98130299690349,30.466024503890644],[30.375052717967602,30.00434523699696],[31.050052239791533,29.63833609362628],[31.72505176161546,29.393587625053524],[32.17505144283154,29.246411345890234],[32.65318110412008,29.113614162980063],[33.46910052611805,28.161052262220792],[34.42504984891155,27.364667993860262],[35.35317419141958,26.562513149236715],[36.11254865347155,25.75470426341523],[37.12504793620581,24.12261698700344],[38.36254705955156,22.05298561667754],[39.1500465016796,20.375041253465433],[40.500045545327644,18.251816319028222],[41.51254482806354,16.534196198259725],[42.35629423034354,14.801154224791581],[42.86254387171158,13.92930384327183],[43.17191865254765,13.054150695298627],[43.29848106288953,12.834868817846521],[43.38285600311756,12.615395567393394],[43.53754339353551,12.395734000022975],[44.55004267627159,11.735650161405832],[45.450042038703735,11.735650161405832],[48.60003980781179,12.505588131780646],[55.35003502545574,14.365653759228442],[58.95003247518379,15.886035719079029],[60.975031038271794,15.994209911785974],[65.75491565746178,16.88633703423319],[70.65002418679991,13.41205503289061],[72.10979915852461,9.280734132427842],[74.30490960056683,6.606897166243519],[76.95001972382386,5.510071711803246],[79.65001781111994,5.510071711803246],[80.53985718074962,5.940820740520149]],[[29.70093319552029,31.072270031660306],[29.98130299690349,30.41748579633928],[30.375052717967602,29.906873916731318],[31.050052239791533,29.540507745394493],[31.72505176161546,29.295522763665502],[32.17505144283154,29.197363639715668],[32.65318110412008,29.113614162980063]],[[65.75491565746178,16.88633703423319],[63.45836261477361,20.88077116880577],[63.22502944673577,22.469443964829516],[59.85003183761575,24.430271928050523],[58.50003279396771,24.73717827217609],[56.92503390971164,24.94136317175375],[56.33372432860119,25.121690004958644]],[[67.02854675228855,24.889731701235817],[65.25002801161183,24.054148269801107],[63.22502944673577,22.469443964829516]],[[90.11661039648769,21.820818029820398],[90.90000984151999,20.375041253465433],[91.80000920395213,17.82393441253792],[91.80000920395213,14.801154224791581],[90.4500101603039,9.52441134501949],[90.22501031969605,7.967776882259704],[90.00001047908802,7.29876275445952]],[[102.220901821694,2.273260323566543],[102.15000187192003,1.974446286104158]],[[94.39121736831608,16.85803225570866],[93.60000792881607,16.965102599435927],[91.80000920395213,17.82393441253792]],[[16.65006244087933,33.93964008831966],[16.425062600271474,34.683017659857974],[16.20006275966344,35.78566189952622],[16.08756283935933,36.87321951208928],[15.750063078447363,37.32187222983504],[15.06744356202158,37.51344748573393]],[[27.900054471279375,32.43331330641721],[28.800053833711523,34.31215165223547],[28.575053993103488,36.1498667868178],[28.462554072799378,36.51238821239364],[28.253574220842975,36.85525019317097]],[[99.56250370492806,4.613591578862773],[98.67598433294692,3.752031394331533]],[[101.25000250948806,2.367912558705407],[101.36255242975687,2.143087178471855],[101.44766236946417,1.665522797277061]],[[42.95452380595619,14.797809010241023],[42.75004395140765,14.746763925028056],[42.35629423034354,14.801154224791581]],[[38.10697724059967,24.070648010417838],[37.80004745803156,24.01990020343248],[37.34051417738748,24.03538146018735],[37.12504793620581,24.12261698700344]]]}},{"type":"Feature","properties":{"id":"italy-malta","name":"Italy-Malta","color":"#a7732a","feature_id":"italy-malta-0","coordinates":[15.733540232516354,36.53285728608339]},"geometry":{"type":"MultiLineString","coordinates":[[[15.06744356202158,37.51344748573393],[15.750063078447363,37.23235432155614],[15.918812958903438,36.87321951208928],[15.525063237839326,36.1498667868178],[15.075063556623434,35.92243557734424],[14.492544437439456,35.92096569276682]]]}},{"type":"Feature","properties":{"id":"epic-malta-sicily-cable-system-emscs","name":"Epic Malta-Sicily Cable System (EMSCS)","color":"#48b648","feature_id":"epic-malta-sicily-cable-system-emscs-0","coordinates":[15.538157320636477,36.53259125431128]},"geometry":{"type":"MultiLineString","coordinates":[[[15.06744356202158,37.51344748573393],[15.637563158143436,37.23235432155614],[15.750063078447363,36.87321951208928],[15.30006339723147,36.1498667868178],[15.075063556623434,36.013486867197166],[14.380044048981722,35.93406361398958]]]}},{"type":"Feature","properties":{"id":"hannibal-system","name":"HANNIBAL System","color":"#5ab946","feature_id":"hannibal-system-0","coordinates":[11.869247045735086,37.15682288531832]},"geometry":{"type":"MultiLineString","coordinates":[[[11.090886379052035,36.84993699396254],[11.4273661406866,36.84931057824871],[12.173015612461494,37.368220507849735],[12.591375316091606,37.65058617278613]]]}},{"type":"Feature","properties":{"id":"didon","name":"Didon","color":"#37b44d","feature_id":"didon-0","coordinates":[11.846552497834175,37.23066081797086]},"geometry":{"type":"MultiLineString","coordinates":[[[11.090886379052035,36.84993699396254],[11.4273661406866,36.93929548035904],[12.173015612461494,37.45757668483838],[12.591375316091606,37.65058617278613]]]}},{"type":"Feature","properties":{"id":"mednautilus-submarine-system","name":"MedNautilus Submarine System","color":"#53b847","feature_id":"mednautilus-submarine-system-0","coordinates":[25.35702498681571,34.50526654887337]},"geometry":{"type":"MultiLineString","coordinates":[[[34.97190100000024,32.76170000000019],[34.200050008303506,33.09551711711581],[31.050052239791533,33.565491482352044],[28.800053833711523,34.31215165223547],[26.574796150245934,35.455341306260905],[25.933624491168022,35.836586354437685],[25.386282830980196,35.785858781085906],[24.012167225495322,35.512042558637575],[23.737557420031504,35.83127933955618],[22.950057977903466,35.96797434759339],[18.00006148452737,36.87321951208928],[15.750063078447363,37.589786573603064],[15.06744356202158,37.51344748573393]],[[15.06744356202158,37.51344748573393],[15.750063078447363,37.50058844605323],[18.00006148452737,36.51238821239364],[19.342345691678812,36.33070897538892],[22.050058614875596,35.54192681258013],[23.400057658523455,35.08292270029031],[25.200056383983473,34.54413627297858],[31.050052239791533,33.09551711711581],[33.750050327087614,32.33831157801293],[34.76967960477271,32.04501185826483],[34.53754976861957,32.243210016262736],[34.65004968892368,32.62301664000789],[34.97190100000024,32.76170000000019]],[[24.012167225495322,35.512042558637575],[23.962557260639358,35.78566189952622],[23.850057339143632,36.51238821239364],[23.737557420031504,37.589786573603064],[23.73618742100205,37.97607797573181]],[[34.200050008303506,33.09551711711581],[33.750050327087614,33.565491482352044],[33.750050327087614,34.49779087043369],[33.61060042587536,34.82728147271538]],[[28.988233700403125,41.04061756347715],[28.305814183835448,40.72743084616859],[27.678864627972537,40.55231865816978],[27.18003498134806,40.495681130064526],[26.754935282492717,40.43735937424689],[26.677105337628205,40.3798096956335],[26.60562538826515,40.30783009842792],[26.531695440637915,40.26268557434711],[26.481695476058352,40.22138987189183],[26.388625541989953,40.19170400909338],[26.39077554046697,40.13245384394122],[26.34819557063097,40.08674097664053],[26.260015633098497,40.04234480534568],[26.09819574773315,39.97772318814789],[25.42505622459151,39.524987333511675],[25.42505622459151,38.651811712711336],[25.875055905807404,37.411283634923244],[26.212555666719368,37.23235432155614],[26.325055587023478,36.87321951208928],[25.875055905807404,36.602754740329765],[25.933624491168022,35.836586354437685]],[[25.875055905807404,36.602754740329765],[25.42505622459151,36.602754740329765],[24.975056543375437,36.33133835588799],[24.52505686215936,36.33133835588799],[24.187557101843478,36.51238821239364],[23.850057340335432,37.589786573603064],[23.73618742100205,37.97607797573181]],[[28.988233700403125,41.04061756347715],[28.283554199604758,40.7445175271339],[27.67066463378159,40.571463184888],[27.204354964119698,40.51728976261815],[26.755425282145552,40.45564648003217],[26.64400536107653,40.37549034333631],[26.59292539726206,40.31736166816431],[26.51655545136326,40.27677072796649],[26.447805500066316,40.223703402432285],[26.372225553608057,40.19439284484717],[26.376225550774205,40.14678775490784],[26.344195573464642,40.1045953658932],[26.246055642987802,40.05564908987763],[25.3125563042874,39.524987333511675],[25.3125563042874,38.651811712711336],[25.650056065199365,37.411283634923244],[25.200056383983473,37.23235432155614],[24.63755678246347,37.277126582876754],[24.166306973235315,37.239619217488034],[23.962557260639358,37.589786573603064],[23.73618742100205,37.97607797573181]]]}},{"type":"Feature","properties":{"id":"channel-islands-9-liberty-submarine-cable","name":"Channel Islands-9 Liberty Submarine Cable","color":"#3a499e","feature_id":"channel-islands-9-liberty-submarine-cable-0","coordinates":[-2.86354726673046,50.11697655271897]},"geometry":{"type":"MultiLineString","coordinates":[[[-3.598459602999541,50.32426129232395],[-3.149923532624674,50.23926850952171],[-2.812423771712709,50.09514516168246],[-2.474924010800744,49.58728674004685],[-2.533373969394162,49.503391023483246]]]}},{"type":"Feature","properties":{"id":"circe-south","name":"Circe South","color":"#cb218e","feature_id":"circe-south-0","coordinates":[0.9178745154388831,50.47986575279056]},"geometry":{"type":"MultiLineString","coordinates":[[[0.366673976184613,50.81931962098235],[0.450073917103194,50.740281893948165],[1.350073279535343,50.23926850952171],[1.493433177977853,50.17917387330185]]]}},{"type":"Feature","properties":{"id":"uk-channel-islands-7","name":"UK-Channel Islands-7","color":"#923e97","feature_id":"uk-channel-islands-7-0","coordinates":[-2.7645302674141687,50.19480860949745]},"geometry":{"type":"MultiLineString","coordinates":[[[-2.533373969394162,49.503391023483246],[-2.418674050648689,49.58728674004685],[-2.699923851408691,50.167261162927154],[-3.037423612320747,50.31116725161073],[-3.598459602999541,50.32426129232395]]]}},{"type":"Feature","properties":{"id":"pan-european-crossing-uk-belgium","name":"Pan European Crossing (UK-Belgium)","color":"#60b446","feature_id":"pan-european-crossing-uk-belgium-0","coordinates":[2.201398555109915,51.30637567738274]},"geometry":{"type":"MultiLineString","coordinates":[[[1.440993215126906,51.35857144425905],[1.800072960751236,51.30637567738274],[2.475072482575348,51.30637567738274],[2.961892137707676,51.246659538426435]]]}},{"type":"Feature","properties":{"id":"concerto","name":"Concerto","color":"#33c0cc","feature_id":"concerto-0","coordinates":[3.0774214030658826,52.217550300511036]},"geometry":{"type":"MultiLineString","coordinates":[[[4.524191030960483,52.36366638180829],[4.520747594069142,52.36330677716338],[4.516013673110618,52.36282489532795],[4.510043038254378,52.36222583101928],[4.502889459669888,52.361514678954684],[4.494606707526613,52.36069653385145],[4.485248551994019,52.359776490426896],[4.474868763241573,52.35875964339831],[4.463521111438739,52.357651087483],[4.451259366754986,52.35645591739826],[4.438137299359777,52.355179227861385],[4.42420867942258,52.35382611358969],[4.409527277112859,52.35240166930047],[4.394146862600081,52.350910989711025],[4.378121206053712,52.34935916953866],[4.361504077643218,52.347751303500665],[4.344349247538065,52.34609248631435],[4.326710485907718,52.34438781269701],[4.308641562921644,52.34264237736595],[4.290196248749308,52.34086127503847],[4.271428313560177,52.33904960043186],[4.252391527523717,52.33721244826344],[4.233139660809392,52.335354913250505],[4.21372648358667,52.333482090110344],[4.194205766025015,52.33159907356026],[4.174631278293895,52.32971095831757],[4.155056790562774,52.32782283909956],[4.135536073001121,52.32593981062353],[4.116122895778398,52.32406696760679],[4.096871029064074,52.32220940476663],[4.077834243027612,52.32037221682035],[4.059066307838481,52.31856049848527],[4.040620993666145,52.31677934447867],[4.022552070680072,52.31503384951786],[4.004913309049725,52.31332910832014],[3.987758478944571,52.3116702156028],[3.971141350534078,52.31006226608316],[3.955115693987709,52.30851035447851],[3.939735279474931,52.30701957550614],[3.92505387716521,52.30559502388337],[3.911125257228013,52.30424179432749],[3.898003189832804,52.3029649815558],[3.88574144514905,52.30176968028561],[3.874393793346218,52.30066098523421],[3.864014004593771,52.299643991118906],[3.854655849061178,52.29872379265699],[3.846373096917903,52.29790548456578],[3.839219518333412,52.297194161562565],[3.833248883477172,52.29659491836465],[3.825071525627307,52.2957530502539],[3.825071525627307,52.2957530502539],[3.816071532002986,52.29422355935958],[3.807071538378665,52.292694041875734],[3.798071544754344,52.291164497443546],[3.789071551130024,52.2896349257042],[3.780071557505705,52.288105326298854],[3.771071563881385,52.2865756988687],[3.762071570257066,52.28504604305491],[3.753071576632747,52.28351635849867],[3.744071583008429,52.28198664484115],[3.735071589384111,52.28045690172353],[3.726071595759793,52.27892712878699],[3.717071602135475,52.277397325672695],[3.708071608511158,52.27586749202184],[3.699071614886841,52.274337627475596],[3.690071621262524,52.27280773167514],[3.681071627638207,52.27127780426164],[3.67207163401389,52.2697478448763],[3.663071640389574,52.268217853160266],[3.654071646765257,52.26668782875474],[3.645071653140941,52.265157771300885],[3.636071659516625,52.26362768043989],[3.627071665892308,52.26209755581292],[3.618071672267992,52.26056739706116],[3.609071678643677,52.25903720382579],[3.60007168501936,52.25750697574799],[3.591071691395044,52.25597671246892],[3.582071697770728,52.25444641362978],[3.573071704146412,52.25291607887173],[3.564071710522096,52.25138570783596],[3.55507171689778,52.249855300163645],[3.546071723273463,52.24832485549595],[3.537071729649147,52.246794373474074],[3.528071736024831,52.24526385373918],[3.519071742400514,52.24373329593245],[3.510071748776197,52.24220269969506],[3.50107175515188,52.240672064668196],[3.492071761527563,52.239141390493025],[3.483071767903246,52.237610676810725],[3.474071774278928,52.23607992326248],[3.46507178065461,52.234549129489466],[3.456071787030292,52.23301829513286],[3.447071793405974,52.23148741983383],[3.438071799781655,52.22995650323357],[3.429071806157336,52.228425544973256],[3.420071812533017,52.226894544694055],[3.411071818908697,52.22536350203715],[3.402071825284377,52.22383241664372],[3.393071831660056,52.22230128815493],[3.375071844411414,52.219238900456034],[3.375071844411414,52.219238900456034],[2.025072800763373,52.211580224391206],[1.620287931266729,52.20721179437706]]]}},{"type":"Feature","properties":{"id":"farland-north","name":"Farland North","color":"#52bb77","feature_id":"farland-north-0","coordinates":[2.525472849613037,51.77218715330889]},"geometry":{"type":"MultiLineString","coordinates":[[[1.590603109141634,52.15784048805771],[2.025072801359273,52.00429650272413],[2.92507216379124,51.586833980054095],[3.496161759226153,51.563950944766155]]]}},{"type":"Feature","properties":{"id":"circe-north","name":"Circe North","color":"#3cb54e","feature_id":"circe-north-0","coordinates":[3.126835437758527,52.41790126031551]},"geometry":{"type":"MultiLineString","coordinates":[[[1.72927301090669,52.46882263773048],[2.25007264196731,52.41790126031551],[3.825071526223208,52.41790126031551],[4.524191030960483,52.36366638180829]]]}},{"type":"Feature","properties":{"id":"e-llan","name":"E-LLAN","color":"#44b549","feature_id":"e-llan-0","coordinates":[-3.771466810717881,53.95209894866871]},"geometry":{"type":"MultiLineString","coordinates":[[[-4.480882589760787,54.150478175109725],[-4.274922735664679,54.10005748241058],[-3.599923213840749,53.90168472607427],[-3.050753602877637,53.80897597127547]]]}},{"type":"Feature","properties":{"id":"lanis-3","name":"Lanis-3","color":"#2d8cb2","feature_id":"lanis-3-0","coordinates":[-5.219973632599597,55.20510575766682]},"geometry":{"type":"MultiLineString","coordinates":[[[-4.659932462920117,55.5413317564715],[-4.9499222574887,55.462350188098306],[-5.624921779312721,54.81936191424915],[-5.718721712863974,54.753701760470875]]]}},{"type":"Feature","properties":{"id":"finland-estonia-3-eesf-3","name":"Finland-Estonia 3 (EESF-3)","color":"#41b769","feature_id":"finland-estonia-3-eesf-3-0","coordinates":[24.568446825338455,59.810273160278776]},"geometry":{"type":"MultiLineString","coordinates":[[[24.27661703815667,59.40095452286688],[24.412556941855435,59.679663707208995],[24.7500567027674,59.9624316341522],[24.932476573539617,60.171163188940554]]]}},{"type":"Feature","properties":{"id":"bcs-east","name":"BCS East","color":"#bc6f29","feature_id":"bcs-east-0","coordinates":[20.812559492127384,56.24404101569347]},"geometry":{"type":"MultiLineString","coordinates":[[[21.011189351416185,56.50836914088424],[20.812559492127384,56.407518054312945],[20.812559492127384,56.15772578558828],[21.08267930077222,56.028506850473754]]]}},{"type":"Feature","properties":{"id":"batam-dumai-melaka-bdm","name":"Batam Dumai Melaka (BDM)","color":"#3cbba0","feature_id":"batam-dumai-melaka-bdm-0","coordinates":[102.74660651831417,1.4715089599375142]},"geometry":{"type":"MultiLineString","coordinates":[[[104.0166370000003,1.066798000000349],[103.50000091556807,1.173168272269238],[103.34065102845322,1.18732775753875],[102.68279723997186,1.502034277710852],[102.15000187192003,1.693340822791726],[101.72812717078021,1.721403338307429],[101.44766236946417,1.665522797277061]],[[101.44766236946417,1.665522797277061],[101.72812717078021,1.777527123428276],[101.92500203131218,1.918228780215599],[102.220901821694,2.273260323566543]]]}},{"type":"Feature","properties":{"id":"pgascom","name":"PGASCOM","color":"#7c9e3e","feature_id":"pgascom-0","coordinates":[103.83548378915265,0.23511774804232174]},"geometry":{"type":"MultiLineString","coordinates":[[[103.46670093915829,-0.816543192375546],[103.89375063663218,-0.331409329660265],[103.89375063663218,0.118588418888312],[103.55625087572004,0.793562652607196],[103.66875079602414,1.018534216615524],[104.0166370000003,1.066798000000349]],[[103.70596076966436,1.259306292565431],[103.86557565659176,1.185378176915766],[104.0166370000003,1.066798000000349]]]}},{"type":"Feature","properties":{"id":"boracay-palawan-submarine-cable-system-bpscs","name":"Boracay-Palawan Submarine Cable System (BPSCS)","color":"#2b51a3","feature_id":"boracay-palawan-submarine-cable-system-bpscs-0","coordinates":[120.40542620577979,11.407132406675665]},"geometry":{"type":"MultiLineString","coordinates":[[[119.50037958074992,10.820000490489418],[120.14998912056028,10.97582844878326],[120.59998880177636,11.735650161405832],[120.8249886423844,12.175887185507976],[121.05018848285071,12.363012914770698]],[[120.2007690845874,12.005434247136186],[120.37498896116831,11.84577637362577],[120.59998880177636,11.735650161405832]],[[121.94481206784093,11.949266020482876],[121.93593035478695,11.937514333211984],[121.95077784426886,11.930929802473868]]]}},{"type":"Feature","properties":{"id":"saudi-arabia-sudan-1-sas-1","name":"Saudi Arabia-Sudan-1 (SAS-1)","color":"#68bc45","feature_id":"saudi-arabia-sudan-1-sas-1-0","coordinates":[38.0983476031497,20.654265086135478]},"geometry":{"type":"MultiLineString","coordinates":[[[37.21967786917097,19.61556659454616],[37.58923757400337,20.13043586514086],[38.10004724550894,20.656013867895815],[39.18275647850768,21.481533475502996]]]}},{"type":"Feature","properties":{"id":"falcon","name":"FALCON","color":"#c62026","feature_id":"falcon-0","coordinates":[57.68145496109924,24.435579750299368]},"geometry":{"type":"MultiLineString","coordinates":[[[39.18275647850768,21.481533475502996],[38.70004682046353,20.375041253465433],[37.58923757400337,19.81323778521068],[37.21967786917097,19.61556659454616]],[[72.87590260996693,19.07607425728523],[70.20002450558383,19.740987365524937],[66.60002705585578,20.375041253465433],[63.675029127951845,22.469443964829516],[59.85003265596724,24.634955698183607],[58.50003279396771,23.968510996734643],[58.1762030233719,23.68487753168473],[57.88128323229574,24.12261698700344],[57.37503359092771,25.348717422116714],[57.15003375031967,26.1593079707739],[56.98128386986378,26.562513149236715],[56.74300403866356,26.97318161868908],[56.27415437080105,27.18725294593831],[56.2500343878877,26.813799487940788],[55.80003470667163,26.411476060868516],[55.35003502545574,26.36108632539156],[53.55003630059162,26.36108632539156],[52.875036778767694,26.562513149236715],[52.20003725694376,26.964304734562898],[51.525037735119646,27.364667993860262],[50.175038691471606,27.962503359972466],[49.16253940873571,28.65581241773305],[48.60003980721571,29.1482487910328],[48.4875398869116,29.246454972180413],[47.974840250113054,29.37410420420039],[48.60003980721571,28.853067255226264],[49.16253940873571,28.458185766004554],[50.175038691471606,26.964304734562898],[50.175038691471606,26.461843796188983],[50.214198663730556,26.28537535931817],[50.34378857192768,26.461843796188983],[50.51253845238375,26.461843796188983],[50.57601840741415,26.229494838391265],[51.187537974207686,26.36108632539156],[51.637537655423756,26.260240971577822],[51.75003757572769,26.05828756029904],[51.519277739200085,25.294608758024626],[52.20003725694376,25.75470426341523],[53.55003630059162,25.855985466072205],[55.1250351848477,25.855985466072205],[55.1250351848477,25.55188275942587],[55.30853505485483,25.269353998130182],[55.237585105116516,25.55188275942587],[55.35003502545574,25.75470426341523],[55.80003470667163,26.10880867686235],[56.24681439016876,26.181305362781977],[56.362534308191634,26.461843796188983],[56.58753414879967,26.461843796188983],[56.756284029255745,26.1593079707739],[57.26253367062378,25.348717422116714],[57.825033272143784,24.12261698700344],[58.1762030233719,23.68487753168473],[58.50003279396771,23.81422051502533],[58.95003247518379,23.65974644119216],[59.85003183761575,22.884654113882444],[60.07503167703199,22.469443964829516],[59.85003183761575,19.104405475930452],[58.95003247518379,18.038005439608753],[55.35003502545574,16.318380026359527],[52.65003693815965,15.23578178303578],[48.60003980721571,13.492128176464083],[45.450042038703735,12.72515592356304],[44.55004267627159,12.230866087669199],[43.67816829391569,12.395734000022975],[43.3266060429656,12.615395567393394],[43.24223110273756,12.834868817846521],[43.05941873224354,13.054150695298627],[42.637544031103545,13.92930384327183],[42.1312943897355,14.801154224791581],[41.062545146848734,16.534196198259725],[40.05004586411157,18.251816319028222],[38.70004682046353,20.375041253465433],[37.91254737833549,22.05298561667754],[36.67504825499064,24.12261698700344],[35.66254897225548,25.75470426341523],[35.01567443050744,26.562513149236715],[34.200050008303506,27.364667993860262],[33.497250506175384,28.161052262220792],[32.99067586503547,28.95155473219332],[32.70942606427547,29.344566989489813],[32.54067268382206,29.63833609362628],[32.54068118381597,29.974234637029465]],[[76.97022970950695,8.798160747261367],[76.50002004260797,8.190543417795496],[75.54513271613803,6.628746603597807],[74.25002163652778,5.398081130463647],[73.5000221678345,4.16666819886197]],[[75.54513271613803,6.628746603597807],[79.20001812990387,6.628746603597807],[79.87208765380376,6.927036656836354]],[[52.182457269397545,16.213003862431094],[52.65003693815965,15.669513225155248],[52.65003693815965,15.23578178303578]],[[60.627371286941326,25.258664579046147],[59.85003265596724,24.634955698183607]],[[48.5317798555716,29.92363278689715],[48.60003980721571,29.540507745394493],[48.4875398869116,29.246454972180413]],[[48.5317798555716,29.92363278689715],[48.71253972751964,29.540507745394493],[48.60003980721571,29.1482487910328]],[[42.95452380595619,14.797809010241023],[42.75004395140765,14.692360031374392],[42.1312943897355,14.801154224791581]]]}}]} \ No newline at end of file diff --git a/package.json b/package.json index 1337443..ba381ba 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "build:web": "npm -w @wing/web run build", "build:api": "npm -w @wing/api run build", "lint": "npm -w @wing/web run lint", - "prepare:data": "node scripts/prepare-zones.mjs && node scripts/prepare-legacy.mjs" + "prepare:data": "node scripts/prepare-zones.mjs && node scripts/prepare-legacy.mjs", + "prepare:subcables": "node scripts/prepare-subcables.mjs" }, "devDependencies": { "xlsx": "^0.18.5" diff --git a/scripts/prepare-subcables.mjs b/scripts/prepare-subcables.mjs new file mode 100644 index 0000000..75b7fa3 --- /dev/null +++ b/scripts/prepare-subcables.mjs @@ -0,0 +1,176 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const OUT_DIR = path.resolve(__dirname, "..", "apps", "web", "public", "data", "subcables"); +const GEO_URL = "https://www.submarinecablemap.com/api/v3/cable/cable-geo.json"; +const DETAILS_URL_BASE = "https://www.submarinecablemap.com/api/v3/cable/"; + +const CONCURRENCY = Math.max(1, Math.min(24, Number(process.env.CONCURRENCY || 12))); +const TIMEOUT_MS = Math.max(5_000, Math.min(60_000, Number(process.env.TIMEOUT_MS || 20_000))); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function fetchText(url, { timeoutMs = TIMEOUT_MS } = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + signal: controller.signal, + headers: { + accept: "application/json", + }, + }); + const text = await res.text(); + const contentType = res.headers.get("content-type") || ""; + if (!res.ok) { + throw new Error(`HTTP ${res.status} (${res.statusText})`); + } + return { text, contentType }; + } finally { + clearTimeout(timeout); + } +} + +async function fetchJson(url) { + const { text, contentType } = await fetchText(url); + if (!contentType.toLowerCase().includes("application/json")) { + const snippet = text.slice(0, 200).replace(/\s+/g, " ").trim(); + throw new Error(`Unexpected content-type (${contentType || "unknown"}): ${snippet || ""}`); + } + try { + return JSON.parse(text); + } catch (e) { + throw new Error(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`); + } +} + +async function fetchJsonWithRetry(url, attempts = 2) { + let lastErr = null; + for (let i = 0; i < attempts; i += 1) { + try { + return await fetchJson(url); + } catch (e) { + lastErr = e; + if (i < attempts - 1) { + await sleep(250 * (i + 1)); + } + } + } + throw lastErr; +} + +function pickCableDetails(raw) { + const obj = raw && typeof raw === "object" ? raw : {}; + const landingPoints = Array.isArray(obj.landing_points) ? obj.landing_points : []; + return { + id: String(obj.id || ""), + name: String(obj.name || ""), + length: obj.length == null ? null : String(obj.length), + rfs: obj.rfs == null ? null : String(obj.rfs), + rfs_year: typeof obj.rfs_year === "number" ? obj.rfs_year : null, + is_planned: Boolean(obj.is_planned), + owners: obj.owners == null ? null : String(obj.owners), + suppliers: obj.suppliers == null ? null : String(obj.suppliers), + landing_points: landingPoints.map((lp) => { + const p = lp && typeof lp === "object" ? lp : {}; + return { + id: String(p.id || ""), + name: String(p.name || ""), + country: String(p.country || ""), + is_tbd: p.is_tbd === true, + }; + }), + notes: obj.notes == null ? null : String(obj.notes), + url: obj.url == null ? null : String(obj.url), + }; +} + +async function main() { + await fs.mkdir(OUT_DIR, { recursive: true }); + + console.log(`[subcables] fetching geojson: ${GEO_URL}`); + const geo = await fetchJsonWithRetry(GEO_URL, 3); + const geoPath = path.join(OUT_DIR, "cable-geo.json"); + await fs.writeFile(geoPath, JSON.stringify(geo)); + + const features = Array.isArray(geo?.features) ? geo.features : []; + const ids = Array.from( + new Set( + features + .map((f) => f?.properties?.id) + .filter((v) => typeof v === "string" && v.trim().length > 0) + .map((v) => v.trim()), + ), + ).sort(); + + console.log(`[subcables] cables: ${ids.length} (concurrency=${CONCURRENCY}, timeoutMs=${TIMEOUT_MS})`); + + const byId = {}; + const failures = []; + let cursor = 0; + let completed = 0; + const startedAt = Date.now(); + + const worker = async () => { + for (;;) { + const idx = cursor; + cursor += 1; + if (idx >= ids.length) return; + const id = ids[idx]; + const url = new URL(`${id}.json`, DETAILS_URL_BASE).toString(); + try { + const raw = await fetchJsonWithRetry(url, 2); + const picked = pickCableDetails(raw); + if (!picked.id) { + throw new Error("Missing id in details response"); + } + byId[id] = picked; + } catch (e) { + failures.push({ id, error: e instanceof Error ? e.message : String(e) }); + } finally { + completed += 1; + if (completed % 25 === 0 || completed === ids.length) { + const sec = Math.max(1, Math.round((Date.now() - startedAt) / 1000)); + const rate = (completed / sec).toFixed(1); + console.log(`[subcables] ${completed}/${ids.length} (${rate}/s)`); + } + } + } + }; + + await Promise.all(Array.from({ length: CONCURRENCY }, () => worker())); + + const detailsOut = { + version: 1, + generated_at: new Date().toISOString(), + by_id: byId, + }; + const detailsPath = path.join(OUT_DIR, "cable-details.min.json"); + await fs.writeFile(detailsPath, JSON.stringify(detailsOut)); + + if (failures.length > 0) { + console.error(`[subcables] failures: ${failures.length}`); + for (const f of failures.slice(0, 30)) { + console.error(`- ${f.id}: ${f.error}`); + } + if (failures.length > 30) { + console.error(`- ... +${failures.length - 30} more`); + } + process.exitCode = 1; + } + + console.log(`[subcables] wrote: ${geoPath}`); + console.log(`[subcables] wrote: ${detailsPath}`); +} + +main().catch((e) => { + console.error(`[subcables] fatal: ${e instanceof Error ? e.stack || e.message : String(e)}`); + process.exitCode = 1; +}); + From ca5560aff2b127a22573acefc49e555f1be45712 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 02:11:37 +0900 Subject: [PATCH 47/58] =?UTF-8?q?feat(map):=20=ED=95=B4=EC=A0=80=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=ED=8C=A8=EB=84=90=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - subcable entity 생성 (타입 정의 + 데이터 로딩 hook) - MapLibre 레이어: 케이블 라인 + 호버 하이라이트 + 라벨 - 지도 표시 설정에 해저케이블 토글 추가 - 클릭 시 우측 정보 패널 (길이, 개통, 운영사, landing points) - Map3D + DashboardPage 통합 Co-Authored-By: Claude Opus 4.6 --- .../src/entities/subcable/api/useSubcables.ts | 52 ++++ apps/web/src/entities/subcable/model/types.ts | 39 +++ .../src/features/mapToggles/MapToggles.tsx | 2 + .../web/src/pages/dashboard/DashboardPage.tsx | 18 ++ apps/web/src/widgets/map3d/Map3D.tsx | 19 ++ .../widgets/map3d/hooks/useSubcablesLayer.ts | 229 ++++++++++++++++++ .../web/src/widgets/map3d/lib/layerHelpers.ts | 4 + apps/web/src/widgets/map3d/types.ts | 5 + .../subcableInfo/SubcableInfoPanel.tsx | 107 ++++++++ 9 files changed, 475 insertions(+) create mode 100644 apps/web/src/entities/subcable/api/useSubcables.ts create mode 100644 apps/web/src/entities/subcable/model/types.ts create mode 100644 apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts create mode 100644 apps/web/src/widgets/subcableInfo/SubcableInfoPanel.tsx 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 && ( + + )} +
+ ); +} From 7eff97afd4e5e28c3c6872a88fe399e50489bcd7 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 02:28:11 +0900 Subject: [PATCH 48/58] =?UTF-8?q?fix(map):=20=ED=95=B4=EC=A0=80=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=8B=9C=EC=9D=B8=EC=84=B1=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MapLibre 중첩 interpolate 표현식 에러 수정 - 6레이어 구조: hitarea, casing, line, glow, points, label - 호버 시 flat value 사용 (case 내 interpolate 제거) - Globe/Mercator 양쪽 프로젝션 레이어 순서 지원 - 진한 색상, 굵은 라인, 포인트 마커로 시인성 향상 Co-Authored-By: Claude Opus 4.6 --- apps/web/src/widgets/map3d/Map3D.tsx | 2 +- .../map3d/hooks/useProjectionToggle.ts | 6 + .../widgets/map3d/hooks/useSubcablesLayer.ts | 222 +++++++++++++----- .../web/src/widgets/map3d/lib/layerHelpers.ts | 6 +- 4 files changed, 180 insertions(+), 56 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index b00753c..19e919a 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -505,7 +505,7 @@ export function Map3D({ }, ); - // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function + // eslint-disable-next-line @typescript-eslint/no-unused-vars const noopCable = useCallback((_: string | null) => {}, []); useSubcablesLayer( diff --git a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts index ec1e0ce..2b92733 100644 --- a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts +++ b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts @@ -91,6 +91,12 @@ export function useProjectionToggle( if (!map.isStyleLoaded()) return; const ordering = [ + 'subcables-hitarea', + 'subcables-casing', + 'subcables-line', + 'subcables-glow', + 'subcables-points', + 'subcables-label', 'zones-fill', 'zones-line', 'zones-label', diff --git a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts index 38f7ff3..9e4c87b 100644 --- a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts +++ b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, type MutableRefObject } from 'react'; +import { useEffect, useMemo, 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'; @@ -6,11 +6,20 @@ import type { MapProjectionId } from '../types'; import { ensureGeoJsonSource, ensureLayer, setLayerVisibility, cleanupLayers } from '../lib/layerHelpers'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; +/* ── Layer / Source IDs ─────────────────────────────────────────────── */ const SRC_ID = 'subcables-src'; +const POINTS_SRC_ID = 'subcables-pts-src'; + +const HITAREA_ID = 'subcables-hitarea'; +const CASING_ID = 'subcables-casing'; const LINE_ID = 'subcables-line'; -const LINE_HOVER_ID = 'subcables-line-hover'; +const GLOW_ID = 'subcables-glow'; +const POINTS_ID = 'subcables-points'; const LABEL_ID = 'subcables-label'; +const ALL_LAYER_IDS = [LABEL_ID, POINTS_ID, GLOW_ID, LINE_ID, CASING_ID, HITAREA_ID]; +const ALL_SOURCE_IDS = [POINTS_SRC_ID, SRC_ID]; + export function useSubcablesLayer( mapRef: MutableRefObject, projectionBusyRef: MutableRefObject, @@ -32,6 +41,23 @@ export function useSubcablesLayer( onHoverRef.current = onHoverCable; onClickRef.current = onClickCable; + /* ── Derived point features (cable midpoints for circle markers) ── */ + const pointsGeoJson = useMemo(() => { + if (!subcableGeo) return { type: 'FeatureCollection', features: [] }; + const features: GeoJSON.Feature[] = []; + for (const f of subcableGeo.features) { + const coords = f.properties.coordinates; + if (!coords || coords.length < 2) continue; + features.push({ + type: 'Feature', + properties: { id: f.properties.id, color: f.properties.color }, + geometry: { type: 'Point', coordinates: coords }, + }); + } + return { type: 'FeatureCollection', features }; + }, [subcableGeo]); + + /* ── Main layer setup effect ──────────────────────────────────────── */ useEffect(() => { const map = mapRef.current; if (!map) return; @@ -39,16 +65,17 @@ export function useSubcablesLayer( 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); + const visible = overlays.subcables; + for (const id of ALL_LAYER_IDS) { + setLayerVisibility(map, id, visible); + } if (!subcableGeo) return; if (!map.isStyleLoaded()) return; try { ensureGeoJsonSource(map, SRC_ID, subcableGeo); + ensureGeoJsonSource(map, POINTS_SRC_ID, pointsGeoJson); const before = map.getLayer('zones-fill') ? 'zones-fill' @@ -56,6 +83,43 @@ export function useSubcablesLayer( ? 'deck-globe' : undefined; + const vis = visible ? 'visible' : 'none'; + + /* 1) Hit-area — invisible wide line for easy hover detection */ + ensureLayer( + map, + { + id: HITAREA_ID, + type: 'line', + source: SRC_ID, + paint: { + 'line-color': 'rgba(0,0,0,0)', + 'line-width': 14, + 'line-opacity': 0, + }, + layout: { visibility: vis, 'line-cap': 'round', 'line-join': 'round' }, + } as unknown as LayerSpecification, + { before }, + ); + + /* 2) Dark casing behind cable for contrast */ + ensureLayer( + map, + { + id: CASING_ID, + type: 'line', + source: SRC_ID, + paint: { + 'line-color': 'rgba(0,0,0,0.55)', + 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 2.4, 6, 3.6, 10, 5.5], + 'line-opacity': ['interpolate', ['linear'], ['zoom'], 2, 0.3, 5, 0.5, 8, 0.65], + }, + layout: { visibility: vis, 'line-cap': 'round', 'line-join': 'round' }, + } as unknown as LayerSpecification, + { before }, + ); + + /* 3) Main cable line — vivid color */ ensureLayer( map, { @@ -64,31 +128,53 @@ export function useSubcablesLayer( 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], + 'line-opacity': ['interpolate', ['linear'], ['zoom'], 2, 0.7, 6, 0.82, 10, 0.92], + 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 1.6, 6, 2.5, 10, 4.0], }, - layout: { visibility, 'line-cap': 'round', 'line-join': 'round' }, + layout: { visibility: vis, 'line-cap': 'round', 'line-join': 'round' }, } as unknown as LayerSpecification, { before }, ); + /* 4) Glow — visible only on hover */ ensureLayer( map, { - id: LINE_HOVER_ID, + id: GLOW_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], + 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 5, 6, 8, 10, 12], + 'line-blur': ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 5, 10, 7], }, filter: ['==', ['get', 'id'], ''], - layout: { visibility, 'line-cap': 'round', 'line-join': 'round' }, + layout: { visibility: vis, 'line-cap': 'round', 'line-join': 'round' }, } as unknown as LayerSpecification, { before }, ); + /* 5) Point markers at cable representative coordinates */ + ensureLayer( + map, + { + id: POINTS_ID, + type: 'circle', + source: POINTS_SRC_ID, + paint: { + 'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 1.5, 6, 2.5, 10, 4], + 'circle-color': ['get', 'color'], + 'circle-opacity': ['interpolate', ['linear'], ['zoom'], 2, 0.5, 5, 0.7, 8, 0.85], + 'circle-stroke-color': 'rgba(0,0,0,0.5)', + 'circle-stroke-width': 0.5, + }, + layout: { visibility: vis }, + minzoom: 3, + } as unknown as LayerSpecification, + ); + + /* 6) Cable name label along line */ ensureLayer( map, { @@ -96,7 +182,7 @@ export function useSubcablesLayer( type: 'symbol', source: SRC_ID, layout: { - visibility, + visibility: vis, 'symbol-placement': 'line', 'text-field': ['get', 'name'], 'text-size': ['interpolate', ['linear'], ['zoom'], 4, 9, 8, 11, 12, 13], @@ -106,52 +192,75 @@ export function useSubcablesLayer( '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], + 'text-color': 'rgba(220,232,245,0.82)', + 'text-halo-color': 'rgba(2,6,23,0.9)', + 'text-halo-width': 1.2, + 'text-halo-blur': 0.5, + 'text-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0, 5, 0.7, 8, 0.88], }, minzoom: 4, } as unknown as LayerSpecification, ); - // Update hover highlight + /* ── Hover highlight (flat values — no nested interpolate) ── */ if (hoveredCableId) { + const matchExpr = ['==', ['get', 'id'], hoveredCableId]; + + // Main line: hovered=bright+thick, rest=dimmed+thin 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); + map.setPaintProperty(LINE_ID, 'line-opacity', ['case', matchExpr, 1.0, 0.2] as never); + map.setPaintProperty(LINE_ID, 'line-width', ['case', matchExpr, 4.5, 1.2] 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); + // Casing: dim non-hovered + if (map.getLayer(CASING_ID)) { + map.setPaintProperty(CASING_ID, 'line-opacity', ['case', matchExpr, 0.7, 0.12] as never); + map.setPaintProperty(CASING_ID, 'line-width', ['case', matchExpr, 6.5, 2.0] as never); + } + // Glow: show only on hovered cable + if (map.getLayer(GLOW_ID)) { + map.setFilter(GLOW_ID, matchExpr as never); + map.setPaintProperty(GLOW_ID, 'line-opacity', 0.35); + } + // Points: dim non-hovered + if (map.getLayer(POINTS_ID)) { + map.setPaintProperty(POINTS_ID, 'circle-opacity', ['case', matchExpr, 1.0, 0.15] as never); + map.setPaintProperty(POINTS_ID, 'circle-radius', ['case', matchExpr, 4, 1.5] as never); } } else { + // Restore zoom-based interpolation defaults 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, + LINE_ID, 'line-opacity', + ['interpolate', ['linear'], ['zoom'], 2, 0.7, 6, 0.82, 10, 0.92] as never, ); map.setPaintProperty( - LINE_ID, - 'line-width', - ['interpolate', ['linear'], ['zoom'], 2, 0.8, 6, 1.2, 10, 1.8] as never, + LINE_ID, 'line-width', + ['interpolate', ['linear'], ['zoom'], 2, 1.6, 6, 2.5, 10, 4.0] 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); + if (map.getLayer(CASING_ID)) { + map.setPaintProperty( + CASING_ID, 'line-opacity', + ['interpolate', ['linear'], ['zoom'], 2, 0.3, 5, 0.5, 8, 0.65] as never, + ); + map.setPaintProperty( + CASING_ID, 'line-width', + ['interpolate', ['linear'], ['zoom'], 2, 2.4, 6, 3.6, 10, 5.5] as never, + ); + } + if (map.getLayer(GLOW_ID)) { + map.setFilter(GLOW_ID, ['==', ['get', 'id'], ''] as never); + map.setPaintProperty(GLOW_ID, 'line-opacity', 0); + } + if (map.getLayer(POINTS_ID)) { + map.setPaintProperty( + POINTS_ID, 'circle-opacity', + ['interpolate', ['linear'], ['zoom'], 2, 0.5, 5, 0.7, 8, 0.85] as never, + ); + map.setPaintProperty( + POINTS_ID, 'circle-radius', + ['interpolate', ['linear'], ['zoom'], 2, 1.5, 6, 2.5, 10, 4] as never, + ); } } } catch (e) { @@ -166,15 +275,15 @@ export function useSubcablesLayer( return () => { stop(); }; - }, [subcableGeo, overlays.subcables, projection, mapSyncEpoch, hoveredCableId, reorderGlobeFeatureLayers]); + }, [subcableGeo, pointsGeoJson, overlays.subcables, projection, mapSyncEpoch, hoveredCableId, reorderGlobeFeatureLayers]); - // Mouse events + /* ── Mouse events (bind to hit-area layer for easy hovering) ───── */ useEffect(() => { const map = mapRef.current; if (!map) return; if (!overlays.subcables) return; - const onMouseEnter = (e: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => { + const onMouseMove = (e: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => { const cableId = e.features?.[0]?.properties?.id; if (typeof cableId === 'string' && cableId) { map.getCanvas().style.cursor = 'pointer'; @@ -195,13 +304,15 @@ export function useSubcablesLayer( }; 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); + // Bind to hit-area for wider hover target, fallback to main line + const targetLayer = map.getLayer(HITAREA_ID) ? HITAREA_ID : LINE_ID; + if (!map.getLayer(targetLayer)) return; + map.on('mousemove', targetLayer, onMouseMove); + map.on('mouseleave', targetLayer, onMouseLeave); + map.on('click', targetLayer, onClick); }; - if (map.isStyleLoaded() && map.getLayer(LINE_ID)) { + if (map.isStyleLoaded() && (map.getLayer(HITAREA_ID) || map.getLayer(LINE_ID))) { addEvents(); } else { map.once('idle', addEvents); @@ -209,7 +320,10 @@ export function useSubcablesLayer( return () => { try { - map.off('mouseenter', LINE_ID, onMouseEnter); + map.off('mousemove', HITAREA_ID, onMouseMove); + map.off('mouseleave', HITAREA_ID, onMouseLeave); + map.off('click', HITAREA_ID, onClick); + map.off('mousemove', LINE_ID, onMouseMove); map.off('mouseleave', LINE_ID, onMouseLeave); map.off('click', LINE_ID, onClick); } catch { @@ -218,12 +332,12 @@ export function useSubcablesLayer( }; }, [overlays.subcables, mapSyncEpoch]); - // Cleanup on unmount + /* ── Cleanup on unmount ───────────────────────────────────────────── */ useEffect(() => { const mapInstance = mapRef.current; return () => { if (!mapInstance) return; - cleanupLayers(mapInstance, [LABEL_ID, LINE_HOVER_ID, LINE_ID], [SRC_ID]); + cleanupLayers(mapInstance, ALL_LAYER_IDS, ALL_SOURCE_IDS); }; }, []); } diff --git a/apps/web/src/widgets/map3d/lib/layerHelpers.ts b/apps/web/src/widgets/map3d/lib/layerHelpers.ts index 0c335e5..bfe12ca 100644 --- a/apps/web/src/widgets/map3d/lib/layerHelpers.ts +++ b/apps/web/src/widgets/map3d/lib/layerHelpers.ts @@ -37,8 +37,11 @@ const GLOBE_NATIVE_LAYER_IDS = [ 'fleet-circles-ml-fill', 'fleet-circles-ml', 'pair-range-ml', + 'subcables-hitarea', + 'subcables-casing', 'subcables-line', - 'subcables-line-hover', + 'subcables-glow', + 'subcables-points', 'subcables-label', 'deck-globe', ]; @@ -52,6 +55,7 @@ const GLOBE_NATIVE_SOURCE_IDS = [ 'fleet-circles-ml-fill-src', 'pair-range-ml-src', 'subcables-src', + 'subcables-pts-src', ]; export function clearGlobeNativeLayers(map: maplibregl.Map) { From fb1334ce45dd9dce3103afbd3a8e3919c20719fc Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 02:36:20 +0900 Subject: [PATCH 49/58] =?UTF-8?q?fix(map):=20=ED=95=B4=EC=A0=80=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=ED=98=B8=EB=B2=84/=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=EC=85=98=20=EB=B2=84=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useEffect 3개 분리 (레이어생성/호버/이벤트) - hoveredCableId를 레이어 생성 deps에서 분리하여 깜박임 제거 - 이벤트 바인딩에 retry 로직 추가 (프로젝션 전환 후) - paint 기본값을 상수로 추출하여 일관성 보장 Co-Authored-By: Claude Opus 4.6 --- .../widgets/map3d/hooks/useSubcablesLayer.ts | 178 ++++++++++-------- 1 file changed, 101 insertions(+), 77 deletions(-) diff --git a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts index 9e4c87b..b7211dd 100644 --- a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts +++ b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts @@ -20,6 +20,14 @@ const LABEL_ID = 'subcables-label'; const ALL_LAYER_IDS = [LABEL_ID, POINTS_ID, GLOW_ID, LINE_ID, CASING_ID, HITAREA_ID]; const ALL_SOURCE_IDS = [POINTS_SRC_ID, SRC_ID]; +/* ── Paint defaults (used for layer creation + hover reset) ──────── */ +const LINE_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.7, 6, 0.82, 10, 0.92]; +const LINE_WIDTH_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 1.6, 6, 2.5, 10, 4.0]; +const CASING_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.3, 5, 0.5, 8, 0.65]; +const CASING_WIDTH_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 2.4, 6, 3.6, 10, 5.5]; +const POINTS_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.5, 5, 0.7, 8, 0.85]; +const POINTS_RADIUS_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 1.5, 6, 2.5, 10, 4]; + export function useSubcablesLayer( mapRef: MutableRefObject, projectionBusyRef: MutableRefObject, @@ -41,6 +49,9 @@ export function useSubcablesLayer( onHoverRef.current = onHoverCable; onClickRef.current = onClickCable; + const hoveredCableIdRef = useRef(hoveredCableId); + hoveredCableIdRef.current = hoveredCableId; + /* ── Derived point features (cable midpoints for circle markers) ── */ const pointsGeoJson = useMemo(() => { if (!subcableGeo) return { type: 'FeatureCollection', features: [] }; @@ -57,7 +68,11 @@ export function useSubcablesLayer( return { type: 'FeatureCollection', features }; }, [subcableGeo]); - /* ── Main layer setup effect ──────────────────────────────────────── */ + /* ================================================================ + * Effect 1: Layer creation & data update + * - Does NOT depend on hoveredCableId (prevents flicker) + * - Creates sources, layers, sets visibility + * ================================================================ */ useEffect(() => { const map = mapRef.current; if (!map) return; @@ -111,8 +126,8 @@ export function useSubcablesLayer( source: SRC_ID, paint: { 'line-color': 'rgba(0,0,0,0.55)', - 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 2.4, 6, 3.6, 10, 5.5], - 'line-opacity': ['interpolate', ['linear'], ['zoom'], 2, 0.3, 5, 0.5, 8, 0.65], + 'line-width': CASING_WIDTH_DEFAULT, + 'line-opacity': CASING_OPACITY_DEFAULT, }, layout: { visibility: vis, 'line-cap': 'round', 'line-join': 'round' }, } as unknown as LayerSpecification, @@ -128,8 +143,8 @@ export function useSubcablesLayer( source: SRC_ID, paint: { 'line-color': ['get', 'color'], - 'line-opacity': ['interpolate', ['linear'], ['zoom'], 2, 0.7, 6, 0.82, 10, 0.92], - 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 1.6, 6, 2.5, 10, 4.0], + 'line-opacity': LINE_OPACITY_DEFAULT, + 'line-width': LINE_WIDTH_DEFAULT, }, layout: { visibility: vis, 'line-cap': 'round', 'line-join': 'round' }, } as unknown as LayerSpecification, @@ -163,9 +178,9 @@ export function useSubcablesLayer( type: 'circle', source: POINTS_SRC_ID, paint: { - 'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 1.5, 6, 2.5, 10, 4], + 'circle-radius': POINTS_RADIUS_DEFAULT, 'circle-color': ['get', 'color'], - 'circle-opacity': ['interpolate', ['linear'], ['zoom'], 2, 0.5, 5, 0.7, 8, 0.85], + 'circle-opacity': POINTS_OPACITY_DEFAULT, 'circle-stroke-color': 'rgba(0,0,0,0.5)', 'circle-stroke-width': 0.5, }, @@ -202,67 +217,8 @@ export function useSubcablesLayer( } as unknown as LayerSpecification, ); - /* ── Hover highlight (flat values — no nested interpolate) ── */ - if (hoveredCableId) { - const matchExpr = ['==', ['get', 'id'], hoveredCableId]; - - // Main line: hovered=bright+thick, rest=dimmed+thin - if (map.getLayer(LINE_ID)) { - map.setPaintProperty(LINE_ID, 'line-opacity', ['case', matchExpr, 1.0, 0.2] as never); - map.setPaintProperty(LINE_ID, 'line-width', ['case', matchExpr, 4.5, 1.2] as never); - } - // Casing: dim non-hovered - if (map.getLayer(CASING_ID)) { - map.setPaintProperty(CASING_ID, 'line-opacity', ['case', matchExpr, 0.7, 0.12] as never); - map.setPaintProperty(CASING_ID, 'line-width', ['case', matchExpr, 6.5, 2.0] as never); - } - // Glow: show only on hovered cable - if (map.getLayer(GLOW_ID)) { - map.setFilter(GLOW_ID, matchExpr as never); - map.setPaintProperty(GLOW_ID, 'line-opacity', 0.35); - } - // Points: dim non-hovered - if (map.getLayer(POINTS_ID)) { - map.setPaintProperty(POINTS_ID, 'circle-opacity', ['case', matchExpr, 1.0, 0.15] as never); - map.setPaintProperty(POINTS_ID, 'circle-radius', ['case', matchExpr, 4, 1.5] as never); - } - } else { - // Restore zoom-based interpolation defaults - if (map.getLayer(LINE_ID)) { - map.setPaintProperty( - LINE_ID, 'line-opacity', - ['interpolate', ['linear'], ['zoom'], 2, 0.7, 6, 0.82, 10, 0.92] as never, - ); - map.setPaintProperty( - LINE_ID, 'line-width', - ['interpolate', ['linear'], ['zoom'], 2, 1.6, 6, 2.5, 10, 4.0] as never, - ); - } - if (map.getLayer(CASING_ID)) { - map.setPaintProperty( - CASING_ID, 'line-opacity', - ['interpolate', ['linear'], ['zoom'], 2, 0.3, 5, 0.5, 8, 0.65] as never, - ); - map.setPaintProperty( - CASING_ID, 'line-width', - ['interpolate', ['linear'], ['zoom'], 2, 2.4, 6, 3.6, 10, 5.5] as never, - ); - } - if (map.getLayer(GLOW_ID)) { - map.setFilter(GLOW_ID, ['==', ['get', 'id'], ''] as never); - map.setPaintProperty(GLOW_ID, 'line-opacity', 0); - } - if (map.getLayer(POINTS_ID)) { - map.setPaintProperty( - POINTS_ID, 'circle-opacity', - ['interpolate', ['linear'], ['zoom'], 2, 0.5, 5, 0.7, 8, 0.85] as never, - ); - map.setPaintProperty( - POINTS_ID, 'circle-radius', - ['interpolate', ['linear'], ['zoom'], 2, 1.5, 6, 2.5, 10, 4] as never, - ); - } - } + // Re-apply current hover state after layer (re-)creation + applyHoverHighlight(map, hoveredCableIdRef.current); } catch (e) { console.warn('Subcables layer setup failed:', e); } finally { @@ -275,14 +231,35 @@ export function useSubcablesLayer( return () => { stop(); }; - }, [subcableGeo, pointsGeoJson, overlays.subcables, projection, mapSyncEpoch, hoveredCableId, reorderGlobeFeatureLayers]); + // hoveredCableId intentionally excluded — handled by Effect 2 + }, [subcableGeo, pointsGeoJson, overlays.subcables, projection, mapSyncEpoch, reorderGlobeFeatureLayers]); - /* ── Mouse events (bind to hit-area layer for easy hovering) ───── */ + /* ================================================================ + * Effect 2: Hover highlight (paint-only, no layer creation) + * - Lightweight, no flicker + * ================================================================ */ + useEffect(() => { + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + if (projectionBusyRef.current) return; + if (!map.getLayer(LINE_ID)) return; + + applyHoverHighlight(map, hoveredCableId); + kickRepaint(map); + }, [hoveredCableId]); + + /* ================================================================ + * Effect 3: Mouse events (bind to hit-area for easy hovering) + * - Retries binding until layer exists + * ================================================================ */ useEffect(() => { const map = mapRef.current; if (!map) return; if (!overlays.subcables) return; + let cancelled = false; + let retryTimer: ReturnType | null = null; + const onMouseMove = (e: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => { const cableId = e.features?.[0]?.properties?.id; if (typeof cableId === 'string' && cableId) { @@ -303,22 +280,28 @@ export function useSubcablesLayer( } }; - const addEvents = () => { - // Bind to hit-area for wider hover target, fallback to main line - const targetLayer = map.getLayer(HITAREA_ID) ? HITAREA_ID : LINE_ID; - if (!map.getLayer(targetLayer)) return; + const bindEvents = () => { + if (cancelled) return; + const targetLayer = map.getLayer(HITAREA_ID) ? HITAREA_ID : map.getLayer(LINE_ID) ? LINE_ID : null; + if (!targetLayer) { + // Layer not yet created — retry after short delay + retryTimer = setTimeout(bindEvents, 200); + return; + } map.on('mousemove', targetLayer, onMouseMove); map.on('mouseleave', targetLayer, onMouseLeave); map.on('click', targetLayer, onClick); }; - if (map.isStyleLoaded() && (map.getLayer(HITAREA_ID) || map.getLayer(LINE_ID))) { - addEvents(); + if (map.isStyleLoaded()) { + bindEvents(); } else { - map.once('idle', addEvents); + map.once('idle', bindEvents); } return () => { + cancelled = true; + if (retryTimer) clearTimeout(retryTimer); try { map.off('mousemove', HITAREA_ID, onMouseMove); map.off('mouseleave', HITAREA_ID, onMouseLeave); @@ -341,3 +324,44 @@ export function useSubcablesLayer( }; }, []); } + +/* ── Hover highlight helper (paint-only mutations) ────────────────── */ +function applyHoverHighlight(map: maplibregl.Map, hoveredId: string | null) { + if (hoveredId) { + const matchExpr = ['==', ['get', 'id'], hoveredId]; + + if (map.getLayer(LINE_ID)) { + map.setPaintProperty(LINE_ID, 'line-opacity', ['case', matchExpr, 1.0, 0.2] as never); + map.setPaintProperty(LINE_ID, 'line-width', ['case', matchExpr, 4.5, 1.2] as never); + } + if (map.getLayer(CASING_ID)) { + map.setPaintProperty(CASING_ID, 'line-opacity', ['case', matchExpr, 0.7, 0.12] as never); + map.setPaintProperty(CASING_ID, 'line-width', ['case', matchExpr, 6.5, 2.0] as never); + } + if (map.getLayer(GLOW_ID)) { + map.setFilter(GLOW_ID, matchExpr as never); + map.setPaintProperty(GLOW_ID, 'line-opacity', 0.35); + } + if (map.getLayer(POINTS_ID)) { + map.setPaintProperty(POINTS_ID, 'circle-opacity', ['case', matchExpr, 1.0, 0.15] as never); + map.setPaintProperty(POINTS_ID, 'circle-radius', ['case', matchExpr, 4, 1.5] as never); + } + } else { + if (map.getLayer(LINE_ID)) { + map.setPaintProperty(LINE_ID, 'line-opacity', LINE_OPACITY_DEFAULT as never); + map.setPaintProperty(LINE_ID, 'line-width', LINE_WIDTH_DEFAULT as never); + } + if (map.getLayer(CASING_ID)) { + map.setPaintProperty(CASING_ID, 'line-opacity', CASING_OPACITY_DEFAULT as never); + map.setPaintProperty(CASING_ID, 'line-width', CASING_WIDTH_DEFAULT as never); + } + if (map.getLayer(GLOW_ID)) { + map.setFilter(GLOW_ID, ['==', ['get', 'id'], ''] as never); + map.setPaintProperty(GLOW_ID, 'line-opacity', 0); + } + if (map.getLayer(POINTS_ID)) { + map.setPaintProperty(POINTS_ID, 'circle-opacity', POINTS_OPACITY_DEFAULT as never); + map.setPaintProperty(POINTS_ID, 'circle-radius', POINTS_RADIUS_DEFAULT as never); + } + } +} From a16ccc9a0122c40bba37707c54eb0eb0a8c6355b Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 05:21:53 +0900 Subject: [PATCH 50/58] =?UTF-8?q?feat(map):=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EB=AA=A8=EB=93=88=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useNativeMapLayers 범용 hook 생성 - source/layer 생성, visibility, cleanup 자동화 - projectionBusy/isStyleLoaded 가드 내장 - Globe 레이어 순서 관리 내장 - beforeLayer 후보 배열 지원 - useSubcablesLayer를 useNativeMapLayers로 전환 - React Compiler ref 접근 규칙 준수 (useEffect 내 할당) Co-Authored-By: Claude Opus 4.6 --- .../widgets/map3d/hooks/useNativeMapLayers.ts | 167 ++++++++++ .../widgets/map3d/hooks/useSubcablesLayer.ts | 293 +++++++----------- 2 files changed, 274 insertions(+), 186 deletions(-) create mode 100644 apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts diff --git a/apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts b/apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts new file mode 100644 index 0000000..6b5e0c6 --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts @@ -0,0 +1,167 @@ +/** + * useNativeMapLayers — Mercator/Globe 공통 MapLibre 네이티브 레이어 관리 hook + * + * 반복되는 보일러플레이트를 자동화합니다: + * - projectionBusy / isStyleLoaded 가드 + * - GeoJSON source 생성/업데이트 + * - Layer 생성 (ensureLayer) + * - Visibility 토글 + * - Globe 레이어 순서 관리 (reorderGlobeFeatureLayers) + * - kickRepaint + * - Unmount 시 cleanupLayers + * + * 호버 하이라이트, 마우스 이벤트 등 레이어별 커스텀 로직은 + * 별도 useEffect에서 처리합니다. + */ +import { useEffect, useRef, type MutableRefObject } from 'react'; +import maplibregl, { type LayerSpecification } from 'maplibre-gl'; +import { ensureGeoJsonSource, ensureLayer, setLayerVisibility, cleanupLayers } from '../lib/layerHelpers'; +import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; + +/* ── Public types ──────────────────────────────────────────────────── */ + +export interface NativeSourceConfig { + id: string; + data: GeoJSON.GeoJSON | null; +} + +export interface NativeLayerSpec { + id: string; + type: 'line' | 'fill' | 'circle' | 'symbol'; + sourceId: string; + paint: Record; + layout?: Record; + filter?: unknown[]; + minzoom?: number; + maxzoom?: number; +} + +export interface NativeMapLayersConfig { + /** GeoJSON 데이터 소스 (다중 지원) */ + sources: NativeSourceConfig[]; + /** 레이어 스펙 배열 (생성 순서대로) */ + layers: NativeLayerSpec[]; + /** 전체 레이어 on/off */ + visible: boolean; + /** + * 이 레이어들을 삽입할 기준 레이어 ID. + * 배열이면 첫 번째로 존재하는 레이어를 사용합니다. + */ + beforeLayer?: string | string[]; + /** + * 레이어 (재)생성 후 호출되는 콜백. + * 호버 하이라이트 재적용 등에 사용합니다. + */ + onAfterSetup?: (map: maplibregl.Map) => void; +} + +/* ── Hook ──────────────────────────────────────────────────────────── */ + +/** + * @param mapRef - Map 인스턴스 ref + * @param projectionBusyRef - 프로젝션 전환 중 가드 ref + * @param reorderGlobeFeatureLayers - Globe 레이어 순서 재정렬 함수 + * @param config - 소스/레이어/visibility 설정 + * @param deps - 이 값이 변경되면 레이어를 다시 셋업합니다. + * (subcableGeo, overlays.subcables, projection, mapSyncEpoch 등) + */ +export function useNativeMapLayers( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + reorderGlobeFeatureLayers: () => void, + config: NativeMapLayersConfig, + deps: readonly unknown[], +) { + // 최신 config를 항상 읽기 위한 ref (deps에 config 객체를 넣지 않기 위함) + const configRef = useRef(config); + useEffect(() => { + configRef.current = config; + }); + + /* ── 레이어 생성/데이터 업데이트 ─────────────────────────────────── */ + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const ensure = () => { + const cfg = configRef.current; + if (projectionBusyRef.current) return; + + // 1. Visibility 토글 + for (const spec of cfg.layers) { + setLayerVisibility(map, spec.id, cfg.visible); + } + + // 2. 데이터가 있는 source가 하나도 없으면 종료 + const hasData = cfg.sources.some((s) => s.data != null); + if (!hasData) return; + if (!map.isStyleLoaded()) return; + + try { + // 3. Source 생성/업데이트 + for (const src of cfg.sources) { + if (src.data) { + ensureGeoJsonSource(map, src.id, src.data); + } + } + + // 4. Before layer 해석 + let before: string | undefined; + if (cfg.beforeLayer) { + const candidates = Array.isArray(cfg.beforeLayer) ? cfg.beforeLayer : [cfg.beforeLayer]; + for (const candidate of candidates) { + if (map.getLayer(candidate)) { + before = candidate; + break; + } + } + } + + // 5. Layer 생성 + const vis = cfg.visible ? 'visible' : 'none'; + for (const spec of cfg.layers) { + const layerDef: Record = { + id: spec.id, + type: spec.type, + source: spec.sourceId, + paint: spec.paint, + layout: { ...spec.layout, visibility: vis }, + }; + if (spec.filter) layerDef.filter = spec.filter; + if (spec.minzoom != null) layerDef.minzoom = spec.minzoom; + if (spec.maxzoom != null) layerDef.maxzoom = spec.maxzoom; + + ensureLayer(map, layerDef as unknown as LayerSpecification, { before }); + } + + // 6. Post-setup callback + if (cfg.onAfterSetup) { + cfg.onAfterSetup(map); + } + } catch (e) { + console.warn('Native map layers setup failed:', e); + } finally { + reorderGlobeFeatureLayers(); + kickRepaint(map); + } + }; + + const stop = onMapStyleReady(map, ensure); + return () => { + stop(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + /* ── Unmount cleanup ─────────────────────────────────────────────── */ + useEffect(() => { + const mapInstance = mapRef.current; + return () => { + if (!mapInstance) return; + const cfg = configRef.current; + const layerIds = [...cfg.layers].reverse().map((l) => l.id); + const sourceIds = [...cfg.sources].reverse().map((s) => s.id); + cleanupLayers(mapInstance, layerIds, sourceIds); + }; + }, []); +} diff --git a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts index b7211dd..59f9411 100644 --- a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts +++ b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts @@ -1,10 +1,10 @@ import { useEffect, useMemo, useRef, type MutableRefObject } from 'react'; -import maplibregl, { type LayerSpecification } from 'maplibre-gl'; +import maplibregl 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'; +import { kickRepaint } from '../lib/mapCore'; +import { useNativeMapLayers, type NativeLayerSpec } from './useNativeMapLayers'; /* ── Layer / Source IDs ─────────────────────────────────────────────── */ const SRC_ID = 'subcables-src'; @@ -17,9 +17,6 @@ const GLOW_ID = 'subcables-glow'; const POINTS_ID = 'subcables-points'; const LABEL_ID = 'subcables-label'; -const ALL_LAYER_IDS = [LABEL_ID, POINTS_ID, GLOW_ID, LINE_ID, CASING_ID, HITAREA_ID]; -const ALL_SOURCE_IDS = [POINTS_SRC_ID, SRC_ID]; - /* ── Paint defaults (used for layer creation + hover reset) ──────── */ const LINE_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.7, 6, 0.82, 10, 0.92]; const LINE_WIDTH_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 1.6, 6, 2.5, 10, 4.0]; @@ -28,6 +25,87 @@ const CASING_WIDTH_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 2.4, 6, 3. const POINTS_OPACITY_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 0.5, 5, 0.7, 8, 0.85]; const POINTS_RADIUS_DEFAULT = ['interpolate', ['linear'], ['zoom'], 2, 1.5, 6, 2.5, 10, 4]; +/* ── Layer specifications ────────────────────────────────────────── */ +const LAYER_SPECS: NativeLayerSpec[] = [ + { + id: HITAREA_ID, + type: 'line', + sourceId: SRC_ID, + paint: { 'line-color': 'rgba(0,0,0,0)', 'line-width': 14, 'line-opacity': 0 }, + layout: { 'line-cap': 'round', 'line-join': 'round' }, + }, + { + id: CASING_ID, + type: 'line', + sourceId: SRC_ID, + paint: { + 'line-color': 'rgba(0,0,0,0.55)', + 'line-width': CASING_WIDTH_DEFAULT, + 'line-opacity': CASING_OPACITY_DEFAULT, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' }, + }, + { + id: LINE_ID, + type: 'line', + sourceId: SRC_ID, + paint: { + 'line-color': ['get', 'color'], + 'line-opacity': LINE_OPACITY_DEFAULT, + 'line-width': LINE_WIDTH_DEFAULT, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' }, + }, + { + id: GLOW_ID, + type: 'line', + sourceId: SRC_ID, + paint: { + 'line-color': ['get', 'color'], + 'line-opacity': 0, + 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 5, 6, 8, 10, 12], + 'line-blur': ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 5, 10, 7], + }, + filter: ['==', ['get', 'id'], ''], + layout: { 'line-cap': 'round', 'line-join': 'round' }, + }, + { + id: POINTS_ID, + type: 'circle', + sourceId: POINTS_SRC_ID, + paint: { + 'circle-radius': POINTS_RADIUS_DEFAULT, + 'circle-color': ['get', 'color'], + 'circle-opacity': POINTS_OPACITY_DEFAULT, + 'circle-stroke-color': 'rgba(0,0,0,0.5)', + 'circle-stroke-width': 0.5, + }, + minzoom: 3, + }, + { + id: LABEL_ID, + type: 'symbol', + sourceId: SRC_ID, + paint: { + 'text-color': 'rgba(220,232,245,0.82)', + 'text-halo-color': 'rgba(2,6,23,0.9)', + 'text-halo-width': 1.2, + 'text-halo-blur': 0.5, + 'text-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0, 5, 0.7, 8, 0.88], + }, + layout: { + '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', + }, + minzoom: 4, + }, +]; + export function useSubcablesLayer( mapRef: MutableRefObject, projectionBusyRef: MutableRefObject, @@ -46,13 +124,14 @@ export function useSubcablesLayer( const onHoverRef = useRef(onHoverCable); const onClickRef = useRef(onClickCable); - onHoverRef.current = onHoverCable; - onClickRef.current = onClickCable; - const hoveredCableIdRef = useRef(hoveredCableId); - hoveredCableIdRef.current = hoveredCableId; + useEffect(() => { + onHoverRef.current = onHoverCable; + onClickRef.current = onClickCable; + hoveredCableIdRef.current = hoveredCableId; + }); - /* ── Derived point features (cable midpoints for circle markers) ── */ + /* ── Derived point features ──────────────────────────────────────── */ const pointsGeoJson = useMemo(() => { if (!subcableGeo) return { type: 'FeatureCollection', features: [] }; const features: GeoJSON.Feature[] = []; @@ -69,174 +148,27 @@ export function useSubcablesLayer( }, [subcableGeo]); /* ================================================================ - * Effect 1: Layer creation & data update - * - Does NOT depend on hoveredCableId (prevents flicker) - * - Creates sources, layers, sets visibility + * Effect 1: Layer creation & data update (via useNativeMapLayers) * ================================================================ */ - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const ensure = () => { - if (projectionBusyRef.current) return; - - const visible = overlays.subcables; - for (const id of ALL_LAYER_IDS) { - setLayerVisibility(map, id, visible); - } - - if (!subcableGeo) return; - if (!map.isStyleLoaded()) return; - - try { - ensureGeoJsonSource(map, SRC_ID, subcableGeo); - ensureGeoJsonSource(map, POINTS_SRC_ID, pointsGeoJson); - - const before = map.getLayer('zones-fill') - ? 'zones-fill' - : map.getLayer('deck-globe') - ? 'deck-globe' - : undefined; - - const vis = visible ? 'visible' : 'none'; - - /* 1) Hit-area — invisible wide line for easy hover detection */ - ensureLayer( - map, - { - id: HITAREA_ID, - type: 'line', - source: SRC_ID, - paint: { - 'line-color': 'rgba(0,0,0,0)', - 'line-width': 14, - 'line-opacity': 0, - }, - layout: { visibility: vis, 'line-cap': 'round', 'line-join': 'round' }, - } as unknown as LayerSpecification, - { before }, - ); - - /* 2) Dark casing behind cable for contrast */ - ensureLayer( - map, - { - id: CASING_ID, - type: 'line', - source: SRC_ID, - paint: { - 'line-color': 'rgba(0,0,0,0.55)', - 'line-width': CASING_WIDTH_DEFAULT, - 'line-opacity': CASING_OPACITY_DEFAULT, - }, - layout: { visibility: vis, 'line-cap': 'round', 'line-join': 'round' }, - } as unknown as LayerSpecification, - { before }, - ); - - /* 3) Main cable line — vivid color */ - ensureLayer( - map, - { - id: LINE_ID, - type: 'line', - source: SRC_ID, - paint: { - 'line-color': ['get', 'color'], - 'line-opacity': LINE_OPACITY_DEFAULT, - 'line-width': LINE_WIDTH_DEFAULT, - }, - layout: { visibility: vis, 'line-cap': 'round', 'line-join': 'round' }, - } as unknown as LayerSpecification, - { before }, - ); - - /* 4) Glow — visible only on hover */ - ensureLayer( - map, - { - id: GLOW_ID, - type: 'line', - source: SRC_ID, - paint: { - 'line-color': ['get', 'color'], - 'line-opacity': 0, - 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 5, 6, 8, 10, 12], - 'line-blur': ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 5, 10, 7], - }, - filter: ['==', ['get', 'id'], ''], - layout: { visibility: vis, 'line-cap': 'round', 'line-join': 'round' }, - } as unknown as LayerSpecification, - { before }, - ); - - /* 5) Point markers at cable representative coordinates */ - ensureLayer( - map, - { - id: POINTS_ID, - type: 'circle', - source: POINTS_SRC_ID, - paint: { - 'circle-radius': POINTS_RADIUS_DEFAULT, - 'circle-color': ['get', 'color'], - 'circle-opacity': POINTS_OPACITY_DEFAULT, - 'circle-stroke-color': 'rgba(0,0,0,0.5)', - 'circle-stroke-width': 0.5, - }, - layout: { visibility: vis }, - minzoom: 3, - } as unknown as LayerSpecification, - ); - - /* 6) Cable name label along line */ - ensureLayer( - map, - { - id: LABEL_ID, - type: 'symbol', - source: SRC_ID, - layout: { - visibility: vis, - '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(220,232,245,0.82)', - 'text-halo-color': 'rgba(2,6,23,0.9)', - 'text-halo-width': 1.2, - 'text-halo-blur': 0.5, - 'text-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0, 5, 0.7, 8, 0.88], - }, - minzoom: 4, - } as unknown as LayerSpecification, - ); - - // Re-apply current hover state after layer (re-)creation - applyHoverHighlight(map, hoveredCableIdRef.current); - } catch (e) { - console.warn('Subcables layer setup failed:', e); - } finally { - reorderGlobeFeatureLayers(); - kickRepaint(map); - } - }; - - const stop = onMapStyleReady(map, ensure); - return () => { - stop(); - }; - // hoveredCableId intentionally excluded — handled by Effect 2 - }, [subcableGeo, pointsGeoJson, overlays.subcables, projection, mapSyncEpoch, reorderGlobeFeatureLayers]); + useNativeMapLayers( + mapRef, + projectionBusyRef, + reorderGlobeFeatureLayers, + { + sources: [ + { id: SRC_ID, data: subcableGeo }, + { id: POINTS_SRC_ID, data: pointsGeoJson }, + ], + layers: LAYER_SPECS, + visible: overlays.subcables, + beforeLayer: ['zones-fill', 'deck-globe'], + onAfterSetup: (map) => applyHoverHighlight(map, hoveredCableIdRef.current), + }, + [subcableGeo, pointsGeoJson, overlays.subcables, projection, mapSyncEpoch, reorderGlobeFeatureLayers], + ); /* ================================================================ * Effect 2: Hover highlight (paint-only, no layer creation) - * - Lightweight, no flicker * ================================================================ */ useEffect(() => { const map = mapRef.current; @@ -250,7 +182,6 @@ export function useSubcablesLayer( /* ================================================================ * Effect 3: Mouse events (bind to hit-area for easy hovering) - * - Retries binding until layer exists * ================================================================ */ useEffect(() => { const map = mapRef.current; @@ -284,7 +215,6 @@ export function useSubcablesLayer( if (cancelled) return; const targetLayer = map.getLayer(HITAREA_ID) ? HITAREA_ID : map.getLayer(LINE_ID) ? LINE_ID : null; if (!targetLayer) { - // Layer not yet created — retry after short delay retryTimer = setTimeout(bindEvents, 200); return; } @@ -314,15 +244,6 @@ export function useSubcablesLayer( } }; }, [overlays.subcables, mapSyncEpoch]); - - /* ── Cleanup on unmount ───────────────────────────────────────────── */ - useEffect(() => { - const mapInstance = mapRef.current; - return () => { - if (!mapInstance) return; - cleanupLayers(mapInstance, ALL_LAYER_IDS, ALL_SOURCE_IDS); - }; - }, []); } /* ── Hover highlight helper (paint-only mutations) ────────────────── */ From f5ef24c02fd45baad6f077b7e01de50afb0f0ed1 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 05:28:44 +0900 Subject: [PATCH 51/58] =?UTF-8?q?perf(map):=20=ED=95=B4=EC=A0=80=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GeoJSON source tolerance:1, buffer:64 (저줌 vertex 단순화) - hitarea/casing/glow 레이어 minzoom:3 (저줌 렌더 제외) - ensureGeoJsonSource에 source options 파라미터 추가 - NativeSourceConfig에 options 필드 추가 Co-Authored-By: Claude Opus 4.6 --- apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts | 6 ++++-- apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts | 5 ++++- apps/web/src/widgets/map3d/lib/layerHelpers.ts | 2 ++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts b/apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts index 6b5e0c6..d82cd42 100644 --- a/apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useNativeMapLayers.ts @@ -14,7 +14,7 @@ * 별도 useEffect에서 처리합니다. */ import { useEffect, useRef, type MutableRefObject } from 'react'; -import maplibregl, { type LayerSpecification } from 'maplibre-gl'; +import maplibregl, { type GeoJSONSourceSpecification, type LayerSpecification } from 'maplibre-gl'; import { ensureGeoJsonSource, ensureLayer, setLayerVisibility, cleanupLayers } from '../lib/layerHelpers'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; @@ -23,6 +23,8 @@ import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; export interface NativeSourceConfig { id: string; data: GeoJSON.GeoJSON | null; + /** GeoJSON source 옵션 (tolerance, buffer 등) */ + options?: Partial>; } export interface NativeLayerSpec { @@ -101,7 +103,7 @@ export function useNativeMapLayers( // 3. Source 생성/업데이트 for (const src of cfg.sources) { if (src.data) { - ensureGeoJsonSource(map, src.id, src.data); + ensureGeoJsonSource(map, src.id, src.data, src.options); } } diff --git a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts index 59f9411..7fcc138 100644 --- a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts +++ b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts @@ -33,6 +33,7 @@ const LAYER_SPECS: NativeLayerSpec[] = [ sourceId: SRC_ID, paint: { 'line-color': 'rgba(0,0,0,0)', 'line-width': 14, 'line-opacity': 0 }, layout: { 'line-cap': 'round', 'line-join': 'round' }, + minzoom: 3, }, { id: CASING_ID, @@ -44,6 +45,7 @@ const LAYER_SPECS: NativeLayerSpec[] = [ 'line-opacity': CASING_OPACITY_DEFAULT, }, layout: { 'line-cap': 'round', 'line-join': 'round' }, + minzoom: 3, }, { id: LINE_ID, @@ -68,6 +70,7 @@ const LAYER_SPECS: NativeLayerSpec[] = [ }, filter: ['==', ['get', 'id'], ''], layout: { 'line-cap': 'round', 'line-join': 'round' }, + minzoom: 3, }, { id: POINTS_ID, @@ -156,7 +159,7 @@ export function useSubcablesLayer( reorderGlobeFeatureLayers, { sources: [ - { id: SRC_ID, data: subcableGeo }, + { id: SRC_ID, data: subcableGeo, options: { tolerance: 1, buffer: 64 } }, { id: POINTS_SRC_ID, data: pointsGeoJson }, ], layers: LAYER_SPECS, diff --git a/apps/web/src/widgets/map3d/lib/layerHelpers.ts b/apps/web/src/widgets/map3d/lib/layerHelpers.ts index bfe12ca..8a06564 100644 --- a/apps/web/src/widgets/map3d/lib/layerHelpers.ts +++ b/apps/web/src/widgets/map3d/lib/layerHelpers.ts @@ -71,6 +71,7 @@ export function ensureGeoJsonSource( map: maplibregl.Map, sourceId: string, data: GeoJSON.GeoJSON, + options?: Partial>, ) { const existing = map.getSource(sourceId); if (existing) { @@ -79,6 +80,7 @@ export function ensureGeoJsonSource( map.addSource(sourceId, { type: 'geojson', data, + ...options, } satisfies GeoJSONSourceSpecification); } } From 289f1bebc03e4c2dfad7d6fae0e45a6a89e27f31 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 05:47:16 +0900 Subject: [PATCH 52/58] =?UTF-8?q?feat(map):=20=EC=88=98=EC=8B=AC=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EB=B8=94=20=EA=B0=9C=EC=84=A0=20+=20?= =?UTF-8?q?=ED=95=9C=EA=B8=80=20=EC=A7=80=EB=AA=85=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 수심 레이블: minzoom 10→7, 텍스트 크기 확대, halo 가독성 개선 - 해저 지형명: minzoom 8→6, 텍스트 크기 확대 - MapTiler 베이스맵 한글 지명 적용 (name:ko fallback) Co-Authored-By: Claude Opus 4.6 --- .../src/widgets/map3d/layers/bathymetry.ts | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/apps/web/src/widgets/map3d/layers/bathymetry.ts b/apps/web/src/widgets/map3d/layers/bathymetry.ts index 6b55e33..5f480f5 100644 --- a/apps/web/src/widgets/map3d/layers/bathymetry.ts +++ b/apps/web/src/widgets/map3d/layers/bathymetry.ts @@ -159,22 +159,22 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK type: 'symbol', source: oceanSourceId, 'source-layer': 'contour_line', - minzoom: 10, + minzoom: 7, filter: bathyMajorDepthFilter as unknown as unknown[], layout: { 'symbol-placement': 'line', 'text-field': depthLabel, 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], - 'text-size': ['interpolate', ['linear'], ['zoom'], 10, 12, 12, 14, 14, 15], + 'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 9, 12, 11, 14, 13, 16], 'text-allow-overlap': false, - 'text-padding': 2, + 'text-padding': 4, 'text-rotation-alignment': 'map', }, paint: { - 'text-color': 'rgba(226,232,240,0.72)', - 'text-halo-color': 'rgba(2,6,23,0.82)', - 'text-halo-width': 1.0, - 'text-halo-blur': 0.6, + 'text-color': 'rgba(226,232,240,0.78)', + 'text-halo-color': 'rgba(2,6,23,0.88)', + 'text-halo-width': 1.2, + 'text-halo-blur': 0.5, }, } as unknown as LayerSpecification; @@ -183,21 +183,21 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK type: 'symbol', source: oceanSourceId, 'source-layer': 'landform', - minzoom: 8, + minzoom: 6, filter: ['has', 'name'] as unknown as unknown[], layout: { 'text-field': ['get', 'name'] as unknown as unknown[], 'text-font': ['Noto Sans Italic', 'Noto Sans Regular', 'Open Sans Italic', 'Open Sans Regular'], - 'text-size': ['interpolate', ['linear'], ['zoom'], 8, 11, 10, 12, 12, 13], + 'text-size': ['interpolate', ['linear'], ['zoom'], 6, 10, 8, 12, 10, 13, 12, 14], 'text-allow-overlap': false, 'text-anchor': 'center', 'text-offset': [0, 0.0], }, paint: { - 'text-color': 'rgba(148,163,184,0.70)', - 'text-halo-color': 'rgba(2,6,23,0.85)', - 'text-halo-width': 1.0, - 'text-halo-blur': 0.7, + 'text-color': 'rgba(148,163,184,0.75)', + 'text-halo-color': 'rgba(2,6,23,0.88)', + 'text-halo-width': 1.2, + 'text-halo-blur': 0.6, }, } as unknown as LayerSpecification; @@ -272,6 +272,19 @@ export function applyBathymetryZoomProfile(map: maplibregl.Map, baseMap: BaseMap } } +function applyKoreanLabels(style: StyleSpecification) { + if (!style.layers) return; + const koTextField = ['coalesce', ['get', 'name:ko'], ['get', 'name']]; + for (const layer of style.layers as unknown as LayerSpecification[]) { + if ((layer as { type?: string }).type !== 'symbol') continue; + const layout = (layer as Record).layout as + | Record + | undefined; + if (!layout?.['text-field']) continue; + layout['text-field'] = koTextField; + } +} + export async function resolveInitialMapStyle(signal: AbortSignal): Promise { const key = getMapTilerKey(); if (!key) return '/map/styles/osm-seamark.json'; @@ -283,6 +296,7 @@ export async function resolveInitialMapStyle(signal: AbortSignal): Promise Date: Mon, 16 Feb 2026 05:57:22 +0900 Subject: [PATCH 53/58] =?UTF-8?q?fix(map):=20=EC=88=98=EC=8B=AC=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EB=B8=94=EC=9D=B4=20=ED=95=9C=EA=B8=80=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=EC=97=90=20=EC=9D=98=ED=95=B4=20=EB=8D=AE?= =?UTF-8?q?=EC=96=B4=EC=93=B0=EC=9D=B4=EB=8A=94=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyKoreanLabels를 injectOceanBathymetryLayers보다 먼저 호출하여 수심 text-field가 보존되도록 순서 변경 Co-Authored-By: Claude Opus 4.6 --- apps/web/src/widgets/map3d/layers/bathymetry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/widgets/map3d/layers/bathymetry.ts b/apps/web/src/widgets/map3d/layers/bathymetry.ts index 5f480f5..65d3909 100644 --- a/apps/web/src/widgets/map3d/layers/bathymetry.ts +++ b/apps/web/src/widgets/map3d/layers/bathymetry.ts @@ -295,8 +295,8 @@ export async function resolveInitialMapStyle(signal: AbortSignal): Promise Date: Mon, 16 Feb 2026 06:17:20 +0900 Subject: [PATCH 54/58] =?UTF-8?q?feat(map):=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90=20+=20=EC=88=98?= =?UTF-8?q?=EC=8B=AC=20=EB=B2=94=EB=A1=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 나침반/줌 컨트롤 분리, 기어 버튼으로 설정 패널 토글 - 설정 항목: 레이블 언어, 육지/물/수심 색상, 수심 폰트 크기/색상 - 런타임 map.setPaintProperty/setLayoutProperty로 즉시 적용 - 수심 색상 범례 (좌하단 그라데이션 바 + 눈금) - 초기화 버튼으로 디폴트 복원 Co-Authored-By: Claude Opus 4.6 --- apps/web/src/app/styles.css | 176 ++++++++++++++++++ .../features/mapSettings/MapSettingsPanel.tsx | 157 ++++++++++++++++ apps/web/src/features/mapSettings/types.ts | 32 ++++ .../web/src/pages/dashboard/DashboardPage.tsx | 8 + apps/web/src/widgets/legend/DepthLegend.tsx | 33 ++++ apps/web/src/widgets/map3d/Map3D.tsx | 4 + .../web/src/widgets/map3d/hooks/useMapInit.ts | 3 +- .../map3d/hooks/useMapStyleSettings.ts | 165 ++++++++++++++++ .../src/widgets/map3d/layers/bathymetry.ts | 7 +- apps/web/src/widgets/map3d/types.ts | 2 + 10 files changed, 584 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/features/mapSettings/MapSettingsPanel.tsx create mode 100644 apps/web/src/features/mapSettings/types.ts create mode 100644 apps/web/src/widgets/legend/DepthLegend.tsx create mode 100644 apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts diff --git a/apps/web/src/app/styles.css b/apps/web/src/app/styles.css index a959fd6..1037b54 100644 --- a/apps/web/src/app/styles.css +++ b/apps/web/src/app/styles.css @@ -921,6 +921,182 @@ body { border-radius: 8px; } +/* ── Map Settings Panel ────────────────────────────────────────────── */ + +.map-settings-gear { + position: absolute; + top: 95px; + left: 10px; + z-index: 850; + width: 29px; + height: 29px; + border-radius: 4px; + border: 1px solid var(--border); + background: rgba(15, 23, 42, 0.92); + backdrop-filter: blur(8px); + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + transition: color 0.15s, border-color 0.15s; + user-select: none; + padding: 0; +} + +.map-settings-gear:hover { + color: var(--text); + border-color: var(--accent); +} + +.map-settings-gear.open { + color: var(--accent); + border-color: var(--accent); +} + +.map-settings-panel { + position: absolute; + top: 10px; + left: 48px; + z-index: 850; + background: rgba(15, 23, 42, 0.95); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + width: 240px; + max-height: calc(100vh - 80px); + overflow-y: auto; +} + +.map-settings-panel .ms-title { + font-size: 10px; + font-weight: 700; + color: var(--text); + letter-spacing: 1px; + margin-bottom: 10px; +} + +.map-settings-panel .ms-section { + margin-bottom: 10px; +} + +.map-settings-panel .ms-label { + font-size: 8px; + font-weight: 700; + color: var(--muted); + letter-spacing: 1px; + margin-bottom: 4px; +} + +.map-settings-panel .ms-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.map-settings-panel .ms-color-input { + width: 24px; + height: 24px; + border: 1px solid var(--border); + border-radius: 4px; + padding: 0; + cursor: pointer; + background: transparent; + flex-shrink: 0; +} + +.map-settings-panel .ms-color-input::-webkit-color-swatch-wrapper { + padding: 1px; +} + +.map-settings-panel .ms-color-input::-webkit-color-swatch { + border: none; + border-radius: 2px; +} + +.map-settings-panel .ms-hex { + font-size: 9px; + color: var(--muted); + font-family: monospace; +} + +.map-settings-panel .ms-depth-label { + font-size: 9px; + color: var(--text); + min-width: 48px; + text-align: right; +} + +.map-settings-panel select { + font-size: 10px; + padding: 4px 8px; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--card); + color: var(--text); + cursor: pointer; + outline: none; + width: 100%; +} + +.map-settings-panel select:focus { + border-color: var(--accent); +} + +.map-settings-panel .ms-reset { + width: 100%; + font-size: 9px; + padding: 5px 8px; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--card); + color: var(--muted); + cursor: pointer; + transition: all 0.15s; + margin-top: 4px; +} + +.map-settings-panel .ms-reset:hover { + color: var(--text); + border-color: var(--accent); +} + +/* ── Depth Legend ──────────────────────────────────────────────────── */ + +.depth-legend { + position: absolute; + bottom: 44px; + left: 10px; + z-index: 800; + background: rgba(15, 23, 42, 0.92); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px; + display: flex; + gap: 6px; + align-items: stretch; +} + +.depth-legend__bar { + width: 14px; + border-radius: 3px; + min-height: 120px; +} + +.depth-legend__ticks { + display: flex; + flex-direction: column; + justify-content: space-between; + font-size: 8px; + color: var(--muted); + font-family: monospace; + padding: 1px 0; +} + @media (max-width: 920px) { .app { grid-template-columns: 1fr; diff --git a/apps/web/src/features/mapSettings/MapSettingsPanel.tsx b/apps/web/src/features/mapSettings/MapSettingsPanel.tsx new file mode 100644 index 0000000..f7dc386 --- /dev/null +++ b/apps/web/src/features/mapSettings/MapSettingsPanel.tsx @@ -0,0 +1,157 @@ +import { useState } from 'react'; +import type { MapStyleSettings, MapLabelLanguage, DepthFontSize } from './types'; +import { DEFAULT_MAP_STYLE_SETTINGS } from './types'; + +interface MapSettingsPanelProps { + value: MapStyleSettings; + onChange: (next: MapStyleSettings) => void; +} + +const LANGUAGES: { value: MapLabelLanguage; label: string }[] = [ + { value: 'ko', label: '한국어' }, + { value: 'en', label: 'English' }, + { value: 'ja', label: '日本語' }, + { value: 'zh', label: '中文' }, + { value: 'local', label: '현지어' }, +]; + +const FONT_SIZES: { value: DepthFontSize; label: string }[] = [ + { value: 'small', label: '소' }, + { value: 'medium', label: '중' }, + { value: 'large', label: '대' }, +]; + +function depthLabel(depth: number): string { + return `${Math.abs(depth).toLocaleString()}m`; +} + +export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) { + const [open, setOpen] = useState(false); + + const update = (key: K, val: MapStyleSettings[K]) => { + onChange({ ...value, [key]: val }); + }; + + const updateDepthStop = (index: number, color: string) => { + const next = value.depthStops.map((s, i) => (i === index ? { ...s, color } : s)); + update('depthStops', next); + }; + + return ( + <> + + + {open && ( +
+
지도 설정
+ + {/* ── Language ──────────────────────────────────── */} +
+
레이블 언어
+ +
+ + {/* ── Land color ────────────────────────────────── */} +
+
육지 색상
+
+ update('landColor', e.target.value)} + /> + {value.landColor} +
+
+ + {/* ── Water color ───────────────────────────────── */} +
+
물 기본색
+
+ update('waterBaseColor', e.target.value)} + /> + {value.waterBaseColor} +
+
+ + {/* ── Depth gradient ────────────────────────────── */} +
+
수심 구간 색상
+ {value.depthStops.map((stop, i) => ( +
+ {depthLabel(stop.depth)} + updateDepthStop(i, e.target.value)} + /> + {stop.color} +
+ ))} +
+ + {/* ── Depth font size ───────────────────────────── */} +
+
수심 폰트 크기
+
+ {FONT_SIZES.map((fs) => ( +
update('depthFontSize', fs.value)} + > + {fs.label} +
+ ))} +
+
+ + {/* ── Depth font color ──────────────────────────── */} +
+
수심 폰트 색상
+
+ update('depthFontColor', e.target.value)} + /> + {value.depthFontColor} +
+
+ + {/* ── Reset ─────────────────────────────────────── */} + +
+ )} + + ); +} diff --git a/apps/web/src/features/mapSettings/types.ts b/apps/web/src/features/mapSettings/types.ts new file mode 100644 index 0000000..546a126 --- /dev/null +++ b/apps/web/src/features/mapSettings/types.ts @@ -0,0 +1,32 @@ +export type MapLabelLanguage = 'ko' | 'en' | 'ja' | 'zh' | 'local'; +export type DepthFontSize = 'small' | 'medium' | 'large'; + +export interface DepthColorStop { + depth: number; + color: string; +} + +export interface MapStyleSettings { + labelLanguage: MapLabelLanguage; + landColor: string; + waterBaseColor: string; + depthStops: DepthColorStop[]; + depthFontSize: DepthFontSize; + depthFontColor: string; +} + +export const DEFAULT_MAP_STYLE_SETTINGS: MapStyleSettings = { + labelLanguage: 'ko', + landColor: '#1a1a2e', + waterBaseColor: '#14606e', + depthStops: [ + { depth: -8000, color: '#010610' }, + { depth: -4000, color: '#030c1c' }, + { depth: -2000, color: '#041022' }, + { depth: -1000, color: '#051529' }, + { depth: -500, color: '#061a30' }, + { depth: -100, color: '#08263d' }, + ], + depthFontSize: 'medium', + depthFontColor: '#e2e8f0', +}; diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index bbc2489..62a5302 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -24,6 +24,10 @@ 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 { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel"; +import { DepthLegend } from "../../widgets/legend/DepthLegend"; +import { DEFAULT_MAP_STYLE_SETTINGS } from "../../features/mapSettings/types"; +import type { MapStyleSettings } from "../../features/mapSettings/types"; import { buildLegacyHitMap, computeCountsByType, @@ -111,6 +115,7 @@ export function DashboardPage() { const [baseMap, setBaseMap] = useState("enhanced"); const [projection, setProjection] = useState("mercator"); + const [mapStyleSettings, setMapStyleSettings] = useState(DEFAULT_MAP_STYLE_SETTINGS); const [overlays, setOverlays] = useState({ pairLines: true, @@ -722,7 +727,10 @@ export function DashboardPage() { hoveredCableId={hoveredCableId} onHoverCable={setHoveredCableId} onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))} + mapStyleSettings={mapStyleSettings} /> + + {selectedLegacyVessel ? ( setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} /> diff --git a/apps/web/src/widgets/legend/DepthLegend.tsx b/apps/web/src/widgets/legend/DepthLegend.tsx new file mode 100644 index 0000000..48747e8 --- /dev/null +++ b/apps/web/src/widgets/legend/DepthLegend.tsx @@ -0,0 +1,33 @@ +import { useMemo } from 'react'; +import type { DepthColorStop } from '../../features/mapSettings/types'; + +interface DepthLegendProps { + depthStops: DepthColorStop[]; +} + +export function DepthLegend({ depthStops }: DepthLegendProps) { + const sorted = useMemo( + () => [...depthStops].sort((a, b) => a.depth - b.depth), + [depthStops], + ); + + const gradient = useMemo(() => { + if (sorted.length === 0) return 'transparent'; + const stops = sorted.map((s, i) => { + const pct = (i / (sorted.length - 1)) * 100; + return `${s.color} ${pct.toFixed(0)}%`; + }); + return `linear-gradient(to bottom, ${stops.join(', ')})`; + }, [sorted]); + + return ( +
+
+
+ {sorted.map((s) => ( + {Math.abs(s.depth).toLocaleString()}m + ))} +
+
+ ); +} diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 19e919a..c3a534f 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -26,6 +26,7 @@ import { useGlobeOverlays } from './hooks/useGlobeOverlays'; import { useGlobeInteraction } from './hooks/useGlobeInteraction'; import { useDeckLayers } from './hooks/useDeckLayers'; import { useSubcablesLayer } from './hooks/useSubcablesLayer'; +import { useMapStyleSettings } from './hooks/useMapStyleSettings'; export type { Map3DSettings, BaseMapId, MapProjectionId } from './types'; @@ -64,6 +65,7 @@ export function Map3D({ hoveredCableId = null, onHoverCable, onClickCable, + mapStyleSettings, }: Props) { void onHoverFleet; void onClearFleetHover; @@ -448,6 +450,8 @@ export function Map3D({ { baseMap, projection, showSeamark: settings.showSeamark, mapSyncEpoch, pulseMapSync }, ); + useMapStyleSettings(mapRef, mapStyleSettings, { baseMap, mapSyncEpoch }); + useZonesLayer( mapRef, projectionBusyRef, reorderGlobeFeatureLayers, { zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch }, diff --git a/apps/web/src/widgets/map3d/hooks/useMapInit.ts b/apps/web/src/widgets/map3d/hooks/useMapInit.ts index 26fb2de..6e64376 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapInit.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapInit.ts @@ -91,7 +91,8 @@ export function useMapInit( scrollZoom: { around: 'center' }, }); - map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), 'top-left'); + map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: false }), 'top-left'); + map.addControl(new maplibregl.NavigationControl({ showZoom: false, showCompass: true }), 'top-left'); map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left'); mapRef.current = map; diff --git a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts new file mode 100644 index 0000000..1f6fece --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts @@ -0,0 +1,165 @@ +import { useEffect, useRef, type MutableRefObject } from 'react'; +import maplibregl from 'maplibre-gl'; +import type { MapStyleSettings, MapLabelLanguage, DepthColorStop, DepthFontSize } from '../../../features/mapSettings/types'; +import type { BaseMapId } from '../types'; +import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; + +/* ── Depth font size presets ──────────────────────────────────────── */ +const DEPTH_FONT_SIZE_MAP: Record = { + small: ['interpolate', ['linear'], ['zoom'], 7, 8, 9, 9, 11, 11, 13, 13], + medium: ['interpolate', ['linear'], ['zoom'], 7, 10, 9, 12, 11, 14, 13, 16], + large: ['interpolate', ['linear'], ['zoom'], 7, 12, 9, 15, 11, 18, 13, 20], +}; + +/* ── Helpers ──────────────────────────────────────────────────────── */ +function darkenHex(hex: string, factor = 0.85): string { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `#${[r, g, b].map((c) => Math.round(c * factor).toString(16).padStart(2, '0')).join('')}`; +} + +function lightenHex(hex: string, factor = 1.3): string { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `#${[r, g, b].map((c) => Math.min(255, Math.round(c * factor)).toString(16).padStart(2, '0')).join('')}`; +} + +/* ── Apply functions ──────────────────────────────────────────────── */ + +function applyLabelLanguage(map: maplibregl.Map, lang: MapLabelLanguage) { + const style = map.getStyle(); + if (!style?.layers) return; + for (const layer of style.layers) { + if (layer.type !== 'symbol') continue; + const layout = (layer as { layout?: Record }).layout; + if (!layout?.['text-field']) continue; + if (layer.id === 'bathymetry-labels') continue; + const textField = + lang === 'local' + ? ['get', 'name'] + : ['coalesce', ['get', `name:${lang}`], ['get', 'name']]; + try { + map.setLayoutProperty(layer.id, 'text-field', textField); + } catch { + // ignore + } + } +} + +function applyLandColor(map: maplibregl.Map, color: string) { + const style = map.getStyle(); + if (!style?.layers) return; + const landRegex = /(land|landcover|landuse|earth|continent|terrain|park)/i; + for (const layer of style.layers) { + if (layer.type !== 'fill') continue; + const id = layer.id; + const sourceLayer = String((layer as Record)['source-layer'] ?? ''); + if (!landRegex.test(id) && !landRegex.test(sourceLayer)) continue; + try { + map.setPaintProperty(id, 'fill-color', color); + } catch { + // ignore + } + } +} + +function applyWaterBaseColor(map: maplibregl.Map, fillColor: string) { + const style = map.getStyle(); + if (!style?.layers) return; + const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i; + const lineColor = darkenHex(fillColor, 0.85); + for (const layer of style.layers) { + const id = layer.id; + if (id.startsWith('bathymetry-')) continue; + const sourceLayer = String((layer as Record)['source-layer'] ?? ''); + if (!waterRegex.test(id) && !waterRegex.test(sourceLayer)) continue; + try { + if (layer.type === 'fill') { + map.setPaintProperty(id, 'fill-color', fillColor); + } else if (layer.type === 'line') { + map.setPaintProperty(id, 'line-color', lineColor); + } + } catch { + // ignore + } + } +} + +function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) { + if (!map.getLayer('bathymetry-fill')) return; + const depth = ['to-number', ['get', 'depth']]; + const sorted = [...stops].sort((a, b) => a.depth - b.depth); + const expr: unknown[] = ['interpolate', ['linear'], depth]; + const deepest = sorted[0]; + if (deepest) expr.push(-11000, darkenHex(deepest.color, 0.5)); + for (const s of sorted) { + expr.push(s.depth, s.color); + } + const shallowest = sorted[sorted.length - 1]; + if (shallowest) expr.push(0, lightenHex(shallowest.color, 1.8)); + try { + map.setPaintProperty('bathymetry-fill', 'fill-color', expr as never); + } catch { + // ignore + } +} + +function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) { + const expr = DEPTH_FONT_SIZE_MAP[size]; + for (const layerId of ['bathymetry-labels', 'bathymetry-landforms']) { + if (!map.getLayer(layerId)) continue; + try { + map.setLayoutProperty(layerId, 'text-size', expr); + } catch { + // ignore + } + } +} + +function applyDepthFontColor(map: maplibregl.Map, color: string) { + for (const layerId of ['bathymetry-labels', 'bathymetry-landforms']) { + if (!map.getLayer(layerId)) continue; + try { + map.setPaintProperty(layerId, 'text-color', color); + } catch { + // ignore + } + } +} + +/* ── Hook ──────────────────────────────────────────────────────────── */ +export function useMapStyleSettings( + mapRef: MutableRefObject, + settings: MapStyleSettings | undefined, + opts: { baseMap: BaseMapId; mapSyncEpoch: number }, +) { + const settingsRef = useRef(settings); + useEffect(() => { + settingsRef.current = settings; + }); + + const { baseMap, mapSyncEpoch } = opts; + + useEffect(() => { + const map = mapRef.current; + const s = settingsRef.current; + if (!map || !s) return; + + const stop = onMapStyleReady(map, () => { + applyLabelLanguage(map, s.labelLanguage); + applyLandColor(map, s.landColor); + applyWaterBaseColor(map, s.waterBaseColor); + if (baseMap === 'enhanced') { + applyDepthGradient(map, s.depthStops); + applyDepthFontSize(map, s.depthFontSize); + applyDepthFontColor(map, s.depthFontColor); + } + kickRepaint(map); + }); + + return () => stop(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [settings, baseMap, mapSyncEpoch]); +} diff --git a/apps/web/src/widgets/map3d/layers/bathymetry.ts b/apps/web/src/widgets/map3d/layers/bathymetry.ts index 65d3909..afcf223 100644 --- a/apps/web/src/widgets/map3d/layers/bathymetry.ts +++ b/apps/web/src/widgets/map3d/layers/bathymetry.ts @@ -6,6 +6,9 @@ import maplibregl, { import type { BaseMapId, BathyZoomRange, MapProjectionId } from '../types'; import { getLayerId, getMapTilerKey } from '../lib/mapCore'; +export const SHALLOW_WATER_FILL_DEFAULT = '#14606e'; +export const SHALLOW_WATER_LINE_DEFAULT = '#114f5c'; + const BATHY_ZOOM_RANGES: BathyZoomRange[] = [ { id: 'bathymetry-fill', mercator: [6, 24], globe: [8, 24] }, { id: 'bathymetry-borders', mercator: [6, 24], globe: [8, 24] }, @@ -209,8 +212,8 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK // Brighten base-map water fills so shallow coasts, rivers & lakes blend naturally // with the bathymetry gradient instead of appearing as near-black voids. const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i; - const SHALLOW_WATER_FILL = '#14606e'; - const SHALLOW_WATER_LINE = '#114f5c'; + const SHALLOW_WATER_FILL = SHALLOW_WATER_FILL_DEFAULT; + const SHALLOW_WATER_LINE = SHALLOW_WATER_LINE_DEFAULT; for (const layer of layers) { const id = getLayerId(layer); if (!id) continue; diff --git a/apps/web/src/widgets/map3d/types.ts b/apps/web/src/widgets/map3d/types.ts index 98358f6..16d1d1f 100644 --- a/apps/web/src/widgets/map3d/types.ts +++ b/apps/web/src/widgets/map3d/types.ts @@ -4,6 +4,7 @@ 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'; +import type { MapStyleSettings } from '../../features/mapSettings/types'; export type Map3DSettings = { showSeamark: boolean; @@ -50,6 +51,7 @@ export interface Map3DProps { hoveredCableId?: string | null; onHoverCable?: (cableId: string | null) => void; onClickCable?: (cableId: string | null) => void; + mapStyleSettings?: MapStyleSettings; } export type DashSeg = { From 1a3dd82eb4b18934a9a1026d75beb6db88f7701e Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 06:23:55 +0900 Subject: [PATCH 55/58] =?UTF-8?q?fix(map):=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 육지색 적용 범위 확대 (background + 전체 fill 레이어) - UI 가독성 개선: 라벨 10px, 색상 대비 강화 - 수심 구간 '자동채우기' 토글 추가 (최소/최대 기준 보간) Co-Authored-By: Claude Opus 4.6 --- apps/web/src/app/styles.css | 16 ++-- .../features/mapSettings/MapSettingsPanel.tsx | 96 +++++++++++++++---- .../map3d/hooks/useMapStyleSettings.ts | 15 ++- 3 files changed, 99 insertions(+), 28 deletions(-) diff --git a/apps/web/src/app/styles.css b/apps/web/src/app/styles.css index 1037b54..3d864f8 100644 --- a/apps/web/src/app/styles.css +++ b/apps/web/src/app/styles.css @@ -971,7 +971,7 @@ body { } .map-settings-panel .ms-title { - font-size: 10px; + font-size: 11px; font-weight: 700; color: var(--text); letter-spacing: 1px; @@ -983,11 +983,11 @@ body { } .map-settings-panel .ms-label { - font-size: 8px; - font-weight: 700; - color: var(--muted); - letter-spacing: 1px; - margin-bottom: 4px; + font-size: 10px; + font-weight: 600; + color: var(--text); + letter-spacing: 0.5px; + margin-bottom: 5px; } .map-settings-panel .ms-row { @@ -1019,12 +1019,12 @@ body { .map-settings-panel .ms-hex { font-size: 9px; - color: var(--muted); + color: #94a3b8; font-family: monospace; } .map-settings-panel .ms-depth-label { - font-size: 9px; + font-size: 10px; color: var(--text); min-width: 48px; text-align: right; diff --git a/apps/web/src/features/mapSettings/MapSettingsPanel.tsx b/apps/web/src/features/mapSettings/MapSettingsPanel.tsx index f7dc386..592fb60 100644 --- a/apps/web/src/features/mapSettings/MapSettingsPanel.tsx +++ b/apps/web/src/features/mapSettings/MapSettingsPanel.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import type { MapStyleSettings, MapLabelLanguage, DepthFontSize } from './types'; +import type { MapStyleSettings, MapLabelLanguage, DepthFontSize, DepthColorStop } from './types'; import { DEFAULT_MAP_STYLE_SETTINGS } from './types'; interface MapSettingsPanelProps { @@ -25,8 +25,42 @@ function depthLabel(depth: number): string { return `${Math.abs(depth).toLocaleString()}m`; } +function hexToRgb(hex: string): [number, number, number] { + return [ + parseInt(hex.slice(1, 3), 16), + parseInt(hex.slice(3, 5), 16), + parseInt(hex.slice(5, 7), 16), + ]; +} + +function rgbToHex(r: number, g: number, b: number): string { + return `#${[r, g, b].map((c) => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0')).join('')}`; +} + +function interpolateGradient(stops: DepthColorStop[]): DepthColorStop[] { + if (stops.length < 2) return stops; + const sorted = [...stops].sort((a, b) => a.depth - b.depth); + const first = sorted[0]; + const last = sorted[sorted.length - 1]; + const [r1, g1, b1] = hexToRgb(first.color); + const [r2, g2, b2] = hexToRgb(last.color); + return sorted.map((stop, i) => { + if (i === 0 || i === sorted.length - 1) return stop; + const t = i / (sorted.length - 1); + return { + depth: stop.depth, + color: rgbToHex( + r1 + (r2 - r1) * t, + g1 + (g2 - g1) * t, + b1 + (b2 - b1) * t, + ), + }; + }); +} + export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) { const [open, setOpen] = useState(false); + const [autoGradient, setAutoGradient] = useState(false); const update = (key: K, val: MapStyleSettings[K]) => { onChange({ ...value, [key]: val }); @@ -34,7 +68,19 @@ export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) { const updateDepthStop = (index: number, color: string) => { const next = value.depthStops.map((s, i) => (i === index ? { ...s, color } : s)); - update('depthStops', next); + if (autoGradient && (index === 0 || index === next.length - 1)) { + update('depthStops', interpolateGradient(next)); + } else { + update('depthStops', next); + } + }; + + const toggleAutoGradient = () => { + const next = !autoGradient; + setAutoGradient(next); + if (next) { + update('depthStops', interpolateGradient(value.depthStops)); + } }; return ( @@ -97,19 +143,34 @@ export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) { {/* ── Depth gradient ────────────────────────────── */}
-
수심 구간 색상
- {value.depthStops.map((stop, i) => ( -
- {depthLabel(stop.depth)} - updateDepthStop(i, e.target.value)} - /> - {stop.color} -
- ))} +
+ 수심 구간 색상 + + 자동채우기 + +
+ {value.depthStops.map((stop, i) => { + const isEdge = i === 0 || i === value.depthStops.length - 1; + const dimmed = autoGradient && !isEdge; + return ( +
+ {depthLabel(stop.depth)} + updateDepthStop(i, e.target.value)} + disabled={dimmed} + /> + {stop.color} +
+ ); + })}
{/* ── Depth font size ───────────────────────────── */} @@ -146,7 +207,10 @@ export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) { diff --git a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts index 1f6fece..2cf0724 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts @@ -51,14 +51,21 @@ function applyLabelLanguage(map: maplibregl.Map, lang: MapLabelLanguage) { function applyLandColor(map: maplibregl.Map, color: string) { const style = map.getStyle(); if (!style?.layers) return; - const landRegex = /(land|landcover|landuse|earth|continent|terrain|park)/i; + const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i; + const darkVariant = darkenHex(color, 0.8); for (const layer of style.layers) { - if (layer.type !== 'fill') continue; const id = layer.id; + if (id.startsWith('bathymetry-')) continue; + if (id.startsWith('subcables-')) continue; const sourceLayer = String((layer as Record)['source-layer'] ?? ''); - if (!landRegex.test(id) && !landRegex.test(sourceLayer)) continue; + const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer); + if (isWater) continue; try { - map.setPaintProperty(id, 'fill-color', color); + if (layer.type === 'background') { + map.setPaintProperty(id, 'background-color', color); + } else if (layer.type === 'fill') { + map.setPaintProperty(id, 'fill-color', darkVariant); + } } catch { // ignore } From d2178a613480c5e30aef4c99be8215e6f3623373 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 06:27:48 +0900 Subject: [PATCH 56/58] =?UTF-8?q?fix(map):=20=EC=9C=A1=EC=A7=80=EC=83=89?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=EC=97=90=EC=84=9C=20=EC=88=98=EC=97=AD/?= =?UTF-8?q?=EC=84=A0=EB=B0=95=20=EB=93=B1=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zones, ships, pair, fc, fleet, predict, deck-globe 레이어를 applyLandColor에서 제외하여 수역 표시 복원 Co-Authored-By: Claude Opus 4.6 --- apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts index 2cf0724..00feb3f 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts @@ -57,6 +57,13 @@ function applyLandColor(map: maplibregl.Map, color: string) { const id = layer.id; if (id.startsWith('bathymetry-')) continue; if (id.startsWith('subcables-')) continue; + if (id.startsWith('zones-')) continue; + if (id.startsWith('ships-')) continue; + if (id.startsWith('pair-')) continue; + if (id.startsWith('fc-')) continue; + if (id.startsWith('fleet-')) continue; + if (id.startsWith('predict-')) continue; + if (id === 'deck-globe') continue; const sourceLayer = String((layer as Record)['source-layer'] ?? ''); const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer); if (isWater) continue; From 3acda7432ef9f24a038f6d03b025a4b72fe4e64a Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 07:04:31 +0900 Subject: [PATCH 57/58] =?UTF-8?q?refactor(map):=20UI=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=20=E2=80=94=203D=20=EB=AA=85=EC=B9=AD,=20=EC=88=98=EC=8B=AC=20?= =?UTF-8?q?=EC=A4=8C,=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20=EB=B9=84=ED=99=9C?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "지구본" → "3D" 명칭 변경, 헤더 우측으로 이동 - 레거시 베이스맵 비활성 (주석처리) - 수심 minzoom 통일: fill 3, borders 5, major 3 - NavigationControl 통합, 기어 버튼 겹침 수정 - constants.ts 미사용 BATHY_ZOOM_RANGES 제거 Co-Authored-By: Claude Opus 4.6 --- apps/web/src/app/styles.css | 2 +- .../web/src/pages/dashboard/DashboardPage.tsx | 36 +++++++++---------- apps/web/src/widgets/map3d/constants.ts | 9 ++--- .../web/src/widgets/map3d/hooks/useMapInit.ts | 3 +- .../src/widgets/map3d/layers/bathymetry.ts | 14 ++++---- 5 files changed, 30 insertions(+), 34 deletions(-) diff --git a/apps/web/src/app/styles.css b/apps/web/src/app/styles.css index 3d864f8..462325f 100644 --- a/apps/web/src/app/styles.css +++ b/apps/web/src/app/styles.css @@ -925,7 +925,7 @@ body { .map-settings-gear { position: absolute; - top: 95px; + top: 100px; left: 10px; z-index: 850; width: 29px; diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 62a5302..87e5bdb 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -113,7 +113,9 @@ export function DashboardPage() { const [showTargets, setShowTargets] = useState(true); const [showOthers, setShowOthers] = useState(false); - const [baseMap, setBaseMap] = useState("enhanced"); + // 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용 + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [baseMap, _setBaseMap] = useState("enhanced"); const [projection, setProjection] = useState("mercator"); const [mapStyleSettings, setMapStyleSettings] = useState(DEFAULT_MAP_STYLE_SETTINGS); @@ -357,30 +359,28 @@ export function DashboardPage() {
-
지도 표시 설정
- setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} /> -
- 베이스맵 +
+ 지도 표시 설정 +
+
setProjection((p) => (p === "globe" ? "mercator" : "globe"))} + title="3D 지구본 투영: 드래그로 회전, 휠로 확대/축소" + style={{ fontSize: 9, padding: "2px 8px" }} + > + 3D +
-
+ setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} /> + {/* 베이스맵 선택 — 현재 enhanced 단일 맵 사용, 레거시는 비활성 +
setBaseMap("enhanced")} title="현재 지도(해저지형/3D 표현 포함)"> 기본
setBaseMap("legacy")} title="레거시 대시보드(Carto Dark) 베이스맵"> 레거시
- -
- -
setProjection((p) => (p === "globe" ? "mercator" : "globe"))} - title="지구본(globe) 투영: 드래그로 회전, 휠로 확대/축소" - > - 지구본 -
-
- {/* Attribution (license) stays visible in the map UI; no need to repeat it here. */} +
*/}
diff --git a/apps/web/src/widgets/map3d/constants.ts b/apps/web/src/widgets/map3d/constants.ts index 66b2b78..92db352 100644 --- a/apps/web/src/widgets/map3d/constants.ts +++ b/apps/web/src/widgets/map3d/constants.ts @@ -3,7 +3,6 @@ import { OVERLAY_RGB, rgba as rgbaCss, } from '../../shared/lib/map/palette'; -import type { BathyZoomRange } from './types'; // ── Re-export palette aliases used throughout Map3D ── @@ -158,9 +157,5 @@ export const FLEET_LINE_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.65); export const FLEET_LINE_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95); // ── Bathymetry zoom ranges ── - -export const BATHY_ZOOM_RANGES: BathyZoomRange[] = [ - { id: 'bathymetry-fill', mercator: [5, 24], globe: [7, 24] }, - { id: 'bathymetry-borders', mercator: [5, 24], globe: [7, 24] }, - { id: 'bathymetry-borders-major', mercator: [3, 24], globe: [7, 24] }, -]; +// NOTE: BATHY_ZOOM_RANGES는 bathymetry.ts에서 로컬 정의 + applyBathymetryZoomProfile()에서 사용 +// 이 파일의 export는 사용처가 없어 제거됨 (2026-02-16) diff --git a/apps/web/src/widgets/map3d/hooks/useMapInit.ts b/apps/web/src/widgets/map3d/hooks/useMapInit.ts index 6e64376..26fb2de 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapInit.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapInit.ts @@ -91,8 +91,7 @@ export function useMapInit( scrollZoom: { around: 'center' }, }); - map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: false }), 'top-left'); - map.addControl(new maplibregl.NavigationControl({ showZoom: false, showCompass: true }), 'top-left'); + map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), 'top-left'); map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left'); mapRef.current = map; diff --git a/apps/web/src/widgets/map3d/layers/bathymetry.ts b/apps/web/src/widgets/map3d/layers/bathymetry.ts index afcf223..4c4089b 100644 --- a/apps/web/src/widgets/map3d/layers/bathymetry.ts +++ b/apps/web/src/widgets/map3d/layers/bathymetry.ts @@ -10,9 +10,9 @@ export const SHALLOW_WATER_FILL_DEFAULT = '#14606e'; export const SHALLOW_WATER_LINE_DEFAULT = '#114f5c'; const BATHY_ZOOM_RANGES: BathyZoomRange[] = [ - { id: 'bathymetry-fill', mercator: [6, 24], globe: [8, 24] }, - { id: 'bathymetry-borders', mercator: [6, 24], globe: [8, 24] }, - { id: 'bathymetry-borders-major', mercator: [4, 24], globe: [8, 24] }, + { id: 'bathymetry-fill', mercator: [3, 24], globe: [3, 24] }, + { id: 'bathymetry-borders', mercator: [5, 24], globe: [5, 24] }, + { id: 'bathymetry-borders-major', mercator: [3, 24], globe: [3, 24] }, ]; export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) { @@ -69,7 +69,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK type: 'fill', source: oceanSourceId, 'source-layer': 'contour', - minzoom: 5, + minzoom: 3, maxzoom: 24, paint: { 'fill-color': bathyFillColor, @@ -82,7 +82,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK type: 'line', source: oceanSourceId, 'source-layer': 'contour', - minzoom: 5, + minzoom: 5, // fill은 3부터, borders는 5부터 maxzoom: 24, paint: { 'line-color': 'rgba(255,255,255,0.06)', @@ -304,6 +304,8 @@ export async function resolveInitialMapStyle(signal: AbortSignal): Promise { - if (baseMap === 'legacy') return '/map/styles/carto-dark.json'; + // 레거시 베이스맵 비활성 — 향후 위성/라이트 테마 등 추가 시 재활용 + // if (baseMap === 'legacy') return '/map/styles/carto-dark.json'; + void baseMap; return resolveInitialMapStyle(signal); } From 9fd0567ccf0fec9a1b2363c8fdd02c640f2fa5a6 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 07:06:41 +0900 Subject: [PATCH 58/58] =?UTF-8?q?ci:=20Gitea=20Actions=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EB=B9=8C=EB=93=9C/=EB=B0=B0=ED=8F=AC=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main push 시 모노레포 @wing/web 빌드 후 wing.gc-si.dev에 자동 배포 Co-Authored-By: Claude Opus 4.6 --- .gitea/workflows/deploy.yml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .gitea/workflows/deploy.yml diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..9401039 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,36 @@ +name: Build and Deploy Wing + +on: + push: + branches: + - main + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Configure npm registry + run: | + echo "registry=https://nexus.gc-si.dev/repository/npm-public/" > .npmrc + echo "//nexus.gc-si.dev/repository/npm-public/:_auth=${{ secrets.NEXUS_NPM_AUTH }}" >> .npmrc + + - name: Install dependencies + run: npm ci + + - name: Build web + run: npm -w @wing/web run build + + - name: Deploy to server + run: | + rm -rf /deploy/wing/* + cp -r apps/web/dist/* /deploy/wing/ + echo "Deployed at $(date '+%Y-%m-%d %H:%M:%S')" + ls -la /deploy/wing/