<bmx-geo-editor>
Edits map features - points, lines and polygons with holes, and several of each in one feature - and their attributes, in the page, with nothing sent anywhere. Draw with snapping to vertices and edges; select, move and reshape (drag vertices, drag a midpoint to add one, delete them); cut holes and add parts; split along a drawn line, reshape with one, merge, separate parts, buffer by metres and simplify; topology checks (crossings, overlaps, gaps between neighbours) with fixes. The attribute table is a BMX data grid with typed fields (text, numbers, whole numbers, yes/no, dates), in step with the map both ways; fields are added, renamed, retyped and removed in the panel. Lengths and areas are geodesic, on the WGS84 ellipsoid.
15 properties · 4 events · 26 methods · 5 parts
Example
Try it with your own map files in confidence: they are opened by your browser, on your device. Nothing is uploaded, no copy reaches us or anyone else, and nothing is kept once you close this tab.
Show markup
<bmx-geo-editor id="ex-geo" label="Hollow Farm - field boundaries"></bmx-geo-editor>
<p class="note" id="ex-geo-out" role="status" style="margin: 12px 0 0"></p>
<bmx-geo-editor id="ex-grid" label="Street lights - British National Grid" table="false" style="margin-block-start: 24px"></bmx-geo-editor>
<p class="note" id="ex-grid-out" role="status" style="margin: 12px 0 0"></p>
<script type="module">
await customElements.whenDefined('bmx-geo-editor');
// An invented farm: field boundaries in longitude and latitude, one with a pond cut out of it,
// drawn over a background of invented roads, a stream and woodland. Hill Field was drawn over the
// edge of Top Piece: the Issues tab shows the overlap and fixes it.
const at = (x, y) => [-1.75 + x * 0.0016, 52.05 + y * 0.001];
const field = (id, name, crop, organic, surveyed, ring, holes = []) => ({
type: 'Feature',
id,
properties: { name, crop, organic, surveyed },
geometry: { type: 'Polygon', coordinates: [ring.map(([x, y]) => at(x, y)), ...holes.map(h => h.map(([x, y]) => at(x, y)))] },
});
const editor = document.getElementById('ex-geo');
editor.background = [
{
name: 'Roads, stream and woods',
data: {
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { fill: 'rgba(76, 145, 65, 0.28)', stroke: 'rgba(76, 145, 65, 0.6)' }, geometry: { type: 'Polygon', coordinates: [[at(-1, 7), at(3, 8), at(4, 11), at(-1, 11), at(-1, 7)]] } },
{ type: 'Feature', properties: { fill: 'rgba(76, 145, 65, 0.28)', stroke: 'rgba(76, 145, 65, 0.6)' }, geometry: { type: 'Polygon', coordinates: [[at(11, -2), at(14, -2), at(14, 2), at(12, 1), at(11, -2)]] } },
{ type: 'Feature', properties: { stroke: '#7fb2e5', 'stroke-width': 3 }, geometry: { type: 'LineString', coordinates: [at(-1, 2.5), at(2, 3.2), at(5, 2.4), at(8, 3.6), at(11, 3), at(14, 4)] } },
{ type: 'Feature', properties: { stroke: '#d9a441', 'stroke-width': 4 }, geometry: { type: 'LineString', coordinates: [at(-1, -1), at(14, -1.2)] } },
{ type: 'Feature', properties: { stroke: '#c9ccd1', 'stroke-width': 2 }, geometry: { type: 'LineString', coordinates: [at(6.5, -1.1), at(6.4, 4), at(6.8, 9)] } },
],
},
},
];
editor.layer = {
type: 'FeatureCollection',
name: 'Hollow Farm',
features: [
field(1, 'Long Ground', 'Winter wheat', false, '2026-05-14', [[0, 0], [6, 0], [6, 2.4], [0, 2]], [[[2, 0.6], [2.8, 0.6], [2.8, 1.2], [2, 1.2]]]),
field(2, 'Brook Meadow', 'Permanent grass', true, '2026-06-02', [[0, 2.6], [6, 2.9], [6, 6], [0, 6.4]]),
field(3, 'Top Piece', 'Spring barley', false, '2026-04-21', [[7, 0], [12, 0.2], [12, 3.2], [7, 3.4]]),
field(4, 'Hill Field', 'Oilseed rape', false, null, [[7, 3.3], [12, 3.1], [12.5, 7], [7, 7.5]]),
field(5, 'Little Close', 'Permanent grass', true, '2026-06-02', [[0, 6.8], [3, 6.6], [3, 8.6], [0, 9]]),
],
};
const out = document.getElementById('ex-geo-out');
const report = async () => {
const layer = await editor.getLayer();
let area = 0;
for (const f of layer.features) area += (await editor.measure(f.id))?.area ?? 0;
out.textContent = `${layer.features.length} fields, ${(area / 10000).toFixed(2)} ha in all (geodesic, on the WGS84 ellipsoid).`;
};
editor.addEventListener('bmxChange', report);
await report();
// Street lights as a council keeps them: a CSV of eastings and northings on the British National Grid.
// The editor reads the grid from the column names, draws the lights on the map and keeps the numbers as they are.
const grid = document.getElementById('ex-grid');
const rows = ['Asset,Easting,Northing,Lamp,Height (m),Installed'];
for (let i = 0; i < 18; i += 1) rows.push(`SL-${String(i + 1).padStart(3, '0')},${(431200 + (i % 6) * 42.5).toFixed(1)},${(206400 + Math.floor(i / 6) * 55 + (i % 2) * 6).toFixed(1)},${i % 3 ? 'LED 35W' : 'LED 50W'},${i % 3 ? 6 : 8},2025-0${1 + (i % 9)}-15`);
await grid.load(rows.join('\n'));
grid.coordinates = 'degrees';
const gridOut = document.getElementById('ex-grid-out');
grid.addEventListener('bmxSelect', e => {
const f = e.detail.features[0];
gridOut.textContent = f ? `${f.properties.Asset}: easting ${f.geometry.coordinates[0]}, northing ${f.geometry.coordinates[1]} - ${f.properties.Lamp}` : '';
});
</script>
Reads and writes GeoJSON, Shapefiles (with their .dbf table, .prj system and character set, loose or zipped), KML and KMZ, GPX and CSV (coordinate columns or WKT). Coordinates stay in the system they came in - longitude and latitude, Web Mercator, UTM, British National Grid, the Irish grids, Lambert-93 or any Transverse Mercator, Mercator or Lambert Conformal Conic a .prj describes - and can be converted to another. The map is drawn over the page's own GeoJSON as background layers; no map tiles are fetched.
<bmx-geo-editor src="/data/fields.geojson"></bmx-geo-editor>
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
background |
background |
BmxGeoEditorBackground[] | Record<string, unknown> | string |
— | Layers drawn under the edited one: a list of { name, src | data, fill, stroke }, GeoJSON, or the address of a GeoJSON file. |
coordinates |
coordinates |
string |
'layer' |
How the pointer's coordinates are shown: layer (the layer's own system), degrees, dms, or an EPSG code such as EPSG:27700. Two-way. |
gapLimit |
gap-limit |
number |
10000 |
Gaps between polygons up to this many square metres are reported (larger ones are taken as meant). |
label |
label |
string |
— | A heading for the editor (the layer's name when not given). |
layer |
layer |
BmxGeoLayer | Record<string, unknown> | string | null |
null |
The layer: features, fields and coordinate system, as an object, GeoJSON or JSON text. Two-way: bmxChange reports every edit. |
overlapTolerance |
overlap-tolerance |
number |
0.01 |
Overlaps between polygons smaller than this many square metres are not reported. |
panel |
panel |
boolean |
true |
Show the side panel. Two-way. |
readonly |
readonly |
boolean |
false |
Show the layer without changing it. |
snap |
snap |
boolean |
true |
Snap new and moved vertices to the vertices and edges nearby. Two-way. |
snapTolerance |
snap-tolerance |
number |
10 |
How near, in pixels, a vertex or edge must be to snap to. |
src |
src |
string |
— | An address to read a map file from (the page's own server): GeoJSON, a zipped Shapefile, KML, KMZ, GPX or CSV. |
strings |
strings |
Partial<BmxGeoEditorStrings> | string |
— | Words for translation: an object or JSON. |
table |
table |
boolean |
true |
Show the attribute table under the map. Two-way. |
tool |
tool |
BmxGeoEditorTool |
'select' |
The tool in use. Two-way. |
toolbar |
toolbar |
boolean |
true |
Show the toolbar. |
Events
| Event | Detail | Description |
|---|---|---|
bmxChange |
{ readonly layer: BmxGeoLayer; } |
Fired after every change, with the layer. |
bmxError |
{ readonly message: string; } |
Fired when something cannot be read or written. |
bmxLoad |
{ readonly format: string; readonly features: number; readonly notes: readonly string[]; } |
Fired when a file has been read, with what was found and noted. |
bmxSelect |
{ readonly ids: readonly string[]; readonly features: readonly BmxGeoFeature[]; } |
Fired when the selection changes, with the selected features' ids. |
Methods
| Method | Signature | Description |
|---|---|---|
addFeature |
addFeature(feature: { id?: string; geometry?: unknown; properties?: Record<string, unknown>; } | BmxGeoGeometry) => Promise<string> |
Adds a feature (a GeoJSON Feature or a geometry), in the layer's system. Resolves with its id. |
buffer |
buffer(metres: number, ids?: readonly string[]) => Promise<string[]> |
Adds, for each feature (the selection when none are given), a polygon of everything within metres of it (negative: a polygon shrunk), with its attributes. Resolves with the new ids. |
check |
check() => Promise<BmxGeoIssue[]> |
Everything wrong with the features. |
checkTopology |
checkTopology(options?: BmxGeoTopologyOptions) => Promise<BmxGeoTopologyIssue[]> |
How the layer's features sit: crossings, overlaps, gaps between neighbours, duplicates. |
explode |
explode(ids?: readonly string[]) => Promise<string[]> |
Makes each part of multi-part features (the selection when none are given) a feature of its own. Resolves with the ids. |
export |
export(kind: BmxGeoEditorExport, options?: { crs?: "wgs84" | "keep"; }) => Promise<Blob> |
A file of the layer: GeoJSON, a Shapefile (ZIP), KML, KMZ, GPX, CSV, or a picture of the map (PNG). |
fit |
fit() => Promise<void> |
Shows the whole layer (and the background layers when the layer is empty). |
fixTopology |
fixTopology(issue: BmxGeoTopologyIssue) => Promise<void> |
Fixes a topology issue: cleans a crossing polygon, cuts an overlap from the later feature, fills a gap from the neighbour sharing the longest edge. |
getLayer |
getLayer() => Promise<BmxGeoLayer> |
The layer as it stands (a copy). |
getSelection |
getSelection() => Promise<string[]> |
The selected features' ids. |
load |
load(data: Blob | readonly Blob[] | FileList | BmxGeoLayer | Record<string, unknown> | string) => Promise<void> |
Opens a map file or files (a File, a list of Files such as a Shapefile's parts, a ZIP or KMZ), or a layer or GeoJSON object or text. |
measure |
measure(id: string) => Promise<{ length: number; area: number; } | null> |
A feature's geodesic length (or perimeter) in metres and area in square metres. |
merge |
merge(ids?: readonly string[]) => Promise<string | null> |
Merges features (the selection when none are given) of one kind into the first: polygons dissolved, lines joined, points gathered. Resolves with its id, or null. |
redo |
redo() => Promise<boolean> |
|
removeFeatures |
removeFeatures(ids: readonly string[]) => Promise<number> |
Removes features by id. Resolves with how many went. |
reproject |
reproject(crs: string) => Promise<void> |
Converts the layer into another coordinate system ('EPSG:27700', WKT ...). |
reshape |
reshape(id: string, line: readonly BmxGeoPosition[]) => Promise<boolean> |
Reshapes a line or polygon with a drawn line (positions in the layer's system) that starts on it (and for a polygon ends on it). |
save |
save(kind?: BmxGeoEditorExport, fileName?: string) => Promise<void> |
Downloads a file of the layer (see export). |
select |
select(ids: readonly string[]) => Promise<void> |
Selects features by id (an empty list clears the selection). |
setFocus |
setFocus() => Promise<void> |
|
setLayer |
setLayer(layer: BmxGeoLayer | Record<string, unknown> | string) => Promise<void> |
Replaces the layer (a layer object, GeoJSON or JSON text); undo history is cleared. |
simplify |
simplify(metres: number, ids?: readonly string[]) => Promise<number> |
Simplifies features (the selection when none are given) within metres. Resolves with how many vertices went. |
split |
split(line: readonly BmxGeoPosition[], ids?: readonly string[]) => Promise<string[]> |
Splits features along a line (positions in the layer's system): the given ones, the selection, or every one it crosses. Resolves with the ids of the pieces. |
undo |
undo() => Promise<boolean> |
|
updateFeature |
updateFeature(id: string, patch: { geometry?: BmxGeoGeometry | null; properties?: Record<string, unknown>; }) => Promise<boolean> |
Changes a feature's geometry or attributes (attributes merged). |
zoomTo |
zoomTo(ids?: readonly string[]) => Promise<void> |
Shows features (by id; the selection when none are given) or everything. |
CSS shadow parts
| Part | Description |
|---|---|
panel |
The side panel (feature, fields, layer, issues). |
stage |
The map. |
status |
The line under the map. |
table |
The attribute table under the map. |
toolbar |
The bar of commands. |
CSS custom properties
| Property | Description |
|---|---|
--bmx-geo-editor-background |
The map's background (where nothing is drawn). |
--bmx-geo-editor-background-fill |
Areas of the background layers. |
--bmx-geo-editor-background-stroke |
Lines and outlines of the background layers. |
--bmx-geo-editor-fill |
The edited layer's areas. |
--bmx-geo-editor-handle |
Vertex handles and the line being drawn. |
--bmx-geo-editor-height |
The height of the map and table together. |
--bmx-geo-editor-highlight |
Selected features. |
--bmx-geo-editor-highlight-fill |
Selected areas. |
--bmx-geo-editor-issue |
An issue's overlap or gap, and the ring round its place. |
--bmx-geo-editor-issue-fill |
An issue's overlap or gap, filled. |
--bmx-geo-editor-panel-width |
The side panel's width. |
--bmx-geo-editor-point-stroke |
The ring round each point. |
--bmx-geo-editor-radius |
Corner radius of the frame. |
--bmx-geo-editor-stroke |
The edited layer's lines, outlines and points. |
--bmx-geo-editor-table-height |
The attribute table's height. |