FireForesight Feature Service Integration Guide

Use these endpoints together: /api/services/{apiKey} (service discovery), /api/services/{apiKey}/presentation (contract), /{apiKey}/FeatureServer/{layerId}/presentation (layer contract), and /{apiKey}/FeatureServer/{layerId}/query?f=geojson (data).

The test harness now consumes the same contract via reusable module /js/fireforesight-leaflet-viewer.js.

Default modeImproved UI modeUse the map-page toggle to compare plain renderer behavior against the advanced UI behavior.

Referencing the JS module

This service ships two reusable ES modules under /js/:

Option A — same-site page (inline module script)

<!-- Leaflet full runtime -->
<script type="module">
  import { bootMapPreview } from '/js/fireforesight-leaflet-viewer.js';
  bootMapPreview();
</script>

<!-- Or just the popup helpers (any platform) -->
<script type="module">
  import {
    toCanonicalProperties,
    buildEnhancedPopupHtml,
    createPopupState,
    wirePopupControllers
  } from '/js/fireforesight-popup-runtime.js';
</script>

Option B — same-site separate .js file

// /wwwroot/js/my-map.js
import {
  toCanonicalProperties,
  buildEnhancedPopupHtml,
  createPopupState,
  wirePopupControllers
} from '/js/fireforesight-popup-runtime.js';

export async function initMyMap(apiKey, layerId) {
  const contract = await fetch(`/${apiKey}/FeatureServer/${layerId}/presentation`).then(r => r.json());
  const data = await fetch(`/${apiKey}/FeatureServer/${layerId}/query?f=geojson`).then(r => r.json());
  // ... render with your chosen platform
}
<!-- Then in the page -->
<script type="module" src="/js/my-map.js"></script>

Option C — external site, reference by full URL

<!-- Your HTML on any origin -->
<script type="module">
  import {
    toCanonicalProperties,
    buildEnhancedPopupHtml,
    createPopupState,
    wirePopupControllers
  } from 'https://your-feature-service-host/js/fireforesight-popup-runtime.js';
</script>
For cross-origin imports, Cors:AllowedOrigins must include your consuming site's origin (configured in appsettings.json), and the file must be served with CORS headers. The development config already allows localhost:5003.

What each module exports

// fireforesight-leaflet-viewer.js
export async function bootMapPreview()  // bootstraps a full Leaflet map from URL params

// fireforesight-popup-runtime.js (platform-agnostic)
export function toCanonicalProperties(rawProperties)
export function buildEnhancedPopupHtml(properties, popupId, contract)
export function createPopupState(properties)
export function wirePopupControllers(popupId, state)
export function buildDefaultPopup(properties, isIncident, lat, lng)
export function formatDate(value)
The snippets in each platform section below assume these imports are in scope. The makeIconFromContract, buildMapboxPointLayer, and similar helpers are platform-specific — you implement those thin adapters yourself using the contract.renderer colour/size values.

Advanced UI implementation model

  1. Fetch presentation metadata and GeoJSON data.
  2. Normalize properties to canonical keys (incidentId, siteName, patrolSegments, comments).
  3. Build marker style from presentation.renderer.
  4. Build popup shell from presentation.popup.tabs and presentation.popup.fields.
  5. Attach popup controllers (tab switching, image carousel, comments autoscroll, detail formatting).
  6. Keep a mode toggle that switches between default popup and enhanced popup using the same loaded features.

Canonical adapter

function toCanonicalProperties(p) {
  return {
    layerType: p.layerType,
    objectId: p.objectId,
    incidentId: p.incidentId,
    status: p.status,
    siteName: p.siteName ?? p.name,
    siteUri: p.siteUri,
    fireDate: p.fireDate,
    latestImageUrl: p.latestImageUrl,
    latestImageTime: p.latestImageTime,
    comments: Array.isArray(p.comments) ? p.comments : [],
    patrolSegments: Array.isArray(p.patrolSegments) ? p.patrolSegments : [],
    externalLinks: Array.isArray(p.externalLinks) ? p.externalLinks : []
  };
}
Keep feature data immutable; store popup/carousel state separately by popup ID.

