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

zootropo

v0.8.0

Published

Headless time player for WMS layers: TIME and CQL dates, selection, playback and synced comparison.

Readme

zootropo

npm size license

Headless time player for WMS layers: TIME and CQL dates, selection, playback and synced comparison.

Two MODIS NDVI maps playing month by month on a shared clock

Live demo

zootropo owns the state — which dates exist, which year, months or days are selected, the sequence of steps, play/pause, interval, direction, waiting for the layer to load — and you own every pixel: it ships no styles, only state, events and ARIA/data-* attributes. It works with layers that have a TIME dimension and with layers filtered by a date column through CQL_FILTER, and it can drive several layers with one shared clock to compare them.

Install

npm install zootropo

Also on GitHub Packages as @ghegar/zootropo, with the same entries.

| Entry | What it is | Needs | |---|---|---| | zootropo | Framework-free core: ISO 8601 parsing, catalog, selection, sequence, player, group | nothing | | zootropo/react | useTimePlayer, usePlayerGroup and useUrlSync | react >= 19 | | zootropo/ol | bindWmsTime and GetCapabilities dimension reader | ol >= 10 | | zootropo/maplibre | bindRasterTime for WMS raster sources | maplibre-gl >= 6 |

Quick start

import { createTimePlayer } from 'zootropo';
import { bindWmsTime, fetchDimension } from 'zootropo/ol';

const dimension = await fetchDimension('https://example.org/geoserver/wms', 'ws:ndvi');

const player = createTimePlayer({
    catalog: dimension?.values ?? [],
    kind: 'time',
    initial: 'latest',
    mode: 'timeline',
});

bindWmsTime(player, tileWmsSource);
player.play();

Preparing your layers

zootropo needs two things from a layer: the list of dates it has (the catalog) and a way to ask the server for one of them. There are two ways to get both.

Path A: a TIME dimension

Best for rasters (image mosaics, NetCDF) and for vectors when you control GeoServer.

  1. In GeoServer, open the layer → Dimensions → enable Time. For a vector layer pick the date attribute; for a mosaic, the time attribute of its index.

  2. Any presentation works: List gives exact instants; Interval and resolution is expanded to instants; Continuous interval becomes a range and steps use the granularity you ask for.

  3. Values must be ISO 8601 (2025-03-01, 2025-03-01T06:00:00Z).

  4. Check it:

    curl -s 'https://example.org/geoserver/ws/wms?SERVICE=WMS&REQUEST=GetCapabilities&VERSION=1.3.0' \
      | grep -A1 '<Dimension name="time"'

    or in code, await fetchDimension(url, 'ws:layer') must return values.

  5. Create the player with kind: 'time' and catalog: dimension.values; bindWmsTime writes TIME.

Path B: a date column and CQL_FILTER

For vector layers in GeoServer without a time dimension.

  1. The layer needs a date or timestamp attribute. Pass its name as column.
  2. CQL_FILTER is a GeoServer vendor parameter; MapServer and QGIS Server do not support it.
  3. Get the catalog: WMS does not list the values of a column, but GeoServer's WPS does. fetchColumnDates(url, 'ws:layer', 'column') asks gs:Unique and falls back to WFS without WPS; granularity: 'month' reduces the list. Any list of ISO dates from your backend works too.
  4. Create the player with kind: 'cql', column and that list as catalog; bindWmsTime writes CQL_FILTER (and baseCql, if the layer already has a filter of yours).

Checklist

| Question | Path A (TIME) | Path B (CQL) | |---|---|---| | Where do the dates come from? | GetCapabilities → fetchDimension | fetchColumnDates (WPS gs:Unique) or your list | | What does zootropo send? | TIME=… | CQL_FILTER=column >= '…' AND column < '…' | | Server | Any WMS with a time dimension | GeoServer | | Finest level | Whatever the instants carry, down to hours | Day, or hour with timestamps | | Selecting several dates | Only if the server accepts lists (multiple) | Always | | Typical trap | Service that takes one instant: multiple: false | Layer that also has TIME on: cqlTime |

Let zootropo check it

import { inspectLayer } from 'zootropo/ol';

const report = await inspectLayer('https://example.org/geoserver/ws/wms', 'ws:layer');
report.path;    // 'time' | 'cql' | 'both' | 'none'
report.options; // e.g. { kind: 'time', catalog: '2024-01-01/2024-12-01/P1M' }
report.notes;   // e.g. ['cql-needs-cql-time', 'time-hours']

