diff --git a/app/utils/icons.js b/app/utils/icons.js
index 7d3f25e..7bd5898 100644
--- a/app/utils/icons.js
+++ b/app/utils/icons.js
@@ -53,6 +53,8 @@ import anchor from '@waysidemapping/pinhead/dist/icons/anchor.svg?raw';
import airportTerminal from '@waysidemapping/pinhead/dist/icons/airport_terminal.svg?raw';
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
import barrelWithBunghole from '@waysidemapping/pinhead/dist/icons/barrel_with_bunghole.svg?raw';
+import bicycle from '@waysidemapping/pinhead/dist/icons/bicycle.svg?raw';
+import bulldozer from '@waysidemapping/pinhead/dist/icons/bulldozer.svg?raw';
import climbingWall from '@waysidemapping/pinhead/dist/icons/climbing_wall.svg?raw';
import banknote from '@waysidemapping/pinhead/dist/icons/banknote.svg?raw';
import banknoteInSlot from '@waysidemapping/pinhead/dist/icons/banknote_in_slot.svg?raw';
@@ -95,7 +97,9 @@ import fish from '@waysidemapping/pinhead/dist/icons/fish.svg?raw';
import flagCheckered from '@waysidemapping/pinhead/dist/icons/flag_checkered.svg?raw';
import flowerBouquet from '@waysidemapping/pinhead/dist/icons/flower_bouquet.svg?raw';
import fort from '@waysidemapping/pinhead/dist/icons/fort.svg?raw';
+import fortress from '@waysidemapping/pinhead/dist/icons/fortress.svg?raw';
import forkAndKnife from '@waysidemapping/pinhead/dist/icons/fork_and_knife.svg?raw';
+import gableRoofedBuilding from '@waysidemapping/pinhead/dist/icons/gable_roofed_building.svg?raw';
import gravestone from '@waysidemapping/pinhead/dist/icons/gravestone.svg?raw';
import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw';
import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw';
@@ -139,6 +143,8 @@ import shoppingCart from '@waysidemapping/pinhead/dist/icons/shopping_cart.svg?r
import tableTennisPaddle from '@waysidemapping/pinhead/dist/icons/table_tennis_paddle.svg?raw';
import tattooMachine from '@waysidemapping/pinhead/dist/icons/tattoo_machine.svg?raw';
import toolbox from '@waysidemapping/pinhead/dist/icons/toolbox.svg?raw';
+import townBuildings from '@waysidemapping/pinhead/dist/icons/town_buildings.svg?raw';
+import transitVehicleOnRailwayTrack from '@waysidemapping/pinhead/dist/icons/transit_vehicle_on_railway_track.svg?raw';
import treeAndBenchWithBackrest from '@waysidemapping/pinhead/dist/icons/tree_and_bench_with_backrest.svg?raw';
import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw';
@@ -164,6 +170,8 @@ const ICONS = {
'arrow-left': arrowLeft,
barbell,
'barrel-with-bunghole': barrelWithBunghole,
+ bicycle,
+ bulldozer,
banknote,
'banknote-in-slot': banknoteInSlot,
'badge-shield-with-fire': badgeShieldWithFire,
@@ -216,6 +224,8 @@ const ICONS = {
'flower-bouquet': flowerBouquet,
'fork-and-knife': forkAndKnife,
fort,
+ fortress,
+ 'gable-roofed-building': gableRoofedBuilding,
'gasoline-pump': gasolinePump,
gift,
globe,
@@ -284,6 +294,8 @@ const ICONS = {
target,
'trash-2': trash2,
'upload-cloud': uploadCloud,
+ 'town-buildings': townBuildings,
+ 'transit-vehicle-on-railway-track': transitVehicleOnRailwayTrack,
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
user,
'user-check': userCheck,
diff --git a/app/utils/osm-icons.js b/app/utils/osm-icons.js
index 0f86be8..fde53b2 100644
--- a/app/utils/osm-icons.js
+++ b/app/utils/osm-icons.js
@@ -99,18 +99,9 @@ export const POI_ICON_RULES = [
{ tags: { leisure: 'playground' }, icon: 'play-structure-with-slide' },
{ tags: { leisure: 'marina' }, icon: 'anchor' },
{ tags: { landuse: 'vineyard' }, icon: 'grapes' },
-
- // Transport
- { tags: { aeroway: 'aerodrome' }, icon: 'plane-top-right' },
- { tags: { aeroway: 'terminal' }, icon: 'airport-terminal' },
- { tags: { aeroway: 'heliport' }, icon: 'plane-top-right' },
- { tags: { aeroway: 'helipad' }, icon: 'plane-top-right' },
- { tags: { highway: 'bus_stop' }, icon: 'bus' },
- { tags: { bus: true }, icon: 'bus' },
- {
- tags: { railway: 'tram_stop' },
- icon: 'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track',
- },
+ { tags: { landuse: 'cemetery' }, icon: 'memorial-stone-with-inscription' },
+ { tags: { landuse: 'construction' }, icon: 'bulldozer' },
+ { tags: { landuse: 'residential' }, icon: 'town-buildings' },
// Tourism
{ tags: { tourism: 'museum' }, icon: 'classical-building' },
@@ -131,9 +122,11 @@ export const POI_ICON_RULES = [
{ tags: { historic: 'canal' }, icon: 'winding_way_wide' },
{ tags: { historic: 'bridge' }, icon: 'bridge' },
{ tags: { historic: 'bridge_site' }, icon: 'bridge' },
+ { tags: { historic: 'aqueduct' }, icon: 'bridge' },
{ tags: { historic: 'fort' }, icon: 'fort' },
{ tags: { historic: 'city_gate' }, icon: 'city-gate' },
{ tags: { historic: 'castle' }, icon: 'palace' },
+ { tags: { historic: 'monastery' }, icon: 'fortress' },
{ tags: { building: 'tower', historic: 'yes' }, icon: 'castle-keep' },
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
{ tags: { historic: 'archaeological_site' }, icon: 'grecian-vase' },
@@ -153,7 +146,23 @@ export const POI_ICON_RULES = [
{ tags: { historic: 'wreck' }, icon: 'shipwreck-in-water' },
{ tags: { historic: 'ruins' }, icon: 'camera' },
{ tags: { historic: 'ruin' }, icon: 'camera' },
- { tags: { historic: 'yes' }, icon: 'camera' },
+
+ // Transport
+ { tags: { aeroway: 'aerodrome' }, icon: 'plane-top-right' },
+ { tags: { aeroway: 'terminal' }, icon: 'airport-terminal' },
+ { tags: { aeroway: 'heliport' }, icon: 'plane-top-right' },
+ { tags: { aeroway: 'helipad' }, icon: 'plane-top-right' },
+ { tags: { highway: 'bus_stop' }, icon: 'bus' },
+ { tags: { highway: 'cycleway' }, icon: 'bicycle' },
+ { tags: { bus: true }, icon: 'bus' },
+ {
+ tags: { railway: 'tram_stop' },
+ icon: 'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track',
+ },
+ { tags: { light_rail: true }, icon: 'transit-vehicle-on-railway-track' },
+ { tags: { train: true }, icon: 'transit-vehicle-on-railway-track' },
+ { tags: { tram: true }, icon: 'transit-vehicle-on-railway-track' },
+ { tags: { subway: true }, icon: 'transit-vehicle-on-railway-track' },
// Accommodation
{ tags: { tourism: 'hotel' }, icon: 'person-sleeping-in-bed' },
@@ -190,6 +199,7 @@ export const POI_ICON_RULES = [
{ tags: { leisure: 'stadium' }, icon: 'round-structure-with-flag' },
{ tags: { leisure: 'sports_centre' }, icon: 'person-running' },
{ tags: { leisure: 'pitch' }, icon: 'person-running' },
+ { tags: { landuse: 'recreation_ground' }, icon: 'person-running' },
{ tags: { sport: true }, icon: 'person-running' },
// Healthcare
@@ -204,6 +214,10 @@ export const POI_ICON_RULES = [
{ tags: { building: 'commercial' }, icon: 'commercial-building' },
{ tags: { building: 'apartments' }, icon: 'lowrise-building' },
{ tags: { building: 'office' }, icon: 'lowrise-building' },
+
+ // Fallback
+ { tags: { historic: true }, icon: 'camera' },
+ { tags: { building: true }, icon: 'gable-roofed-building' },
];
/**
diff --git a/doc/dev/place-icons.md b/doc/dev/place-icons.md
new file mode 100644
index 0000000..08a887c
--- /dev/null
+++ b/doc/dev/place-icons.md
@@ -0,0 +1,137 @@
+# Place Icons
+
+How place/POI icons on the map are imported, selected from OSM tags, and rendered.
+
+> Scope: this covers the **POI icon system** used for map markers and UI.
+> It does **not** cover the PWA/launcher icons in `public/icons/` and `release/icons/`
+> (those are app icons referenced from `index.html` / `web-app-manifest.json`).
+
+## File map
+
+| File | Responsibility |
+| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
+| `app/utils/icons.js` | Registry. Imports every SVG as a raw string (`?raw`), exposes `getIcon(name)` and `isIconFilled(name)`. |
+| `app/utils/osm-icons.js` | OSM tag → icon mapping. `POI_ICON_RULES`, `getIconNameForTags(tags)`, `getIconSvgForTags(tags)`. |
+| `app/components/icon.gjs` | Generic `` renderer used across the UI. |
+| `app/components/map.gjs` | Map marker call site; embeds the icon SVG into an OpenLayers `Icon` data-URI. |
+| `app/icons/` | Custom/local SVG files. |
+| `node_modules/@waysidemapping/pinhead/dist/icons/*.svg` | Pinhead source icons (2,320 files). |
+| `node_modules/feather-icons/dist/icons/*.svg` | Feather source icons. |
+| `tests/unit/utils/osm-icons-test.js` | Tests for tag matching + integrity check that every rule icon exists. |
+| `tests/integration/components/icon-test.gjs` | Tests for the `` component. |
+
+## How icons are imported (`app/utils/icons.js`)
+
+Every icon is a **static named import** with Vite's `?raw` suffix, so the file contents
+arrive as a string. There is **no glob/context import**; each icon is listed explicitly.
+
+Imports are grouped and sorted alphabetically (per the header comment at
+`app/utils/icons.js:1`):
+
+1. **Feather icons** — `feather-icons/dist/icons/*.svg`
+2. **Pinhead icons** — `@waysidemapping/pinhead/dist/icons/*.svg`
+3. **Custom/local icons** — `../icons/*.svg`
+
+```js
+import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
+import donut from '@waysidemapping/pinhead/dist/icons/donut.svg?raw';
+import bitcoin from '../icons/bitcoin.svg?raw';
+```
+
+The imported strings are collected in the `ICONS` object, keyed by the kebab-case name
+used everywhere else in the app. Quoted keys are needed when the name contains dashes:
+
+```js
+const ICONS = {
+ activity, // shorthand works for single-word names
+ 'map-pin': mapPin, // quote dashed keys
+ donut,
+ 'loading-ring': loadingRing,
+};
+```
+
+Some registry keys are intentionally **not** normalized to kebab-case
+(e.g. `climbing_wall`, `parking_p`, `winding_way_wide`) — match whatever string the
+rule uses.
+
+`FILLED_ICONS` lists names that render with `fill` instead of `stroke`
+(`app/utils/icons.js:309`). `getIcon(name)` returns the SVG string (or `undefined`);
+`isIconFilled(name)` drives the `.icon-filled` CSS class.
+
+## How icons are selected from OSM tags (`app/utils/osm-icons.js`)
+
+`POI_ICON_RULES` is an **ordered** array of `{ tags, icon }`. Each rule requires **all**
+of its listed tags to match. The **first matching rule wins**, so more specific rules
+must come before catch-alls.
+
+```js
+export const POI_ICON_RULES = [
+ { tags: { cuisine: 'donut' }, icon: 'donut' },
+ ...{ tags: { shop: true }, icon: 'shopping-bag' }, // catch-all, must be late
+];
+```
+
+`getIconNameForTags(tags)` (`app/utils/osm-icons.js:214`) matching semantics:
+
+- Returns `null` if `tags` is falsy or nothing matches.
+- A rule tag only matches if its value is truthy.
+- A tag value is split on `;` and trimmed, so `cuisine=donut;coffee_shop` matches both
+ `donut` and `coffee_shop` rules.
+- `expectedValue: true` matches any non-empty value.
+
+`getIconSvgForTags(tags)` wraps the name lookup and returns the raw SVG. It is exported
+but currently unused.
+
+## Rendering
+
+- **UI:** `` — `app/components/icon.gjs`. Renders
+ only if `getIcon(@name)` returns an SVG. `@filled` overrides `isIconFilled`.
+- **Map markers:** `searchResultStyle` in `app/components/map.gjs:123` calls
+ `getIconNameForTags(tags)` (line 151), strips the `