# DigiMap Widget Documentation

> A powerful, embeddable real estate parcel mapping component with interactive features including search, filtering, property details, measurement tools, and polygon drawing.

**Version:** 1.0.0

## Features

- Interactive satellite map with parcel boundaries
- Location search with autocomplete
- Property filtering by type, value, and size
- Detailed property information drawer
- Distance measurement tool
- Polygon area drawing tool
- Light and dark theme support
- Shadow DOM isolation for style encapsulation
- Full programmatic API for external control

---

## Authentication

DigiMap uses server-to-server authentication to keep your API credentials secure. Your backend server authenticates with the DigiMap API using your API Key and Secret Key, receives JWT tokens, and passes them to the browser. This ensures your credentials are never exposed in client-side code.

### Authentication Flow

1. Your frontend requests authentication from your backend server
2. Your backend sends the API Key, Secret Key, and user details to the DigiMap API
3. DigiMap validates credentials and returns JWT tokens via Set-Cookie headers
4. Your backend extracts the tokens from the cookies and returns them to your frontend
5. Your frontend uses the access token to initialize the DigiMap widget

> **Security:** Never include your API Key or Secret Key in client-side code, HTML, or any publicly accessible file. All authentication requests must be made from your backend server.

### Why Set-Cookie Headers?

The DigiMap authentication API returns tokens via Set-Cookie headers rather than in the JSON response body. This is a deliberate security measure — it prevents tokens from being intercepted by JavaScript running on potentially compromised pages. Your backend extracts the tokens from the cookie headers server-side and passes them to the frontend in your own API response. This approach keeps the token exchange secure while still giving you full control over how the frontend receives the token.

### Endpoint

- **URL:** `https://api.digimap.app/api/Authenticate`
- **Method:** POST
- **Content-Type:** application/json

**Request Body:**

```json
{
  "Json": {
    "ActionMethod": "get",
    "Apikey": "YOUR_API_KEY",
    "SecretKey": "YOUR_SECRET_KEY",
    "AccountID": "YOUR_ACCOUNT_ID",
    "UserID": "12345",
    "Email": "user@example.com",
    "FirstName": "John",
    "LastName": "Doe",
    "metadata": {
      "CorporateID": 1
    }
  }
}
```

> The API returns tokens via Set-Cookie headers (not in the response body). You must parse the access_token and refresh_token from the Set-Cookie headers.

**Response Fields (from Set-Cookie headers):**

| Field | Description |
|---|---|
| `access_token` | JWT access token (expires in 15 minutes) |
| `refresh_token` | Refresh token for obtaining new access tokens (expires in 7 days) |

### Frontend Usage

```html
<!-- 1. Create a container for the map -->
<div id="digimap-container" style="width: 100%; height: 600px;"></div>
<script src="https://embed.digimap.app/embed/digimap.1.0.0.iife.js"></script>

<script>
  // 2. Authenticate through your backend
  async function initDigiMap() {
    const response = await fetch('/api/auth/digimap', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        AccountID: 'YOUR_ACCOUNT_ID',
        UserID: '12345',
        Email: 'user@example.com',
        FirstName: 'John',
        LastName: 'Doe'
      })
    });
    const { accessToken, refreshToken } = await response.json();

    // 3. Initialize — pass both tokens. The widget handles
    //    automatic refresh internally. No refresh loop needed.
    const digimap = new DigiMap({
      container: '#digimap-container',
      accessToken: accessToken,
      refreshToken: refreshToken,
      theme: 'light',
      showSearch: true,
      showFilter: true,
      defaultView: {
        latitude: 27.0436,
        longitude: -82.2187,
        zoom: 12
      }
    });
  }

  initDigiMap();
</script>
```

### Backend Examples

#### Node.js

```nodejs
const express = require('express');
const app = express();
app.use(express.json());

app.post('/api/auth/digimap', async (req, res) => {
  const { AccountID, UserID, Email, FirstName, LastName, metadata } = req.body;

  const payload = {
    Json: {
      ActionMethod: "get",
      Apikey: process.env.DIGIMAP_API_KEY,
      SecretKey: process.env.DIGIMAP_SECRET_KEY,
      AccountID,
      UserID,
      Email,
      FirstName: FirstName || "",
      LastName: LastName || "",
      metadata: metadata || {},
    },
  };

  try {
    const response = await fetch(
      "https://api.digimap.app/api/Authenticate",
      {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
      }
    );

    // Tokens are returned in Set-Cookie headers
    const setCookies = response.headers.getSetCookie() || [];
    let accessToken = "";
    let refreshToken = "";

    for (const cookie of setCookies) {
      const match = cookie.match(/^([^=]+)=([^;]*)/);
      if (match) {
        const [, name, value] = match;
        if (name === "access_token") accessToken = value;
        if (name === "refresh_token")
          refreshToken = decodeURIComponent(value);
      }
    }

    if (!accessToken) {
      return res.status(401).json({ error: "Authentication failed" });
    }

    res.json({ accessToken, refreshToken });
  } catch (error) {
    res.status(500).json({ error: "Authentication error" });
  }
});
```

#### .NET (C#)

```dotnet
using Microsoft.AspNetCore.Mvc;
using System.Net;
using System.Text;
using System.Text.Json;

[ApiController]
[Route("api/auth")]
public class DigiMapAuthController : ControllerBase
{
    private readonly IConfiguration _config;
    private readonly HttpClient _httpClient;

    public DigiMapAuthController(
        IConfiguration config,
        IHttpClientFactory httpClientFactory)
    {
        _config = config;
        _httpClient = httpClientFactory.CreateClient();
    }

    [HttpPost("digimap")]
    public async Task<IActionResult> Authenticate(
        [FromBody] AuthRequest request)
    {
        var payload = new
        {
            Json = new
            {
                ActionMethod = "get",
                Apikey = _config["DigiMap:ApiKey"],
                SecretKey = _config["DigiMap:SecretKey"],
                request.AccountID,
                request.UserID,
                request.Email,
                FirstName = request.FirstName ?? "",
                LastName = request.LastName ?? "",
                metadata = request.Metadata ?? new { }
            }
        };

        var json = JsonSerializer.Serialize(payload);
        var content = new StringContent(
            json, Encoding.UTF8, "application/json");

        // Use HttpClientHandler to capture cookies
        var handler = new HttpClientHandler
        {
            CookieContainer = new CookieContainer()
        };
        using var client = new HttpClient(handler);

        var response = await client.PostAsync(
            "https://api.digimap.app/api/Authenticate",
            content);

        var uri = new Uri("https://api.digimap.app");
        var cookies = handler.CookieContainer.GetCookies(uri);

        var accessToken = cookies["access_token"]?.Value;
        var refreshToken = cookies["refresh_token"]?.Value;

        if (string.IsNullOrEmpty(accessToken))
            return Unauthorized(new { error = "Auth failed" });

        return Ok(new { accessToken, refreshToken });
    }
}

public class AuthRequest
{
    public string? AccountID { get; set; }
    public string UserID { get; set; } = "";
    public string Email { get; set; } = "";
    public string? FirstName { get; set; }
    public string? LastName { get; set; }
    public object? Metadata { get; set; }
}
```

#### PHP

```php
<?php
// POST /api/auth/digimap.php

header('Content-Type: application/json');
$input = json_decode(file_get_contents('php://input'), true);

$payload = json_encode([
    'Json' => [
        'ActionMethod' => 'get',
        'Apikey'       => getenv('DIGIMAP_API_KEY'),
        'SecretKey'    => getenv('DIGIMAP_SECRET_KEY'),
        'AccountID'    => $input['AccountID'] ?? null,
        'UserID'       => $input['UserID'],
        'Email'        => $input['Email'],
        'FirstName'    => $input['FirstName'] ?? '',
        'LastName'     => $input['LastName'] ?? '',
        'metadata'     => $input['metadata'] ?? new stdClass(),
    ]
]);

$ch = curl_init('https://api.digimap.app/api/Authenticate');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $payload,
    CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HEADER         => true,  // Include headers in output
]);

$response = curl_exec($ch);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$headers = substr($response, 0, $headerSize);
curl_close($ch);

// Parse tokens from Set-Cookie headers
$accessToken = '';
$refreshToken = '';

preg_match_all('/Set-Cookie:\s*([^\n]+)/i', $headers, $matches);
foreach ($matches[1] as $cookieLine) {
    if (preg_match('/^access_token=([^;]+)/', $cookieLine, $m)) {
        $accessToken = $m[1];
    }
    if (preg_match('/^refresh_token=([^;]+)/', $cookieLine, $m)) {
        $refreshToken = urldecode($m[1]);
    }
}

if (empty($accessToken)) {
    http_response_code(401);
    echo json_encode(['error' => 'Authentication failed']);
    exit;
}

echo json_encode([
    'accessToken'  => $accessToken,
    'refreshToken' => $refreshToken,
]);
```

