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

plotly-timezone

v1.0.1

Published

Feed Plotly.js a UTC timestamp and an IANA zone, get correct, fast, DST-aware display — without a per-point Intl call.

Readme

plotly-timezone

Feed Plotly.js a UTC timestamp and an IANA timezone name, get correct, fast, DST-aware display — for traces, shapes, ranges, and tooltips — without a per-point library call in the render/pan/zoom loop.

Why this exists

Plotly.js has no native concept of "display this timestamp in an arbitrary IANA timezone." Its own source (plotly.js/src/lib/dates.js) has exactly two behaviors:

  1. Date objects pushed into a trace's x/y array are read back using the browser's own local getters — so a chart fed real Date objects automatically shows the viewer's own local time, and only ever that.
  2. Date strings in the form "YYYY-MM-DD HH:MM:SS.sss" are parsed via Date.UTC(...), and any timezone information is discarded — Plotly then renders ticks, hover text, and axis positioning using UTC getters on that internal value.

Behavior 2 is the exploit this library is built on: if you hand Plotly a string whose digits are already the wall-clock time of some other zone, Plotly displays exactly those digits, to every viewer, everywhere — with no per-point timezone math inside the render/pan/zoom loop. The naive alternative — reformatting every point through a timezone library per point — does not scale to the 100,000+ point trend charts this is meant for. See Performance below: 74x-123x faster than a naive per-point Intl.DateTimeFormat baseline on sorted data.

The riskiest part of this trick is reading a value back off Plotly (a click, a zoom, a hover) and treating the zone-shifted display string as if it were a real instant — that silently corrupts every downstream calculation by the zone offset. This library's read-back wrappers exist specifically to make that mistake hard to make; see Read-back safety wrappers.

What this is not

  • Not a Plotly fork or replacement chart component. It never touches Plotly's rendering, layout engine, or DOM — it only transforms the data/config you hand to Plotly's own API, and optionally decodes what Plotly hands back.
  • Not tied to a UI framework. No React/Angular/Vue assumptions in the core.
  • Not a general-purpose date/timezone library. It uses Intl.DateTimeFormat internally (zero dependencies, built into every supported browser and Node >= 14) rather than bundling a timezone database, and it isn't a moment-timezone / date-fns-tz competitor.

Install

npm install plotly-timezone
# Layer 2's transformers/TimezonePlot expect a Plotly module at runtime; pick whichever
# build you already use — full, dist-min, or basic-dist all work the same way here.
npm install plotly.js

plotly.js is a peer dependency and is only type-imported by this package — Layer 1 (the codec) needs no Plotly install at all.

Quickstart

import { encodeTraceX, encodeLayoutRange, wrapClickHandler } from 'plotly-timezone';
import Plotly from 'plotly.js';

const zone = 'America/New_York';

const trace = encodeTraceX(
  { type: 'scatter', mode: 'lines', x: [Date.UTC(2024, 2, 10, 6, 0, 0)], y: [21.4] },
  zone,
);

const layout = encodeLayoutRange(
  { xaxis: { range: [Date.UTC(2024, 2, 9), Date.UTC(2024, 2, 11)] } },
  zone,
);

await Plotly.newPlot('chart', [trace], layout);

document.getElementById('chart').on(
  'plotly_click',
  wrapClickHandler(zone, (event) => {
    // event.points[0].x is a real Date — the real UTC instant, not a zone-shifted string.
    console.log(event.points[0].x.toISOString());
  }),
);

Or use the TimezonePlot convenience class to stop thinking about encode/decode entirely. examples/index.html has a live chart with a timezone dropdown plus side-by-side code for vanilla JS, React, and Angular; each framework also has its own complete runnable example: examples/vanilla/index.html, examples/react/App.jsx, examples/angular/timezone-chart.component.ts.

Design

  • No global mutable "current zone." Every function takes zone as an explicit argument, or a TimezonePlot instance is constructed bound to one zone. A page can render two Plotly charts side by side in two different timezones.
  • Two layers. Layer 1 (the codec) is small, pure, and works completely standalone — you can use only the codec functions and wire everything else yourself. Layer 2 is sugar on top.
  • Cache is configurable, not invisible. setOffsetCacheLimit() and clearOffsetCache() are public API, not internals — see Performance.

API reference

Layer 1 — Codec

