Documentation

Pro handbook

Introduction

vectorstream is a geometry editor for web maps that keeps your data topologically clean. It does not store polygons that happen to share coordinates. It stores one planar partition: every boundary exists once and is shared by the faces on both sides of it. Moving a shared boundary changes both neighbors. Drawing across existing faces splits them. Two faces never overlap.

The editor runs on Leaflet, OpenLayers and MapLibre GL JS. The editing logic is the same on all three; only the adapter that talks to the map differs.

Not yet published on npm. The package names below are fixed, but no release exists yet. The packages live in this repository as a pnpm workspace. Each package's exports names three files: a source condition that points at the TypeScript sources (what the workspace, its tests and Vite resolve), and types and import that point at dist/, which npm run build writes. From the workspace, use the sources with a bundler that compiles TypeScript, such as Vite.

The packages#

Package What it contains
@vectorstream/core The topology model (nodes, half-edges, faces), the operations and intents that change it, the undo history, validation, GeoJSON import and export, and the translation mechanism. No DOM, no map.
@vectorstream/adapter The interface between editor and map (Adapter), the scene description, shared drawing styles, and NullAdapter — an adapter without a map for tests and headless use.
@vectorstream/editor The Editor, all editing modes (tools), and the Toolbar state.
@vectorstream/leaflet LeafletAdapter — draws the scene on a Leaflet map. Peer dependency leaflet ^1.9.4.
@vectorstream/openlayers OpenLayersAdapter — the same for OpenLayers. Peer dependency ol ^10.10.0.
@vectorstream/maplibre MapLibreAdapter — the same for MapLibre GL JS. Peer dependency maplibre-gl ^6.11.0.

An application uses core, editor and one of the three map packages. Each map package brings adapter along as its own dependency.

A seventh package, @vectorstream/toolbar-ui, is optional: ready-made toolbar buttons for Toolbar (below), for an application that would rather not draw its own — see Toolbar.

An eighth package, @vectorstream/pro, is not one of the free packages: it is an extension of the editor that adds more kinds of rule and a few tools. What is free and what is Pro is the subject of Free and Pro; the interface that Pro plugs into is described in Extensions.

How the pieces work together#

  1. The map is yours: you create a Leaflet, OpenLayers or MapLibre map as usual.
  2. The adapter wraps the map. It draws what the editor asks for and reports pointer and key events. Coordinates are longitude (x) and latitude (y) on all three maps.
  3. The editor holds one or more layers, each a partition, plus the undo history. It passes pointer events to the active mode (the current tool) and redraws after every change.
  4. The toolbar is the state of a tool bar, not a UI: which tools exist, which is active, and which key selects which. You draw the buttons. See Toolbar.
  5. Events tell your application what happened: change, unchanged or rejected after every edit, plus selection, mode and layer changes. See Events.

Quick start#

The code differs only in how the map and the adapter are created. The same example for each map:

leaflet
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { exportGeoJSON, importGeoJSON, type FeatureCollection } from '@vectorstream/core';
import { Editor, Toolbar, defaultToolbarEntries } from '@vectorstream/editor';
import { LeafletAdapter } from '@vectorstream/leaflet';

export function startEditor(container: HTMLElement, data: FeatureCollection) {
  // 1. The map. x is longitude, y is latitude; Leaflet takes [lat, lng].
  const map = L.map(container).setView([51.5112, 7.4555], 17);
  L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
    maxZoom: 19,
    attribution: '© OpenStreetMap contributors',
  }).addTo(map);

  // 2. The adapter draws the editor's scene on the map and reports pointer and key events.
  const adapter = new LeafletAdapter(map);

  // 3. The editor works on a partition — here one built from GeoJSON.
  const editor = new Editor(importGeoJSON(data), adapter);

  // 4. The toolbar is state, not UI: it knows the tools and their shortcut keys.
  const toolbar = new Toolbar(editor, defaultToolbarEntries());
  toolbar.select('draw');

  // 5. Every transaction ends in exactly one of three events.
  editor.on('change', ({ label }) => console.log('changed:', label));
  editor.on('unchanged', ({ label }) => console.log('nothing to do:', label));
  editor.on('rejected', ({ label, violations }) => {
    console.warn('rejected:', label, violations.map((v) => v.detail));
  });

  return {
    map,
    editor,
    toolbar,
    // 6. Export the active layer as GeoJSON.
    save: (): FeatureCollection => exportGeoJSON(editor.partition),
    // 7. Undo and redo; `false` means there was nothing to do, or the step was rejected.
    undo: (): boolean => editor.undo(),
    redo: (): boolean => editor.redo(),
    destroy(): void {
      toolbar.destroy();
      editor.destroy();
      adapter.destroy();
      map.remove();
    },
  };
}
openlayers
import OlMap from 'ol/Map.js';
import View from 'ol/View.js';
import TileLayer from 'ol/layer/Tile.js';
import OSM from 'ol/source/OSM.js';
import { fromLonLat } from 'ol/proj.js';
import 'ol/ol.css';
import { exportGeoJSON, importGeoJSON, type FeatureCollection } from '@vectorstream/core';
import { Editor, Toolbar, defaultToolbarEntries } from '@vectorstream/editor';
import { OpenLayersAdapter } from '@vectorstream/openlayers';

