# PortugalMap

Vanilla JavaScript library for rendering an SVG vector map of Portugal by municipalities, using GeoJSON files.

The package is published as `@beespokeio/vector-map`.

It does not depend on Vue, React, or any other framework. It uses D3 only for geographic projection and SVG path generation.

The Vue wrapper is optional and lives in `vue/PortugalMapVue.js`. The core `js/PortugalMap.js` remains framework-agnostic.

## Files

```txt
js/PortugalMap.js
vue/PortugalMapVue.js
css/portugal-map.css
assets/maps/concelhos_pt_high.geojson
assets/maps/concelhos_pt_low.geojson
assets/maps/distritos_pt_high.geojson
assets/maps/distritos_pt_low.geojson
tests/example1.html
tests/example2.html
tests/example3.html
```

## Installation

In Vue/Vite apps, install the package from this folder or publish it to your registry.

```bash
cd map
npm install
npm run build
```

In a local Vue app, you can install it by path:

```bash
npm install /BeeSpoke/components/map
```

Then import it in the Vue app:

```js
import PortugalMapVue from "@beespokeio/vector-map";
import "@beespokeio/vector-map/style.css";
```

If you are consuming the local build directly before publishing:

```js
import PortugalMapVue from "/path/to/components/map/dist/portugal-map.js";
import "/path/to/components/map/dist/portugal-map.css";
```

The build outputs:

```txt
dist/portugal-map.js
dist/portugal-map.umd.cjs
dist/portugal-map.css
```

For the JavaScript core, you can also import:

```js
import { PortugalMap } from "@beespokeio/vector-map";
```

`d3-geo` is imported by the package and included in the bundle. You no longer need to load `d3-array` or `d3-geo` globally when using the build.

For direct browser usage without a build step, add an import map for `d3-geo`:

```html
<script type="importmap">
  {
    "imports": {
      "d3-geo": "https://cdn.jsdelivr.net/npm/d3-geo@3/+esm"
    }
  }
</script>
```

## Example

Minimal usage with the included files:

```html
<link rel="stylesheet" href="/css/portugal-map.css">

<div id="mapa-portugal"></div>

<script type="importmap">
  {
    "imports": {
      "d3-geo": "https://cdn.jsdelivr.net/npm/d3-geo@3/+esm"
    }
  }
</script>

<script type="module">
  import PortugalMap from "/js/PortugalMap.js";

  const map = new PortugalMap("#mapa-portugal", {});

  map.render();
</script>
```

Example with options:

```html
<link rel="stylesheet" href="/css/portugal-map.css">

<div id="mapa-portugal"></div>

<script type="importmap">
  {
    "imports": {
      "d3-geo": "https://cdn.jsdelivr.net/npm/d3-geo@3/+esm"
    }
  }
</script>

<script type="module">
  import PortugalMap from "/js/PortugalMap.js";

  const map = new PortugalMap("#mapa-portugal", {
    resolution: "high",
    idField: "DICO",
    labelField: "Concelho",
    selectedId: "1820",
    highlightedIds: ["0101", "1106"],
    showIslands: true,
    allowZoom: true,
    zoomLvl: 3,
    showTooltip: true,
    values: {
      "1820": {
        label: "TABUAÇO",
        value: 10,
        color: "#008fbd"
      }
    },
    onSelect(item) {
      console.log("Selected:", item);
    }
  });

  map.render();
</script>
```

## Constructor

```js
const map = new PortugalMap(container, options);
```

`container` can be a CSS selector or a DOM element:

```js
new PortugalMap("#mapa-portugal", options);
new PortugalMap(document.querySelector("#mapa-portugal"), options);
```

