From 595d9528d5c017a809aa1949eb3f43225ae11a88 Mon Sep 17 00:00:00 2001
From: Fabio Manganiello <fabio@manganiello.tech>
Date: Sun, 23 Mar 2025 23:43:39 +0100
Subject: [PATCH] Added dynamic SVG map marker.

---
 frontend/src/components/Map.vue |  8 +++-
 frontend/src/mixins/Points.vue  | 47 ++++++++++++++---------
 frontend/vite.config.ts         |  4 +-
 src/routes/Icons.ts             | 68 +++++++++++++++++++++++++++++++++
 src/routes/index.ts             |  8 +++-
 tsconfig.json                   |  8 +++-
 6 files changed, 121 insertions(+), 22 deletions(-)
 create mode 100644 src/routes/Icons.ts

diff --git a/frontend/src/components/Map.vue b/frontend/src/components/Map.vue
index ba4a9b2..fdb19d8 100644
--- a/frontend/src/components/Map.vue
+++ b/frontend/src/components/Map.vue
@@ -295,7 +295,7 @@ export default {
           })
 
           if (point) {
-            this.selectedPoint = point
+            this.selectPoint(point)
             // @ts-ignore
             this.$refs.popup.setPosition(event.coordinate)
             // Center the map on the selected point
@@ -307,10 +307,16 @@ export default {
       })
     },
 