#### Python

```python
import os
import requests
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/api/auth/digimap', methods=['POST'])
def authenticate():
    data = request.get_json()

    payload = {
        "Json": {
            "ActionMethod": "get",
            "Apikey": os.environ["DIGIMAP_API_KEY"],
            "SecretKey": os.environ["DIGIMAP_SECRET_KEY"],
            "AccountID": data.get("AccountID"),
            "UserID": data["UserID"],
            "Email": data["Email"],
            "FirstName": data.get("FirstName", ""),
            "LastName": data.get("LastName", ""),
            "metadata": data.get("metadata", {}),
        }
    }

    response = requests.post(
        "https://api.digimap.app/api/Authenticate",
        json=payload
    )

    # Tokens are returned in Set-Cookie headers
    cookies = response.cookies
    access_token = cookies.get("access_token", "")
    refresh_token = cookies.get("refresh_token", "")

    if not access_token:
        return jsonify({"error": "Authentication failed"}), 401

    return jsonify({
        "accessToken": access_token,
        "refreshToken": refresh_token,
    })
```

#### Java

```java
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import java.net.*;
import java.net.http.*;
import java.util.*;

@RestController
@RequestMapping("/api/auth")
public class DigiMapAuthController {

    @Value("${digimap.api-key}")
    private String apiKey;

    @Value("${digimap.secret-key}")
    private String secretKey;

    @PostMapping("/digimap")
    public Map<String, String> authenticate(
            @RequestBody Map<String, Object> body)
            throws Exception {

        String json = """
            {
              "Json": {
                "ActionMethod": "get",
                "Apikey": "%s",
                "SecretKey": "%s",
                "AccountID": "%s",
                "UserID": "%s",
                "Email": "%s",
                "FirstName": "%s",
                "LastName": "%s",
                "metadata": {}
              }
            }
            """.formatted(
                apiKey, secretKey,
                body.getOrDefault("AccountID", ""),
                body.getOrDefault("UserID", ""),
                body.get("Email"),
                body.getOrDefault("FirstName", ""),
                body.getOrDefault("LastName", "")
            );

        var cookieHandler = new CookieManager();
        var client = HttpClient.newBuilder()
            .cookieHandler(cookieHandler)
            .build();

        var request = HttpRequest.newBuilder()
            .uri(URI.create(
                "https://api.digimap.app/api/Authenticate"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(json))
            .build();

        client.send(request,
            HttpResponse.BodyHandlers.ofString());

        // Extract tokens from cookie store
        var store = cookieHandler.getCookieStore();
        var uri = URI.create("https://api.digimap.app");
        String accessToken = "";
        String refreshToken = "";

        for (var cookie : store.get(uri)) {
            if ("access_token".equals(cookie.getName()))
                accessToken = cookie.getValue();
            if ("refresh_token".equals(cookie.getName()))
                refreshToken = URLDecoder.decode(
                    cookie.getValue(), "UTF-8");
        }

        if (accessToken.isEmpty())
            throw new RuntimeException("Auth failed");

        return Map.of(
            "accessToken", accessToken,
            "refreshToken", refreshToken
        );
    }
}
```

---

## Quick Start & Framework Examples

### Vanilla JavaScript

```html
<!-- HTML -->
<div id="digimap-container" style="width: 100%; height: 600px;"></div>

<!-- Load the embed script -->
<script src="https://embed.digimap.app/embed/digimap.1.0.0.iife.js"></script>

<script>
  // Initialize the map (use tokens from your auth endpoint)
  const digimap = new DigiMap({
    container: '#digimap-container',
    accessToken: token,
    refreshToken: token,
    theme: 'light',
    showSearch: true,
    showFilter: true,
    showStatistics: true,
    defaultView: {
      latitude: 27.0436,
      longitude: -82.2187,
      zoom: 12
    },
    onParcelSelect: function(parcel) {
      console.log('Selected parcel:', parcel.address);
    },
    onReady: function(api) {
      console.log('DigiMap is ready!');
    }
  });

  // Use the API
  digimap.setCenter(27.9506, -82.4572, 11);
  digimap.openPropertyDrawer('property-123');
</script>
```

### jQuery

```html
<!-- HTML -->
<div id="digimap-container" style="width: 100%; height: 600px;"></div>

<!-- Load jQuery and the embed script -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://embed.digimap.app/embed/digimap.1.0.0.iife.js"></script>

<script>
  $(document).ready(function() {
    // Initialize the map (use tokens from your auth endpoint)
    var digimap = new DigiMap({
      container: '#digimap-container',
      accessToken: token,
      refreshToken: token,
      theme: 'light',
      showSearch: true,
      showFilter: true,
      defaultView: {
        latitude: 27.0436,
        longitude: -82.2187,
        zoom: 12
      },
      onParcelSelect: function(parcel) {
        // Update your jQuery UI with parcel data
        $('#selected-address').text(parcel.address);
        $('#selected-owner').text(parcel.ownerName);
      },
      onReady: function(api) {
        console.log('DigiMap initialized');
      }
    });

    // Bind to your UI elements
    $('#search-btn').on('click', function() {
      var query = $('#search-input').val();
      digimap.setSearchQuery(query);
    });

    $('#theme-toggle').on('click', function() {
      var isDark = $(this).is(':checked');
      digimap.setTheme(isDark ? 'dark' : 'light');
    });
  });
</script>
```

### React

```jsx
import { useEffect, useRef } from 'react';

// Option 1: Using the DigiMap class
function DigiMapEmbed({ onParcelSelect }) {
  const containerRef = useRef(null);
  const digimapRef = useRef(null);

  useEffect(() => {
    if (containerRef.current && !digimapRef.current) {
      digimapRef.current = new window.DigiMap({
        container: containerRef.current,
        theme: 'light',
        showSearch: true,
        showFilter: true,
        showStatistics: true,
        defaultView: {
          latitude: 27.0436,
          longitude: -82.2187,
          zoom: 12
        },
        onParcelSelect: (parcel) => {
          onParcelSelect?.(parcel);
        },
        onReady: (api) => {
          console.log('DigiMap ready');
        }
      });
    }

    return () => {
      digimapRef.current?.destroy();
      digimapRef.current = null;
    };
  }, []);

  return (
    <div 
      ref={containerRef} 
      style={{ width: '100%', height: '600px' }}
    />
  );
}

// Usage in your app
function PropertyPage() {
  const handleParcelSelect = (parcel) => {
    console.log('Selected:', parcel.address);
  };

  return (
    <div className="property-page">
      <h1>Property Map</h1>
      <DigiMapEmbed onParcelSelect={handleParcelSelect} />
    </div>
  );
}

export default PropertyPage;
```

### Angular

```typescript
// digimap.component.ts
import { Component, ElementRef, ViewChild, OnInit, OnDestroy, Output, EventEmitter } from '@angular/core';

declare global {
  interface Window {
    DigiMap: any;
  }
}

@Component({
  selector: 'app-digimap',
  template: `
    <div #digimapContainer style="width: 100%; height: 600px;"></div>
  `
})
export class DigimapComponent implements OnInit, OnDestroy {
  @ViewChild('digimapContainer', { static: true }) containerRef!: ElementRef;
  @Output() parcelSelect = new EventEmitter<any>();
  @Output() ready = new EventEmitter<any>();
  
  private digimap: any;

  ngOnInit(): void {
    this.initializeMap();
  }

  ngOnDestroy(): void {
    this.digimap?.destroy();
  }

  private initializeMap(): void {
    this.digimap = new window.DigiMap({
      container: this.containerRef.nativeElement,
      theme: 'light',
      showSearch: true,
      showFilter: true,
      showStatistics: true,
      defaultView: {
        latitude: 27.0436,
        longitude: -82.2187,
        zoom: 12
      },
      onParcelSelect: (parcel: any) => {
        this.parcelSelect.emit(parcel);
      },
      onReady: (api: any) => {
        this.ready.emit(api);
      }
    });
  }

  // Public methods for external control
  setCenter(lat: number, lng: number, zoom?: number): void {
    this.digimap?.setCenter(lat, lng, zoom);
  }

  setTheme(theme: 'light' | 'dark'): void {
    this.digimap?.setTheme(theme);
  }

  openPropertyDrawer(propertyId: string): void {
    this.digimap?.openPropertyDrawer(propertyId);
  }
}

// Usage in parent component
// <app-digimap 
//   (parcelSelect)="onParcelSelect($event)"
//   (ready)="onMapReady($event)">
// </app-digimap>
```

### Vue