export function startEditor(container: HTMLElement, data: FeatureCollection) {
  // 1. The map. The view stays in Web Mercator; the adapter converts to longitude/latitude.
  const map = new OlMap({
    target: container,
    layers: [new TileLayer({ source: new OSM() })],
    view: new View({ center: fromLonLat([7.4555, 51.5112]), zoom: 17 }),
  });

  // 2. The adapter draws the editor's scene on the map and reports pointer and key events.
  const adapter = new OpenLayersAdapter(map);

  // 3. The editor works on a partition — here one built from GeoJSON.
  const editor = new Editor(importGeoJSON(data), adapter);

  // 4. The toolbar is state, not UI: it knows the tools and their shortcut keys.
  const toolbar = new Toolbar(editor, defaultToolbarEntries());
  toolbar.select('draw');

  // 5. Every transaction ends in exactly one of three events.
  editor.on('change', ({ label }) => console.log('changed:', label));
  editor.on('unchanged', ({ label }) => console.log('nothing to do:', label));
  editor.on('rejected', ({ label, violations }) => {
    console.warn('rejected:', label, violations.map((v) => v.detail));
  });

  return {
    map,
    editor,
    toolbar,
    // 6. Export the active layer as GeoJSON (longitude/latitude, not Web Mercator).
    save: (): FeatureCollection => exportGeoJSON(editor.partition),
    // 7. Undo and redo; `false` means there was nothing to do, or the step was rejected.
    undo: (): boolean => editor.undo(),
    redo: (): boolean => editor.redo(),
    destroy(): void {
      toolbar.destroy();
      editor.destroy();
      adapter.destroy();
      map.setTarget(undefined);
    },
  };
}
maplibre
import * as maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { exportGeoJSON, importGeoJSON, type FeatureCollection } from '@vectorstream/core';
import { Editor, Toolbar, defaultToolbarEntries } from '@vectorstream/editor';
import { MapLibreAdapter } from '@vectorstream/maplibre';

/** A raster style with OpenStreetMap tiles. Any MapLibre style works. */
export const osmStyle: maplibregl.StyleSpecification = {
  version: 8,
  sources: {
    osm: {
      type: 'raster',
      tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
      tileSize: 256,
      attribution: '© OpenStreetMap contributors',
    },
  },
  layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
};

export function startEditor(container: HTMLElement, data: FeatureCollection, style = osmStyle) {
  // 1. The map. MapLibre counts zoom in 512-pixel tiles: 16 here shows what 17 shows in Leaflet.
  const map = new maplibregl.Map({ container, style, center: [7.4555, 51.5112], zoom: 16 });

  // 2. The adapter may be created right away; it adds its sources and layers once the style loads.
  const adapter = new MapLibreAdapter(map);

  // 3. The editor works on a partition — here one built from GeoJSON.
  const editor = new Editor(importGeoJSON(data), adapter);

  // 4. The toolbar is state, not UI: it knows the tools and their shortcut keys.
  const toolbar = new Toolbar(editor, defaultToolbarEntries());
  toolbar.select('draw');

  // 5. Every transaction ends in exactly one of three events.
  editor.on('change', ({ label }) => console.log('changed:', label));
  editor.on('unchanged', ({ label }) => console.log('nothing to do:', label));
  editor.on('rejected', ({ label, violations }) => {
    console.warn('rejected:', label, violations.map((v) => v.detail));
  });

  return {
    map,
    editor,
    toolbar,
    // 6. Export the active layer as GeoJSON.
    save: (): FeatureCollection => exportGeoJSON(editor.partition),
    // 7. Undo and redo; `false` means there was nothing to do, or the step was rejected.
    undo: (): boolean => editor.undo(),
    redo: (): boolean => editor.redo(),
    destroy(): void {
      toolbar.destroy();
      editor.destroy();
      adapter.destroy();
      map.remove();
    },
  };
}

Each getting-started page explains its map's details: Leaflet, OpenLayers, MapLibre.

Where to read on#

  • Faces and edges — what "topologically clean" means in practice.
  • Modes — every tool, its gesture, keys and options.
  • GeoJSON import and export — getting data in and out.
  • Rules between layers — geofencing: the rules within and overlap-free; more kinds come with vectorstream Pro.
  • Free and Pro — what the free packages do, what Pro adds, and what happens to a document with Pro rules.
  • Extensions — the extension interface and a kind of rule of your own.
  • Language — the libraries speak English; German and others are opt-in.
  • API reference — generated from the source comments.

See it on a map.