Skip to content
pierrerabyPublic

About

Tiny WebMercator tile map library (OSM/WMTS) in vanilla JS — zero dependency, zero build, single file. Educational: read the source to learn how a WMTS tile client works.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Repository files navigation

NanoMap

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.

Quickstart (3 lines)

<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:8000

The repo's demo page: index.html + demo.js (Paris, zoom 12, OSM tiles).

Options

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

API

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).

Events

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).

Tile sources

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}",
});

Advanced utilities (module exports)

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).

DIY spirit

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 (and click for interaction).

_createPin / _updatePin in nanomap.js (~40 lines) are a working example of exactly this pattern.

How it works (in brief)

  • 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 (drawState kept 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 after destroy()).
  • CSS is injected once into a <style data-nanomap> (container position: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; }

Security & privacy

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> requires style-src 'unsafe-inline' or a nonce; under a strict CSP use the CSS fallback above (and ignore the console violation). Allow tile servers in img-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-Policy on 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 (_events with null prototype).

Known limitations

  • 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.
  • tileSize changes 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).

Development

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 version

Manual checklist (browser, demo):

  • init from #map, tiles displayed, load logged 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; moveend after 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 window by importing the library (checked automatically in browser.html; the demo, on the other hand, intentionally exposes window.map for the console)

References: the POC's historical assets (webmercator.js resolution table and marker*.png) live on the legacy/original-branch branch.

Structure

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

About

Tiny WebMercator tile map library (OSM/WMTS) in vanilla JS — zero dependency, zero build, single file. Educational: read the source to learn how a WMTS tile client works.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages