DEV Community

Guluhehe
Guluhehe

Posted on

Building a chart editor with no backend: storing charts in the browser and exporting a PNG

RelationCanvas is a relationship chart editor for fiction writers: characters are cards, relationships are labeled arrows. It has no accounts and no database. The server sends the pages and the JavaScript, and it never receives a chart. That decision shaped three parts of the code: saving, PNG export and sharing. Here's how each one works. (It's a Next.js 16 and React 19 app, but none of this depends on the framework.)

One format for everything

A chart is a JSON object wrapped in a small envelope: a format name (relationcanvas), a version number (1) and the chart itself. The chart holds a title, a credit line, a legend toggle, the people (id, name, position, optional picture, note and colour) and the relationships (from, to, and a label that is either a preset or custom text).

The same envelope is used for browser storage, for the downloadable project file, and inside share links. One parser reads all three, so a file can't be accepted in one place and rejected in another. Share links get an extra, stricter key check on top.

Saving to localStorage

Every change is written to localStorage under one key, relationcanvas.chart.v1, with a revision number added to the stored JSON. That sounds like one line of code. The edge cases took most of the work.

Data that won't parse. If the stored value fails validation, the editor copies the raw string to a second key (relationcanvas.chart.v1.backup), starts with an empty chart and shows a notice saying the old data was kept aside. If that backup can't be confirmed, saving is blocked rather than risk overwriting the only copy.

Two tabs. Before each write, the editor reads the stored revision. If it differs from the revision this tab loaded, the write is treated as a conflict, not an overwrite. The editor also listens for the browser's storage event, so when another tab saves, this tab can adopt the newer chart if it has no unsaved changes of its own.

Storage that fails. When a write fails, the editor retries with a delay that starts at one second and doubles up to a 30-second cap. If storage can't be read at all, the notice tells the user to download a project file to keep new work.

Pictures. A character picture is cropped to a square in the browser and stored as a data URL inside the chart. The encoder tries PNG at 256, 192 and 128 pixels, then WebP at quality 0.9, 0.75 and 0.6, and keeps the first result under 120,000 characters. The cap puts a known ceiling on what one picture adds to storage.

The honest limit: clearing site data deletes the chart. The site says so, and the File menu has Download project file and Import project for backups and moving devices.

PNG export without a server

The chart is drawn by a function that returns an SVG string. The same renderer produces the export preview and the read-only share view.

Export takes that SVG, serializes it into a Blob with type image/svg+xml, loads it through an object URL into an image element, and draws the image onto a canvas that has been filled with the theme's background colour. canvas.toBlob produces the PNG, which downloads as relationcanvas.png.

Three details came from things going wrong:

  • Scale. The user picks 2x or 4x. The dialog shows the final pixel size and the line "Full chart ยท No watermark" before downloading.
  • Size limits. If either side would exceed 16,384 pixels, or the total would pass 32,000,000 pixels, export refuses and suggests 2x or moving the characters closer together.
  • A blank-image check. After drawing, the code reads the top-left pixel. If it doesn't match the background colour, the export reports a failure instead of handing over an empty file.

Sharing without a database

A share link is a URL to /view with the chart in the fragment, after the #. The fragment has three dot-separated parts: a version tag (rc1), the project JSON compressed with the browser's CompressionStream in deflate-raw mode and encoded as base64url, and a base64url SHA-256 digest of the compressed bytes.

Browsers don't send the fragment to the server, so names and labels stay out of request logs.

Two rules keep links usable:

  • No pictures. Avatars are removed before encoding. The share dialog says "Images aren't included in this link. PNG export keeps them."
  • 2,000 characters, total. The whole URL, not only the fragment, has to fit in 2,000 characters, because a normal Discord message is 2,000 characters and a longer one turns into a text file instead of a link. If the chart is too big, there's no link, and the editor suggests Export PNG and a project file.

The viewer is defensive about what it accepts. It checks the digest before decompressing. It caps decompression at 4,968,766 bytes, which is twice the size of the largest chart the editor can produce (20 people, every field at its limit, 2,484,383 bytes). It then checks the JSON against a fixed list of allowed keys before turning it into a chart. Older links without the digest still open, because a link someone has already posted should keep working.

The /view page is noindex, follow, so shared charts don't end up in search results.

What this design gives up

A chart lives in one browser profile. There's no sync between devices beyond the project file. Share links are read-only snapshots, not collaboration. Large charts can't be shared as links at all. The editor is designed around casts of up to 20 people, and the limits above are sized for that.

If you've built something similar with localStorage, I'd like to know how you handled two tabs editing the same data.

I drafted this article with help from an AI assistant. The technical details come from the project's source code, and I checked them before publishing.

Top comments (0)