Faces and edges
Most map editors store each polygon on its own. Two parcels that share a boundary then hold that boundary twice, and nothing keeps the two copies equal: move one and you get a gap or an overlap. vectorstream stores a planar partition instead.
The model#
A partition consists of three kinds of elements:
- Nodes — points with coordinates (
n1,n2, …). - Edges — straight segments between two nodes, each stored as a pair of half-edges, one
per direction (
h1,h2, …). The half-edge knows the face on its left. - Faces — the areas the edges enclose (
f1,f2, …). Every partition has exactly one outer face (partition.outerFace), the unbounded area around everything; it has no outline.
The plane is divided without gaps and without overlaps: every location belongs to exactly one face. A boundary between two faces exists once, as one edge, and both faces use it.
What this means in practice:
- Shared boundaries move together. Dragging a vertex on a shared boundary changes both faces.
- Drawing across faces splits them. An outline drawn over two parcels does not lie on top of them; it divides them. The parcels keep the parts outside the outline, and the parts inside become new faces.
- Drawing the same outline twice changes nothing. If an outline coincides with what is
already there, no operation is applied, and the editor reports
unchangedinstead ofchange. - A crossing outline is not rejected. An outline that crosses itself (a bow tie) is split into the cells it encloses.
- Lines and points are part of the same partition. A line is a chain of edges with the same face on both sides; a point is a node without edges. Both live inside a face. A line that runs across a face from boundary to boundary splits it.
- Holes and islands. A face can contain holes, and faces inside holes. GeoJSON export writes holes as inner rings.
In code#
The example below uses only @vectorstream/core: it imports the two sample parcels, then inserts
an outline across their shared boundary inside a transaction — exactly what the editor does when
you draw.
import {
History,
SpatialIndex,
checkGeometry,
importGeoJSON,
insertPolygon,
type FaceId,
type FeatureCollection,
type Partition,
type Point,
} from '@vectorstream/core';
/**
* The core without editor and map: a partition, a history, and an intent inside a transaction.
* The editor does the same for every drawing, with the same checks.
*/
export function insertOutline(data: FeatureCollection, outline: Point[]): { partition: Partition; history: History; faces: FaceId[] } {
const partition = importGeoJSON(data);
const history = new History(partition, {
checkInvariants: true, // structure: half-edge pairs, closed cycles, Euler's formula, …
validators: [(p) => checkGeometry(p, new SpatialIndex(p), 0)], // geometry: crossings, overlaps
});
// All or nothing: if a check fails, the transaction is rolled back and throws.
const faces = history.transaction('insert-outline', (tx) => insertPolygon(tx, outline));
return { partition, history, faces };
}
The test of this example checks what the list above says: the outline returns two new faces (one piece per parcel), the partition then has four bounded faces, each piece carries the attributes of the parcel it was cut from, the same outline inserted again applies no operation, and one undo restores the original.
Attributes belong to elements#
Attributes ({ parcel: '101', use: 'field' }) are stored in a side table keyed by element ID —
faces, nodes and edges can all carry them. When a face is split, both pieces keep its attributes,
except the label text and its style keys, which name one specific object. See
Labels and attributes.
The coordinate grid#
Every node coordinate lies on a grid with mesh size 2−26 (about 1.5 · 10−8
coordinate units; with longitude/latitude that is between one and two millimeters at 51.5°
north). Inserted
and imported coordinates are rounded onto it, so 51.51 is stored as 51.51000000536442. All
geometric decisions — crossings, snapping of crossing points, area signs — are made on these
rounded values, which keeps them exact and repeatable.
The exponent is chosen per partition: importGeoJSON(data, { gridExponent }) or
createPartition(ids, gridExponent). Choose a finer grid for data with features below the default
mesh size; coordinates that are too large for the chosen grid are rejected on import. Layers that
exchange faces must use the same exponent; see Layers.
Coordinates and units#
The core knows no projection. It computes in the numbers it is given, and all three map adapters
give it longitude as x and latitude as y. Lengths and areas shown to the user (the measurement
values) are computed by the adapter, in meters on the earth for the maps' default projections.
Read on: Validity and rejections, Snapping,
API reference: Partition.