From 438bf0c31c5b1ec5eeb1fea5f928029356710d0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sun, 22 Mar 2026 12:21:27 +0400 Subject: [PATCH] Add icons to search result markers --- app/components/map.gjs | 81 +++++++++++++++++++++---- app/utils/icons.js | 67 +++++++++++++++++++-- app/utils/osm-icons.js | 131 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 264 insertions(+), 15 deletions(-) create mode 100644 app/utils/osm-icons.js 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); +}