Saving to Your Own Storage or Server
The SDK doesn't store charts itself: toJSON() gives you the state, and you keep it wherever your app keeps user data. Choose by where the user should find their chart again:
| Store | The user's chart is kept | Notes |
|---|---|---|
local | In this browser, until cleared | About 5 MB per site. The simplest |
| IndexedDB | In this browser, until cleared | Much larger, asynchronous. For many saved charts |
| Your server | In their account, on any device | Needs a signed-in user, and an API on your server |
| A file | Wherever they save it | Export and import, e.g. to share a layout |
TypeScript Example: Auto-Save to the Browser or a Server
The chart saves itself after every change: add RSI, switch symbol, draw, or resize a pane, and the log on the right shows each save. Reload chart opens it again from the store, as a page reload would. Save to: Server switches to a store that calls a REST API with fetch. A small mock in the example answers the API in the browser, so you can see each request; in your app, your server answers it.
The code for this example is found below.
- TS
import "scichart-scitrader/scitrader.css";
import {
CandleInterval,
CoinbaseExchange,
defaultExchangeProvider,
DrawingToolbar,
DrawingToolsController,
EStudyType,
idbPriceStore,
ISciFinanceChartPanelApi,
ISciFinanceChartPanelState,
OHLCStudy,
registerDrawingTools,
SciFinanceChartPanel,
studyProvider,
VolumeStudy,
} from "scichart-scitrader";
// This example expects this HTML:
// <div style="display: flex; flex-direction: column; height: 100%">
// <div id="toolbar"></div>
// <div style="display: flex; flex: 1; min-height: 0">
// <div id="tools"></div>
// <div id="chart" style="flex: 1; min-width: 0"></div>
// <pre id="log"></pre>
// </div>
// </div>
// Register a data source, and the drawing tools' types so saved drawings can be restored
defaultExchangeProvider.registerExchange(CoinbaseExchange, idbPriceStore);
registerDrawingTools();
// 1. A store: somewhere to load and save a chart. The rest of the app only uses this interface,
// so the storage can change without touching the chart code
interface IChartStateStore {
readonly name: string;
load(): Promise<ISciFinanceChartPanelState | null>;
save(state: ISciFinanceChartPanelState): Promise<void>;
clear(): Promise<void>;
}
// What is stored: the chart's state, inside an envelope with a version. Bump the version when your
// app can no longer restore older saves, and they are ignored instead of breaking the chart
const SCHEMA_VERSION = 1;
interface ISavedChart {
version: number;
savedAt: string;
state: ISciFinanceChartPanelState;
}
const wrap = (state: ISciFinanceChartPanelState): ISavedChart => ({
version: SCHEMA_VERSION,
savedAt: new Date().toISOString(),
state,
});
const unwrap = (saved: ISavedChart | null) =>
saved?.version === SCHEMA_VERSION && saved.state?.finance && saved.state?.layout ? saved.state : null;
// 2a. The browser's localStorage: per browser, about 5 MB, and synchronous
class LocalStorageStore implements IChartStateStore {
readonly name = "This browser";
constructor(private readonly key: string) {}
async load() {
const raw = localStorage.getItem(this.key);
return unwrap(raw ? JSON.parse(raw) : null);
}
async save(state: ISciFinanceChartPanelState) {
localStorage.setItem(this.key, JSON.stringify(wrap(state)));
}
async clear() {
localStorage.removeItem(this.key);
}
}
// 2b. Your server, over HTTP: the user's charts follow them to any device. Send whatever your API
// needs to know the user, e.g. a session cookie (credentials: "include") or an Authorization header
class ServerStore implements IChartStateStore {
readonly name = "Server";
constructor(private readonly url: string) {}
async load() {
const response = await fetch(this.url, { credentials: "include" });
if (response.status === 404) return null; // nothing saved yet
if (!response.ok) throw new Error(`Load failed: ${response.status}`);
return unwrap(await response.json());
}
async save(state: ISciFinanceChartPanelState) {
const response = await fetch(this.url, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(wrap(state)),
keepalive: true, // lets a save sent as the page closes still arrive
});
if (!response.ok) throw new Error(`Save failed: ${response.status}`);
}
async clear() {
await fetch(this.url, { method: "DELETE", credentials: "include" });
}
}
const STORES: IChartStateStore[] = [new LocalStorageStore("my-app:chart"), new ServerStore("/api/charts/main")];
let store = STORES[0];
const container = document.getElementById("chart");
let financeChartPanel: ISciFinanceChartPanelApi;
let drawing: DrawingToolsController;
let tools: DrawingToolbar;
let stopAutoSave = () => {};
// 3. Open the chart: restore the saved one if there is one, otherwise create a new one. If a saved
// chart can't be restored, discard it and start fresh, so a bad save can never leave a blank chart
async function openChart() {
stopAutoSave();
tools?.destroy();
drawing?.detach();
financeChartPanel?.cleanup();
let state: ISciFinanceChartPanelState | null = null;
try {
state = await store.load();
} catch (e) {
log(`Load failed: ${e}`);
}
try {
financeChartPanel = state
? await SciFinanceChartPanel.fromJSON(container, state, { useSubCharts: state.useSubCharts })
: await createNewChart();
log(state ? `Restored from ${store.name}` : `Nothing saved in ${store.name}: new chart`);
} catch (e) {
log(`Restore failed, starting fresh: ${e}`);
await store.clear();
financeChartPanel?.cleanup();
financeChartPanel = await createNewChart();
}
drawing = DrawingToolsController.attach(financeChartPanel.financeApi);
tools = DrawingToolbar.create(document.getElementById("tools"), drawing, { orientation: "vertical" });
stopAutoSave = autoSave(financeChartPanel, drawing, (state) => store.save(state));
}
function createNewChart() {
return SciFinanceChartPanel.create(container, {
exchangeName: CoinbaseExchange.name, // name="Coinbase" matches the built-in CoinbaseExchange
symbol: "BTC-USD",
interval: CandleInterval.SIX_HOURS,
period: "3M",
studies: [new OHLCStudy(), new VolumeStudy()],
});
}
// 4. Auto-save: save after each change the chart reports, at most every half second, and right away
// when the page is hidden or closed. Returns a function that stops it
function autoSave(
panel: ISciFinanceChartPanelApi,
drawingTools: DrawingToolsController,
save: (state: ISciFinanceChartPanelState) => Promise<void>
): () => void {
const chart = panel.financeApi;
let timer: ReturnType<typeof setTimeout> | undefined;
const saveNow = () => {
clearTimeout(timer);
timer = undefined;
const state = panel.toJSON();
save(state).then(
() => log(`Saved ${JSON.stringify(state).length.toLocaleString()} characters to ${store.name}`),
(e) => log(`${e}`)
);
};
const saveSoon = () => {
clearTimeout(timer);
timer = setTimeout(saveNow, 500);
};
const onDataRendered = ({ type }: { type: string }) =>
(type === "symbol-change" || type === "interval-change") && saveSoon();
const onHidden = () => document.visibilityState === "hidden" && timer !== undefined && saveNow();
// What can change: studies, the theme, the candle type, the live-tip mode, the symbol and interval,
// the pane layout (sizes, order, hidden, maximised), and the drawings
const events = [
chart.studiesChanged,
chart.themeChanged,
chart.candleTypeChanged,
chart.liveTipModeChanged,
panel.paneGroupApi.stateChanged,
drawingTools.drawingsChanged,
];
events.forEach((event) => event.subscribe(saveSoon));
chart.dataRenderComplete.subscribe(onDataRendered);
// A study's edited settings fire no event: they are saved with the next change, or when the page hides
document.addEventListener("visibilitychange", onHidden);
window.addEventListener("pagehide", saveNow);
return () => {
clearTimeout(timer);
events.forEach((event) => event.unsubscribe(saveSoon));
chart.dataRenderComplete.unsubscribe(onDataRendered);
document.removeEventListener("visibilitychange", onHidden);
window.removeEventListener("pagehide", saveNow);
};
}
function renderToolbar() {
const toolbar = document.getElementById("toolbar");
Object.assign(toolbar.style, toolbarStyle);
const button = (text: string, selected: boolean, onClick: () => void) => {
const b = document.createElement("button");
b.textContent = text;
Object.assign(b.style, buttonStyle(selected));
b.onclick = onClick;
return b;
};
const chart = () => financeChartPanel.financeApi;
toolbar.replaceChildren(
...STORES.map((s) =>
button(`Save to: ${s.name}`, s === store, () => {
store = s;
renderToolbar();
openChart();
})
),
button("BTC-USD", false, () => chart().setSymbol("BTC-USD")),
button("ETH-USD", false, () => chart().setSymbol("ETH-USD")),
button("Add RSI", false, () => chart().attachStudy(studyProvider.getStudy(EStudyType.RSI))),
// Open the chart again from the store, as a page reload would
button("Reload chart", false, () => openChart()),
button("Clear saved", false, () => store.clear().then(() => log(`Cleared ${store.name}`)))
);
}
function log(message: string) {
const pre = document.getElementById("log");
Object.assign(pre.style, logStyle);
pre.textContent = `${new Date().toLocaleTimeString()} ${message}\n` + pre.textContent;
}
// A stand-in for your server, so the example runs in the browser: it answers /api/charts/… itself,
// keeping the saves in sessionStorage. In your app, this is your backend, and you leave this out
function installMockServer() {
const realFetch = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (!url.startsWith("/api/charts/")) return realFetch(input, init);
const method = init?.method ?? "GET";
await new Promise((r) => setTimeout(r, 150)); // network latency
log(`${method} ${url}${init?.body ? ` (${String(init.body).length.toLocaleString()} characters)` : ""}`);
const key = `mock-server:${url}`;
if (method === "PUT") sessionStorage.setItem(key, String(init.body));
if (method === "DELETE") sessionStorage.removeItem(key);
const body = sessionStorage.getItem(key);
return method === "GET" && !body ? new Response(null, { status: 404 }) : new Response(method === "GET" ? body : null);
};
}
// The toolbar and log use SciTrader's UI colours: its --st-ui-* CSS variables, with the stylesheet's defaults
const toolbarStyle: Partial<CSSStyleDeclaration> = {
display: "flex",
flexWrap: "wrap",
gap: "4px",
padding: "8px 12px",
background: "var(--st-ui-toolbar-background, #2b2839)",
borderBottom: "1px solid var(--st-ui-divider, rgba(255,255,255,0.1))",
};
const buttonStyle = (selected: boolean): Partial<CSSStyleDeclaration> => ({
padding: "4px 12px",
border: "1px solid var(--st-ui-divider, rgba(255,255,255,0.15))",
borderRadius: "4px",
background: selected ? "var(--st-ui-accent, #5c5cff)" : "var(--st-ui-button-background, #363345)",
color: "var(--st-ui-text, #e8e8ee)",
font: "13px sans-serif",
cursor: "pointer",
});
const logStyle: Partial<CSSStyleDeclaration> = {
width: "300px",
margin: "0",
padding: "8px",
overflow: "auto",
background: "var(--st-ui-background, #2b2839)",
color: "var(--st-ui-text-muted, #9da2bc)",
font: "11px monospace",
whiteSpace: "pre-wrap",
borderLeft: "1px solid var(--st-ui-divider, rgba(255,255,255,0.1))",
};
installMockServer();
renderToolbar();
openChart();
React Example: Auto-Save to the Browser or a Server
The same in React: the chart is created from the loaded state with restoreState, and auto-saving runs in an effect.
The code for this example is found below.
- React
import "scichart-scitrader/scitrader.css";
import * as React from "react";
import {
CandleInterval,
CoinbaseExchange,
defaultExchangeProvider,
DrawingToolsController,
EStudyType,
idbPriceStore,
ISciFinanceChartPanelApi,
ISciFinanceChartPanelConfig,
ISciFinanceChartPanelState,
OHLCStudy,
registerDrawingTools,
studyProvider,
VolumeStudy,
} from "scichart-scitrader";
import { DrawingToolbar, SciFinanceChartView, useDrawingTools } from "scichart-scitrader-react";
// Register a data source, and the drawing tools' types so saved drawings can be restored
defaultExchangeProvider.registerExchange(CoinbaseExchange, idbPriceStore);
registerDrawingTools();
// 1. A store: somewhere to load and save a chart. The rest of the app only uses this interface,
// so the storage can change without touching the chart code
interface IChartStateStore {
readonly name: string;
load(): Promise<ISciFinanceChartPanelState | null>;
save(state: ISciFinanceChartPanelState): Promise<void>;
clear(): Promise<void>;
}
// What is stored: the chart's state, inside an envelope with a version. Bump the version when your
// app can no longer restore older saves, and they are ignored instead of breaking the chart
const SCHEMA_VERSION = 1;
interface ISavedChart {
version: number;
savedAt: string;
state: ISciFinanceChartPanelState;
}
const wrap = (state: ISciFinanceChartPanelState): ISavedChart => ({
version: SCHEMA_VERSION,
savedAt: new Date().toISOString(),
state,
});
const unwrap = (saved: ISavedChart | null) =>
saved?.version === SCHEMA_VERSION && saved.state?.finance && saved.state?.layout ? saved.state : null;
// 2a. The browser's localStorage: per browser, about 5 MB, and synchronous
class LocalStorageStore implements IChartStateStore {
readonly name = "This browser";
constructor(private readonly key: string) {}
async load() {
const raw = localStorage.getItem(this.key);
return unwrap(raw ? JSON.parse(raw) : null);
}
async save(state: ISciFinanceChartPanelState) {
localStorage.setItem(this.key, JSON.stringify(wrap(state)));
}
async clear() {
localStorage.removeItem(this.key);
}
}
// 2b. Your server, over HTTP: the user's charts follow them to any device. Send whatever your API
// needs to know the user, e.g. a session cookie (credentials: "include") or an Authorization header
class ServerStore implements IChartStateStore {
readonly name = "Server";
constructor(private readonly url: string) {}
async load() {
const response = await fetch(this.url, { credentials: "include" });
if (response.status === 404) return null; // nothing saved yet
if (!response.ok) throw new Error(`Load failed: ${response.status}`);
return unwrap(await response.json());
}
async save(state: ISciFinanceChartPanelState) {
const response = await fetch(this.url, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(wrap(state)),
keepalive: true, // lets a save sent as the page closes still arrive
});
if (!response.ok) throw new Error(`Save failed: ${response.status}`);
}
async clear() {
await fetch(this.url, { method: "DELETE", credentials: "include" });
}
}
const STORES: IChartStateStore[] = [new LocalStorageStore("my-app:chart"), new ServerStore("/api/charts/main")];
// The log is outside React, so the mock server can write to it too
const logListeners = new Set<(line: string) => void>();
const log = (message: string) => logListeners.forEach((l) => l(`${new Date().toLocaleTimeString()} ${message}`));
installMockServer(log);
export function StorageChart() {
const [store, setStore] = React.useState(STORES[0]);
// undefined while loading; null when nothing is saved
const [saved, setSaved] = React.useState<ISciFinanceChartPanelState | null | undefined>(undefined);
const [mount, setMount] = React.useState(0);
const [api, setApi] = React.useState<ISciFinanceChartPanelApi>();
const { controller } = useDrawingTools(api);
const [lines, setLines] = React.useState<string[]>([]);
React.useEffect(() => {
const listener = (line: string) => setLines((l) => [line, ...l]);
logListeners.add(listener);
return () => void logListeners.delete(listener);
}, []);
// 3. Open the chart: load the saved state, then create the chart from it (or a new one)
const open = React.useCallback(
(from: IChartStateStore) => {
controller?.clearSelection(); // the drawing tools detach after the old chart is gone
setApi(undefined);
setSaved(undefined);
from.load()
.catch((e) => (log(`Load failed: ${e}`), null))
.then((state) => {
log(state ? `Restored from ${from.name}` : `Nothing saved in ${from.name}: new chart`);
setSaved(state);
setMount((m) => m + 1);
});
},
[controller]
);
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useEffect(() => open(store), [store]);
// 4. Auto-save while this chart is open
React.useEffect(() => {
if (!api || !controller) return undefined;
return autoSave(api, controller, (state) => store.save(state), store.name);
}, [api, controller, store]);
// Create the config once per mounted chart: studies are live objects bound to one chart
const config = React.useMemo<ISciFinanceChartPanelConfig>(
() => ({
exchangeName: CoinbaseExchange.name, // name="Coinbase" matches the built-in CoinbaseExchange
symbol: "BTC-USD",
interval: CandleInterval.SIX_HOURS,
period: "3M",
studies: [new OHLCStudy(), new VolumeStudy()],
}),
[mount]
);
const chart = () => api?.financeApi;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div style={toolbarStyle}>
{STORES.map((s) => (
<button key={s.name} style={buttonStyle(s === store)} onClick={() => setStore(s)}>
Save to: {s.name}
</button>
))}
<button style={buttonStyle(false)} onClick={() => chart()?.setSymbol("BTC-USD")}>BTC-USD</button>
<button style={buttonStyle(false)} onClick={() => chart()?.setSymbol("ETH-USD")}>ETH-USD</button>
<button
style={buttonStyle(false)}
onClick={() => chart()?.attachStudy(studyProvider.getStudy(EStudyType.RSI))}
>
Add RSI
</button>
{/* Open the chart again from the store, as a page reload would */}
<button style={buttonStyle(false)} onClick={() => open(store)}>Reload chart</button>
<button style={buttonStyle(false)} onClick={() => store.clear().then(() => log(`Cleared ${store.name}`))}>
Clear saved
</button>
</div>
<div style={{ display: "flex", flex: 1, minHeight: 0 }}>
<DrawingToolbar controller={controller} orientation="vertical" />
<div style={{ flex: 1, minWidth: 0 }}>
{saved !== undefined && (
<SciFinanceChartView
key={mount}
config={config}
restoreState={saved}
useSubCharts={saved?.useSubCharts}
onInit={setApi}
/>
)}
</div>
<pre style={logStyle}>{lines.join("\n")}</pre>
</div>
</div>
);
}
// Auto-save: save after each change the chart reports, at most every half second, and right away
// when the page is hidden or closed. Returns a function that stops it
function autoSave(
panel: ISciFinanceChartPanelApi,
drawingTools: DrawingToolsController,
save: (state: ISciFinanceChartPanelState) => Promise<void>,
storeName: string
): () => void {
const chart = panel.financeApi;
let timer: ReturnType<typeof setTimeout> | undefined;
const saveNow = () => {
clearTimeout(timer);
timer = undefined;
const state = panel.toJSON();
save(state).then(
() => log(`Saved ${JSON.stringify(state).length.toLocaleString()} characters to ${storeName}`),
(e) => log(`${e}`)
);
};
const saveSoon = () => {
clearTimeout(timer);
timer = setTimeout(saveNow, 500);
};
const onDataRendered = ({ type }: { type: string }) =>
(type === "symbol-change" || type === "interval-change") && saveSoon();
const onHidden = () => document.visibilityState === "hidden" && timer !== undefined && saveNow();
// What can change: studies, the theme, the candle type, the live-tip mode, the symbol and interval,
// the pane layout (sizes, order, hidden, maximised), and the drawings
const events = [
chart.studiesChanged,
chart.themeChanged,
chart.candleTypeChanged,
chart.liveTipModeChanged,
panel.paneGroupApi.stateChanged,
drawingTools.drawingsChanged,
];
events.forEach((event) => event.subscribe(saveSoon));
chart.dataRenderComplete.subscribe(onDataRendered);
// A study's edited settings fire no event: they are saved with the next change, or when the page hides
document.addEventListener("visibilitychange", onHidden);
window.addEventListener("pagehide", saveNow);
return () => {
clearTimeout(timer);
events.forEach((event) => event.unsubscribe(saveSoon));
chart.dataRenderComplete.unsubscribe(onDataRendered);
document.removeEventListener("visibilitychange", onHidden);
window.removeEventListener("pagehide", saveNow);
};
}
// A stand-in for your server, so the example runs in the browser: it answers /api/charts/… itself,
// keeping the saves in sessionStorage. In your app, this is your backend, and you leave this out
function installMockServer(onRequest: (message: string) => void) {
const realFetch = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (!url.startsWith("/api/charts/")) return realFetch(input, init);
const method = init?.method ?? "GET";
await new Promise((r) => setTimeout(r, 150)); // network latency
onRequest(`${method} ${url}${init?.body ? ` (${String(init.body).length.toLocaleString()} characters)` : ""}`);
const key = `mock-server:${url}`;
if (method === "PUT") sessionStorage.setItem(key, String(init.body));
if (method === "DELETE") sessionStorage.removeItem(key);
const body = sessionStorage.getItem(key);
return method === "GET" && !body ? new Response(null, { status: 404 }) : new Response(method === "GET" ? body : null);
};
}
// The toolbar and log use SciTrader's UI colours: its --st-ui-* CSS variables, with the stylesheet's defaults
const toolbarStyle: React.CSSProperties = {
display: "flex",
flexWrap: "wrap",
gap: 4,
padding: "8px 12px",
background: "var(--st-ui-toolbar-background, #2b2839)",
borderBottom: "1px solid var(--st-ui-divider, rgba(255,255,255,0.1))",
};
const buttonStyle = (selected: boolean): React.CSSProperties => ({
padding: "4px 12px",
border: "1px solid var(--st-ui-divider, rgba(255,255,255,0.15))",
borderRadius: 4,
background: selected ? "var(--st-ui-accent, #5c5cff)" : "var(--st-ui-button-background, #363345)",
color: "var(--st-ui-text, #e8e8ee)",
font: "13px sans-serif",
cursor: "pointer",
});
const logStyle: React.CSSProperties = {
width: 300,
margin: 0,
padding: 8,
overflow: "auto",
background: "var(--st-ui-background, #2b2839)",
color: "var(--st-ui-text-muted, #9da2bc)",
font: "11px monospace",
whiteSpace: "pre-wrap",
borderLeft: "1px solid var(--st-ui-divider, rgba(255,255,255,0.1))",
};
A Store Interface
Keep the chart code apart from the storage: give the rest of your app one small interface, and switch stores without touching the chart:
import { ISciFinanceChartPanelState } from "scichart-scitrader";
interface IChartStateStore {
load(): Promise<ISciFinanceChartPanelState | null>;
save(state: ISciFinanceChartPanelState): Promise<void>;
clear(): Promise<void>;
}
Store the state inside an envelope with your own version, so you can tell old saves from new ones; see Versions:
const SCHEMA_VERSION = 1;
interface ISavedChart {
version: number;
savedAt: string;
state: ISciFinanceChartPanelState;
}
const wrap = (state: ISciFinanceChartPanelState): ISavedChart => ({ version: SCHEMA_VERSION, savedAt: new Date().toISOString(), state });
// A save from another version, or one that isn't a chart, is ignored
const unwrap = (saved: ISavedChart | null) =>
saved?.version === SCHEMA_VERSION && saved.state?.finance && saved.state?.layout ? saved.state : null;
localStorage
class LocalStorageStore implements IChartStateStore {
constructor(private readonly key: string) {}
async load() {
const raw = localStorage.getItem(this.key);
return unwrap(raw ? JSON.parse(raw) : null);
}
async save(state: ISciFinanceChartPanelState) {
localStorage.setItem(this.key, JSON.stringify(wrap(state)));
}
async clear() {
localStorage.removeItem(this.key);
}
}
localStorage.setItem throws when the storage is full or blocked, e.g. in some private browsing modes, so catch it where you save. The SciTrader app keeps its chart in localStorage this way.
Your Server
To keep each user's chart in their account, so it follows them to any device, save it through your own API. On the client:
class ServerStore implements IChartStateStore {
constructor(private readonly url: string) {} // e.g. "/api/charts/main"
async load() {
const response = await fetch(this.url, { credentials: "include" }); // send the session cookie
if (response.status === 404) return null; // nothing saved yet
if (!response.ok) throw new Error(`Load failed: ${response.status}`);
return unwrap(await response.json());
}
async save(state: ISciFinanceChartPanelState) {
const response = await fetch(this.url, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(wrap(state)),
keepalive: true, // a save sent as the page closes still arrives
});
if (!response.ok) throw new Error(`Save failed: ${response.status}`);
}
async clear() {
await fetch(this.url, { method: "DELETE", credentials: "include" });
}
}
Identify the user however your API does: a session cookie, as here, or an Authorization: Bearer … header. keepalive lets a request outlive the page, up to 64 KB, which covers a typical chart.
On the server, keep the JSON as it comes, by user and chart. For example, with Node and Express:
import express from "express";
const app = express();
app.use(express.json({ limit: "1mb" }));
// requireUser: your sign-in middleware, which sets req.user
app.get("/api/charts/:chartId", requireUser, async (req, res) => {
const saved = await db.charts.find({ userId: req.user.id, chartId: req.params.chartId });
saved ? res.json(saved.data) : res.sendStatus(404);
});
app.put("/api/charts/:chartId", requireUser, async (req, res) => {
await db.charts.upsert({ userId: req.user.id, chartId: req.params.chartId, data: req.body, updatedAt: new Date() });
res.sendStatus(204);
});
app.delete("/api/charts/:chartId", requireUser, async (req, res) => {
await db.charts.delete({ userId: req.user.id, chartId: req.params.chartId });
res.sendStatus(204);
});
- Store it as JSON, e.g. a
jsonbcolumn in PostgreSQL, or a document in MongoDB. Your server needn't understand it. - Check the size and shape before storing: a chart is a few KB, so reject anything far bigger.
- Only the user's own charts: take the user from the session, never from the request body.
IndexedDB
For larger or many saved charts, IndexedDB holds far more than localStorage, asynchronously. The idb package makes it short:
import { openDB } from "idb";
const db = openDB("my-app", 1, { upgrade: (d) => d.createObjectStore("charts") });
class IndexedDbStore implements IChartStateStore {
constructor(private readonly key: string) {}
async load() {
return unwrap((await (await db).get("charts", this.key)) ?? null);
}
async save(state: ISciFinanceChartPanelState) {
await (await db).put("charts", wrap(state), this.key);
}
async clear() {
await (await db).delete("charts", this.key);
}
}
Export and Import as a File
Let users download a chart, and open it again, e.g. to move a layout to another browser, or share it:
function exportChart(financeChartPanel: ISciFinanceChartPanelApi) {
const blob = new Blob([JSON.stringify(wrap(financeChartPanel.toJSON()), null, 2)], { type: "application/json" });
const link = Object.assign(document.createElement("a"), { href: URL.createObjectURL(blob), download: "chart.json" });
link.click();
URL.revokeObjectURL(link.href);
}
// <input type="file" accept="application/json">
async function importChart(file: File): Promise<ISciFinanceChartPanelState | null> {
return unwrap(JSON.parse(await file.text()));
}
Saving Automatically
Rather than a Save button, save after every change. The chart reports its changes as events:
| Change | Event |
|---|---|
| A study added or removed | finance |
| The symbol or interval | finance, with type "symbol- or "interval- |
| The candle type | finance |
| The live-tip mode | finance |
| The theme | finance |
| Pane sizes, order, hidden, maximised or closed | pane |
| A drawing added, moved or removed | Drawing |
- Wait for changes to settle. Dragging a divider fires many events: save half a second after the last one.
- Save when the page is hidden or closed, on
visibilitychangeandpagehide. Changes with no event, such as a study's edited settings, are saved then, or with the next change. - Stop auto-saving before the chart is removed, so a closing chart doesn't save a half-built state.
The example's autoSave(financeChartPanel, drawingTools, save) does all of this, and returns a function that stops it.
Loading Saved State Safely
A saved state can outlive the code that wrote it. If it can't be restored, e.g. it names a study your app no longer registers, fromJSON throws. Don't let one bad save leave the user with a blank chart on every visit:
async function openChart(store: IChartStateStore) {
const state = await store.load().catch(() => null);
if (state) {
try {
return await SciFinanceChartPanel.fromJSON(element, state, { useSubCharts: state.useSubCharts });
} catch {
await store.clear(); // discard it, and start fresh
}
}
return SciFinanceChartPanel.create(element, defaultConfig);
}
If a restore can hang or crash the page, rather than throw, the SciTrader app also marks each restore before it starts (localStorage.setItem("restore-pending", "1")), and clears the mark once the chart is ready. If the mark is still there on the next load, the previous restore never finished, so it discards the saved chart instead of trying it again.
Several Charts and Layouts
Save each chart under its own key, e.g. charts/<user>/<chartId>. For named layouts the user can pick from, save a list:
interface ISavedLayout {
name: string; // "Day trading", "Swing"
state: ISciFinanceChartPanelState;
}
To open a saved layout on another symbol, change finance.symbol before restoring; see Editing a Saved Chart.
Settings Beside the Chart
A chart's state doesn't include the theme or your app's own settings. Save them beside it, in the same request or file:
interface IUserSettings {
version: 1;
chart: ISciFinanceChartPanelState;
themeId: string; // builtInThemeId(theme): "dark", "light" or "ocean"
watchlist: string[];
}
Restore the chart with fromJSON(element, settings.chart, { theme: SCITRADER_THEMES[settings.themeId] }). The SciTrader app exports this kind of bundle, with its watchlists, favourite studies and preferences, as one JSON file.
Where to read next
- The Saved JSON Format: what is in the state you store.
- Saving and Restoring:
toJSON,fromJSONand React. - Styling and Themes: remembering the user's theme.