## Options

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `mapUrl` | `string \| null` | `null` | Manual URL for the GeoJSON file. When set, it takes priority over `resolution` and `mapUrls`. |
| `mapUrls` | `object` | see below | GeoJSON file URLs by resolution. |
| `resolution` | `"high" \| "low"` | `"high"` | GeoJSON resolution to load when `mapUrl` is not set. |
| `districtMode` | `boolean` | `false` | Switches the built-in defaults from municipality data to district/area data. It defaults to `DI` / `Distrito` and the bundled district GeoJSON files unless you override them explicitly. |
| `idField` | `string` | `"DICO"` | GeoJSON property name used as the municipality ID. |
| `labelField` | `string` | `"Concelho"` | GeoJSON property name used as the municipality label. |
| `selectedId` | `string \| null` | `null` | Initially selected municipality ID. |
| `highlightedIds` | `array` | `[]` | List of highlighted IDs. |
| `disabledIds` | `array` | `[]` | IDs that stay visible but cannot be selected or keyboard-activated. |
| `values` | `object` | `{}` | External data associated by municipality ID. It can define `label`, `labelHtml`, `value`, and `color`. |
| `legends` | `array` | `[]` | Legend entries rendered inside the map. Each item accepts `{ color, label }`. |
| `legendTitle` | `string` | `""` | Optional title shown above the legend items. |
| `legendPosition` | `"bottom-left" \| "bottom-right" \| "top-left" \| "top-right"` | `"bottom-left"` | Legend corner position inside the map. |
| `showTooltip` | `boolean` | `false` | Shows a tooltip with the municipality name on hover. |
| `tooltipClassName` | `string` | `"portugal-map__tooltip"` | CSS class applied to the tooltip element. |
| `width` | `number` | `600` | SVG `viewBox` width. |
| `height` | `number` | `800` | SVG `viewBox` height. |
| `allowZoom` | `boolean` | `false` | When `true`, centers the map on `selectedId` and applies zoom. |
| `zoomOnInit` | `boolean` | `true` | When `false`, does not zoom to the initial `selectedId` on first render. Later selections still apply zoom. |
| `zoomOnMapSelect` | `boolean` | `true` | When `false`, clicking a municipality in the map updates selection without zooming. External `selectedId` changes can still zoom. |
| `allowScrollZoom` | `boolean` | `false` | Enables mouse wheel zoom over the map. |
| `allowDragPan` | `boolean` | `false` | Enables mouse drag panning while zoom is active. |
| `disableAnimations` | `boolean` | `false` | Disables animated viewport zoom transitions and uses instant transform updates instead. |
| `minZoom` | `number` | `1` | Minimum allowed zoom level. |
| `maxZoom` | `number` | `8` | Maximum allowed zoom level. |
| `zoomStep` | `number` | `0.5` | Increment used by zoom buttons and mouse wheel. |
| `initialZoomLvl` | `number` | `1` | Base zoom used on initial render, reset, and non-zooming map selections. |
| `zoomLvl` | `number` | `2` | Zoom multiplier used when zooming to a selected municipality. |
| `showZoomControls` | `boolean` | `false` | Shows internal `+`, `-`, and reset buttons over the map. |
| `zoomControlsPosition` | `"bottom-left" \| "bottom-right" \| "top-left" \| "top-right"` | `"top-right"` | Corner position for the internal zoom controls. |
| `className` | `string` | `"portugal-map"` | CSS class applied to the SVG. |
| `defaultFill` | `string` | `"#edf3f5"` | Fill color for municipalities without a special state. |
| `disabledFill` | `string` | `"#e3eaed"` | Fill color used for IDs listed in `disabledIds`. |
| `selectedFill` | `string` | `"#008fbd"` | Fill color for the selected municipality. |
| `highlightedFill` | `string` | `"#cceaf3"` | Fill color for highlighted municipalities. |
| `hoverFill` | `string` | `"#d8eef6"` | Fill color applied on hover. |
| `stroke` | `string` | `"#aebdc4"` | Base municipality stroke color. |
| `selectedStroke` | `string \| null` | `null` | Optional stroke color for the selected municipality. When `null`, `stroke` is used. |
| `selectedStrokeWidth` | `number \| null` | `null` | Optional stroke width for the selected municipality. When `null`, the normal CSS is used. |
| `useIslandInsets` | `boolean` | `true` | When enabled, renders mainland Portugal, the Azores, and Madeira in separate areas, without real geographic distance. |
| `showIslands` | `boolean` | `true` | Shows or hides the Azores and Madeira. |
| `insetLabels` | `boolean` | `true` | Shows labels in the archipelago insets. |
| `insetBoxes` | `object` | see below | Defines position, size, and padding for mainland Portugal, the Azores, and Madeira. |
| `onSelect` | `function \| null` | `null` | Callback called when a municipality is selected. |
| `onHover` | `function \| null` | `null` | Callback called when hovering a municipality. |
| `onLeave` | `function \| null` | `null` | Callback called when leaving a municipality. |