```typescript
<!-- DigiMap.vue -->
<template>
  <div ref="container" :style="containerStyle"></div>
</template>

<script>
export default {
  name: 'DigiMap',
  props: {
    theme: {
      type: String,
      default: 'auto',
      validator: (value) => ['light', 'dark', 'auto'].includes(value)
    },
    showSearch: {
      type: Boolean,
      default: true
    },
    showFilter: {
      type: Boolean,
      default: true
    },
    showStatistics: {
      type: Boolean,
      default: false
    },
    defaultView: {
      type: [String, Object],
      default: 'us'
    },
    height: {
      type: String,
      default: '600px'
    }
  },
  emits: ['parcel-select', 'search', 'ready'],
  data() {
    return {
      digimap: null
    };
  },
  computed: {
    containerStyle() {
      return {
        width: '100%',
        height: this.height
      };
    }
  },
  mounted() {
    this.initializeMap();
  },
  beforeUnmount() {
    this.digimap?.destroy();
  },
  methods: {
    initializeMap() {
      this.digimap = new window.DigiMap({
        container: this.$refs.container,
        theme: this.theme,
        showSearch: this.showSearch,
        showFilter: this.showFilter,
        showStatistics: this.showStatistics,
        defaultView: this.defaultView,
        onParcelSelect: (parcel) => {
          this.$emit('parcel-select', parcel);
        },
        onSearch: (query) => {
          this.$emit('search', query);
        },
        onReady: (api) => {
          this.$emit('ready', api);
        }
      });
    },
    setCenter(lat, lng, zoom) {
      this.digimap?.setCenter(lat, lng, zoom);
    },
    setTheme(theme) {
      this.digimap?.setTheme(theme);
    },
    openPropertyDrawer(propertyId) {
      this.digimap?.openPropertyDrawer(propertyId);
    },
    getSelectedParcel() {
      return this.digimap?.getSelectedParcel() || null;
    }
  }
};
</script>

<!-- Usage -->
<!--
<DigiMap
  theme="light"
  :show-statistics="true"
  :default-view="{ latitude: 27.9506, longitude: -82.4572, zoom: 11 }"
  @parcel-select="handleParcelSelect"
  @ready="handleReady"
/>
-->
```

---

## Configuration Options

| Option | Type | Required | Default | Description |
|---|---|---|---|---|
| `container` | `string | HTMLElement` | No | — | CSS selector or DOM element where the map will be mounted. When omitted, the widget runs in headless mode — no map or search UI is rendered, but you can still call API methods like openPropertyDrawer() to show the property detail drawer overlaid on your page |
| `accessToken` | `string` | Yes | — | JWT access token obtained from server-side authentication. Required for the widget to communicate with DigiMap services. The widget will throw an error if not provided |
| `refreshIntervalMs` | `number` | No | 600000 | Advanced — The widget automatically refreshes tokens internally; you do not need to build a refresh loop. This option only fine-tunes the interval between automatic refreshes. Defaults to 600000 (10 minutes). Valid range is 300000 to 900000 (5 to 15 minutes). Values outside this range are ignored and the default is used. Most integrations should omit this option entirely |
| `apiBaseUrl` | `string` | No | 'https://embed.digimap.app' | Base URL for all API calls. Defaults to 'https://embed.digimap.app'. When the widget is embedded on external sites, this ensures API calls go to the correct DigiMap server instead of the host site's domain. Override this only for development or custom deployments |
| `cssUrl` | `string` | No | — | URL to a custom CSS file that overrides the widget's default stylesheet. Useful for self-hosted deployments or when you need to apply custom CSS beyond the color token system. The widget auto-loads its built-in CSS by default; this option replaces that with your specified URL |
| `theme` | `'light' | 'dark' | 'auto'` | No | 'auto' | Color theme for the map interface. 'auto' (default) detects the host page's theme by checking for a 'dark' class on the <html> element or falling back to the browser's prefers-color-scheme media query, and updates reactively if the host page theme changes. 'light' applies light colors regardless of the host page. 'dark' applies dark colors regardless of the host page |
| `colors` | `{ light?: ColorTokens, dark?: ColorTokens }` | No | — | Customize the widget color scheme using the pure-override system. Provide a 'light' and/or 'dark' object with any of the 8 color tokens you want to change. Only the tokens you specify are overridden — all others keep their built-in defaults. There is no auto-derivation between tokens. See the Color System section below for the interactive token reference and config generator |
| `useShadowDOM` | `boolean` | No | true | Enable Shadow DOM for complete CSS isolation from host page. Disable to allow external CSS customization |
| `showHeader` | `boolean` | No | true | Show or hide the entire header bar (search, filter, view mode toggle) |
| `showSearch` | `boolean` | No | true | Show the location search box in the header |
| `showFilter` | `boolean` | No | true | Show the filter controls button in the header |
| `showMoveSearchToggle` | `boolean` | No | true | Show the 'Move Search With Map' toggle button on the map. When enabled, the map automatically searches for properties as the user pans or zooms |
| `showStatistics` | `boolean` | No | false | Show the statistics panel after searching a location |
| `defaultView` | `'us' | { latitude: number; longitude: number; zoom?: number }` | No | 'us' | Initial map view — 'us' to fit the continental United States, or an object with coordinates and zoom. If the object has missing or invalid latitude/longitude values, the widget falls back to 'us' mode and fires onError with a warning |
| `defaultView.latitude` | `number` | No | — | Latitude coordinate for initial map center (when using object form) |
| `defaultView.longitude` | `number` | No | — | Longitude coordinate for initial map center (when using object form) |
| `defaultView.zoom` | `number` | No | 12 | Initial zoom level 1-22 (when using object form) |
| `defaultMapType` | `'streets' | 'satellite'` | No | 'streets' | Sets the initial map style. Use 'streets' for the standard street map or 'satellite' for satellite imagery |
| `controls` | `object` | No | {} | Configure visibility of individual map control buttons. All controls default to true (visible). Pass an object with boolean flags to hide specific controls |
| `controls.showRuler` | `boolean` | No | true | Show the distance measurement (ruler) tool |
| `controls.showLassoSearch` | `boolean` | No | true | Show the polygon draw (lasso search) tool |
| `controls.showFullscreen` | `boolean` | No | true | Show the fullscreen toggle button |
| `controls.showEnable3D` | `boolean` | No | true | Show the 3D tilt view toggle button |
| `controls.showResetView` | `boolean` | No | true | Show the reset view button (resets bearing to north and pitch to 0) |
| `controls.showSatelliteToggle` | `boolean` | No | true | Show the satellite/street map toggle button |
| `controls.showZoomIn` | `boolean` | No | true | Show the zoom in button |
| `controls.showZoomOut` | `boolean` | No | true | Show the zoom out button |
| `customActions` | `CustomAction[]` | No | — | Array of custom action buttons to inject at predefined placement points in the UI. Each action specifies a placement (where to render), an onClick callback that receives contextual data, and optional icon, label, tooltip, variant, and visibility condition. Supported placements: 'property-detail-header' (next to the share button in the property details drawer) |
| `customActions[].id` | `string` | No | — | Optional unique identifier for the action button |
| `customActions[].placement` | `'property-detail-header'` | Yes | — | Where to render the button. Currently supported: 'property-detail-header' (next to the share button in the property details drawer) |
| `customActions[].label` | `string` | No | — | Button text. Omit for icon-only buttons |
| `customActions[].icon` | `string` | No | — | Icon name from the built-in set (e.g. 'plus', 'star', 'send', 'download', 'external-link', 'bookmark', 'heart', 'copy', 'mail', 'phone', 'flag', 'zap') or a raw SVG string starting with '<svg' |
| `customActions[].tooltip` | `string` | No | — | Hover text shown on mouseover |
| `customActions[].variant` | `'default' | 'outline' | 'ghost'` | No | 'ghost' | Button style variant |
| `customActions[].className` | `string` | No | — | Optional extra CSS class name for custom styling |
| `customActions[].onClick` | `(context: { placement: string; parcel?: Parcel }) => void` | Yes | — | Called when the button is clicked. Receives a context object with the placement string and contextual data — for 'property-detail-header', the context includes the full parcel object |
| `customActions[].condition` | `(context: { placement: string; parcel?: Parcel }) => boolean` | No | — | Optional visibility condition. Receives the same context as onClick. Return false to hide the button. Defaults to always visible |

---

## API Methods

### `openPropertyDrawer(propertyId: string): void`

Opens the property details drawer for the specified property

**Parameters:**

| Name | Type | Description |
|---|---|---|
| `propertyId` | `string` | The unique identifier of the property to display |

**Example (JavaScript):**

```javascript
// Open a property's details drawer
digimap.openPropertyDrawer('property-123');

// Example: Open drawer when clicking a list item
document.querySelectorAll('.property-item').forEach(item => {
  item.addEventListener('click', () => {
    const propertyId = item.dataset.propertyId;
    digimap.openPropertyDrawer(propertyId);
  });
});
```

### `getPropertyDetails(id: string, useListingId?: boolean): Promise<PropertyDetails | null>`

