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 `` wrapper, and embeds the + paths in white inside the red pin at a `0.8` scale (lines 194-199). Results are cached + in `cachedIconUrls` keyed by icon name / `'default'`. + +## Adding a new icon + +1. **Find an SVG.** + - Pinhead: browse `node_modules/@waysidemapping/pinhead/dist/icons/` or search the + gallery at https://pinhead.ink. Filenames use snake_case (`coffee_bean.svg`). + - Feather: `node_modules/feather-icons/dist/icons/`. + - Otherwise drop a custom file in `app/icons/`. +2. **Import it** in `app/utils/icons.js` in the correct group, keeping the group + alphabetical, using `?raw`. +3. **Register it** in the `ICONS` map with a kebab-case key (quote it if it has dashes). +4. **Mark it filled** in `FILLED_ICONS` if it is a fill-style (not stroke-style) icon. +5. **Map OSM tags** in `POI_ICON_RULES` (`app/utils/osm-icons.js`), inserting the rule + before any catch-all that would otherwise shadow it. + +No test is needed just to register a new icon or tag rule. The generic integrity test +(`all icons used in POI_ICON_RULES exist in the icons utility`) already checks every +rule's icon. Only add a focused test in `tests/unit/utils/osm-icons-test.js` for +non-trivial matching behavior (e.g. ordering/shadowing, semicolon-separated values, or +a new catch-all). + +## Gotchas + +- **Order matters.** First match wins; put specific rules before catch-alls + (`shop: true`, `office: true`, `craft: true`, `sport: true`, `historic: yes`). +- **The integrity test is your safety net.** `osm-icons-test.js` asserts every icon named + in `POI_ICON_RULES` exists in `getIcon`. Forgetting the import/registry entry fails CI. + This covers _all_ icons automatically, so you normally don't need to add a test when + adding one. +- **Naming mismatch.** Pinhead filenames are snake_case; registry keys are usually + kebab-case, with a few unnormalized exceptions. A typo yields `null` (no marker icon) + rather than an error. +- **Two icon systems.** Don't touch `public/icons/` (PWA icons) when working on POI icons; + `pnpm build:icons` regenerates those PNGs and is release-only. +- **Don't confuse with categories.** Tag→category matching for the sidebar/chips lives in + `app/utils/poi-category-matcher.js` and `app/utils/poi-categories.js`, separate from + tag→icon. + +## Running the tests + +```sh +pnpm test # builds and runs the full QUnit suite via Testem +pnpm lint # ESLint + Stylelint + Prettier + ember-template-lint +pnpm lint:fix # auto-fix +```