It reads the time dimension from GetCapabilities and the date attributes from WFS DescribeFeatureType, with the first and last value of each one. Without WFS it still reports the time dimension and adds no-wfs. The same check is in the live demo under «¿Mi capa es compatible?», no code needed.

Core

import { createTimePlayer } from 'zootropo';

const player = createTimePlayer({
    catalog: '2000-03-01/2025-03-01/P1M,2025-05-01/2026-08-01/P1M',
    kind: 'time',
    initial: 'latest',
    onEvent: event => sync(event),
});

player.subscribe(() => render(player.getSnapshot()));
player.open('2024');
player.select('2024-03', 'toggle');
player.play();

Playback changes at any time with setMode, setIntervalMs, setDirection, setLoop, step(delta) and seek(key). seek returns a promise that resolves to true once the bound layer has loaded the step (right away when nothing loads), and to false when the key is not in the sequence, timeoutMs passes or the player is destroyed; in a group it waits for every member.

await player.seek('2024-03');

Catalog

The catalog is the list of dates that exist. It accepts the values of a WMS Dimension, an array of ISO strings or { instants, ranges }. Comma lists (2012-01-01T00:00:00Z,2016-01-01T00:00:00Z) are read as they are; periodic intervals start/end/period are expanded to instants (P1M, P1D, PT6H…), with month steps clamped to the end of the month instead of drifting. Intervals without a period, or with more steps than maxInstants (10 000), stay as continuous ranges and are answered lazily. Dates are calendar strings; the model never uses Date, so there are no time zone shifts.

Selection and navigation

Five levels: years, months of a year, days of a month, hours of a day and minutes of an hour, with keys YYYY, YYYY-MM, YYYY-MM-DD, YYYY-MM-DDTHH and YYYY-MM-DDTHH:MM. Hours and minutes only appear when the catalog instants carry a time (radar every 5 minutes, model runs every 6 hours). items holds what the current view shows, only what exists in the catalog. select(key, 'toggle') adds or removes keys of the same level and parent; a key from another level or parent replaces the selection. multiple: false keeps one key; multiple: 'mixed' lets any keys live together and drops the ancestors and descendants of a key you add. open(key) goes one level down, back() one level up.

Value

| Kind | Value | |---|---| | time | The instants of the catalog covered by the selection or the current step, in their original format. Instants that are consecutive in the catalog are compacted to first/last; continuous ranges are clipped to the period. compact: false lists every instant | | cql | Half-open ranges on column (fecha >= '2024-01-01' AND fecha < '2024-02-01'), contiguous periods merged, joined with OR, and baseCql added with AND |

Sequence and playback

| Mode | Steps | |---|---| | year, month, day, hour, minute | The units of that level inside the selected parent (every year for year), or the selected keys when there are two or more | | timeline | The whole catalog at granularity (month by default), across years | | auto | Inferred from the selection, then the view; the first mode with at least two steps |

play() starts on the selected step when it is part of the sequence, otherwise on the first one. The interval is clamped to 100–10 000 ms. With loop: false the player pauses after the last step. Changing the selection by hand pauses and goes back to the selection.

More

| Feature | API | Behavior | |---|---|---| | Live layers | updateCatalog(values), follow | PRESENT, present, current and now resolve to the current time, so 2024-06-01T10:00:00Z/PRESENT/PT5M keeps growing. updateCatalog swaps the catalog without stopping playback: the step and the selection stay when they still exist, and a timeline gets the new steps. With follow: true, a selection on the latest date moves to the new latest one. watchDimension does the polling | | Waiting for the layer | loading(), ready(), timeoutMs | The player waits for ready() before advancing and pauses with a timeout event after timeoutMs (30 s). bindWmsTime calls both for you | | Comparing layers | createPlayerGroup([terra, aqua], { intervalMs: 1000 }) | One clock, interval and direction for every member. The group walks the union of the members' sequences; a member without that date keeps its previous step and reports stale: true. It waits until every member is ready; play, pause or a manual selection on a member act on the group | | Dates in the URL | syncUrl(player, { param: 'date' }), toParam, fromParam | Reads the selection from the query string once the catalog arrives and writes it back with history.replaceState (push on request). Runs of three or more consecutive keys are compacted: ?date=2024-01..2024-06,2024-09. In React, useUrlSync(player) | | Labels | createFormatter({ locale: 'es-MX' }).keys(keys) | Intl.DateTimeFormat with the locale you pass; English by default |

React

import type { TimePlayer } from 'zootropo';
import { useTimePlayer } from 'zootropo/react';

