[PRE-ALPHA] an open source web-based 2D/3D graphing calculator cartesium.org
cartesium docs embedding.md
17 kB
Markdown

Embedding Cartesium graphs #

Cartesium exposes two deliberately different iframe contracts:

  • /embed/v1#doc=... is the versioned, passive external viewer for flashcards, feeds, and ordinary third-party sites. It is a live canvas backed by Cartesium's Wasm and worker renderer, but has no calculator, account, storage, or editing UI.
  • /embed#... is the existing interactive documentation viewer. It supports the documentation activation flow, presentation commands, and the existing read-only expression panel. It is an internal Cartesium documentation surface, not the minimal third-party contract.

The external v1 route accepts exactly one anonymous doc source or one public at source. ?interaction=viewport remains explicitly unimplemented; it must not be inferred from a URL or silently enabled by a future host.

Route examples use <base-path> for the configured URL mount prefix from CARTESIUM_BASE_PATH. It includes its leading slash when non-empty; omit it when Cartesium is served at a domain root. The current shared-subdomain test deployment uses /cartesium.

External v1 URL and minimal host #

Build a GraphSnapshotV1, encode it with Cartesium's bounded encodeGraphSnapshot implementation, and place the resulting Base64URL payload in the fragment. Fragments are not sent in HTTP requests, so anonymous graph data stays out of ordinary server request logs. The URL is:

<base-path>/embed/v1#doc=<compressed GraphSnapshotV1>
<base-path>/embed/v1#at=<encodeURIComponent(at://did/collection/rkey)>

The payload format is UTF-8 JSON for a validated GraphSnapshotV1, compressed with gzip, then encoded as URL-safe Base64 without = padding (+ becomes - and / becomes _). This is the same format produced by Cartesium's ordinary 2D share-link action.

The smallest supported host needs no SDK or host-side JavaScript:

<iframe
  src="https://cartesium.org/embed/v1#doc=BASE64URL_PAYLOAD"
  title="Graph of a sine wave and a circle"
  loading="lazy"
  sandbox="allow-scripts allow-same-origin"
  tabindex="-1"
  referrerpolicy="no-referrer"
  style="display:block;width:100%;aspect-ratio:3/2;border:0;pointer-events:none"
></iframe>

pointer-events:none is required for the passive contract. Pointer and wheel events do not bubble across an iframe boundary, so an iframe cannot guarantee that a host's page will keep scrolling when the pointer is over it. The passive viewer installs no graph input handlers and is not focusable, while the host-side rule guarantees scroll and focus ownership remains with the card or page. An interactive host may use /embed instead and omit tabindex and pointer-events:none; it must then document that the graph owns gestures while active.

The iframe title is the accessible name for the embedded browsing context. The surrounding card or page remains responsible for meaningful graph alt text for users or clients that cannot execute the viewer (for example, “Sine wave crossing a unit circle”). Do not put private data or credentials in the fragment. HTTPS is recommended for both host and viewer to avoid mixed-content failures. If the host has a Content Security Policy, it needs at least:

frame-src https://cartesium.org

The iframe requests no browser permissions and has no allow attribute. The document response permits framing with Content-Security-Policy: frame-ancestors * and does not emit X-Frame-Options.

The current v1 safety limits are 750,000 Base64URL payload characters (the fragment parser allows 64 additional selector characters), 500,000 decoded JSON bytes, 200 expressions, 20,000 characters per expression source, and 500 characters for the graph name. These are published operational limits, not promises that their exact numbers can never change within v1. Input over a limit is rejected deterministically before renderer initialization. Keep anonymous links comfortably below about 64 KiB when possible and use a future published-record/reference contract for larger graphs.

The v1 fragment must contain exactly one source selector. The accepted forms are unpadded Base64URL in #doc=... and a percent-encoded canonical AT URI in #at=.... Construct the latter as:

`${base}/embed/v1#at=${encodeURIComponent('at://did/collection/rkey')}`

The viewer uses URLSearchParams for the one percent-decoding pass, then strictly validates at://did/collection/rkey and its bounded DID, collection, and record-key components. Duplicate selectors, doc plus at, a missing selector, malformed or oversized AT URIs, and unknown fragment parameters produce a structured error lifecycle message. URL query parameters are currently rejected too; in particular, ?interaction=viewport is reserved future work and is not silently enabled.

For an #at source, the browser makes exactly one credential-free request to the same-origin resolver:

<base-path>/api/embed/v1/at?uri=<encodeURIComponent(at://did/collection/rkey)>

The resolver accepts only the configured Cartesium publication collections. V1 records contain a validated GraphSnapshotV1; V2 records are accepted only when their validated PublishedDocumentV2 is 2D. 3D, unknown collections, malformed records, and oversized snapshots are rejected. The response contains only {version: 1, kind: "2d", snapshot}; it is not an arbitrary AT record or blob proxy. The server performs public DID/PDS reads without credentials, so a host needs neither PDS CORS access nor an ATProto login.

Configured publication records use the application-level tid key. Cartesium's own publishing flow is create/delete-only, but the resolver accepts any valid record key in an allowlisted collection because AT records can be changed or deleted externally. Successful resolver responses use a short bounded public cache (max-age=60), so an external change or deletion becomes visible within at most that cache window (subject to an intermediary's normal behavior). Missing, deleted, private/inaccessible, and invalid references never receive a cacheable error response: inaccessible records intentionally collapse to the same publication-not-found state to avoid disclosing record existence. Upstream failures report publication-unavailable; the viewer does not retry automatically. Generic clients should use the publication's static preview when the viewer reports an unavailable or unsupported state.

The viewer is deterministic for a given snapshot and viewport size. It does not read or write localStorage, sessionStorage, cookies, drafts, OAuth state, or account state. A doc source performs no graph-data lookup; an at source performs the single same-origin public resolver request described above. It does still load its own JavaScript, module workers, Wasm, and styles, and normal network metadata (IP address, timing, and the host's referrer policy if changed) can exist. The resolver request explicitly uses credentials: "omit"; server-side PDS reads also omit credentials. The initial iframe navigation may still carry ambient cookies scoped to cartesium.org, but the viewer never reads them or varies rendering by them. This is session-independent behavior: v1 output does not depend on a Cartesium login or browser storage. The document's referrerpolicy="no-referrer" avoids sending the flashcard URL as a referrer in the baseline example.

The browser must support CompressionStream, DecompressionStream, module workers, Canvas 2D, ResizeObserver, and Wasm. A client unable to run the viewer should use the graph's content-addressed/static PNG preview instead; generic feed clients should show that image, title, description, alt text, and canonical link rather than execute iframe HTML.

Existing interactive viewer #

The existing interactive viewer accepts the documentation formats:

<base-path>/embed#preset=featured-lemniscate
<base-path>/embed#doc=<compressed GraphSnapshotV1>

It remains available for Cartesium's own documentation and is not silently changed into the passive route. The normal calculator URLs are:

<base-path>/calc/2d#preset=featured-lemniscate
<base-path>/calc/2d#doc=<compressed GraphSnapshotV1>

An interactive documentation embed can therefore offer an explicit “Edit” link without converting the document.

GraphEmbed #

apps/web/src/lib/components/GraphEmbed.svelte is the docs-facing wrapper. It:

  • leaves the iframe unmounted until the reader activates the example;
  • coordinates all instances in the JavaScript realm so only one iframe is live;
  • displays the exact expression sources of a built-in preset directly on the documentation page;
  • remembers the last reported viewport and restores it after reactivation;
  • expands the same iframe wrapper with fixed positioning, avoiding reparenting or a second render;
  • pushes one browser-history entry so Back, Escape, and the visible return button can restore the containing document; and
  • provides Reset, Expand, and Edit controls without placing authoring or account concerns inside the viewer.

The current resting state accepts an optional authored previewUrl. For documented built-in presets, it automatically resolves a checked-in image from static/graph-previews; document snapshots and unregistered presets fall back to the lightweight graph placeholder.

src/lib/embed/docs-preview-presets.json is the explicit preview manifest. Regenerate its images from the real renderer with:

cd apps/web
npm run previews:update

The generator starts a loopback Vite server and headless Chromium, waits for each graph to settle, and atomically writes a 960 by 600 PNG. Set CHROMIUM_PATH if Chromium is not on PATH. Passing preset IDs after the script generates only that subset; CARTESIUM_PREVIEW_SETTLE_MS adjusts the post-render quiet interval. The ordinary npm run build includes the docs and consumes the checked-in assets without requiring Chromium on the deployment host. Run previews:update only when documented presets or rendering changes make those assets stale.

The manual lifecycle page at <base-path>/docs/embed-lab renders twenty image-backed resting examples for regression and memory testing. They create no iframe, worker, Wasm engine, or live canvas until activated.

Declarative documentation and math #

Documentation pages may use the .svx extension to combine Markdown prose, Svelte components, and TeX. MDsveX is part of the ordinary SvelteKit preprocessing configuration, so there is no separate documentation build. For example:

A matrix-vector inequality $Ax \le b$ defines the feasible region

$$
\left\{x \in \mathbb{R}^2 : Ax \le b\right\}.
$$

<GraphEmbed
  title="Linear-program feasible region"
  preset="linear-algebra-lp-region"
/>

remark-math recognizes inline $...$ and display $$...$$ nodes. The local remark-mathjax transform renders those nodes to MathJax 4 CommonHTML during the Vite build and adds page-adaptive CSS to the page's existing <svelte:head>. The shipped page therefore contains already-typeset HTML and does not load or execute MathJax in the browser.

The New Computer Modern WOFF2 files are self-hosted below <base-path>/mathjax/newcm. Normal dev, dev:frontend, build, and build:frontend commands copy them from the pinned npm package into the generated static tree. The copied directory is ignored rather than checked in.

The reader-facing /docs reference uses a responsive documentation shell. Navigation metadata lives in src/lib/docs/navigation.ts and links directly to the reference page's syntax sections. Narrow windows replace the persistent sidebar with an explicit Contents drawer; Escape, route navigation, or the backdrop closes it. Markdown headings receive deterministic fragment IDs and focusable self-links at build time through scripts/remark-heading-links.mjs. The internal <base-path>/docs/embed-lab lifecycle harness deliberately bypasses this reader shell.

External v1 lifecycle protocol #

The public viewer emits only two output messages from apps/web/src/lib/embed/protocol.ts; it accepts no postMessage commands. The message schema has independent versions from the URL API and document format:

{
  "type": "cartesium.embed.ready",
  "protocolVersion": 1,
  "embedApiVersion": 1,
  "interaction": "none",
  "title": "Sine wave"
}

Errors use the same version and interaction fields plus a bounded code and sanitized message, for example type: "cartesium.embed.error" with code: "missing-source", code: "invalid-document", or one of the public resolution states (publication-not-found, publication-invalid, publication-unavailable, or unsupported-publication). The complete current error-code set is defined in the shipped TypeScript protocol module. The effective v1 interaction is always none; there is no capability negotiation or inbound command surface yet.

The existing /embed documentation viewer continues to use the separate legacy cartesium:embed:* protocol, including presentation commands and viewport updates. That protocol is not part of the public /embed/v1 contract. For any lifecycle message, a host must validate the expected Cartesium origin, event.source === iframe.contentWindow, and the complete schema before consuming it; checking event.origin alone is insufficient. Cartesium's own documentation wrapper applies both checks to its internal route.

The viewer response permits framing. The ordinary publication landing page continues to use frame-ancestors 'none'; it is an Open Graph redirect surface, not an executable embed.

Gesture ownership #

An inactive card behaves like ordinary document content. Its activation button must be used before it can consume drag, wheel, or pinch gestures. Once active, dragging pans the graph and wheel/pinch zoom it. Expanding does not construct a new renderer, so camera state survives the transition in both directions. The expanded viewer adds a read-only list of the loaded expressions beside the graph, or beneath it in a narrow window, so the definition remains visible without exposing editing controls.

For a future spatial relationship document, the outer diagram camera should own gestures until a graph node is explicitly entered. The same GraphEmbed component can then own gestures inside that node, while Back/Escape restores the saved outer camera.

Security and deployment notes #

The viewer response's frame-ancestors * controls who may embed Cartesium; it does not make the graph data trusted. Snapshot fields are decoded through the bounded versioned validator, expression evaluation remains inside Cartesium's Wasm/worker pipeline, and the v1 route cannot receive document replacement, viewport, account, or publishing commands. The host must treat lifecycle messages as advisory and validate origin/source as above. The host must not let record-authored data choose an iframe origin.

Same-origin hosting is convenient for local development, but a dedicated embed.cartesium.org origin is preferable for deployment: it separates the untrusted graph-rendering surface from authenticated application state and makes the allow-scripts allow-same-origin sandbox less dependent on same-origin application privileges. That origin split is a deployment decision; the contract above currently uses cartesium.org.

Internal architecture note #

The v1 viewer deliberately does not import CalculatorShell.svelte or GraphCanvas.svelte. Those components own authoring, local drafts, account and publication state, camera/tracing input, and PNG/GIF export; importing them would put calculator-only module graphs on the external viewer's path and could reintroduce server/runtime failures. EmbedV1Viewer.svelte converts the validated snapshot to an anonymous GraphDocument2d, uses the shared scene and spatial-tile computation, and renders retained resources through PassiveGraphCanvas.svelte and CanvasRenderer. The passive surface installs no pointer, wheel, touch, focus, or keyboard listeners. Its only lifecycle messages are the versioned ready and error values described above.

The response header hook applies frame-ancestors * to both executable viewer routes. Publication landing pages remain metadata/redirect surfaces with their existing restrictive framing policy.

Deferred work #

  • a narrowly specified interaction=viewport mode with explicit gesture, focus, scrolling, and ephemeral-camera semantics;
  • authored preview generation for compressed documentation snapshots;
  • an embeddable 3D viewer and 3D-specific postMessage camera protocol;
  • intersection-based prewarming just before a card enters the viewport;
  • pausing and resuming slider animation through the protocol;
  • keyboard instructions and broader focus/accessibility validation; and
  • the separate HTML/MathJax/SVG spatial multigraph document system.