Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ Tool + app for visualizing time series data.

Visit https://aaronstacy.com/plottimeseries

You can also upload a CSV file using the button in the upper right corner. The
data is updated locally, nothing ever leaves your browser.
You can also upload a 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 updated
locally, nothing ever leaves your browser.

### Sharing a whole dataset in a link

Expand All @@ -23,10 +24,17 @@ rendering][example_csv_hash], but you can find it in [at the bottom of the
source of this README.md
file](/aaronj1335/plottimeseries/blob/main/README.md?plain=1).

Encode a gzip'd + base64 encoded CSV into the URL fragment `#csv=...` to create
a link that can be shared. 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, you
can run:
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:

```bash
printf 'https://aaronstacy.com/plottimeseries#csv=%s' "$(gzip -nc your.csv | base64 | tr -d '\n' | tr '+/' '-_' | tr -d '=')"
Expand Down
13 changes: 12 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { TimeSeriesChart } from './components/TimeSeriesChart.tsx';
import { HoverDetails } from './components/HoverDetails.tsx';
import { DataTable } from './components/DataTable.tsx';
import { getCSVData } from './data.ts';
import { canShare, shareCSV } from './share.ts';
import { cssVar } from './theme.ts';
import { type ChartOptions, getChartOptions } from './chartOptions.ts';

Expand All @@ -24,10 +25,15 @@ declare global {
function App() {
const [dataset, setDataset] = useState<ProcessedCSV>(EMPTY_CSV);
const { data, formattedData, columns, columnStyles } = dataset;
// The text as it was read, not as it was parsed: a shared link should reopen
// the same CSV, comments, column options and all.
const [csv, setCSV] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

const chartOptions = useMemo(() => getChartOptions(window), []);
// A report opened off disk has nobody to share with, so it gets no button.
const shareable = useMemo(() => canShare(window), []);

const [columnWidths, setColumnWidths] = useState<number[] | null>(null);

Expand All @@ -44,7 +50,8 @@ function App() {
setError(null);

try {
const result = processCSV(await readCSV());
const text = await readCSV();
const result = processCSV(text);
if (result.data.length === 0) {
if (result.columns.length > 0 && !result.columns.some(isDateColumn)) {
throw new Error(`No "date" column found in CSV (columns: ${result.columns.join(', ')})`);
Expand All @@ -53,6 +60,7 @@ function App() {
}

setDataset(result);
setCSV(text);
setIsolatedSeries(null);
setHoveredDate(null);
} catch (err: unknown) {
Expand Down Expand Up @@ -99,6 +107,8 @@ function App() {
setIsolatedSeries(prev => (prev === series ? null : series));
};

const handleShare = useCallback(() => shareCSV(window, csv), [csv]);

const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) void loadCSV(() => file.text());
Expand Down Expand Up @@ -129,6 +139,7 @@ function App() {
onToggleSpreadDates={() => setSpreadDates(!spreadDates)}
columnColors={columnColors}
onFileUpload={handleFileUpload}
onShare={shareable ? handleShare : null}
columnStyles={columnStyles}
chartOptions={chartOptions}
/>
Expand Down
20 changes: 20 additions & 0 deletions src/components/ShareButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import assert from 'node:assert';
import { describe, it } from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { ShareButton, shareLabel } from './ShareButton.tsx';

describe('ShareButton', () => {
it('offers to share before it has been clicked', () => {
const markup = renderToStaticMarkup(<ShareButton onShare={() => Promise.resolve('copied')} />);

assert.match(markup, /Share Link/);
assert.match(markup, /class="share-button"/);
assert.doesNotMatch(markup, /disabled/);
});

it('names every state it can land in, so none renders blank', () => {
for (const status of ['idle', 'working', 'copied', 'address-bar', 'failed'] as const) {
assert.ok(shareLabel(status).length > 0, `no label for ${status}`);
}
});
});
71 changes: 71 additions & 0 deletions src/components/ShareButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React, { useEffect, useState } from 'react';
import type { ShareOutcome } from '../share.ts';

/** How long the result of a click stays on the button before it offers again. */
const RESET_MS = 2500;

type ShareStatus = 'idle' | 'working' | ShareOutcome | 'failed';

/**
* Short enough to keep the toolbar from reflowing on every click; the `title`
* carries the detail.
*/
const LABELS: Record<ShareStatus, string> = {
idle: 'Share Link',
working: 'Sharing…',
copied: 'Copied!',
'address-bar': 'In URL bar',
failed: 'Failed',
};

const TITLES: Record<ShareStatus, string> = {
idle: 'Copy a link that carries this CSV, compressed, in its fragment',
working: 'Compressing the CSV into a link',
copied: 'The link is on your clipboard',
'address-bar': 'The clipboard was unavailable, so the link is in the address bar',
failed: 'The link could not be built',
};

export function shareLabel(status: ShareStatus): string {
return LABELS[status];
}

interface ShareButtonProps {
onShare: () => Promise<ShareOutcome>;
}

export const ShareButton: React.FC<ShareButtonProps> = ({ onShare }) => {
const [status, setStatus] = useState<ShareStatus>('idle');
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (status === 'idle' || status === 'working') return;

const timer = setTimeout(() => setStatus('idle'), RESET_MS);
return () => clearTimeout(timer);
}, [status]);

const handleClick = async () => {
setStatus('working');
setError(null);
try {
setStatus(await onShare());
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
setStatus('failed');
}
};

return (
<button
onClick={() => void handleClick()}
// A second click while the first is compressing would throw away the link
// it is most of the way through building.
disabled={status === 'working'}
className="share-button"
title={error ?? TITLES[status]}
>
{LABELS[status]}
</button>
);
};
6 changes: 6 additions & 0 deletions src/components/TimeSeriesChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import * as d3 from 'd3';
import { type ColumnStyles, type DataPoint, isSeriesColumn } from '../dataProcessing.ts';
import type { ChartOptions } from '../chartOptions.ts';
import { subdivideGridPositions } from '../gridLines.ts';
import { ShareButton } from './ShareButton.tsx';
import type { ShareOutcome } from '../share.ts';
import { cssVar, THEME } from '../theme.ts';

const CLIP_ID = 'plot-area-clip';
Expand All @@ -28,6 +30,8 @@ interface TimeSeriesChartProps {
onToggleSpreadDates: () => void;
columnColors: Record<string, string>;
onFileUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
/** `null` where a link to this page would not open for anyone else. */
onShare: (() => Promise<ShareOutcome>) | null;
columnStyles?: ColumnStyles;
chartOptions?: ChartOptions;
}
Expand All @@ -44,6 +48,7 @@ export const TimeSeriesChart: React.FC<TimeSeriesChartProps> = ({
onToggleSpreadDates,
columnColors,
onFileUpload,
onShare,
columnStyles = {},
chartOptions = {},
}) => {
Expand Down Expand Up @@ -383,6 +388,7 @@ export const TimeSeriesChart: React.FC<TimeSeriesChartProps> = ({
<input type="checkbox" checked={spreadDates} onChange={onToggleSpreadDates} /> Spread
Duplicate Dates
</label>
{onShare && <ShareButton onShare={onShare} />}
<button onClick={() => fileInputRef.current?.click()} className="upload-button">
Upload CSV
</button>
Expand Down
39 changes: 38 additions & 1 deletion src/csvFragment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it } from 'node:test';
import assert from 'node:assert';
import * as zlib from 'node:zlib';

import { MAX_DECOMPRESSED_BYTES, decodeCSVFragment } from './csvFragment.ts';
import { MAX_DECOMPRESSED_BYTES, decodeCSVFragment, encodeCSVFragment } from './csvFragment.ts';

const CSV = `date,value
2023-01-01,1
Expand Down Expand Up @@ -72,3 +72,40 @@ describe('decodeCSVFragment', () => {
assert.strictEqual(MAX_DECOMPRESSED_BYTES, 32 * 1024 * 1024);
});
});

