A standalone, embeddable map editor for VersaTiles. Draw and style markers, lines, circles and polygons on a vector map and share the result via a self-contained URL hash.
This is a SvelteKit application built with MapLibre GL and @versatiles/style. It was extracted from node-versatiles-svelte to be developed on its own.
- Draw markers, lines, polygons and circles. Drag an element or its nodes to change it, select a node to delete it, and Shift-click to select several elements.
- Style them: symbols with color, size, rotation, halo and a label, with colors of their own for the text and the halo, and one font for all labels, which can differ from the font of the background map; lines and outlines with color, width and dash patterns; fills with color, opacity and patterns. Elements can have a popup text, and lines, polygons and circles show their length, area or radius.
- Edit quickly: undo and redo, duplicate (Cmd/Ctrl+D), copy and paste a style (Cmd/Ctrl+Alt+C/V), delete (Delete/Backspace).
- Background map: vector map or satellite imagery, with or without streets, borders and labels, with a theme, a label font in any face of the tile server (e.g. bold or condensed), a label language, fewer or no labels, and changed saturation, brightness and contrast.
- Legend with a position, a layout and a font. New entries start with a color of the map that the legend does not show yet.
- Search for addresses and places.
- Import and export GeoJSON and KML, and import tables (CSV/TSV) as markers, with colors and symbols by category.
- Save the map as a file and open it again. The editor keeps the map, its undo history and the view in the browser, so a reload keeps the work. A map that is opened shows all its elements; where its author looked is not part of the map.
- Share a link or embed the map in a website, with a selectable accuracy. The author decides what visitors see over the map (a search, the legend, a scale bar, buttons to zoom, to reset the view, for the whole screen and for their location) and what they can do with it (move, zoom, rotate, tilt, within limits). Shared links and embedded maps open the read-only viewer, which phones also see in place of the editor.
- Configurable color schemes and fonts for an organisation, see Configuration.
npm install
npm run dev # start the dev server
npm run build # build the static site (adapter-static)
npm run preview # preview the production buildThe code is in src/lib, in folders by layer: each one uses the ones below it, none uses code of
one above it (only interaction/, state/ and document/ name the type of the editor's
document). From the top:
-
app/: the two pages:MapEditor.sveltethe editor (/) andMapViewer.sveltethe read-only viewer (/view/), both around the map inMapFrame.svelte, with the layout and the theme -
components/: the Svelte components:ui/generic controls,pickers/for a color, a font or a symbol of the map,shell/the frame of the editor,inspector/the sidebar,dialogs/,table_import/,map_viewer/what floats over the map (also in the viewer),map_editor/(only in the editor), andcommon/the helpers of several of them -
sessions/: the maps of the browser storage, kept in step with the open map -
files/: opening, saving and downloading maps as files -
editor/: the map document of the editor, with its handlers, the commands on the selected elements, the style clipboard and the palette of used colors; only the editor loads it -
interaction/: selecting, moving, reshaping and drawing with the mouse and fingers -
state/: the history with undo and redo, and the legend that follows the styles -
document/: the map document of the viewer and the editor: elements, background and legend on the map -
rendering/: how the map is drawn: the layers of the elements, the editor's layers over the background map, and loading this style -
element/,style/: the elements with their geometry and texts, and their styles -
background/: the background map, its style, its symbols and fonts, and the configuration -
in
src/libitself: the helpers that all layers may use (geometry,notify,event_handler,version)
Rules:
- A module lives next to its users: in their folder, or in the nearest folder above all of them.
One exception:
files/is only used by the menu inshell/and the editor, but the file handling is kept together, apart from the components. - Components import other folders with
#lib/…(a subpath import ofpackage.json), TypeScript modules with relative paths. - A folder with an
index.tsis imported through it, while its own modules import each other directly. Code that loads at different times (viewer and editor) gets no sharedindex.ts: it would put the editor's code into the viewer's first load.
npm run check # lint, types, builds of app and package, unit and Playwright tests, formatting
npm run lint # ESLint
npm run check-types # svelte-check (TypeScript + Svelte)
npm run format:check # Prettier
npm run test-unit # Vitest unit tests
npm run test-coverage # Vitest unit tests with a coverage report
npm run test-playwright # Playwright e2e tests: all in Chromium, those tagged @cross-browser in Firefox
npm run test-playwright-all # Playwright e2e tests: all in Chromium and Firefox, like CI
npm run test-playwright-docker # all Playwright tests in a Linux container with a virtual displayMost Playwright tests check the editor itself, which works the same in every browser. So locally,
Firefox runs only the tests tagged @cross-browser: input, dialogs and focus, clipboard, files,
scrolling and rendering. Tag a new test like this if the browsers may differ in what it checks.
Headless Firefox on Linux cannot create a WebGL context, so MapLibre never renders there and every Firefox test times out. That is why CI runs the Playwright tests on macOS.
npm run doc # regenerate the sections below
npm run doc-bundle # bundle treemap only
npm run doc-graph # dependency graph onlySized by the bundle's own source map: 1277.6 KB raw, 356.1 KB gzipped, across 71 modules.
Sized by the bundle's own source map: 1277.6 KB raw, 356.1 KB gzipped, across 71 modules.
The read-only viewer at /view/ (the folder view/ next to the editor, so it needs no rewrite rules on a web server, also in a subfolder) reads the map from the URL hash, so it can be embedded in an <iframe>:
<iframe src="https://your-host/view/#<state-hash>" style="width: 600px; height: 600px;"></iframe>The state can alternatively be provided via the iframe's data attribute.
The viewer also reads the map from the query, as /view/?map=<state-hash>. Unlike the hash, the query reaches the web server, which can then describe the map, e.g. in the meta tags of a preview in chats and social media. If a link has both, the viewer shows the map of the query. The Share dialog makes links with the hash, unless the configuration's shareLink sets another form (see Configuration), and the editor (/) reads only the hash.
A link is for viewing, and it is the map. Nothing is stored on a server: all of the map is in the link itself, so a link shows the map as it was when the link was made. It holds what the viewer shows, and leaves out what only the author of a map needs: its title, the color scheme of the color picker, and a legend that shared maps do not show. To pass a map on for editing, download it as a .mapjson file, which has all of it. The editor (/) still takes a link, e.g. one made by another program: it opens the map as a new one in the browser storage and removes the hash from the URL.
The Share dialog (at the top right of the editor) creates the link and the embed code, with a preview in different aspect ratios. Its options:
-
Accuracy: how exactly the positions are stored, like a quality slider, from Low at the left to Exact (1 m) at the right, each step twice as fine as the one before. A lower accuracy makes a shorter link; below the slider the dialog shows how the positions are rounded and how long the link is. Automatic chooses High: fine enough for the visible area. A link of more than 2000 characters gets a warning, since some chat and mail programs cut long links, with what would make it shorter.
-
Scrolling the page does not zoom the map (below the embed code): an embedded map leaves the wheel to the page around it, and zooms with Ctrl (or ⌘) and the wheel. On unless switched off; a map in a window of its own always zooms with the wheel.
The embed code asks the page for what the buttons of the map need: allow="fullscreen" for the button for the whole screen, allow="geolocation" for the one for the visitor's location. Without them, a page of another site does not get these buttons.
What a shared map shows is set on the map itself, in Shared map… of the menu (the dialog has a button that leads there). The sidebar then has:
- Visible area: the area that the map shows completely, on every screen; its handles are on the map.
- Rotation and tilt: how the map is turned when it opens.
- Visitors: whether they can move the map and zoom (both by default), and rotate and tilt it (neither by default); whether they stay in the area that the map shows when it opens; and how far they can zoom out and in.
- Navigation buttons: one stack with its place on the map, and which buttons it has: those for zooming, one that shows the map as it opened, one for the whole screen, and one that shows where the visitor is and follows them. The buttons for zooming come with a compass if visitors can rotate or tilt the map.
- Controls: the address search, with which visitors can find a place, e.g. their street; the legend; and a scale bar, each with its place on the map.
These settings are stored in the map.
How a map is encoded (URL hash, GeoJSON, KML) is published as its own npm package,
@versatiles/map-state, e.g. to render shared maps in other apps or to
create links on a server. The editor uses it from packages/map-state/src. Its API documentation is
generated from its sources (npm run doc-api writes it to build/api) and published with the editor
at https://versatiles.org/versatiles-map-editor/api/.
The file format of the editor, .mapjson, is explained in
packages/map-state/MAPJSON.md, with its JSON Schema in
packages/map-state/schema.
The package is released by npm run release, together with the editor (see Versions),
when one of the commits since its version on npm changes its published files. Its tag, e.g.
map-state-v1.1.0, starts the workflow release-map-state.yml, which checks that the tag matches the
version, runs the tests of the package and publishes it to npm with provenance.
The workflow uses npm trusted publishing, so no token
is stored in GitHub. It can only be set up for a package that exists: publish the first version
once by hand (npm publish --workspace packages/map-state --access public, which builds before
packing), then add the workflow as trusted publisher in the package settings on npmjs.com.
To run the editor on your own web server, see Running the editor on your own web server: installing a release, the web server, all fields of the configuration, and the tile server and geocoder it needs.
An organisation running the editor can use its own tile server and geocoder, start new maps where and how it likes, and offer its own color schemes and fonts, e.g. its corporate identity. Put them into map-editor.config.jsonc next to the editor's index.html. In this repository it is static/map-editor.config.jsonc, which lists every field with its default, commented out. No rebuild is needed. The file is JSON with comments (// and /* */, and a comma after the last item is allowed). Every field is optional:
tileServer(defaulthttps://tiles.versatiles.org) serves the background map, the satellite imagery, the symbols (sprites) and the fonts (glyphs), with the same vector tiles (Shortbread) andassets/as tiles.versatiles.org.geocoder(defaulthttps://geocode.versatiles.org/api) is the address search, with the API of Photon. Both apply to the editor and the viewer, and only this file sets them, never a shared map.startView([west, south, east, north]) is what a new map shows, instead of the country of the user (guessed from the time zone, in the EU and the US) or the whole world.startBackgroundis the background of a new map, its settings as in.mapjsonfiles (e.g.{ "theme": "gray" }or{ "base": "satellite" }), anddefaultLanguagethe language of its labels ("user": of the browser, the default;"local"; or a code like"de"), unless the starting background sets one. A new map stores its background like a chosen one (unless it is the editor's default), so its links look the same on every server.defaultColorSchemeis theidof the scheme that the color picker offers by default (one of the configured or predefined schemes); without it, the first one.colorSchemesare offered in the color picker before the predefined schemes. Eachidmay be used once; a scheme with theidof a predefined one replaces it. WithreplaceDefaultSchemes, only they are offered, and the first one is the default.- The editor offers all font faces of the tile server, from its list
assets/glyphs/font_families.json, as a family and a style (e.g. "Lato" and "Bold"), for the labels of the map and of each marker.fontsare glyph names of faces (e.g.lato_bold) that are offered first, with their families. Faces that are not available as map glyphs are skipped with a warning in the browser console. WithreplaceDefaultFonts, only they are offered. Without the list of the tile server, a few regular faces are offered. shareLink(default"view/#{map}") is the form of the links that the Share dialog makes: an address with{map}where the map goes, absolute or relative to the file."view/?map={map}"puts the map into the query, which reaches the web server, e.g. to describe the map in a preview in chats. Another viewer, e.g."https://maps.example.org/{map}", must show the map itself. The embed code always has the editor's own viewer, with the map in the hash, which works at any length.
A missing file leaves the defaults. An invalid field gets its default, so one mistake does not discard the whole file; the browser console names it, and also an unknown field, e.g. a misspelled one. The legend uses a generic font (sans-serif, serif or monospace) instead, since the map's glyph fonts are usually not available as web fonts.
Every change on main is deployed to GitHub Pages once CI has passed. For running the editor on
another web server, it has releases: each is a ZIP archive of the build on the
releases page, with the notes of
its changes, and the same build as the npm package
@versatiles/map-editor, for projects that
get the editor like their other dependencies. The version is in package.json, and the editor shows it as the tooltip of its title
and in the generator of its pages.
To release, run npm run release on main, with everything committed and the
GitHub CLI logged in. scripts/release.ts decides what to release, from
the conventional commits since the release before, shows it
with the changes, and asks for confirmation before it changes anything (--dry-run only shows it,
--yes releases it without asking, e.g. npm run release -- --yes):
- The editor is released if a commit since the tag of its version is a feature (
feat), a bug fix (fix), faster code (perf), a revert or a breaking change (!). Documentation, tests, CI, refactorings and chores alone are no reason for a release. The version is raised asvrtdoes: major for a breaking change, minor for a feature, else patch. @versatiles/map-stateis released by the same rules, from the commits since its version on npm that change its published files (its sources, schema and documents, not its tests). The editor contains the package, so such a commit releases the editor too.- It runs
npm run check, raises the versions, writes the commits into the changelogs (CHANGELOG.md,packages/map-state/CHANGELOG.md), commits this, tags it (v3.1.0,map-state-v1.1.0), pushes the commit with only these tags, and creates the GitHub releases with the changes. - The tag
v…starts the workflowrelease-editor.yml, which checks that the tag matches the version, runs the unit tests, builds the editor, addsversatiles-map-editor-<version>.zipto its release, and publishes the package@versatiles/map-editorof the same version to npm. If no workflow started, it can be started by hand for the tag:gh workflow run release-editor.yml -f tag=v3.1.0; a version that is on npm already is left as it is.
The npm package of the editor. @versatiles/map-editor is the built site in dist/, with a
small package.json of the version of the editor and no dependencies. It is made for a release
and not committed: npm run build && npm run pack-editor writes it to release/npm/
(scripts/release/pack_editor.ts; its README is scripts/release/editor_package_readme.md), and
npm pack --dry-run ./release/npm shows what would be published. It has the default
map-editor.config.jsonc, with tiles.versatiles.org as its tile server, like the archive: a
site with a tile server of its own replaces the file by one with "tileServer": "/".
Publishing to npm needs no token: npm knows the two workflows as trusted publishers of their
packages. That is set once per package on npmjs.com, under Settings → Trusted Publisher, GitHub
Actions: the organization versatiles-org, the repository versatiles-map-editor, the workflow
release-editor.yml for @versatiles/map-editor (release-map-state.yml for
@versatiles/map-state), no environment, and "Allow npm publish". npm offers this only for a
package that exists, so the first version of a package is published by hand:
npm publish ./release/npm --access public for the editor. Until then its job warns and
publishes nothing. Without a trusted publisher npm answers a publish with 404, with one that may
not publish with 403 ("OIDC permission denied for this action").
Release candidates. npm run release -- --rc releases candidates: versions like 4.0.0-rc.1, counted up
with every candidate, the editor as a prerelease on GitHub, the package on npm under the tag
next, so latest stays the last release. A release without --rc after candidates gets their
version, e.g. 4.0.0, also without new commits. --package-version 1.0.1-rc.1 gives the package
this version instead of the one that its commits lead to: the package stays at 1.0.x for version 1
of the formats, although its changes since 1.0.0, which was never used, would make it 2.0.0. So
its candidates and its release are npm run release -- --rc --package-version 1.0.1-rc.1 (then
-rc.2, …) and npm run release -- --package-version 1.0.1.
The formats of the maps are frozen. From version 4.0.0 of the editor on, with its release
candidates, every later version opens the links and the .mapjson files of an earlier one, as the
same map. What that covers, what can still be added, and its limits are in the
README of the package. Tests keep it: sample links
and files of version 1 that are never written again (npm run freeze-samples only adds samples,
e.g. for a new field), and the tables of the formats as literals. A change that would break it
fails them; do not adapt the samples or the literals to the code.
The package @versatiles/map-state has releases of its own, with its own
changelog and tags, see above. The editor continues the
versions of node-versatiles-svelte, from
which it was extracted: its tags v1.x.x and v2.x.x come from there, and release no editor.
MIT — see LICENSE.
{ "tileServer": "https://tiles.example.org", "geocoder": "https://geocode.example.org/api", "startView": [9.73, 53.39, 10.33, 53.74], "startBackground": { "theme": "gray" }, "defaultLanguage": "de", "defaultColorScheme": "corporate", "colorSchemes": [{ "id": "corporate", "name": "Corporate", "colors": ["#003366", "#e30613", "#f5a800"] }], "replaceDefaultSchemes": false, "fonts": ["open_sans_regular", "lato_bold"], "replaceDefaultFonts": false, "shareLink": "view/?map={map}" }