Fetches complete property details by propertyId or listingId. By default uses the propertyId. Set useListingId to true to fetch by listingId instead — useful for properties that don't have a propertyId. Returns all the data displayed in the property details drawer including address, price, beds/baths, MLS status, photos, agent info, and the raw API response. Returns null if the property is not found or the request fails.

**Parameters:**

| Name | Type | Description |
|---|---|---|
| `id` | `string` | The propertyId or listingId to fetch details for |
| `useListingId` | `boolean` | When checked, treats the id as a listingId |

**Returns:** `Promise<PropertyDetails | null>`

**Example (JavaScript):**

```javascript
// Fetch property details by propertyId
digimap.getPropertyDetails('property-123').then(function(details) {
  if (details) {
    console.log('Address:', details.address);
    console.log('Price:', details.price);
    console.log('Beds:', details.beds);
    console.log('Baths:', details.baths);
    console.log('Photos:', details.photos);
  } else {
    console.log('Property not found');
  }
});

// Fetch property details by listingId
digimap.getPropertyDetails('listing-456', true).then(function(details) {
  if (details) {
    console.log('Address:', details.address);
    console.log('Price:', details.price);
  }
});
```

### `closeParcelDrawer(): void`

Closes the property details drawer if open

**Example (JavaScript):**

```javascript
// Close the drawer
digimap.closeParcelDrawer();

// Example: Close drawer when pressing Escape
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') {
    digimap.closeParcelDrawer();
  }
});
```

### `openFilterDrawer(): void`

Opens the filter drawer panel from the right side of the screen

**Example (JavaScript):**

```javascript
// Open the filter drawer
digimap.openFilterDrawer();

// Example: Open filter drawer from a custom button
document.getElementById('custom-filter-btn').addEventListener('click', () => {
  digimap.openFilterDrawer();
});
```

### `closeFilterDrawer(): void`

Closes the filter drawer panel if open

**Example (JavaScript):**

```javascript
// Close the filter drawer
digimap.closeFilterDrawer();

// Example: Close filter drawer after applying filters
document.getElementById('apply-filters').addEventListener('click', () => {
  // Apply your filter logic
  digimap.closeFilterDrawer();
});
```

### `highlightParcel(parcelId: string): void`

Highlights a parcel on the map and centers the view on it

**Parameters:**

| Name | Type | Description |
|---|---|---|
| `parcelId` | `string` | The unique identifier of the parcel to highlight |

**Example (JavaScript):**

```javascript
// Highlight a specific parcel
digimap.highlightParcel('parcel-456');

// Example: Highlight on hover
document.querySelectorAll('.parcel-item').forEach(item => {
  item.addEventListener('mouseenter', () => {
    digimap.highlightParcel(item.dataset.parcelId);
  });
});
```

### `setSearchQuery(query: string): void`

Sets the search input value and triggers a search

**Parameters:**

| Name | Type | Description |
|---|---|---|
| `query` | `string` | The search query text |

**Example (JavaScript):**

```javascript
// Search for an address
digimap.setSearchQuery('123 Main Street, Tampa, FL');

// Example: Search from an external input
const searchInput = document.getElementById('external-search');
const searchBtn = document.getElementById('search-btn');
searchBtn.addEventListener('click', () => {
  digimap.setSearchQuery(searchInput.value);
});
```

### `runSearch(query: string): Promise<boolean>`

Programmatically executes a full search: runs autocomplete on the query, selects the first result, and navigates the map to that location. Returns a Promise that resolves to true if the search succeeded, or false if no results were found or the query was empty. Clears active filters and aborts any pending searches before executing

**Parameters:**

| Name | Type | Description |
|---|---|---|
| `query` | `string` | A full or partial address to search for |

**Example (JavaScript):**

```javascript
// Search and navigate to a specific address
const success = await digimap.runSearch('123 Main Street, Tampa, FL');
console.log(success ? 'Found it!' : 'No results');

// Example: External search form
document.getElementById('go-btn').addEventListener('click', async () => {
  const addr = document.getElementById('address-input').value;
  const found = await digimap.runSearch(addr);
  if (!found) alert('Address not found');
});
```

### `clearSearch(): void`

Clears the current search results, removes all property pins from the map, resets the search input, and deselects any selected properties. Use this to return the map to a clean state without any active search. Aborts any in-progress search requests

**Example (JavaScript):**

```javascript
// Clear search results
digimap.clearSearch();

// Example: Reset button
document.getElementById('reset-btn').addEventListener('click', () => {
  digimap.clearSearch();
  console.log('Search cleared');
});
```

### `runAutoComplete(query: string, options?: { updateUI?: boolean }): Promise<AutoCompleteResult[]>`

Programmatically runs the autocomplete search API and returns an array of matching results. Each result includes id, type (address, city, zip, county, apn), display text, coordinates, and the raw API response. When updateUI is true, it also fills the search input with the query and opens the typeahead dropdown showing the results — equivalent to the user typing in the search box. When updateUI is false (default), results are returned silently without changing the UI. Requires a minimum query length of 3 characters; returns an empty array for shorter queries

**Parameters:**

| Name | Type | Description |
|---|---|---|
| `query` | `string` | The search text to autocomplete (minimum 3 characters) |
| `options.updateUI` | `boolean` | If true, populates the search input and opens the typeahead dropdown with results. Defaults to false |

**Example (JavaScript):**

```javascript
// Get autocomplete results without affecting the UI
const results = await digimap.runAutoComplete('Tampa');
console.log(results); // [{ id, type, display, latitude, longitude, ... }]

// Show results in the search UI (as if the user typed it)
await digimap.runAutoComplete('123 Main St', { updateUI: true });

// Example: Power an external typeahead
const input = document.getElementById('my-search');
input.addEventListener('input', async () => {
  const results = await digimap.runAutoComplete(input.value);
  renderSuggestions(results);
});
```

### `setCenter(lat: number, lng: number, zoom?: number): boolean`

Moves the map to the specified coordinates with optional zoom. Returns true if the operation succeeded, or false if validation failed. Validates lat (-90 to 90), lng (-180 to 180), and zoom (0 to 24); fires onError with a warning and ignores the call if values are invalid

**Parameters:**

| Name | Type | Description |
|---|---|---|
| `lat` | `number` | Latitude coordinate |
| `lng` | `number` | Longitude coordinate |
| `zoom` | `number` | Optional zoom level (1-22) |

**Example (JavaScript):**

```javascript
// Center on Tampa, FL at zoom level 12
digimap.setCenter(27.9506, -82.4572, 12);

// Example: Jump to predefined locations
const locations = {
  tampa: { lat: 27.9506, lng: -82.4572 },
  miami: { lat: 25.7617, lng: -80.1918 }
};
document.getElementById('goto-tampa').addEventListener('click', () => {
  digimap.setCenter(locations.tampa.lat, locations.tampa.lng, 11);
});
```

### `setZoom(level: number): boolean`

Sets the map zoom level. Returns true if the operation succeeded, or false if validation failed. Validates that the value is a number between 0 and 24; fires onError with a warning and ignores the call if the value is invalid

**Parameters:**

| Name | Type | Description |
|---|---|---|
| `level` | `number` | Zoom level (0-24) |

**Example (JavaScript):**

```javascript
// Zoom to street level
digimap.setZoom(16);

// Example: Zoom controls
document.getElementById('zoom-in').addEventListener('click', () => {
  digimap.setZoom(18);
});
document.getElementById('zoom-out').addEventListener('click', () => {
  digimap.setZoom(10);
});
```

### `getSelectedParcel(): Parcel | null`

Returns the currently selected parcel object, or null if none selected

**Returns:** `Parcel | null`

**Example (JavaScript):**

```javascript
// Get the currently selected parcel
const parcel = digimap.getSelectedParcel();
if (parcel) {
  console.log('Selected:', parcel.address);
  console.log('Owner:', parcel.ownerName);
  console.log('Value:', parcel.marketValue);
}
```

### `getPropertiesIds(): Array<{ propertyId: string; listingId: string }>`

Returns an array of objects containing propertyId and listingId for all properties in the current search results. Use this to discover which IDs can be passed to setSelectedProperties for external selection management. The listingId may be an empty string if no listing is associated with the property.

**Returns:** `Array<{ propertyId: string; listingId: string }>`

**Example (JavaScript):**

```javascript
// Get all available properties from current search results
const properties = digimap.getPropertiesIds();
console.log('Available properties:', properties);
console.log('Total properties:', properties.length);

// Example: Build a custom property list from available properties
const properties = digimap.getPropertiesIds();
properties.forEach(({ propertyId, listingId }) => {
  console.log('Property ID:', propertyId, 'Listing ID:', listingId);
});
```

### `setSelectedProperties(ids: string[]): void`

