Symbols
Draw icons and text labels for GeoJSON points with a collision-aware symbol layer.
Text labels
#ng-map-symbol-layer renders a GeoJSON point source as a MapLibre symbol layer. Set textField to a property expression or a {name} template. The places in these examples are fictional, so every label you see on the map comes from the layer, not from the basemap. Labels collide by default, so crowded ones thin out as you zoom out. Without textColor and textHaloColor the colors follow the map theme: dark text on a white halo in light mode, light text on a black halo in dark mode. Switch the site theme to see them change. The other examples set their own colors.
import { Component } from '@angular/core';
import { MapComponent, MapSymbolLayerComponent } from 'ng-mapcn';
// Fictional places: the basemap does not label them, so every label here comes from the layer.
const places: GeoJSON.FeatureCollection<GeoJSON.Point, { name: string; kind: string }> = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { name: 'Café Aurora', kind: 'cafe' }, geometry: { type: 'Point', coordinates: [-9.152, 38.727] } },
{ type: 'Feature', properties: { name: 'Torre Norte', kind: 'viewpoint' }, geometry: { type: 'Point', coordinates: [-9.143, 38.7335] } },
// ...
],
};
@Component({
selector: 'app-symbol-labels',
standalone: true,
imports: [MapComponent, MapSymbolLayerComponent],
template: `
<!-- No textColor / textHaloColor: the defaults follow the map theme. -->
<ng-map [center]="[-9.14, 38.722]" [zoom]="13.5">
<ng-map-symbol-layer
[data]="places"
[textField]="['get', 'name']"
[textSize]="14"
[textHaloWidth]="2"
/>
</ng-map>
`,
})
export class SymbolLabelsComponent {
readonly places = places;
}Icons
# Register images by id with images and pick one per feature with icon, here the expression ['get', 'kind']: orange pins are cafés, green pins viewpoints and blue pins stations. The label sits below the icon by default. The images survive theme switches.
import { Component } from '@angular/core';
import { MapComponent, type MapSymbolImage, MapSymbolLayerComponent } from 'ng-mapcn';
@Component({
selector: 'app-symbol-icons',
standalone: true,
imports: [MapComponent, MapSymbolLayerComponent],
template: `
<ng-map [center]="[-9.14, 38.722]" [zoom]="13.5">
<ng-map-symbol-layer
[data]="places"
[images]="images"
[icon]="['get', 'kind']"
textField="{name}"
textColor="#ffffff"
[textHaloColor]="halo"
[textHaloWidth]="2.5"
/>
</ng-map>
`,
})
export class SymbolIconsComponent {
// Each feature has a "kind" property; it picks the image registered under that id.
// PNG, WebP or JPEG: SVG cannot be decoded by map.loadImage.
readonly images: Record<string, MapSymbolImage> = {
cafe: { url: '/pin-orange.png', pixelRatio: 4 },
viewpoint: { url: '/pin-green.png', pixelRatio: 4 },
station: { url: '/pin-blue.png', pixelRatio: 4 },
};
readonly places = places;
readonly halo = ['match', ['get', 'kind'],
'cafe', '#c2410c', 'viewpoint', '#15803d', 'station', '#1d4ed8', '#334155'];
}Live options
#allowOverlap and textSize are applied with diffed setters, so toggling them never re-creates the layer. The labels are packed on purpose: with overlap off, colliding ones are dropped.
import { Component, signal } from '@angular/core';
import { MapComponent, MapSymbolLayerComponent } from 'ng-mapcn';
@Component({
selector: 'app-symbol-live',
standalone: true,
imports: [MapComponent, MapSymbolLayerComponent],
template: `
<button (click)="overlap.set(!overlap())">Allow overlap: {{ overlap() }}</button>
<button (click)="size.set(size() === 12 ? 18 : 12)">Text size: {{ size() }}</button>
<ng-map [center]="[-9.14, 38.722]" [zoom]="12.8">
<ng-map-symbol-layer
[data]="places"
textField="{name}"
textColor="#ffffff"
[textHaloColor]="halo"
[textHaloWidth]="2.5"
[allowOverlap]="overlap()"
[textSize]="size()"
/>
</ng-map>
`,
})
export class SymbolLiveComponent {
readonly overlap = signal(false);
readonly size = signal(12);
readonly places = places;
readonly halo = ['match', ['get', 'kind'],
'cafe', '#c2410c', 'viewpoint', '#15803d', 'station', '#1d4ed8', '#334155'];
}Images
# An image is a URL (PNG, WebP or JPEG; SVG cannot be decoded), a URL with sdf or pixelRatio, or already decoded pixels. This map uses all three forms: the blue pin is a URL, the pink dot is raw pixels built in code, and the diamond is an SDF image that iconColor recolors to purple. Only SDF images are recolored, and SDF and regular images cannot be mixed in one layer, so there is one layer for each. Both layers pass the same images: an id is registered once per map and stays until the last layer using it is gone. Changing images adds, swaps or removes images without re-creating the layer.
import { Component } from '@angular/core';
import { MapComponent, type MapSymbolImage, MapSymbolLayerComponent } from 'ng-mapcn';
// A diamond signed-distance field: alpha encodes the distance to the edge (192 on it),
// which is what lets MapLibre recolor it with iconColor.
function diamondSdf(size: number, radius: number, buffer = 8): MapSymbolImage {
const data = new Uint8ClampedArray(size * size * 4);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const dx = Math.abs(x + 0.5 - size / 2);
const dy = Math.abs(y + 0.5 - size / 2);
const distance = (dx + dy) / Math.SQRT2 - radius;
data[(y * size + x) * 4 + 3] = Math.max(0, Math.min(255, 255 * (0.75 - distance / buffer)));
}
}
return { data: { width: size, height: size, data }, sdf: true, pixelRatio: 2 };
}
// A soft pink dot, decoded pixels built in code (RGBA, straight alpha).
function pinkDot(size: number, radius: number): MapSymbolImage {
const data = new Uint8ClampedArray(size * size * 4);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const distance = Math.hypot(x + 0.5 - size / 2, y + 0.5 - size / 2);
const i = (y * size + x) * 4;
data.set([219, 39, 119, Math.max(0, Math.min(255, 255 * (radius + 1 - distance)))], i);
}
}
return { data: { width: size, height: size, data }, pixelRatio: 2 };
}
@Component({
selector: 'app-symbol-images',
standalone: true,
imports: [MapComponent, MapSymbolLayerComponent],
template: `
<ng-map [center]="[-9.14, 38.722]" [zoom]="13.5">
<!-- SDF and regular images cannot share a layer: one layer each. -->
<ng-map-symbol-layer
[data]="places"
[images]="images"
[filter]="['!=', ['get', 'kind'], 'viewpoint']"
[icon]="['match', ['get', 'kind'], 'station', 'pin', 'dot']"
textField="{name}"
textColor="#ffffff"
[textHaloColor]="halo"
[textHaloWidth]="2.5"
/>
<ng-map-symbol-layer
[data]="places"
[images]="images"
[filter]="['==', ['get', 'kind'], 'viewpoint']"
icon="badge"
iconColor="#7c3aed"
textField="{name}"
textColor="#ffffff"
[textHaloColor]="halo"
[textHaloWidth]="2.5"
/>
</ng-map>
`,
})
export class SymbolImagesComponent {
readonly images: Record<string, MapSymbolImage> = {
// A URL (or { url, sdf, pixelRatio }): decoded once with map.loadImage.
pin: { url: '/pin-blue.png', pixelRatio: 4 },
// Decoded pixels: { data: ImageData | ImageBitmap | { width, height, data } }.
dot: pinkDot(48, 18),
// An SDF image: iconColor recolors it (only SDF images are recolored).
badge: diamondSdf(64, 18),
};
readonly places = places;
readonly halo = ['match', ['get', 'kind'],
'cafe', '#c2410c', 'viewpoint', '#15803d', 'station', '#1d4ed8', '#334155'];
}
// Images are decoded once per URL and registered again after a theme switch.
// Both layers share the ids: each id is added once and removed with the last layer using it
// (the first layer to add an id provides its image). A failed image is reported through
// (mapError) and skipped.Text, filters and zoom
# Text options are reconciled live, so a new textSize or color never re-creates the layer. filter, minZoom and maxZoom work like on the other layer components. Here the text size is interpolated over the zoom level and the buttons switch the filter.
import { Component, computed, signal } from '@angular/core';
import type { FilterSpecification } from 'maplibre-gl';
import { MapComponent, MapSymbolLayerComponent } from 'ng-mapcn';
@Component({
selector: 'app-symbol-filters',
standalone: true,
imports: [MapComponent, MapSymbolLayerComponent],
template: `
@for (option of kinds; track option) {
<button (click)="kind.set(option)" [attr.aria-pressed]="kind() === option">{{ option }}</button>
}
<ng-map [center]="[-9.14, 38.722]" [zoom]="13">
<ng-map-symbol-layer
[data]="places"
textField="{name}"
textColor="#ffffff"
[textHaloColor]="halo"
[textHaloWidth]="2.5"
[textSize]="['interpolate', ['linear'], ['zoom'], 11, 9, 16, 18]"
[filter]="filter()"
[minZoom]="11"
[maxZoom]="19"
/>
</ng-map>
`,
})
export class SymbolFiltersComponent {
readonly kinds = ['all', 'cafe', 'viewpoint', 'station'];
readonly kind = signal('all');
readonly places = places;
readonly halo = ['match', ['get', 'kind'],
'cafe', '#c2410c', 'viewpoint', '#15803d', 'station', '#1d4ed8', '#334155'];
// undefined removes the filter; changing it never re-creates the layer.
readonly filter = computed<FilterSpecification | undefined>(() =>
this.kind() === 'all' ? undefined : ['==', ['get', 'kind'], this.kind()],
);
}
// Text needs the style's glyphs (the default Carto basemaps have them). On the blank style
// every text option is ignored, with a console warning once.Interaction and ids
# Set interactive to receive featureClick and featureHover events. The pointer turns into a hand over a symbol. Two layers share this map, each with its own id: the source and layer ids are symbol-source-<id> and symbol-layer-<id>.
Clicked: none
Hovered: none
import { Component, signal } from '@angular/core';
import { MapComponent, MapSymbolLayerComponent } from 'ng-mapcn';
@Component({
selector: 'app-symbol-interaction',
standalone: true,
imports: [MapComponent, MapSymbolLayerComponent],
template: `
<p>Clicked: {{ clicked() ?? 'none' }}</p>
<p>Hovered: {{ hovered() ?? 'none' }}</p>
<ng-map [center]="[-9.14, 38.722]" [zoom]="13.5">
<!-- Two layers on one map: the id gives each its own source and layer. -->
<ng-map-symbol-layer
id="cafes"
[data]="cafes"
textField="{name}"
textColor="#ffffff"
textHaloColor="#c2410c"
[textHaloWidth]="2.5"
[interactive]="true"
(featureClick)="clicked.set($event.feature.properties?.name ?? null)"
(featureHover)="hovered.set($event?.feature.properties?.name ?? null)"
/>
<ng-map-symbol-layer
id="viewpoints"
[data]="viewpoints"
textField="{name}"
textColor="#ffffff"
textHaloColor="#15803d"
[textHaloWidth]="2.5"
[interactive]="true"
(featureClick)="clicked.set($event.feature.properties?.name ?? null)"
(featureHover)="hovered.set($event?.feature.properties?.name ?? null)"
/>
</ng-map>
`,
})
export class SymbolInteractionComponent {
readonly clicked = signal<string | null>(null);
readonly hovered = signal<string | null>(null);
readonly cafes = cafes; // GeoJSON points
readonly viewpoints = viewpoints;
}
// Ids: symbol-source-<id> and symbol-layer-<id> (auto-generated without id), so the
// layers above are symbol-layer-cafes and symbol-layer-viewpoints.