## GeoJSON Resolution

By default, the widget loads the high-resolution version:

```js
const map = new PortugalMap("#mapa-portugal", {
  resolution: "high"
});
```

To load the smaller file:

```js
const map = new PortugalMap("#mapa-portugal", {
  resolution: "low"
});
```

Default URLs:

```js
mapUrls: {
  high: new URL("../assets/maps/concelhos_pt_high.geojson", import.meta.url).href,
  low: new URL("../assets/maps/concelhos_pt_low.geojson", import.meta.url).href
}
```

This means that, when you import `js/PortugalMap.js` or the package build, the included GeoJSON files are resolved automatically without passing `mapUrls`.

You can also pass your own URLs:

```js
const map = new PortugalMap("#mapa-portugal", {
  resolution: "low",
  mapUrls: {
    high: "/assets/maps/concelhos_pt_high.geojson",
    low: "/assets/maps/concelhos_pt_low.geojson"
  }
});
```

If `mapUrl` is set, that URL is used directly and `resolution` is ignored:

```js
const map = new PortugalMap("#mapa-portugal", {
  mapUrl: "/assets/maps/portugal-concelhos.geojson"
});
```

To render districts or areas by `DI` instead of municipalities by `DICO`, use the included district files:

```js
const map = new PortugalMap("#mapa-portugal", {
  mapUrl: new URL("../assets/maps/distritos_pt_high.geojson", import.meta.url).href,
  idField: "DI",
  labelField: "Distrito"
});
```

## Island Insets

By default, the widget moves the Azores and Madeira closer to mainland Portugal in small boxes on the left side.

```js
insetBoxes: {
  continent: {
    x: 220,
    y: 20,
    width: 340,
    height: 740
  },
  azores: {
    x: 20,
    y: 430,
    width: 170,
    height: 125,
    padding: 16
  },
  madeira: {
    x: 20,
    y: 585,
    width: 170,
    height: 90,
    padding: 18,
    shapeOffsetX: 12,
    shapeOffsetY: 0
  }
}
```

`padding` moves the shapes away from the box edge. `shapeOffsetX` and `shapeOffsetY` let you visually adjust the shapes inside the box.

To hide the islands:

```js
const map = new PortugalMap("#mapa-portugal", {
  showIslands: false
});
```

When `showIslands` is `false` and `useIslandInsets` is enabled, mainland Portugal automatically uses the central map area instead of the narrower box used while the archipelagos are visible.

Or after rendering:

```js
map.setShowIslands(false);
map.setShowIslands(true);
```

## Values

`values` associates external data with a municipality by ID.

```js
values: {
  "1820": {
    label: "TABUAÇO",
    labelHtml: "TABUAÇO <strong>10</strong>",
    value: 10,
    color: "#008fbd"
  }
}
```

Color priority:

1. `values[id].color`
2. `selectedFill`
3. `highlightedFill`
4. `defaultFill`

If `labelHtml` exists, the tooltip renders it as HTML. Otherwise, it uses `label` as plain text.

## Tooltip

To show a tooltip with the municipality name on hover:

```js
const map = new PortugalMap("#mapa-portugal", {
  showTooltip: true
});
```

The tooltip uses the calculated label for the municipality. If `values[id].label` exists, it takes priority over the field defined in `labelField`.

To customize the CSS class:

```js
const map = new PortugalMap("#mapa-portugal", {
  showTooltip: true,
  tooltipClassName: "my-map-tooltip"
});
```

## Zooming To The Selected Municipality

When `allowZoom` is enabled, `initialZoomLvl` controls the base/reset zoom and `zoomLvl` controls the zoom used when the map should focus a selected municipality.

