Map SDK Integration Guide
This guide shows how to consume @agastyadreamspty/map-sdk in an Angular app.
The SDK is built for composability. Pick the smallest integration mode that fits your app.
Runnable consumer samples live in examples/host-example/direct-api/.
1. Choose Your Mode
Full-page explorer
Use this when the SDK should own the map page layout, filters, legend, parcel search, and popup behavior.
Best when:
- you want the default warehouse-style explorer
- you do not want to build your own shell
- you want the SDK to fetch and render data itself
Widgets-only
Use the exported widgets if you want to place filters or legend controls into your own page layout.
Best when:
- you already have your own layout or map page
- you want to keep only the reusable controls
- you want the consumer app to control surrounding chrome
Headless / custom renderer
Use the adapter and renderer APIs when your app owns the map vendor.
Best when:
- you already use Leaflet, OpenStreetMap, Google Maps, Mapbox, or another vendor
- you want the SDK to provide the map data and rules, not the canvas
2. Install the Package
npm install @agastyadreamspty/map-sdk
If you are developing locally from the warehouse repo:
cd H:\workspace\data-warehouse\frontend
npm run build:lib
npm pack
Then install the generated tarball in the consumer repo.
3. Bootstrap the SDK
The easiest entry point is provideMapSdk(...).
import { ApplicationConfig } from '@angular/core';
import { provideMapSdk } from '@agastyadreamspty/map-sdk';
export const appConfig: ApplicationConfig = {
providers: [
provideMapSdk({
runtimeConfig: {
apiBaseUrl: 'http://localhost:8080/api',
legendEndpoint: '/map/legend',
defaultIncludeInactive: false
}
})
]
};
Filter-only integration
If your app only needs province and municipality filters, inject LocationService
directly and skip the full host UI.
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { LocationService, MapFeatureFilterOption } from '@agastyadreamspty/map-sdk';
@Component({
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
template: `
<label>
Province
<select [formControl]="provinceId">
<option [ngValue]="null">Select province</option>
<option *ngFor="let province of provinces" [ngValue]="province.id">
{{ province.name }}
</option>
</select>
</label>
<label>
Municipality
<select [formControl]="municipalityId">
<option [ngValue]="null">Select municipality</option>
<option *ngFor="let municipality of municipalities" [ngValue]="municipality.id">
{{ municipality.name }}
</option>
</select>
</label>
`
})
export class ParcelFiltersComponent implements OnInit {
private readonly locations = inject(LocationService);
provinceId = new FormControl<number | null>(null);
municipalityId = new FormControl<number | null>(null);
provinces: MapFeatureFilterOption[] = [];
municipalities: MapFeatureFilterOption[] = [];
ngOnInit(): void {
this.locations.getProvinces().subscribe((provinces) => {
this.provinces = provinces;
});
this.provinceId.valueChanges.subscribe((provinceId) => {
if (provinceId === null) {
this.municipalities = [];
this.municipalityId.setValue(null, { emitEvent: false });
return;
}
this.locations.getMunicipalities(provinceId).subscribe((municipalities) => {
this.municipalities = municipalities;
this.municipalityId.setValue(null, { emitEvent: false });
});
});
}
}
This keeps the SDK focused on supplying filter data while the consumer app owns the page layout and rendering.
When to use the lower-level providers
Use the lower-level providers when you want to override only one concern:
provideMapFeatureRuntimeConfig(...)provideMapFeatureHttpConfig(...)provideMapFeatureAdapter(...)provideMapFeatureDataSource(...)provideMapFeatureRendererAdapter(...)
4. Full-Page Host Setup
If you want the complete explorer experience, bind the main host component directly.
import { Component } from '@angular/core';
import { MapFeatureConfig, MapSdkHostComponent } from '@agastyadreamspty/map-sdk';
@Component({
standalone: true,
imports: [MapSdkHostComponent],
templateUrl: './map-page.component.html'
})
export class MapPageComponent {
mapFeatureConfig: Partial<MapFeatureConfig> = {
apiBaseUrl: 'http://localhost:8080/api',
includeInactive: false,
features: {
legend: true,
search: true,
provinceFilters: true,
municipalityFilters: true,
selectedMunicipalityOverlay: true,
refreshButton: true,
boundaries: true,
parcels: true
},
ui: {
title: 'Map Explorer',
subtitle: 'Browse boundaries, parcels, and live legend layers.',
themeClass: 'smoc-map-theme',
mapContainerId: 'smoc-map',
showStatusBanner: true,
showLoadingOverlay: true,
mountMode: 'full-page'
}
};
}
<dw-map-sdk-host
[config]="mapFeatureConfig"
[showLegend]="true"
[showSearch]="true"
(provinceChange)="onProvinceChange($event)"
(municipalityChange)="onMunicipalityChange($event)"
(layerClick)="onLayerClick($event)"
(layerVisibilityChange)="onLayerVisibilityChange($event)"
/>
Suggested event handling
- use
provinceChangeto update app state or URL filters - use
municipalityChangeto refresh context - use
layerVisibilityChangeto persist legend state - use
layerClickto open a side panel or local details drawer
5. Widgets-Only Composition
If the consumer app wants to keep the layout but reuse the controls, compose the widgets manually.
<section class="custom-map-shell">
<aside class="custom-map-shell__controls">
<dw-map-sdk-filters
[provinceOptions]="provinceOptions"
[municipalityOptions]="municipalityOptions"
[selectedProvinceId]="selectedProvinceId"
[selectedMunicipalityId]="selectedMunicipalityId"
[provinceLoading]="provinceLoading"
[municipalityLoading]="municipalityLoading"
(provinceChange)="onProvinceChange($event)"
(municipalityChange)="onMunicipalityChange($event)"
/>
<dw-map-sdk-legend-panel
[groups]="legendGroups"
[adminMode]="false"
(legendVisibilityToggle)="onLegendVisibilityToggle($event)"
(legendColorChange)="onLegendColorChange($event)"
/>
<dw-map-sdk-zoom-layer-status
[zoom]="zoom"
[summary]="zoomSummary"
/>
</aside>
<div class="custom-map-shell__canvas">
<!-- your own map vendor canvas -->
</div>
</section>
This pattern is ideal when you want the SDK UI but not the full explorer shell.
6. Parcel Search Integration
The parcel search widget is controlled entirely by inputs and outputs.
<dw-map-sdk-parcel-search
[query]="searchQuery"
[suggestions]="suggestions"
[loading]="searchLoading"
[status]="searchStatus"
[placeholder]="'Search parcels by LPI, SG number, or description'"
[autocompleteOpen]="autocompleteOpen"
[activeSuggestionIndex]="activeSuggestionIndex"
[isFocused]="searchFocused"
(queryChange)="onSearchQueryChange($event)"
(focus)="onSearchFocus()"
(blur)="onSearchBlur()"
(keydown)="onSearchKeydown($event)"
(clear)="onSearchClear()"
(suggestionSelect)="onSuggestionSelect($event)"
/>
Recommended behavior:
- search on typing only after the minimum length is reached
- select a suggestion on click or keyboard navigation
- fetch geometry when the consumer confirms the suggestion
- do not hard-code app state inside the widget
7. Custom Renderer Integration
Use MapFeatureRendererAdapter when your app owns the map canvas and the SDK
should only provide data, visibility rules, and parcel/legend behavior.
In this mode:
- the consumer app owns the actual map surface
- the SDK still loads provinces, municipalities, legend data, and parcel data
- the consumer decides how those features are drawn on the canvas
- the consumer decides how popups, drawers, and toolbars look
- the SDK still provides the same zoom thresholds and visibility rules used by the full host
What the adapter methods mean
import { MapFeatureRendererAdapter } from '@agastyadreamspty/map-sdk';
export const customRenderer: MapFeatureRendererAdapter = {
mount(container) {
myMap.mount(container);
},
setViewport(viewport) {
myMap.setViewport(viewport);
},
setZoomPolicy(policy) {
myMap.setZoomPolicy(policy);
},
setLayerVisibility(layerId, visible) {
myMap.setLayerVisibility(layerId, visible);
},
setLayerLabelsVisible(layerId, visible) {
myMap.setLayerLabelsVisible(layerId, visible);
},
setLayerStyle(layerId, style) {
myMap.setLayerStyle(layerId, style);
},
renderLayer(layerId, geometry, descriptor) {
myMap.renderLayer(layerId, geometry, descriptor);
},
clearLayer(layerId) {
myMap.clearLayer(layerId);
},
clearAll() {
myMap.clearAll();
},
destroy() {
myMap.destroy();
}
};
mount(container): attach your vendor canvas to the DOM container created by the SDK or your shell.setViewport(viewport): move the map to the requested center, zoom, and bounds.setZoomPolicy(policy): keep your renderer in sync with the SDK zoom thresholds.setLayerVisibility(layerId, visible): show or hide an existing logical layer.setLayerLabelsVisible(layerId, visible): toggle labels independently from geometry.setLayerStyle(layerId, style): restyle the layer after legend or theme changes.renderLayer(layerId, geometry, descriptor?): draw the supplied GeoJSON-like geometry in your canvas.clearLayer(layerId): remove one logical layer.clearAll(): remove every SDK-managed layer from the canvas.destroy(): tear down listeners, sources, layers, and DOM hooks.getZoomState(): optional hook when your renderer can report zoom state back to the SDK.
The important idea is that the SDK does not own the canvas in this mode. The consumer app owns the canvas, while the SDK still owns the data contract and visibility contract.
Recommended consumer structure
The easiest pattern is:
- provide the SDK runtime config once during app bootstrap
- create your own map component or service wrapper
- inject
MapSdkServiceandLocationService - implement the renderer adapter around your map vendor
- call the SDK services when the user changes province, municipality, search, or legend state
- pass the returned geometry into your renderer
- keep popups and side panels in the consumer app
import {
LocationService,
MapFeatureParcelSearchSuggestion,
MAP_SDK_RENDERER,
MapFeatureRendererAdapter,
MapSdkService
} from '@agastyadreamspty/map-sdk';
import { Inject, Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CustomMapFacade {
constructor(
private readonly map: MapSdkService,
private readonly locations: LocationService,
@Inject(MAP_SDK_RENDERER) private readonly renderer: MapFeatureRendererAdapter
) {}
connect(container: HTMLElement): void {
this.renderer.mount(container);
}
loadProvinces() {
return this.locations.getProvinces();
}
loadMunicipalities(provinceId: number) {
return this.locations.getMunicipalities(provinceId);
}
loadParcelSearch(provinceId: number, municipalityId: number | null, query: string) {
return this.map.searchParcels(provinceId, municipalityId, query, 10);
}
loadParcelGeometry(provinceId: number, suggestion: MapFeatureParcelSearchSuggestion) {
return this.map.getParcelGeometryBySuggestion(provinceId, suggestion);
}
loadProvinceGeometry(provinceId: number) {
return this.map.getProvinceGeometry(provinceId);
}
}
How parcel layers should behave
Parcel rendering in the custom-renderer path should follow the same business rules as the full SDK host:
- the parcel layer is only relevant after a province is selected
- municipality selection narrows the parcel scope
- parcel search suggestions are fetched from the SDK, not hard-coded in the app
- when a suggestion is selected, the app requests parcel geometry
- the geometry is then rendered into the consumer-owned canvas as a logical parcel layer
- parcel visibility follows the active zoom policy
- parcel labels should follow the parcel label zoom threshold
- parcel features should clear when the province, municipality, or active search context changes
Typical parcel flow:
- user types a parcel search term
- consumer app calls
MapSdkService.searchParcels(...) - user picks a suggestion
- consumer app calls
MapSdkService.getParcelGeometryBySuggestion(...) - consumer app passes the returned geometry to
renderer.renderLayer(...) - consumer app fits the map viewport to the returned bounds or geometry extent
- if the user clicks the parcel, the app can call
MapSdkService.getParcelDetail(...) - the app shows the parcel popup or side panel using its own UI
Use a stable layer id for parcels, such as:
parcel-layerparcel-searchparcel:${suggestion.recordId}
The exact naming is up to the consumer, but it should be stable so visibility, style, and clear operations target the same logical layer.
How boundary and legend behavior should work
The same pattern applies to boundary and legend layers:
- call
loadProvinces()andloadMunicipalities()for the location filters - call
loadLegend(...)for the visible boundary and parcel categories - use the zoom policy to decide when each boundary family should become visible
- call
getBoundaryZoom(...)andgetParcelZoom(...)when you need the SDK thresholds - call
getBoundaryLabelState(...)andgetParcelLabelState(...)when you want label visibility to match the SDK - use
setLayerVisibility(...)andsetLayerLabelsVisible(...)to keep the canvas aligned with the current state
This keeps the custom renderer visually consistent with the full-page host even though the app is drawing the map itself.
How other SDK functionality fits in
If the consumer app wants additional SDK features, it can layer them on without switching away from the custom renderer path:
- use
LocationServicefor province and municipality dropdowns - use
MapSdkService.loadLegend(...)for legend panels or layer toggles - use
MapSdkService.getProvinceGeometry(...)andMapSdkService.getMunicipalityGeometry(...)for focus and selection overlays - use
MapSdkService.getParcelDetail(...)for click popups or detail drawers - use
MapFeatureAdapteronly if you need to replace the backend contract itself - use the widget components only if you want SDK-controlled UI widgets around your own canvas
Example provider setup
import { provideMapSdk } from '@agastyadreamspty/map-sdk';
provideMapSdk({
runtimeConfig: {
apiBaseUrl: 'http://localhost:8080/api',
legendEndpoint: '/map/legend',
defaultIncludeInactive: false
},
rendererAdapter: customRenderer
});
The renderer adapter is what lets your app keep its own canvas implementation while still using the SDK's API, zoom rules, and layer-state contract.
Direct API + Vendor Canvas Files
Use this mode when you do not want the SDK full UI at all. The consumer app owns the canvas and calls the SDK services directly:
LocationServicefor province and municipality dropdown dataMapSdkServicefor legend, geometry, search, and click-detail lookupsMapFeatureHttpDataSourcefor the province, district, municipality, township, suburb, and parcel MVT tile URL builders
The layer contract is simple:
- Province boundary tiles:
buildProvinceBoundaryTileUrl(provinceId) - District boundary tiles:
buildDistrictBoundaryTileUrl(provinceId) - Municipality boundary tiles:
buildMunicipalityBoundaryTileUrl(provinceId) - Township boundary tiles:
buildTownshipBoundaryTileUrl(provinceId) - Suburb boundary tiles:
buildSuburbBoundaryTileUrl(provinceId) - Parcel tiles:
buildParcelTileUrl(provinceId, municipalityId, 'ERF,FARM')
The tabs below show a copy-paste-ready vendor file for each library. Each file uses the SDK tile builders and keeps the app in control of the canvas, popups, and route state.
The same files live in examples/host-example/direct-api/ inside this package.
If you want a single entry point for the examples, start from
examples/host-example/direct-api/sample-app/main.ts.
The SDK package includes leaflet and leaflet.vectorgrid as runtime
dependencies, so Leaflet consumers do not need to install them separately.
=== "Leaflet"
`leaflet-map-canvas.service.ts`
```ts
import { Injectable } from '@angular/core';
import * as L from 'leaflet';
import 'leaflet.vectorgrid';
import {
MapFeatureDetailResponse,
MapFeatureHttpDataSource,
MapFeatureParcelSearchSuggestion,
MapSdkService
} from '@agastyadreamspty/map-sdk';
@Injectable({ providedIn: 'root' })
export class LeafletMapCanvasService {
private map: L.Map | null = null;
private readonly layers = new globalThis.Map<string, L.Layer>();
constructor(
private readonly tiles: MapFeatureHttpDataSource,
private readonly sdk: MapSdkService
) {}
mount(container: HTMLElement): void {
this.map = L.map(container, { zoomControl: true }).setView([-30.5595, 22.9375], 7);
}
setScope(provinceId: number, municipalityId: number | null): void {
this.clearAll();
this.addBoundaryLayer('province-boundary', this.tiles.buildProvinceBoundaryTileUrl(provinceId), 'provinces');
this.addBoundaryLayer('district-boundary', this.tiles.buildDistrictBoundaryTileUrl(provinceId), 'districts');
this.addBoundaryLayer('municipality-boundary', this.tiles.buildMunicipalityBoundaryTileUrl(provinceId), 'municipalities');
this.addBoundaryLayer('township-boundary', this.tiles.buildTownshipBoundaryTileUrl(provinceId), 'townships');
this.addBoundaryLayer('suburb-boundary', this.tiles.buildSuburbBoundaryTileUrl(provinceId), 'suburbs');
this.addParcelLayer(provinceId, municipalityId);
}
onParcelClick(handler: (detail: MapFeatureDetailResponse | null) => void): void {
const layer = this.layers.get('parcel-layer') as any;
layer?.off?.('click');
layer?.on?.('click', (event: any) => {
const props = event?.layer?.properties ?? event?.properties ?? {};
const sourceId = String(props['sourceId'] ?? props['source_uid'] ?? props['parcelKey'] ?? '');
const parcelType = String(props['parcelType'] ?? props['sourceKind'] ?? 'ERF');
const provinceId = Number(props['provinceId'] ?? props['province_id'] ?? 0);
if (!provinceId || !sourceId) {
handler(null);
return;
}
this.sdk.getParcelDetail(provinceId, parcelType, sourceId).subscribe(handler);
});
}
clearAll(): void {
this.layers.forEach((layer) => {
layer.remove();
});
this.layers.clear();
}
destroy(): void {
this.clearAll();
this.map?.remove();
this.map = null;
}
private addBoundaryLayer(layerId: string, url: string, sourceLayer: string): void {
this.addVectorTileLayer(layerId, url, sourceLayer, {
color: '#0f766e',
weight: 2,
fill: false
});
}
private addParcelLayer(provinceId: number, municipalityId: number | null): void {
const url = this.tiles.buildParcelTileUrl(provinceId, municipalityId, 'ERF,FARM');
this.addVectorTileLayer('parcel-layer', url, 'parcels', {
color: '#0f766e',
weight: 2,
fill: true,
fillOpacity: 0.18
});
}
private addVectorTileLayer(
layerId: string,
url: string,
sourceLayer: string,
style: Record<string, unknown>
): void {
if (!this.map) return;
const vectorGrid = (L as any).vectorGrid.protobuf(url, {
interactive: true,
rendererFactory: (L as any).canvas.tile,
vectorTileLayerStyles: {
[sourceLayer]: style
},
getFeatureId: (feature: any) =>
feature?.properties?.sourceId ??
feature?.properties?.source_uid ??
feature?.properties?.id ??
feature?.id ??
layerId
});
vectorGrid.addTo(this.map);
this.layers.set(layerId, vectorGrid);
}
}
```
=== "Mapbox GL"
`mapbox-gl-map-canvas.service.ts`
```ts
import { Injectable } from '@angular/core';
import mapboxgl from 'mapbox-gl';
import {
MapFeatureDetailResponse,
MapFeatureHttpDataSource,
MapFeatureParcelSearchSuggestion,
MapSdkService
} from '@agastyadreamspty/map-sdk';
@Injectable({ providedIn: 'root' })
export class MapboxGlMapCanvasService {
private map: mapboxgl.Map | null = null;
private readonly sourceIds = new Set<string>();
constructor(
private readonly tiles: MapFeatureHttpDataSource,
private readonly sdk: MapSdkService
) {}
mount(container: HTMLElement): void {
this.map = new mapboxgl.Map({
container,
style: 'mapbox://styles/mapbox/streets-v12',
center: [22.9375, -30.5595],
zoom: 7
});
}
setScope(provinceId: number, municipalityId: number | null): void {
this.clearAll();
this.addBoundarySource('province-boundary', this.tiles.buildProvinceBoundaryTileUrl(provinceId), 'provinces', 'line');
this.addBoundarySource('district-boundary', this.tiles.buildDistrictBoundaryTileUrl(provinceId), 'districts', 'line');
this.addBoundarySource('municipality-boundary', this.tiles.buildMunicipalityBoundaryTileUrl(provinceId), 'municipalities', 'line');
this.addBoundarySource('township-boundary', this.tiles.buildTownshipBoundaryTileUrl(provinceId), 'townships', 'line');
this.addBoundarySource('suburb-boundary', this.tiles.buildSuburbBoundaryTileUrl(provinceId), 'suburbs', 'line');
this.addParcelSource('parcel-layer', this.tiles.buildParcelTileUrl(provinceId, municipalityId, 'ERF,FARM'));
}
onParcelClick(handler: (detail: MapFeatureDetailResponse | null) => void): void {
if (!this.map) return;
this.map.off('click', 'parcel-fill');
this.map.on('click', 'parcel-fill', (event) => {
const feature = event.features?.[0];
const props = feature?.properties ?? {};
const sourceId = String(props['sourceId'] ?? props['source_uid'] ?? props['parcelKey'] ?? '');
const parcelType = String(props['parcelType'] ?? props['sourceKind'] ?? 'ERF');
const provinceId = Number(props['provinceId'] ?? props['province_id'] ?? 0);
if (!provinceId || !sourceId) {
handler(null);
return;
}
this.sdk.getParcelDetail(provinceId, parcelType, sourceId).subscribe(handler);
});
}
clearAll(): void {
if (!this.map) return;
Array.from(this.sourceIds).forEach((layerId) => {
if (this.map?.getLayer(`${layerId}-line`)) this.map.removeLayer(`${layerId}-line`);
if (this.map?.getLayer(`${layerId}-fill`)) this.map.removeLayer(`${layerId}-fill`);
if (this.map?.getSource(layerId)) this.map.removeSource(layerId);
});
this.sourceIds.clear();
}
destroy(): void {
this.clearAll();
this.map?.remove();
this.map = null;
}
private addBoundarySource(layerId: string, tileUrl: string, sourceLayer: string, layerType: 'line' | 'fill'): void {
if (!this.map) return;
this.map.addSource(layerId, {
type: 'vector',
tiles: [tileUrl]
});
this.sourceIds.add(layerId);
const layerConfig: mapboxgl.AnyLayer =
layerType === 'fill'
? {
id: `${layerId}-fill`,
type: 'fill',
source: layerId,
'source-layer': sourceLayer,
paint: {
'fill-color': '#0f766e',
'fill-opacity': 0.16
}
}
: {
id: `${layerId}-line`,
type: 'line',
source: layerId,
'source-layer': sourceLayer,
paint: {
'line-color': '#0f766e',
'line-width': 2
}
};
this.map.addLayer(layerConfig);
}
private addParcelSource(layerId: string, tileUrl: string): void {
if (!this.map) return;
this.map.addSource(layerId, {
type: 'vector',
tiles: [tileUrl]
});
this.sourceIds.add(layerId);
this.map.addLayer({
id: 'parcel-fill',
type: 'fill',
source: layerId,
'source-layer': 'parcels',
paint: {
'fill-color': '#0f766e',
'fill-opacity': 0.18
}
});
this.map.addLayer({
id: 'parcel-line',
type: 'line',
source: layerId,
'source-layer': 'parcels',
paint: {
'line-color': '#0f766e',
'line-width': 1.5
}
});
}
}
```
=== "OpenLayers"
`openlayers-map-canvas.service.ts`
```ts
import { Injectable } from '@angular/core';
import Map from 'ol/Map';
import View from 'ol/View';
import MVT from 'ol/format/MVT';
import VectorTileLayer from 'ol/layer/VectorTile';
import VectorTileSource from 'ol/source/VectorTile';
import { Fill, Stroke, Style } from 'ol/style';
import {
MapFeatureDetailResponse,
MapFeatureHttpDataSource,
MapFeatureParcelSearchSuggestion,
MapSdkService
} from '@agastyadreamspty/map-sdk';
@Injectable({ providedIn: 'root' })
export class OpenLayersMapCanvasService {
private map: Map | null = null;
private readonly layers = new globalThis.Map<string, VectorTileLayer<VectorTileSource>>();
constructor(
private readonly tiles: MapFeatureHttpDataSource,
private readonly sdk: MapSdkService
) {}
mount(container: HTMLElement): void {
this.map = new Map({
target: container,
view: new View({
center: [0, 0],
zoom: 7
})
});
}
setScope(provinceId: number, municipalityId: number | null): void {
this.clearAll();
this.addTileLayer('province-boundary', this.tiles.buildProvinceBoundaryTileUrl(provinceId), 'provinces', this.boundaryStyle('#0f766e'));
this.addTileLayer('district-boundary', this.tiles.buildDistrictBoundaryTileUrl(provinceId), 'districts', this.boundaryStyle('#115e59'));
this.addTileLayer('municipality-boundary', this.tiles.buildMunicipalityBoundaryTileUrl(provinceId), 'municipalities', this.boundaryStyle('#0f766e'));
this.addTileLayer('township-boundary', this.tiles.buildTownshipBoundaryTileUrl(provinceId), 'townships', this.boundaryStyle('#0891b2'));
this.addTileLayer('suburb-boundary', this.tiles.buildSuburbBoundaryTileUrl(provinceId), 'suburbs', this.boundaryStyle('#334155'));
this.addTileLayer('parcel-layer', this.tiles.buildParcelTileUrl(provinceId, municipalityId, 'ERF,FARM'), 'parcels', this.parcelStyle());
}
onParcelClick(handler: (detail: MapFeatureDetailResponse | null) => void): void {
if (!this.map) return;
this.map.on('singleclick', (event) => {
let parcelFeature: any = null;
this.map?.forEachFeatureAtPixel(event.pixel, (feature, layer) => {
if ((layer as any)?.get?.('id') === 'parcel-layer') {
parcelFeature = feature;
return true;
}
return false;
});
if (!parcelFeature) {
return;
}
const props = parcelFeature.getProperties?.() ?? {};
const sourceId = String(props['sourceId'] ?? props['source_uid'] ?? props['parcelKey'] ?? '');
const parcelType = String(props['parcelType'] ?? props['sourceKind'] ?? 'ERF');
const provinceId = Number(props['provinceId'] ?? props['province_id'] ?? 0);
if (!provinceId || !sourceId) {
handler(null);
return;
}
this.sdk.getParcelDetail(provinceId, parcelType, sourceId).subscribe(handler);
});
}
clearAll(): void {
if (!this.map) return;
this.layers.forEach((layer) => {
this.map?.removeLayer(layer);
});
this.layers.clear();
}
destroy(): void {
this.clearAll();
this.map?.setTarget(undefined);
this.map = null;
}
private addTileLayer(
id: string,
tileUrl: string,
sourceLayer: string,
style: Style
): void {
if (!this.map) return;
const layer = new VectorTileLayer({
properties: { id },
source: new VectorTileSource({
format: new MVT(),
url: tileUrl
}),
style: (feature) => {
const layerName = sourceLayer;
void feature;
void layerName;
return style;
}
});
this.layers.set(id, layer);
this.map.addLayer(layer);
}
private boundaryStyle(color: string): Style {
return new Style({
stroke: new Stroke({
color,
width: 2
})
});
}
private parcelStyle(): Style {
return new Style({
stroke: new Stroke({
color: '#0f766e',
width: 1.5
}),
fill: new Fill({
color: 'rgba(15, 118, 110, 0.18)'
})
});
}
}
```
How this direct-API pattern works:
- the app loads dropdown data from
LocationService - the app asks
MapFeatureHttpDataSourcefor the tile URL of each layer it wants to show - the vendor renderer creates one layer per logical map family
- parcel MVT tiles are handled like the other layers, but the parcel layer usually gets click handling and popup details from
MapSdkService.getParcelDetail(...) - when the user changes province or municipality, the app rebuilds the affected tile layers
- if the user needs to inspect parcel data without tiles, the app can still call
MapSdkService.searchParcels(...)orMapSdkService.getParcelGeometryBySuggestion(...)from the same flow
Layer Lifecycle By Family
The direct-API path works best when the app treats each map family as its own logical layer and rebuilds only the layers that depend on the active scope.
Province boundary
- build the tile URL with
MapFeatureHttpDataSource.buildProvinceBoundaryTileUrl(provinceId) - mount it after the user selects a province
- clear it when the province changes or when the app resets the map
- keep it visible as the parent boundary for the rest of the selected scope
District boundary
- build the tile URL with
MapFeatureHttpDataSource.buildDistrictBoundaryTileUrl(provinceId) - use it when the app wants district context inside the selected province
- rebuild it together with the province layer if the province changes
- use the app's own zoom rules to hide or show it if the vendor supports that
Municipality boundary
- build the tile URL with
MapFeatureHttpDataSource.buildMunicipalityBoundaryTileUrl(provinceId) - rebuild it when province or municipality scope changes
- keep the layer id stable so the consumer can toggle or clear it without guessing
- use the SDK
MapSdkService.getMunicipalityGeometry(...)path if the app also wants a geometry overlay or focus extent
Suburb boundary
- build the tile URL with
MapFeatureHttpDataSource.buildSuburbBoundaryTileUrl(provinceId) - treat it as a lower-level context layer beneath municipality
- clear or rebuild it when the province scope changes
- keep styling subtle so it does not compete with the selected parcel layer
Township boundary
- build the tile URL with
MapFeatureHttpDataSource.buildTownshipBoundaryTileUrl(provinceId) - use the
townshipsMVT source layer from zoom level10 - rebuild it when the province changes
- use
MapSdkDirectApiFacade.getTownshipBoundaryDetails(...)for click details
Parcel MVT Flow
Parcel tiles are the most important direct-API flow because they usually need both tile rendering and click-to-detail behavior.
Recommended flow:
- select province
- optionally select municipality
- build the parcel tile URL with
MapFeatureHttpDataSource.buildParcelTileUrl(provinceId, municipalityId, 'ERF,FARM') - mount the parcel tile layer using the vendor-specific source or vector grid
- keep the parcel layer id stable, such as
parcel-layer - wire click handling from the rendered parcel feature to
MapSdkService.getParcelDetail(...) - render the returned detail into a side panel, drawer, or popup owned by the app
Suggested parcel feature fields to read from the tile payload:
sourceIdparcelTypeparcelKeymunicipalityIdlegendKeydisplay_namenametag_xtag_y
The parcelType value is ERF, FARM, FARM_PORTION, or PARENT_FARM and
must be passed unchanged to MapSdkService.getParcelDetail(...). Together with
sourceId and the province selected by provinceId, it uniquely selects the
parcel detail record without exposing a source-table parameter.
- provinceId
- province_id
- parcelKey
If a parcel click event does not include enough metadata, the consumer should show a fallback state instead of guessing.
Boundary Detail Flow
Boundary layers are visually similar to parcel layers, but the consumer app usually treats them as context layers instead of interactive records.
Use them this way:
- province boundary: select the province, mount the province tile layer, and use
MapSdkService.getProvinceGeometry(provinceId)when the page also needs a focus overlay or a zoom-to-fit boundary - district boundary: mount the district tile layer for the active province, and
use the clicked feature metadata with
MapSdkService.getAdminBoundaryDetails(...)when the consumer needs a detail drawer or popup - municipality boundary: mount the municipality tile layer for the active
province, and use
MapSdkService.getMunicipalityGeometry(provinceId, municipalityId)when the consumer wants a geometry overlay or an extent reset - suburb boundary: mount the suburb tile layer as a lower-level context layer,
and use
MapSdkService.getAdminBoundaryDetails(...)or the feature metadata from the click event when the consumer wants detail content
Today, province and municipality have the clearest dedicated geometry helpers. District and suburb detail flows still rely on the shared admin detail lookup or on the clicked tile payload, which is why the docs keep them in the consumer canvas pattern instead of pretending there is a dedicated helper for each one.
Practical rule:
- tile layers are for drawing
- geometry/detail calls are for overlays, panels, zoom-to-fit, or record inspection
If the clicked boundary record does not provide enough information for a detail call, keep the map visible and show a fallback message in the app-owned panel.
Loading, Empty, and Error States
The SDK gives the consumer data and contracts, but the consumer app still owns the visible states.
Recommended states:
- loading: show a local spinner or skeleton while the app requests tiles or geometry
- empty: show a helper message when a layer returns no data for the current scope
- error: show a retry message when a tile or detail request fails
- stale: clear the panel or layer when province or municipality changes and a previous request is no longer relevant
For direct-API consumers, the app should keep its own request identifiers,
switchMap/subscription cancelation, or a scoped request token so a slower
response from the previous province does not overwrite the current map state.
Recommended guard pattern:
- capture the active province and municipality before calling the SDK
- store a local request token or rely on observable cancelation
- ignore responses that arrive for an older token
- clear the layer or detail panel before mounting a new scope
This matters most for parcel tiles because the user can change province or municipality while the previous request is still in flight.
What The Consumer App Owns
In the direct-API path, the consumer should own:
- the map canvas
- the route state
- the loading and error UI
- the popup or details panel
- the layer ids
- the feature click behavior
- the vendor-specific styling
The SDK should own:
- the API surface
- the tile URL builders
- the geometry and detail fetch methods
- the filter data calls
- the shared rules for what data belongs to a selected scope
The earlier end-to-end custom map component example still applies if you want a generic component shell around the canvas. The vendor files above are the copy-paste-ready pieces for the direct tile path.
What still stays outside the SDK today:
- the app's own map canvas instance
- vendor-specific popup rendering
- route and query-parameter synchronization
- request cancelation policy
- panel layout and empty-state copy
- any custom legend behavior that is not driven by the shared contract
Leaflet -> bootstrap app -> setScope(...) -> vector grid tiles -> click parcel -> load detail
Mapbox GL -> bootstrap app -> setScope(...) -> vector source tiles -> click parcel -> load detail
OpenLayers -> bootstrap app -> setScope(...) -> vector tile layers -> click parcel -> load detail
Full consumer page examples
The tabs below show how a consumer app can use each vendor-specific canvas service from the previous section.
All three examples follow the same lifecycle:
- bootstrap the SDK once in
app.config.ts - fetch province and municipality lists from
LocationService - mount the vendor canvas service into the page container
- call
setScope(provinceId, municipalityId)when the user changes filters - use
onParcelClick(...)or the vendor click callback to load parcel details - let the page own the sidebar, popup, and route state
=== "Leaflet page"
```ts
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideMapSdk } from '@agastyadreamspty/map-sdk';
export const appConfig: ApplicationConfig = {
providers: [
provideMapSdk({
runtimeConfig: {
apiBaseUrl: 'http://localhost:8080/api',
legendEndpoint: '/map/legend',
defaultIncludeInactive: false
}
})
]
};
```
```ts
// leaflet-map-page.component.ts
import { CommonModule } from '@angular/common';
import { AfterViewInit, Component, ElementRef, ViewChild, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import {
LocationService,
MapFeatureDetailResponse,
MapFeatureFilterOption
} from '@agastyadreamspty/map-sdk';
import { LeafletMapCanvasService } from './leaflet-map-canvas.service';
@Component({
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
template: `
<section class="map-page">
<header class="map-page__toolbar">
<select [formControl]="provinceId">
<option [ngValue]="null">Select province</option>
<option *ngFor="let province of provinces" [ngValue]="province.id">
{{ province.name }}
</option>
</select>
<select [formControl]="municipalityId">
<option [ngValue]="null">Select municipality</option>
<option *ngFor="let municipality of municipalities" [ngValue]="municipality.id">
{{ municipality.name }}
</option>
</select>
</header>
<div class="map-page__layout">
<div #mapCanvas class="map-page__canvas"></div>
<aside class="map-page__panel">
<pre>{{ parcelDetail | json }}</pre>
</aside>
</div>
</section>
`
})
export class LeafletMapPageComponent implements AfterViewInit {
private readonly locations = inject(LocationService);
private readonly canvas = inject(LeafletMapCanvasService);
@ViewChild('mapCanvas', { static: true }) private readonly mapCanvas!: ElementRef<HTMLElement>;
provinceId = new FormControl<number | null>(null);
municipalityId = new FormControl<number | null>(null);
provinces: MapFeatureFilterOption[] = [];
municipalities: MapFeatureFilterOption[] = [];
parcelDetail: MapFeatureDetailResponse | null = null;
ngAfterViewInit(): void {
this.canvas.mount(this.mapCanvas.nativeElement);
this.locations.getProvinces().subscribe((provinces) => {
this.provinces = provinces;
});
this.provinceId.valueChanges.subscribe((provinceId) => {
if (provinceId === null) {
this.municipalities = [];
this.municipalityId.setValue(null, { emitEvent: false });
this.canvas.clearAll();
return;
}
this.locations.getMunicipalities(provinceId).subscribe((municipalities) => {
this.municipalities = municipalities;
});
this.canvas.setScope(provinceId, this.municipalityId.value);
this.bindParcelClick();
});
this.municipalityId.valueChanges.subscribe(() => {
const provinceId = this.provinceId.value;
if (provinceId === null) return;
this.canvas.setScope(provinceId, this.municipalityId.value);
this.bindParcelClick();
});
}
private bindParcelClick(): void {
this.canvas.onParcelClick((detail) => {
this.parcelDetail = detail;
});
}
}
```
=== "Mapbox GL page"
```ts
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideMapSdk } from '@agastyadreamspty/map-sdk';
export const appConfig: ApplicationConfig = {
providers: [
provideMapSdk({
runtimeConfig: {
apiBaseUrl: 'http://localhost:8080/api',
legendEndpoint: '/map/legend',
defaultIncludeInactive: false
}
})
]
};
```
```ts
// mapbox-gl-map-page.component.ts
import { CommonModule } from '@angular/common';
import { AfterViewInit, Component, ElementRef, ViewChild, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import {
LocationService,
MapFeatureDetailResponse,
MapFeatureFilterOption
} from '@agastyadreamspty/map-sdk';
import { MapboxGlMapCanvasService } from './mapbox-gl-map-canvas.service';
@Component({
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
template: `
<section class="map-page">
<header class="map-page__toolbar">
<select [formControl]="provinceId">
<option [ngValue]="null">Select province</option>
<option *ngFor="let province of provinces" [ngValue]="province.id">
{{ province.name }}
</option>
</select>
<select [formControl]="municipalityId">
<option [ngValue]="null">Select municipality</option>
<option *ngFor="let municipality of municipalities" [ngValue]="municipality.id">
{{ municipality.name }}
</option>
</select>
</header>
<div class="map-page__layout">
<div #mapCanvas class="map-page__canvas"></div>
<aside class="map-page__panel">
<pre>{{ parcelDetail | json }}</pre>
</aside>
</div>
</section>
`
})
export class MapboxGlMapPageComponent implements AfterViewInit {
private readonly locations = inject(LocationService);
private readonly canvas = inject(MapboxGlMapCanvasService);
@ViewChild('mapCanvas', { static: true }) private readonly mapCanvas!: ElementRef<HTMLElement>;
provinceId = new FormControl<number | null>(null);
municipalityId = new FormControl<number | null>(null);
provinces: MapFeatureFilterOption[] = [];
municipalities: MapFeatureFilterOption[] = [];
parcelDetail: MapFeatureDetailResponse | null = null;
ngAfterViewInit(): void {
this.canvas.mount(this.mapCanvas.nativeElement);
this.locations.getProvinces().subscribe((provinces) => {
this.provinces = provinces;
});
this.provinceId.valueChanges.subscribe((provinceId) => {
if (provinceId === null) {
this.municipalities = [];
this.municipalityId.setValue(null, { emitEvent: false });
this.canvas.clearAll();
return;
}
this.locations.getMunicipalities(provinceId).subscribe((municipalities) => {
this.municipalities = municipalities;
});
this.canvas.setScope(provinceId, this.municipalityId.value);
this.bindParcelClick();
});
this.municipalityId.valueChanges.subscribe(() => {
const provinceId = this.provinceId.value;
if (provinceId === null) return;
this.canvas.setScope(provinceId, this.municipalityId.value);
this.bindParcelClick();
});
}
private bindParcelClick(): void {
this.canvas.onParcelClick((detail) => {
this.parcelDetail = detail;
});
}
}
```
=== "OpenLayers page"
```ts
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideMapSdk } from '@agastyadreamspty/map-sdk';
export const appConfig: ApplicationConfig = {
providers: [
provideMapSdk({
runtimeConfig: {
apiBaseUrl: 'http://localhost:8080/api',
legendEndpoint: '/map/legend',
defaultIncludeInactive: false
}
})
]
};
```
```ts
// openlayers-map-page.component.ts
import { CommonModule } from '@angular/common';
import { AfterViewInit, Component, ElementRef, ViewChild, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import {
LocationService,
MapFeatureDetailResponse,
MapFeatureFilterOption
} from '@agastyadreamspty/map-sdk';
import { OpenLayersMapCanvasService } from './openlayers-map-canvas.service';
@Component({
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
template: `
<section class="map-page">
<header class="map-page__toolbar">
<select [formControl]="provinceId">
<option [ngValue]="null">Select province</option>
<option *ngFor="let province of provinces" [ngValue]="province.id">
{{ province.name }}
</option>
</select>
<select [formControl]="municipalityId">
<option [ngValue]="null">Select municipality</option>
<option *ngFor="let municipality of municipalities" [ngValue]="municipality.id">
{{ municipality.name }}
</option>
</select>
</header>
<div class="map-page__layout">
<div #mapCanvas class="map-page__canvas"></div>
<aside class="map-page__panel">
<pre>{{ parcelDetail | json }}</pre>
</aside>
</div>
</section>
`
})
export class OpenLayersMapPageComponent implements AfterViewInit {
private readonly locations = inject(LocationService);
private readonly canvas = inject(OpenLayersMapCanvasService);
@ViewChild('mapCanvas', { static: true }) private readonly mapCanvas!: ElementRef<HTMLElement>;
provinceId = new FormControl<number | null>(null);
municipalityId = new FormControl<number | null>(null);
provinces: MapFeatureFilterOption[] = [];
municipalities: MapFeatureFilterOption[] = [];
parcelDetail: MapFeatureDetailResponse | null = null;
ngAfterViewInit(): void {
this.canvas.mount(this.mapCanvas.nativeElement);
this.locations.getProvinces().subscribe((provinces) => {
this.provinces = provinces;
});
this.provinceId.valueChanges.subscribe((provinceId) => {
if (provinceId === null) {
this.municipalities = [];
this.municipalityId.setValue(null, { emitEvent: false });
this.canvas.clearAll();
return;
}
this.locations.getMunicipalities(provinceId).subscribe((municipalities) => {
this.municipalities = municipalities;
});
this.canvas.setScope(provinceId, this.municipalityId.value);
this.bindParcelClick();
});
this.municipalityId.valueChanges.subscribe(() => {
const provinceId = this.provinceId.value;
if (provinceId === null) return;
this.canvas.setScope(provinceId, this.municipalityId.value);
this.bindParcelClick();
});
}
private bindParcelClick(): void {
this.canvas.onParcelClick((detail) => {
this.parcelDetail = detail;
});
}
}
```
The important part is the shape of the interaction, not the vendor:
- the SDK gives you the data and tile URLs
- the app creates the map layers
- the app owns the parcel click handling and panel rendering
- the app can still use
MapSdkService.searchParcels(...)andMapSdkService.getParcelGeometryBySuggestion(...)if it wants suggestion-driven parcel lookup as well as tile rendering
End-to-end custom map component
This example shows the full consumer flow when the app owns the canvas:
- bootstrap the SDK once
- mount the app-owned map vendor into a local container
- load provinces and municipalities through the SDK
- load parcel suggestions through the SDK
- render parcel geometry into the app's canvas
- use the SDK parcel detail call when the user clicks a rendered parcel
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideMapSdk } from '@agastyadreamspty/map-sdk';
import { customRenderer } from './map-vendor.renderer';
export const appConfig: ApplicationConfig = {
providers: [
provideMapSdk({
runtimeConfig: {
apiBaseUrl: 'http://localhost:8080/api',
legendEndpoint: '/map/legend',
defaultIncludeInactive: false
},
rendererAdapter: customRenderer
})
]
};
// custom-map.component.ts
import {
AfterViewInit,
Component,
ElementRef,
Inject,
OnDestroy,
ViewChild,
inject
} from '@angular/core';
import {
LocationService,
MAP_SDK_RENDERER,
MapFeatureGeometryResponse,
MapFeatureParcelSearchResponse,
MapFeatureParcelSearchSuggestion,
MapFeatureRendererAdapter,
MapSdkService
} from '@agastyadreamspty/map-sdk';
import { Subject, takeUntil } from 'rxjs';
@Component({
standalone: true,
selector: 'app-custom-map',
template: `
<section class="custom-map-shell">
<header class="custom-map-shell__toolbar">
<select [value]="selectedProvinceId ?? ''" (change)="onProvinceChange($any($event.target).value)">
<option value="">Select province</option>
<option *ngFor="let province of provinces" [value]="province.id">
{{ province.name }}
</option>
</select>
<select [value]="selectedMunicipalityId ?? ''" (change)="onMunicipalityChange($any($event.target).value)">
<option value="">Select municipality</option>
<option *ngFor="let municipality of municipalities" [value]="municipality.id">
{{ municipality.name }}
</option>
</select>
<input
[value]="searchTerm"
placeholder="Search parcels"
(input)="onSearchTermChange($any($event.target).value)"
/>
</header>
<div class="custom-map-shell__body">
<div #mapCanvas class="custom-map-shell__canvas"></div>
<aside class="custom-map-shell__panel">
<button
*ngFor="let suggestion of suggestions"
type="button"
(click)="onSuggestionSelect(suggestion)"
>
{{ suggestion.displayLabel }}
</button>
<pre *ngIf="parcelDetailsJson">{{ parcelDetailsJson }}</pre>
</aside>
</div>
</section>
`
})
export class CustomMapComponent implements AfterViewInit, OnDestroy {
private readonly map = inject(MapSdkService);
private readonly locations = inject(LocationService);
@Inject(MAP_SDK_RENDERER) private readonly renderer!: MapFeatureRendererAdapter;
private readonly destroy$ = new Subject<void>();
@ViewChild('mapCanvas', { static: true }) private readonly mapCanvas!: ElementRef<HTMLElement>;
provinces: Array<{ id: number; name: string }> = [];
municipalities: Array<{ id: number; name: string }> = [];
suggestions: MapFeatureParcelSearchSuggestion[] = [];
selectedProvinceId: number | null = null;
selectedMunicipalityId: number | null = null;
searchTerm = '';
parcelDetailsJson = '';
ngAfterViewInit(): void {
this.renderer.mount(this.mapCanvas.nativeElement);
this.locations.getProvinces()
.pipe(takeUntil(this.destroy$))
.subscribe((provinces) => {
this.provinces = provinces;
});
}
onProvinceChange(value: string): void {
const provinceId = value ? Number(value) : null;
this.selectedProvinceId = provinceId;
this.selectedMunicipalityId = null;
this.municipalities = [];
this.suggestions = [];
this.parcelDetailsJson = '';
if (provinceId === null) {
this.renderer.clearAll();
return;
}
this.locations.getMunicipalities(provinceId)
.pipe(takeUntil(this.destroy$))
.subscribe((municipalities) => {
this.municipalities = municipalities;
});
this.map.getProvinceGeometry(provinceId)
.pipe(takeUntil(this.destroy$))
.subscribe((geometry) => {
this.renderProvinceBoundary(provinceId, geometry);
});
}
onMunicipalityChange(value: string): void {
const municipalityId = value ? Number(value) : null;
this.selectedMunicipalityId = municipalityId;
this.suggestions = [];
this.parcelDetailsJson = '';
if (this.selectedProvinceId === null || municipalityId === null) {
return;
}
this.map.getMunicipalityGeometry(this.selectedProvinceId, municipalityId)
.pipe(takeUntil(this.destroy$))
.subscribe((geometry) => {
this.renderer.clearLayer('municipality-boundary');
this.renderer.renderLayer('municipality-boundary', geometry, {
id: 'municipality-boundary',
kind: 'MUNICIPALITY',
visible: true,
labelsVisible: true
});
});
}
onSearchTermChange(value: string): void {
this.searchTerm = value;
if (this.selectedProvinceId === null || value.trim().length < 3) {
this.suggestions = [];
return;
}
this.map.searchParcels(this.selectedProvinceId, this.selectedMunicipalityId, value.trim(), 10)
.pipe(takeUntil(this.destroy$))
.subscribe((response: MapFeatureParcelSearchResponse) => {
this.suggestions = response.suggestions ?? [];
});
}
onSuggestionSelect(suggestion: MapFeatureParcelSearchSuggestion): void {
if (this.selectedProvinceId === null) {
return;
}
this.map.getParcelGeometryBySuggestion(this.selectedProvinceId, suggestion)
.pipe(takeUntil(this.destroy$))
.subscribe((geometry) => {
this.renderParcelGeometry(suggestion, geometry);
});
}
private renderProvinceBoundary(provinceId: number, geometry: MapFeatureGeometryResponse): void {
this.renderer.clearLayer('province-boundary');
this.renderer.renderLayer('province-boundary', geometry, {
id: `province-${provinceId}`,
kind: 'PROVINCE_BOUNDARY',
visible: true,
labelsVisible: true
});
}
private renderParcelGeometry(
suggestion: MapFeatureParcelSearchSuggestion,
geometry: MapFeatureGeometryResponse
): void {
const layerId = `parcel:${suggestion.recordId}`;
this.renderer.clearLayer('parcel-layer');
this.renderer.renderLayer(layerId, geometry, {
id: layerId,
kind: 'ERF',
visible: true,
labelsVisible: true
});
this.map.getParcelDetail(
Number(suggestion.provinceCode),
suggestion.sourceLayer,
suggestion.sourceUid
)
.pipe(takeUntil(this.destroy$))
.subscribe((details) => {
this.parcelDetailsJson = JSON.stringify(details ?? suggestion, null, 2);
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.renderer.destroy();
}
}
How this component behaves:
- the SDK bootstrap happens once in
app.config.ts - the component owns the select boxes, search input, and detail panel
- the SDK owns the HTTP/data calls
- the renderer owns how geometry is actually drawn
renderLayer(...)is used every time the app gets geometry back from the SDKclearLayer(...)andclearAll(...)keep the canvas in sync with changing selectiongetParcelDetail(...)is used when the app wants popup or panel details for a clicked parcel
In a real app, your onLayerClick handler or vendor click callback would usually
call MapSdkService.getParcelDetail(...) with the clicked feature metadata, then
open your own dialog or drawer.
8. Custom Backend Adapter
If the SDK should use your own backend contract, provide a MapFeatureAdapter.
import { MapFeatureAdapter } from '@agastyadreamspty/map-sdk';
export const adapter: MapFeatureAdapter = {
loadLegend: (provinceId, includeInactive, municipalityId) =>
api.loadLegend(provinceId, includeInactive, municipalityId),
loadProvinces: () => api.loadProvinces(),
loadMunicipalities: (provinceId) => api.loadMunicipalities(provinceId),
getProvinceGeometry: (provinceId) => api.getProvinceGeometry(provinceId),
getMunicipalityGeometry: (provinceId, municipalityId) =>
api.getMunicipalityGeometry(provinceId, municipalityId),
searchParcels: (provinceId, municipalityId, query, limit) =>
api.searchParcels(provinceId, municipalityId, query, limit ?? 8),
getParcelGeometryBySuggestion: (provinceId, suggestion) =>
api.getParcelGeometryBySuggestion(provinceId, suggestion),
getAdminBoundaryDetails: (sourceKind, provinceId, sourceId, displayName, locationId) =>
api.getAdminBoundaryDetails(sourceKind, provinceId, sourceId, displayName, locationId)
};
9. Theme Overrides
The SDK ships with CSS variables that the consumer can override.
.smoc-map-theme {
--map-feature-surface: #ffffff;
--map-feature-surface-elevated: #f6f8fc;
--map-feature-border: rgba(15, 23, 42, 0.12);
--map-feature-text: #0f172a;
--map-feature-muted-text: #475569;
--map-feature-accent: #0f766e;
--map-feature-accent-strong: #115e59;
--map-feature-chip-surface: rgba(15, 118, 110, 0.12);
--map-feature-chip-text: #0f172a;
}
Use the themeClass field in MapFeatureConfig to attach the wrapper class.
10. Events and Callbacks
The SDK exposes Angular outputs and a typed event bus.
Angular outputs
provinceChangemunicipalityChangelayerVisibilityChangelayerClick
Event bus
import { FeatureEventBus } from '@agastyadreamspty/map-sdk';
constructor(private readonly eventBus: FeatureEventBus) {}
this.eventBus.emit({
type: 'selection',
payload: {
layerId: 'parcel:123'
}
});
Use the event bus when your app needs a shared SDK event channel instead of one-off component outputs.
11. Projected Host Slots
The full-page host now supports native Angular content projection slots so a consumer can keep the SDK's map behavior and inject custom page chrome around it.
The supported slots are:
mapSdkHeadermapSdkSidebarmapSdkFooter
Each slot is projected into dw-map-sdk-host with a standard attribute marker.
If a slot is omitted, that section stays empty and the host automatically
collapses it.
Example:
<dw-map-sdk-host>
<app-custom-toolbar mapSdkHeader></app-custom-toolbar>
<app-custom-filter-panel mapSdkSidebar></app-custom-filter-panel>
<app-custom-footer mapSdkFooter></app-custom-footer>
</dw-map-sdk-host>
This gives the consumer a way to add page-level controls, legends, or branded chrome without reimplementing the SDK's map loading, filtering, or parcel logic. The SDK still owns the main map shell, while the app owns the projected regions.
If you do not need projected slots, you can still use the SDK as a plain component wrapper or as API-only services.
12. Recommended Consumer Flow
For most apps:
- install the package
- configure
provideMapSdk(...) - decide whether you want the full host or widget composition
- override the theme class
- bind outputs to app state
- refresh the package when the warehouse publishes a new version
13. Example Consumer Component
import { Component } from '@angular/core';
import { MapFeatureConfig, MapSdkHostComponent } from '@agastyadreamspty/map-sdk';
@Component({
standalone: true,
imports: [MapSdkHostComponent],
template: `
<dw-map-sdk-host
[config]="config"
[showLegend]="true"
[showSearch]="true"
(layerClick)="openDetails($event)"
/>
`
})
export class ConsumerMapPageComponent {
config: Partial<MapFeatureConfig> = {
apiBaseUrl: 'http://localhost:8080/api',
includeInactive: false,
ui: {
title: 'Map Explorer',
subtitle: 'Browse boundaries, parcels, and live legend layers.',
themeClass: 'smoc-map-theme',
mapContainerId: 'smoc-map',
showStatusBanner: true,
showLoadingOverlay: true,
mountMode: 'full-page'
}
};
openDetails(event: unknown): void {
console.log('layer click', event);
}
}