telecanvas
v0.1.0
Published
Shared remote SVG canvas: draw in real physical units (mm) on any networked display
Maintainers
Readme
TeleCanvas
Shared remote SVG canvas: draw in real physical units (mm) on any networked display.
Run a tiny host server, open its URL on a TV / projector / any browser, and
push SVG markup to it over HTTP. Every connected viewer mirrors the canvas
live. After a one-minute on-screen calibration, one SVG unit is exactly one
millimeter on the physical display — an 80-unit-wide marker is 80 mm wide
on glass.
Quick start
npx telecanvas
# [INFO] TeleCanvas listening at http://localhost:8100/
# [INFO] TeleCanvas listening at http://192.168.1.23:8100/Open the URL on the display, then draw from anywhere:
curl -X PUT --data-raw '<circle r="40" fill="red"/>' http://192.168.1.23:8100/Coordinate contract
The viewer computes its SVG viewBox so that:
- 1 SVG unit = 1 mm on the physical display (once calibrated),
- the origin is at the center of the viewport,
- y grows downward (standard SVG orientation).
A 1080p TV that is 940 mm wide shows roughly x ∈ [-470, 470]. Content is
not scaled to fit — it is drawn physically true, which is the point.
Wire contract
PUT / with the canvas' inner SVG markup as the raw text body. The
server stores it as the current canvas, answers 200, and broadcasts it to
every connected viewer. CORS is permissive (Access-Control-Allow-Origin: *,
PUT allowed), so browsers can push cross-origin. Each PUT replaces the whole
canvas; push the full document every time (coalescing rapid updates is the
client's job — see TeleCanvasClient).
Viewers subscribe via WebSocket on the same port and receive each update as
one line-framed JSON string. This viewer transport is package-internal and
may change; the PUT / contract is stable.
CLI
Usage: telecanvas [options]
-p, --port <n> Port to listen on (default: 8100)
--host <addr> Address to bind (default: all interfaces)
--persist <file> File persisting canvas content across restarts
(default: telecanvas-buffer.txt in the working directory)
--no-persist Disable persistence
--kiosk Also open the viewer fullscreen in a kiosk browser
-h, --help Show this help
-v, --version Print version--kiosk launches an installed Chromium-family browser (Chrome, Chromium,
Edge, Brave) in chromeless fullscreen with a throwaway profile, and closes it
together with the server on Ctrl-C. Without one installed, the default
browser opens as a regular window — press F11 yourself.
Library API
import { createServer, push, TeleCanvasClient } from 'telecanvas';
// Host a canvas programmatically
const server = await createServer({ port: 8100, persist: 'canvas.txt' });
console.log(server.urls); // reachable viewer URLs
server.push('<rect x="-40" y="-25" width="80" height="50" fill="cyan"/>');
console.log(server.content); // current canvas markup
await server.close(); // flushes persistence, disconnects viewers
// One-shot push to any TeleCanvas server (Node >= 18 or browser)
await push('http://192.168.1.23:8100/', '<circle r="40"/>');
// Continuous pusher: coalesces rapid updates so at most one PUT is in
// flight and only the newest frame is sent next — safe to call per
// animation frame
const client = new TeleCanvasClient('http://192.168.1.23:8100/');
client.push('<circle r="41"/>');
await client.clear();createServer options: port (default 8100, 0 for ephemeral), host,
persist (file path; omit to disable), staticDir (serve your own viewer
directory instead of the built-in page).
The viewer page is embedded in the server bundle itself, so the package stays self-contained even when another app bundles it (vite, rollup, esbuild, Electron) — no static files need to survive the build.
subscribe(url, onUpdate) taps the live viewer feed (browser or Node ≥ 22):
onUpdate receives the current canvas markup on connect and on every
update, with automatic reconnection until the returned dispose function is
called.
Embedding a viewer in an app
mountView attaches a live, view-only viewer to any element — no
framework dependency:
import { mountView } from 'telecanvas';
// Fills its container; CSS px map to mm at the 96 dpi reference
const view = mountView(container, { src: 'http://192.168.1.23:8100/' });
// Emulate a fixed-size external display (mm). One dimension given, the
// other follows the container's aspect ratio; both given, the emulated
// display keeps its aspect and letterboxes inside the container
view.update({ width: 940, height: 529 });
view.unmount();The injected <svg> follows the mm-centered coordinate contract and is
owned entirely by mountView, so the container may live inside a
framework-managed DOM tree — frameworks never reconcile children they
didn't render. Keep the container a dedicated leaf (no template children
on it). In Vue:
<script setup>
import { mountView } from 'telecanvas';
const el = ref();
let view;
onMounted(() => (view = mountView(el.value, { src, width: 940 })));
onBeforeUnmount(() => view.unmount());
</script>
<template><div ref="el" class="preview" /></template>Scale calibration
Browsers guess the display's physical size and are usually wrong on TVs and
projectors, so the viewer shows an “Uncalibrated” hint until you
calibrate. Open the calibration overlay with the 📏 button in the corner,
the c key, or by loading the page with ?calibrate. Three methods:
- Ruler (most accurate) — the overlay renders a bar of known pixel width; measure it with a ruler or tape, type the length in mm.
- Credit card — hold any ISO ID-1 card (85.60 × 53.98 mm) against the screen and drag the outline until it matches. No tools needed.
- Diagonal — type the advertised panel diagonal in inches. Least accurate; fine as a starting estimate.
Each method shows the computed px/mm and the implied display dimensions as
immediate sanity feedback. Saving stores the scale in localStorage
(per-origin, per-device). The historical devtools interface still works as
an escape hatch: window.scale = 4.5 (px per mm), assign garbage to clear.
Development
npm install
npm test # vitest
npm run build # typecheck + rollup → dist/
node dist/cli.js