Version française : README.fr.md
Tiny WebMercator tile map library (OSM / WMTS) in vanilla JavaScript:
zero dependency, zero build, single file (nanomap.js ~20 KB source, or nanomap.min.js ~11 KB / ~3.9 KB gzip).
Drag, wheel zoom anchored under the cursor, double-buffering (no flicker), marker, events —
with a Leaflet-lite API.
Grew out of the educational nanomap POC: the original, written by hand in the
pre-coding-agent era (spring 2024), lives on the legacy/original-branch branch, with
poc-cleanup as its cleaned-up pass; the library rewrite is on the nano-lib branch.
<div id="map" style="width: 100%; height: 400px"></div>
<script type="module">
import { NanoMap } from "./nanomap.js";
const map = NanoMap.create("#map", { center: [48.8566, 2.3522], zoom: 12 });
</script>Copy nanomap.js (or nanomap.min.js, the minified version) next to your page and
serve the folder — ES modules don't work by double-clicking (file://):
python3 -m http.server 8000 # then open http://localhost:8000The repo's demo page: index.html + demo.js (Paris, zoom 12, OSM tiles).
| Option | Default | Description |
|---|---|---|
center |
[0, 0] |
initial center [lat, lon] |
zoom |
12 |
initial zoom (clamped to min/maxZoom) |
minZoom / maxZoom |
0 / 19 |
zoom bounds (wheel included); if minZoom > maxZoom, maxZoom is normalized to minZoom; non-finite values fall back to defaults |
tileSize |
256 |
tile size in pixels (all geometry adapts to it); clamped to 16–4096, out of range → 256 |
tileUrl |
OSM | a function ({x, y, z}) => url or a template string with {x} {y} {z}; any other value → default OSM |
marker |
true |
pin that follows the center, [lat, lon] = fixed pin, false = no pin |
| Method | Description |
|---|---|
NanoMap.create(container, options) |
container: CSS selector or HTMLElement |
setView([lat, lon], zoom) |
set center + zoom in one call |
setCenter([lat, lon]) |
re-center (keeps the zoom) |
setZoom(z) |
set zoom (clamped) |
getCenter() / getZoom() |
current state |
latLngToPixel([lat, lon]) |
→ {x, y} world pixels at the current zoom (tileSize scale) |
pixelToLatLng(x, y) |
→ [lat, lon] (tileSize scale) |
setMarker([lat, lon] | null) |
move / remove the pin (switches to explicit mode) |
on(event, cb) / off(event, cb) |
event subscription (on accepts duplicates; off removes one occurrence) |
destroy() |
removes canvas, pin, listeners, injected style |
All methods (setView, setCenter, setZoom, setMarker…) are no-ops after
destroy() (safe to call, the map stays dead).
| Event | Payload |
|---|---|
click |
{ lat, lon, x, y } (x, y = world pixels, tileSize scale) |
dblclick |
{ lat, lon, x, y } |
moveend |
{ center: [lat, lon], zoom } |
load |
first render finished (tiles loaded or errored) |
Details: a click is emitted when the mouse moved less than 4 px between mousedown and
mouseup (anti-drag threshold, constant CLICK_SLOP). In that case it's a pure click: no pan,
no moveend. A double-click produces click, click, dblclick (standard browser behavior).
OSM is the default source (fine for demos; in production, prefer IGN to avoid OSM rate-limiting). Full IGN Géoplateforme example (WMTS, no key):
NanoMap.create("#map", {
center: [48.8566, 2.3522],
zoom: 12,
tileUrl:
"https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0" +
"&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2&STYLE=normal&FORMAT=image/png" +
"&TILEMATRIXSET=PM&TILEMATRIX={z}&TILECOL={x}&TILEROW={y}",
});The 8 WebMercator conversions are exported for advanced use (and for Node tests):
lon2tile, lat2tile, tile2long, tile2lat, lon2pixel, lat2pixel, pixel2lon, pixel2lat
(fixed 256 tile size).
NanoMap deliberately ships no drawing API and no overlay layers — the pin itself is
just a <div class="nanomap-pin"> holding a hand-drawn canvas, added by the library
exactly like you would. Extending the map is meant to be done by you, with the pieces
already exposed:
- an overlay
<div>/<canvas>absolutely positioned above the map container (between the tile canvases and the pin), latLngToPixel([lat, lon])/pixelToLatLng(x, y)to place elements in world pixels, plus the 8 module exports above for the raw WebMercator math,- reposition / redraw on
moveend(andclickfor interaction).
_createPin / _updatePin in nanomap.js (~40 lines) are a working example of
exactly this pattern.
- Double canvas: while one canvas displays the map, the other is redrawn in the background (edges reached on drag, or new zoom) then swapped — no flicker.
- Drag invariant: the world point grabbed at mousedown stays under the cursor, even
across canvas swaps (
drawStatekept per canvas). - Lock always released: errored tiles count toward the render; network down = grey map but drag/zoom stay responsive.
epoch: every draw/destroy invalidates in-flight tiles (no ghost tiles, no stray callbacks afterdestroy()).- CSS is injected once into a
<style data-nanomap>(containerposition:relative; overflow:hidden, absolute canvases). If your site's CSP forbids injected styles, add these rules manually and ignore the injected style:
.nanomap { position: relative; overflow: hidden; user-select: none; }
.nanomap canvas { position: absolute; left: 0; top: 0; max-width: none; }
.nanomap-pin { position: absolute; width: 15px; height: 25px; pointer-events: none; z-index: 1; }The attack surface is deliberately small: zero dependency, no innerHTML/eval,
tiles (untrusted content) are drawn on canvas — never inserted into the DOM — and the
library never reads map pixels (getImageData/toDataURL absent).
- CSP: the injected
<style data-nanomap>requiresstyle-src 'unsafe-inline'or a nonce; under a strict CSP use the CSS fallback above (and ignore the console violation). Allow tile servers inimg-src:https://tile.openstreetmap.org,https://data.geopf.fr, or your own source. - Referrer: tile requests send the page's referer to the tile server
(privacy). Configure
Referrer-Policyon your side if needed. - Tainted canvas: with cross-origin tiles without CORS, the map canvas becomes unreadable (pixels can't be exfiltrated); the pin, drawn from scratch, stays clean. To read map pixels, serve the tiles with CORS.
- Integrity: the distribution is a file you copy — you control its version; no auto-update, no hashes to verify.
- Hostile inputs: invalid options, events and lat/lng are neutralized (fallback, clamp,
explicit
NaN); reserved event names (__proto__,constructor…) don't crash (_eventswith null prototype).
- Mouse only (no touch/pinch) — the original POC was a mouse playground.
- The wheel over the map is fully captured (no page scroll while hovering the map).
- The container must have an explicit size (page CSS).
- Programmatic calls during a tile load cancel the current draw (last request wins); the render restarts immediately, tiles reloaded from cache.
- The grid requests one margin tile per side (POC legacy): the drag can continue a few hundred pixels without redrawing, at the cost of a few extra requests.
tileSizechanges the tile grid; the exported conversions stay at 256.- At world edges (lon ±180, lat ±85.05), out-of-bounds tiles can generate invalid server-side URLs (POC behavior, tiles simply error out).
node test/smoke.mjs # Node tests of the conversions (known values, Paris z12)
# nanomap.min.js is committed; to regenerate it (minified version):
npx -y terser nanomap.js -o nanomap.min.js --module --compress --mangle
python3 -m http.server 8000 # then:
# http://localhost:8000/ → interactive demo
# http://localhost:8000/test/browser.html → browser smoke-test (source version)
# http://localhost:8000/test/browser.html?min=1 → same, minified versionManual checklist (browser, demo):
- init from
#map, tiles displayed,loadlogged once in the console - smooth drag; repeated fast drag (20×) with no offset
- drag crossing a canvas border, then release while the redraw is loading → map
stays covered, center synced (regression: lat/lon were swapped in
_borders) - window resize during a drag → redraw centered on the real viewport center, map covered after release
- wheel zoom anchored under the cursor, clamped to bounds (page doesn't scroll)
-
click→ consistent lon/lat/pixels;moveendafter every pan/zoom - marker follows the center during drag/zoom;
setMarker(null)removes it -
destroy()then re-creation without errors or listener leaks - network down → grey map but drag/zoom still responsive (lock released)
- nothing added to
windowby importing the library (checked automatically in browser.html; the demo, on the other hand, intentionally exposeswindow.mapfor the console)
References: the POC's historical assets (webmercator.js resolution table and
marker*.png) live on the legacy/original-branch branch.
nanomap/
├── nanomap.js # THE lib (ESM, zero import, readable)
├── nanomap.min.js # minified version (terser, ~11 KB / ~3.9 KB gzip)
├── index.html # demo page (div#map + demo.js)
├── demo.js # demo using only the public API
├── test/
│ ├── smoke.mjs # Node tests of the conversions
│ └── browser.html # automated browser smoke-test (?min=1 for the minified version)
├── README.md # English version (main)
└── README.fr.md # French version