Programmatically sets the selected properties by replacing the current multi-selection with the provided array of property IDs. Only IDs present in the current search results are accepted — unknown, non-string, or empty-string IDs are silently filtered and a warning is emitted via onError (code: INVALID_METHOD_ARGS) with the list of ignored IDs. If no search has been run yet, all IDs will be rejected since there are no results to match against. Duplicates are deduplicated automatically. Pass an empty array to clear all selections. Call getPropertiesIds() first to retrieve the valid IDs for the current search.

**Parameters:**

| Name | Type | Description |
|---|---|---|
| `ids` | `string[]` | ex. id1, id2, id3 |

**Example (JavaScript):**

```javascript
// First get the available properties
const properties = digimap.getPropertiesIds();
const ids = properties.map(p => p.propertyId);

// Select all available properties
digimap.setSelectedProperties(ids);

// Select specific properties by ID
digimap.setSelectedProperties(['id-001', 'id-002', 'id-003']);

// Clear all selections
digimap.setSelectedProperties([]);

// Example: Select the first 5 properties
const properties = digimap.getPropertiesIds();
digimap.setSelectedProperties(properties.slice(0, 5).map(p => p.propertyId));
```

### `getSelectedProperties(): string[]`

Returns an array of the currently selected property IDs. These are the IDs that have been selected via setSelectedProperties or by user interaction in list/grid view

**Returns:** `string[]`

**Example (JavaScript):**

```javascript
// Get currently selected property IDs
const selectedIds = digimap.getSelectedProperties();
console.log('Selected IDs:', selectedIds);
console.log('Count:', selectedIds.length);

// Example: Check if a specific property is selected
const selectedIds = digimap.getSelectedProperties();
const isSelected = selectedIds.includes('parcel-123');
console.log('Is selected:', isSelected);
```

### `setAccessToken(token: string): void`

Edge case only — The widget handles token refresh automatically, so you do not need to call this method during normal operation. Use this only if the host application's user session changes (e.g., a different user logs in) and you need to swap the token without reinitializing the widget

**Parameters:**

| Name | Type | Description |
|---|---|---|
| `token` | `string` | New JWT access token to replace the current one |

**Example (JavaScript):**

```javascript
// Edge case: swap token when host app user changes (not needed for refresh)
digimap.setAccessToken(newAccessToken);
```

### `showHeader(visible: boolean): void`

Shows or hides the entire header bar including search, filter button, and view mode toggle

**Parameters:**

| Name | Type | Description |
|---|---|---|
| `visible` | `boolean` | Whether to show (true) or hide (false) the header bar |

**Example (JavaScript):**

```javascript
// Hide the header
digimap.showHeader(false);

// Show the header
digimap.showHeader(true);

// Example: Toggle header visibility
let headerVisible = true;
document.getElementById('toggle-header').addEventListener('click', () => {
  headerVisible = !headerVisible;
  digimap.showHeader(headerVisible);
});
```

### `showSearch(visible: boolean): void`

Shows or hides the search input box in the header without affecting other header elements

**Parameters:**

| Name | Type | Description |
|---|---|---|
| `visible` | `boolean` | Whether to show (true) or hide (false) the search box |

**Example (JavaScript):**

```javascript
// Hide the search box
digimap.showSearch(false);

// Show the search box
digimap.showSearch(true);
```

### `showFilter(visible: boolean): void`

Shows or hides the filter button in the header without affecting other header elements

**Parameters:**

| Name | Type | Description |
|---|---|---|
| `visible` | `boolean` | Whether to show (true) or hide (false) the filter button |

**Example (JavaScript):**

```javascript
// Hide the filter button
digimap.showFilter(false);

// Show the filter button
digimap.showFilter(true);
```

### `setMoveSearchWithMap(enabled: boolean): void`

Enables or disables automatic property searching when the user pans or zooms the map. When disabled, the map can be moved without triggering new searches. When re-enabled, an immediate search is triggered for the current viewport

**Parameters:**

| Name | Type | Description |
|---|---|---|
| `enabled` | `boolean` | Whether to enable (true) or disable (false) automatic searching when the map moves |

**Example (JavaScript):**

```javascript
// Disable move search
digimap.setMoveSearchWithMap(false);

// Re-enable move search
digimap.setMoveSearchWithMap(true);
```

### `showMoveSearchToggle(visible: boolean): void`

Shows or hides the 'Move Search With Map' toggle button on the map

**Parameters:**

| Name | Type | Description |
|---|---|---|
| `visible` | `boolean` | Whether to show (true) or hide (false) the move search toggle button |

**Example (JavaScript):**

```javascript
// Hide the move search toggle
digimap.showMoveSearchToggle(false);

// Show the move search toggle
digimap.showMoveSearchToggle(true);
```

### `setTheme(theme: 'light' | 'dark'): void`

Switches the map interface between light and dark themes

**Parameters:**

| Name | Type | Description |
|---|---|---|
| `theme` | `'light' | 'dark'` | The theme to apply |

**Example (JavaScript):**

```javascript
// Switch to dark theme
digimap.setTheme('dark');

// Example: Theme toggle button
let isDark = false;
document.getElementById('theme-toggle').addEventListener('click', () => {
  isDark = !isDark;
  digimap.setTheme(isDark ? 'dark' : 'light');
});
```

### `setMapType(type: 'streets' | 'satellite'): boolean`

Switches the map between street and satellite view. Returns true if applied, false if the value is invalid

**Parameters:**

| Name | Type | Description |
|---|---|---|
| `type` | `'streets' | 'satellite'` | The map style to apply — 'streets' for street map or 'satellite' for satellite imagery |

**Example (JavaScript):**

```javascript
// Switch to satellite view
digimap.setMapType('satellite');

// Example: Map type toggle button
let isSatellite = false;
document.getElementById('map-type-toggle').addEventListener('click', () => {
  isSatellite = !isSatellite;
  digimap.setMapType(isSatellite ? 'satellite' : 'streets');
});
```

### `addQuickLookupPin(lat: number, lng: number): Promise<Parcel | null>`

Performs a Quick Lookup at the specified coordinates, placing a temporary pin on the map with property information if a property is found within ~500 feet. Returns the found property or null. Only one quick lookup pin can exist at a time; calling this again replaces the previous one

**Parameters:**

| Name | Type | Description |
|---|---|---|
| `lat` | `number` | Latitude of the location to look up |
| `lng` | `number` | Longitude of the location to look up |

**Returns:** `Promise<Parcel | null>`

**Example (JavaScript):**

```javascript
digimap.addQuickLookupPin(27.9506, -82.4572).then(function(parcel) {
  if (parcel) {
    console.log('Found property:', parcel.address);
  } else {
    console.log('No property found nearby');
  }
});
```

### `clearQuickLookup(): void`

Removes the current Quick Lookup pin and popup from the map. Fires the onQuickLookupClear event with reason 'api'

**Example (JavaScript):**

```javascript
digimap.clearQuickLookup();
```

### `setActiveView(mode: 'map' | 'split' | 'grid' | 'list' | 'compact'): boolean`

Switches the active view mode. Returns true if the mode was applied successfully, false if the provided mode is invalid. Fires an INVALID_METHOD_ARGS warning via the onError callback when an invalid mode is provided

**Parameters:**

| Name | Type | Description |
|---|---|---|
| `mode` | `'map' | 'split' | 'grid' | 'list' | 'compact'` | The view mode to activate. 'map' = full map, 'split' = map + property cards side by side, 'grid' = property cards in a grid, 'list' = property table view, 'compact' = compact list view |

**Example (JavaScript):**

```javascript
// Switch to grid view
digimap.setActiveView('grid');

// Example: View mode toggle buttons
document.querySelectorAll('[data-view]').forEach(btn => {
  btn.addEventListener('click', () => {
    const mode = btn.dataset.view;
    const success = digimap.setActiveView(mode);
    if (!success) console.warn('Invalid view mode:', mode);
  });
});
```

### `getActiveView(): 'map' | 'split' | 'grid' | 'list' | 'compact'`

Returns the currently active view mode. Returns 'map' as the default if the widget is not yet initialized

**Example (JavaScript):**

```javascript
// Get the current view mode
const currentView = digimap.getActiveView();
console.log('Current view:', currentView);
```

### `destroy(): void`

Cleans up all resources and removes the map from the DOM

**Example (JavaScript):**

```javascript
// Clean up when done
digimap.destroy();

// Example: Clean up before page unload
window.addEventListener('beforeunload', () => {
  digimap.destroy();
});
```

---

## Events