Leaflet

Option A — Use the full Leaflet runtime (same site, handles everything)

<script type="module">
  import { bootMapPreview } from '/js/fireforesight-leaflet-viewer.js';
  // Reads key, layer, url, layerId, presentation from the page URL automatically.
  bootMapPreview();
</script>

Option B — Bring your own Leaflet map, use popup helpers only

<script type="module">
import {
  toCanonicalProperties,
  buildEnhancedPopupHtml,
  createPopupState,
  wirePopupControllers
} from '/js/fireforesight-popup-runtime.js';

const layerContract = await fetch(`/${apiKey}/FeatureServer/${layerId}/presentation`).then(r => r.json());
const data = await fetch(`/${apiKey}/FeatureServer/${layerId}/query?f=geojson`).then(r => r.json());

const popupState = new Map();
const markers = L.layerGroup().addTo(map);

for (const feature of data.features) {
  const p = toCanonicalProperties(feature.properties);
  const [lng, lat] = feature.geometry.coordinates;
  const isIncident = p.layerType === 'incident';
  const popupId = `${feature.id}-${Math.random().toString(36).slice(2, 8)}`;
  popupState.set(popupId, createPopupState(p));

  const marker = L.marker([lat, lng]);
  marker.bindPopup(buildEnhancedPopupHtml(p, popupId, layerContract), {
    className: isIncident ? 'incident-popup' : 'camera-popup',
    minWidth: 620, maxWidth: 620
  });
  marker.on('popupopen', () => wirePopupControllers(popupId, popupState.get(popupId)));
  marker.addTo(markers);
}
</script>

OpenLayers

OpenLayers uses its own map/layer/style API. Import popup helpers from the platform-agnostic module; keep Leaflet out of your bundle.
<script type="module">
import {
  toCanonicalProperties,
  buildEnhancedPopupHtml,
  createPopupState,
  wirePopupControllers
} from '/js/fireforesight-popup-runtime.js';
// For external sites: use full URL
// from 'https://your-feature-service-host/js/fireforesight-popup-runtime.js';

const contract = await fetch(`/${apiKey}/FeatureServer/${layerId}/presentation`).then(r => r.json());
const data = await fetch(`/${apiKey}/FeatureServer/${layerId}/query?f=geojson`).then(r => r.json());

const features = new ol.format.GeoJSON().readFeatures(data, { featureProjection: 'EPSG:3857' });
const source = new ol.source.Vector({ features });
const vector = new ol.layer.Vector({ source });
map.addLayer(vector);

const popupEl = document.getElementById('popup');
const popup = new ol.Overlay({ element: popupEl, positioning: 'bottom-center', stopEvent: true });
map.addOverlay(popup);

map.on('singleclick', evt => {
  const feature = map.forEachFeatureAtPixel(evt.pixel, f => f);
  if (!feature) return popup.setPosition(undefined);
  const p = toCanonicalProperties(feature.getProperties());
  const popupId = `ol-${Date.now()}`;
  popupEl.innerHTML = buildEnhancedPopupHtml(p, popupId, contract);
  wirePopupControllers(popupId, createPopupState(p));
  popup.setPosition(evt.coordinate);
});
</script>

Mapbox GL JS

Mapbox GL JS v3+ supports ES modules natively. Import popup helpers; do not import the Leaflet viewer module.
<script type="module">
import {
  toCanonicalProperties,
  buildEnhancedPopupHtml,
  createPopupState,
  wirePopupControllers
} from '/js/fireforesight-popup-runtime.js';

const contract = await fetch(`/${apiKey}/FeatureServer/${layerId}/presentation`).then(r => r.json());
const data = await fetch(`/${apiKey}/FeatureServer/${layerId}/query?f=geojson`).then(r => r.json());