Zero dependencies. Uses Intl.DateTimeFormat only. zone accepts any valid IANA zone name; undefined, null, '', or the sentinel 'local' are all a well-defined no-op — the natural fallback for "browser local," so you don't need a separate code path for that case.

getZoneOffsetMinutes(utcMs: number, zone?: string | null): number

Resolve (and cache) how many minutes zone's wall clock is ahead of UTC at utcMs. Exposed publicly for advanced use.

encodeInstant(utcMs: number, zone?: string | null): Date | string

The literal wall-clock string Plotly will parse verbatim, e.g. "2024-03-14 09:30:00.000".

Return-type contract — read this once: when zone is the local no-op sentinel, this returns a plain Date (preserving today's default Plotly behavior — browser-local display via native Date getters). When zone is an explicit IANA name, it returns a string. Don't assume one or the other at a call site where zone might be the sentinel.

decodeInstant(value: string | number | Date, zone?: string | null): number   // -> utcMs
decodeInstantToDate(value: string | number | Date, zone?: string | null): Date

Inverse of encodeInstant: given whatever Plotly handed back from an event payload (string, Date, or number — Plotly's event payloads are inconsistent about which), recover the real UTC instant.

DST note: a wall-clock time during a "fall back" overlap (e.g. 01:30 on the day DST ends) genuinely corresponds to two different real instants; a wall-clock time inside a "spring forward" gap corresponds to none. Neither is a bug — it's an inherent property of DST. Both are resolved deterministically to the later of the two candidate instants (the standard-time reading), so decodeInstant is a pure, repeatable function of its input even though it cannot always be a perfect inverse of encodeInstant across a transition.

encodeRange(startUtcMs: number, endUtcMs: number, zone?: string | null): [Date, Date] | [string, string]
decodeRange(value: [string | number | Date, string | number | Date], zone?: string | null): [number, number]

Range convenience for xaxis.range, rangebreaks[].bounds, or a shape span.

clearOffsetCache(zone?: string): void
setOffsetCacheLimit(maxEntriesPerZone: number): void
isLocalZone(zone?: string | null): boolean

Cache management (see Performance) and a helper to check whether a zone value is the local no-op sentinel.

Layer 2 — Ergonomic helpers

Trace/shape/layout transformers take existing Plotly data structures and return zone-converted copies. They never mutate the input.

encodeTraceX(trace: Partial<Plotly.Data>, zone?: string | null, xKeys?: string[]): Plotly.Data

xKeys defaults to ['x']; pass e.g. ['x0', 'x1'] for finance traces that carry more than one x-series.

encodeShapeSpan(shape: Partial<Plotly.Shape>, zone?: string | null): Plotly.Shape

Converts x0/x1 specifically — for a shaded region / annotation span.

encodeLayoutRange(layout: Partial<Plotly.Layout>, zone?: string | null): Plotly.Layout

Converts layout.xaxis.range and each layout.xaxis.rangebreaks[].bounds. Rangebreaks with pattern: 'day of week' or pattern: 'hour' use plain numeric bounds (weekday/hour numbers, not timestamps) per Plotly's own spec, and are correctly left untouched.

Read-back safety wrappers

The feature that matters most, because it's the one thing that goes silently wrong in every hand-rolled version of this trick. Each wrapper walks the raw Plotly event payload, finds every field that represents a date-axis value, decodes it via decodeInstant, and calls your handler with real Dates in their place — your business logic never needs to know encoding happened.

wrapRelayoutHandler(zone: string, handler: (event: DecodedRelayoutEvent) => void): (event: any) => void
wrapClickHandler(zone: string, handler: (event: DecodedPlotlyEvent) => void): (event: any) => void
wrapHoverHandler(zone: string, handler: (event: DecodedPlotlyEvent) => void): (event: any) => void
wrapSelectedHandler(zone: string, handler: (event: DecodedSelectionEvent) => void): (event: any) => void

| Wrapper | Decodes | |---|---| | wrapRelayoutHandler | 'xaxis.range[0]' / 'xaxis.range[1]' (zoom/pan drag) or 'xaxis.range' array (programmatic relayout) | | wrapClickHandler / wrapHoverHandler | points[].x | | wrapSelectedHandler | points[].x, range.x (box select), lassoPoints.x (lasso select) |

TimezonePlot — stateful convenience class

A thin composition of Layer 1 + Layer 2 plus direct calls into a real Plotly module, for consumers who'd rather not think about encode/decode at every call site. Nothing here is a global singleton — construct one per chart, and two instances can safely run two different zones side by side.

class TimezonePlot {
  constructor(plotly: PlotlyLike, zone: string | null | undefined, options?: { cacheLimit?: number });
  setZone(zone: string | null | undefined): void;
  newPlot(el, data, layout?, config?): Promise<unknown>;
  react(el, data, layout?, config?): Promise<unknown>;
  relayout(el, update): Promise<unknown>;      // encodes any date-range values in `update` first
  restyle(el, update, traceIndices?): Promise<unknown>;
  on(el, eventName, handler): void;            // auto-wraps with the matching decode helper
}

plotly is accepted as a constructor param (or import it yourself and pass the module) rather than imported inside this library — consumers use different builds (plotly.js, plotly.js-dist-min, plotly.js-basic-dist, ...), and this library only type-imports Plotly's types, never a specific build.

Cookbook

Building a date-axis trace

const trace = encodeTraceX(
  { type: 'scatter', mode: 'lines', x: utcTimestamps, y: values },
  'Europe/Berlin',
);

Handling zoom/pan

el.on(
  'plotly_relayout',
  wrapRelayoutHandler('Europe/Berlin', (event) => {
    const start = event['xaxis.range[0]'];
    const end = event['xaxis.range[1]'];
    if (start && end) refetchData(start, end); // real Dates, safe to use directly
  }),
);

Adding a shaded region (shape)

const maintenanceWindow = encodeShapeSpan(
  { type: 'rect', xref: 'x', yref: 'paper', x0: startUtcMs, x1: endUtcMs, y0: 0, y1: 1 },
  'Europe/Berlin',
);

Reading a click event

el.on(
  'plotly_click',
  wrapClickHandler('Europe/Berlin', (event) => {
    console.log(event.points[0].x.toISOString()); // real UTC instant
  }),
);

FAQ

Why doesn't this cost a per-point library call, like formatting with a timezone library would? Plotly only ever needs to see the string once, at chart-build/update time — after that, drawing, panning, and zooming all happen inside Plotly's own render loop against its already-parsed internal numeric representation, and this library is never called again until you build/update the chart again. On the encode side, a per-zone/per-UTC-hour-bucket cache means the expensive part (an Intl.DateTimeFormat call) only runs once per distinct hour touched, not once per point; building the wall-clock string after that is pure arithmetic and Date UTC-getter calls.

What if I want plain browser-local time, like Plotly's own default? Pass undefined, null, '', or 'local' as the zone anywhere one is expected — every function treats that as a well-defined no-op path.

What happens if I decode a wall-clock time during a DST fall-back overlap? See the DST note under decodeInstant above — it's resolved deterministically, but it is inherently not a perfect inverse of encodeInstant for that one hour a year.

Performance

Amortized cost per point after cache warm-up is O(1) — no Intl call. Offsets are cached per zone, per UTC-hour bucket, with a configurable LRU eviction limit (default 4096 buckets per zone, ~170 days) so long-running apps have a bounded memory footprint. Clear a zone's cache (or all of them) with clearOffsetCache(), or resize the limit with setOffsetCacheLimit().

This bucketing is exact for every zone whose DST/offset transitions land on a whole UTC-hour boundary — true for effectively every real-world zone. It is only imprecise for the vanishingly rare zones that both use a fractional-hour offset and observe DST (e.g. Lord Howe Island's 30-minute shift), where a transition could fall mid-bucket. This is a deliberate, documented tradeoff of O(1) amortized lookups, not a bug.

| Points | plotly-timezone | naive per-point Intl.DateTimeFormat | Speedup | |---:|---:|---:|---:| | 100,000 | 1,384,705 pts/sec | 18,576 pts/sec | 74.5x | | 500,000 | 2,263,559 pts/sec | 18,696 pts/sec | 121.1x | | 1,000,000 | 2,249,382 pts/sec | 18,354 pts/sec | 122.6x |

An unsorted worst case (offsets requested out of hour-bucket order) drops speedup to 13x-66x as the series exceeds the default cache limit and buckets get evicted before reuse. That's an acceptable, documented tradeoff — real trace data for a single chart is virtually always time-ordered — not a bug;