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",