+    selectPoint(point: GPSPoint) {
+      this.selectedPoint = point
+      this.highlightPoint(this.pointsLayer as VectorLayer, point)
+    },
+
     clearSelectedPoint() {
       this.selectedPoint = null
       this.selectedPointIndex = null
       this.selectedFeature = null
+      this.removePointHighlight(this.pointsLayer as VectorLayer)
     },
 
     refreshShowMetricsFromURL() {
diff --git a/frontend/src/mixins/Points.vue b/frontend/src/mixins/Points.vue
index b6513e5..f6f8fe9 100644
--- a/frontend/src/mixins/Points.vue
+++ b/frontend/src/mixins/Points.vue
@@ -4,7 +4,7 @@ import Map from 'ol/Map';
 import Point from 'ol/geom/Point';
 import VectorLayer from 'ol/layer/Vector';
 import VectorSource from 'ol/source/Vector';
-import { Circle, Fill, Style, Stroke } from 'ol/style';
+import { Icon, Style } from 'ol/style';
 import { useGeographic } from 'ol/proj';
 
 import GPSPoint from '../models/GPSPoint';
@@ -13,24 +13,29 @@ import Units from './Units.vue';
 
 const minZoom = 2
 const maxZoom = 18
+const iconSize = 32
 
 useGeographic()
 
 const pointStyles = {
   default: new Style({
-    image: new Circle({
-      radius: 6,
-      fill: new Fill({ color: 'aquamarine' }),
-      stroke: new Stroke({ color: 'blue', width: 1 }),
+    image: new Icon({
+      anchor: [0.5, iconSize - 2],
+      anchorXUnits: 'fraction',
+      anchorYUnits: 'pixels',
+      // TODO Apply favourite accent color
+      src: `/icons/poi.svg?size=${iconSize}&color=3468db&border=1f2d3d&fill=cff0ff`,
     }),
-    zIndex: Infinity,  // Ensure that points are always displayed above other layers
+    zIndex: 100,
   }),
 
   highlighted: new Style({
-    image: new Circle({
-      radius: 10,
-      fill: new Fill({ color: 'rgba(255, 0, 0, 0.5)' }),
-      stroke: new Stroke({ color: '#FF0000', width: 2 }),
+    image: new Icon({
+      anchor: [0.5, iconSize - 2],
+      anchorXUnits: 'fraction',
+      anchorYUnits: 'pixels',
+      src: `/icons/poi.svg?size=${iconSize}&color=dd3300&border=3d2d1f&fill=ffcc00`,
+      scale: 1.3,
     }),
     zIndex: Infinity,  // Ensure that points are always displayed above other layers
   }),
@@ -89,17 +94,10 @@ export default {
     createPointsLayer(points: Point[]): VectorLayer {
       const pointFeatures = points.map((point: Point) => new Feature(point))
       return new VectorLayer({
+        style: pointStyles.default,
         source: new VectorSource({
           features: pointFeatures,
         }),
-        style: new Style({
-          image: new Circle({
-            radius: 6,
-            fill: new Fill({ color: 'aquamarine' }),
-            stroke: new Stroke({ color: 'blue', width: 1 }),
-          }),
-          zIndex: Infinity,  // Ensure that points are always displayed above other layers
-        }),
       })
     },
 
@@ -194,6 +192,19 @@ export default {
         this.highlightedFeature = feature
       }
     },
+
+    removePointHighlight(layer: VectorLayer | null) {
+      if (!layer || !this.highlightedPointId) {
+        return
+      }
+
+      const feature = this.highlightedFeature
+      if (feature) {
+        feature.setStyle(pointStyles.default)
+        this.highlightedPointId = null
+        this.highlightedFeature = null
+      }
+    },
   },
 }
 </script>
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 1bcbc5f..6bc03bd 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -35,9 +35,11 @@ export default defineConfig((env) => {
     server: {
       port: 5173,
       proxy: {
-        // proxy requests with the API path to the server
+        // Proxy requests with the API path to the server
         // <http://localhost:5173/api> -> <http://localhost:3000/api>
         [serverAPIPath]: serverURL.origin,
+        // Proxy requests to /icons/poi.svg to the server
+        '/icons/poi.svg': `${serverURL.origin}`,
       },
     },
   }
diff --git a/src/routes/Icons.ts b/src/routes/Icons.ts
new file mode 100644
index 0000000..61a54e5
--- /dev/null
+++ b/src/routes/Icons.ts
@@ -0,0 +1,68 @@
+import { Request, Response } from 'express';
+
+import Route from './Route';
+import Routes from './Routes';
+
+const circleSVGTemplate = `
+  <circle class="{{CLASS}}" cx="50" cy="34.902" r="18.597"></circle>
+`
+
+const poiSVGTemplate = `<?xml version="1.0" encoding="utf-8"?>
+<svg width="{{SIZE}}" height="{{SIZE}}" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="{{CLASS}}" preserveAspectRatio="xMidYMid meet">
+  <defs>
+    <style type="text/css">
+      .with-border path {
+        background-size: cover;
+        box-sizing: border-box;
+        stroke: #{{BORDER}};
+        stroke-width: 1px;
+        stroke-dasharray: none;
+        stroke-linejoin: round;
+      }
+
+      .with-fill circle {
+        fill: #{{FILL}};
+      }
+    </style>
+  </defs>
+  <path d="M50.002 0C30.763 0 15 15.718 15 34.902c0 7.432 2.374 14.34 6.392 20.019L45.73 96.994c3.409 4.453 5.675 3.607 8.51-.235l26.843-45.683c.542-.981.967-2.026 1.338-3.092A34.446 34.446 0 0 0 85 34.902C85 15.718 69.24 0 50.002 0zm0 16.354c10.359 0 18.597 8.218 18.597 18.548c0 10.33-8.238 18.544-18.597 18.544c-10.36 0-18.601-8.215-18.601-18.544c0-10.33 8.241-18.548 18.6-18.548z" fill="#{{COLOR}}"></path>{{CIRCLE}}
+</svg>
+`
+
+class PoiIconRoute extends Route {
+  protected version: string;
+
+  constructor() {
+    super('/icons/poi.svg');
+  }
+
+  get = async (req: Request, res: Response) => {
+    const { color, size, classes, border, fill } = req.query;
+    let classesArray = new Set(classes?.toString().split(' ') || []);
+
+    if (border) {
+      classesArray.add('with-border');
+    }
+
+    if (fill) {
+      classesArray.add('with-fill');
+    }
+
+    const svg = poiSVGTemplate
+      .replaceAll('{{COLOR}}', color?.toString() || '000000')
+      .replaceAll('{{SIZE}}', size?.toString() || '100')
+      .replaceAll('{{CLASS}}', classesArray.size ? Array.from(classesArray).join(' ') : '')
+      .replaceAll('{{BORDER}}', border?.toString() || '000000')
+      .replaceAll('{{FILL}}', fill?.toString() || '000000')
+      .replaceAll('{{CIRCLE}}', fill ? circleSVGTemplate : '');
+
+    res.setHeader('Content-Type', 'image/svg+xml');
+    res.send(svg);
+  }
+}
+
+class IconRoutes extends Routes {
+  public routes = [new PoiIconRoute()];
+}
+
+export default IconRoutes;
diff --git a/src/routes/index.ts b/src/routes/index.ts
index 45118b8..d3a36c1 100644
--- a/src/routes/index.ts
+++ b/src/routes/index.ts
@@ -1,9 +1,15 @@
 import ApiRoutes from "./api";
+import IconRoutes from "./Icons";
 import Routes from "./Routes";
 
 class AllRoutes extends Routes {
   private api: ApiRoutes = new ApiRoutes();
-  public routes = [...this.api.routes];
+  private icons: IconRoutes = new IconRoutes();
+
+  public routes = [
+    ...this.api.routes,
+    ...this.icons.routes,
+  ];
 }
 
 export default AllRoutes;
diff --git a/tsconfig.json b/tsconfig.json
index d802ffc..b945d5c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,7 +2,13 @@
   "compilerOptions": {
     "target": "es2017",
     "module": "commonjs",
-    "lib": ["dom", "es6", "es2017", "esnext.asynciterable"],
+    "lib": [
+      "dom",
+      "es6",
+      "es2017",
+      "ES2021.String",
+      "esnext.asynciterable"
+    ],
     "skipLibCheck": true,
     "sourceMap": true,
     "outDir": "./dist",