# @phila/phila-ui-map-core

<!-- status-badge-start -->

### Component Status

| Component              | Status                                                             |
| ---------------------- | ------------------------------------------------------------------ |
| BasemapDropdown        | ![Stable](https://img.shields.io/badge/-Stable-brightgreen)        |
| BasemapToggle          | ![Stable](https://img.shields.io/badge/-Stable-brightgreen)        |
| CircleLayer            | ![Stable](https://img.shields.io/badge/-Stable-brightgreen)        |
| DrawTool               | ![Stable](https://img.shields.io/badge/-Stable-brightgreen)        |
| FillLayer              | ![Stable](https://img.shields.io/badge/-Stable-brightgreen)        |
| GeolocationButton      | ![Stable](https://img.shields.io/badge/-Stable-brightgreen)        |
| LineLayer              | ![Stable](https://img.shields.io/badge/-Stable-brightgreen)        |
| Map                    | ![Stable](https://img.shields.io/badge/-Stable-brightgreen)        |
| MapButton              | ![Stable](https://img.shields.io/badge/-Stable-brightgreen)        |
| MapCheckboxLegend      | ![Stable](https://img.shields.io/badge/-Stable-brightgreen)        |
| MapFloatingPanel       | ![Stable](https://img.shields.io/badge/-Stable-brightgreen)        |
| MapIconTextPin         | ![Stable](https://img.shields.io/badge/-Stable-brightgreen)        |
| MapMarker              | ![Stable](https://img.shields.io/badge/-Stable-brightgreen)        |
| MapNavigationControl   | ![Stable](https://img.shields.io/badge/-Stable-brightgreen)        |
| MapPopup               | ![Stable](https://img.shields.io/badge/-Stable-brightgreen)        |
| MapSearchControl       | ![Stable](https://img.shields.io/badge/-Stable-brightgreen)        |
| MapTooltip             | ![In Progress](https://img.shields.io/badge/-In%20Progress-yellow) |
| Pictometry             | ![Stable](https://img.shields.io/badge/-Stable-brightgreen)        |
| PictometryLayerControl | ![Stable](https://img.shields.io/badge/-Stable-brightgreen)        |
| RasterLayer            | ![Stable](https://img.shields.io/badge/-Stable-brightgreen)        |
| SymbolLayer            | ![Stable](https://img.shields.io/badge/-Stable-brightgreen)        |

<!-- status-badge-end -->

Vue 3 components for building map applications in Philadelphia city government apps. Built on MapLibre GL JS. Includes the core Map component, layer and marker components, map controls (navigation, drawing, search), and utility functions for working with Philadelphia data sources (AIS geocoding, parcel data).

## What's in the package

- **Components**: Map, layer components (FillLayer, LineLayer, CircleLayer, SymbolLayer, RasterLayer), MapMarker, and controls (MapNavigationControl, MapSearchControl, DrawTool, BasemapToggle, GeolocationButton, etc.)

> **Importing the Map component:** The export name `Map` conflicts with JavaScript's built-in `Map` constructor. Alias it when importing:
>
> ```ts
> import { Map as PhilaMap } from "@phila/phila-ui-map-core";
> ```

> **CSS:** The package bundles MapLibre GL CSS so you only need a single import:
>
> ```ts
> import "@phila/phila-ui-map-core/dist/assets/phila-ui-map-core.css";
> ```

- **Component Types**: Props and emit interfaces for each component, located in `types.ts` files within each component folder
- **Composables**:
  - `useMapControl` — for building custom controls that need map access
  - `useAisAddressSuggestions` — AIS address autocomplete; returns a reactive `searchSuggestions` array suitable for `<Search :suggestions>` from `@phila/phila-ui-search`. Use it standalone for non-map search inputs, or rely on `<MapSearchControl :autocomplete="true">` which wires it for you.

  Example (non-map use):

  ```ts
  import { useAisAddressSuggestions } from "@phila/phila-ui-map-core";
  import { Search } from "@phila/phila-ui-search";
  import { ref } from "vue";

  const query = ref("");
  const { searchSuggestions, dismissSuggestions } = useAisAddressSuggestions(query);

  function onSelect(suggestion: string) {
    dismissSuggestions();
    // ...your search/route logic here
  }
  ```

  ```vue
  <Search v-model="query" :suggestions="searchSuggestions" @select="onSelect" />
  ```

- **Utils**: Plain async functions and their TypeScript interfaces for Philadelphia data (`geocodeAddress`, `queryParcelAtPoint`, `fetchParcelGeometry`, etc.)
- **Defaults**: basemap and imagery configuration objects

## Running the examples

Example apps are in `src/examples/`. To run them:

```bash
pnpm dev
```

Then open:

- `http://localhost:3000/` - Example 1: Just a map, no controls
- `http://localhost:3000/example2.html` - Example 2: Map with address search
- `http://localhost:3000/example3.html` - Example 3: Parcel clicking, geocoding, basemap switching, and controls

Vue DevTools is available at `http://localhost:3000/__devtools__/` in a separate window. The DevTools will reflect whichever example you currently have open in another tab. Running the examples (instead of viewing Storybook) lets you inspect all components and their state in Vue DevTools.

## Architecture Decisions

### No shared state

These components do not manage shared state. If your app needs shared state (e.g., a selected address used by multiple components), use your own state management like Pinia. Components communicate via events, and your app decides what to do with the results.

### Components emit events

Components like `MapSearchControl` emit events (`@result`, `@error`) rather than writing to global state. The parent component handles the results however it needs to.

### Utils vs Composables

Reusable functions are exported as either utils or composables:

- **Utils** (`utils/`): Plain async functions with no Vue dependencies. Examples: `geocodeAddress()`, `queryParcelAtPoint()`, `fetchParcelGeometry()`
- **Composables** (`composables/`): Functions that use Vue reactivity (refs, inject, provide, lifecycle hooks). Example: `useMapControl()`

If a function just fetches data and returns it, it's a util. If it needs Vue's reactivity system, it's a composable.

### Data types live with their utility functions

Each util file exports both functions and their associated TypeScript interfaces:

**utils/ais.ts**

- Functions: `geocodeAddress()`
- Interfaces: `AisGeocodeResult`, `AisResponse`, `AisAutocompleteResult`

**utils/parcels.ts**

- Functions: `queryParcelAtPoint()`, `fetchParcelGeometry()`
- Interfaces: `ParcelQueryResult`, `ParcelFeatureCollection`

This keeps related code together - if you need to fetch parcel data, import from `utils/parcels` and you get both the functions and the type definitions.

### Configurable defaults

Basemap and imagery sources have sensible defaults for Philadelphia apps, but you can use any tile source. The `defaults/imagery.ts` exports let you customize or extend the built-in options.

### Progressive complexity

Simple maps can be created using just props:

```vue
<Map
  :center="[-75.16, 39.95]"
  :zoom="17"
  basemap-url="https://tiles.arcgis.com/..."
  labels-url="https://tiles.arcgis.com/..."
/>
```

For more complex apps with dynamic layers, custom controls, or multiple data layers, use slots:

```vue
<Map :center="[-75.16, 39.95]" :zoom="17" :no-default-tile-layers="true">
  <RasterLayer id="basemap-labels" :source="labelsSource" before-id="basemap-ceiling" />
  <RasterLayer id="basemap" :source="basemapSource" before-id="basemap-labels" />
  <FillLayer id="parcels" :source="parcelSource" :paint="fillPaint" />
  <MapSearchControl position="top-left" @result="handleResult" />
  <MapNavigationControl position="bottom-left" />
</Map>
```

Most apps only need the props approach.

### Pictometry (Eagleview oblique imagery)

The Map component includes optional Pictometry/Eagleview integration for viewing oblique aerial imagery. Enable it with props on the Map component:

```vue
<Map :enable-pictometry="true" :pictometry-credentials="credentials" pictometry-button-position="top-right">
  <!-- other layers and controls -->
</Map>
```

| Map Prop                   | Type                    | Default       | Description                         |
| -------------------------- | ----------------------- | ------------- | ----------------------------------- |
| `enablePictometry`         | `boolean`               | `false`       | Show the Pictometry toggle button   |
| `pictometryCredentials`    | `PictometryCredentials` | —             | OAuth2 `{ clientId, clientSecret }` |
| `pictometryButtonPosition` | `MapControlPosition`    | `"top-right"` | Button placement on the map         |

When the user clicks the Pictometry button, the Map component handles everything: loading the Eagleview SDK from CDN, authenticating, and mounting the viewer in a panel. The viewer opens at the current map center.

#### Events

**PictometryPanel** emits these events (forwarded through Map):

| Event         | Payload                             | Description                                |
| ------------- | ----------------------------------- | ------------------------------------------ |
| `ready`       | —                                   | Viewer initialized successfully            |
| `view-change` | `PictometryView`                    | User panned, zoomed, or rotated the viewer |
| `error`       | `{ message: string; type: string }` | Initialization or runtime error            |

`PictometryView` contains `{ lonLat: { lon, lat }, zoom?, pitch?, rotation? }`.

The Map component converts `view-change` events to its own `move` event, translating `lon/lat` to `lng/lat` for consistency with MapLibre conventions.

#### Layer controls

The viewer panel includes built-in controls for toggling overlay layers:

- **Parcels** — US Parcels layer (property boundaries)
- **Street Labels** — Street name labels overlaid on imagery

These appear at the bottom-left of the Pictometry panel when the viewer is initialized. Initial visibility is controlled via PictometryPanel props (`showParcels`, `showLabels`).

For custom layer toggling, use the `usePictometry` composable directly:

```ts
const { setLayerVisibility, setParcelsVisible, setLabelsVisible } = usePictometry();

// Toggle any layer by name
setLayerVisibility("US Parcels", true);

// Convenience methods
setParcelsVisible(true);
setLabelsVisible(false);
```

To build a custom layer control, use `PictometryLayerControl`:

```vue
<PictometryLayerControl
  layer-name="US Parcels"
  label="Show Parcels"
  :initial-state="false"
  @toggle="({ layerName, visible }) => setLayerVisibility(layerName, visible)"
/>
```

#### Viewer lifecycle

The Pictometry panel uses a **v-show pattern** to preserve viewer state across open/close cycles. On first open, the component mounts and initializes the Eagleview SDK. On close, the panel is hidden but the viewer stays mounted. On reopen, the panel is revealed without reinitializing — the user's navigation (pan, zoom, rotation) is preserved.

The viewer location is only set on first open (using the map center at click time). Subsequent reopens preserve the user's last position in the viewer.

#### SDK loading

The Eagleview Embedded Explorer SDK is loaded dynamically from CDN. It creates `window.ev.EmbeddedExplorer` when ready. The loader handles race conditions and waits for the SDK to fully initialize (not just script onload). Components that use Pictometry are lazy-loaded via `defineAsyncComponent`, so the SDK is never fetched unless `enablePictometry` is true.

### Layer ordering with basemapBeforeId

When building apps with many feature layers, you may want to ensure basemap and imagery layers always stay below your feature layers, even when the basemap source changes (e.g., switching between street map and imagery). The `basemapBeforeId` prop controls this.

**The problem:** When a basemap layer's source changes, MapLibre removes and re-adds the layer. Without a `before-id`, the layer gets added to the top of the layer stack, covering your feature layers.

**The solution:** Pass the ID of one of your feature layers to `basemapBeforeId`. The basemap will always be inserted before that layer:

```vue
<Map basemap-before-id="parcels">
  <FillLayer id="parcels" :source="parcelSource" :paint="fillPaint" />
  <LineLayer id="roads" :source="roadSource" :paint="linePaint" />
</Map>
```

This keeps basemaps below all your feature layers, even when switching between street map and imagery.

**For apps with all-dynamic layers:** If all your feature layers can be toggled on/off, the Map component's built-in `basemap-ceiling` layer handles this automatically. The basemap ceiling is an invisible layer that always exists in the layer stack, ensuring basemaps stay below feature layers even when all feature layers are toggled off. No `basemapBeforeId` is needed unless you want to override the default behavior.

### Typed layer components

There are separate components for each MapLibre layer type: `FillLayer`, `LineLayer`, `CircleLayer`, `SymbolLayer`, and `RasterLayer`. Each component has strongly-typed props matching the MapLibre specification for that layer type (e.g., `FillLayerProps` only accepts fill-specific paint properties). This provides better TypeScript support and clearer APIs than a single generic component.

### Map instance injection

Child components that need access to the MapLibre map instance use Vue's provide/inject. The Map component provides the instance, and child components inject it. Examples:

- `useMapControl()` - a composable that injects the map instance for building custom controls
- Layer components (`FillLayer`, `RasterLayer`, etc.) - inject the map to call `map.addLayer()`, `map.addSource()`, etc.

Importantly, all components that use the injected map instance are designed to keep their state visible in Vue DevTools. For example, the DrawTool adds layers to the map for drawing states, but it does this by rendering layer components (which appear in the Vue component tree) rather than calling `map.addLayer()` directly (which would be invisible to Vue DevTools).

## Common Patterns

### Icon-based SymbolLayer (simple mode)

When displaying a single icon type, pass the `icon` and `iconName` props. The component handles `loadImage`, `addImage`, and cleanup automatically.

```vue
<SymbolLayer
  id="address-marker"
  :source="{
    type: 'geojson',
    data: {
      type: 'FeatureCollection',
      features: [
        {
          type: 'Feature',
          geometry: { type: 'Point', coordinates: [-75.1636, 39.9523] },
          properties: { name: 'City Hall' },
        },
      ],
    },
  }"
  icon="/images/marker_blue.png"
  icon-name="marker-blue"
  :layout="{
    'icon-anchor': 'bottom',
    'icon-size': 0.05,
    'icon-allow-overlap': true,
  }"
/>
```

When the `icon` prop is provided, SymbolLayer loads the image, registers it under `iconName`, injects `icon-image` into layout, and cleans up on unmount.

### Icon-based SymbolLayer (data-driven)

When you need different icons based on feature properties, register images yourself and pass a match expression in layout. Do not use the `icon` prop in this case.

```vue
<script setup>
import { inject, watch } from "vue";

const map = inject("map");
const isLoaded = inject("isLoaded");

watch(isLoaded, async loaded => {
  if (!loaded) return;
  const img1 = await map.value.loadImage("/images/red-school.png");
  map.value.addImage("red-school", img1.data);
  const img2 = await map.value.loadImage("/images/blue-school.png");
  map.value.addImage("blue-school", img2.data);
});
</script>

<template>
  <SymbolLayer
    id="school-markers"
    :source="schoolsSource"
    :layout="{
      'icon-image': [
        'match',
        ['get', 'GRADE_LEVEL'],
        'ELEMENTARY SCHOOL',
        'red-school',
        'HIGH SCHOOL',
        'blue-school',
        'red-school',
      ],
      'icon-size': 0.05,
    }"
  />
</template>
```

### Polygon with styled outline

Pass the same source object to both a FillLayer and a LineLayer. Each creates its own MapLibre source (named after its `id`), so there is no conflict. This is preferred over sharing a source by string ID because each component has an independent lifecycle with no unmount ordering issues.

```vue
<script setup>
const districtSource = {
  type: "geojson",
  data: "https://services.arcgis.com/.../query?where=1=1&outFields=*&f=geojson",
};
</script>

<template>
  <FillLayer id="districts-fill" :source="districtSource" :paint="{ 'fill-color': '#1976D2', 'fill-opacity': 0.2 }" />
  <LineLayer id="districts-outline" :source="districtSource" :paint="{ 'line-color': '#1976D2', 'line-width': 3 }" />
</template>
```

### Reactive data updates

When the source prop changes, the component detects the update. For GeoJSON sources, it uses `setData()` for efficiency rather than removing and re-adding the layer.

```vue
<script setup>
import { ref } from "vue";

const source = ref({
  type: "geojson",
  data: { type: "FeatureCollection", features: [] },
});

async function loadData() {
  const response = await fetch("https://api.example.com/points");
  const geojson = await response.json();
  source.value = { type: "geojson", data: geojson };
}
</script>

<template>
  <CircleLayer id="points" :source="source" :paint="{ 'circle-radius': 6, 'circle-color': '#1976D2' }" />
</template>
```

### WMS raster layers

WMS endpoints work through RasterLayer by using the `{bbox-epsg-3857}` placeholder in the tile URL. MapLibre substitutes the current tile's bounding box. Use `tileSize: 1024` for better image quality from WMS.

```vue
<RasterLayer
  id="zoning"
  :source="{
    type: 'raster',
    tiles: [
      'https://citygeo-geoserver.databridge.phila.gov/geoserver/wms?service=WMS&version=1.1.1&request=GetMap&layers=zoning_basedistricts&styles=&bbox={bbox-epsg-3857}&width=1024&height=1024&srs=EPSG:3857&format=image/png&transparent=true',
    ],
    tileSize: 1024,
  }"
/>
```
