Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion web/src/data/gazetteer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,63 @@ export const GAZETTEER: Record<string, GazEntry> = {
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<string, readonly GeoCoord[]> = {
'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. */
Expand All @@ -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
Expand All @@ -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). */
Expand Down Expand Up @@ -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,
Expand All @@ -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,
})
}

Expand Down
26 changes: 25 additions & 1 deletion web/src/design/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]] },
Expand All @@ -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 = {
Expand All @@ -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. */
Expand Down
103 changes: 93 additions & 10 deletions web/src/globe/GlobeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
}

/**
Expand Down Expand Up @@ -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<Anchor[]>(() => {
const output: Anchor[] = []
for (const marker of markers) {
Expand All @@ -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(
Expand All @@ -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<PointDatum[]>(
() => markers.map((marker) => ({ ...marker, intensity: quantise(markerIntensity[marker.nodeId] ?? 0) })),
Expand All @@ -185,7 +225,7 @@ export default function GlobeView({
// Each edge becomes three stacked arcs (soft halo, mid glow, bright core).
const arcData = useMemo<ArcDatum[]>(
() =>
edges.flatMap((edge) =>
arcEdges.flatMap((edge) =>
ARC_LAYERS.map((layer) => ({
layer,
edgeId: edge.id,
Expand All @@ -197,7 +237,21 @@ export default function GlobeView({
})),
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[edges, edgeSig],
[arcEdges, arcSig],
)

const pathData = useMemo<PathDatum[]>(
() =>
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(
Expand Down Expand Up @@ -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
Expand All @@ -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}
Expand Down
Loading