export function Dates({ player }: { player: TimePlayer }) {
    const t = useTimePlayer(player);
    return (
        <>
            <button {...t.getBackProps()}>‹</button>
            <ul {...t.getListProps()}>
                {t.items.map(item => <li key={item.key} {...t.getItemProps(item)}>{item.key}</li>)}
            </ul>
            <button {...t.getPlayProps()}>{t.playing ? 'Pause' : 'Play'}</button>
        </>
    );
}

getStepProps(delta), getIntervalProps() and getDirectionProps() complete the transport. Items carry aria-selected, aria-current="step" and data-selected, data-partial, data-current, data-contains-current. Clicking a year selects it and opens its months (openOnSelect); double click or ArrowRight opens, ArrowLeft goes back. usePlayerGroup gives the same transport getters for a group. Every label can be replaced with labels.

OpenLayers

bindWmsTime(player, source) writes TIME (or CQL_FILTER for cql players) with updateParams, counts tile and image loads and calls loading()/ready(). For a cql player on a layer that also has a time dimension, pass cqlTime: 'catalog' (the catalog extent) or a fixed value to send a wide TIME too. fetchDimension(url, layer) reads one layer's dimension and fetchDimensions every layer's, from a WMS 1.3.0 GetCapabilities, by full and local name. watchDimension(player, url, layer, { intervalMs }) reads it again every intervalMs (one minute by default, five seconds at least), calls updateCatalog only when the values change, and returns a handle with stop().

For smooth playback use bindWmsBuffer(player, front, back) instead of bindWmsTime, with two identical WMS layers added to the map. The front layer shows the current step while the back one, at opacity 0, loads the next. When the player reaches it, they swap, and the player only waits if the next step was not loaded yet. With the NDVI layers of the demo the time spent waiting for tiles went from about 65 % to 15 %.

To record the animation as MP4, WebM or GIF, cartela holds the view with planHold and moves the player in beforeFrame (beforeFrame: (_, index) => player.seek(keys[index])); its README has the recipe.

MapLibre

import { bindRasterTime } from 'zootropo/maplibre';

map.addSource('ndvi', { type: 'raster', tileSize: 256, tiles: [template] });
map.addLayer({ id: 'ndvi', type: 'raster', source: 'ndvi' });

bindRasterTime(player, map, 'ndvi');

bindRasterTime(player, map, sourceId) rewrites the source's tile template with setTiles: TIME or CQL_FILTER (and cqlTime, as in bindWmsTime) go into the query and {bbox-epsg-3857} stays as it is. The base is the source's current template, or template when you already have one built. If something else writes a new template to the source, it becomes the base. destroy() puts back the template the source had (or the last one written by someone else) unless the source was rewritten after the last step; restore: false leaves the last step on the map. Loading ends with the source's sourcedata events (or idle) after the new tiles were requested. bindRasterBuffer(player, map, frontLayerId, backLayerId) is the bindWmsBuffer of MapLibre: two raster layers with their own sources, swapped through raster-opacity; MapLibre animates that swap with the style's transition (300 ms), which raster-opacity-transition: { duration: 0 } turns into a hard cut. The map and the source are structural types, so maplibre-gl is only a type.

Working with urdimbre

When several tools filter the same layer, none of them should own CQL_FILTER. Pass composer (any object with set(name, fragment), such as the CqlComposer of urdimbre) to bindWmsTime or bindRasterTime and the cql value is written there as the time fragment (fragment renames it) instead of into the source; whoever owns the composer applies the composed filter. TIME, including cqlTime, still goes to the source, and destroy() removes the fragment. Without composer nothing changes. inspectLayer and fetchColumnDates take wfsUrl (and wpsUrl) when your catalog already knows the endpoint, so nothing is guessed from the WMS URL.

Known limitations

  • A GeoServer layer with a TIME dimension applies its default time when the request has no TIME, even with CQL_FILTER. Use cqlTime when you filter such a layer by CQL.
  • With several instants in TIME, what the server draws depends on the layer. Some services, like NASA GIBS, accept a single instant: use multiple: false.

Development

npm test
npm run lint
npm run typecheck
npm run demo
npm run e2e

The demo plays MODIS NDVI from NASA GIBS on two maps with a shared clock, and Atlantic storms from a public GeoServer by TIME or by CQL.

A vX.Y.Z tag that matches package.json runs release.yml: tests, the GitHub release with that version's changelog section, npm with provenance (trusted publishing, no token) and GitHub Packages.

Contributing

Issues are welcome. Open one before sending a pull request.

License

AGPL-3.0-only © GHEgar