# `hb-map`

**Category:** maps · **Tags:** maps · **Package:** `@htmlbricks/hb-map`

## Description

`hb-map` is an interactive map web component built with OpenLayers. You configure **center** and **zoom**, a **source** (OpenStreetMap raster, custom XYZ tiles, or CARTO vector basemaps), **options** (fit view to markers, label layout), and **data** for on-map markers (longitude/latitude, optional icon, HTML popup, and optional text label).

The component listens for **single clicks**: clicks on empty map emit coordinates and current view; clicks on markers open optional popup HTML and emit a marker event. Setting **`screenshot`** to `yes` captures the visible map to PNG and emits a **base64** payload when rendering completes.

## Basemap sources (`source`)

Set `source` as a JSON string (see [Attributes](#attributes-snake_case-string-values-in-html)).

| `source.type` | Behavior |
|---------------|----------|
| `osm` | OpenStreetMap raster tiles. |
| `xyz` | Raster tiles from `source.url` (XYZ template with `{z}`, `{x}`, `{y}`). |
| `carto_vector` | CARTO vector tiles (default tile URL if `url` is omitted). Styling uses built-in presets. |

### CARTO vector: `source.style`

For `carto_vector`, `source.style` may be `positron`, `dark_matter`, or `voyager` when the page uses a **light** color scheme.

When the component detects a **dark** scheme, the vector basemap **always** uses the **dark_matter** palette, regardless of `source.style`. Detection order:

1. `hb-map`, then `document.documentElement`, then `document.body`: `data-theme="dark"` or class `theme-dark` → dark; `data-theme="light"` or `theme-light` → light.
2. If neither is set explicitly, **`prefers-color-scheme: dark`** selects dark.

The host and document are observed for `data-theme` and `class` changes, and the media query is listened to, so the basemap updates when the theme toggles.

### CARTO vector: boundaries and labels

- **`boundaries_only`**: When `true`, only the `boundary` layer is drawn as lines (plus **country names** from the `place` layer where `class === "country"`). Land, roads, water fills, and other layers are not drawn.
- **`national_boundaries_only`**: When `true`, only international / country boundaries (OSM `admin_level` **2**) are shown as **solid** lines. When `false`, level 2 stays solid and other allowed levels use a **dashed** stroke.
- **`boundary_admin_levels`**: Optional JSON **array string** of numeric admin levels to keep, e.g. `"[2]"` for countries only, `"[2,4]"` for countries and other divisions present in the tiles. Empty or omitted means no extra filter (all boundary features at the current zoom still pass the national-only rule when that flag applies). With `national_boundaries_only`, the effective set is the **intersection** with level 2; if the list excludes `2`, no boundaries render.

Boolean fields in serialized `source` may use **`yes` / `no`** (or `true` / `false` / `1` / `0` for compatibility).

### OSM in dark mode

For `osm`, raster tiles are rendered with a **grayscale + invert** filter when the same dark detection applies, so the map stays readable on dark UIs.

## Marker data (`data`)

`data` is a JSON array of objects. Each object may include a **`marker`** field. The current implementation **renders only `marker` entries** (icons, labels, popups, and click handling). The TypeScript `Component` type also describes optional **`point`** and **`line`** shapes for consistency with the schema; those are **not** drawn by this version of the component.

### Marker object (`marker`)

| Field | Description |
|-------|-------------|
| `lngLat` | `[longitude, latitude]` in EPSG:4326 (same order as OpenLayers `fromLonLat`). |
| `icon` | Optional: `uri` (image URL), `scale`, `anchor` `[x, y]`, `opacity`, `color` (tint). |
| `id` | Optional string passed through on `markerClick`. |
| `popupHtml` | Optional HTML string shown in the map popup when the marker is clicked. |
| `text` | Optional label next to or around the icon (or alone if there is no `icon.uri`). |
| `text_position` | Per-marker override: `top`, `right`, `bottom`, `left` (default from `options.text_position` or `right`). |
| `text_offset` | Per-marker pixel gap for label placement; falls back to `options.text_offset`, then an internal default. |

If there is no custom icon and no `text`, a default pin icon is used.

## Options (`options`)

JSON string. Common fields:

| Field | Description |
|-------|-------------|
| `centerFromGeometries` | When truthy, the view is centered or **fitted** from marker positions (fit when there are multiple markers with valid extent). |
| `text_position` | Default label side relative to the icon: `top`, `right`, `bottom`, `left`. |
| `text_offset` | Default pixel gap between icon and label. |
| `text_scale` | Multiplier for label font size (default scale 1 → base ~13px). |

## Screenshots

Set the attribute **`screenshot="yes"`** to request a capture after the next render completes. The component dispatches **`screenshotTaken`** with `{ base64 }` (PNG data URL). Clear or change the attribute between captures if your host framework does not re-trigger on repeated `yes`.

## Styling (Bulma and host size)

The map canvas sits on a host whose background can follow Bulma’s scheme variable **`--bulma-scheme-main`**. Size is controlled by **`--hb-map-width`**, **`--hb-map-height`**, and fallback **`--hb-map-default-size`** (default `200px` when width/height are unset). See [Bulma CSS variables](https://bulma.io/documentation/features/css-variables/) and `extra/docs.ts` (`styleSetup.vars`).

| Variable | Purpose |
|----------|---------|
| `--bulma-scheme-main` | Background behind the map. |
| `--hb-map-default-size` | Fallback width and height when explicit size vars are empty. |
| `--hb-map-width` | Optional explicit host width. |
| `--hb-map-height` | Optional explicit host height. |

## CSS parts

None.

## HTML slots

None.

## Custom element

`hb-map`

## Attributes (snake_case; string values in HTML)

Web component attributes are strings. Pass objects and arrays as **JSON strings**. Use **`yes` / `no`** for boolean fields where the project encoding applies; nested `source` booleans also accept `true` / `false` as strings.

| Attribute | Required | Description |
|-----------|----------|-------------|
| `id` | No | Optional element id. |
| `style` | No | Optional host inline style (type-level; use CSS variables for layout when possible). |
| `zoom` | Yes* | Zoom level as a string (e.g. `"11"`). Parsed to a number internally. |
| `center` | Yes* | JSON array string: **`[longitude, latitude]`** in EPSG:4326. |
| `data` | Yes* | JSON array of geometry entries; **markers** use the `marker` property (see [Marker data](#marker-data-data)). |
| `source` | Yes* | JSON object: at minimum `{ "type": "osm" \| "xyz" \| "carto_vector", ... }` with optional `url`, `style`, `boundaries_only`, `boundary_admin_levels`, `national_boundaries_only` for vector tiles. |
| `options` | Yes* | JSON object for view and label defaults (see [Options](#options-options)). |
| `screenshot` | No | Set to `yes` to capture the map and emit `screenshotTaken`. |

\*The TypeScript `Component` type marks these as required for a complete configuration. The implementation still applies **defaults** when attributes are missing: `zoom` `6`, `center` **`[37.5176038, 15.0819224]`** (same `[longitude, latitude]` order as elsewhere, passed to `fromLonLat`), `source` `{ type: "osm" }`, `options` `{}`, `data` `[]`.

On window **resize**, the map layout is updated (debounced).

## Events

Listen with `addEventListener` or your framework’s binding. All `detail` payloads are plain objects.

| Event | `detail` |
|-------|----------|
| `pointClickCoordinates` | `{ coordinates: { latitude, longitude }, zoom, center }` — `center` is `[lon, lat]` in EPSG:4326 when derived from the view. Emitted when the click does not hit a marker. |
| `markerClick` | `{ coordinates: { latitude, longitude }, id? }` — `id` is present when the feature carries `marker.id` in `data`. |
| `screenshotTaken` | `{ base64 }` — PNG data URL string. |

## TypeScript (`types/webcomponent.type.d.ts`)

```typescript
export type Component = {
  id?: string;
  style?: string;
  zoom: number;
  center: number[];
  data: {
    marker?: {
      lngLat: number[];
      icon?: {
        uri: string;
        scale?: number;
        anchor?: number[];
        opacity?: number;
        color?: string;
      };
      id?: string;
      popupHtml?: string;
      text?: string;
      text_position?: "top" | "right" | "bottom" | "left";
      text_offset?: number;
    };
    point?: {
      lngLat: number[];
      icon?: {
        uri: string;
        scale?: number;
        anchor?: number[];
        opacity?: number;
        color?: string;
      };
      id?: string;
      popupHtml?: string;
    };
    line?: {
      lngLat: number[];
      icon?: {
        uri: string;
        scale?: number;
        anchor?: number[];
        opacity?: number;
        color?: string;
      };
      id?: string;
      popupHtml?: string;
    }[];
  }[];
  source: {
    type: string;
    url?: string;
    /**
     * Basemap palette for light UI. In dark color scheme (`prefers-color-scheme: dark` or
     * `data-theme="dark"` / `.theme-dark` on host or document), `dark_matter` is always used.
     */
    style?: "positron" | "dark_matter" | "voyager";
    /** When `true`, only the vector `boundary` layer is drawn (no land, roads, water fill, etc.). */
    boundaries_only?: boolean;
    /**
     * Optional JSON array string of OSM `admin_level` values to keep for `boundary` features,
     * e.g. `"[2]"` for country borders, `"[2,4]"` for countries plus common internal divisions.
     * Omit or empty to show all boundaries present in the tiles at the current zoom.
     */
    boundary_admin_levels?: string;
    /**
     * When `true`, only international / country boundaries (`admin_level` 2) are drawn; internal
     * regional lines are hidden. Combines with `boundary_admin_levels` by intersection (e.g. if
     * the JSON list excludes `2`, no boundaries render). National lines use a solid stroke;
     * when `false`, level 2 stays solid and other levels stay dashed.
     */
    national_boundaries_only?: boolean;
  };
  options: {
    centerFromGeometries?: boolean;
    text_position?: "top" | "right" | "bottom" | "left";
    text_offset?: number;
    text_scale?: number;
  };
  screenshot?: string;
};

export type Events = {
  pointClickCoordinates: {
    coordinates: { latitude: number; longitude: number };
    zoom: number;
    center: number[];
  };
  markerClick: {
    coordinates: { latitude: number; longitude: number };
    id?: string;
  };
  screenshotTaken: {
    base64: string;
  };
};
```

## Minimal HTML example

```html
<hb-map
  zoom="9"
  center="[10,10]"
  source='{"type":"osm"}'
  options="{}"
  data="[]"
></hb-map>
```

## Example: CARTO Voyager with markers

```html
<hb-map
  zoom="11"
  center="[2.3522,48.8566]"
  source='{"type":"carto_vector","style":"voyager"}'
  options='{"centerFromGeometries":true}'
  data='[{"marker":{"lngLat":[2.36,48.86],"text":"Paris"}}]'
></hb-map>
```

Inside JSON strings, use standard JSON booleans (`true` / `false`). Truthy string values (e.g. `"yes"`) for `centerFromGeometries` are also treated as enabled when parsed into a plain object.