| Event | Signature | Description |
|---|---|---|
| `onReady` | `(api: DigiMapPublicAPI) => void` | Called when the map is fully initialized and ready for interaction |
| `onError` | `(error: { type: string; message: string; details?: any }) => void` | Called when a warning or error occurs within the widget (e.g., invalid configuration, failed token refresh, map initialization errors) |
| `onSearch` | `(query: string) => void` | Called when a user submits a location search query (e.g., types an address and hits Enter). This is a lightweight event that fires immediately with the raw query text. For detailed search lifecycle tracking, use onSearchStart / onSearchComplete / onSearchError instead |
| `onLocationSelect` | `(payload: { selection: object; searchType: string; query: string }) => void` | Called when an autocomplete location suggestion is selected, whether by user click or programmatically. Provides the full selected item object, the resolved search type, and the raw query string |
| `onSearchStart` | `(payload: { searchId: string; location: string; searchType: string; filters: object; trigger: string }) => void` | Called when a property search request begins. Fires for both user-initiated and programmatic searches, as well as map-move searches. The searchId can be used to correlate with the corresponding onSearchComplete or onSearchError event |
| `onSearchComplete` | `(payload: { searchId: string; totalProperties: number; mlsBreakdown: object; assessorCount: number; timeToFirstBatchMs: number; totalFetchTimeMs: number; totalRenderTimeMs: number; filters: object; truncated: boolean }) => void` | Called when all search results have been fetched and rendered. Provides comprehensive metrics including result counts, MLS status breakdowns, and detailed timing information |
| `onSearchError` | `(payload: { searchId: string; error: string; phase: string; partialResultsCount: number }) => void` | Called when a search fails due to network errors, API errors, or timeouts. Provides information about which phase failed and how many partial results were retrieved before the failure |
| `onSearchEmpty` | `(payload: { searchId: string; location: string; searchType: string; filters: object }) => void` | Called when a search completes successfully but returns no properties. This fires in addition to onSearchComplete (which will have totalProperties: 0). Use this for specific handling of empty results, such as showing a custom message or suggesting the user broaden their filters |
| `onFilterChange` | `(payload: { filter: string; oldValue: any; newValue: any; allFilters: object }) => void` | Called when any search filter is modified, whether before or after a search. Provides the specific filter that changed along with its old and new values, plus the complete set of current filter values |
| `onSearchResultInteraction` | `(payload: { property: Parcel; resultIndex: number; searchId: string; interactionType: string }) => void` | Called when a user clicks a property card or map pin from search results. Connects search quality to user engagement by tracking which results users interact with |
| `onParcelSelect` | `(parcel: Parcel) => void` | Called when a user selects a parcel on the map |
| `onDrawerOpen` | `(payload: { drawerType: string; parcelId?: string }) => void` | Called when any drawer opens — property details, filter, or comparables. Use this to track drawer usage or coordinate with your page layout (e.g., shrinking a sidebar when the drawer opens) |
| `onDrawerClose` | `(payload: { drawerType: string; reason: string }) => void` | Called when any drawer closes. Provides the reason so you can distinguish user-initiated closes from programmatic ones |
| `onQuickLookup` | `(payload: { property: Parcel; coordinates: { lat: number; lng: number } }) => void` | Called when a Quick Lookup finds a property near the clicked map coordinates. The property is displayed as a temporary pin with an info popup. Fires for both user map clicks and programmatic addQuickLookupPin() calls |
| `onQuickLookupClear` | `(payload: { reason: string }) => void` | Called when a Quick Lookup pin is explicitly dismissed. Reason values: 'escape' (user pressed Escape key), 'dismiss' (user clicked Dismiss button or closed popup), 'api' (clearQuickLookup() was called programmatically). Does not fire when the pin is implicitly cleared by a new search, filter reset, or view reset |
| `onSelectionChange` | `(ids: string[]) => void` | Called whenever the multi-selection state changes. Fires on user checkbox interaction (table/card view), Select All/Deselect All actions, and setSelectedProperties() API calls. Receives an empty array when the selection is cleared. |
| `onMapViewChange` | `(payload: { center: { latitude: number; longitude: number }; zoom: number; bounds: { northeast: { latitude: number; longitude: number }; southwest: { latitude: number; longitude: number } } }) => void` | Called when the map view changes (pan or zoom). Debounced at 300ms to prevent excessive events during continuous interactions like scroll-wheel zooming |
| `onActiveViewChange` | `(payload: { previousView: string; activeView: string; trigger: string }) => void` | Called when the active view mode changes. View modes are 'map', 'split', 'grid', 'list', and 'compact'. The trigger indicates the source: 'user' for manual toggle, 'programmatic' for setActiveView() API calls, and 'auto' for automatic resets (e.g. zooming out resets to map view) |

### Event Details

#### `onReady`

Called when the map is fully initialized and ready for interaction

**Payload:** The API object for programmatic control

#### `onError`

Called when a warning or error occurs within the widget (e.g., invalid configuration, failed token refresh, map initialization errors)

**Payload:** Error object with type ('warning' or 'error'), a descriptive message, and optional details

#### `onSearch`

Called when a user submits a location search query (e.g., types an address and hits Enter). This is a lightweight event that fires immediately with the raw query text. For detailed search lifecycle tracking, use onSearchStart / onSearchComplete / onSearchError instead

**Payload:** The search query string

#### `onLocationSelect`

Called when an autocomplete location suggestion is selected, whether by user click or programmatically. Provides the full selected item object, the resolved search type, and the raw query string

**Payload:** Object with selection (full autocomplete result object), searchType ('address', 'city', 'county', 'zip', or 'apn'), and query (the text that was typed)

#### `onSearchStart`

Called when a property search request begins. Fires for both user-initiated and programmatic searches, as well as map-move searches. The searchId can be used to correlate with the corresponding onSearchComplete or onSearchError event

**Payload:** Object with searchId (unique identifier), location (display name), searchType ('address', 'city', 'county', 'zip', or 'apn'), filters (active filter values), and trigger ('user', 'programmatic', or 'map_move')

#### `onSearchComplete`

Called when all search results have been fetched and rendered. Provides comprehensive metrics including result counts, MLS status breakdowns, and detailed timing information

**Payload:** Object with searchId, totalProperties (total count), mlsBreakdown (e.g. { active: 10, sold: 5, pending: 3 }), assessorCount, timeToFirstBatchMs (time until first results appeared), totalFetchTimeMs (total API fetch time), totalRenderTimeMs (time from search start to final render), filters (applied filters), and truncated (whether results were capped)

#### `onSearchError`

Called when a search fails due to network errors, API errors, or timeouts. Provides information about which phase failed and how many partial results were retrieved before the failure

**Payload:** Object with searchId, error (error message), phase ('fetch' or 'render'), and partialResultsCount (number of results retrieved before failure)

#### `onSearchEmpty`

Called when a search completes successfully but returns no properties. This fires in addition to onSearchComplete (which will have totalProperties: 0). Use this for specific handling of empty results, such as showing a custom message or suggesting the user broaden their filters

**Payload:** Object with searchId (unique identifier matching the corresponding onSearchStart), location (display name of the searched area), searchType ('address', 'city', 'county', 'zip', or 'apn'), and filters (active filter values at the time of the search)

#### `onFilterChange`

Called when any search filter is modified, whether before or after a search. Provides the specific filter that changed along with its old and new values, plus the complete set of current filter values

**Payload:** Object with filter (name of the changed filter), oldValue (previous value), newValue (new value), and allFilters (all current filter values)

#### `onSearchResultInteraction`

Called when a user clicks a property card or map pin from search results. Connects search quality to user engagement by tracking which results users interact with

**Payload:** Object with property (the Parcel object), resultIndex (position in the result list), searchId (the search that produced this result), and interactionType ('card_click' or 'pin_click')

#### `onParcelSelect`

Called when a user selects a parcel on the map

**Payload:** Parcel object containing PropertyId, address, owner, valuation, and property details

#### `onDrawerOpen`

Called when any drawer opens — property details, filter, or comparables. Use this to track drawer usage or coordinate with your page layout (e.g., shrinking a sidebar when the drawer opens)

**Payload:** Object with drawerType ('property', 'filter', or 'comparables') and optional parcelId (only for property/comparables drawers)

#### `onDrawerClose`

Called when any drawer closes. Provides the reason so you can distinguish user-initiated closes from programmatic ones

**Payload:** Object with drawerType ('property', 'filter', or 'comparables') and reason ('user' for manual close, 'api' for programmatic close via closeParcelDrawer/closeFilterDrawer, 'navigation' for closing due to navigating to a different property)

#### `onQuickLookup`

Called when a Quick Lookup finds a property near the clicked map coordinates. The property is displayed as a temporary pin with an info popup. Fires for both user map clicks and programmatic addQuickLookupPin() calls

**Payload:** Object with property (the found Parcel object) and coordinates (the original { lat, lng } that was clicked/queried)

#### `onQuickLookupClear`

Called when a Quick Lookup pin is explicitly dismissed. Reason values: 'escape' (user pressed Escape key), 'dismiss' (user clicked Dismiss button or closed popup), 'api' (clearQuickLookup() was called programmatically). Does not fire when the pin is implicitly cleared by a new search, filter reset, or view reset

**Payload:** Object with reason ('escape', 'dismiss', or 'api') indicating how the quick lookup was cleared

#### `onSelectionChange`

Called whenever the multi-selection state changes. Fires on user checkbox interaction (table/card view), Select All/Deselect All actions, and setSelectedProperties() API calls. Receives an empty array when the selection is cleared.

**Payload:** Array of currently selected property ID strings. Empty array when all properties are deselected.

#### `onMapViewChange`

Called when the map view changes (pan or zoom). Debounced at 300ms to prevent excessive events during continuous interactions like scroll-wheel zooming

**Payload:** Object with center coordinates (latitude, longitude), current zoom level, and bounding box (northeast and southwest corners)

#### `onActiveViewChange`

Called when the active view mode changes. View modes are 'map', 'split', 'grid', 'list', and 'compact'. The trigger indicates the source: 'user' for manual toggle, 'programmatic' for setActiveView() API calls, and 'auto' for automatic resets (e.g. zooming out resets to map view)

**Payload:** Object with previousView (the view before the change), activeView (the new view), and trigger ('user', 'programmatic', or 'auto')

### Consolidated Event Example

Here is a practical example showing several events wired up together:

```javascript
const digimap = new DigiMap({
  container: '#map',
  accessToken: token,

  onReady: (api) => {
    console.log('DigiMap is ready');
  },

  onSearch: (query) => {
    console.log('User searched for:', query);
  },

  onSearchComplete: (result) => {
    console.log(`Found ${result.totalProperties} properties in ${result.totalFetchTimeMs}ms`);
    if (result.truncated) {
      console.warn('Results were capped — try narrowing your filters');
    }
  },

  onSearchEmpty: ({ location, searchType }) => {
    console.log(`No properties found in ${location} (${searchType})`);
  },

  onDrawerOpen: ({ drawerType }) => {
    if (drawerType === 'property') {
      // Collapse your sidebar when the property drawer opens
      mySidebar.collapse();
    }
  },

  onActiveViewChange: ({ previousView, activeView, trigger }) => {
    console.log(`View changed from ${previousView} to ${activeView} (${trigger})`);
  },

  onError: (error) => {
    if (error.type === 'error') {
      showToast('Something went wrong: ' + error.message);
    }
  }
});
```

> **Tip:** `onSearch` fires immediately when the user submits a query (with the raw text). `onSearchStart` fires when the actual API request begins (with structured metadata like searchId, searchType, and filters). `onSearchComplete` fires when results are fully loaded. `onSearchEmpty` fires when a search completes with no properties found. Use `onSearch` for lightweight logging; use `onSearchStart`/`onSearchComplete`/`onSearchEmpty` for detailed lifecycle tracking.

---

## Responsive Sizing

The widget fills whatever container you give it. Here are common patterns for responsive layouts:

### Full Viewport Height

```html
<div id="map" style="width: 100%; height: 100vh;"></div>
```

### Flexbox Layout (header + map)

```html
<div style="display: flex; flex-direction: column; height: 100vh;">
  <header style="height: 64px;">My App Header</header>
  <div id="map" style="flex: 1;"></div>
</div>
```

### CSS Grid with Sidebar

```html
<div style="display: grid; grid-template-columns: 280px 1fr; height: 100vh;">
  <aside>Sidebar</aside>
  <div id="map"></div>
</div>
```

### Responsive with min-height

```html
<div id="map" style="width: 100%; height: 70vh; min-height: 400px;"></div>
```

> **Note:** Avoid fixed pixel heights on mobile. Use viewport units (`vh`), `flex: 1`, or `min-height` to ensure the map adapts to different screen sizes.

---

## Error Handling

All errors and warnings are delivered through the `onError` callback.

| Type | Code | Description |
|---|---|---|
| error | `AUTH_FAILED` | The access token is invalid, expired, or missing. The widget will attempt automatic refresh before firing this error. |
| error | `NETWORK_ERROR` | A network request failed due to connectivity issues, DNS resolution failure, or server unavailability. |
| error | `MAP_INIT_ERROR` | The map failed to initialize, typically due to missing container element, invalid Mapbox token, or WebGL not being supported by the browser. |
| error | `API_ERROR` | The DigiMap API returned an unexpected error response (4xx or 5xx status). |
| warning | `INVALID_CONFIG` | A configuration option has an invalid value. The widget falls back to the default and continues operating. |
| warning | `INVALID_METHOD_ARGS` | An API method was called with invalid arguments. The call is ignored and the widget continues. |
| warning | `SEARCH_EMPTY` | A search returned no results for the given query or location. |
| warning | `REFRESH_FALLBACK` | Token refresh encountered an issue but the widget recovered using a fallback mechanism. |

### Error Examples

#### AUTH_FAILED

The access token is invalid, expired, or missing. The widget will attempt automatic refresh before firing this error.

```javascript
{ type: 'error', message: 'Authentication failed: token expired', details: { code: 'AUTH_FAILED' } }
```

#### NETWORK_ERROR

A network request failed due to connectivity issues, DNS resolution failure, or server unavailability.

```javascript
{ type: 'error', message: 'Network request failed: unable to reach API', details: { code: 'NETWORK_ERROR', url: '/api/...' } }
```

#### MAP_INIT_ERROR

The map failed to initialize, typically due to missing container element, invalid Mapbox token, or WebGL not being supported by the browser.

```javascript
{ type: 'error', message: 'Map initialization failed: container not found', details: { code: 'MAP_INIT_ERROR' } }
```

#### API_ERROR

The DigiMap API returned an unexpected error response (4xx or 5xx status).

```javascript
{ type: 'error', message: 'API returned 500: Internal Server Error', details: { code: 'API_ERROR', status: 500 } }
```

#### INVALID_CONFIG

A configuration option has an invalid value. The widget falls back to the default and continues operating.

```javascript
{ type: 'warning', message: 'Invalid defaultView coordinates, falling back to US view', details: { code: 'INVALID_CONFIG', field: 'defaultView' } }
```

#### INVALID_METHOD_ARGS

An API method was called with invalid arguments. The call is ignored and the widget continues.

```javascript
{ type: 'warning', message: 'setCenter: latitude must be between -90 and 90', details: { code: 'INVALID_METHOD_ARGS', method: 'setCenter' } }
```

#### SEARCH_EMPTY

A search returned no results for the given query or location.

```javascript
{ type: 'warning', message: 'No properties found for this location', details: { code: 'SEARCH_EMPTY', query: '...' } }
```

#### REFRESH_FALLBACK

Token refresh encountered an issue but the widget recovered using a fallback mechanism.

```javascript
{ type: 'warning', message: 'Token refresh delayed, retrying...', details: { code: 'REFRESH_FALLBACK' } }
```

---

## Parcel Data Schema

The `Parcel` object returned by events and API methods contains the following fields:

### Identification

| Field | Type | Description |
|---|---|---|
| `PropertyId` | `string` | Unique parcel identifier |
| `apn` | `string` | Assessor Parcel Number |
| `fips` | `string` | FIPS county code |

### Location

| Field | Type | Description |
|---|---|---|
| `address` | `string` | Full street address |
| `city` | `string` | City name |
| `state` | `string` | State abbreviation (e.g., FL) |
| `zip` | `string` | ZIP code |
| `county` | `string` | County name |
| `latitude` | `number` | Latitude coordinate of parcel centroid |
| `longitude` | `number` | Longitude coordinate of parcel centroid |

### Ownership

| Field | Type | Description |
|---|---|---|
| `ownerName` | `string` | Current property owner name |
| `ownerAddress` | `string` | Owner's mailing address |
| `ownerType` | `string` | Type of ownership (e.g., Individual, Corporate, Trust) |

### Valuation

| Field | Type | Description |
|---|---|---|
| `marketValue` | `number` | Total market value (assessed) |
| `landValue` | `number` | Assessed land value |
| `improvementValue` | `number` | Assessed improvement/building value |
| `assessedValue` | `number` | Total assessed value for tax purposes |
| `taxAmount` | `number` | Annual property tax amount |

### Property Details

| Field | Type | Description |
|---|---|---|
| `propertyType` | `string` | Property classification (e.g., Residential, Commercial, Vacant Land) |
| `landUse` | `string` | Land use code or description |
| `yearBuilt` | `number` | Year the primary structure was built |
| `bedrooms` | `number` | Number of bedrooms |
| `bathrooms` | `number` | Number of bathrooms |
| `livingArea` | `number` | Living area in square feet |
| `lotSize` | `number` | Lot size in square feet |
| `stories` | `number` | Number of stories |
| `pool` | `boolean` | Whether the property has a pool |

### Sale History

| Field | Type | Description |
|---|---|---|
| `lastSaleDate` | `string` | Date of most recent sale (ISO format) |
| `lastSalePrice` | `number` | Price of most recent sale |

### MLS Data (when available)

> **Note:** MLS fields are only present for properties with active, pending, or recently sold MLS listings. For off-market properties (no MLS activity), these fields will be null or omitted entirely. Always check for null before accessing MLS fields.

| Field | Type | Description |
|---|---|---|
| `mlsStatus` | `string | null` | Current MLS listing status (Active, Pending, Sold, etc.). Null for off-market properties |
| `listPrice` | `number | null` | Current or last list price. Null for off-market properties |
| `listDate` | `string | null` | Date the property was listed. Null for off-market properties |
| `daysOnMarket` | `number | null` | Number of days on the market. Null for off-market properties |
| `mlsNumber` | `string | null` | MLS listing number. Null for off-market properties |

---

## Theming

DigiMap supports three theme modes: light, dark, and auto. The widget manages its own theme independently from the host page, so you can embed a light widget on a dark page (or vice versa) without any conflicts.

### Theme Modes

#### light ('light')

The default mode. The widget always renders with light colors, regardless of the host page's theme. Use this when your website has a fixed light design, or when you want the map to always appear in light mode.

```javascript
const digimap = new DigiMap({
  container: '#map',
  accessToken: token,
  refreshToken: token,
  theme: 'light'  // Always light, even if your page is dark
});
```

#### dark ('dark')

The widget always renders with dark colors, regardless of the host page's theme. Use this when your website has a fixed dark design, or when you prefer the map to always appear in dark mode.

```javascript
const digimap = new DigiMap({
  container: '#map',
  accessToken: token,
  refreshToken: token,
  theme: 'dark'  // Always dark, even if your page is light
});
```

#### auto ('auto')

The widget automatically matches the host page's theme and updates in real time if the page theme changes. This is the recommended mode for websites that have their own dark/light toggle.

```javascript
const digimap = new DigiMap({
  container: '#map',
  accessToken: token,
  refreshToken: token,
  theme: 'auto'  // Follows the host page's theme automatically
});
```

### How Auto Mode Works

When theme is set to 'auto', the widget detects the host page's current theme using two methods, checked in this order:

1. **HTML dark class** — The widget watches the <html> element for a 'dark' CSS class. This is the most common pattern used by frameworks like Tailwind CSS, Next.js, Nuxt, and most theme toggle libraries. When your page adds or removes the 'dark' class on <html>, the widget detects the change instantly and switches its own theme to match.
2. **prefers-color-scheme** — If no 'dark' class is found on <html>, the widget falls back to the browser's prefers-color-scheme media query. This reflects the user's operating system or browser theme preference (e.g., macOS Dark Mode, Windows dark theme). The widget also listens for changes to this setting.

The widget listens for changes to both signals in real time. If your page toggles between light and dark mode, the widget updates automatically without any additional code.

> **Programmatic Theme Switching:** You can change the theme at runtime using the `setTheme(theme)` API method. See the API Methods section for details.

---

## Color System

DigiMap uses an 8-token color system. Override only the tokens you need — all others keep their defaults.

| Token | CSS Variable | Config Key | Light Default | Dark Default | Description |
|---|---|---|---|---|---|
| Primary | `--dm-primary` | `primary` | `#3b82f6` | `#0099ff` | Main brand color used for interactive elements and visual emphasis |
| Accent | `--dm-accent` | `accent` | `#34d399` | `#2cc78a` | Secondary brand color used for highlights and visual variety |
| Destructive | `--dm-destructive` | `destructive` | `#e53e3e` | `#cc3333` | Color for error states and dangerous actions |
| Surface | `--dm-surface` | `surface` | `#f7f7f7` | `#272d36` | Background color for elevated elements like cards, panels, and popovers. The bg-card-sub utility applies a subtle depth shift (slightly darker in light mode, lighter in dark mode) for sub-headers and secondary panels. |
| Surface Text | `--dm-surface-text` | `surfaceText` | `#6b7280` | `#a6a6a6` | Muted text color used on surfaces for secondary/supporting content |
| Border | `--dm-border` | `border` | `#dedede` | `#2b3340` | Color for all borders, dividers, and separators |
| Background | `--dm-background` | `background` | `#ffffff` | `#171b21` | Root page-level background color |
| Foreground | `--dm-foreground` | `foreground` | `#3f4554` | `#f2f2f2` | Primary text and content color |

### Affected Elements by Token

#### Primary (`primary`)

- Primary buttons (background and border)
- Links and clickable text
- Focus rings around inputs
- Sidebar active highlights
- Update banner icon gradient (start)
- About dialog header gradient (start)
- About dialog close button
- Selected/active tab indicators
- Loading spinners and progress bars

#### Accent (`accent`)

- Accent badges and tags
- Secondary highlights
- Update banner icon gradient (end)
- About dialog header gradient (end)
- Sidebar accent hover state
- Toggle/switch active state

#### Destructive (`destructive`)

- Error messages and alerts
- Delete/remove action buttons
- Form validation error indicators
- Destructive confirmation dialogs

#### Surface (`surface`)

- Card backgrounds
- Popover/dropdown backgrounds
- Sidebar background
- Input field backgrounds
- Table alternating row backgrounds
- About dialog table stripe rows
- Filter drawer panel
- Secondary/muted backgrounds
- Sub-header bar (bg-card-sub — subtle depth shift)
- Property list panel background (bg-card-sub)

#### Surface Text (`surfaceText`)

- Secondary/muted text
- Placeholder text in inputs
- Table label/header text
- Timestamps and metadata
- Update banner subtitle text
- About dialog table labels
- Close button icon strokes
- Helper text below form fields

#### Border (`border`)

- Card borders
- Input field borders
- Table divider lines
- Sidebar borders
- Popover borders
- About dialog footer divider
- Update banner edge highlights
- Separator lines between sections

#### Background (`background`)

- Page/widget root background
- Update banner card background (translucent)
- About dialog popup background
- Modal/overlay content backgrounds

#### Foreground (`foreground`)

- Headings and titles
- Body text and paragraphs
- Table value/data text
- Update banner title text
- About dialog table values
- Icon fills where text-colored
- Navigation labels

### Color Configuration Examples

**Minimal override:**

```javascript
// Override only the tokens you need — all others keep defaults
const digimap = new DigiMap({
  container: "#map",
  accessToken: token,
  refreshToken: token,
  colors: {
    light: {
      primary: "#6366f1",  // Only primary changes
      accent: "#f59e0b"    // Only accent changes
    }
    // Dark mode keeps its built-in defaults
  }
});
```

**Full custom:**

```javascript
// Full control over every token in both modes
const digimap = new DigiMap({
  container: "#map",
  accessToken: token,
  refreshToken: token,
  colors: {
    light: {
      primary: "#6366f1",
      accent: "#f59e0b",
      destructive: "#dc2626",
      surface: "#f8f7ff",
      surfaceText: "#71717a",
      border: "#e4e4e7",
      background: "#ffffff",
      foreground: "#18181b"
    },
    dark: {
      primary: "#818cf8",
      accent: "#fbbf24",
      destructive: "#ef4444",
      surface: "#27272a",
      surfaceText: "#a1a1aa",
      border: "#3f3f46",
      background: "#09090b",
      foreground: "#fafafa"
    }
  }
});
```

**Light mode only:**

```javascript
// Light overrides only — dark mode keeps built-in defaults
const digimap = new DigiMap({
  container: "#map",
  accessToken: token,
  refreshToken: token,
  colors: {
    light: {
      primary: "#059669",
      accent: "#0ea5e9"
    }
  }
});
```

---

## Versioning & Backward Compatibility

DigiMap follows semantic versioning (major.minor.patch). The script URL includes the full version number to give you control over when you upgrade.

- **Patch versions (1.0.x):** Bug fixes and minor improvements. Fully backward compatible. No code changes needed on your end.
- **Minor versions (1.x.0):** New features and enhancements. Backward compatible — existing code continues to work. New features are opt-in.
- **Major versions (x.0.0):** Breaking changes. A migration guide will be provided. Previous major versions continue to work for a deprecation period (minimum 12 months).

```html
<!-- Pin to a specific version -->
<script src="https://embed.digimap.app/embed/digimap.1.0.0.iife.js"></script>

<!-- When upgrading, change the version in the URL -->
<script src="https://embed.digimap.app/embed/digimap.1.1.0.iife.js"></script>
```

Previous versions remain available at their original URLs. Your integration will not break when a new version is released — you upgrade by changing the version in the script URL when you're ready.

---

## TypeScript Support

A standalone `.d.ts` type definition file is available for TypeScript users. Download it from:

```
https://embed.digimap.app/embed/digimap.d.ts
```

Add it to your project with a triple-slash directive:

```typescript
/// <reference path="./digimap.d.ts" />

const digimap = new DigiMap({
  container: '#map',
  accessToken: token,
  onParcelSelect: (parcel) => {
    console.log(parcel.address); // fully typed
  }
});
```
