Skip to content

Repository files navigation

Time series plotting app and CLI tool

validate publish

Tool + app for visualizing time series data.

Screenshot of time series plot

Usage

App

Visit https://aaronstacy.com/plottimeseries

You can also update the CSV file using the button in the upper right corner, and share what you are looking at with the button next to it. The data is read locally, nothing ever leaves your browser.

Sharing a whole dataset in a link

[An example of this link doesn't work with GitHub Markdown rendering][example_csv_hash], but you can find it in at the bottom of the source of this README.md file.

The Share Link button in the upper right builds that link out of whatever the page is currently plotting -- uploaded file included -- and copies it to your clipboard. The CSV is gzip'd and encoded in the browser, so the data still never leaves your machine. (If the clipboard is not available, the link is put in the address bar instead.) A report opened from disk has no button: its file:// link would only open for someone who already has that file.

The link itself is a gzip'd + base64 encoded CSV in the URL fragment #csv=.... Plain URL encoded text in the ?csv= query parameter also works, but that is sent to the server as a request. To build the URL from a shell instead, you can run:

printf 'https://aaronstacy.com/plottimeseries#csv=%s' "$(gzip -nc your.csv | base64 | tr -d '\n' | tr '+/' '-_' | tr -d '=')"

This only works for smaller files, typically < 3 MB or so.

#csv=... also takes plain URL encoded text, the same thing ?csv=... takes, so a small hand-written link can skip the gzip pipeline and still keep the data out of the request:

printf 'https://aaronstacy.com/plottimeseries#csv=%s' "$(jq -sRr @uri < your.csv)"

Which of the two a link uses is read off the payload, with no flag to set: base64url spells everything in A-Z a-z 0-9 - _ =, and a CSV needs at least the comma between its date column and a value column, so anything holding a character outside that alphabet is read as text. Gzip is what makes a whole dataset fit; URL encoding costs about three bytes per comma and newline.

CLI

Every commit on main publishes prebuilt artifacts to the latest release. Neither of them needs npm, a checkout, or a build:

  • plottimeseries.cjs, a single JavaScript file that runs on any stable Node.js:

    node plottimeseries.cjs path/to/your/file.csv > index.html
  • plottimeseries-<platform>.tar.gz, holding the same plottimeseries.cjs plus two standalone executables that do not need Node.js at all:

    tar -xzf plottimeseries-linux-x64.tar.gz
    ./plottimeseries path/to/your/file.csv > index.html
    ./plottimeseries-compiled path/to/your/file.csv > index.html

    All three do the same thing and print the same bytes. plottimeseries is a Node.js single executable application of the script above injected into a copy of the Node.js binary. plottimeseries-compiled is the same program compiled to native code by scriptc, with no JavaScript engine in it at all, so it is smaller and starts faster.

Then open index.html in a web browser.

From source

From a checkout the same thing is npm run build:

  1. Clone this repository
  2. Install dependencies: npm install
  3. Build the assets: npm run build path/to/your/file.csv > index.html
  4. Open index.html in a web browser

npm run build:standalone builds all three artifacts locally into dist/. Both executables are built for the platform you run it on: the single executable application embeds whichever Node.js ran the build, and the compiled one needs clang on PATH. If scriptc cannot compile, the build says so and carries on with the other two.

The CLI has to stay inside the subset of TypeScript that scriptc compiles statically, which is why scripts/cli.ts and everything it imports avoid throw, regular expressions and DOM types. npm run scriptc:coverage reports what does not compile, and npm run validate runs it, so drifting out of the subset fails there rather than quietly dropping the compiled executable from a release.

The CSV can also be piped in on stdin, and the y scale can be pinned with --y-max / --y-min (note the -- that stops npm from eating the flags):

npm run build -- --y-max 100 --y-min 0 path/to/your/file.csv > index.html
cat path/to/your/file.csv | npm run build -- --y-max 100 > index.html

The prebuilt artifacts take the same arguments, without the --:

cat path/to/your/file.csv | ./plottimeseries --y-max 100 > index.html

In the app the same settings are available as yMax / yMin query parameters.

Styling columns

A column header can carry a style spec in curly braces. Commas inside the braces do not split the CSV field, so all three of these are equivalent:

date,col1{type: decimal, places: 2},col2
date,col1{type:'decimal'\, places: 2},col2
date,"col1{type: decimal, places: 2}",col2
Key Values Effect Example
type percent, decimal, integer, currency How numbers are formatted, instead of guessing from the data range all four
places integer 0-20 Decimal places (decimals also works) 0 vs 4
currency ISO code, e.g. eur Currency for type: currency, defaults to USD eur and jpy
color any CSS color Line and legend color, instead of the generated one named and hex
label any text Header text, instead of the prettified column name avg_temp_c renamed
plot false Keep the column in the tables but leave it off the plot volume in the table only

For example (open it):

date,ratio{type: percent, places: 2},revenue{type: currency, color: #d62728},id{plot: false, label: 'Trade ID'}
2026-01-01,0.7,1234.5,A-1
2026-01-02,0.62,2310.25,A-2
2026-01-03,0.81,1875.4,A-3
2026-01-04,0.55,2640.75,A-4

Those links all put the CSV in the ?csv= query parameter as plain URL-encoded text, so they can be pasted into a URL decoder to see exactly what they set.

Unrecognized keys and values are ignored, so a typo in a spec cannot break the plot. Column names are matched after the spec is stripped, so col1{...} is still the column col1 everywhere else.

Duplicate dates

A lot of time series data just has a date, and frequently those can be duplicated, which renders weirdly. The Spread Duplicate Dates checkbox next to that button nudges rows that share a date apart along the time axis, so rows on the same date read as separate points rather than one hiding the others (example).

Developing

  1. npm install
  2. npm run dev
  3. Open http://localhost:3000 in a web browser

To validate changes, run npm run validate.

The standalone artifacts are built separately, since scriptc and the single executable application need a toolchain rather than a sandbox:

  1. npm run build:standalone
  2. ./dist/plottimeseries public/data.csv > /dev/null && ./dist/plottimeseries-compiled public/data.csv > /dev/null

Security

The generated report is a single self-contained HTML file with the CSV, styles and bundle inlined, so it can be locked down tightly:

  • Content-Security-Policy, generated in scripts/securityHeaders.ts and emitted as a <meta> tag by scripts/report.ts. It is default-src 'none' plus a sha256 hash for each inline <script> and <style>, so nothing runs but the exact bundle that was built. npm run validate recomputes the hashes from the built page and fails if they and the policy have drifted apart. Because it sits in renderReport, every artifact emits it — npm run build, the .cjs, the executable and the compiled binary still print the same bytes. It stays inside the scriptc subset for that reason: node:crypto's sha256 compiles statically, so the binary needs no JavaScript engine to produce it.
  • No unsafe-eval. CSV parsing uses d3.csvParseRows rather than d3.csvParse, because the latter compiles a row-to-object function with new Function out of the column names — which, for this app, can come from the ?csv= query parameter.
  • _headers, written next to the site by --headers-file. GitHub Pages ignores it — it is the Cloudflare Pages / Netlify convention — but it is the only way to deliver frame-ancestors, X-Frame-Options and X-Content-Type-Options, so it is generated for the day the site moves to a host that reads it.
  • Framing. Since GitHub Pages cannot send X-Frame-Options and a <meta> CSP ignores frame-ancestors, the app refuses to render when it is not the top window (src/frameGuard.ts). That check travels with the file, so it also applies to a report opened from disk.
  • Decompression limits. #csv= hands attacker-controlled bytes to a gzip decoder, and gzip reaches about 1032:1 — a link small enough to paste into a chat message expands to gigabytes. src/csvFragment.ts decompresses as a stream and aborts once the output passes 32 MiB, so a hostile link fails with a message instead of taking the tab with it.
  • Nothing is ever sent to a server. The fragment exists so that sharing a dataset does not require a server to store it. There is no endpoint to authenticate, no bucket to leave public, and no credential in the browser — the security property is that the data never leaves the machine that has it.
  • CI requests no token scopes by default, pins actions to commit SHAs, checks out without persisting credentials, and installs with --ignore-scripts.

About

App + tool for plotting timeseries

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages