[PRE-ALPHA] an open source web-based 2D/3D graphing calculator cartesium.org
README.md

Cartesium web #

The browser calculator is a SvelteKit application backed by the Rust engine's WebAssembly adapter.

Start with the engineering documentation index. The shared/2D architecture explains the mathematical model, documents, workers, and Canvas pipeline. The 3D engine guide owns 3D rendering and architectural priorities. Performance measurement owns profiling procedures; project status summarizes product scope.

The normal development and production commands rebuild the ignored Wasm bindings before starting Vite, so a Git checkout cannot silently run a stale engine binary:

cargo install wasm-bindgen-cli --version 0.2.126 --locked
npm run check
npm run build

Everything you need to build a Svelte project, powered by sv.

Developing #

Once you've created a project and installed dependencies with npm install (or pnpm install or yarn), start a development server:

npm run dev

# or start the server and open the app in a new browser tab
npm run dev -- --open

Development runs at / by default. Set CARTESIUM_BASE_PATH explicitly when testing a deployment below a URL prefix.

When only Svelte or TypeScript changed and the generated Wasm is already known to match the checked-out Rust source, npm run dev:frontend skips the engine rebuild.

Building #

Vendored MathQuill #

MathQuill's full browser build, CSS, fonts, license, source archive, and provenance are committed in static/mathquill. Its standalone public declarations live in src/lib/vendor/mathquill/mathquill.d.ts. Ordinary installs and builds use these files without a sibling MathQuill clone, MathQuill download, or npm publication. Production build commands verify their checksums automatically. Verify their integrity separately from apps/web:

npm run mathquill:verify

To update, commit the desired changes in a MathQuill clone and install that clone's build dependencies using its lockfile. Then run from apps/web:

npm run mathquill:vendor -- --source /path/to/mathquill
# Or select an exact commit instead of the clone's HEAD:
npm run mathquill:vendor -- --source /path/to/mathquill --commit <commit-hash>
npm run mathquill:verify

The importer archives the selected commit (default: HEAD), builds that snapshot in a temporary directory, and updates the assets, public declarations, source archive, and checksums. Uncommitted changes and the clone's current checkout do not affect the selected source. The commit must be available in the local clone; fetch remote revisions first if needed. Installed build tool versions must match the selected revision's lockfile. The importer does not install dependencies or modify the source clone. Review and commit the updated files together. The source archive can also be extracted into a new directory and rebuilt with npm ci followed by make; the recorded Node/tool versions identify the original build environment.

These assets prepare the dependency for the visual expression editor. Expression rows still use text input. Future integration should initialize MathQuill only in the browser and prefix asset URLs with the application's configured base path. Keeping the files in static does not load them on calculator or embed pages until a consumer explicitly requests them.

Production build #

The production build is a Node application mounted below a configurable URL base path. CARTESIUM_BASE_PATH sets that prefix at build time; when unset, the application is served at the domain root. Set it to a slash-prefixed path such as /cartesium when mounting below a URL prefix. The deployment template passes an empty path explicitly. Set CARTESIUM_ORIGIN at build time when the public origin cannot be inferred reliably from request headers; SvelteKit 3 does not read the Node adapter's former runtime ORIGIN setting. In the examples below, <base-path> means this configured URL prefix, including its leading slash when non-empty; omit it when the application is mounted at the domain root. The landing page is server-rendered for initial metadata and navigation; the calculators remain client-rendered. The Node process also serves published-graph Open Graph metadata and validates/proxies public preview images from AT Protocol PDS hosts. Generate the Wasm bindings and build it with:

npm ci
npm run build

Similarly, npm run build:frontend skips Wasm generation for a frontend-only production rebuild.

The main calculator displays the commit used to produce the frontend build in the expression-panel footer. The development server adds a -dev marker to the short hash (for example, Build abc123456789-dev) so it is visibly distinct from a production build. The full raw commit remains in the footer's tooltip and data-build-commit attribute in both channels. Builds read git rev-parse HEAD from the repository root; when building from CI or a source archive, set CARTESIUM_COMMIT explicitly. The value is compiled into build/, so it does not need to be passed to the runtime container.

The self-contained Node output is written to build/. Run it directly with:

CARTESIUM_PUBLICATION_V2_COLLECTION=org.example.cartesium.publicationV2 \
CARTESIUM_SPACES_ENABLED=false \
node build

The Vite development server is unchanged and does not need these values until a published route is requested.

Browser documents #

Saved drafts are durable per-browser documents in localStorage under cartesium.documents; the active working copy and recovery state live in sessionStorage. The shared Drafts library can filter 2D, 3D, or all local documents, and JSON export includes a saved 3D document's semantic camera presentation when one exists. These drafts are private to that browser and are not automatically written to an AT Protocol PDS. When Spaces is enabled and an account is signed in, Save for a new document creates or updates a private document in the account's personal Cartesium Space. Existing browser drafts remain in the separate browser-only library. The active document identity is tracked as either a local id or a Space record key, so a document cannot silently be saved to the wrong store. A signed-out user sees only the browser store; the header labels it Browser only because localStorage is tied to that browser profile.

Documents may also have an optional authored description. The description is part of the portable document, so it follows local and private draft saves, working-copy recovery, JSON export, V2 share links, and V2 publication. It is plain text and is limited to 2,000 graphemes / 10,000 UTF-8 bytes. Tags are not part of the current document or discovery contract.

Container deployment #

Build the application before building the small runtime image; Rust, npm, and Wasm build tools stay outside the production image:

npm ci
export CARTESIUM_ORIGIN=https://example.org
npm run build
export CARTESIUM_PUBLICATION_V2_COLLECTION=org.example.cartesium.publicationV2
docker compose up -d --build

Compose accepts runtime values from the invoking shell, from an untracked .env beside compose.yaml, or from an explicit deployment file:

cp .env.example .env
# edit .env, then:
docker compose up -d --build

# Or keep the file elsewhere:
docker compose --env-file /path/to/cartesium.env up -d --build

The .env file is Compose input; compose.yaml passes runtime values into the container. Do not commit a real deployment .env. Supply CARTESIUM_ORIGIN to the build before running Compose. Its value is only the scheme and host, without the <base-path> URL prefix.

Test and production deployments #

The repository includes a non-secret deploy.sh.example template. From the local repository root, copy it to the ignored local deploy.sh, set the SSH destination, and run it there:

cp deploy.sh.example deploy.sh
chmod +x deploy.sh

CARTESIUM_DEPLOY_REMOTE=user@example.org ./deploy.sh test
CARTESIUM_DEPLOY_REMOTE=user@example.org ./deploy.sh prod

The template contains deployment topology but no SSH credentials or runtime secrets. SSH authentication comes from the local SSH configuration/key, and each remote directory keeps its own untracked .env.

Each remote directory must have its own untracked .env; the deployment script deliberately does not copy or overwrite those files. It uploads .env.example first. Initialize a new deployment's environment on the server:

cd <production-deployment-directory>
cp .env.example .env
# edit .env

Then return to the local repository and rerun ./deploy.sh test or ./deploy.sh prod.

The production .env should include values like:

CARTESIUM_HOST_PORT=3333
CARTESIUM_PUBLICATION_V2_COLLECTION=org.example.cartesium.publicationV2
CARTESIUM_SPACES_ENABLED=false
# Required only when Spaces is enabled.
CARTESIUM_LEXICON_PREFIX=org.cartesium.dev

Override the SSH destination with CARTESIUM_DEPLOY_REMOTE when necessary.

The host name, certificates, and collection NSID are deployment configuration, not repository configuration. The base path and public origin are compiled into each build, so rebuild whenever either changes. Static file copying alone no longer deploys Cartesium because crawlers must reach the dynamic published-graph and media routes.

AT Protocol publishing #

The AT-native publishing path is stateless on the Cartesium server:

  • GET <base-path>/p/v2/<did>/<rkey> does the same for the unified 2D/3D publication contract. A V2 3D record includes its authored semantic camera presentation; V2 2D records retain the viewport already present in the GraphSnapshotV1 payload.
  • GET <base-path>/media/v2/<did>/<rkey>/<cid>.png provides the equivalent content-addressed preview route for V2 publications.
  • Cartesium does not retain the record, snapshot, or PNG. Preview responses use Cache-Control: no-store so browsers and caching proxies do not retain them while preview moderation is unavailable.
  • Publish uses browser OAuth. Its keys and session credentials remain in the browser rather than the Cartesium server.
  • <base-path>/ is the landing page. The canonical calculators are <base-path>/calc/2d and <base-path>/calc/3d; <base-path>/calc opens the 2D calculator. The old root graph fragments and <base-path>/3d remain supported as compatibility entry points, while published reader URLs remain unchanged.
  • <base-path>/explore is the public listed-document view. It includes listed V2 publications by default, supports title/description search and a 2D/3D filter, and paginates with opaque cursors. No sign-in is required. The page sends noindex while public listings are unmoderated.
  • The browser uploads the validated snapshot and a normalized 1200×630 PNG directly to the user's PDS, then creates a new immutable publication record. Every Publish action creates a new record key.
  • Publish asks whether the record is listed or unlisted. Both are public, immutable records: listed is eligible for Cartesium discovery, while unlisted is link-addressable. All publication pages send noindex through a robots meta tag and X-Robots-Tag; unlisted pages also specify nofollow and noarchive. Preview PNGs send X-Robots-Tag: noindex. Unlisted is not access control; PDS observers and technically capable Jetstream/indexing clients can still discover it.
  • <base-path>/oauth-client-metadata.json exposes deployment-specific OAuth metadata. <base-path>/resolve-handle performs DNS TXT resolution with a bounded, public-host-only HTTPS well-known fallback.
  • The account picker searches public actor directories after two typed characters, using Typeahead first and the Bluesky public AppView as a fallback when the primary service is unavailable. Suggestions are optional; users may still enter a handle or DID manually. A selected suggestion only supplies the handle to the existing OAuth flow; its returned DID is not used as an authentication authority.

Set CARTESIUM_PUBLICATION_V2_COLLECTION to the production NSID selected for unified 2D/3D publications. The repository's lexicons/org/example/cartesium/publicationV2.json is the namespace-neutral example: copy it to a path matching the production NSID and change its id before publishing it under that authority. V2 records use this shape:

{
  $type: <configured collection NSID>,
  kind: "2d" | "3d",
  title: string,
  createdAt: ISO timestamp,
  snapshot: application/json PublishedDocumentV2 blob,
  preview: image/png blob
}

The JSON blob is a PublishedDocumentV2 envelope containing the discriminated 2D/3D document and optional semantic 3D presentation state. The optional discoverability field is listed or unlisted; records created before the field existed default to listed.

Public discovery AppView #

Public discovery is deliberately separate from Spaces. Spaces continue to hold only a signed-in account's private mutable drafts; they do not represent groups, shared servers, or the scope of /explore. The public AppView indexes normal public AT repository publication records and serves the same listed view to every visitor.

appview/ is a standalone Node service backed by PostgreSQL. On first start it applies numbered migrations, pins a Jetstream archive target, and backfills only commit events in the configured V2 publication collection. Jetstream can therefore skip unrelated archive blocks instead of sending global account, identity, and sync history. Once publication DIDs are known, the AppView reconciles those repository-level markers for only the relevant actors, then follows the gapless live stream. Creates, updates, deletes, identity changes, account status, and repo sync events are applied in database transactions. The public query excludes unlisted publications, inactive accounts, and records or actors marked locally hidden. Search uses a weighted PostgreSQL full-text index over title and description.

The consumer tolerates non-conforming metadata on historical identity, account, and sync markers because hosted archive history can contain legacy values such as an empty optional timestamp. Publication records still pass through Cartesium's own strict normalization before they can be indexed.

The service exposes GET /v1/publications, /healthz, and /readyz only on the private Compose network. SvelteKit's same-origin <base-path>/api/publications endpoint validates public query parameters, calls the private service, and constructs canonical reader and preview URLs. The browser never receives the database URL or hosted Jetstream credential. During initial indexing, API responses and /readyz include the durable backfill cursor and pinned target for diagnostics. They are deliberately not presented as a percentage: collection-filtered cursors jump across unrelated archive ranges and therefore do not measure elapsed or remaining work.

For the Compose deployment, configure a strong CARTESIUM_APPVIEW_DB_PASSWORD. Hosted Jetstream archive replay may additionally require CARTESIUM_APPVIEW_JETSTREAM_API_KEY; that variable is passed only to the AppView container. CARTESIUM_APPVIEW_URL defaults to http://appview:4100 for the web container. The PostgreSQL volume is named appview-postgres; retain it across deployments so replay resumes from the stored cursor rather than rebuilding the index.

The Jetstream v2 API key is archive authorization only. It authorizes the hosted archive snapshot/planning requests; it is not a cursor identity, repository identity, or substitute for the durable PostgreSQL cursor. The cursor is the Jetstream sequence persisted in appview_state.firehose_cursor. Live streaming is unauthenticated. Keep the key in the untracked deployment .env; deployment verification never prints .env, Compose's resolved configuration, database URLs, or Jetstream credentials.

AppView deployment verification and recovery #

deploy.sh.example starts the Compose services and then probes the private AppView GET /readyz endpoint from inside the appview container. Container health alone is not an indexing guarantee. The verifier accepts a normal running state while the first filtered archive backfill is in progress, then requires both state: complete and backfillComplete: true. It reports the durable cursor and pinned target as raw sequence values; those values are not a percentage because collection-filtered archive cursors do not measure elapsed or remaining work.

The first backfill may take substantially longer than a normal container start. The template waits up to 3,600 seconds by default, reports cursor/target changes while it waits, and fails with the last safe state plus service-status/log commands if the wait expires (a possible stall), AppView reports degraded, or /readyz is unreachable/invalid. Increase the bounded wait for a new or very large archive without changing the deployment topology:

CARTESIUM_APPVIEW_VERIFY_TIMEOUT=7200 \
CARTESIUM_APPVIEW_VERIFY_INTERVAL=15 \
CARTESIUM_DEPLOY_REMOTE=user@example.org ./deploy.sh test

The Compose-managed appview-postgres volume holds the migrations, indexed publications, cursor, pinned backfill target, and index state. Its Docker volume name is normally project-scoped (<compose-project>_appview-postgres), so keep the same Compose project (cartesium-test or cartesium-prod) to keep using the same state. up, image rebuilds, container replacement, and ordinary down retain a named volume. docker compose down --volumes (or removing that exact volume) destroys the cursor and index; it is a deliberate from-zero replay, not a routine deployment step. Do not delete it before taking a database backup.

For a backup, run from the remote deployment directory while the PostgreSQL service is available. The dump is written to the host running Docker and does not print the database password:

PROJECT=cartesium-test
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
BACKUP="appview-${PROJECT}-${STAMP}.sql"
docker compose -p "$PROJECT" exec -T postgres \
  sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > "$BACKUP"
test -s "$BACKUP"

Exercise a reset/replay against cartesium-test first. After the backup above has been checked, stop AppView and reset only the derived index tables and durable cursor. This preserves the named volume and its PostgreSQL service:

PROJECT=cartesium-test
docker compose -p "$PROJECT" stop appview web
docker compose -p "$PROJECT" exec -T postgres \
  sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
BEGIN;
TRUNCATE TABLE public.publications, public.actors;
UPDATE public.appview_state
SET firehose_cursor = 0,
    backfill_target = NULL,
    backfill_state = 'running',
    last_event_at = NULL,
    last_error = NULL,
    updated_at = now()
WHERE key = true;
COMMIT;
SQL
docker compose -p "$PROJECT" up --detach appview web

A fresh volume starts migrations at cursor zero. Leave the archive key in .env if the hosted Jetstream v2 archive requires it, then run the deployment verifier (or inspect /readyz) until the state is complete. Investigate Jetstream authorization and network access before repeating a degraded replay; inspect service output with:

docker compose -p "$PROJECT" logs --tail=100 appview

If the schema or volume itself is unrecoverable, a volume reset is a broader, destructive last resort: after a verified backup, confirm the exact Compose project and then remove all of that project's named volumes before bringing the services back:

PROJECT=cartesium-test
docker compose -p "$PROJECT" down --volumes
docker compose -p "$PROJECT" up --detach --build

This deletes the project-scoped appview-postgres volume and any other named volumes later added to that Compose project. Use it on cartesium-test first; do not use it for a routine deployment.

