diff --git a/app/components/map.gjs b/app/components/map.gjs
index fad1a0c..7cc5836 100644
--- a/app/components/map.gjs
+++ b/app/components/map.gjs
@@ -2,7 +2,7 @@ import Component from '@glimmer/component';
import { service } from '@ember/service';
import { modifier } from 'ember-modifier';
import 'ol/ol.css';
-import Map from 'ol/Map.js';
+import OlMap from 'ol/Map.js';
import { defaults as defaultControls, Control } from 'ol/control.js';
import { defaults as defaultInteractions, DragPan } from 'ol/interaction.js';
import Kinetic from 'ol/Kinetic.js';
@@ -18,6 +18,8 @@ import Point from 'ol/geom/Point.js';
import Geolocation from 'ol/Geolocation.js';
import { Style, Circle, Fill, Stroke, Icon } from 'ol/style.js';
import { apply } from 'ol-mapbox-style';
+import { getIcon } from '../utils/icons';
+import { getIconNameForTags } from '../utils/osm-icons';
export default class MapComponent extends Component {
@service osm;
@@ -113,15 +115,66 @@ export default class MapComponent extends Component {
// Create a vector source and layer for search results
this.searchResultsSource = new VectorSource();
- let cachedSearchResultSvgUrl = null;
+ const cachedIconUrls = new Map();
- const searchResultStyle = (feature) => {
- if (!cachedSearchResultSvgUrl) {
+ const searchResultStyle = (feature) => {
+ const originalPlace = feature.get('originalPlace');
+ // Some search results might be just the place object without separate tags
+ // If it's a raw place object, it might have osmTags property.
+ // Or it might be the tags object itself.
+ const tags = originalPlace.osmTags || originalPlace;
+ const iconName = getIconNameForTags(tags);
+
+ // Use 'default' key for the standard red dot marker. Use iconName as key if present.
+ const cacheKey = iconName || 'default';
+
+ if (!cachedIconUrls.has(cacheKey)) {
const markerColor =
getComputedStyle(document.documentElement)
.getPropertyValue('--marker-color-primary')
.trim() || '#ea4335';
+ // Default content: Red circle
+ let innerContent = ` `;
+
+ if (iconName) {
+ const rawSvg = getIcon(iconName);
+ if (rawSvg) {
+ // Pinhead icons are usually 15x15 viewBox="0 0 15 15".
+ // We want to center it on 12,12.
+ // A 12x12 icon centered at 12,12 means top-left at 6,6.
+ // However, since we are embedding a new SVG, we can just use x/y/width/height.
+ // But we need to strip the outer tag to embed the paths cleanly if we want full control,
+ // or we can nest the SVG. Nesting is safer.
+
+ // The rawSvg string contains ... .
+ // We want to make it white. We can add a group with fill="white".
+ // But if the SVG has fill attributes, they override. Pinhead icons usually don't have fills.
+
+ // Let's strip the outer SVG tag to get the path content.
+ let content = rawSvg.trim();
+ const svgStart = content.indexOf('', svgStart);
+ const contentStart = svgEnd + 1;
+ const contentEnd = content.lastIndexOf(' ');
+
+ if (svgStart !== -1 && contentEnd !== -1) {
+ content = content.substring(contentStart, contentEnd);
+ }
+
+ // We render the red circle background, then the icon on top.
+ // Icon is scaled down slightly to fit nicely inside the circle.
+ // 15x15 scaled by 0.8 is 12x12.
+ // Translate to 6,6 to center.
+ innerContent = `
+
+
+ ${content}
+
+ `;
+ }
+ }
+
const svg = `
@@ -130,16 +183,19 @@ export default class MapComponent extends Component {
-
+ ${innerContent}
`;
- cachedSearchResultSvgUrl =
- 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg.trim());
+
+ cachedIconUrls.set(
+ cacheKey,
+ 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg.trim())
+ );
}
return new Style({
image: new Icon({
- src: cachedSearchResultSvgUrl,
+ src: cachedIconUrls.get(cacheKey),
anchor: [0.5, 0.65],
scale: 1,
}),
@@ -183,9 +239,14 @@ export default class MapComponent extends Component {
projection: 'EPSG:3857',
});
- this.mapInstance = new Map({
+ this.mapInstance = new OlMap({
target: element,
- layers: [openfreemap, selectedShapeLayer, searchResultLayer, bookmarkLayer],
+ layers: [
+ openfreemap,
+ selectedShapeLayer,
+ searchResultLayer,
+ bookmarkLayer,
+ ],
view: view,
controls: defaultControls({
zoom: true,
diff --git a/app/utils/icons.js b/app/utils/icons.js
index f2542ec..59c905f 100644
--- a/app/utils/icons.js
+++ b/app/utils/icons.js
@@ -1,5 +1,5 @@
-import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
import activity from 'feather-icons/dist/icons/activity.svg?raw';
+import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
import clock from 'feather-icons/dist/icons/clock.svg?raw';
@@ -20,53 +20,109 @@ import menu from 'feather-icons/dist/icons/menu.svg?raw';
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
import phone from 'feather-icons/dist/icons/phone.svg?raw';
import plus from 'feather-icons/dist/icons/plus.svg?raw';
-import server from 'feather-icons/dist/icons/server.svg?raw';
import search from 'feather-icons/dist/icons/search.svg?raw';
+import server from 'feather-icons/dist/icons/server.svg?raw';
import settings from 'feather-icons/dist/icons/settings.svg?raw';
import target from 'feather-icons/dist/icons/target.svg?raw';
import user from 'feather-icons/dist/icons/user.svg?raw';
import x from 'feather-icons/dist/icons/x.svg?raw';
import zap from 'feather-icons/dist/icons/zap.svg?raw';
+
+import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
+import beachUmbrellaInGround from '@waysidemapping/pinhead/dist/icons/beach_umbrella_in_ground.svg?raw';
+import beerMugWithFoam from '@waysidemapping/pinhead/dist/icons/beer_mug_with_foam.svg?raw';
+import burgerAndDrinkCupWithStraw from '@waysidemapping/pinhead/dist/icons/burger_and_drink_cup_with_straw.svg?raw';
import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
+import classicalBuilding from '@waysidemapping/pinhead/dist/icons/classical_building.svg?raw';
+import classicalBuildingWithDomeAndFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_dome_and_flag.svg?raw';
+import classicalBuildingWithFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_flag.svg?raw';
+import cleaver from '@waysidemapping/pinhead/dist/icons/cleaver.svg?raw';
+import coffeeBean from '@waysidemapping/pinhead/dist/icons/coffee_bean.svg?raw';
+import comedyMaskAndTragedyMask from '@waysidemapping/pinhead/dist/icons/comedy_mask_and_tragedy_mask.svg?raw';
+import croissant from '@waysidemapping/pinhead/dist/icons/croissant.svg?raw';
import cupAndSaucer from '@waysidemapping/pinhead/dist/icons/cup_and_saucer.svg?raw';
+import donut from '@waysidemapping/pinhead/dist/icons/donut.svg?raw';
+import film from '@waysidemapping/pinhead/dist/icons/film.svg?raw';
+import flagCheckered from '@waysidemapping/pinhead/dist/icons/flag_checkered.svg?raw';
+import fort from '@waysidemapping/pinhead/dist/icons/fort.svg?raw';
import forkAndKnife from '@waysidemapping/pinhead/dist/icons/fork_and_knife.svg?raw';
+import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone.svg?raw';
+import memorialStoneWithInscription from '@waysidemapping/pinhead/dist/icons/memorial_stone_with_inscription.svg?raw';
+import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
+import personCricketBattingAtCricketBall from '@waysidemapping/pinhead/dist/icons/person_cricket_batting_at_cricket_ball.svg?raw';
+import personJockeyingRacehorse from '@waysidemapping/pinhead/dist/icons/person_jockeying_racehorse.svg?raw';
+import personRunning from '@waysidemapping/pinhead/dist/icons/person_running.svg?raw';
import personSleepingInBed from '@waysidemapping/pinhead/dist/icons/person_sleeping_in_bed.svg?raw';
+import personSwimmingInWater from '@waysidemapping/pinhead/dist/icons/person_swimming_in_water.svg?raw';
+import personSwingingGolfClub from '@waysidemapping/pinhead/dist/icons/person_swinging_golf_club.svg?raw';
+import roundStructureWithFlag from '@waysidemapping/pinhead/dist/icons/round_structure_with_flag.svg?raw';
+import sailingShipInWater from '@waysidemapping/pinhead/dist/icons/sailing_ship_in_water.svg?raw';
import shoppingBasket from '@waysidemapping/pinhead/dist/icons/shopping_basket.svg?raw';
+import shoppingCart from '@waysidemapping/pinhead/dist/icons/shopping_cart.svg?raw';
+import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw';
+
import wikipedia from '../icons/wikipedia.svg?raw';
const ICONS = {
- 'arrow-left': arrowLeft,
activity,
+ angelfish,
+ 'arrow-left': arrowLeft,
+ 'beach-umbrella-in-ground': beachUmbrellaInGround,
+ 'beer-mug-with-foam': beerMugWithFoam,
bookmark,
+ 'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw,
camera,
'check-square': checkSquare,
+ 'classical-building': classicalBuilding,
+ 'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
+ 'classical-building-with-flag': classicalBuildingWithFlag,
+ cleaver,
clock,
+ 'coffee-bean': coffeeBean,
+ 'comedy-mask-and-tragedy-mask': comedyMaskAndTragedyMask,
+ croissant,
'cup-and-saucer': cupAndSaucer,
+ donut,
edit,
facebook,
+ film,
+ 'flag-checkered': flagCheckered,
+ 'fork-and-knife': forkAndKnife,
+ fort,
gift,
globe,
heart,
home,
+ 'ice-cream-on-cone': iceCreamOnCone,
info,
instagram,
- 'fork-and-knife': forkAndKnife,
'log-in': logIn,
'log-out': logOut,
mail,
map,
'map-pin': mapPin,
+ 'memorial-stone-with-inscription': memorialStoneWithInscription,
menu,
navigation,
+ palace,
+ 'person-cricket-batting-at-cricket-ball': personCricketBattingAtCricketBall,
+ 'person-jockeying-racehorse': personJockeyingRacehorse,
+ 'person-running': personRunning,
'person-sleeping-in-bed': personSleepingInBed,
+ 'person-swimming-in-water': personSwimmingInWater,
+ 'person-swinging-golf-club': personSwingingGolfClub,
phone,
plus,
- server,
+ 'round-structure-with-flag': roundStructureWithFlag,
+ 'sailing-ship-in-water': sailingShipInWater,
search,
+ server,
settings,
'shopping-basket': shoppingBasket,
+ 'shopping-cart': shoppingCart,
target,
user,
+ 'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
wikipedia,
x,
zap,
@@ -76,6 +132,7 @@ const FILLED_ICONS = [
'fork-and-knife',
'wikipedia',
'cup-and-saucer',
+ 'coffee-bean',
'shopping-basket',
'camera',
'person-sleeping-in-bed',
diff --git a/app/utils/osm-icons.js b/app/utils/osm-icons.js
new file mode 100644
index 0000000..3f93c67
--- /dev/null
+++ b/app/utils/osm-icons.js
@@ -0,0 +1,131 @@
+import { getIcon } from './icons';
+
+// Rules for mapping OSM tags to icons.
+// Rules are evaluated in order. The first rule where all specified tags match is used.
+export const POI_ICON_RULES = [
+ // Specific Cuisine
+ { tags: { cuisine: 'donut' }, icon: 'donut' },
+ { tags: { cuisine: 'doughnut' }, icon: 'donut' },
+ { tags: { cuisine: 'coffee_shop' }, icon: 'coffee-bean' },
+ { tags: { cuisine: 'coffee' }, icon: 'coffee-bean' },
+
+ // General Amenity/Shop Types
+ { tags: { amenity: 'ice_cream' }, icon: 'ice-cream-on-cone' },
+ { tags: { cuisine: 'ice_cream' }, icon: 'ice-cream-on-cone' },
+ { tags: { shop: 'ice_cream' }, icon: 'ice-cream-on-cone' },
+
+ { tags: { amenity: 'cafe' }, icon: 'cup-and-saucer' },
+ { tags: { amenity: 'restaurant' }, icon: 'fork-and-knife' },
+ { tags: { amenity: 'fast_food' }, icon: 'burger-and-drink-cup-with-straw' },
+ { tags: { amenity: 'pub' }, icon: 'beer-mug-with-foam' },
+ { tags: { amenity: 'food_court' }, icon: 'fork-and-knife' },
+
+ { tags: { shop: 'coffee' }, icon: 'coffee-bean' },
+ { tags: { shop: 'tea' }, icon: 'coffee-bean' },
+ { tags: { shop: 'pastry' }, icon: 'donut' }, // Pastry shops often have donuts
+
+ // Groceries
+ { tags: { shop: 'supermarket' }, icon: 'shopping-cart' },
+ { tags: { shop: 'convenience' }, icon: 'shopping-basket' },
+ { tags: { shop: 'grocery' }, icon: 'shopping-basket' },
+ { tags: { shop: 'greengrocer' }, icon: 'shopping-basket' },
+ { tags: { shop: 'bakery' }, icon: 'croissant' },
+ { tags: { shop: 'butcher' }, icon: 'cleaver' },
+ { tags: { shop: 'deli' }, icon: 'shopping-basket' },
+
+ // Natural
+ { tags: { natural: 'beach' }, icon: 'beach-umbrella-in-ground' },
+
+ // Tourism
+ { tags: { tourism: 'museum' }, icon: 'classical-building' },
+ { tags: { tourism: 'gallery' }, icon: 'wall-hanging-with-mountains-and-sun' },
+ { tags: { tourism: 'aquarium' }, icon: 'angelfish' },
+ { tags: { tourism: 'theme_park' }, icon: 'camera' },
+ { tags: { tourism: 'attraction' }, icon: 'camera' },
+ { tags: { tourism: 'viewpoint' }, icon: 'camera' },
+ { tags: { tourism: 'zoo' }, icon: 'camera' },
+ { tags: { tourism: 'artwork' }, icon: 'camera' },
+ { tags: { amenity: 'cinema' }, icon: 'film' },
+ { tags: { amenity: 'theatre' }, icon: 'camera' },
+ { tags: { amenity: 'arts_centre' }, icon: 'comedy-mask-and-tragedy-mask' },
+ { tags: { amenity: 'arts_center' }, icon: 'comedy-mask-and-tragedy-mask' },
+
+ // Historic
+ { tags: { historic: 'fort' }, icon: 'fort' },
+ { tags: { historic: 'castle' }, icon: 'palace' },
+ { tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
+ { tags: { historic: 'archaeological_site' }, icon: 'camera' },
+ { tags: { historic: 'memorial' }, icon: 'memorial-stone-with-inscription' },
+ {
+ tags: { historic: 'monument' },
+ icon: 'classical-building-with-dome-and-flag',
+ },
+ { tags: { historic: 'ship' }, icon: 'sailing-ship-in-water' },
+
+ // Accommodation
+ { tags: { tourism: 'hotel' }, icon: 'person-sleeping-in-bed' },
+ { tags: { tourism: 'hostel' }, icon: 'person-sleeping-in-bed' },
+ { tags: { tourism: 'motel' }, icon: 'person-sleeping-in-bed' },
+ { tags: { tourism: 'guest_house' }, icon: 'person-sleeping-in-bed' },
+
+ // Sports / Motorsports
+ { tags: { sport: 'motor' }, icon: 'flag-checkered' },
+ { tags: { sport: 'karting' }, icon: 'flag-checkered' },
+ { tags: { sport: 'motocross' }, icon: 'flag-checkered' },
+ {
+ tags: { sport: 'cricket' },
+ icon: 'person-cricket-batting-at-cricket-ball',
+ },
+ { tags: { leisure: 'water_park' }, icon: 'person-swimming-in-water' },
+ { tags: { sport: 'golf' }, icon: 'person-swinging-golf-club' },
+ { tags: { leisure: 'golf_course' }, icon: 'person-swinging-golf-club' },
+ { tags: { sport: 'horse_racing' }, icon: 'person-jockeying-racehorse' },
+ { tags: { leisure: 'stadium' }, icon: 'round-structure-with-flag' },
+ { tags: { sport: 'stadium' }, icon: 'round-structure-with-flag' },
+
+ { tags: { leisure: 'sports_centre' }, icon: 'person-running' },
+ { tags: { sport: 'fitness_centre' }, icon: 'person-running' },
+];
+
+/**
+ * Finds the appropriate icon name based on the place's OSM tags.
+ * @param {Object} tags - The OSM tags of the place.
+ * @returns {string|null} - The name of the icon or null if no match found.
+ */
+export function getIconNameForTags(tags) {
+ if (!tags) return null;
+
+ for (const rule of POI_ICON_RULES) {
+ let match = true;
+ for (const [key, expectedValue] of Object.entries(rule.tags)) {
+ const tagValue = tags[key];
+ if (!tagValue) {
+ match = false;
+ break;
+ }
+
+ // Check for exact match or if value is in a semicolon-separated list
+ // e.g. "donut;coffee_shop"
+ const values = tagValue.split(';').map((v) => v.trim());
+ if (!values.includes(expectedValue)) {
+ match = false;
+ break;
+ }
+ }
+ if (match) {
+ return rule.icon;
+ }
+ }
+ return null;
+}
+
+/**
+ * Returns the raw SVG string for the icon corresponding to the given tags.
+ * @param {Object} tags - The OSM tags.
+ * @returns {string|null} - The raw SVG string or null.
+ */
+export function getIconSvgForTags(tags) {
+ const iconName = getIconNameForTags(tags);
+ if (!iconName) return null;
+ return getIcon(iconName);
+}