Streets Basemap
leaflet-mapuiOpenStreetMap Standard tiles — the canonical free street network, transit, and POI basemap.
Installation
$pnpm dlx uipkge-ng@latest add leaflet-map$npx uipkge-ng@latest add leaflet-map$yarn dlx uipkge-ng@latest add leaflet-map$bunx uipkge-ng@latest add leaflet-mapManifest:
https://uipkge.dev/r/angular/leaflet-map.jsonInstalls to:src/app/components/ui/leaflet-map/Examples
Live demo coming soon — source below is exact.
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
variant | 'default''muted''streets''outdoors''light''dark''satellite''satellite-streets''navigation-day''navigation-night''standard' | default | optional |
size | 'default''sm''lg''xl''full' | — | optional |
tileUrlCustom raster tile URL template — overrides `variant`. | string | — | optional |
tileAttribution | string | — | optional |
tileSubdomains | string | string[] | — | optional |
centerInitial [lng, lat] — Mapbox order, matching the `map` component. | [number, number] | [0, 20] | optional |
zoom | number | 2 | optional |
minZoom | number | — | optional |
maxZoom | number | — | optional |
navigationShow the zoom +/- control. | boolean | true | optional |
navigationPosition | LeafletPosition | bottom-right | optional |
fullscreenShow the HTML5 fullscreen toggle button. | boolean | false | optional |
fullscreenPosition | LeafletPosition | top-right | optional |
attributionShow tile credits behind a ⓘ button (hover/tap). Keep on — OSM/Esri tiles require attribution. | boolean | true | optional |
scrollWheelZoom | boolean | true | optional |
muted | boolean | false | optional |
class | string | — | optional |
created | EventEmitter<L.Map> | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
LeafletTilePresetinterface LeafletTilePreset {
/** Raster tile URL template ({z}/{x}/{y}, optional {s} subdomains + {r} retina). */
url: string
/** Required provider attribution HTML. */
attribution: string
subdomains?: string | string[]
maxZoom?: number
/** Optional label/boundary overlay composited above the base tiles. */
overlayUrl?: string
}npm dependencies
Files installed (5)
src/app/components/ui/leaflet-map/leaflet-map.component.ts38 kB
import { AfterViewInit, ChangeDetectionStrategy, Component, Directive, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges, ViewChild, booleanAttribute, forwardRef, inject, signal, } from '@angular/core' import type * as L from 'leaflet' import { cn } from '@/lib/utils' import { LEAFLET_TILES, LEAFLET_THEME_TILES, leafletMapVariants, type LeafletMapVariant, type LeafletMapVariants, type LeafletTilePreset, } from './leaflet-map.variants' import { defined, fixDefaultLeafletIcon, loadLeaflet, toLatLng, toLatLngBounds, toLatLngs, type LeafletModule, type LeafletPosition, } from './leaflet-context' import { ensureLeafletStyles } from './leaflet-map.styles' export type LeafletMapSize = NonNullable<LeafletMapVariants['size']> export type LeafletMarkerAnchor = 'center' | 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' type MapCallback = (map: L.Map, Lmod: LeafletModule) => void const CORNER_ORDER: LeafletPosition[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'] /** * Angular port of the UIPKGE LeafletMap: a thin, theme-aware Leaflet wrapper rendering free * raster tiles (no API key). Standalone. Class strings come from shared `leafletMapVariants` * and the chrome markup (zoom group, fullscreen button, ⓘ attribution) is the React markup. * * Styles: the React item imports `leaflet/dist/leaflet.css` + `leaflet-map.css`. Here both * live in `leaflet-map.styles.ts` and the map injects them into `<head>` once on construction * (`<style data-uipkge-leaflet>`), so an installed item works with no angular.json edits. * Already loading `leaflet/dist/leaflet.css` globally is harmless (identical rules). * * Children draw themselves once the map exists and follow their inputs, like React: * - `<ui-leaflet-marker>`: projected content becomes a custom div-icon (the real DOM nodes are * moved into the icon, so bindings and listeners keep working); `anchor` picks which point * of that content sits on the coordinate. With no content it uses Leaflet's default pin. * - `<ui-leaflet-popup>`: binds to its parent marker / path (or opens standalone at `lngLat`); * its content is moved into the popup, bindings stay live. * - `<ui-leaflet-tooltip>`: same, but a hover tooltip. * - `<ui-leaflet-polyline>`, `<ui-leaflet-polygon>`, `<ui-leaflet-circle>`, * `<ui-leaflet-circle-marker>` (pixel radius), `<ui-leaflet-geojson>`, `<ui-leaflet-tile-layer>`. * Use `created` (the raw L.Map) for anything else. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-leaflet-map, [ui-leaflet-map]', standalone: true, host: { '[attr.data-slot]': '"leaflet-map"', '[attr.data-variant]': 'variant', '[attr.data-muted]': 'isMuted', '[attr.data-uipkge]': '""', '[class]': 'hostClass', }, template: ` <div #mapEl class="size-full"></div> @for (corner of cornerOrder; track corner) { @if (showZoomAt(corner) || showFullscreenAt(corner)) { <div [class]="cornerClass(corner)"> @if (showZoomAt(corner)) { <div class="border-border bg-card divide-border flex flex-col divide-y overflow-hidden rounded-lg border shadow-sm" > <button type="button" class="text-muted-foreground hover:bg-muted flex size-8 items-center justify-center transition-colors disabled:pointer-events-none disabled:opacity-40" aria-label="Zoom in" [disabled]="!canZoomIn()" (click)="zoomIn()" > <svg class="size-full" viewBox="0 0 29 29" fill="currentColor" aria-hidden="true"> <path d="M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z" /> </svg> </button> <button type="button" class="text-muted-foreground hover:bg-muted flex size-8 items-center justify-center transition-colors disabled:pointer-events-none disabled:opacity-40" aria-label="Zoom out" [disabled]="!canZoomOut()" (click)="zoomOut()" > <svg class="size-full" viewBox="0 0 29 29" fill="currentColor" aria-hidden="true"> <path d="M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z" /> </svg> </button> </div> } @if (showFullscreenAt(corner)) { <button type="button" class="border-border bg-card text-muted-foreground hover:bg-muted flex size-8 items-center justify-center rounded-lg border shadow-sm transition-colors" aria-label="Toggle fullscreen" (click)="toggleFullscreen()" > <svg class="size-full" viewBox="0 0 29 29" fill="currentColor" aria-hidden="true"> @if (isFullscreen()) { <path d="M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z" /> } @else { <path d="M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z" /> } </svg> </button> } </div> } } @if (mapReady() && attribution && attributions().length > 0) { <div class="group absolute bottom-3 left-3 z-[1000] flex flex-col items-start gap-1.5"> <div role="note" [class]="attributionClass()" [innerHTML]="attributions().join(' | ')"></div> <button type="button" class="border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground flex size-4 items-center justify-center rounded-full border shadow-xs transition-colors" aria-label="Map data attribution" [attr.aria-expanded]="showAttribution()" (click)="showAttribution.set(!showAttribution())" > <svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" > <circle cx="12" cy="12" r="10" /> <path d="M12 16v-4M12 8h.01" /> </svg> </button> </div> } <ng-content /> `, }) export class UiLeafletMapComponent implements AfterViewInit, OnChanges, OnDestroy { @Input() variant: LeafletMapVariant = 'default' @Input() size?: LeafletMapSize /** Custom raster tile URL template — overrides `variant`. */ @Input() tileUrl?: string @Input() tileAttribution?: string @Input() tileSubdomains?: string | string[] /** Initial [lng, lat] — Mapbox order, matching the `map` component. */ @Input() center: [number, number] = [0, 20] @Input() zoom = 2 @Input() minZoom?: number @Input() maxZoom?: number /** Show the zoom +/- control. */ @Input({ transform: booleanAttribute }) navigation = true @Input() navigationPosition: LeafletPosition = 'bottom-right' /** Show the HTML5 fullscreen toggle button. */ @Input({ transform: booleanAttribute }) fullscreen = false @Input() fullscreenPosition: LeafletPosition = 'top-right' /** Show tile credits behind a ⓘ button (hover/tap). Keep on — OSM/Esri tiles require attribution. */ @Input({ transform: booleanAttribute }) attribution = true @Input({ transform: booleanAttribute }) scrollWheelZoom = true @Input({ transform: booleanAttribute }) muted = false @Input('class') className?: string @Output() created = new EventEmitter<L.Map>() @ViewChild('mapEl', { static: false }) mapEl?: ElementRef<HTMLDivElement> readonly cornerOrder = CORNER_ORDER readonly mapReady = signal(false) readonly canZoomIn = signal(true) readonly canZoomOut = signal(true) readonly isFullscreen = signal(false) readonly attributions = signal<string[]>([]) readonly showAttribution = signal(false) private map: L.Map | null = null private Lmod: LeafletModule | null = null private baseLayer: L.TileLayer | null = null private overlayLayer: L.TileLayer | null = null private themeObserver: MutationObserver | null = null private resizeObserver: ResizeObserver | null = null private intersectionObserver: IntersectionObserver | null = null private readonly mapCallbacks = new Set<MapCallback>() private destroyed = false private creating = false private dark = false private readonly onFullscreenChange = () => this.isFullscreen.set(Boolean(document.fullscreenElement)) constructor() { // No inject() here: the map must stay constructible with plain `new` (host-display spec). ensureLeafletStyles() } /** The component host (the map element's parent), fullscreen / in-view target. */ private get hostElement(): HTMLElement | null { return this.mapEl?.nativeElement.parentElement ?? null } get isMuted(): boolean { return this.muted || this.variant === 'muted' } get hostClass(): string { return cn( 'block', leafletMapVariants({ variant: this.variant, ...(this.size ? { size: this.size } : {}) }), this.className, ) } showZoomAt(corner: LeafletPosition): boolean { return this.mapReady() && this.navigation && (this.navigationPosition ?? 'bottom-right') === corner } showFullscreenAt(corner: LeafletPosition): boolean { return this.mapReady() && this.fullscreen && (this.fullscreenPosition ?? 'top-right') === corner } /** Corner stack placement; bottom-left sits above the ⓘ button when credits are shown. */ cornerClass(corner: LeafletPosition): string { let placement: string switch (corner) { case 'top-left': placement = 'left-3 top-3' break case 'top-right': placement = 'right-3 top-3' break case 'bottom-left': placement = this.attribution && this.attributions().length ? 'bottom-9 left-3' : 'bottom-3 left-3' break default: placement = 'bottom-3 right-3' } return cn('absolute z-[1000] flex flex-col gap-2.5', placement) } attributionClass(): string { return cn( 'border-border bg-popover text-popover-foreground max-w-64 rounded-md border px-2.5 py-1.5 text-[11px] leading-relaxed shadow-md transition-opacity [&_a]:underline', this.showAttribution() ? 'visible opacity-100' : 'invisible opacity-0 group-hover:visible group-hover:opacity-100', ) } /** Resolve the tile preset: explicit `tileUrl` wins, then the variant preset, then theme-aware light/dark. */ resolveTiles(): LeafletTilePreset { if (this.tileUrl) { return { url: this.tileUrl, attribution: this.tileAttribution ?? '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', subdomains: this.tileSubdomains, maxZoom: this.maxZoom, } } if (this.variant && this.variant !== 'default' && this.variant !== 'muted') { const preset = LEAFLET_TILES[this.variant as keyof typeof LEAFLET_TILES] if (preset) return preset } return this.dark ? LEAFLET_THEME_TILES.dark : LEAFLET_THEME_TILES.light } getMap(): L.Map | null { return this.map } /** * Runs `cb` with the map once it exists (immediately if it already does). Returns an * unsubscribe for callers destroyed before the map is created. Used by the child layers. */ whenMap(cb: MapCallback): () => void { if (this.map && this.Lmod) { cb(this.map, this.Lmod) return () => {} } this.mapCallbacks.add(cb) return () => this.mapCallbacks.delete(cb) } /** Mapbox-style camera shim — accepts { center: [lng, lat], zoom, duration(ms) }. */ flyTo(options: { center?: [number, number]; zoom?: number; duration?: number } = {}): void { const m = this.map if (!m) return m.flyTo(options.center ? toLatLng(options.center) : m.getCenter(), options.zoom ?? m.getZoom(), { duration: (options.duration ?? 800) / 1000, }) } setView(options: { center?: [number, number]; zoom?: number } = {}): void { const m = this.map if (!m) return m.setView(options.center ? toLatLng(options.center) : m.getCenter(), options.zoom ?? m.getZoom()) } jumpTo(options: { center?: [number, number]; zoom?: number } = {}): void { const m = this.map if (!m) return m.setView(options.center ? toLatLng(options.center) : m.getCenter(), options.zoom ?? m.getZoom(), { animate: false, }) } fitBounds( bounds: [[number, number], [number, number]] | L.LatLngBoundsExpression, options?: L.FitBoundsOptions, ): void { this.map?.fitBounds(toLatLngBounds(bounds as [[number, number], [number, number]]), options) } panTo(center: [number, number]): void { this.map?.panTo(toLatLng(center)) } zoomIn(): void { this.map?.zoomIn() } zoomOut(): void { this.map?.zoomOut() } resize(): void { this.map?.invalidateSize() } toggleFullscreen(): void { const el = this.hostElement if (!el) return if (document.fullscreenElement) void document.exitFullscreen() else void el.requestFullscreen?.() } ngAfterViewInit(): void { if (typeof document === 'undefined' || typeof window === 'undefined') return const root = document.documentElement const syncTheme = () => { const wasDark = this.dark this.dark = root.classList.contains('dark') if (wasDark !== this.dark && this.map) this.applyTiles(this.resolveTiles()) } syncTheme() this.themeObserver = new MutationObserver(syncTheme) this.themeObserver.observe(root, { attributes: true, attributeFilter: ['class'] }) document.addEventListener('fullscreenchange', this.onFullscreenChange) const el = this.mapEl?.nativeElement if (typeof ResizeObserver !== 'undefined' && el) { // Next frame: resizing the map inside the callback changes layout again, which the browser // reports as a "ResizeObserver loop" error (logged as ERROR by Angular's global listeners). this.resizeObserver = new ResizeObserver(() => requestAnimationFrame(() => this.map?.invalidateSize())) this.resizeObserver.observe(el) } // Like React: only create the map once it scrolls near the viewport. const host = this.hostElement if (host && typeof IntersectionObserver !== 'undefined') { this.intersectionObserver = new IntersectionObserver( ([entry]) => { if (entry?.isIntersecting) void this.createMap() }, { rootMargin: '160px', threshold: 0.01 }, ) this.intersectionObserver.observe(host) } else { void this.createMap() } } ngOnChanges(changes: SimpleChanges): void { if (changes['attribution'] && !this.attribution) this.showAttribution.set(false) if (!this.map || !this.Lmod) return // Compare coordinates, not array identity: a parent that rebuilds the // `[lng, lat]` array each change-detection pass must not snap the view back. const prev = changes['center']?.previousValue as [number, number] | undefined const centerMoved = !!changes['center'] && !(prev && prev[0] === this.center?.[0] && prev[1] === this.center?.[1]) if (centerMoved || changes['zoom']) { this.map.setView(toLatLng(this.center), this.zoom) } if (changes['variant'] || changes['tileUrl'] || changes['tileAttribution'] || changes['tileSubdomains']) { this.applyTiles(this.resolveTiles()) } if (changes['scrollWheelZoom']) { if (this.scrollWheelZoom) this.map.scrollWheelZoom.enable() else this.map.scrollWheelZoom.disable() } } ngOnDestroy(): void { this.destroyed = true this.mapCallbacks.clear() this.intersectionObserver?.disconnect() this.intersectionObserver = null this.resizeObserver?.disconnect() this.resizeObserver = null this.themeObserver?.disconnect() this.themeObserver = null if (typeof document !== 'undefined') document.removeEventListener('fullscreenchange', this.onFullscreenChange) try { this.map?.remove() } catch { /* already destroyed */ } this.map = null } private async createMap(): Promise<void> { const el = this.mapEl?.nativeElement if (!el || this.map || this.creating) return this.creating = true const Lmod = await loadLeaflet() if (this.destroyed || this.map) return this.intersectionObserver?.disconnect() this.intersectionObserver = null this.Lmod = Lmod fixDefaultLeafletIcon(Lmod) const tiles = this.resolveTiles() const m = Lmod.map( el, defined({ center: toLatLng(this.center ?? [0, 20]), zoom: this.zoom, minZoom: this.minZoom, maxZoom: this.maxZoom ?? tiles.maxZoom, zoomControl: false, attributionControl: false, scrollWheelZoom: this.scrollWheelZoom, }), ) this.map = m this.applyTiles(tiles) m.on('zoomend', () => this.syncZoomBounds()) this.syncZoomBounds() m.on('layeradd layerremove', () => this.collectAttributions()) this.collectAttributions() this.mapReady.set(true) const pending = [...this.mapCallbacks] this.mapCallbacks.clear() for (const cb of pending) cb(m, Lmod) this.created.emit(m) } private syncZoomBounds(): void { const m = this.map if (!m) return this.canZoomIn.set(m.getZoom() < m.getMaxZoom()) this.canZoomOut.set(m.getZoom() > m.getMinZoom()) } /** Attribution strings from every layer (base tiles, overlays, tile-layer children), deduped. */ private collectAttributions(): void { const m = this.map if (!m) return const seen = new Set<string>() m.eachLayer((layer) => { const a = (layer as L.TileLayer).options?.attribution if (typeof a === 'string' && a) seen.add(a) }) this.attributions.set([...seen]) } private applyTiles(tiles: LeafletTilePreset): void { const m = this.map const Lmod = this.Lmod if (!m || !Lmod) return this.baseLayer?.remove() this.baseLayer = null this.overlayLayer?.remove() this.overlayLayer = null this.baseLayer = Lmod.tileLayer( tiles.url, defined({ attribution: tiles.attribution, subdomains: tiles.subdomains, maxZoom: tiles.maxZoom ?? 19, }), ) this.baseLayer.addTo(m) if (tiles.overlayUrl) { this.overlayLayer = Lmod.tileLayer(tiles.overlayUrl, { maxZoom: tiles.maxZoom ?? 19 }) this.overlayLayer.addTo(m) } } } export { leafletMapVariants, LEAFLET_TILES, LEAFLET_THEME_TILES, type LeafletMapVariant, type LeafletMapVariants } /** * Base for children that own a Leaflet layer: waits for the enclosing map, adds the layer, * removes it on destroy, and publishes it so a nested `<ui-leaflet-popup>` can bind (React's * LeafletLayerContext). */ @Directive() export abstract class LeafletLayerHost<T extends L.Layer = L.Layer> implements AfterViewInit, OnDestroy { protected readonly mapHost = inject(UiLeafletMapComponent, { optional: true }) protected layer: T | null = null private readonly layerCallbacks = new Set<(layer: T) => void>() private offMap: (() => void) | null = null protected abstract build(Lmod: LeafletModule): T getLayer(): T | null { return this.layer } /** Runs `cb` with the layer once built (immediately if it already is). */ whenLayer(cb: (layer: T) => void): () => void { if (this.layer) { cb(this.layer) return () => {} } this.layerCallbacks.add(cb) return () => this.layerCallbacks.delete(cb) } ngAfterViewInit(): void { this.offMap = this.mapHost?.whenMap((map, Lmod) => { const layer = this.build(Lmod) layer.addTo(map) this.layer = layer this.onLayerAdded(layer) const pending = [...this.layerCallbacks] this.layerCallbacks.clear() for (const cb of pending) cb(layer) }) ?? null } ngOnDestroy(): void { this.offMap?.() this.offMap = null this.layerCallbacks.clear() try { this.layer?.remove() } catch { /* map already destroyed */ } this.layer = null } protected onLayerAdded(_layer: T): void {} } /** * Marker at `[lng, lat]` (Mapbox order). Projected content (anything but a nested * `<ui-leaflet-popup>` / `<ui-leaflet-tooltip>`) becomes a custom div-icon: the nodes are moved into a * `.uipkge-leaflet-anchor` element, so bindings and listeners keep working, and `anchor` * decides which point of the content sits on the coordinate. No content: Leaflet's default pin. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-leaflet-marker, [ui-leaflet-marker]', standalone: true, // Content is parked here until the map moves it into the marker icon (React renders nothing). host: { hidden: '' }, providers: [{ provide: LeafletLayerHost, useExisting: forwardRef(() => UiLeafletMarkerComponent) }], template: `<ng-content />`, }) export class UiLeafletMarkerComponent extends LeafletLayerHost<L.Marker> implements OnChanges { readonly el = inject<ElementRef<HTMLElement>>(ElementRef) /** [lng, lat] — Mapbox order. */ @Input() lngLat: [number, number] = [0, 0] /** Which edge/corner of the marker content sits on the coordinate. */ @Input() anchor: LeafletMarkerAnchor = 'center' @Input() draggable?: boolean @Input() opacity?: number @Input() zIndexOffset?: number @Input() title?: string @Input() alt?: string @Output() ready = new EventEmitter<L.Marker>() /** React `onClick` / Vue `click` on the marker (named `layerClick` — plain `click` would shadow the host's DOM event). */ @Output() layerClick = new EventEmitter<L.LeafletMouseEvent>() /** The `.uipkge-leaflet-anchor` icon element holding the projected content (null for the default pin). */ iconEl: HTMLElement | null = null buildOptions(): L.MarkerOptions { return defined({ interactive: true, draggable: this.draggable, opacity: this.opacity, zIndexOffset: this.zIndexOffset, title: this.title, alt: this.alt, }) } protected build(Lmod: LeafletModule): L.Marker { const options = this.buildOptions() const host = this.el.nativeElement const content = [...(host?.childNodes ?? [])].filter( (n) => !(n instanceof Element && n.matches('ui-leaflet-popup, [ui-leaflet-popup], ui-leaflet-tooltip, [ui-leaflet-tooltip]')), ) const hasHtml = content.some((n) => n instanceof Element || (n.nodeType === 3 && n.textContent!.trim() !== '')) if (hasHtml) { const iconEl = document.createElement('div') iconEl.className = 'uipkge-leaflet-anchor' iconEl.dataset['anchor'] = this.anchor ?? 'center' for (const n of content) iconEl.appendChild(n) this.iconEl = iconEl options.icon = Lmod.divIcon({ className: 'uipkge-leaflet-div-icon', html: iconEl }) } return Lmod.marker(toLatLng(this.lngLat), options) } protected override onLayerAdded(marker: L.Marker): void { marker.on('click', (ev: L.LeafletMouseEvent) => this.layerClick.emit(ev)) this.ready.emit(marker) } ngOnChanges(changes: SimpleChanges): void { const m = this.layer if (!m) return if (changes['lngLat'] && this.lngLat) m.setLatLng(toLatLng(this.lngLat)) if (changes['opacity'] && this.opacity !== undefined) m.setOpacity(this.opacity) if (changes['zIndexOffset'] && this.zIndexOffset !== undefined) m.setZIndexOffset(this.zIndexOffset) if (changes['anchor'] && this.iconEl) this.iconEl.dataset['anchor'] = this.anchor ?? 'center' } } /** * Popup bound to the parent marker / path, or opened standalone at `lngLat` on the map. * Its content is moved into the popup's content node, so Angular bindings stay live. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-leaflet-popup, [ui-leaflet-popup]', standalone: true, host: { hidden: '' }, template: `<ng-content />`, }) export class UiLeafletPopupComponent implements AfterViewInit, OnDestroy { private readonly hostEl = inject<ElementRef<HTMLElement>>(ElementRef, { optional: true }) private readonly mapHost = inject(UiLeafletMapComponent, { optional: true }) private readonly parent = inject(LeafletLayerHost, { optional: true }) /** [lng, lat] — standalone popup on the map. Omit inside a layer to bind to it. */ @Input() lngLat?: [number, number] @Input() maxWidth?: number /** Minimum popup width. Defaults to 200 — keeps card-style content from collapsing narrow. */ @Input() minWidth = 200 @Input() offset?: [number, number] @Input() className?: string @Input({ transform: booleanAttribute }) autoClose?: boolean @Input({ transform: booleanAttribute }) closeOnClick?: boolean @Input({ transform: booleanAttribute }) closeButton?: boolean @Input({ transform: booleanAttribute }) keepInView?: boolean private off: (() => void) | null = null private boundTo: L.Layer | null = null private popup: L.Popup | null = null buildOptions(): L.PopupOptions { return defined({ maxWidth: this.maxWidth, minWidth: this.minWidth, offset: this.offset, className: this.className, autoClose: this.autoClose, closeOnClick: this.closeOnClick, closeButton: this.closeButton, keepInView: this.keepInView, } as L.PopupOptions) } ngAfterViewInit(): void { if (this.parent) { this.off = this.parent.whenLayer((layer) => { this.boundTo = layer layer.bindPopup(this.takeContent(), this.buildOptions()) }) } else if (this.lngLat && this.mapHost) { const lngLat = this.lngLat this.off = this.mapHost.whenMap((map, Lmod) => { this.popup = Lmod.popup(this.buildOptions()).setLatLng(toLatLng(lngLat)).setContent(this.takeContent()) this.popup.openOn(map) }) } } /** Moves the projected nodes into a `.uipkge-leaflet-popup-src` node (React portals there). */ private takeContent(): HTMLElement { const el = document.createElement('div') el.className = 'uipkge-leaflet-popup-src' for (const n of [...(this.hostEl?.nativeElement?.childNodes ?? [])]) el.appendChild(n) return el } ngOnDestroy(): void { this.off?.() this.off = null try { this.boundTo?.unbindPopup() this.popup?.remove() } catch { /* map already destroyed */ } this.boundTo = null this.popup = null } } /** * Tooltip bound to the parent marker / path, or floating standalone at `lngLat` on the map. * Its content is moved into the tooltip's content node, so Angular bindings stay live. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-leaflet-tooltip, [ui-leaflet-tooltip]', standalone: true, host: { hidden: '' }, template: `<ng-content />`, }) export class UiLeafletTooltipComponent implements AfterViewInit, OnDestroy { private readonly hostEl = inject<ElementRef<HTMLElement>>(ElementRef, { optional: true }) private readonly mapHost = inject(UiLeafletMapComponent, { optional: true }) private readonly parent = inject(LeafletLayerHost, { optional: true }) /** [lng, lat] — standalone tooltip on the map. Omit inside a layer to bind to it. */ @Input() lngLat?: [number, number] @Input() offset?: [number, number] @Input() direction?: 'top' | 'bottom' | 'left' | 'right' | 'center' | 'auto' @Input({ transform: booleanAttribute }) permanent?: boolean @Input({ transform: booleanAttribute }) sticky?: boolean @Input() opacity?: number @Input() className?: string @Input({ transform: booleanAttribute }) interactive?: boolean private off: (() => void) | null = null private boundTo: L.Layer | null = null private tooltip: L.Tooltip | null = null buildOptions(): L.TooltipOptions { return defined({ offset: this.offset, direction: this.direction, permanent: this.permanent, sticky: this.sticky, opacity: this.opacity, className: this.className, interactive: this.interactive, } as L.TooltipOptions) } ngAfterViewInit(): void { if (this.parent) { this.off = this.parent.whenLayer((layer) => { this.boundTo = layer layer.bindTooltip(this.takeContent(), this.buildOptions()) }) } else if (this.lngLat && this.mapHost) { const lngLat = this.lngLat this.off = this.mapHost.whenMap((map, Lmod) => { this.tooltip = Lmod.tooltip(this.buildOptions()).setLatLng(toLatLng(lngLat)).setContent(this.takeContent()) this.tooltip.addTo(map) }) } } /** Moves the projected nodes into a `.uipkge-leaflet-tooltip-src` node (React portals there). */ private takeContent(): HTMLElement { const el = document.createElement('div') el.className = 'uipkge-leaflet-tooltip-src' for (const n of [...(this.hostEl?.nativeElement?.childNodes ?? [])]) el.appendChild(n) return el } ngOnDestroy(): void { this.off?.() this.off = null try { this.boundTo?.unbindTooltip() this.tooltip?.remove() } catch { /* map already destroyed */ } this.boundTo = null this.tooltip = null } } /** Shared path inputs (React's LeafletPathProps). */ @Directive() abstract class LeafletPathHost<T extends L.Path> extends LeafletLayerHost<T> implements OnChanges { @Input() color?: string @Input() weight?: number @Input() opacity?: number @Input() lineCap?: 'butt' | 'round' | 'square' @Input() lineJoin?: 'miter' | 'round' | 'bevel' @Input() dashArray?: string | number[] @Input() dashOffset?: string @Input() fill?: boolean @Input() fillColor?: string @Input() fillOpacity?: number @Input() className?: string /** React `onClick` / Vue `click` on the path. */ @Output() layerClick = new EventEmitter<L.LeafletMouseEvent>() protected override onLayerAdded(layer: T): void { layer.on('click', (ev: L.LeafletMouseEvent) => this.layerClick.emit(ev)) } protected pathOptions(): L.PathOptions { return defined({ color: this.color, weight: this.weight, opacity: this.opacity, lineCap: this.lineCap, lineJoin: this.lineJoin, dashArray: this.dashArray, dashOffset: this.dashOffset, fill: this.fill, fillColor: this.fillColor, fillOpacity: this.fillOpacity, className: this.className, interactive: true, } as L.PathOptions) } /** Geometry updates (lngLatPath / center / radius); styles are handled here. */ protected abstract updateGeometry(layer: T, changes: SimpleChanges): void ngOnChanges(changes: SimpleChanges): void { const layer = this.layer if (!layer) return this.updateGeometry(layer, changes) const styleKeys = [ 'color', 'weight', 'opacity', 'lineCap', 'lineJoin', 'dashArray', 'dashOffset', 'fill', 'fillColor', 'fillOpacity', ] if (styleKeys.some((k) => changes[k])) layer.setStyle(this.pathOptions()) } } /** Polyline through `[lng, lat][]` (or `[][]` for multi-part lines). */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-leaflet-polyline, [ui-leaflet-polyline]', standalone: true, providers: [{ provide: LeafletLayerHost, useExisting: forwardRef(() => UiLeafletPolylineComponent) }], template: `<ng-content />`, }) export class UiLeafletPolylineComponent extends LeafletPathHost<L.Polyline> { @Input() lngLatPath: [number, number][] | [number, number][][] = [] @Input() smoothFactor?: number @Input({ transform: booleanAttribute }) noClip?: boolean buildOptions(): L.PolylineOptions { return defined({ ...this.pathOptions(), smoothFactor: this.smoothFactor, noClip: this.noClip } as L.PolylineOptions) } protected build(Lmod: LeafletModule): L.Polyline { return Lmod.polyline(toLatLngs(this.lngLatPath) as L.LatLngExpression[], this.buildOptions()) } protected updateGeometry(layer: L.Polyline, changes: SimpleChanges): void { if (changes['lngLatPath']) layer.setLatLngs(toLatLngs(this.lngLatPath) as L.LatLngExpression[]) } } /** Polygon ring(s) as `[lng, lat][]` (or `[][]` for holes/multi). */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-leaflet-polygon, [ui-leaflet-polygon]', standalone: true, providers: [{ provide: LeafletLayerHost, useExisting: forwardRef(() => UiLeafletPolygonComponent) }], template: `<ng-content />`, }) export class UiLeafletPolygonComponent extends LeafletPathHost<L.Polygon> { @Input() lngLatPath: [number, number][] | [number, number][][] = [] buildOptions(): L.PolylineOptions { return this.pathOptions() as L.PolylineOptions } protected build(Lmod: LeafletModule): L.Polygon { return Lmod.polygon(toLatLngs(this.lngLatPath) as L.LatLngExpression[], this.buildOptions()) } protected updateGeometry(layer: L.Polygon, changes: SimpleChanges): void { if (changes['lngLatPath']) layer.setLatLngs(toLatLngs(this.lngLatPath) as L.LatLngExpression[]) } } /** Circle at `[lng, lat]` with a radius in meters. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-leaflet-circle, [ui-leaflet-circle]', standalone: true, providers: [{ provide: LeafletLayerHost, useExisting: forwardRef(() => UiLeafletCircleComponent) }], template: `<ng-content />`, }) export class UiLeafletCircleComponent extends LeafletPathHost<L.Circle> { /** [lng, lat] — Mapbox order. */ @Input() center: [number, number] = [0, 0] /** Radius in meters. */ @Input() radius?: number buildOptions(): L.CircleMarkerOptions { return this.pathOptions() as L.CircleMarkerOptions } protected build(Lmod: LeafletModule): L.Circle { return Lmod.circle( toLatLng(this.center), defined({ ...this.buildOptions(), radius: this.radius }) as L.CircleOptions, ) } protected updateGeometry(layer: L.Circle, changes: SimpleChanges): void { if (changes['center'] && this.center) layer.setLatLng(toLatLng(this.center)) if (changes['radius'] && this.radius !== undefined) layer.setRadius(this.radius) } } /** CircleMarker at `[lng, lat]` with a radius in pixels (stays the same size at any zoom). */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-leaflet-circle-marker, [ui-leaflet-circle-marker]', standalone: true, providers: [{ provide: LeafletLayerHost, useExisting: forwardRef(() => UiLeafletCircleMarkerComponent) }], template: `<ng-content />`, }) export class UiLeafletCircleMarkerComponent extends LeafletPathHost<L.CircleMarker> { /** [lng, lat] — Mapbox order. */ @Input() center: [number, number] = [0, 0] /** Radius in pixels. */ @Input() radius?: number buildOptions(): L.CircleMarkerOptions { return this.pathOptions() as L.CircleMarkerOptions } protected build(Lmod: LeafletModule): L.CircleMarker { return Lmod.circleMarker( toLatLng(this.center), defined({ ...this.buildOptions(), radius: this.radius }) as L.CircleMarkerOptions, ) } protected updateGeometry(layer: L.CircleMarker, changes: SimpleChanges): void { if (changes['center'] && this.center) layer.setLatLng(toLatLng(this.center)) if (changes['radius'] && this.radius !== undefined) layer.setRadius(this.radius) } } /** GeoJSON overlay: FeatureCollection / Feature / geometry with Leaflet GeoJSON options. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-leaflet-geojson, [ui-leaflet-geojson]', standalone: true, providers: [{ provide: LeafletLayerHost, useExisting: forwardRef(() => UiLeafletGeoJsonComponent) }], template: `<ng-content />`, }) export class UiLeafletGeoJsonComponent extends LeafletLayerHost<L.GeoJSON> implements OnChanges { /** GeoJSON FeatureCollection / Feature / geometry. */ @Input() geojson!: GeoJSON.GeoJSON /** Leaflet GeoJSON options: `style`, `pointToLayer`, `onEachFeature`, `filter`, `coordsToLatLng`. */ @Input() options?: L.GeoJSONOptions /** React `onClick` / Vue `click` on a GeoJSON feature. */ @Output() layerClick = new EventEmitter<L.LeafletMouseEvent>() protected build(Lmod: LeafletModule): L.GeoJSON { return Lmod.geoJSON(this.geojson as L.GeoJsonObject, this.options) } protected override onLayerAdded(layer: L.GeoJSON): void { layer.on('click', (ev: L.LeafletMouseEvent) => this.layerClick.emit(ev)) } ngOnChanges(changes: SimpleChanges): void { const layer = this.layer if (!layer || !this.geojson) return if (changes['geojson']) { layer.clearLayers() layer.addData(this.geojson as L.GeoJsonObject) } } } /** Extra raster tile layer composited onto the map. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-leaflet-tile-layer, [ui-leaflet-tile-layer]', standalone: true, template: ``, }) export class UiLeafletTileLayerComponent extends LeafletLayerHost<L.TileLayer> implements OnChanges { /** Raster tile URL template ({z}/{x}/{y}, optional {s} subdomains + {r} retina). */ @Input() url!: string @Input() attribution?: string @Input() subdomains?: string | string[] @Input() minZoom?: number @Input() maxZoom?: number @Input() opacity?: number @Input() zIndex?: number @Input({ transform: booleanAttribute }) tms?: boolean buildOptions(): L.TileLayerOptions { return defined({ attribution: this.attribution, subdomains: this.subdomains, minZoom: this.minZoom, maxZoom: this.maxZoom, opacity: this.opacity, zIndex: this.zIndex, tms: this.tms, }) } protected build(Lmod: LeafletModule): L.TileLayer { return Lmod.tileLayer(this.url, this.buildOptions()) } ngOnChanges(changes: SimpleChanges): void { const layer = this.layer if (!layer) return if (changes['url'] && this.url) layer.setUrl(this.url) if (changes['opacity'] && this.opacity !== undefined) layer.setOpacity(this.opacity) if (changes['zIndex'] && this.zIndex !== undefined) layer.setZIndex(this.zIndex) } }src/app/components/ui/leaflet-map/leaflet-context.ts2.7 kB
import { InjectionToken } from '@angular/core' import type * as L from 'leaflet' /** * Angular port of `leaflet-context.ts`. Leaflet touches `window`/`document` * at import time, so the module is resolved lazily on the client only via * `loadLeaflet()` — never a static top-level import. */ export type LeafletModule = typeof import('leaflet') /** Published by `<ui-leaflet-map>`; injected by `ui-leaflet-*` children. */ export const LEAFLET_MAP = new InjectionToken<L.Map | null>('uipkge-leaflet-map') /** Nearest ancestor layer a popup/tooltip binds to. */ export const LEAFLET_PARENT_LAYER = new InjectionToken<L.Layer | null>('uipkge-leaflet-layer') let leafletPromise: Promise<LeafletModule> | null = null /** Lazily loads Leaflet's ESM build (client only). */ export function loadLeaflet(): Promise<LeafletModule> { if (!leafletPromise) leafletPromise = import('leaflet') return leafletPromise } /** [lng, lat] (Mapbox order, matching the `map` component) -> Leaflet [lat, lng]. */ export function toLatLng(c: [number, number]): L.LatLngExpression { return [c[1], c[0]] } /** Converts a [lng, lat][] path to Leaflet [lat, lng][]. */ export function toLatLngs( path: [number, number][] | [number, number][][], ): L.LatLngExpression[] | L.LatLngExpression[][] { if (!path.length) return [] return Array.isArray(path[0]![0]) ? (path as [number, number][][]).map((ring) => ring.map(toLatLng)) : (path as [number, number][]).map(toLatLng) } /** [[west,south],[east,north]] in [lng, lat] -> a bounds literal Leaflet accepts. */ export function toLatLngBounds(bounds: [[number, number], [number, number]]): L.LatLngBoundsExpression { return [toLatLng(bounds[0]), toLatLng(bounds[1])] as L.LatLngBoundsExpression } export type LeafletPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' /** * Leaflet merges layer options by assignment, so passing `undefined` would * clobber its defaults (e.g. `subdomains: 'abc'` -> crash). Strip undefined * keys before handing options to Leaflet. */ export function defined<T extends object>(o: T): T { return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T } const LEAFLET_ICON_BASE = 'https://unpkg.com/[email protected]/dist/images' let defaultIconFixed = false /** * Leaflet's default pin references image paths relative to the CSS file, which * bundlers can't resolve — point them at the versioned unpkg assets instead. */ export function fixDefaultLeafletIcon(Lmod: LeafletModule): void { if (defaultIconFixed) return defaultIconFixed = true Lmod.Icon.Default.mergeOptions({ iconRetinaUrl: `${LEAFLET_ICON_BASE}/marker-icon-2x.png`, iconUrl: `${LEAFLET_ICON_BASE}/marker-icon.png`, shadowUrl: `${LEAFLET_ICON_BASE}/marker-shadow.png`, }) }src/app/components/ui/leaflet-map/leaflet-map.styles.ts19.7 kB
/** * Stylesheets for the Angular LeafletMap, injected once into <head> by * `ensureLeafletStyles()` (called from UiLeafletMapComponent's constructor). * * Why a TS module and not `styleUrl` / angular.json `styles`: the React item imports * `leaflet/dist/leaflet.css` + `./leaflet-map.css` and lets the bundler handle them. Angular * has nothing equivalent a copied registry file can rely on (a node_modules path in `styleUrl` * is fragile, angular.json edits are manual, and JIT/TestBed cannot resolve `styleUrl` without * `compileComponents()`), and the combined ~18 kB would fail Angular's default 8 kB * `anyComponentStyle` budget. Both sheets are global anyway: Leaflet renders its panes, popups * and marker icons outside any component subtree. */ /** * Leaflet 1.9.4 core stylesheet (`leaflet/dist/leaflet.css`), vendored verbatim except: * relative `images/*` URLs point at the versioned unpkg assets (bundlers cannot resolve them * from here), and the `.leaflet-default-icon-path` rule is dropped: with it, L.Icon.Default * would prefix the absolute icon URLs set by `fixDefaultLeafletIcon()` with a guessed path. * * Leaflet: BSD 2-Clause License. Copyright (c) 2010-2023, Volodymyr Agafonkin; * Copyright (c) 2010-2011, CloudMade. https://github.com/Leaflet/Leaflet/blob/main/LICENSE */ export const LEAFLET_CORE_CSS = `/* required styles */ .leaflet-pane, .leaflet-tile, .leaflet-marker-icon, .leaflet-marker-shadow, .leaflet-tile-container, .leaflet-pane > svg, .leaflet-pane > canvas, .leaflet-zoom-box, .leaflet-image-layer, .leaflet-layer { position: absolute; left: 0; top: 0; } .leaflet-container { overflow: hidden; } .leaflet-tile, .leaflet-marker-icon, .leaflet-marker-shadow { -webkit-user-select: none; -moz-user-select: none; user-select: none; -webkit-user-drag: none; } /* Prevents IE11 from highlighting tiles in blue */ .leaflet-tile::selection { background: transparent; } /* Safari renders non-retina tile on retina better with this, but Chrome is worse */ .leaflet-safari .leaflet-tile { image-rendering: -webkit-optimize-contrast; } /* hack that prevents hw layers "stretching" when loading new tiles */ .leaflet-safari .leaflet-tile-container { width: 1600px; height: 1600px; -webkit-transform-origin: 0 0; } .leaflet-marker-icon, .leaflet-marker-shadow { display: block; } /* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ /* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ .leaflet-container .leaflet-overlay-pane svg { max-width: none !important; max-height: none !important; } .leaflet-container .leaflet-marker-pane img, .leaflet-container .leaflet-shadow-pane img, .leaflet-container .leaflet-tile-pane img, .leaflet-container img.leaflet-image-layer, .leaflet-container .leaflet-tile { max-width: none !important; max-height: none !important; width: auto; padding: 0; } .leaflet-container img.leaflet-tile { /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ mix-blend-mode: plus-lighter; } .leaflet-container.leaflet-touch-zoom { -ms-touch-action: pan-x pan-y; touch-action: pan-x pan-y; } .leaflet-container.leaflet-touch-drag { -ms-touch-action: pinch-zoom; /* Fallback for FF which doesn't support pinch-zoom */ touch-action: none; touch-action: pinch-zoom; } .leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { -ms-touch-action: none; touch-action: none; } .leaflet-container { -webkit-tap-highlight-color: transparent; } .leaflet-container a { -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); } .leaflet-tile { filter: inherit; visibility: hidden; } .leaflet-tile-loaded { visibility: inherit; } .leaflet-zoom-box { width: 0; height: 0; -moz-box-sizing: border-box; box-sizing: border-box; z-index: 800; } /* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ .leaflet-overlay-pane svg { -moz-user-select: none; } .leaflet-pane { z-index: 400; } .leaflet-tile-pane { z-index: 200; } .leaflet-overlay-pane { z-index: 400; } .leaflet-shadow-pane { z-index: 500; } .leaflet-marker-pane { z-index: 600; } .leaflet-tooltip-pane { z-index: 650; } .leaflet-popup-pane { z-index: 700; } .leaflet-map-pane canvas { z-index: 100; } .leaflet-map-pane svg { z-index: 200; } .leaflet-vml-shape { width: 1px; height: 1px; } .lvml { behavior: url(#default#VML); display: inline-block; position: absolute; } /* control positioning */ .leaflet-control { position: relative; z-index: 800; pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ pointer-events: auto; } .leaflet-top, .leaflet-bottom { position: absolute; z-index: 1000; pointer-events: none; } .leaflet-top { top: 0; } .leaflet-right { right: 0; } .leaflet-bottom { bottom: 0; } .leaflet-left { left: 0; } .leaflet-control { float: left; clear: both; } .leaflet-right .leaflet-control { float: right; } .leaflet-top .leaflet-control { margin-top: 10px; } .leaflet-bottom .leaflet-control { margin-bottom: 10px; } .leaflet-left .leaflet-control { margin-left: 10px; } .leaflet-right .leaflet-control { margin-right: 10px; } /* zoom and fade animations */ .leaflet-fade-anim .leaflet-popup { opacity: 0; -webkit-transition: opacity 0.2s linear; -moz-transition: opacity 0.2s linear; transition: opacity 0.2s linear; } .leaflet-fade-anim .leaflet-map-pane .leaflet-popup { opacity: 1; } .leaflet-zoom-animated { -webkit-transform-origin: 0 0; -ms-transform-origin: 0 0; transform-origin: 0 0; } svg.leaflet-zoom-animated { will-change: transform; } .leaflet-zoom-anim .leaflet-zoom-animated { -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); transition: transform 0.25s cubic-bezier(0,0,0.25,1); } .leaflet-zoom-anim .leaflet-tile, .leaflet-pan-anim .leaflet-tile { -webkit-transition: none; -moz-transition: none; transition: none; } .leaflet-zoom-anim .leaflet-zoom-hide { visibility: hidden; } /* cursors */ .leaflet-interactive { cursor: pointer; } .leaflet-grab { cursor: -webkit-grab; cursor: -moz-grab; cursor: grab; } .leaflet-crosshair, .leaflet-crosshair .leaflet-interactive { cursor: crosshair; } .leaflet-popup-pane, .leaflet-control { cursor: auto; } .leaflet-dragging .leaflet-grab, .leaflet-dragging .leaflet-grab .leaflet-interactive, .leaflet-dragging .leaflet-marker-draggable { cursor: move; cursor: -webkit-grabbing; cursor: -moz-grabbing; cursor: grabbing; } /* marker & overlays interactivity */ .leaflet-marker-icon, .leaflet-marker-shadow, .leaflet-image-layer, .leaflet-pane > svg path, .leaflet-tile-container { pointer-events: none; } .leaflet-marker-icon.leaflet-interactive, .leaflet-image-layer.leaflet-interactive, .leaflet-pane > svg path.leaflet-interactive, svg.leaflet-image-layer.leaflet-interactive path { pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ pointer-events: auto; } /* visual tweaks */ .leaflet-container { background: #ddd; outline-offset: 1px; } .leaflet-container a { color: #0078A8; } .leaflet-zoom-box { border: 2px dotted #38f; background: rgba(255,255,255,0.5); } /* general typography */ .leaflet-container { font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; font-size: 12px; font-size: 0.75rem; line-height: 1.5; } /* general toolbar styles */ .leaflet-bar { box-shadow: 0 1px 5px rgba(0,0,0,0.65); border-radius: 4px; } .leaflet-bar a { background-color: #fff; border-bottom: 1px solid #ccc; width: 26px; height: 26px; line-height: 26px; display: block; text-align: center; text-decoration: none; color: black; } .leaflet-bar a, .leaflet-control-layers-toggle { background-position: 50% 50%; background-repeat: no-repeat; display: block; } .leaflet-bar a:hover, .leaflet-bar a:focus { background-color: #f4f4f4; } .leaflet-bar a:first-child { border-top-left-radius: 4px; border-top-right-radius: 4px; } .leaflet-bar a:last-child { border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; border-bottom: none; } .leaflet-bar a.leaflet-disabled { cursor: default; background-color: #f4f4f4; color: #bbb; } .leaflet-touch .leaflet-bar a { width: 30px; height: 30px; line-height: 30px; } .leaflet-touch .leaflet-bar a:first-child { border-top-left-radius: 2px; border-top-right-radius: 2px; } .leaflet-touch .leaflet-bar a:last-child { border-bottom-left-radius: 2px; border-bottom-right-radius: 2px; } /* zoom control */ .leaflet-control-zoom-in, .leaflet-control-zoom-out { font: bold 18px 'Lucida Console', Monaco, monospace; text-indent: 1px; } .leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { font-size: 22px; } /* layers control */ .leaflet-control-layers { box-shadow: 0 1px 5px rgba(0,0,0,0.4); background: #fff; border-radius: 5px; } .leaflet-control-layers-toggle { background-image: url(https://unpkg.com/[email protected]/dist/images/layers.png); width: 36px; height: 36px; } .leaflet-retina .leaflet-control-layers-toggle { background-image: url(https://unpkg.com/[email protected]/dist/images/layers-2x.png); background-size: 26px 26px; } .leaflet-touch .leaflet-control-layers-toggle { width: 44px; height: 44px; } .leaflet-control-layers .leaflet-control-layers-list, .leaflet-control-layers-expanded .leaflet-control-layers-toggle { display: none; } .leaflet-control-layers-expanded .leaflet-control-layers-list { display: block; position: relative; } .leaflet-control-layers-expanded { padding: 6px 10px 6px 6px; color: #333; background: #fff; } .leaflet-control-layers-scrollbar { overflow-y: scroll; overflow-x: hidden; padding-right: 5px; } .leaflet-control-layers-selector { margin-top: 2px; position: relative; top: 1px; } .leaflet-control-layers label { display: block; font-size: 13px; font-size: 1.08333em; } .leaflet-control-layers-separator { height: 0; border-top: 1px solid #ddd; margin: 5px -10px 5px -6px; } /* attribution and scale controls */ .leaflet-container .leaflet-control-attribution { background: #fff; background: rgba(255, 255, 255, 0.8); margin: 0; } .leaflet-control-attribution, .leaflet-control-scale-line { padding: 0 5px; color: #333; line-height: 1.4; } .leaflet-control-attribution a { text-decoration: none; } .leaflet-control-attribution a:hover, .leaflet-control-attribution a:focus { text-decoration: underline; } .leaflet-attribution-flag { display: inline !important; vertical-align: baseline !important; width: 1em; height: 0.6669em; } .leaflet-left .leaflet-control-scale { margin-left: 5px; } .leaflet-bottom .leaflet-control-scale { margin-bottom: 5px; } .leaflet-control-scale-line { border: 2px solid #777; border-top: none; line-height: 1.1; padding: 2px 5px 1px; white-space: nowrap; -moz-box-sizing: border-box; box-sizing: border-box; background: rgba(255, 255, 255, 0.8); text-shadow: 1px 1px #fff; } .leaflet-control-scale-line:not(:first-child) { border-top: 2px solid #777; border-bottom: none; margin-top: -2px; } .leaflet-control-scale-line:not(:first-child):not(:last-child) { border-bottom: 2px solid #777; } .leaflet-touch .leaflet-control-attribution, .leaflet-touch .leaflet-control-layers, .leaflet-touch .leaflet-bar { box-shadow: none; } .leaflet-touch .leaflet-control-layers, .leaflet-touch .leaflet-bar { border: 2px solid rgba(0,0,0,0.2); background-clip: padding-box; } /* popup */ .leaflet-popup { position: absolute; text-align: center; margin-bottom: 20px; } .leaflet-popup-content-wrapper { padding: 1px; text-align: left; border-radius: 12px; } .leaflet-popup-content { margin: 13px 24px 13px 20px; line-height: 1.3; font-size: 13px; font-size: 1.08333em; min-height: 1px; } .leaflet-popup-content p { margin: 17px 0; margin: 1.3em 0; } .leaflet-popup-tip-container { width: 40px; height: 20px; position: absolute; left: 50%; margin-top: -1px; margin-left: -20px; overflow: hidden; pointer-events: none; } .leaflet-popup-tip { width: 17px; height: 17px; padding: 1px; margin: -10px auto 0; pointer-events: auto; -webkit-transform: rotate(45deg); -moz-transform: rotate(45deg); -ms-transform: rotate(45deg); transform: rotate(45deg); } .leaflet-popup-content-wrapper, .leaflet-popup-tip { background: white; color: #333; box-shadow: 0 3px 14px rgba(0,0,0,0.4); } .leaflet-container a.leaflet-popup-close-button { position: absolute; top: 0; right: 0; border: none; text-align: center; width: 24px; height: 24px; font: 16px/24px Tahoma, Verdana, sans-serif; color: #757575; text-decoration: none; background: transparent; } .leaflet-container a.leaflet-popup-close-button:hover, .leaflet-container a.leaflet-popup-close-button:focus { color: #585858; } .leaflet-popup-scrolled { overflow: auto; } .leaflet-oldie .leaflet-popup-content-wrapper { -ms-zoom: 1; } .leaflet-oldie .leaflet-popup-tip { width: 24px; margin: 0 auto; -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); } .leaflet-oldie .leaflet-control-zoom, .leaflet-oldie .leaflet-control-layers, .leaflet-oldie .leaflet-popup-content-wrapper, .leaflet-oldie .leaflet-popup-tip { border: 1px solid #999; } /* div icon */ .leaflet-div-icon { background: #fff; border: 1px solid #666; } /* Tooltip */ /* Base styles for the element that has a tooltip */ .leaflet-tooltip { position: absolute; padding: 6px; background-color: #fff; border: 1px solid #fff; border-radius: 3px; color: #222; white-space: nowrap; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; pointer-events: none; box-shadow: 0 1px 3px rgba(0,0,0,0.4); } .leaflet-tooltip.leaflet-interactive { cursor: pointer; pointer-events: auto; } .leaflet-tooltip-top:before, .leaflet-tooltip-bottom:before, .leaflet-tooltip-left:before, .leaflet-tooltip-right:before { position: absolute; pointer-events: none; border: 6px solid transparent; background: transparent; content: ""; } /* Directions */ .leaflet-tooltip-bottom { margin-top: 6px; } .leaflet-tooltip-top { margin-top: -6px; } .leaflet-tooltip-bottom:before, .leaflet-tooltip-top:before { left: 50%; margin-left: -6px; } .leaflet-tooltip-top:before { bottom: 0; margin-bottom: -12px; border-top-color: #fff; } .leaflet-tooltip-bottom:before { top: 0; margin-top: -12px; margin-left: -6px; border-bottom-color: #fff; } .leaflet-tooltip-left { margin-left: -6px; } .leaflet-tooltip-right { margin-left: 6px; } .leaflet-tooltip-left:before, .leaflet-tooltip-right:before { top: 50%; margin-top: -6px; } .leaflet-tooltip-left:before { right: 0; margin-right: -12px; border-left-color: #fff; } .leaflet-tooltip-right:before { left: 0; margin-left: -12px; border-right-color: #fff; } /* Printing */ @media print { /* Prevent printers from removing background-images of controls. */ .leaflet-control { -webkit-print-color-adjust: exact; print-color-adjust: exact; } } ` /** * Map chrome: re-skins Leaflet's controls, popups and tooltips to the design tokens, plus the * custom-marker anchor and popup-source rules. Byte-identical to the React/Vue * `leaflet-map.css` (leaflet-map.spec.ts enforces it). */ export const LEAFLET_MAP_CSS = `/** * Leaflet map chrome — re-skins Leaflet's controls, popups, and tooltips to the * design-system tokens so they sit naturally on light and dark surfaces. * Global (Leaflet renders this chrome outside the component subtree). Import * once, e.g. in your root CSS: \`@import './components/ui/leaflet-map/leaflet-map.css';\` */ .leaflet-container { font: inherit; background: var(--muted); } .leaflet-bar { border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm); } .leaflet-bar a, .leaflet-bar a:hover { background: var(--card); color: var(--foreground); border-bottom-color: var(--border); } .leaflet-bar a:hover { background: var(--muted); } .leaflet-bar a.leaflet-disabled { background: var(--card); color: var(--muted-foreground); } .leaflet-popup-content-wrapper { background: var(--popover); color: var(--popover-foreground); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-md); } .leaflet-popup-content { margin: var(--spacing-2, 8px) var(--spacing-3, 12px); font: inherit; line-height: 1.5; } .leaflet-popup-tip { background: var(--popover); border: 1px solid var(--border); box-shadow: none; } .leaflet-popup-close-button { color: var(--muted-foreground) !important; } .leaflet-tooltip { background: var(--popover); color: var(--popover-foreground); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm); font: inherit; } .leaflet-tooltip-top:before { border-top-color: var(--border); } .leaflet-tooltip-bottom:before { border-bottom-color: var(--border); } /* \`muted\` variant — desaturate only the tile pane so overlaid data keeps its * colour. */ [data-slot='leaflet-map'][data-muted='true'] .leaflet-tile-pane { filter: grayscale(55%) contrast(0.92); } /* Custom marker icons: Leaflet wraps our element in its own icon div; keep it * unstyled so slot content controls the look. DivIcon forces a 12×12 inline * box plus anchor margins — override so the icon sizes to its content and the * inner anchor transform does the positioning. */ .uipkge-leaflet-div-icon { background: transparent; border: none; width: auto !important; height: auto !important; margin: 0 !important; } /* Marker slot content is a real DOM element moved into the icon. It stays * hidden while parked inside the component tree, and the anchor transform * decides which point of the content sits on the coordinate. */ .uipkge-leaflet-anchor { display: none; } .leaflet-marker-icon .uipkge-leaflet-anchor { display: block; /* shrink-to-fit inside the abspos icon resolves to min-content and wraps * labels — force natural content width instead */ width: max-content; } .uipkge-leaflet-anchor[data-anchor='center'] { transform: translate(-50%, -50%); } .uipkge-leaflet-anchor[data-anchor='top'] { transform: translate(-50%, 0); } .uipkge-leaflet-anchor[data-anchor='bottom'] { transform: translate(-50%, -100%); } .uipkge-leaflet-anchor[data-anchor='left'] { transform: translate(0, -50%); } .uipkge-leaflet-anchor[data-anchor='right'] { transform: translate(-100%, -50%); } .uipkge-leaflet-anchor[data-anchor='top-left'] { transform: translate(0, 0); } .uipkge-leaflet-anchor[data-anchor='top-right'] { transform: translate(-100%, 0); } .uipkge-leaflet-anchor[data-anchor='bottom-left'] { transform: translate(0, -100%); } .uipkge-leaflet-anchor[data-anchor='bottom-right'] { transform: translate(-100%, -100%); } /* Popup/tooltip slot sources are real DOM moved into the overlay's content * node when it opens. */ .uipkge-leaflet-popup-src, .uipkge-leaflet-tooltip-src { display: none; } .leaflet-popup-content .uipkge-leaflet-popup-src, .leaflet-tooltip .uipkge-leaflet-tooltip-src { display: block; } ` const STYLE_ATTR = 'data-uipkge-leaflet' /** * Appends the Leaflet core + chrome stylesheet to `<head>` once per document (chrome after * core, so its token overrides win). Safe alongside an app-level `leaflet/dist/leaflet.css`: * the rules are identical. No-op without a DOM (SSR). */ export function ensureLeafletStyles(doc: Document | null = typeof document === 'undefined' ? null : document): void { if (!doc?.head || doc.head.querySelector(`style[${STYLE_ATTR}]`)) return const style = doc.createElement('style') style.setAttribute(STYLE_ATTR, '') style.textContent = `${LEAFLET_CORE_CSS}\n${LEAFLET_MAP_CSS}` doc.head.appendChild(style) }src/app/components/ui/leaflet-map/leaflet-map.variants.ts3.7 kB
import { cva, type VariantProps } from 'class-variance-authority' export type LeafletMapVariant = | 'default' | 'muted' | 'streets' | 'outdoors' | 'light' | 'dark' | 'satellite' | 'satellite-streets' | 'navigation-day' | 'navigation-night' | 'standard' export interface LeafletTilePreset { /** Raster tile URL template ({z}/{x}/{y}, optional {s} subdomains + {r} retina). */ url: string /** Required provider attribution HTML. */ attribution: string subdomains?: string | string[] maxZoom?: number /** Optional label/boundary overlay composited above the base tiles. */ overlayUrl?: string } const OSM_ATTR = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' const TOPO_ATTR = `${OSM_ATTR} | map style: © <a href="https://opentopomap.org">OpenTopoMap</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)` const ESRI_ATTR = 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community' const OSM_STANDARD: LeafletTilePreset = { url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: OSM_ATTR, maxZoom: 19, } // CARTO basemaps moved behind an API key — Esri Canvas/Street services stay key-free. const ESRI_LIGHT: LeafletTilePreset = { url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}', attribution: ESRI_ATTR, maxZoom: 16, overlayUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Reference/MapServer/tile/{z}/{y}/{x}', } const ESRI_DARK: LeafletTilePreset = { url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}', attribution: ESRI_ATTR, maxZoom: 16, overlayUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Reference/MapServer/tile/{z}/{y}/{x}', } const ESRI_STREETS: LeafletTilePreset = { url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}', attribution: ESRI_ATTR, maxZoom: 19, } const ESRI_SATELLITE: LeafletTilePreset = { url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', attribution: ESRI_ATTR, maxZoom: 19, } export const LEAFLET_TILES: Record<Exclude<LeafletMapVariant, 'default' | 'muted'>, LeafletTilePreset> = { streets: OSM_STANDARD, standard: OSM_STANDARD, outdoors: { url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', attribution: TOPO_ATTR, subdomains: 'abc', maxZoom: 17, }, light: ESRI_LIGHT, dark: ESRI_DARK, satellite: ESRI_SATELLITE, 'satellite-streets': { ...ESRI_SATELLITE, overlayUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}', }, 'navigation-day': ESRI_STREETS, 'navigation-night': ESRI_DARK, } /** Theme-aware default tiles: Esri light/dark canvas following the app theme. */ export const LEAFLET_THEME_TILES = { light: ESRI_LIGHT, dark: ESRI_DARK } export const leafletMapVariants = cva('relative size-full overflow-hidden bg-muted isolate', { variants: { variant: { default: '', muted: '', streets: '', outdoors: '', light: '', dark: '', satellite: '', 'satellite-streets': '', 'navigation-day': '', 'navigation-night': '', standard: '', }, size: { default: 'h-96 w-full', sm: 'h-64 w-full', lg: 'h-[500px] w-full', xl: 'h-[650px] w-full', full: 'size-full', }, }, defaultVariants: { variant: 'default', }, }) export type LeafletMapVariants = VariantProps<typeof leafletMapVariants>src/app/components/ui/leaflet-map/index.ts0.8 kB
export { UiLeafletMapComponent, UiLeafletMarkerComponent, UiLeafletPopupComponent, UiLeafletTooltipComponent, UiLeafletPolylineComponent, UiLeafletPolygonComponent, UiLeafletCircleComponent, UiLeafletCircleMarkerComponent, UiLeafletGeoJsonComponent, UiLeafletTileLayerComponent, LeafletLayerHost, leafletMapVariants, LEAFLET_TILES, LEAFLET_THEME_TILES, type LeafletMapVariant, type LeafletMapVariants, type LeafletMapSize, type LeafletMarkerAnchor, } from './leaflet-map.component' export { LEAFLET_MAP, LEAFLET_PARENT_LAYER, loadLeaflet, toLatLng, toLatLngs, toLatLngBounds, defined, fixDefaultLeafletIcon, type LeafletModule, type LeafletPosition, } from './leaflet-context' export { ensureLeafletStyles, LEAFLET_CORE_CSS, LEAFLET_MAP_CSS } from './leaflet-map.styles'
Raw manifest:https://uipkge.dev/r/angular/leaflet-map-streets.json