```js
const map = new PortugalMap("#mapa-portugal", {
  selectedId: "1820",
  allowZoom: true,
  zoomOnInit: false,
  allowScrollZoom: true,
  allowDragPan: true,
  minZoom: 1,
  maxZoom: 6,
  zoomStep: 0.5,
  initialZoomLvl: 1,
  zoomLvl: 3,
  showZoomControls: true
});
```

To disable zoom, set `allowZoom: false` or clear `selectedId`.
To keep the initial municipality selected without zooming on first render, use `zoomOnInit: false`.
For direct interaction, enable `allowScrollZoom` for the mouse wheel and `allowDragPan` for dragging the map.
Use `minZoom`, `maxZoom`, and `zoomStep` to control zoom limits and pace.
Use `initialZoomLvl` for the starting/reset zoom, and `zoomLvl` for zoom-to-municipality actions.

If table/search selection should zoom, but map clicks should only select, use:

```js
const map = new PortugalMap("#mapa-portugal", {
  allowZoom: true,
  zoomOnMapSelect: false
});
```

## Event Payload

`onSelect`, `onHover`, and `onLeave` receive:

```js
{
  id: "1820",
  label: "TABUAÇO",
  value: 10,
  data: {
    label: "TABUAÇO",
    value: 10,
    color: "#008fbd"
  },
  feature: {}
}
```

## Public Methods

### `render()`

Loads the GeoJSON and draws the map.

```js
await map.render();
```

### `setSelected(id)`

Updates the selected municipality.

```js
map.setSelected("1820");
```

### `setValues(values)`

Updates the external data associated with municipalities.

```js
map.setValues({
  "1820": {
    value: 20,
    color: "#008fbd"
  }
});
```

### `setHighlightedIds(ids)`

Updates the highlighted municipalities.

```js
map.setHighlightedIds(["1820", "1106"]);
```

### `setShowIslands(showIslands)`

Shows or hides the Azores and Madeira insets.

```js
map.setShowIslands(false);
```

### `setShowTooltip(showTooltip)`

Shows or hides the hover tooltip.

```js
map.setShowTooltip(true);
```

### `setAllowZoom(allowZoom)`

Enables or disables zoom centered on the selected municipality.

```js
map.setAllowZoom(true);
```

### `setInitialZoomLvl(initialZoomLvl)`

Updates the base zoom used on initial render and reset.

```js
map.setInitialZoomLvl(1.2);
```

### `setZoomLvl(zoomLvl)`

Updates the zoom multiplier used when focusing a municipality.

```js
map.setZoomLvl(4);
```

### `setMinZoom(minZoom)`

Updates the minimum zoom limit.

```js
map.setMinZoom(1);
```

### `setMaxZoom(maxZoom)`

Updates the maximum zoom limit.

```js
map.setMaxZoom(6);
```

### `setZoomStep(zoomStep)`

Updates the increment used by `zoomIn()`, `zoomOut()`, and scroll.

```js
map.setZoomStep(0.5);
```

### `zoomIn(step)`

Increases the current zoom. Automatically enables `allowZoom`.

```js
map.zoomIn();
map.zoomIn(0.5);
```

### `zoomOut(step)`

Decreases the current zoom without going below `1`. Automatically enables `allowZoom`.

```js
map.zoomOut();
map.zoomOut(0.5);
```

### `resetZoom()`

Returns to the base zoom and disables `allowZoom`.

```js
map.resetZoom();
```

### `destroy()`

Removes the SVG and clears internal listeners.

```js
map.destroy();
```

## CSS Classes

Each municipality always receives:

```txt
portugal-map__region
```

Additional classes:

```txt
is-selected
is-highlighted
has-value
```

SVG groups receive:

```txt
portugal-map__group
portugal-map__group--continent
portugal-map__group--azores
portugal-map__group--madeira
```

## Vue Wrapper

The Vue wrapper is a thin layer over the vanilla core. It only handles the Vue lifecycle, synchronizes props, and converts core callbacks into Vue events.

Import:

```js
import PortugalMapVue from "@beespokeio/vector-map";
import "@beespokeio/vector-map/style.css";
```

Usage:

```vue
<template>
  <PortugalMapVue
    v-model:selected-id="selectedId"
    :highlighted-ids="highlightedIds"
    :values="values"
    :allow-zoom="true"
    :zoom-on-init="false"
    :allow-scroll-zoom="true"
    :allow-drag-pan="true"
    :min-zoom="1"
    :max-zoom="6"
    :zoom-step="0.5"
    :zoom-lvl="3"
    show-zoom-controls
    show-tooltip
    @select="handleSelect"
  />
</template>
```

Example without `v-model`:

```vue
<template>
  <PortugalMapVue
    :selected-id="selectedId"
    :show-islands="showIslands"
    @select="handleSelect"
    @hover="handleHover"
    @leave="handleLeave"
  />
</template>
```

Emitted events:

```txt
update:selectedId
select
hover
leave
ready
error
```

Main wrapper props:

```txt
mapUrl
mapUrls
resolution
districtMode
idField
labelField
selectedId
highlightedIds
disabledIds
values
legends
legendTitle
legendPosition
showTooltip
showIslands
width
height
allowZoom
zoomOnInit
zoomOnMapSelect
allowScrollZoom
allowDragPan
disableAnimations
minZoom
maxZoom
zoomStep
initialZoomLvl
zoomLvl
showZoomControls
zoomControlsPosition
defaultFill
disabledFill
selectedFill
highlightedFill
hoverFill
stroke
selectedStroke
selectedStrokeWidth
useIslandInsets
insetLabels
insetBoxes
```

If you need to control zoom programmatically in the Vue wrapper, use a `ref`:

```vue
<template>
  <PortugalMapVue ref="mapRef" :selected-id="selectedId" :allow-zoom="true" />
  <button @click="mapRef?.zoomIn()">Zoom +</button>
  <button @click="mapRef?.zoomOut()">Zoom -</button>
</template>
```

The `tests/example3.html` file shows the Vue wrapper working via CDN/import map.

## Manual Tests

The files in `tests/` are HTML examples for manual validation. Serve them over HTTP because the component loads GeoJSON files with `fetch()`.

```bash
cd /BeeSpoke/components/map
source /home/fernando/.nvm/nvm.sh
nvm use 22
npm install
npm run build
python3 -m http.server 8000
```

Then open in the browser:

```txt
http://localhost:8000/tests/example1.html
http://localhost:8000/tests/example2.html
http://localhost:8000/tests/example3.html
```

Expected validation:

- `example1.html`: the map appears, Tabuaço starts selected, hover shows the tooltip, and clicking updates the text.
- `example2.html`: the basic map appears without errors.
- `example3.html`: the Vue wrapper appears and the buttons select/clear, hide/show islands, and update zoom.
- `example3.html`: the Vue wrapper appears and the buttons select/clear, hide/show islands, and the map shows internal zoom and reset controls.

The examples use CDN/import maps for `d3-geo` and, for Vue, also for `vue`, so they require internet access.

## Legends And District Mode

You can position the legend with `legendPosition`, give it a title with `legendTitle`, and switch the built-in defaults to district data with `districtMode`:

```js
const map = new PortugalMap("#mapa-portugal", {
  districtMode: true,
  selectedId: "01",
  legends: [
    { color: "#008fbd", label: "Selected" },
    { color: "#cceaf3", label: "Highlighted" }
  ],
  legendTitle: "Legend",
  legendPosition: "bottom-right"
});
```

Supported `legendPosition` values:

```txt
bottom-left
bottom-right
top-left
top-right
```

## Disabled IDs

You can pass `disabledIds` to keep some areas visible but non-selectable:

```js
const map = new PortugalMap("#mapa-portugal", {
  disabledIds: ["0303", "1820"]
});
```

You can also customize their color and disable zoom/reset animations:

```js
const map = new PortugalMap("#mapa-portugal", {
  disabledIds: ["0303", "1820"],
  disabledFill: "#d9e1e5",
  disableAnimations: true
});
```
