npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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:

  1. AmphitriteLeaflowLayer: Uses Amphitrite available data for currents, winds, waves and others with API key authentication, simple and straightforward integration.
  2. 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.GridLayer and L.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

Weatherviz

🛠 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 three

If you are using the React components, also install:

npm install react react-leaflet

If 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-temperature is spelled with the double t in the public type. It is kept as-is for backwards compatibility.

Type-dependent behaviour:

  • sediments renders as a categorical layer: the legend switches to labelled swatches instead of a continuous gradient, using the library's built-in sediment classes.
  • sea-temperature and sattelite-sea-temperature enable color auto-adjust (autoAdjustColors) and the interactive legend range by default. All other types have color adjustment disabled unless you opt in.
  • chlorophyll tiles carry log10(CHL): colour stops, labelSteps, displayRange and the interactive legend range are in log10 units (-2 is 0.01 mg/m³, 1 is 10 mg/m³). Its log10 unit 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 |

⚠️ showPressureIsolines defaults to true. Pass false explicitly 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-wind reads exactly like wind.
  • No publication is watched: latestUpdate is null and tileVersion is 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 a clim-* 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 as date and onDateChanged. It is null for 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, dayBoundaryColor and dayLabelColor cover 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 of PlaceLabel objects. Omit it to use the built-in Natural Earth dataset. data holds arbitrary app metadata.
  • filter(label) selects labels. Keep countries with label.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.
  • popupOptions accepts Leaflet options or a function of the label. Use className, maxWidth, etc. The app class is added to leaflow-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 default attribution carries the required OpenStreetMap and CARTO notices and is merged into Leaflet's attribution control automatically. If you override attribution or point url at 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 white

The 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 than latLonGridLayer.setOptions(...). While the chart owns the view it is holding the grid's colours, and writing to the layer directly both loses the char