Repository: osmandapp/web
Pull request #716: add track appearance tags
Author: login: alisa911
Created: 2025-03-25T13:26:42Z | Merged: 2025-03-27T09:23:31Z



Diff:
diff --git a/map/src/context/TracksRoutingCache.js b/map/src/context/TracksRoutingCache.js
index 5a696de90a..cd906a4496 100644
--- a/map/src/context/TracksRoutingCache.js
+++ b/map/src/context/TracksRoutingCache.js
@@ -1,6 +1,7 @@
 import _ from 'lodash';
 import TracksManager from '../manager/track/TracksManager';
 import EditablePolyline from '../map/util/creator/EditablePolyline';
+import { DEFAULT_TRACK_LINE_WEIGHT } from '../map/util/TrackLayerProvider';
 
 const MAX_STARTED_ROUTER_JOBS = 6;
 export const GET_ANALYSIS_DEBOUNCE_MS = 1000; // don't flood get-analysis
@@ -183,7 +184,7 @@ function refreshTempLine({ ctx, geometry, track, tempLine, color }) {
     // don't destroy tempLine (empty geo)
     if (geometry && geometry.length > 0) {
         const polyline = new EditablePolyline(null, ctx, geometry, null, track).create(); // latlngs-only
-        tempLine.setStyle({ color, dashArray: null });
+        tempLine.setStyle({ color, dashArray: null, weight: DEFAULT_TRACK_LINE_WEIGHT });
         tempLine.setLatLngs(polyline._latlngs);
         tempLine.options.name = undefined;
     } else {
diff --git a/map/src/map/layers/LocalClientTrackLayer.js b/map/src/map/layers/LocalClientTrackLayer.js
index 8db51699ae..0ad346f4b9 100644
--- a/map/src/map/layers/LocalClientTrackLayer.js
+++ b/map/src/map/layers/LocalClientTrackLayer.js
@@ -708,7 +708,7 @@ export default function LocalClientTrackLayer() {
                             createNewRouteLine(prevPoint, newPoint, points, layers);
                         } else {
                             ctxTrack.hasGeo = true;
-                            let tempLine = TrackLayerProvider.createEditableTempLPolyline(
+                            const tempLine = TrackLayerProvider.createEditableTempLPolyline(
                                 prevPoint,
                                 newPoint,
                                 map,
diff --git a/map/src/map/util/TrackLayerProvider.js b/map/src/map/util/TrackLayerProvider.js
index aeb704eb98..de1b8c3cdc 100644
--- a/map/src/map/util/TrackLayerProvider.js
+++ b/map/src/map/util/TrackLayerProvider.js
@@ -17,12 +17,24 @@ export const WPT_SIMPLIFY_THRESHOLD = 500;
 export const POINTS_SIMPLIFY_THRESHOLD = 1000;
 export const POINTS_SIMPLIFY_ZOOM_THRESHOLD = 17;
 
+export const DEFAULT_TRACK_LINE_WEIGHT = 7;
+
 function createLayersByTrackData({ data, ctx, map, groupId, type = GPX_FILE_TYPE, simplifyWpts = false }) {
-    let layers = [];
+    const layers = [];
+    const trackAppearance = data.trackAppearance;
     data.tracks?.forEach((track) => {
         if (track.points?.length > 0) {
-            let res = parsePoints({ map, ctx, points: track.points, layers, hidden: true });
-            addStartEnd(track.points, layers, res.coordsTrk, res.coordsAll);
+            const res = parsePoints({
+                map,
+                ctx,
+                points: track.points,
+                layers,
+                hidden: true,
+                trackAppearance,
+            });
+            if (trackAppearance?.showStartFinish !== false) {
+                addStartEnd(track.points, layers, res.coordsTrk, res.coordsAll);
+            }
         }
     });
     parseWpt({ points: data.wpts, layers, ctx, data, map, simplify: simplifyWpts, groupId });
@@ -34,12 +46,22 @@ function createLayersByTrackData({ data, ctx, map, groupId, type = GPX_FILE_TYPE
     }
 }
 
-function parsePoints({ map, ctx, points, layers, draggable = false, hidden = false }) {
+function parsePoints({ map, ctx, points, layers, draggable = false, hidden = false, trackAppearance = null }) {
     let coordsTrk = [];
     let coordsAll = [];
     points.forEach((point, index) => {
         if (point.geometry !== undefined && point.geometry !== null) {
-            coordsAll = drawRoutePoints({ map, points, point, coordsAll, layers, ctx, draggable, index });
+            coordsAll = drawRoutePoints({
+                map,
+                points,
+                point,
+                coordsAll,
+                layers,
+                ctx,
+                draggable,
+                index,
+                trackAppearance,
+            });
         } else {
             coordsTrk.push(new L.LatLng(point.lat, point.lng));
             if (point.profile === TracksManager.PROFILE_GAP && coordsTrk.length > 0) {
@@ -104,7 +126,7 @@ function addStartEnd(points, layers, coordsTrk, coordsAll) {
     }
 }
 
-function drawRoutePoints({ map, ctx, points, point, coordsAll, layers, draggable, index }) {
+function drawRoutePoints({ map, ctx, points, point, coordsAll, layers, draggable, index, trackAppearance }) {
     let coords = [];
 
     // draw tempLine for orphaned empty geo but not for gap
@@ -137,7 +159,7 @@ function drawRoutePoints({ map, ctx, points, point, coordsAll, layers, draggable
         if (p.profile === TracksManager.PROFILE_GAP && coords.length > 0) {
             addStartEndGap(point, points, layers, draggable);
             coords.push(new L.LatLng(p.lat, p.lng));
-            layers.push(createPolyline(coords, ctx, point, points));
+            layers.push(createPolyline({ coords, ctx, map, point, points, trackAppearance }));
             coordsAll = coordsAll.concat(Object.assign([], coords));
             coords = [];
         } else {
@@ -147,22 +169,169 @@ function drawRoutePoints({ map, ctx, points, point, coordsAll, layers, draggable
 
     if (coords.length > 0) {
         coordsAll = coordsAll.concat(Object.assign([], coords));
-        layers.push(createPolyline(coords, ctx, point, points));
+        layers.push(createPolyline({ coords, ctx, map, point, points, trackAppearance }));
     }
 
     return coordsAll;
 }
 
-function createPolyline(coords, ctx, point, points) {
-    let polyline = new L.Polyline(coords, getPolylineOpt());
-    if (ctx) {
-        polyline.setStyle({
-            color: ctx.trackRouter.getColor(getPointGeoProfile(point, points)),
+function createPolyline({ coords, ctx, map, point, points, trackAppearance }) {
+    const color = trackAppearance?.color ?? ctx.trackRouter.getColor(getPointGeoProfile(point, points));
+    const width = trackAppearance?.width ?? 'medium';
+    const arrowSettings = {
+        show: trackAppearance?.showArrows,
+        color: 'white',
+    };
+    const polyline = new L.Polyline(coords, {
+        color,
+        weight: getPolylineWeight(width, map.getZoom()),
+        ...(arrowSettings.show ? { renderer: L.svg() } : {}),
+    });
+
+    if (!arrowSettings.show) return polyline;
+
+    polyline.on('add', function () {
+        renderArrows({ polyline, lineWidth: width, coords, map, arrowSettings });
+
+        const updateStyle = () => {
+            const z = map.getZoom();
+            polyline.setStyle({
+                weight: getPolylineWeight(width, z),
+            });
+            renderArrows({ polyline, lineWidth: width, coords, map, arrowSettings });
+        };
+
+        map.on('zoomend moveend', updateStyle);
+
+        polyline.on('redraw-arrows', updateStyle);
+
+        polyline.on('remove', () => {
+            map.off('zoomend moveend', updateStyle);
+            const svg = polyline._renderer?._container;
+            if (svg) {
+                svg.querySelectorAll('.arrowhead').forEach((el) => el.remove());
+            }
         });
-    }
+    });
+
     return polyline;
 }
 
+function renderArrows({ polyline, lineWidth, coords, map, arrowSettings }) {
+    const step = getArrowStep(map.getZoom());
+    const size = getArrowSize(map.getZoom());
+    const width = getArrowWidth(lineWidth, map.getZoom());
+
+    const svg = polyline._renderer._container;
+
+    svg.querySelectorAll('.arrowhead').forEach((el) => el.remove());
+
+    const pixelPoints = coords.map((latlng) => map.latLngToLayerPoint(latlng));
+
+    const segments = [];
+    let totalLength = 0;
+    for (let i = 0; i < pixelPoints.length - 1; i++) {
+        const p1 = pixelPoints[i];
+        const p2 = pixelPoints[i + 1];
+        const dx = p2.x - p1.x;
+        const dy = p2.y - p1.y;
+        const length = Math.hypot(dx, dy);
+        segments.push({ p1, p2, length });
+        totalLength += length;
+    }
+
+    const count = Math.floor(totalLength / step);
+    for (let i = 1; i <= count; i++) {
+        const dist = i * step;
+        let acc = 0;
+        for (let seg of segments) {
+            if (acc + seg.length >= dist) {
+                const t = (dist - acc) / seg.length;
+                const x = seg.p1.x + (seg.p2.x - seg.p1.x) * t;
+                const y = seg.p1.y + (seg.p2.y - seg.p1.y) * t;
+                const angle = (Math.atan2(seg.p2.y - seg.p1.y, seg.p2.x - seg.p1.x) * 180) / Math.PI;
+
+                const aw = width;
+                const path = [`M ${-size} ${-aw}`, `L 0 0`, `L ${-size} ${aw}`, `L ${-size + 2} 0`, `Z`].join(' ');
+
+                const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+                arrow.setAttribute('class', 'arrowhead');
+                arrow.setAttribute('d', path);
+                arrow.setAttribute('fill', arrowSettings.color);
+                arrow.setAttribute('transform', `translate(${x}, ${y}) rotate(${angle})`);
+                svg.appendChild(arrow);
+                break;
+            }
+            acc += seg.length;
+        }
+    }
+}
+
+function getPolylineWeight(width, zoom) {
+    if (typeof width === 'number') {
+        const scale = Math.min(1, zoom / 12);
+        return Math.max(1, Math.round(width * scale));
+    }
+    if (width === 'thin') {
+        if (zoom <= 10) return 2;
+        if (zoom <= 13) return 3;
+        if (zoom <= 15) return 4;
+        return 5;
+    } else if (width === 'medium') {
+        return getDefaultWeight(zoom);
+    } else if (width === 'bold') {
+        if (zoom <= 10) return 2;
+        if (zoom <= 13) return 6;
+        if (zoom <= 15) return 9;
+        return 11;
+    } else {
+        return getDefaultWeight(zoom);
+    }
+}
+
+function getDefaultWeight(zoom) {
+    if (zoom <= 10) return 2;
+    if (zoom <= 13) return 5;
+    if (zoom <= 15) return 6;
+    return 7;
+}
+
+function getArrowStep(zoom) {
+    if (zoom <= 13) return 10;
+    if (zoom <= 15) return 20;
+    return 30;
+}
+
+function getArrowSize(zoom) {
+    if (zoom <= 13) return 5;
+    if (zoom <= 15) return 8;
+    return 10;
+}
+
+function getArrowWidth(width, zoom) {
+    if (typeof width === 'number') {
+        const scale = Math.min(1, zoom / 12);
+        return Math.max(1, Math.round(width * 0.25 * scale));
+    }
+    if (width === 'thin') {
+        return 1;
+    } else if (width === 'medium') {
+        return getDefaultArrowWidth(zoom);
+    } else if (width === 'bold') {
+        if (zoom <= 13) return 2;
+        if (zoom <= 15) return 3;
+        return 3;
+    } else {
+        return getDefaultArrowWidth(zoom);
+    }
+}
+
+function getDefaultArrowWidth(zoom) {
+    if (zoom <= 13) return 1;
+    if (zoom <= 15) return 2;
+    return 2;
+}
+
 function addStartEndGap(point, allPoints, layers, editTrack) {
     if (!editTrack) {
         let end = new L.LatLng(point.lat, point.lng);


Reviews:
user: login: RZR-UA
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T14:45:09Z
user: login: RZR-UA
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T14:47:10Z
user: login: RZR-UA
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T14:48:15Z
user: login: RZR-UA
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T14:59:21Z

Review comments:
diff_hunk: @@ -183,7 +183,7 @@ function refreshTempLine({ ctx, geometry, track, tempLine, color }) {
     // don't destroy tempLine (empty geo)
     if (geometry && geometry.length > 0) {
         const polyline = new EditablePolyline(null, ctx, geometry, null, track).create(); // latlngs-only
-        tempLine.setStyle({ color, dashArray: null });
+        tempLine.setStyle({ color, dashArray: null, weight: 7 });
path: map/src/context/TracksRoutingCache.js
user: login: RZR-UA
body: What is 7? If it is the default width(weight), please add const.
created_at: 2025-03-25T14:45:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -18,11 +18,21 @@ export const POINTS_SIMPLIFY_THRESHOLD = 1000;
 export const POINTS_SIMPLIFY_ZOOM_THRESHOLD = 17;
 
 function createLayersByTrackData({ data, ctx, map, groupId, type = GPX_FILE_TYPE, simplifyWpts = false }) {
-    let layers = [];
+    const layers = [];
+    const trackApp = data.trackAppearance;
     data.tracks?.forEach((track) => {
         if (track.points?.length > 0) {
-            let res = parsePoints({ map, ctx, points: track.points, layers, hidden: true });
-            addStartEnd(track.points, layers, res.coordsTrk, res.coordsAll);
+            const res = parsePoints({
+                map,
+                ctx,
+                points: track.points,
+                layers,
+                hidden: true,
+                trackApp,
+            });
+            if (trackApp?.showStartFinish !== false) {
path: map/src/map/util/TrackLayerProvider.js
user: login: RZR-UA
body: `if (trackApp?.showStartFinish !== false)` slightly weird condition. `undefined !== false`...
created_at: 2025-03-25T14:47:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -18,11 +18,21 @@ export const POINTS_SIMPLIFY_THRESHOLD = 1000;
 export const POINTS_SIMPLIFY_ZOOM_THRESHOLD = 17;
 
 function createLayersByTrackData({ data, ctx, map, groupId, type = GPX_FILE_TYPE, simplifyWpts = false }) {
-    let layers = [];
+    const layers = [];
+    const trackApp = data.trackAppearance;
path: map/src/map/util/TrackLayerProvider.js
user: login: RZR-UA
body: Rename to trackAppearance (trackApp read as "track application")
created_at: 2025-03-25T14:48:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -147,22 +157,168 @@ function drawRoutePoints({ map, ctx, points, point, coordsAll, layers, draggable
 
     if (coords.length > 0) {
         coordsAll = coordsAll.concat(Object.assign([], coords));
-        layers.push(createPolyline(coords, ctx, point, points));
+        layers.push(createPolyline({ coords, ctx, map, point, points, trackApp }));
     }
 
     return coordsAll;
 }
 
-function createPolyline(coords, ctx, point, points) {
-    let polyline = new L.Polyline(coords, getPolylineOpt());
-    if (ctx) {
-        polyline.setStyle({
-            color: ctx.trackRouter.getColor(getPointGeoProfile(point, points)),
+function createPolyline({ coords, ctx, map, point, points, trackApp }) {
+    const color = trackApp?.color ?? ctx.trackRouter.getColor(getPointGeoProfile(point, points));
+    const width = trackApp?.width ?? 'medium';
+    const arrowSettings = {
+        show: trackApp?.showArrows,
+        color: 'white',
+    };
+    const polyline = new L.Polyline(coords, {
+        color,
+        weight: getPolylineWeight(width, map.getZoom()),
+        ...(arrowSettings.show ? { renderer: L.svg() } : {}),
+    });
+
+    if (!arrowSettings.show) return polyline;
+
+    polyline.on('add', function () {
+        renderArrows({ polyline, lineWidth: width, coords, map, arrowSettings });
+
+        const updateStyle = () => {
+            const z = map.getZoom();
+            polyline.setStyle({
+                weight: getPolylineWeight(width, z),
+            });
+            renderArrows({ polyline, lineWidth: width, coords, map, arrowSettings });
+        };
+
+        map.on('zoomend moveend', updateStyle);
+
+        polyline.on('redraw-arrows', updateStyle);
+
+        polyline.on('remove', () => {
+            map.off('zoomend moveend', updateStyle);
+            const svg = polyline._renderer?._container;
+            if (svg) {
+                svg.querySelectorAll('.arrowhead').forEach((el) => el.remove());
+            }
         });
-    }
+    });
+
     return polyline;
 }
 
+function renderArrows({ polyline, lineWidth, coords, map, arrowSettings }) {
+    const step = getArrowStep(map.getZoom());
+    const size = getArrowSize(map.getZoom());
+    const width = getArrowWidth(lineWidth, map.getZoom());
+
+    const svg = polyline._renderer._container;
+
+    svg.querySelectorAll('.arrowhead').forEach((el) => el.remove());
+
+    const pixelPoints = coords.map((latlng) => map.latLngToLayerPoint(latlng));
+
+    const segments = [];
+    let totalLength = 0;
+    for (let i = 0; i < pixelPoints.length - 1; i++) {
+        const p1 = pixelPoints[i];
+        const p2 = pixelPoints[i + 1];
+        const dx = p2.x - p1.x;
+        const dy = p2.y - p1.y;
+        const length = Math.hypot(dx, dy);
+        segments.push({ p1, p2, length });
+        totalLength += length;
+    }
+
+    const count = Math.floor(totalLength / step);
+    for (let i = 1; i <= count; i++) {
+        const dist = i * step;
+        let acc = 0;
+        for (let seg of segments) {
+            if (acc + seg.length >= dist) {
+                const t = (dist - acc) / seg.length;
+                const x = seg.p1.x + (seg.p2.x - seg.p1.x) * t;
+                const y = seg.p1.y + (seg.p2.y - seg.p1.y) * t;
+                const angle = (Math.atan2(seg.p2.y - seg.p1.y, seg.p2.x - seg.p1.x) * 180) / Math.PI;
+
+                const aw = width;
+                const path = [`M ${-size} ${-aw}`, `L 0 0`, `L ${-size} ${aw}`, `L ${-size + 2} 0`, `Z`].join(' ');
+
+                const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+                arrow.setAttribute('class', 'arrowhead');
+                arrow.setAttribute('d', path);
+                arrow.setAttribute('fill', arrowSettings.color);
+                arrow.setAttribute('transform', `translate(${x}, ${y}) rotate(${angle})`);
+                svg.appendChild(arrow);
+                break;
+            }
+            acc += seg.length;
+        }
+    }
+}
+
+function getPolylineWeight(width, zoom) {
+    if (typeof width === 'number') {
+        const scale = Math.min(1, zoom / 12);
+        return Math.max(1, Math.round(width * scale));
+    }
+    if (width === 'thin') {
+        if (zoom <= 10) return 2;
+        if (zoom <= 13) return 3;
+        if (zoom <= 15) return 4;
+        return 5;
+    } else if (width === 'medium') {
+        if (zoom <= 10) return 2;
+        if (zoom <= 13) return 5;
+        if (zoom <= 15) return 6;
+        return 7;
path: map/src/map/util/TrackLayerProvider.js
user: login: RZR-UA
body: might be removed because meduim is default
created_at: 2025-03-25T14:59:21Z
author_association: CONTRIBUTOR

PR comments:
user: login: alisa911
created_at: 2025-03-25T13:26:51Z
body: https://github.com/osmandapp/OsmAnd/issues/21550
author_association: CONTRIBUTOR
user: login: alisa911
created_at: 2025-03-26T14:52:49Z
body: https://github.com/osmandapp/web/issues/711
author_association: CONTRIBUTOR