ng-mapcn
v22.0.0-beta.9
Published
Beautiful maps for Angular, made simple. MapLibre GL components for Angular 22+ with theme-aware styling inspired by shadcn/ui.
Downloads
1,055
Maintainers
Readme
ng-mapcn
ng-mapcn is a set of composable MapLibre GL map components for Angular. It is built for Angular apps that need theme-aware maps (markers, popups, routes, GeoJSON, arcs, clusters, heatmaps, symbol labels and icons, raster/vector tile sources, image overlays, 3D buildings and terrain, draw tools) without API keys or a paid tile provider. It is an Angular port of the mapcn API, with MapLibre GL as the rendering engine and the copy-paste ownership model of shadcn/ui: ng add ng-mapcn copies the sources into your project so you own the code.
Docs · Blocks gallery · Playground · Installation · Testing · API reference
Features
- MapLibre GL JS v6 under the hood, no API keys required (Carto basemaps)
- Theme-aware: follows the
dark/lightclass ordata-themeattribute on<html>(orprefers-color-scheme) and swaps the basemap at runtime - Composable: declare markers, popups, routes, GeoJSON, arcs, clusters, heatmaps, symbol labels and icons, tile sources (raster, vector, PMTiles), image overlays, 3D buildings and terrain as children of
<ng-map> - Signals-first: standalone components, signal inputs/outputs,
effect()-based reconciliation - Copy-paste distribution:
ng add ng-mapcncopies the sources into your project (shadcn-style). Also usable as a regular npm package. - SSR-safe: nothing touches
windowor MapLibre during server render - Testable: a MapLibre fake and Angular CDK harnesses (
ng-mapcn/testing) let you unit-test maps in jsdom without WebGL - AI-ready: an agent skill (
npx skills add juanvieiraprado99/ng-mapcn) plusllms.txtteach coding agents the real API
Compatibility
| ng-mapcn | Angular | MapLibre GL | npm tag |
| -------- | ---------- | ----------- | ---------------- |
| 22.x | ^22.0.0 | ^6.11.2 | latest, beta |
| 21.x | ^21.2.9 | ^5.16.0 | n/a |
| 18.x | ^18.2.14 | ^5.16.0 | n/a |
Node.js ^22.22.3 || ^24.15.0 || >=26.0.0. The commands below use the @beta tag so you always get the newest 22.x prerelease.
Quick start
1. Install
Copy-paste (recommended, shadcn-style):
ng add ng-mapcn@betang add installs with your workspace's package manager (npm, pnpm, yarn or bun), so run it as npx ng add, pnpm ng add, yarn ng add or bunx ng add as you prefer.
This will:
- Add
maplibre-glto your dependencies (and@types/geojsonto your dev dependencies) - Register
maplibre-gl/dist/maplibre-gl.cssinangular.json - Register MapLibre's worker files as build assets (see MapLibre worker)
- Append the required
.maplibregl-popup-*CSS overrides to your global stylesheet - Copy the component sources to
src/app/components/ui/map(customize with--path)
If your app already depends on an older maplibre-gl, ng add keeps your version and warns: update it to ^6.11.2.
Drawing (ng-map-draw) is opt-in so apps that do not draw never install terra-draw: run ng add ng-mapcn@beta --draw to also copy the draw sources to <path>/draw (import from there) and add terra-draw and terra-draw-maplibre-gl-adapter to your dependencies.
Or as a regular npm package:
npm install ng-mapcn@beta maplibre-gl
# or: pnpm add / yarn add / bun add ng-mapcn@beta maplibre-glTo draw with ng-map-draw, also install its optional peer dependencies and import from the ng-mapcn/draw entry point (the core ng-mapcn entry never loads terra-draw):
npm install terra-draw terra-draw-maplibre-gl-adapter
# or: pnpm add / yarn add / bun add terra-draw terra-draw-maplibre-gl-adapterThen in your global styles.scss:
// theme tokens + component utility classes
@use 'ng-mapcn/src/lib/styles/index' as *;
// required .maplibregl-popup-* overrides
@use 'ng-mapcn/styles.scss' as *;
// MapLibre base CSS
@import 'maplibre-gl/dist/maplibre-gl.css';The popup overrides are required. MapLibre injects its own popup chrome, which must be neutralized so the styled content shows cleanly:
.maplibregl-popup-content {
background: transparent !important;
box-shadow: none !important;
padding: 0 !important;
border-radius: 0 !important;
max-width: none !important;
}
.maplibregl-popup-tip {
display: none !important;
}MapLibre worker
MapLibre GL v6 ships as ES modules and loads its web worker from maplibre-gl-worker.mjs next to the bundle that imports it, and that worker imports maplibre-gl-shared.mjs. Angular's builder does not emit these files on its own, so copy both to the output root (ng add does this for you):
// angular.json → projects.<app>.architect.build.options.assets
{ "glob": "maplibre-gl-worker.mjs", "input": "node_modules/maplibre-gl/dist", "output": "/" },
{ "glob": "maplibre-gl-shared.mjs", "input": "node_modules/maplibre-gl/dist", "output": "/" }Bundlers cannot resolve MapLibre's default worker location (import.meta.url); under ng serve it points into Vite's prebundled deps and the console shows Worker failed to load. So, before creating the first map, <ng-map> points MapLibre at maplibre-gl-worker.mjs resolved against <base href>, which works in ng serve and production builds alike. If you serve the files from somewhere else (a CDN, a deployUrl), call MapLibre's own setWorkerUrl('https://…/maplibre-gl-worker.mjs') before the first map is created; <ng-map> keeps a URL you already set. MapLibre v6 requires WebGL2: without it the Map constructor throws a GPUInitializationError, which <ng-map> emits through (mapError).
2. Add a map
A complete standalone component (with ng add, import from ./components/ui/map instead of 'ng-mapcn'):
import { Component } from '@angular/core';
import { MapComponent, MapMarkerComponent, MarkerPopupComponent } from 'ng-mapcn';
@Component({
selector: 'app-map',
imports: [MapComponent, MapMarkerComponent, MarkerPopupComponent],
template: `
<div class="map-container">
<ng-map [center]="[-46.63, -23.55]" [zoom]="10">
<ng-map-marker [longitude]="-46.63" [latitude]="-23.55">
<ng-marker-popup><strong>São Paulo</strong></ng-marker-popup>
</ng-map-marker>
</ng-map>
</div>
`,
styles: `
.map-container {
height: 400px;
}
`,
})
export class AppMap {}The map fills its parent, so give the parent an explicit height. More building blocks (controls, routes, GeoJSON, arcs, clusters, heatmaps, symbols, 3D) are listed below and in the blocks gallery.
Components
| Component | Description |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ng-map | Map host. Inputs: center, zoom, theme (light/dark/auto), styles (per-theme basemap), blank (tile-less dataviz style), projection, viewport (controlled mode), loading, options (raw MapLibre MapOptions). Outputs: viewportChange, mapReady, mapClick, mapError. |
| ng-map-controls | Zoom / compass / locate / fullscreen buttons. Rendered inside MapLibre's corner containers, so it stacks with the scale, legend, minimap and style switcher. Breaking change: the fixed bottom offsets are gone; style the card with className, not host-descendant selectors. |
| ng-map-scale | Scale bar as a MapLibre control. unit (metric/imperial/both), maxWidth, position. |
| ng-map-minimap | Overview map as a MapLibre control (default bottom-left). Follows the main viewport at zoomOffset (5) levels lower, draws the viewport footprint, and click, drag or arrow keys navigate the main map. Mirrors the main basemap unless styles/blank is set. width, height, [(collapsed)]; collapsing frees its WebGL context. |
| ng-map-legend | Legend card: category swatches and colour gradients from sections. title, collapsible, [(collapsed)], position; content projects below. |
| ng-map-style-switcher | Basemap listbox. options, [(value)], position; the selected option overrides provideMapStyles() and [styles] for that map. |
| ng-map-marker | Declarative marker. longitude, latitude, draggable, offset, rotation, anchor, alignments, ariaLabel; click/hover/drag outputs. Keyboard: Enter/Space click, arrow keys drag. |
| ng-marker-content | Projects custom HTML into the marker element (empty = default dot). |
| ng-marker-popup / ng-marker-tooltip / ng-marker-label | Popup on click (a named, non-modal role="dialog" card; the marker gets aria-haspopup="dialog", aria-expanded and, while open, aria-controls), tooltip on hover or focus, CSS label above/below the marker. |
| ng-map-popup | Standalone popup at a lng/lat (no marker), a named, non-modal role="dialog" card. |
| ng-map-route | LineString layer with color/width/opacity/dashArray and click output. |
| ng-route-playback / ng-route-playback-control | Animates the route progress (overrides [progress]): [(playing)], [(progress)], [(speed)], duration, loop, follow, ended; play()/pause()/toggle()/restart()/seek(). The control (child of ng-map, [for]="pb") adds play/pause, a scrubber and a speed selector as a corner control. |
| ng-map-geojson | Fill + outline layers from GeoJSON data or URL. Declarative hover via promoteId + fillHoverPaint (feature-state). |
| ng-map-arc | Curved arcs between points (flight paths). Invisible hit layer for comfortable hover/click; hoverPaint via feature-state. |
| ng-map-cluster-layer | Clustered points with count labels, threshold-based colors/sizes and expansion-zoom on click. |
| ng-map-heatmap | Point-density heatmap layer with a theme-aware gradient, weightProperty/weightRange, zoom-dependent radius/intensity/opacity expressions and maxZoom. |
| ng-map-symbol-layer | Icons and text labels for GeoJSON points: images registered by id, data-driven icon, textField, collision-aware, allowOverlap, filter, featureClick/featureHover. |
| ng-map-raster-source | Raster tile source (XYZ/TMS/WMS) plus its raster layer: tiles/url, tileSize, opacity, visible, beforeId. |
| ng-map-vector-source | Vector tile source (MVT, TileJSON, pmtiles://), source only; draw it with layers that wait for it via MapContextService.hasSource(id). |
| ng-map-image-overlay | Image pinned to four corner coordinates, with opacity, visible and beforeId; url and coordinates update in place. |
| ng-map-fill-extrusion | 3D extrusion of polygons (3D buildings): defaults to the Carto basemap buildings (protomaps / buildings, height), or any source by id; color, opacity, minZoom, filter, beforeId. |
| ng-map-terrain | 3D terrain from a raster-dem source (public Terrarium tiles by default) with exaggeration and an optional hillshade; re-applied after a theme switch. |
| ng-map-draw | Draw, select and edit points, lines and polygons (terra-draw). Optional ng-mapcn/draw entry point: two-way GeoJSON features, mode, drawEnd/featureEdit/featureSelect, theme-aware, restarts across theme switches. Needs terra-draw and terra-draw-maplibre-gl-adapter. |
ng-map also takes [fitBounds]="{ padding, maxZoom, duration, animate, bounds? }" to fit the camera to its children's data (or explicit bounds) once loaded and whenever they change; MapContextService.fitBounds() is the imperative form.
Utilities: MapViewport, getViewport, buildArcCoordinates, mergeHoverPaint, blankMapStyle, defaultStyles, and MapContextService (map / isLoaded / resolvedTheme signals and the hasSource(id) source registry for advanced layer work via inject).
Theming
Without a theme input, the map watches the dark/light class (or a data-theme="dark"/"light" attribute) on document.documentElement (works with any theme toggler) and falls back to prefers-color-scheme. Switching theme calls map.setStyle(..., { diff: true }) and every layer component re-adds itself automatically once the style settles.
Basemaps
Any MapLibre-compatible style works. Set it per map with styles, or once for the whole app with provideMapStyles():
import { provideMapStyles } from 'ng-mapcn';
export const appConfig: ApplicationConfig = {
providers: [
provideMapStyles({
light: 'https://tiles.openfreemap.org/styles/positron',
dark: 'https://tiles.openfreemap.org/styles/dark',
}),
// ...or pass a Signal (or a factory that injects a service) to swap the
// basemap at runtime: provideMapStyles(() => inject(BasemapService).styles)
],
};Precedence: ng-map-style-switcher selection → [styles] → [blank] → provideMapStyles() → Carto default.
Labels and translations
Control tooltips, the loader and the popup close button are English by default. Override them app-wide with provideMapLabels() (static value, signal or factory; missing keys keep the English default), or per component with the labels input on ng-map, ng-map-controls, ng-map-scale, ng-map-legend, ng-map-style-switcher, ng-map-minimap, ng-route-playback-control, ng-map-marker, ng-map-popup and ng-marker-popup:
import { provideMapLabels } from 'ng-mapcn';
provideMapLabels({
zoomIn: 'Aproximar',
zoomOut: 'Afastar',
loading: 'Carregando mapa',
closePopup: 'Fechar',
});
// follow the active locale: provideMapLabels(() => inject(I18nService).mapLabels)Keys: zoomIn, zoomOut, resetNorth, locate, fullscreen, loading, closePopup, popup, marker, scale, legend, showLegend, hideLegend, mapStyle, minimap, showMinimap, hideMinimap.
| Provider | Style URL | API key |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
| Carto (default) | https://basemaps.cartocdn.com/gl/{positron\|dark-matter\|voyager}-gl-style/style.json | no |
| OpenFreeMap | https://tiles.openfreemap.org/styles/{liberty\|bright\|positron\|dark\|fiord} | no |
| OpenStreetMap | raster tiles https://tile.openstreetmap.org/{z}/{x}/{y}.png — build the StyleSpecification by hand; dev/demo only (tile usage policy) | no |
| MapTiler | https://api.maptiler.com/maps/{streets-v4\|streets-v4-dark\|dataviz-v4}/style.json?key=KEY | yes |
| Stadia Maps | https://tiles.stadiamaps.com/styles/{alidade_smooth\|alidade_smooth_dark}.json?api_key=KEY | keyless on localhost |
Live examples for each: ng-mapcn demo → Map Styles.
SSR support
ng-mapcn is server-side-rendering safe. The map is created in afterNextRender
(browser-only, after hydration) and every child component (markers, popups,
layers, controls) gates its work on the map signal, which is null on the
server — so nothing touches document, window, or MapLibre during server
render. On the server the map host renders a plain container <div> plus the
loader, which matches the first client render and hydrates cleanly.
In a consumer app with @angular/ssr, just enable hydration:
// app.config.ts
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(withEventReplay()),
// ...your other providers
],
};Notes:
- Theme flash: on the server the theme resolves to
light(nomatchMedia); if the user is in dark mode the basemap swaps once on the client after hydration. This mirrors the Reactmapcnbehavior. Set thethemeinput explicitly to avoid it. - If a bundler complains about
maplibre-glduring server build/prerender, mark it external for the server (externalDependencies: ["maplibre-gl"]) or addngSkipHydrationto the element hosting<ng-map>.
Security
- Only load trusted basemap styles and attributions. The attribution control (on by default) renders style and source attribution strings as HTML, so the URLs you pass to
[styles]/provideMapStyles(), acustomAttributionin[options], and GeoJSON URLs given to[data]must come from sources you control or trust, never straight from user input. ng-mapcn requiresmaplibre-gl^6.11.2, which includes the fix for GHSA-jrc7-96c5-q579 (attribution sanitizer bypass inmaplibre-gl≤ 6.4.0). - Content Security Policy: with the worker self-hosted as above,
worker-src 'self'is enough (noblob:). MapLibre also needsimg-src data: blob: 'self'plus your tile/style hosts inconnect-srcandimg-src(https://basemaps.cartocdn.com https://*.basemaps.cartocdn.comfor the default basemaps: the style is served from the first; TileJSON, glyphs, sprites and tiles fromtiles.basemaps.cartocdn.comandtiles-a/b/c.basemaps.cartocdn.com). - Privacy: the default basemaps are served by Carto, a third party that receives your users' IP address and the areas they view. Self-host or swap the style with
[styles]/provideMapStyles()if that matters.ng-map-controls' locate button asks for the browser's geolocation and emits the coordinates through(locateChange): treat them as personal data.
Performance
- Lazy-load the map. MapLibre is large: in the demo's production build its chunk is about 1 MB (≈285 KB gzipped), more than the demo's entire initial JavaScript (≈160 KB gzipped), and the first map also fetches the worker files (≈150 KB gzipped, on the worker thread, cached afterwards). Keep
ng-mapcnout of your initial bundle with a lazy route (loadComponent) or@defer (on viewport) { <ng-map>…</ng-map> }. - Many points → layers, not markers. Each
<ng-map-marker>is a DOM element with its own component; for hundreds of points use<ng-map-cluster-layer>,<ng-map-heatmap>or<ng-map-geojson>, which render on the GPU. - Pass new references to update data. Layer components push
[data]/[coordinates]to MapLibre only when the reference changes; replace arrays and objects instead of mutating them.
Testing your maps
Unit-test components that render <ng-map> in jsdom, without WebGL. ng-mapcn imports maplibre-gl statically, so the test runner replaces that module with a fake that ships in the package (ng-mapcn/testing/maplibre). It records calls, fires events on demand and renders markers, popups and controls into the map container.
// src/testing/setup.ts (Vitest)
import { configureFakeMapLibre, fakeRegistry } from 'ng-mapcn/testing/maplibre';
import { beforeEach, vi } from 'vitest';
vi.mock('maplibre-gl', () => import('ng-mapcn/testing/maplibre'));
configureFakeMapLibre({ spy: vi.fn });
beforeEach(() => fakeRegistry.reset());// vitest.config.mts: process ng-mapcn itself so the mock reaches it
export default defineConfig({ test: { server: { deps: { inline: [/ng-mapcn/] } } } });import { loadMap, renderMap, getFakeMap, structuralCalls } from 'ng-mapcn/testing';
const { fixture } = await renderMap(
'<ng-map><ng-map-route id="trip" [coordinates]="c" /></ng-map>',
{
imports: [MapRouteComponent],
hostProps: {
c: [
[0, 0],
[1, 1],
],
},
},
);
await loadMap(fixture);
expect(structuralCalls(getFakeMap(fixture))).toEqual(['addSource:trip', 'addLayer:trip-layer']);ng-mapcn/testing:renderMap,loadMap,reloadStyle,getFakeMap,fireMapEvent,structuralCallsand friends, plusMAP_TEST_SELECTORS, the stable DOM selectors of a rendered map for your own Playwright or Cypress tests.ng-mapcn/testing/harness: Angular CDK component harnesses (NgMapHarness,NgMapMarkerHarness,NgMapPopupHarness,NgMapControlsHarness).@angular/cdkis an optional peer dependency, only needed for this entry point.NgMapMarkerHarness.isPopupOpen()reads the marker'saria-expanded(or, when the marker content is its own button, that button's, mirrored from<ng-marker-popup #info>with[attr.aria-expanded]="info.isOpen()"), so it reports that marker's own popup.- Vitest is supported first-class. Jest works by mapping
maplibre-glto the fake withmoduleNameMapper, but ng-mapcn's own tests do not cover it. Karma/Jasmine are not supported (they cannot mock an ES module). ng adddoes not copy the testing files: install theng-mapcnpackage to use them.- Change host values after rendering with
update()(it marks the host for check, as zoneless apps need) or signals, callrenderMap()once per test, and pair fake timers withconfigureMapTesting({ advanceTimers })(otherwiseloadMap()throws).
Setup for Vitest and Jest, fake timers, the harnesses and the limitations: Testing your maps.
For LLMs
The docs site serves llms.txt (index) and llms-full.txt (every component, input, output and default in one file), following the llmstxt.org convention. Point your coding agent at either instead of scraping the docs.
AI agent skill
ng-mapcn ships an agent skill that teaches coding agents (Claude Code, Cursor, Codex, GitHub Copilot, Windsurf, Gemini and others) the library's real API: component selectors, inputs and outputs, setup for both install channels, copy-paste recipes and common pitfalls. Without it, agents tend to guess class names and selectors that do not exist.
Install it with the skills.sh CLI, from your app's root:
npx skills add juanvieiraprado99/ng-mapcnThe CLI downloads the skill and configures it for your agent. Pass --list to see the skills in the repository without installing, or --skill ng-mapcn to install this one explicitly.
Contributing
Contributions are welcome. Please read the contributing guide and the code of conduct. To report a vulnerability, follow the security policy instead of opening a public issue.
To run the demo locally:
npm install
npm run build-ui-dev # builds the lib, installs it locally and serves the demoCredits
- mapcn (docs) — the React + MapLibre GL library that inspired this project. ng-mapcn is an Angular port of its composition model and component API. All credit for the original design goes to its authors.
- Zard UI (docs) — the Angular component library (shadcn-style, copy-paste) used to build this project's demo & documentation site.
- MapLibre GL JS — the rendering engine.
- Carto — the default free basemaps.
License
MIT © ng-mapcn. See the LICENSE file.