map.addSource('ff-layer', { type: 'geojson', data });
map.addLayer({
  id: 'ff-points',
  type: 'circle',
  source: 'ff-layer',
  paint: {
    'circle-radius': 7,
    'circle-color': ['case', ['==', ['get', 'layerType'], 'incident'], '#e67e22', '#2b6cb0'],
    'circle-stroke-color': '#fff',
    'circle-stroke-width': 1.5
  }
});

map.on('click', 'ff-points', e => {
  const raw = e.features?.[0]?.properties ?? {};
  // Mapbox serialises nested objects to JSON strings — parse them back
  const parsed = Object.fromEntries(
    Object.entries(raw).map(([k, v]) => {
      try { return [k, JSON.parse(v)]; } catch { return [k, v]; }
    })
  );
  const p = toCanonicalProperties(parsed);
  const popupId = `mb-${Date.now()}`;

  new mapboxgl.Popup({ maxWidth: '640px' })
    .setLngLat(e.lngLat)
    .setHTML(buildEnhancedPopupHtml(p, popupId, contract))
    .addTo(map);

  requestAnimationFrame(() => wirePopupControllers(popupId, createPopupState(p)));
});
</script>

Cesium

Cesium renders entity descriptions in an iframe (InfoBox). The popup HTML is injected as entity.description; tab/carousel controllers must be wired after the iframe loads.
<script type="module">
import {
  toCanonicalProperties,
  buildEnhancedPopupHtml,
  createPopupState
} from '/js/fireforesight-popup-runtime.js';

const contract = await fetch(`/${apiKey}/FeatureServer/${layerId}/presentation`).then(r => r.json());
const geojson = await fetch(`/${apiKey}/FeatureServer/${layerId}/query?f=geojson`).then(r => r.json());
const dataSource = await Cesium.GeoJsonDataSource.load(geojson);
viewer.dataSources.add(dataSource);

const popupStates = new Map();

for (const entity of dataSource.entities.values) {
  // Cesium wraps each property in a Property object — use .getValue()
  const raw = Object.fromEntries(
    entity.properties.propertyNames.map(k => [k, entity.properties[k]?.getValue()])
  );
  const p = toCanonicalProperties(raw);
  const popupId = `cz-${entity.id}`;
  popupStates.set(popupId, createPopupState(p));
  entity.description = buildEnhancedPopupHtml(p, popupId, contract);
}

// Wire popup controllers once the InfoBox iframe document is accessible
viewer.selectedEntityChanged.addEventListener(entity => {
  if (!entity) return;
  const popupId = `cz-${entity.id}`;
  const state = popupStates.get(popupId);
  if (!state) return;

  // InfoBox uses a sandboxed iframe; reach into it after a short delay
  const iframe = viewer.infoBox?.frame;
  if (!iframe) return;
  setTimeout(() => {
    const doc = iframe.contentDocument;
    if (!doc) return;
    // wirePopupControllers targets document-level getElementById;
    // override window refs to point at the iframe document:
    const origById = window.document.getElementById.bind(window.document);
    try {
      window.document.getElementById = id => doc.getElementById(id) ?? origById(id);
      wirePopupControllers(popupId, state);
    } finally {
      window.document.getElementById = origById;
    }
  }, 300);
});
</script>

Esri (ArcGIS Maps SDK for JavaScript)

The ArcGIS Maps SDK uses AMD/ESM modules itself. Import the popup helpers before the SDK loads, or use a dynamic import() inside the module script.
<!-- Load the ArcGIS SDK normally -->
<link rel="stylesheet" href="https://js.arcgis.com/4.30/esri/themes/light/main.css" />
<script src="https://js.arcgis.com/4.30/"></script>

<!-- Then load your module script -->
<script type="module">
import {
  toCanonicalProperties,
  buildEnhancedPopupHtml,
  createPopupState,
  wirePopupControllers
} from '/js/fireforesight-popup-runtime.js';

require([
  'esri/Map', 'esri/views/MapView', 'esri/layers/FeatureLayer'
], (EsriMap, MapView, FeatureLayer) => {

  const contract = fetch(`/${apiKey}/FeatureServer/${layerId}/presentation`).then(r => r.json());

  const layer = new FeatureLayer({
    url: `https://your-host/${apiKey}/FeatureServer/${layerId}`,
    outFields: ['*'],
    // drawingInfo and popupInfo from the layer metadata provide sensible defaults.
    // Override popupTemplate to use the enhanced UI:
    popupTemplate: {
      title: (feature) => feature.graphic.attributes.siteName ?? feature.graphic.attributes.SiteName ?? 'Site',
      content: async (feature) => {
        const layerContract = await contract;
        const attrs = feature.graphic.attributes;
        const p = toCanonicalProperties(attrs);
        const popupId = `esri-${Date.now()}`;
        const div = document.createElement('div');
        div.innerHTML = buildEnhancedPopupHtml(p, popupId, layerContract);
        setTimeout(() => wirePopupControllers(popupId, createPopupState(p)), 0);
        return div;
      }
    }
  });

  const map = new EsriMap({ basemap: 'streets-navigation-vector', layers: [layer] });
  new MapView({ container: 'viewDiv', map, zoom: 5, center: [133, -25] });
});
</script>

Esri (ArcGIS Online / ArcGIS Pro)

  1. Copy the layer URL from the service browser page.
  2. ArcGIS Online: Add → Add Layer from Web → ArcGIS Server Web Service.
  3. ArcGIS Pro: Catalog → Servers → New ArcGIS Server, then paste service URL.
  4. Renderer and popup defaults are provided via layer metadata; customize as needed in your map.

Contract-driven popup strategy

// These are the actual exported functions from /js/fireforesight-popup-runtime.js

export function toCanonicalProperties(p) {
  return {
    layerType: p.layerType,
    objectId: p.objectId,
    incidentId: p.incidentId,
    status: p.status,
    siteName: p.siteName ?? p.name,
    siteUri: p.siteUri,
    fireDate: p.fireDate,
    latestImageUrl: p.latestImageUrl,
    latestImageTime: p.latestImageTime,
    comments: Array.isArray(p.comments) ? p.comments : [],
    patrolSegments: Array.isArray(p.patrolSegments) ? p.patrolSegments : [],
    externalLinks: Array.isArray(p.externalLinks) ? p.externalLinks : []
  };
}

export function buildEnhancedPopupHtml(properties, popupId, contract) {
  const tabs = contract.popup?.tabs ?? [];
  const fields = contract.popup?.fields ?? [];
  const detailsRows = fields
    .map(f => `<tr><td>${f.label}</td><td>${properties[f.key] ?? '—'}</td></tr>`)
    .join('');
  return renderPopupShell({ popupId, tabs, detailsRows, properties });
}

export function createPopupState(p) {
  return {
    images: p.patrolSegments?.map(s => ({ url: s.latestImageUrl, direction: s.direction, timestamp: s.latestTimeStamp }))
              ?? (p.latestImageUrl ? [{ url: p.latestImageUrl, timestamp: p.latestImageTime }] : []),
    comments: p.comments ?? [],
    imageIndex: 0,
    activeTab: 'image'
  };
}

export function wirePopupControllers(popupId, state) {
  bindTabEvents(popupId, state);
  bindCarouselEvents(popupId, state);
  renderCurrentImage(popupId, state);
}

export function buildDefaultPopup(properties, isIncident, lat, lng) {
  // Returns a plain text/HTML popup for default mode rendering
  if (isIncident) {
    return `<strong>Incident #${properties.incidentId}</strong><br/>
            ${properties.status} — ${properties.siteName}<br/>
            ${lat.toFixed(6)}, ${lng.toFixed(6)}`;
  }
  return `<strong>${properties.siteName}</strong><br/>${lat.toFixed(6)}, ${lng.toFixed(6)}`;
}

export function formatDate(val) {
  if (!val) return '—';
  const d = new Date(val);
  return isNaN(d) ? String(val) : d.toLocaleString();
}
For production, place this file at /wwwroot/js/fireforesight-popup-runtime.js, export each function, and import the exact methods you need. All platform snippets above share the same module — zero duplication.