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.
Maintainers
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:
Dateobjects pushed into a trace'sx/yarray are read back using the browser's own local getters — so a chart fed realDateobjects automatically shows the viewer's own local time, and only ever that.- Date strings in the form
"YYYY-MM-DD HH:MM:SS.sss"are parsed viaDate.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.DateTimeFormatinternally (zero dependencies, built into every supported browser and Node >= 14) rather than bundling a timezone database, and it isn't amoment-timezone/date-fns-tzcompetitor.
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.jsplotly.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
zoneas an explicit argument, or aTimezonePlotinstance 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()andclearOffsetCache()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): numberResolve (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 | stringThe literal wall-clock string Plotly will parse verbatim, e.g. "2024-03-14 09:30:00.000".
Return-type contract — read this once: when
zoneis the local no-op sentinel, this returns a plainDate(preserving today's default Plotly behavior — browser-local display via nativeDategetters). Whenzoneis an explicit IANA name, it returns astring. Don't assume one or the other at a call site wherezonemight be the sentinel.
decodeInstant(value: string | number | Date, zone?: string | null): number // -> utcMs
decodeInstantToDate(value: string | number | Date, zone?: string | null): DateInverse 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:30on 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), sodecodeInstantis a pure, repeatable function of its input even though it cannot always be a perfect inverse ofencodeInstantacross 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): booleanCache 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.DataxKeys 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.ShapeConverts x0/x1 specifically — for a shaded region / annotation span.
encodeLayoutRange(layout: Partial<Plotly.Layout>, zone?: string | null): Plotly.LayoutConverts 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;