To restore the backup instead of replaying from zero, stop appview, prepare an empty target database, and only then pipe the schema-bearing dump to psql. Stop web too so no route can call AppView while the database is replaced:

PROJECT=cartesium-test
BACKUP=appview-cartesium-test-YYYYMMDDTHHMMSSZ.sql
docker compose -p "$PROJECT" stop appview web
docker compose -p "$PROJECT" exec -T postgres \
  sh -c 'dropdb --if-exists -U "$POSTGRES_USER" "$POSTGRES_DB" && createdb -U "$POSTGRES_USER" "$POSTGRES_DB"'
docker compose -p "$PROJECT" exec -T postgres \
  sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < "$BACKUP"
docker compose -p "$PROJECT" up --detach appview web

After a restore, run the verifier again. Keep the backup until the restored index has been checked.

Only after the test replay reaches complete and the test deployment serves /explore should the same procedure be considered for cartesium-prod; take a fresh production dump immediately before any production reset or replay. A normal deployment preserves the volume and resumes from its durable cursor.

The OAuth request is least-privilege: create/delete access to the exact configured publication collection(s) and uploads restricted to JSON and PNG. An existing OAuth session may need to authorize again after V2 is enabled so it receives the additional collection scope. For local OAuth testing, provide the collection environment variable and open Vite using its http://127.0.0.1:<port><base-path>/calc/2d URL; the OAuth callback still returns to http://127.0.0.1:<port><base-path>/. AT Protocol's development-client rules require an IP loopback redirect rather than localhost.

Spaces alpha #

Spaces are an optional alpha capability. Set CARTESIUM_SPACES_ENABLED=true and CARTESIUM_LEXICON_PREFIX=org.cartesium.dev for the development authority. The server derives the two names below and exposes baseline and Spaces-capable OAuth profiles. Sign-in tries the Spaces profile first and transparently falls back to baseline permissions when PAR rejects the exact Space scope. Private documents are shown only when that scope is present in the granted token:

space type:  <prefix>.calc.documents
collection:  <prefix>.calc.document

For the temporary authority these are org.cartesium.dev.calc.documents and org.cartesium.dev.calc.document. The checked-in schemas live at lexicons/org/cartesium/dev/calc/documents.json and lexicons/org/cartesium/dev/calc/document.json. The corresponding development V2 publication schema is also checked in beside them. It is a local source file only; this repository does not publish or deploy lexicons automatically.

The alpha client uses these XRPC methods:

  • com.atproto.simplespace.getSpace and createSpace inspect/create at://<account-did>/space/<prefix>.calc.documents/self.
  • com.atproto.space.listRecords, getRecord, getBlob, createRecord, putRecord, and deleteRecord read and mutate the private collection.

The first private write creates the personal Space with the alpha member-list policy and open app access required by the Cartesium client. A Space document record contains metadata (kind, title, and timestamps) plus one JSON blob; that blob is the existing portable CartesiumDocumentFileV1, including the optional 3D presentation. Updates replace the blob and retain the original creation timestamp. Reads validate the Space URI, policy, record URI, record type, blob media type/size, document kind, and document file before opening it. Before updating, Cartesium performs a best-effort preflight read immediately before uploading the replacement blob and compares the record CID held by the editor with the current CID. A mismatch is refused and can be reloaded or saved as a new private copy. The alpha API has no conditional/CAS write, so a small non-atomic race window remains between that read and putRecord; this is conflict detection rather than a complete concurrency guarantee.

Space access is not encryption or a promise that no infrastructure can observe the data. It is the persistence boundary for private account documents in this alpha. Public publication discoverability is an independent dimension: an unlisted record may be in a Space in a future product, while a public publication may be listed or unlisted. Cartesium currently uses Spaces only for the signed-in user's personal document library; member invitations, classroom rosters, restricted browsing, feeds, search indexing, and collaborative presence are future layers rather than assumptions in this lexicon.

Every NSID segment except the last is part of the reversed DNS authority. For example, the placeholder org.example.cartesium.publicationV2 is authorized by the _lexicon.cartesium.example.org TXT record. After choosing the real NSID, place its schema under the matching lexicons/<segments...>.json path, point that authority's _lexicon TXT record to the publishing account DID, sign in with goat account login, and run goat lex publish from this directory.