diff --git a/httpdocs/css/base.css b/httpdocs/css/base.css index fd4fe7f..2041853 100644 --- a/httpdocs/css/base.css +++ b/httpdocs/css/base.css @@ -130,6 +130,8 @@ Neutral: #131211 --text: color-mix(in oklch, var(--neutral) 20%, black); --textOnColor: var(--neutral); --semiBg: #ffffffbb; + --contrastText: white; + --contrastBackground: black; --baseFontWeightModifier: 50; @@ -148,6 +150,9 @@ Neutral: #131211 --text: color-mix(in oklch, var(--neutral) 20%, white); --semiBg: #00000077; + --contrastText: black; + --contrastBackground: white; + --baseFontWeightModifier: -50; } } diff --git a/httpdocs/images/marker.svg b/httpdocs/images/marker.svg new file mode 100644 index 0000000..da4c914 --- /dev/null +++ b/httpdocs/images/marker.svg @@ -0,0 +1,4 @@ + + Marker Arrow + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 235b152..8ed1d87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,10 +13,12 @@ "@emotion/styled": "^11.11.5", "@mui/icons-material": "^5.16.5", "@mui/material": "^5.15.20", + "@types/leaflet-rotatedmarker": "^0.2.5", "axios": "^1.7.4", "bcrypt": "^5.1.1", "chalk": "^4.1.2", "compression": "^1.7.4", + "culori": "^4.0.1", "dotenv": "^16.4.5", "ejs": "^3.1.10", "express": "^4.19.2", @@ -28,6 +30,8 @@ "jsonwebtoken": "^9.0.2", "leaflet": "^1.9.4", "leaflet-defaulticon-compatibility": "^0.1.2", + "leaflet-polycolor": "^2.0.5", + "leaflet-rotatedmarker": "^0.2.0", "module-alias": "^2.2.3", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -40,6 +44,7 @@ "@tsconfig/node20": "^20.1.4", "@types/bcrypt": "^5.0.2", "@types/compression": "^1.7.5", + "@types/culori": "^2.1.1", "@types/express": "^4.17.21", "@types/hpp": "^0.2.5", "@types/jest": "^29.5.11", @@ -2090,6 +2095,13 @@ "@types/node": "*" } }, + "node_modules/@types/culori": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@types/culori/-/culori-2.1.1.tgz", + "integrity": "sha512-NzLYD0vNHLxTdPp8+RlvGbR2NfOZkwxcYGFwxNtm+WH2NuUNV8785zv1h0sulFQ5aFQ9n/jNDUuJeo3Bh7+oFA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "8.56.7", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.7.tgz", @@ -2144,7 +2156,6 @@ "version": "7946.0.14", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==", - "dev": true, "license": "MIT" }, "node_modules/@types/graceful-fs": { @@ -2230,12 +2241,20 @@ "version": "1.9.12", "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.12.tgz", "integrity": "sha512-BK7XS+NyRI291HIo0HCfE18Lp8oA30H1gpi1tf0mF3TgiCEzanQjOqNZ4x126SXzzi2oNSZhZ5axJp1k0iM6jg==", - "dev": true, "license": "MIT", "dependencies": { "@types/geojson": "*" } }, + "node_modules/@types/leaflet-rotatedmarker": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@types/leaflet-rotatedmarker/-/leaflet-rotatedmarker-0.2.5.tgz", + "integrity": "sha512-GaKK1bdQ6NYGkVdZj2cHe8Eu1BVf40Jhtmd8pZj5gQSJcTy5iTog0hsMIhf6QQDKnaEgrRJzm4OES6B9vxi4dw==", + "license": "MIT", + "dependencies": { + "@types/leaflet": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -4072,6 +4091,15 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, + "node_modules/culori": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/culori/-/culori-4.0.1.tgz", + "integrity": "sha512-LSnjA6HuIUOlkfKVbzi2OlToZE8OjFi667JWN9qNymXVXzGDmvuP60SSgC+e92sd7B7158f7Fy3Mb6rXS5EDPw==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -7493,6 +7521,21 @@ "integrity": "sha512-IrKagWxkTwzxUkFIumy/Zmo3ksjuAu3zEadtOuJcKzuXaD76Gwvg2Z1mLyx7y52ykOzM8rAH5ChBs4DnfdGa6Q==", "license": "BSD-2-Clause" }, + "node_modules/leaflet-polycolor": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/leaflet-polycolor/-/leaflet-polycolor-2.0.5.tgz", + "integrity": "sha512-nksE5PlCgzULil8rDzGfOnVH1o62GKyT4oLFyaqXUEidwcCMDvKr7x4DTCDdpUjiaoOLYBZTKwCT2XA0bfgExQ==", + "license": "MIT", + "dependencies": { + "leaflet": "^1.9.2" + } + }, + "node_modules/leaflet-rotatedmarker": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/leaflet-rotatedmarker/-/leaflet-rotatedmarker-0.2.0.tgz", + "integrity": "sha512-yc97gxLXwbZa+Gk9VCcqI0CkvIBC9oNTTjFsHqq4EQvANrvaboib4UdeQLyTnEqDpaXHCqzwwVIDHtvz2mUiDg==", + "license": "MIT" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", diff --git a/package.json b/package.json index ef6452b..d04e595 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@tsconfig/node20": "^20.1.4", "@types/bcrypt": "^5.0.2", "@types/compression": "^1.7.5", + "@types/culori": "^2.1.1", "@types/express": "^4.17.21", "@types/hpp": "^0.2.5", "@types/jest": "^29.5.11", @@ -67,10 +68,12 @@ "@emotion/styled": "^11.11.5", "@mui/icons-material": "^5.16.5", "@mui/material": "^5.15.20", + "@types/leaflet-rotatedmarker": "^0.2.5", "axios": "^1.7.4", "bcrypt": "^5.1.1", "chalk": "^4.1.2", "compression": "^1.7.4", + "culori": "^4.0.1", "dotenv": "^16.4.5", "ejs": "^3.1.10", "express": "^4.19.2", @@ -82,6 +85,8 @@ "jsonwebtoken": "^9.0.2", "leaflet": "^1.9.4", "leaflet-defaulticon-compatibility": "^0.1.2", + "leaflet-polycolor": "^2.0.5", + "leaflet-rotatedmarker": "^0.2.0", "module-alias": "^2.2.3", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/client/components/Map.tsx b/src/client/components/Map.tsx index c6e8eaf..bc8aeb3 100644 --- a/src/client/components/Map.tsx +++ b/src/client/components/Map.tsx @@ -1,22 +1,72 @@ import React, { useEffect } from 'react' -import { MapContainer, Marker, Popup, TileLayer, useMap } from 'react-leaflet' +import { MapContainer, Marker, Popup, TileLayer, useMap } from 'react-leaflet' +import leafletPolycolor from 'leaflet-polycolor'; +import { formatRgb, toGamut, parse, Oklch } from 'culori'; +import L, { LatLngExpression } from 'leaflet'; +import 'leaflet-rotatedmarker'; import 'leaflet/dist/leaflet.css'; -import 'leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.webpack.css'; // Re-uses images from ~leaflet package -// import L from 'leaflet'; -import 'leaflet-defaulticon-compatibility'; import "../css/map.css"; +leafletPolycolor(L); // Used to recenter the map to new coordinates const MapRecenter = ({ lat, lon, zoom }: { lat: number, lon: number, zoom: number }) => { const map = useMap(); - useEffect(() => { // Fly to that coordinates and set new zoom level map.flyTo([lat, lon], zoom); }, [lat, lon]); return null; +}; + +const MultiColorPolyline = ({ cleanEntries }: { cleanEntries: Models.IEntry[] }) => { + const map = useMap(); + const useRelativeColors = true; // Change candidate; Use color in range to maximum speed, like from 0 to max, rather than fixed range + + function calculateHue(baseHue, maxSpeed, currentSpeed) { + // range of currentSpeed and maxSpeed transfered to range from 0 to 360 + const hueOffset = (currentSpeed / maxSpeed) * 360; + // add baseHue to the hueOffset and overflow at 360 + const hue = (baseHue + hueOffset) % 360; + + return hue; + } + + useEffect(() => { + if (map) { + let maxSpeed = 0; + + if (useRelativeColors) { + maxSpeed = cleanEntries.reduce((maxSpeed, entry) => { + // compare the current entry's GPS speed with the maxSpeed found so far + return Math.max(maxSpeed, entry.speed.gps); + }, cleanEntries[0].speed.gps); + maxSpeed *= 3.6; // convert M/S to KM/h + } + + const colorsArray = cleanEntries.map((entry) => { + const startColor = parse('oklch(62.8% 0.2577 29.23)') as Oklch; // red + const currentSpeed = entry.speed.gps * 3.6; // convert to km/h + + startColor.h = calculateHue(startColor.h, maxSpeed, currentSpeed); + startColor.l = currentSpeed > maxSpeed * 0.8 ? startColor.l = currentSpeed / maxSpeed : startColor.l; + + const rgbInGamut = toGamut('rgb', 'oklch', null)(startColor); // map OKLCH to the RGB gamut + const colorRgb = formatRgb(rgbInGamut); // format the result as an RGB string + return colorRgb; + }); + + const polylineArray: LatLngExpression[] = cleanEntries.map((entry) => ([entry.lat, entry.lon])); + + L.polycolor(polylineArray, { + colors: colorsArray, + weight: 5 + }).addTo(map); + } + }, [map]); + + return null; }; function Map({ entries }: { entries: Models.IEntry[] }) { @@ -28,25 +78,68 @@ function Map({ entries }: { entries: Models.IEntry[] }) { const cleanEntries = entries.filter((entry) => !entry.ignore); + // Function to create custom icon with dynamic className + function createCustomIcon(entry: Models.IEntry) { + let className = ""; + let iconSize = 15; + if (entry.index == 0) { + className = "start" + } + if (entry == lastEntry) { + className = "end" + } + + if (className) { + iconSize = 22; + } + + return L.divIcon({ + html: ` + + Marker Arrow + + `, + shadowUrl: null, + shadowSize: null, + shadowAnchor: null, + iconSize: [iconSize, iconSize], + iconAnchor: [iconSize / 2, iconSize / 2], + popupAnchor: [0, 0], + className: `customMarkerIcon ${className}`, + }); + } + return ( - + {cleanEntries.map((entry) => { - console.log(entry.index); return ( - - -
{JSON.stringify(entry, null, 2)}
-
-
+
+ + +
{JSON.stringify(entry, null, 2)}
+
+
+ + +
) })} + + +
) } diff --git a/src/client/css/map.css b/src/client/css/map.css new file mode 100644 index 0000000..8bf913d --- /dev/null +++ b/src/client/css/map.css @@ -0,0 +1,37 @@ +.mapContainer { + height: 100%; +} + + +.leaflet-popup-content { + font-size: 1.2rem; + min-width: min-content; +} + +.leaflet-overlay-pane canvas { /* polyline */ + filter: drop-shadow(0px 0px 3px var(--neutral)); +} + + +.customMarkerIcon { + + &.start, &.end { + display: flex; + place-content: center; + border: 2px solid var(--contrastBackground); + outline: 3px solid var(--contrastBackground); + outline-offset: 3px; + border-radius: 50%; + background: var(--contrastBackground); + + svg { + height: 80%; + } + } + + &.start { + outline: none; + } + + +} \ No newline at end of file diff --git a/src/client/tsconfig.json b/src/client/tsconfig.json index 7777c58..953d117 100644 --- a/src/client/tsconfig.json +++ b/src/client/tsconfig.json @@ -6,5 +6,5 @@ "jsx": "react", "esModuleInterop": true }, - "include": ["**/*.tsx", "**/*.ts", "types.d.ts", "../../types.d.ts"] + "include": ["**/*.tsx", "**/*.ts", "types.d.ts", "../../types.d.ts", "types_polyline.d.ts"] } \ No newline at end of file diff --git a/src/client/types_polyline.d.ts b/src/client/types_polyline.d.ts new file mode 100644 index 0000000..f955d35 --- /dev/null +++ b/src/client/types_polyline.d.ts @@ -0,0 +1,14 @@ +// Polyline plugin for native Leaflet +import * as L from 'leaflet'; + +declare module 'leaflet' { + namespace Polycolor { + interface Options { + colors: Array; + weight?: number; + } + } + + // Declare the actual polycolor function under the L namespace + function polycolor(latlngs: L.LatLngExpression[], options: Polycolor.Options): L.Polyline; +}