leaflow-gl
v3.29.0
Published
**Leaflow-GL** is a high-performance WebGL extension for **Leaflet maps** that uses **GPU-accelerated rendering** to create dynamic visualizations of **vector field flows**, such as currents, wind, waves and others. It is developed by [Amphitrite](https:/
Readme
📦 Leaflow-GL
Leaflow-GL is a high-performance WebGL extension for Leaflet maps that uses GPU-accelerated rendering to create dynamic visualizations of vector field flows, such as currents, wind, waves and others. It is developed by Amphitrite, a French startup specializing in real-time ocean data intelligence, so it provides extensions and API Keys to access their data and forecasts.
The library externalizes components that draw animated particle flows over the map, representing dynamic vector fields like ocean currents or atmospheric winds, and displays a colored heatmap background, indicating intensity variations of the underlying flow data.
Users can choose one of the two main components to use in the main application depending on their needs:
- AmphitriteLeaflowLayer: Uses Amphitrite available data for currents, winds, waves and others with API key authentication, simple and straightforward integration.
- LeaflowLayer: Requires users to have their own data, and has a complex integration.
The layers are built using pure Three.js for GPU rendering, and can be seamlessly integrated into Leaflet-based projects.
The library is highly customizable and users can:
- Make use of the core API (vanilla JavaScript/TypeScript, extending Leaflet's
L.GridLayerandL.Layer); - Or opt out for the React wrapper (easy-to-use React components, compatible with
react-leaflet); - Tweak and fine tune particles parameters as they wish: opacity, trail, size, count, speed, color, etc...;
- Modify the colors/intensity association of the heatmap backgrounds for each visualization system;
🎮 Demo
🛠 Installation
1. Install Leaflow-GL
npm install leaflow-gl✅ This will install the Leaflow-GL library directly into your project with public npm registry.
2. Install Peer Dependencies
Leaflow-GL relies on the following libraries, which must be installed separately in your project:
| Dependency | Recommended Version |
| --------------- | ------------------------------------------------------------- |
| leaflet | ^1.9.4 |
| three | ^0.176.0 |
| react | ^18.0.0 or ^19.0.0 (if using React components) |
| react-leaflet | ^4.0.0 or ^5.0.0 (only if using React components) |
| lil-gui | ^0.19.0 or ^0.20.0 (optional, only needed for debug mode) |
You can install all required peer dependencies with:
npm install leaflet threeIf you are using the React components, also install:
npm install react react-leafletIf you plan to use debug mode to tweak particle settings visually during runtime, install:
npm install lil-gui✅ All peer dependencies must already exist in your project at compatible versions.
⚡ Notes
- Three.js and Leaflet are mandatory.
- React and React-Leaflet are needed only if you use the React components.
- lil-gui is optional and used only for debug/visual parameter tweaking.
Stylesheet
Leaflow-GL's own CSS is injected automatically by the bundle — there is no stylesheet to import. You still need Leaflet's, since Leaflet is an external peer dependency:
import "leaflet/dist/leaflet.css";🖱 Smooth Wheel Zoom (Leaflet map option)
Leaflow-GL augments Leaflet's MapOptions with a continuous, fractional wheel-zoom
handler. It is registered automatically when the library is imported, and is
enabled per map through map options — not through the layer.
| Map option | Type | Default | Description |
| ------------------- | --------------------- | ------- | ---------------------------------------------------------------------- |
| smoothWheelZoom | boolean \| "center" | false | true zooms toward the cursor; "center" zooms toward the map center |
| smoothSensitivity | number | 1 | Wheel sensitivity multiplier |
Disable Leaflet's stock scrollWheelZoom when using it, and set zoomSnap={0} so
zoom levels are not quantized:
<MapContainer
zoom={4}
zoomSnap={0}
scrollWheelZoom={false}
smoothWheelZoom={true}
smoothSensitivity={3}
center={[10, -10]}
/>Vanilla equivalent:
const map = L.map("map", {
zoomSnap: 0,
scrollWheelZoom: false,
smoothWheelZoom: true,
smoothSensitivity: 3,
}).setView([10, -10], 4);🌀 AmphitriteLeaflowLayer
Users may choose this component for a straightforward and easy integration, since all the data is already available for you through Amphitrite's API. This component is highly customizable and users can choose between multiple types of environmental data visualizations. You will be provided an API key which is mandatory to have access to the data, simply input the apiKey inside the component and it'll do the rest.
Available Data Types
AmphitriteDataType supports the following values. The display names and icons
below are the ones exported as MENU_TYPE_TO_NAME and MENU_TYPE_TO_ICON.
| Type | Display name | Icon |
| --------------------------- | ------------------------------------------ | ---- |
| currents | Oceanic currents | 🌀 |
| currents-tides | Oceanic + Tidal Currents | 🌙 |
| currents-arctic | Arctic Ocean Currents | 🌀 |
| currents-mercator | Oceanic currents (Mercator) | 🌀 |
| wind | Wind (10m above surface) | 🍃 |
| wind-gust | Wind Gust | 🍃 |
| wave-height | Wave Height (significant) | 🌊 |
| wave-period | Wave Period (mean) | 🌊 |
| swell-height | Swell Height | 🌊 |
| swell-period | Swell Period | 🌊 |
| wind-wave-height | Wind Wave Height | 🌊 |
| wind-wave-period | Wind Wave Period | 🌊 |
| wind-aifs | Wind, ECMWF AIFS model (10m) | 🍃 |
| wave-height-aifs | Wave Height, ECMWF AIFS model | 🌊 |
| wave-period-aifs | Wave Period, ECMWF AIFS model | 🌊 |
| sea-temperature | Sea Temperature (surface) | 🌡️ |
| sattelite-sea-temperature | Satellite Sea Surface Temperature (L3S-HR) | 🌡️ |
| chlorophyll | Satellite Chlorophyll-a (L3 multi-sensor) | 🌿 |
| salinity | Salinity (surface) | 🧂 |
| ice | Ice concentration | 🧊 |
| ice-ostia | Ice concentration (OSTIA) | 🧊 |
| ice-galeio | Ice concentration (GALEIO) | 🧊 |
| icing | Icing predictor (m·°C/s) | 🧊 |
| icing-class | Icing class (0 none … 4 extreme) | 🧊 |
| cloud | Cloud Coverage | 🌥️ |
| snow | Snow | ❄️ |
| precipitation | Precipitation | 🌧️ |
| humidity | Humidity | 💧 |
| pressure | Pressure (surface air) | 🎈 |
| air-temperature | Air Temperature (2m above surface) | 🌡️ |
| min-air-temperature | Min Air Temperature (2m above surface) | 🌡️ |
| max-air-temperature | Max Air Temperature (2m above surface) | 🌡️ |
| bathymetry | Bathymetry | 🌍 |
| sediments | Sediments | 🗺️ |
| clim-wind | Wind Climatology | 📅 |
| clim-wind-gust | Wind Gust Climatology | 📅 |
| clim-wave-height | Wave Height Climatology | 📅 |
| clim-wave-period | Wave Period Climatology | 📅 |
⚠️
sattelite-sea-temperatureis spelled with the doubletin the public type. It is kept as-is for backwards compatibility.
Type-dependent behaviour:
sedimentsrenders as a categorical layer: the legend switches to labelled swatches instead of a continuous gradient, using the library's built-in sediment classes.sea-temperatureandsattelite-sea-temperatureenable color auto-adjust (autoAdjustColors) and the interactive legend range by default. All other types have color adjustment disabled unless you opt in.chlorophylltiles carry log10(CHL): colour stops,labelSteps,displayRangeand the interactive legend range are in log10 units (-2is 0.01 mg/m³,1is 10 mg/m³). Itslog10unit makes the legend and the click-info box show the concentration itself. The default palette runs from 0.01 mg/m³ (blue) to 20 mg/m³ (red) and saturates beyond both ends. Pixels the satellite saw as cloud are painted with the satellite cloud colour.- The
clim-*types are monthly climatologies, not forecasts: they are selected by calendar month rather than by date-time, and swap the date-time picker for a month picker. See Climatology. - Land-area visibility varies per type; the land color defaults to
"gray".
Example - React
Here's an example on how to use AmphitriteLeaflowLayer + React to integrate it in your platform:
import { AmphitriteLeaflowLayer } from "leaflow-gl/react";
import { MapContainer } from "react-leaflet";
function Main() {
return (
<MapContainer
center={[0, 0]}
zoom={3}
scrollWheelZoom={false}
>
<AmphitriteLeaflowLayer
type="wind"
apiKey="your-api-key-here"
date={new Date()}
appearanceMode="light"
backgroundColor="#7f7f7f"
landArea="gray"
clickInfoBoxEnabled={true}
/>
</MapContainer>
);
}Example - Vanilla
Here's an example on how to use AmphitriteLeaflowLayer with Vanilla Javascript/Typescript to integrate it in your platform:
import { AmphitriteLeaflowLayer } from "leaflow-gl";
const amphiLayer = new AmphitriteLeaflowLayer({
type: "wind",
apiKey: "your-api-key-here",
date: new Date(),
appearanceMode: "light",
backgroundColor: "#7f7f7f",
landArea: "gray",
clickInfoBoxEnabled: true,
});
leafletMap.addLayer(amphiLayer);📚 Docs
🛠 Constructor Parameters
The AmphitriteLeaflowLayer accepts the following parameters:
Required Parameters
| Parameter | Type | Description |
| ---------- | --------------------- | ------------------------------------------------------------------------------ |
| type | AmphitriteDataType | Type of data to retrieve from Amphitrite's database (see Available Data Types) |
| apiKey | string | API Key provided by Amphitrite to authenticate data requests |
| landArea | "gray" | "white" | Color of land areas. Required by the type; falls back to "gray" at runtime |
Core Optional Parameters
| Parameter | Type | Default | Description |
| --------------------- | ------------------------------------- | ------------ | --------------------------------------------------------------------------------- |
| date | Date | new Date() | Date for which to retrieve data (supports both historical data and forecasts) |
| climatologyMonth | ClimatologyMonth | from date | Month a clim-* type shows, 1–12; wins over the month of date |
| autoForecastUpdates | boolean | true | Follow completed default-source publications and refresh tiles automatically |
| tileVersion | string | automatic | Manual published-run override appended to mutable forecast requests as ?v=... |
| appearanceMode | "light" | "twilight" | "dark" | unchanged | Theme the heatmap tiles, particles, legend and point-forecast popup |
| backgroundColor | string | map CSS | CSS background shown behind unrendered and no-data tiles |
| heatmap | LeaflowHeatmap | auto | Custom heatmap color mapping (defaults to Amphitrite's optimized settings) |
| particles | ParticlesProps | auto | Custom particle behavior configuration (defaults to Amphitrite's settings) |
| storm | string | undefined | Storm to focus on: display name or id (e.g., "SAUDEL", "WP172026") |
| storms | StormsOptions | undefined | Storm overlay configuration (API key, provider, invests) — see Storms |
Overlays
| Parameter | Type | Default | Description |
| ------------------------ | --------- | ------- | --------------------------------------------------- |
| showPressureIsolines | boolean | true | Show surface-pressure isolines and high/low centers |
| pressureIsolinesInterval | 2 \| 5 | 2 | Spacing between pressure isolines, in hPa |
| showBathymetryIsolines | boolean | false | Show bathymetry isolines with depth labels |
⚠️
showPressureIsolinesdefaults totrue. Passfalseexplicitly to hide it.
Color Range Control
| Parameter | Type | Default | Description |
| --------------------------- | ---------------------------- | -------------- | ------------------------------------------------------------------------------------------- |
| displayRange | DisplayRange | undefined | Clamp the color mapping to an explicit { min, max } window |
| autoAdjustColors | boolean | varies by type | Continuously re-fit the color range to the visible data (on by default for sea temperature) |
| onAutoAdjustColorsChanged | (enabled: boolean) => void | — | Fires whenever auto-adjust toggles, including manual override via the legend |
Feature Components
| Parameter | Type | Default | Description |
| ----------------------- | ----------------------- | ------- | ---------------------------------------------------------------------------------------- |
| clickInfoBoxEnabled | boolean | false | Enable clicking on the map to display point information |
| clickInfoBoxOptions | ClickInfoBoxProps | — | Customize appearance of the click info box (immutable, set at construction) |
| dateTimePickerOptions | DateTimePickerOptions | — | Configure the date-time picker for date selection |
| climatologyMonthPickerOptions | ClimatologyMonthPickerOptions | — | Configure the month picker shown instead of the date picker by clim-* types |
| heatmapLegendOptions | HeatmapLegendProps | — | Configure the heatmap legend display |
| latLonGrid | LatLonGridOptions | off | Latitude/longitude graticule with depth-graded levels |
| labels | LabelsOptions | off | Country/city place-name overlay drawn above the heatmap |
| seamarks | SeamarksOptions | off | Transparent nautical symbols over weather tiles and below particles, within a zoom range |
| openSeaMap | OpenSeaMapOptions | on | OpenSeaMap nautical chart that becomes the basemap past zoom 10 |
| downloadPdfOptions | DownloadPdfOptions | enabled | Configure PDF download feature for point forecasts |
Callbacks
| Parameter | Type | Description |
| --------------------------- | -------------------------------------- | -------------------------------------------------------------------------------- |
| onLeaflowDataReady | (data?: LeaflowData) => void | Called when flow data is loaded and ready |
| onLeaflowLandDataReady | (landData?: LeaflowLandData) => void | Called when land mask data is loaded |
| onDateChanged | (date: Date) => void | Called when the selected date changes (via date-time picker or programmatically) |
| onClimatologyMonthChanged | (month: ClimatologyMonth) => void | Called when the month of a clim-* type changes, including from the month picker |
| onStormDataReady | (storms: StormData[]) => void | Called when the storm list is fetched, and after every refresh |
| onStormsError | (error: Error) => void | Called when a storm request fails (StormApiError carries the HTTP status) |
| onAutoAdjustColorsChanged | (enabled: boolean) => void | Called when the auto-adjust color state toggles |
| onLatestUpdateChanged | (latestUpdate: string \| null) => void | Called with the active data type's raw publication timestamp when it changes |
| onClimatologyStartDateChanged | (date: Date \| null) => void | Called with the first date served from climatology instead of forecast data (see Climatology) |
Advanced Options
| Parameter | Type | Default | Description |
| ------------------- | --------- | ------- | ---------------------------------------------------------------- |
| showDebugControls | boolean | false | Show real-time controls to tweak particle and heatmap parameters |
Inherited Leaflet Options
All standard L.GridLayerOptions are supported, including:
| Parameter | Type | Default | Description |
| --------------- | ---------------- | ------- | -------------------------------------------------------- |
| tileSize | number | 256 | Size of tiles in pixels |
| opacity | number | 1 | Layer opacity (0-1) |
| zIndex | number | — | Z-index for layer stacking |
| bounds | L.LatLngBounds | auto | Geographical bounds (auto-configured based on data type) |
| maxNativeZoom | number | 3 | Maximum zoom level with native tile data |
| attribution | string | — | Attribution text |
📅 Climatology
clim-wind, clim-wind-gust, clim-wave-height and clim-wave-period are
monthly climatologies: each is one tile pyramid per calendar month, holding
that month's field averaged over many years. They are not forecasts, so they
carry no timestamp, no forecast run and no publication version — a month is the
whole selection.
<AmphitriteLeaflowLayer
type="clim-wind"
apiKey="your-api-key-here"
landArea="gray"
climatologyMonth={7} // July; omit to follow the month of `date`
onClimatologyMonthChanged={(month) => console.log("month", month)}
/>Choosing the month. Leave climatologyMonth unset and the month of date
selects the tiles, so a host that already drives date needs no extra wiring:
any day in March shows March. Passing climatologyMonth (1 = January through
12 = December) overrides that, and setClimatologyMonth(undefined) hands the
choice back to the date.
The month picker. While a clim-* type is active the floating date-time
picker is replaced by a twelve-button month picker, styled to match it. Picking
a month there also moves the layer date into that month, so onDateChanged
fires alongside onClimatologyMonthChanged and the rest of the layer never
disagrees with the tiles on screen. Configure it with
climatologyMonthPickerOptions:
| Option | Type | Default | Description |
| --------------------------------------------- | --------------------------------- | ----------------------- | -------------------------------------------------- |
| title | string | active type's name | Label on the left of the header |
| monthLabels | string[] | Jan…Dec | Twelve button labels, for localisation |
| color / backgroundColor / textColor / … | string | date-picker palette | Same colour vocabulary as dateTimePickerOptions |
| collapsed / defaultCollapsed | boolean | false | Controlled and initial collapsed state |
| onMonthChanged | (month: ClimatologyMonth) => void | — | Month picked in the control |
| onCollapsedChanged | (collapsed: boolean) => void | — | Collapse state changed |
What changes for a climatology type
- Tiles come from the climatology pyramid, which currently publishes to zoom 5 — two levels deeper than the forecast pyramids.
- The palette, channel, units and particles are those of the variable the
climatology averages, so
clim-windreads exactly likewind. - No publication is watched:
latestUpdateisnullandtileVersionis not appended. - Pressure isolines are suppressed — they are a live forecast field and would put two unrelated times on one map.
- The point-forecast callout is hidden: the forecast API has nothing to say about a multi-year monthly mean. The click-info readout still samples the tiles, and names the month it read.
Wind and wave are the only variables with a published climatology. Any other type ignores
climatologyMonth, which is simply remembered for when aclim-*type is selected.
Separately, and unchanged: wind, wind-gust, wave-height and wave-period
fall back to the same climatology when the requested date runs past the end of
the forecast, keeping their own timeline on the picker.
🌪 Storms
Tropical storms come from the Amphitrite storm API
(https://api.amphitrite.fr/storms, documented
here). It runs on its
own host with its own key, so pass that key in storms.apiKey:
<AmphitriteLeaflowLayer
type="wind"
apiKey={tilesApiKey}
storms={{
visible: true, // draw every storm whose track reaches the viewport
apiKey: stormApiKey, // sent as the x-api-key header
provider: "jtwc", // covers the JTWC *and* NHC basins
includeInvests: true, // keep the disturbances being watched
}}
storm={focusedStorm} // optional: frame and isolate one storm
onStormDataReady={storms => setStorms(storms)}
/>| Option | Type | Default | Description |
| --------------------- | ---------------------------- | --------------------------- | ----------------------------------------------------------------------------- |
| visible | boolean | false | Draws the overlay without focusing one storm |
| apiKey | string | layer key | Key for the storm API, sent as x-api-key |
| baseUrl | string | https://api.amphitrite.fr | Overrides the storm API host |
| provider | "jtwc" \| "jma" \| "ecmwf" | "jtwc" | Forecasting provider — see Providers below |
| includeInvests | boolean | true | Keeps invest areas — disturbances that may become storms |
| showAllInView | boolean | true | Draws every storm intersecting the viewport, not only the focused one |
| refreshIntervalMs | number | 900000 | Re-fetch cadence. 0 disables it |
| historicalForecasts | boolean | false | When the shown date is well in the past, draws the forecast issued back then |
| minZoomForLabels | number | 5 | Zoom at which the step info bubbles appear |
| windRadii | boolean \| "auto" | "auto" | Draws the per-quadrant wind footprint. "auto" = focused storms only |
| cone | boolean | true | Draws the forecast cone of named storms |
| contextMenu | boolean | true | Opens a menu on right-click over a storm |
| onStateChanged | (state) => void | — | Fires when the overlay changes something a host with its own controls mirrors |
Wind radii, and why "auto"
The wind footprint is the densest thing the overlay puts on the map, and a view
showing every storm in it is already carrying tracks, cones, labels and the data
layer underneath. But once a user has picked one storm, the area actually under
gale-force wind is usually the thing they picked it to see. "auto" splits the
difference — off while browsing, on once focused — and true/false force it
either way. The right-click menu and setStormWindRadii both flip it at
runtime; onStateChanged reports where it landed, so a checkbox in the host UI
can follow the map instead of drifting out of step with it:
const [stormState, setStormState] = useState<StormOverlayState | null>(null);
const storms = useMemo(
() => ({ visible: true, apiKey, windRadii: "auto" as const,
onStateChanged: setStormState }),
[apiKey],
);
<Checkbox checked={stormState?.windRadii ?? false} ... />Right-click menu
Right-clicking a track point, a storm marker, or the map within reach of a track
opens the overlay menu. Right-clicks that land nowhere near a storm are left
alone, so the host keeps its own context menu. Set contextMenu: false to turn
the whole thing off.
The menu carries what the point was clicked for: showing that time step, loading the forecast that was in force then, going back to the live one, focusing or releasing the storm, framing its track, toggling the wind radii and the cone, copying the position, and hiding the overlay.
Reading a forecast from the past
With historicalForecasts: true, clicking a past position on the track replaces
the focused storm with the advisory that was current at that moment — cone,
forecast line and all. While that is on screen a banner names the advisory being
shown and offers the way back; showLatestStormForecast() does the same thing
from code, and onStateChanged reports it in state.archivedForecast. Every
track point also carries a hover tooltip with its time, classification and
intensity, which is how a step says what it is when the map is too crowded for
its bubble.
Providers
Providers are separate agencies that track their own sets of storms, so the same system appears under a different id, a different name and a different intensity in each. One provider is shown at a time.
| Provider | Covers | Winds | Cone | Horizon |
| --------- | ----------------------------------------------------- | ------------------- | --------------------------------------------- | -------- |
| "jtwc" | JTWC basins plus the NHC Atlantic and eastern Pacific | 1-minute mean | Published outline, from the NHC radii tables | ~5 days |
| "jma" | Western North Pacific only | 10-minute mean | JMA forecast-error circles, no outline | ~5 days |
| "ecmwf" | Worldwide | Model instantaneous | NHC radii tables, the basin's own or borrowed | ~12 days |
The wind figures are not comparable across providers: a 10-minute mean reads
roughly 12% lower than a 1-minute mean for the same storm, and ECMWF's figure is
lower still — the model's instantaneous 10m maximum, which is not a sustained
wind in any window. Only one provider is on screen at a time, so the bubbles show
the bare figure; the basis is on StormStepInfo.windAveragingMinutes (null for
ECMWF) and on the provider entry, and stormWindBasisLabel(provider) names it
for a picker. Wind radii differ too — JTWC and ECMWF grade them at 34kn/64kn and
JMA at 30kn/50kn — and the overlay follows whichever its provider publishes.
ECMWF is a model, not a warning centre. It ships no development stage at all
(type is always null), so every classification the overlay shows for it —
"Typhoon", "CAT 2" — is derived from the model wind rather than an agency call;
provider.gradesStorms is false and every point comes back with
typeIsDerived: true. It publishes no invests, its cone is the NHC radii table
applied to a model track rather than an official ECMWF product, and it runs to
roughly twelve days where a warning centre stops at five. It is the right
provider for seeing where a system might go next week, and the wrong one for
quoting an intensity.
To build a provider picker, ask the registry rather than hardcoding the list:
import { listStormProviders } from "leaflow-gl";
listStormProviders().map(provider => (
<button
key={provider.id}
onClick={() => setProvider(provider.id)}
>
{provider.label} — {provider.description}
</button>
));Each entry carries id, label, agencyName, description, basins,
windAveragingMinutes (null when the figure is not an average),
gradesStorms, the windField thresholds and whether the provider publishes
invests and a cone outline. Everything provider-specific lives in
src/core/storm-data/providers/StormProviders.ts, so supporting a new agency is
one entry there rather than a sweep through the renderer.
Switching provider refetches and re-resolves the focused storm by name, so a storm both agencies track stays selected across the switch.
What gets drawn
- The observed (best-track) line, solid, with a step icon per report: a dot below hurricane force, a category badge above it. JMA publishes only the current position, so its storms have no observed line.
- The forecast line and the official cone — the outline the API publishes when there is one, otherwise the envelope of the cone circles.
- The wind field of the point nearest the shown date, from the per-quadrant
radii, on the two thresholds the provider grades (34kn/64kn for JTWC,
30kn/50kn for JMA) — subject to
windRadii. - One spinning marker, at the latest observed position: the storm as it is now. When the map shows some other date, a dashed ring marks where the storm is then. Only one thing on a track is ever drawn as a cyclone.
- Invest areas in amber, with a dashed watch area instead of a cone: they have no official forecast, and the styling says so.
- Info bubbles carrying the UTC time, the classification named the way the agency names it (typhoon, cyclone, hurricane), the forecast horizon, the intensity in knots and km/h, the pressure, and the motion at the analysis point.
Where and when
A storm is drawn when its bounds — track_bbox, or the track points themselves
when the API sends none, plus the cone and the wind footprint — intersect the
viewport, and when the date the map shows falls inside its track window.
Focusing a storm frames it using those same bounds.
Observed, analysis, forecast
Which list a point came from decides what it is, never its timestamp. Agencies
issue an advisory every six hours while fixes keep arriving, so a track normally
carries observations newer than the forecast issued for it — twelve hours
newer, in the worst case seen. Every observation is therefore past, the newest
of them is the analysis (track.analysisIndex, and the position the spinning
marker sits on), and the forecast is only what still lies ahead of it. Horizons
the observations have already overtaken are dropped in favour of the fix that
replaced them. track.forecastAgeMs says how far the storm has run ahead of its
own advisory.
🧩 Key Type Definitions
UnitSpec
Defines a unit of measurement with optional scale and offset transformations.
type UnitSpec = {
label: string; // Display label (e.g., "m/s", "kn", "°C")
scale?: number; // Multiplication factor for conversion
offset?: number; // Offset to add after scaling
log10?: boolean; // Data carry log10 of the quantity: show 10^value, then scale and offset
};Example transformations:
- Speed:
{ label: "kn", scale: 1.94384 }- converts m/s to knots - Temperature:
{ label: "°F", scale: 1.8, offset: 32 }- converts °C to °F - Chlorophyll:
{ label: "mg/m³", log10: true }- tiles carry log10(CHL); labels show 10^value with two significant digits (0.012,3.1,65)
DisplayRange
An explicit color-mapping window. Values outside the range clamp to the end colors.
type DisplayRange = {
min: number;
max: number;
};ClickInfoBoxProps
Customizes the appearance of the click information box.
interface ClickInfoBoxProps {
textColor?: string; // Color of text (e.g., "#FFFFFF")
backgroundColor?: string; // Background color (e.g., "rgba(0,0,0,0.8)")
fontFamily?: string; // Font family (e.g., "Arial, sans-serif")
}Example:
clickInfoBoxOptions: {
textColor: "#FFFFFF",
backgroundColor: "rgba(0, 0, 0, 0.85)",
fontFamily: "Roboto, sans-serif"
}DateTimePickerOptions
Configures the floating date-time picker component for date/time selection.
The picker renders a single horizontal timeline rail. One click anywhere on the
rail selects a day and an hour together, and dragging scrubs through
consecutive timestamps. Each day column is sized from the data cadence, so tick
spacing stays the same whether the layer publishes hourly or daily data; days
that do not fit are reached by scrolling horizontally over the rail (trackpad,
shifted mouse wheel, or regular mouse wheel) or with the ‹ › buttons. The
header states the timestep and the latest available date.
The play button immediately before the date readout advances through the
available timestamps once per second; it becomes a pause button while running
and stops automatically at the final timestamp.
type DateTimePickerEndDate = Date | number | "max";
type DateTimePickerSize = "big" | "medium" | "small";
type TimestampResolutionMode = "earliest" | "nearest" | "latest";
type DateTimePickerDisplayCadence =
| "daily"
| "hourly"
| "3-hourly"
| "4-hourly"
| "6-hourly"
| "8-hourly"
| "12-hourly";
type DateTimePickerOptions = {
startDate?: Date; // First day shown in the picker
endDate?: DateTimePickerEndDate; // Last day, a day offset, or the latest available day
style?: Record<string, string>; // Custom CSS styles
className?: string; // CSS class name
width?: string; // CSS width; defaults to shrinking to content
size?: DateTimePickerSize; // Visual size preset; defaults to "small"
daySlotsCount?: number; // Max day columns shown at once
climatologyMonths?: number; // Climatology month slots past the forecast; defaults to 3
climatologyDays?: number; // Show this many climatology days at the forecast cadence instead of month slots; 0 hides climatology
climatologyColor?: string; // Color of the climatology section and badge
displayCadence?: DateTimePickerDisplayCadence; // Force a cadence, e.g. "daily"
selectionMode?: TimestampResolutionMode; // Snapping; defaults to "nearest"
showSnapOptionButton?: boolean; // Show the snap-mode toggle; defaults to false
defaultCollapsed?: boolean; // Start collapsed (initial state only)
collapsed?: boolean; // Controlled collapsed state
color?: string; // Accent color for the selection playhead
backgroundColor?: string; // Background color
hoverBackgroundColor?: string; // Background while hovered; defaults to backgroundColor
todayBadgeColor?: string; // Color of the current-time marker
dotAvailableColor?: string; // Color of the rail ticks
dotGhostColor?: string; // Color of the hatch over days without data
railBackgroundColor?: string; // Background of the rail track
railBorderColor?: string; // Border of the rail track
dayBoundaryColor?: string; // Divider between day columns
dayLabelColor?: string; // Day numbers printed inside the rail
textColor?: string; // Primary text color
textMutedColor?: string; // Secondary text color
textSize?: string; // Font size (e.g., "12px")
fontFamily?: string; // Font family
onDatetimeChanged?: (date: Date) => void; // Callback when date changes
onSelectionModeChanged?: (mode: TimestampResolutionMode) => void;
onCollapsedChanged?: (collapsed: boolean) => void;
};Climatology
For wind, wind-gust, wave-height and wave-period, every date after the
last forecast timestamp is served from the monthly climatology average.
The AIFS types (wind-aifs, wave-height-aifs, wave-period-aifs) have no
climatology fallback: their timeline ends with the forecast.
By default the picker adds one slot per month after the last forecast day,
labelled with the month and "Climatology" (for example Oct, Nov,
Dec), instead of hourly steps: climatology is a single value per month.
The first slot starts at the first climatology timestamp and covers the rest
of the month the forecast ends in; later slots start at 00:00 UTC on the 1st.
climatologyMonths sets how many slots are shown (default 3). A requested
date inside a month selects that month's slot, and a requested date past the
last slot (through date or setDate) adds slots up to its month, at most 12.
Added slots stay until the forecast timestamps or picker options change.
Setting climatologyDays, or an explicit endDate, switches to a day-by-day
view instead: climatology continues at the forecast's final cadence for that
many days (or up to the end date). climatologyDays: 0 keeps the picker on
forecast data only.
Either way the climatology section is hatched and tinted with
climatologyColor, a dashed marker shows where the forecast ends, and the
header shows a Climatology badge whenever the selected date is past the
forecast. The header tooltip and the badge name the month whose climatology
is on the map (for example "Showing the December climatology average, not a
forecast").
The layer exposes where that switch happens:
climatologyStartDate/onClimatologyStartDateChanged: the first climatology timestamp (the last forecast timestamp plus the forecast's final time step), which is where the picker's first climatology slot starts. It uses the same wall-clock UTC form asdateandonDateChanged. It isnullfor data types without climatology, and until the active type's timestamps load. The callback fires again when a new forecast run moves the horizon.isShowingClimatology: whether the tiles currently on screen are climatology.
Keyboard support once the rail has focus: ←/→ step one timestamp, ↑/↓
step one day, PageUp/PageDown step one page of days, and Home/End jump
to the first and last available timestamps.
The
dayCircle*color options were removed along with the day circles they styled. Drop them from existing configurations;color,dayBoundaryColoranddayLabelColorcover the rail equivalents.
Example:
dateTimePickerOptions: {
startDate: new Date("2024-01-15T00:00:00Z"),
endDate: new Date("2024-01-21T00:00:00Z"),
width: "500px",
color: "#4CAF50",
backgroundColor: "rgba(255, 255, 255, 0.72)",
hoverBackgroundColor: "#FFFFFF",
textColor: "#333333",
onDatetimeChanged: (date) => console.log("Selected:", date)
}HeatmapLegendProps
Configures the heatmap legend display.
type HeatmapLegendOrientation = "horizontal" | "vertical";
interface HeatmapLegendProps {
labelSteps?: number[] | "all"; // Which values to show labels for (e.g., [0, 10, 20, 30])
orientation?: HeatmapLegendOrientation; // "vertical" (default) or "horizontal"
className?: string; // CSS class name
style?: { [key: string]: string }; // Custom CSS styles
}Example:
heatmapLegendOptions: {
labelSteps: [0, 5, 10, 15, 20, 25, 30],
orientation: "vertical",
style: { right: "20px", top: "100px" }
}When color adjustment is enabled, the legend is interactive: dragging its
handles commits a DisplayRange, and it exposes an auto-adapt toggle.
LatLonGridOptions
Configures the latitude/longitude graticule.
The overlay is a WebGL/Three.js layer that ranks grid levels by how far apart
they land on screen rather than by zoom band. A level fades in wide, soft
and cool, then sharpens, brightens and warms towards color as the zoom pushes
its lines apart, while the next subdivision starts dissolving in underneath it.
Nothing appears or disappears at a threshold, and it redraws on every frame of a
zoom, so the grid animates continuously with the view instead of snapping
between static grids. Soft nodes mark the lattice crossings, and the labels ride
the same ramp as the lines they belong to.
interface LatLonGridOptions extends L.LayerOptions {
enabled?: boolean; // Whether grid is enabled
// Labels
latitudeLabelsLeft?: boolean; // Show latitude labels on left side
latitudeLabelsRight?: boolean; // Show latitude labels on right side
longitudeLabelsTop?: boolean; // Show longitude labels on top
longitudeLabelsBottom?: boolean; // Show longitude labels on bottom
marginLeft?: number; // Left margin for labels (pixels)
marginRight?: number; // Right margin for labels (pixels)
marginTop?: number; // Top margin for labels (pixels)
marginBottom?: number; // Bottom margin for labels (pixels)
showLabels?: boolean; // Draw coordinate labels at all
labelSize?: number; // Label size at full focus, CSS px
labelColor?: string; // Text colour at full focus
haloColor?: string; // Colour of the halo behind the text
haloOpacity?: number; // Halo opacity relative to the glyph
// Appearance
opacity?: number; // Overall opacity of the whole overlay
color?: string; // Colour of a fully resolved line (near)
farColor?: string; // Colour of a line still fading in (far)
accentColor?: string; // Equator, prime meridian, antimeridian
depth?: number; // Camera vignette strength, 0 flat to 1 strong
density?: number; // How eagerly finer levels appear
showNodes?: boolean; // Glow on the lattice crossings
showAccents?: boolean; // Emphasise the three reference lines
// Line weight (CSS px of stroke width)
lineWidth?: number; // A fully resolved line
farLineWidth?: number; // A line that has only just emerged
accentLineWidth?: number; // Equator, prime meridian, antimeridian
lineSoftness?: number; // Multiplier on the defocus outside the stroke
// Budget
maxPixelRatio?: number; // Cap on the device pixel ratio rendered at
minZoom?: number; // Hide the grid below this zoom
maxZoom?: number; // Hide the grid above this zoom
}| Option | Default | Notes |
| ----------------------- | ----------- | ----------------------------------------------------------------------- |
| opacity | 1 | Multiplies every line, node and label |
| color | "#ffffff" | The near end of the depth ramp |
| farColor | "#93bed4" | The far end; a cooler, duller tone is what reads as aerial perspective |
| accentColor | "#ffd8a3" | Equator and prime meridian solid, antimeridian dashed |
| labelColor | "#f4fbff" | Text at full focus; emerging labels tint towards farColor |
| haloColor | "#040a12" | Keeps text legible over bright heatmap cells |
| haloOpacity | 0.85 | 0 removes the halo entirely |
| depth | 0.4 | 0 disables the vignette and flattens the overlay |
| density | 1 | Above 1 shows finer subdivisions sooner, below 1 keeps the grid sparser |
| lineWidth | 1.5 | Stroke width of a fully resolved line, CSS px |
| farLineWidth | 0.6 | Stroke width of a line that has only just emerged, CSS px |
| accentLineWidth | 2.25 | Stroke width of the three accent lines at full focus, CSS px |
| lineSoftness | 1 | Multiplier on the defocus outside every stroke; 0 is perfectly crisp |
| labelSize | 11.5 | Emerging labels are drawn slightly smaller than this |
| showLabels | true | |
| showNodes | false | Soft glow on the lattice crossings |
| showAccents | true | |
| maxPixelRatio | 2 | Lower it to trade crispness for fill rate on dense displays |
| latitudeLabelsLeft | true | |
| latitudeLabelsRight | false | |
| longitudeLabelsTop | false | |
| longitudeLabelsBottom | true | |
| marginLeft | 70 | Latitude labels are centred on this inset |
| marginRight | 50 | |
| marginTop | 2 | |
| marginBottom | 10 | Longitude label baseline sits this far above the bottom edge |
Levels follow the nautical ladder 90 / 30 / 10 / 5 / 1 degrees and the same shape again in minutes and in seconds, down to one arcsecond. Each spacing divides the one above it, so a line such as 30N is always drawn once, with the weight of the coarsest level that owns it. Latitude and longitude are ranked independently: Mercator stretches a degree of latitude by sec(lat), so near the poles the parallels reach a finer level than the meridians and the cells stay roughly square.
Line weight rides the same focus ramp as opacity and colour: a level is drawn at
farLineWidth when it first emerges and grows to lineWidth once it is fully
resolved, with lineSoftness scaling the defocus that blurs a young level and
sharpens as it approaches. Setting farLineWidth equal to lineWidth gives
every level the same weight and lets opacity alone carry the transition, and
lineSoftness: 0 makes every line perfectly crisp. All three are stroke widths
in CSS pixels, independent of the device pixel ratio.
Example:
latLonGrid: {
enabled: true,
latitudeLabelsLeft: true,
longitudeLabelsBottom: true,
marginLeft: 10,
marginBottom: 10
}Example - a warmer, denser grid with no vignette:
latLonGrid: {
enabled: true,
color: "#ffeccd",
farColor: "#c98f4a",
accentColor: "#7fe3ff",
density: 1.5,
depth: 0,
}Example - a heavier, flat-weight grid with crisp edges:
latLonGrid: {
enabled: true,
lineWidth: 2.5,
farLineWidth: 2.5, // every level the same weight; only opacity fades
accentLineWidth: 4,
lineSoftness: 0, // no defocus on emerging levels
}Example - hairlines that stay out of the way of the data:
latLonGrid: {
enabled: true,
lineWidth: 0.8,
farLineWidth: 0.4,
accentLineWidth: 1.2,
lineSoftness: 0.6,
}LabelsOptions
Adds a country and city place-name overlay on top of the heatmap. Two backends are available, with very different interaction costs.
type LabelsSource = "vector" | "raster";
type LabelsStyle = "light" | "dark" | "voyager";
type LabelsOptions = {
enabled?: boolean; // Show the overlay; defaults to false
source?: LabelsSource; // Defaults to "vector" (cheapest)
style?: LabelsStyle; // Palette; defaults to "light"
opacity?: number; // 0-1; defaults to 1
minZoom?: number; // Lowest zoom that draws labels
maxZoom?: number; // Highest zoom that draws labels
// ── raster only ──
url?: string; // Custom tile template (overrides style)
attribution?: string; // Defaults to the required OSM + CARTO notice
updateWhenIdle?: boolean; // Defer tile creation until the map settles; default true
keepBuffer?: number; // Off-screen tile rows retained; default 1
};Choosing a source
| | "vector" (default) | "raster" |
| ------------------------ | ----------------------------------------------------- | ------------------------------------- |
| Mechanism | Bundled Natural Earth dataset drawn on one canvas | OSM/CARTO labels-only tile images |
| Requests per pan/zoom | 0 | ~700–1,100 |
| Transfer | ~73 KB gzip for base labels | ~11 KB per tile, repeatedly |
| DOM elements added | One canvas; buttons only with interaction hooks | ~70–120 <img> |
| Place coverage | 177 countries + 5,559 cities | Full OSM place hierarchy |
| Localization | Single name set | — |
| Attribution | None (public domain) | Required (OSM + CARTO) |
| Styling control | Full (palette, halo, collision) | Fixed by the tile style |
Use "vector" unless you specifically need dense place coverage above
zoom ~8. It costs no per-view network traffic and redraws
only when the map settles — so panning and zooming stay on the compositor
rather than competing with the particle simulation for the frame budget.
Use "raster" when you need every town and village at high zoom. Keep
updateWhenIdle: true (the default) — it is what stops label tiles from being
created mid-animation, which is the main source of interaction jank.
Both LeaflowLayer and AmphitriteLeaflowLayer use all bundled Natural Earth
cities and countries. The consuming application decides which labels to show,
whether they are clickable, and what happens when clicked.
Pass app-owned hooks through labels.customization:
load()optionally supplies an ordered array ofPlaceLabelobjects. Omit it to use the built-in Natural Earth dataset.dataholds arbitrary app metadata.filter(label)selects labels. Keep countries withlabel.kind === "country".onClick(event)receives the label, map, world-copy anchor and mouse event. Use it to open an app sidebar, select a record or perform another action.getPopup(label)returns an HTMLElement or Promise of one. Your app owns the data, markup and styling; Leaflow handles popup placement and cleanup.popupOptionsaccepts Leaflet options or a function of the label. UseclassName,maxWidth, etc. The app class is added toleaflow-place-popup.interactive(label)restricts click targets; it defaults to cities only.ariaLabel(label)supplies the accessible name.
Set labels.clickable: false to disable all clicks and close the current popup
without changing the filter. It defaults to true, but no click targets exist
unless your app supplies onClick or getPopup. These options apply only to
vector labels; raster labels are baked into images.
For example, in the consuming application's code (where findRecord,
loadDetails and selectRecord are your app's own functions):
import type { LabelsOptions } from "leaflow-gl";
const labels: LabelsOptions = {
enabled: true,
clickable: true,
customization: {
filter: label => label.kind === "country" || !!findRecord(label),
interactive: label => label.kind === "city" && !!findRecord(label),
ariaLabel: label => `Details for ${label.name}`,
onClick: ({ label }) => selectRecord(findRecord(label)),
getPopup: async label => {
const details = await loadDetails(findRecord(label));
const content = document.createElement("section");
content.className = "my-place-details";
const heading = document.createElement("h3");
heading.textContent = label.name;
const description = document.createElement("p");
description.textContent = details.description;
content.append(heading, description);
return content;
},
popupOptions: { className: "my-place-popup", maxWidth: 320 },
},
};
// Use the same labels object with either imperative layer or React component.
layer.setLabels(labels);
layer.setLabels({ clickable: false });.leaflow-place-popup.my-place-popup {
--leaflow-place-background: #fff;
--leaflow-place-text: #223;
}
.my-place-details h3 { margin: 0 0 6px; }
.my-place-details p { margin: 0; }In React, pass labels={{ ...labels, clickable: clicksEnabled }} to
AmphitriteLeaflowLayer from leaflow-gl/react. Updates are forwarded without
remounting the map. Keep the customization object stable (for example with
useMemo) until its data or callbacks change. setLabels({ customization })
replaces the hooks and reloads the data; passing customization: undefined
explicitly restores the defaults. Omitting it preserves the current hooks.
Countries are drawn first, preserving input order within each kind. Clicks support keyboard activation. Popup results from a previous configuration are ignored. Disabling clicks, hiding labels, setting opacity to zero, replacing hooks or removing the layer closes the managed popup.
The overlay is drawn above the heatmap, land and particle layers.
"raster" sits in the tile pane at zIndex + 10; "vector" renders in the
overlay pane, alongside the lat/lon grid and bathymetry labels.
Example:
labels: {
enabled: true,
style: "light", // light glyphs + dark halo, for saturated heatmaps
opacity: 0.9
}Pick the palette against your data colors: "light" renders light text with a
dark halo (good over dark heatmaps), "dark" renders dark text with a light
halo (good over pale heatmaps), and "voyager" is a warmer mixed-tone set.
⚠️
"raster"attribution is mandatory. The defaultattributioncarries the required OpenStreetMap and CARTO notices and is merged into Leaflet's attribution control automatically. If you overrideattributionor pointurlat another provider, you are responsible for that provider's terms. CARTO's basemap tiles are free for low-volume use — check their terms before shipping high-traffic apps. The"vector"source is public-domain Natural Earth data and carries no such obligation.
SeamarksOptions
Adds a transparent seamark overlay without replacing the weather heatmap. Its tile container is stacked above Leaflow's weather, land and label tiles, while the particle canvas is moved directly above it.
type SeamarksOptions = {
enabled?: boolean; // Defaults to false
minZoom?: number; // Inclusive; defaults to 10
maxZoom?: number; // Inclusive; defaults to 18
maxNativeZoom?: number; // Defaults to 18
url?: string; // Custom transparent tile template
attribution?: string; // Defaults to the OpenSeaMap + OSM notice
opacity?: number; // 0..1; defaults to 1
className?: string; // Additional tile-container class
};In React, control the overlay directly from application state:
<AmphitriteLeaflowLayer
// ...required props
seamarks={{
enabled: showSeamarks,
minZoom: 10,
maxZoom: 18,
}}
/>Every field is live. An imperative toggle keeps the previously configured zoom range and source:
layer.setSeamarks({ enabled: false });
layer.setSeamarks({ enabled: true });The containing Leaflet map must allow the requested zooms. OpenSeaMap's default
seamark service publishes native tiles through zoom 18. seamarks and
openSeaMap can be configured together: the transparent overlay renders over
weather while the full chart is inactive, then steps aside when the chart takes
over above its minZoom because that chart already includes the same symbols.
This avoids duplicate tiles while preserving both modes. Disable openSeaMap
when the transparent weather overlay should continue through its full range;
setSeamarks(undefined) disables only that overlay.
⚠️ Attribution is mandatory. The default attribution is registered with Leaflet while the overlay is on the map. If you use another tile service, pass the attribution required by that provider.
OpenSeaMapOptions
Hands the view over to the OpenSeaMap nautical chart once the map zooms past the depth the forecast data covers. This is on by default.
type OpenSeaMapOptions = {
enabled?: boolean; // Whether the chart takes over at high zoom; defaults to true
minZoom?: number; // Highest zoom the data still owns; defaults to 10
maxZoom?: number; // Deepest zoom the chart draws at; defaults to 18
base?: boolean; // Draw the OSM base under the seamarks; defaults to true
url?: string; // Custom seamark tile template
baseUrl?: string; // Custom base tile template
attribution?: string; // Defaults to the required OpenSeaMap + OSM notice
replaces?: OpenSeaMapReplacedLayers; // Which layers the chart stands in for
particlesColor?: string | null; // Particle tint on the chart; defaults to "#4a1d96"
latLonGridStyle?: OpenSeaMapGridStyle | null; // Graticule palette on the chart
};What it replaces
Amphitrite's forecast tiles have a handful of native zoom levels. Past
minZoom there is nothing left to sample, and an upscaled heatmap says less
about a harbour approach than a chart does — so the chart takes over as the
basemap.
It is a rendered basemap in its own right: it already colours land and sea, draws coastlines and names places. The layers doing that job for the forecast view therefore have nothing left to add and are taken off screen, while everything that is data over a basemap keeps drawing on top of the chart.
| Replaced by the chart | Still drawn over the chart | | --------------------- | --------------------------------------------- | | heatmap background | particles (tinted, see below) | | land fill | lat/lon grid | | land contours | pressure isolines | | place names | bathymetry isolines and depth labels | | heatmap legend | storm tracks, click info marker, your markers |
The chart's pane sits below Leaflet's tile pane, so the layers that keep drawing stack over it by the ordinary pane rules — a host app's own overlays included, with no z-index work needed.
Nothing is disposed either way, so zooming back out below minZoom restores
every replaced layer exactly as it was, with the tiles it had already loaded and
no refetch.
The floating controls that live outside the map — the date-time picker and the point-forecast panel — are not affected. The heatmap legend is hidden with the heatmap it describes, and keeps its unit, display range and auto-adapt state while it is.
⚠️ Bathymetry isolines are published to zoom 8. Keeping them on the chart means Leaflet upscales that level, so the contours turn visibly blocky past zoom 11 or so. Set
replaces: { bathymetry: true }if you would rather they stepped aside for the chart's own soundings.
Changing the split
Every row of the table above is a field on replaces, and only the fields you
pass are overridden:
type OpenSeaMapReplacedLayers = {
heatmap?: boolean; // defaults to true
land?: boolean; // land fill and contours; defaults to true
labels?: boolean; // place names; defaults to true
heatmapLegend?: boolean; // defaults to true
particles?: boolean; // defaults to false
latLonGrid?: boolean; // defaults to false
bathymetry?: boolean; // defaults to false
pressureIsolines?: boolean; // defaults to false
};Example — chart with nothing but the graticule over it:
openSeaMap: {
replaces: { particles: true, pressureIsolines: true, bathymetry: true }
}Example — keep the place-name overlay on top of the chart:
openSeaMap: {
replaces: {
labels: false;
}
}Particle colour
The forecast view draws white trails over a dark heatmap; the chart's pale land
and sea fills swallow them. So while the chart owns the view the particles are
tinted #4a1d96 — a dark violet that reads over both and stays clear of the
red, green and black the seamark symbols use.
openSeaMap: {
particlesColor: "#7f1d1d";
} // dark red instead
openSeaMap: {
particlesColor: null;
} // leave them whiteThe trail buffer is not cleared on the switch, so the previous colour fades out over the trail length rather than changing in a single frame.
Graticule palette
latLonGridOptions defaults read white-on-dark for the heatmap, which all but
vanishes on the chart's light fills — so the grid is repainted dark-on-light for
as long as the chart owns the view, and given its own colours back on the way
out. Override any field, or pass null to leave the grid alone:
type OpenSeaMapGridStyle = Pick<
LatLonGridOptions,
"color" | "farColor" | "accentColor" | "labelColor" | "haloColor" | "haloOpacity"
>;
// Defaults
openSeaMap: {
latLonGridStyle: {
color: "#1f2a44",
farColor: "#5c7089",
accentColor: "#8c4a1f",
labelColor: "#101a2b",
haloColor: "#ffffff",
haloOpacity: 0.9
}
}Change graticule options at runtime through
setLatLonGridOptions(...)rather thanlatLonGridLayer.setOptions(...). While the chart owns the view it is holding the grid's colours, and writing to the layer directly both loses the char
