Skip to content

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 provinceChange to update app state or URL filters
  • use municipalityChange to refresh context
  • use layerVisibilityChange to persist legend state
  • use layerClick to 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.

The easiest pattern is:

  1. provide the SDK runtime config once during app bootstrap
  2. create your own map component or service wrapper
  3. inject MapSdkService and LocationService
  4. implement the renderer adapter around your map vendor
  5. call the SDK services when the user changes province, municipality, search, or legend state
  6. pass the returned geometry into your renderer
  7. 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:

  1. user types a parcel search term
  2. consumer app calls MapSdkService.searchParcels(...)
  3. user picks a suggestion
  4. consumer app calls MapSdkService.getParcelGeometryBySuggestion(...)
  5. consumer app passes the returned geometry to renderer.renderLayer(...)
  6. consumer app fits the map viewport to the returned bounds or geometry extent
  7. if the user clicks the parcel, the app can call MapSdkService.getParcelDetail(...)
  8. the app shows the parcel popup or side panel using its own UI

Use a stable layer id for parcels, such as:

  • parcel-layer
  • parcel-search
  • parcel:${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() and loadMunicipalities() 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(...) and getParcelZoom(...) when you need the SDK thresholds
  • call getBoundaryLabelState(...) and getParcelLabelState(...) when you want label visibility to match the SDK
  • use setLayerVisibility(...) and setLayerLabelsVisible(...) 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 LocationService for province and municipality dropdowns
  • use MapSdkService.loadLegend(...) for legend panels or layer toggles
  • use MapSdkService.getProvinceGeometry(...) and MapSdkService.getMunicipalityGeometry(...) for focus and selection overlays
  • use MapSdkService.getParcelDetail(...) for click popups or detail drawers
  • use MapFeatureAdapter only 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:

  • LocationService for province and municipality dropdown data
  • MapSdkService for legend, geometry, search, and click-detail lookups
  • MapFeatureHttpDataSource for 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 MapFeatureHttpDataSource for 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(...) or MapSdkService.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 townships MVT source layer from zoom level 10
  • 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:

  1. select province
  2. optionally select municipality
  3. build the parcel tile URL with MapFeatureHttpDataSource.buildParcelTileUrl(provinceId, municipalityId, 'ERF,FARM')
  4. mount the parcel tile layer using the vendor-specific source or vector grid
  5. keep the parcel layer id stable, such as parcel-layer
  6. wire click handling from the rendered parcel feature to MapSdkService.getParcelDetail(...)
  7. 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:

  • sourceId
  • parcelType
  • parcelKey
  • municipalityId
  • legendKey
  • display_name
  • name
  • tag_x
  • tag_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:

  1. capture the active province and municipality before calling the SDK
  2. store a local request token or rely on observable cancelation
  3. ignore responses that arrive for an older token
  4. 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:

  1. bootstrap the SDK once in app.config.ts
  2. fetch province and municipality lists from LocationService
  3. mount the vendor canvas service into the page container
  4. call setScope(provinceId, municipalityId) when the user changes filters
  5. use onParcelClick(...) or the vendor click callback to load parcel details
  6. 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(...) and MapSdkService.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 SDK
  • clearLayer(...) and clearAll(...) keep the canvas in sync with changing selection
  • getParcelDetail(...) 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

  • provinceChange
  • municipalityChange
  • layerVisibilityChange
  • layerClick

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:

  1. mapSdkHeader
  2. mapSdkSidebar
  3. mapSdkFooter

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.

For most apps:

  1. install the package
  2. configure provideMapSdk(...)
  3. decide whether you want the full host or widget composition
  4. override the theme class
  5. bind outputs to app state
  6. 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);
  }
}