describe('encodeCSVFragment', () => {
it('writes gzip, so the link stays short enough to paste', async () => {
const encoded = await encodeCSVFragment(CSV);
const bytes = Buffer.from(encoded, 'base64url');

assert.deepStrictEqual([bytes[0], bytes[1]], [0x1f, 0x8b]);
assert.strictEqual(zlib.gunzipSync(bytes).toString('utf-8'), CSV);
});

it('writes the alphabet a URL fragment can carry unescaped', async () => {
// Every byte value, so the encoder cannot avoid the two characters
// standard base64 has that base64url does not.
const everyByte = String.fromCharCode(...Array.from({ length: 256 }, (_, i) => i));
const encoded = await encodeCSVFragment(everyByte);

assert.match(encoded, /^[A-Za-z0-9_-]+$/);
assert.strictEqual(encodeURIComponent(encoded), encoded);
});

it('round-trips through the decoder', async () => {
for (const text of [CSV, '', 'date,café\n2023-01-01,€\n']) {
assert.strictEqual(await decodeCSVFragment(await encodeCSVFragment(text)), text);
}
});

it('round-trips a CSV far past one argument per byte', async () => {
// A megabyte of rows: enough that encoding it a byte at a time, as one call
// per chunk of the compressed output, would blow the argument stack.
const rows: string[] = ['date,value'];
for (let i = 0; i < 90000; i++) rows.push(`2023-01-01,${i}`);
const wide = rows.join('\n');
assert.ok(wide.length > 1024 * 1024);

assert.strictEqual(await decodeCSVFragment(await encodeCSVFragment(wide)), wide);
});
});
70 changes: 65 additions & 5 deletions src/csvFragment.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Decoding for the `#csv=` fragment: base64url bytes, gzipped or not.
* The `#csv=` fragment: base64url bytes, gzipped or not, encoded and decoded.
*
* A URL fragment is the only place a large CSV can ride in a link without a
* server to store it. Browsers never put it in a request, so the data is not
Expand All @@ -13,10 +13,10 @@
* defensive tidiness: gzip reaches about 1032:1, so a link small enough to
* paste into a chat message expands to gigabytes and takes the tab with it.
*
* There is deliberately no encoder here. Anything that can run a script in this
* repository can build a whole self-contained report instead, so the encoding
* is documented as a `gzip | base64 | tr` pipeline in the README -- which also
* works from a machine that has the CSV and nothing else.
* The encoder is the share button's half of that: it is the same `gzip |
* base64 | tr` pipeline the README documents for a shell, run in the tab that
* already holds the data, so sharing what is on screen does not mean going
* back to the file it came from.
*/

/**
Expand Down Expand Up @@ -101,3 +101,63 @@ export async function decodeCSVFragment(
if (!isGzip(bytes)) return new TextDecoder().decode(bytes);
return inflate(bytes, limit);
}

// `String.fromCharCode(...bytes)` is one call with one argument per byte, so a
// whole megabyte-sized CSV passed at once overflows the argument stack. 32 KiB
// at a time is well under every engine's limit.
const BTOA_CHUNK_BYTES = 32 * 1024;

function encodeBase64Url(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i += BTOA_CHUNK_BYTES) {
binary += String.fromCharCode(...bytes.subarray(i, i + BTOA_CHUNK_BYTES));
}

// Padding is dropped rather than percent-encoded: `=` is legal in a fragment,
// but `decodeBase64Url` does not need it, and the link is shorter without it.
return btoa(binary).split('+').join('-').split('/').join('_').split('=').join('');
}

async function deflate(text: string): Promise<Uint8Array> {
if (typeof CompressionStream === 'undefined') {
throw new Error('This browser cannot build compressed links: it has no CompressionStream.');
}

// Same shape as `inflate` above: `BufferSource` on the writable side, and the
// chunk type named on `pipeThrough` so the `any` readable side stops there.
const plain = new ReadableStream<BufferSource>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
controller.close();
},
});

const reader = plain.pipeThrough<Uint8Array>(new CompressionStream('gzip')).getReader();

const chunks: Uint8Array[] = [];
let total = 0;

for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
total += value.byteLength;
}

const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
}

/**
* The inverse of `decodeCSVFragment`, always compressing: a CSV is repetitive
* enough that gzip is the difference between a link that pastes into a chat
* message and one that does not.
*/
export async function encodeCSVFragment(csv: string): Promise<string> {
return encodeBase64Url(await deflate(csv));
}
Loading
Loading