diff --git a/web/src/data/gazetteer.ts b/web/src/data/gazetteer.ts index fb4cb42..9164d77 100644 --- a/web/src/data/gazetteer.ts +++ b/web/src/data/gazetteer.ts @@ -57,6 +57,63 @@ export const GAZETTEER: Record = { dairy_farms: { lat: 45.4, lng: 2.0, label: 'EU dairy farms', illustrative: true }, } +/** + * Authored sea routes by edge id. Values are the intermediate waypoints only; + * the endpoints come from the resolved edge. Routes keep the Brazilian lanes + * offshore, leave Argentina through the Rio de la Plata, and take the US Gulf + * lane around Florida rather than across it. + */ +export const EDGE_ROUTES: Record = { + 'santos_port->rotterdam_port': [ + { lat: -23.0, lng: -41.0 }, + { lat: -17.0, lng: -33.0 }, + { lat: -6.0, lng: -27.0 }, + { lat: 8.0, lng: -23.0 }, + { lat: 22.0, lng: -20.0 }, + { lat: 36.0, lng: -16.0 }, + { lat: 45.0, lng: -12.0 }, + { lat: 49.0, lng: -7.0 }, + { lat: 50.5, lng: -2.0 }, + { lat: 51.2, lng: 1.4 }, + ], + 'paranagua_port->hamburg_port': [ + { lat: -28.0, lng: -42.0 }, + { lat: -24.0, lng: -34.0 }, + { lat: -14.0, lng: -26.0 }, + { lat: -1.0, lng: -20.0 }, + { lat: 14.0, lng: -17.0 }, + { lat: 29.0, lng: -13.0 }, + { lat: 42.0, lng: -8.0 }, + { lat: 49.0, lng: -3.0 }, + { lat: 52.0, lng: 3.0 }, + { lat: 53.0, lng: 6.0 }, + ], + 'argentina_wholesaler->rotterdam_port': [ + { lat: -34.7, lng: -58.3 }, + { lat: -37.0, lng: -54.0 }, + { lat: -32.0, lng: -49.0 }, + { lat: -23.0, lng: -44.0 }, + { lat: -12.0, lng: -38.0 }, + { lat: 1.0, lng: -33.0 }, + { lat: 16.0, lng: -29.0 }, + { lat: 31.0, lng: -25.0 }, + { lat: 43.0, lng: -20.0 }, + { lat: 48.0, lng: -13.0 }, + { lat: 50.0, lng: -6.0 }, + ], + 'us_wholesaler->rotterdam_port': [ + { lat: 25.5, lng: -84.0 }, + { lat: 24.0, lng: -80.0 }, + { lat: 29.0, lng: -71.0 }, + { lat: 35.0, lng: -58.0 }, + { lat: 41.0, lng: -44.0 }, + { lat: 46.0, lng: -30.0 }, + { lat: 49.0, lng: -17.0 }, + { lat: 50.5, lng: -7.0 }, + { lat: 51.2, lng: -1.0 }, + ], +} + /** A single rendered marker (one node may yield several — see the pool split). */ export interface Marker { /** Unique marker id: node id, or `${nodeId}::${entityId}` for a split node. */ @@ -71,7 +128,7 @@ export interface Marker { hasRecordedData: boolean } -/** An edge with both endpoints resolved to coordinates, ready for `arcsData`. */ +/** An edge with both endpoints resolved to coordinates, ready for the globe layers. */ export interface ResolvedEdge { id: string startLat: number @@ -81,6 +138,8 @@ export interface ResolvedEdge { isSeaCrossing: boolean /** "Source → Target" in English, for the edge annotation. */ label: string + /** Authored route including both endpoints; absent means draw a great-circle arc. */ + path?: readonly GeoCoord[] } /** A node/edge that could not be placed, with the reason (for logging/UI). */ @@ -145,6 +204,7 @@ export function resolveScene(nodes: Node[], edges: Edge[]): Scene { unplaced.push({ kind: 'edge', id: e.id, reason: 'endpoint has no placement' }) continue } + const waypoints = EDGE_ROUTES[e.id] resolvedEdges.push({ id: e.id, startLat: s.lat, @@ -153,6 +213,7 @@ export function resolveScene(nodes: Node[], edges: Edge[]): Scene { endLng: t.lng, isSeaCrossing: e.isSeaCrossing, label: `${nameById.get(e.source) ?? e.source} → ${nameById.get(e.target) ?? e.target}`, + path: waypoints ? [s, ...waypoints, t] : undefined, }) } diff --git a/web/src/design/tokens.ts b/web/src/design/tokens.ts index 3f94277..42ebd72 100644 --- a/web/src/design/tokens.ts +++ b/web/src/design/tokens.ts @@ -77,8 +77,22 @@ export const globe = { * cyan atmosphere rather than the reference's shock-red. */ export const arc = { - /** Matches the globe's `arcAltitudeAutoScale`; edge apex is derived from it. */ + /** Matches the globe's `arcAltitudeAutoScale` and `arcApex` geometry. */ altitudeAutoScale: 0.45, + /** Low lift for short physical land connectors. */ + overlandAltitude: 0.006, + overlandStrokeScale: 0.45, + /** Surface path z-budget: above the globe, below markers at 0.014. */ + pathAltitude: 0.008, + /** Path-specific hierarchy: moving glow/core dominate a restrained solid halo. */ + pathStrokeScale: { halo: 1, glow: 1.8, core: 2.4 }, + pathAlphaScale: { halo: 0.35, glow: 1, core: 1 }, + /** Commercial relationships are optional context, not geographic routes. */ + commercialAlphaScale: 0.24, + commercialStrokeScale: 0.45, + commercialDashLength: 0.16, + commercialDashGap: 0.1, + commercialDashAnimateMs: 3600, /** Disruption colour: corridors lerp teal → this as tick intensity rises. */ hot: [255, 96, 80] as [number, number, number], halo: { stroke: 2.8, alpha: 0.16, teal: [[57, 208, 200], [126, 224, 230]] }, @@ -87,6 +101,11 @@ export const arc = { coreDashLength: 0.4, coreDashGap: 0.22, coreDashAnimateMs: 1400, + pathCoreDashLength: 0.1, + pathCoreDashGap: 0.065, + pathCoreDashAnimateMs: 1800, + /** Slight phase offset broadens the moving pulse without filling its visible gap. */ + pathGlowDashInitialGap: 0.025, } as const export const marker = { @@ -96,6 +115,11 @@ export const marker = { haloRadius: 4.2, haloSpeed: 0.55, haloRepeatMs: 1800, + portAltitude: 0.009, + portLabelSize: 0.45, + portDotRadius: 0.4, + portOriginColor: 'rgba(246, 197, 91, 0.95)', + portDestinationColor: 'rgba(185, 233, 255, 0.85)', } as const /** CSS custom properties consumed by the frame and annotation stylesheets. */ diff --git a/web/src/globe/GlobeView.tsx b/web/src/globe/GlobeView.tsx index 7b9af2f..02e1c75 100644 --- a/web/src/globe/GlobeView.tsx +++ b/web/src/globe/GlobeView.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import Globe, { type GlobeMethods } from 'react-globe.gl' import type { Marker, ResolvedEdge } from '../data/gazetteer' import { arc as arcTok, color, globe as globeTok, marker as markerTok, texture } from '../design/tokens' -import { arcApex } from './geo' +import { arcApex, densifyPolyline, type LatLng } from './geo' import './labels.css' const SELECTED_MARKER_ID = 'brazil_farms' @@ -32,6 +32,14 @@ interface ArcDatum { intensity: number } +/** One rendered path. Each routed ocean edge gets the same three-layer stack. */ +interface PathDatum { + layer: ArcLayer + edgeId: string + points: LatLng[] + intensity: number +} + /** A marker with its current (quantised) playback intensity baked in. */ interface PointDatum extends Marker { intensity: number @@ -101,11 +109,11 @@ function arcSpec(layer: ArcLayer) { return layer === 'halo' ? arcTok.halo : layer === 'glow' ? arcTok.glow : arcTok.core } -/** Arc layer colour, lerped teal → red by the edge intensity. */ -function colorForArc(datum: ArcDatum): string[] { +/** Corridor layer colour, lerped teal → red by the edge intensity. */ +function colorForCorridor(datum: ArcDatum | PathDatum, layerAlphaScale = 1): string[] { const spec = arcSpec(datum.layer) const t = Math.min(1, datum.intensity) * 0.85 - return spec.teal.map((teal) => rgba(mix(teal, arcTok.hot, t), spec.alpha)) + return spec.teal.map((teal) => rgba(mix(teal, arcTok.hot, t), spec.alpha * layerAlphaScale)) } /** @@ -136,6 +144,24 @@ export default function GlobeView({ return () => window.removeEventListener('resize', onResize) }, []) + // An edge with an authored route renders as a surface path; everything else + // keeps the great-circle arc. A route is densified so long legs follow the + // globe instead of cutting through it as straight chords. + const pathEdges = useMemo( + () => edges.filter((edge) => edge.path !== undefined && edge.path.length > 1), + [edges], + ) + + const arcEdges = useMemo( + () => edges.filter((edge) => !(edge.path !== undefined && edge.path.length > 1)), + [edges], + ) + + const pathGeometry = useMemo( + () => pathEdges.map((edge) => ({ edge, points: densifyPolyline(edge.path ?? []) })), + [pathEdges], + ) + const anchors = useMemo(() => { const output: Anchor[] = [] for (const marker of markers) { @@ -149,7 +175,7 @@ export default function GlobeView({ illustrative: marker.illustrative, }) } - for (const edge of edges) { + for (const edge of arcEdges) { // Anchor the edge label to the arc's actual rendered apex (not the // great-circle midpoint) so it sits on the curve for arcs of any length. const apex = arcApex( @@ -167,14 +193,28 @@ export default function GlobeView({ illustrative: false, }) } + for (const { edge, points } of pathGeometry) { + // A routed edge has no arc apex; its label sits at the middle of the route. + const mid = points[Math.floor(points.length / 2)] + output.push({ + id: `edge:${edge.id}`, + lat: mid.lat, + lng: mid.lng, + alt: arcTok.pathAltitude, + text: edge.label, + kind: 'edge', + illustrative: false, + }) + } return output - }, [markers, edges]) + }, [markers, arcEdges, pathGeometry]) // Signatures of the quantised intensities. useMemo compares deps by value, so a // new-but-identical signature string keeps the previous data reference — the // globe only rebuilds when disruption actually changes. const markerSig = markers.map((m) => quantise(markerIntensity[m.nodeId] ?? 0)).join(',') - const edgeSig = edges.map((e) => quantise(edgeIntensity[e.id] ?? 0)).join(',') + const arcSig = arcEdges.map((e) => `${e.id}:${quantise(edgeIntensity[e.id] ?? 0)}`).join(',') + const pathSig = pathEdges.map((e) => `${e.id}:${quantise(edgeIntensity[e.id] ?? 0)}`).join(',') const pointData = useMemo( () => markers.map((marker) => ({ ...marker, intensity: quantise(markerIntensity[marker.nodeId] ?? 0) })), @@ -185,7 +225,7 @@ export default function GlobeView({ // Each edge becomes three stacked arcs (soft halo, mid glow, bright core). const arcData = useMemo( () => - edges.flatMap((edge) => + arcEdges.flatMap((edge) => ARC_LAYERS.map((layer) => ({ layer, edgeId: edge.id, @@ -197,7 +237,21 @@ export default function GlobeView({ })), ), // eslint-disable-next-line react-hooks/exhaustive-deps - [edges, edgeSig], + [arcEdges, arcSig], + ) + + const pathData = useMemo( + () => + pathGeometry.flatMap(({ edge, points }) => + ARC_LAYERS.map((layer) => ({ + layer, + edgeId: edge.id, + points, + intensity: quantise(edgeIntensity[edge.id] ?? 0), + })), + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [pathGeometry, pathSig], ) const selectedMarker = useMemo( @@ -286,7 +340,7 @@ export default function GlobeView({ arcEndLat={(datum) => (datum as ArcDatum).endLat} arcEndLng={(datum) => (datum as ArcDatum).endLng} arcAltitudeAutoScale={arcTok.altitudeAutoScale} - arcColor={(datum: object) => colorForArc(datum as ArcDatum)} + arcColor={(datum: object) => colorForCorridor(datum as ArcDatum)} arcStroke={(datum: object) => arcSpec((datum as ArcDatum).layer).stroke} arcDashLength={(datum: object) => (datum as ArcDatum).layer === 'core' ? arcTok.coreDashLength : 1 @@ -296,6 +350,35 @@ export default function GlobeView({ (datum as ArcDatum).layer === 'core' ? arcTok.coreDashAnimateMs : 0 } arcsTransitionDuration={0} + pathsData={pathData} + pathPoints={(datum) => (datum as PathDatum).points} + pathPointLat={(point) => (point as LatLng).lat} + pathPointLng={(point) => (point as LatLng).lng} + pathPointAlt={arcTok.pathAltitude} + pathResolution={2} + pathColor={(datum: object) => { + const path = datum as PathDatum + return colorForCorridor(path, arcTok.pathAlphaScale[path.layer]) + }} + pathStroke={(datum: object) => { + const path = datum as PathDatum + return arcSpec(path.layer).stroke * arcTok.pathStrokeScale[path.layer] + }} + pathDashLength={(datum: object) => { + const layer = (datum as PathDatum).layer + return layer === 'halo' ? 1 : arcTok.pathCoreDashLength + }} + pathDashGap={(datum: object) => { + const layer = (datum as PathDatum).layer + return layer === 'halo' ? 0 : arcTok.pathCoreDashGap + }} + pathDashInitialGap={(datum: object) => + (datum as PathDatum).layer === 'glow' ? arcTok.pathGlowDashInitialGap : 0 + } + pathDashAnimateTime={(datum: object) => + (datum as PathDatum).layer === 'halo' ? 0 : arcTok.pathCoreDashAnimateMs + } + pathTransitionDuration={0} htmlElementsData={anchors} htmlLat={(datum: object) => (datum as Anchor).lat} htmlLng={(datum: object) => (datum as Anchor).lng} diff --git a/web/src/globe/geo.ts b/web/src/globe/geo.ts index 50f181e..074568e 100644 --- a/web/src/globe/geo.ts +++ b/web/src/globe/geo.ts @@ -1,17 +1,10 @@ /** - * Geographic helpers for placing annotations on the rendered globe. + * Geographic helpers for rendered globe geometry. * - * Edge-label placement is the non-obvious piece. globe.gl (three-globe) does NOT - * draw an arc along the geographic great circle: it builds a cubic Bézier through - * the great-circle points at t=0.25 and t=0.75, each lifted to 1.5× the arc's - * apex altitude, from surface start to surface end. For a long arc at a small - * fixed altitude the Bézier apex sinks below the surface and the middle of the - * arc is occluded — so labels must anchor to the ACTUAL Bézier apex, not the - * great-circle midpoint, and the arc altitude must scale with length. - * - * `arcApex` replicates three-globe's `calcCurve` exactly (same control-point - * placement, altitude auto-scaling, and axis convention) and returns the apex as - * lat/lng/alt, so a label anchored there sits on the rendered curve. + * `arcApex` preserves three-globe's cubic Bézier calculation for arc geometry; + * long arcs must use the matching altitude auto-scale or their midpoint can sink + * below the globe. `densifyPolyline` protects surface paths from the equivalent + * chord-through-the-globe artefact by bounding every great-circle segment. */ export interface LatLng { @@ -26,6 +19,10 @@ export interface Apex { } const DEG = Math.PI / 180 +const EARTH_RADIUS_KM = 6371.0088 + +/** Longest permitted surface segment before a path is densified. */ +export const MAX_SEGMENT_KM = 250 /** Wrap a longitude in degrees into the (-180, 180] range. */ function wrapLng(lng: number): number { @@ -71,6 +68,39 @@ function slerp(a: LatLng, b: LatLng, t: number): LatLng { return { lat: p.lat, lng: p.lng } } +function greatCircleKm(a: LatLng, b: LatLng): number { + const va = polarToCartesian(a.lat, a.lng, 0) + const vb = polarToCartesian(b.lat, b.lng, 0) + const angular = Math.acos(Math.max(-1, Math.min(1, dot3(va, vb)))) + return angular * EARTH_RADIUS_KM +} + +/** + * Split every waypoint leg into great-circle segments no longer than the given + * surface distance. Interpolation stays in Cartesian space so paths follow the + * globe surface instead of cutting through it as long straight chords. + */ +export function densifyPolyline( + points: readonly LatLng[], + maxSegmentKm = MAX_SEGMENT_KM, +): LatLng[] { + if (maxSegmentKm <= 0) { + throw new Error(`Polyline segment limit must be positive, received ${maxSegmentKm}`) + } + if (points.length < 2) return points.map((point) => ({ ...point })) + + const output: LatLng[] = [{ ...points[0] }] + for (let index = 1; index < points.length; index += 1) { + const start = points[index - 1] + const end = points[index] + const segmentCount = Math.max(1, Math.ceil(greatCircleKm(start, end) / maxSegmentKm)) + for (let segment = 1; segment <= segmentCount; segment += 1) { + output.push(slerp(start, end, segment / segmentCount)) + } + } + return output +} + /** * The rendered apex of a globe.gl arc, replicating three-globe's `calcCurve`. *