diff --git a/app/javascript/maplibre/layers/geojson.js b/app/javascript/maplibre/layers/geojson.js index ca166855..b783944b 100644 --- a/app/javascript/maplibre/layers/geojson.js +++ b/app/javascript/maplibre/layers/geojson.js @@ -1,7 +1,7 @@ import { buffer } from "@turf/buffer" import { draw, select } from 'maplibre/edit' import { initializeKmMarkerStyles, renderKmMarkers } from 'maplibre/layers/geojson/km_markers' -import { renderRouteExtras } from 'maplibre/layers/geojson/route_extras' +import { initializeExtrasLabelStyles, renderRouteExtras } from 'maplibre/layers/geojson/route_extras' import { Layer } from 'maplibre/layers/layer' import { getFeature } from 'maplibre/layers/layers' import { addGeoJSONSource, map, mapProperties } from 'maplibre/map' @@ -27,6 +27,7 @@ export class GeoJSONLayer extends Layer { if (this.layer.cluster) { initializeClusterStyles(this.sourceId, null) } initializeKmMarkerStyles(this.kmMarkerSourceId) initializeViewStyles(this.routeExtrasSourceId) + initializeExtrasLabelStyles(this.routeExtrasSourceId) // Override line-cap to 'butt' for route extras line layer to prevent color overlap at segment junctions // Keep outline as 'round' to ensure continuous white border diff --git a/app/javascript/maplibre/layers/geojson/km_markers.js b/app/javascript/maplibre/layers/geojson/km_markers.js index ae7b7aea..61c6d895 100644 --- a/app/javascript/maplibre/layers/geojson/km_markers.js +++ b/app/javascript/maplibre/layers/geojson/km_markers.js @@ -2,7 +2,40 @@ import { along } from "@turf/along" import { lineString } from "@turf/helpers" import { length } from "@turf/length" import { map, removeStyleLayers } from 'maplibre/map' -import { featureColor, labelFont, setSource, styles } from 'maplibre/styles/styles' +import { featureColor, labelFont, setSource } from 'maplibre/styles/styles' + +function createKmMarkerImage (color) { + const imageName = `km-marker-circle-${color.replace('#', '')}` + + if (!map.hasImage(imageName)) { + const size = 32 + const canvas = document.createElement('canvas') + canvas.width = size + canvas.height = size + const ctx = canvas.getContext('2d') + + const centerX = size / 2 + const centerY = size / 2 + const radius = size / 2 - 3 + + // Draw gray border + ctx.strokeStyle = '#CCC' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.arc(centerX, centerY, radius, 0, Math.PI * 2) + ctx.stroke() + + // Draw filled circle + ctx.fillStyle = color + ctx.beginPath() + ctx.arc(centerX, centerY, radius - 1, 0, Math.PI * 2) + ctx.fill() + + map.addImage(imageName, ctx.getImageData(0, 0, size, size)) + } + + return imageName +} export function renderKmMarkers (features, sourceId) { let kmMarkerFeatures = [] @@ -12,10 +45,14 @@ export function renderKmMarkers (features, sourceId) { const line = lineString(f.geometry.coordinates) const distance = length(line, { units: 'kilometers' }) + const markerColor = f.properties['stroke'] || featureColor + const markerImageName = createKmMarkerImage(markerColor) + let interval = 1 for (let i = 0; i < Math.ceil(distance) + interval; i += interval) { const point = along(line, i, { units: 'kilometers' }) - point.properties['marker-color'] = f.properties['stroke'] || featureColor + point.properties['marker-color'] = markerColor + point.properties['marker-image'] = markerImageName point.properties['marker-size'] = 11 point.properties['marker-opacity'] = 1 point.properties['km'] = i @@ -45,6 +82,7 @@ export function renderKmMarkers (features, sourceId) { export function initializeKmMarkerStyles (sourceId) { removeStyleLayers(sourceId) + kmMarkerStyles().forEach(style => { style = setSource(style, sourceId) map.addLayer(style) @@ -54,43 +92,38 @@ export function initializeKmMarkerStyles (sourceId) { function kmMarkerStyles () { let styleLayers = [] - styleLayers.push(makePointsLayer(1, 14)) - styleLayers.push(makeNumbersLayer(1, 14)) - styleLayers.push(makePointsLayer(2, 11, 14)) - styleLayers.push(makeNumbersLayer(2, 11, 14)) - styleLayers.push(makePointsLayer(5, 10, 11)) - styleLayers.push(makeNumbersLayer(5, 10, 11)) - styleLayers.push(makePointsLayer(10, 9, 10)) - styleLayers.push(makeNumbersLayer(10, 9, 10)) - styleLayers.push(makePointsLayer(25, 8, 9)) - styleLayers.push(makeNumbersLayer(25, 8, 9)) - styleLayers.push(makePointsLayer(50, 7, 8)) - styleLayers.push(makeNumbersLayer(50, 7, 8)) - styleLayers.push(makePointsLayer(100, 5, 7)) - styleLayers.push(makeNumbersLayer(100, 5, 7)) - - const base = { ...styles()['points-layer'] } - styleLayers.push({ - ...base, - id: `km-marker-points-end`, - filter: ["==", ["get", "km-marker-numbers-end"], 1] - }) + // Combined marker layers (icon + text in one symbol layer) + styleLayers.push(makeKmMarkerLayer(1, 14)) + styleLayers.push(makeKmMarkerLayer(2, 12, 14)) + styleLayers.push(makeKmMarkerLayer(5, 10, 12)) + styleLayers.push(makeKmMarkerLayer(10, 9, 10)) + styleLayers.push(makeKmMarkerLayer(25, 8, 9)) + styleLayers.push(makeKmMarkerLayer(50, 7, 8)) + styleLayers.push(makeKmMarkerLayer(100, 5, 7)) + + // End marker (total distance) - combined icon + text styleLayers.push({ - id: `km-marker-numbers-end`, + id: `km-marker-end`, type: 'symbol', filter: ["==", ["get", "km-marker-numbers-end"], 1], layout: { - 'text-allow-overlap': true, + 'icon-image': ['get', 'marker-image'], + 'icon-size': ['/', ['get', 'marker-size'], 14], + 'icon-allow-overlap': false, + 'icon-padding': 0, + 'text-allow-overlap': false, + 'text-padding': 0, 'text-field': ['format', ['get', 'km'], { 'font-scale': 1.0 }, ['concat', '\n', ['get', 'km-unit']], { 'font-scale': 0.7 } ], 'text-size': 12, - 'text-font': ['noto_sans_bold'], + 'text-font': labelFont, 'text-justify': 'center', 'text-anchor': 'center', 'text-line-height': 1.0, - 'text-offset': [0, 0.3] + 'text-offset': [0, 0.3], + 'symbol-sort-key': 20 }, paint: { 'text-color': '#ffffff' @@ -100,31 +133,26 @@ function kmMarkerStyles () { return styleLayers } -function makePointsLayer (divisor, minzoom, maxzoom = 24) { - const base = { ...styles()['points-layer'] } +function makeKmMarkerLayer (divisor, minzoom, maxzoom = 24) { return { - ...base, - id: `km-marker-points-${divisor}`, - filter: ["==", ["%", ["get", "km"], divisor], 0], - minzoom, - maxzoom - } -} - -function makeNumbersLayer (divisor, minzoom, maxzoom = 24) { - return { - id: `km-marker-numbers-${divisor}`, + id: `km-marker-${divisor}`, type: 'symbol', filter: ["==", ["%", ["get", "km"], divisor], 0], minzoom, maxzoom, layout: { + 'icon-image': ['get', 'marker-image'], + 'icon-size': ['/', ['get', 'marker-size'], 14], + 'icon-allow-overlap': false, + 'icon-padding': 0, 'text-allow-overlap': false, + 'text-padding': 0, 'text-field': ['get', 'km'], 'text-size': 11, 'text-font': labelFont, 'text-justify': 'center', - 'text-anchor': 'center' + 'text-anchor': 'center', + 'symbol-sort-key': 10 }, paint: { 'text-color': '#ffffff' diff --git a/app/javascript/maplibre/layers/geojson/route_extras.js b/app/javascript/maplibre/layers/geojson/route_extras.js index 0bf8dfa5..04907dc7 100644 --- a/app/javascript/maplibre/layers/geojson/route_extras.js +++ b/app/javascript/maplibre/layers/geojson/route_extras.js @@ -2,6 +2,25 @@ import { buffer } from "@turf/buffer" import { distance } from "@turf/distance" import { point } from "@turf/helpers" import { map } from 'maplibre/map' +import { labelFont } from 'maplibre/styles/styles' + +// Steepness value to percentage range mapping for labels +const STEEPNESS_RANGES = { + 3: '7-11%', + 4: '12-15%', + 5: '>16%' +} + +// Precompute cumulative distances for a coordinate array to enable O(1) segment length lookups +function computeCumulativeDistances (coords) { + const cumulative = new Array(coords.length) + cumulative[0] = 0 + for (let i = 1; i < coords.length; i++) { + const segmentDist = distance(point(coords[i - 1]), point(coords[i]), { units: 'meters' }) + cumulative[i] = cumulative[i - 1] + segmentDist + } + return cumulative +} // ORS route extras color configurations // Each type maps values to colors and labels for data-driven styling @@ -76,6 +95,68 @@ export function computeExtrasTotals (feature, extrasType) { return { type: extrasType, config, totals, totalDistance } } +// Create point features with labels for route extras segments +function createExtrasLabelFeatures (coords, extrasValues, extrasType, cumulativeDistances) { + const labelFeatures = [] + + extrasValues.forEach(([startIdx, endIdx, value]) => { + // Type-specific filtering + if (extrasType === 'steepness' && Math.abs(value) < 3) return + if (endIdx <= startIdx || startIdx >= coords.length) return + + const end = Math.min(endIdx, coords.length - 1) + const firstCoord = coords[startIdx] + const lastCoord = coords[end] + + // O(1) segment length lookup using precomputed cumulative distances + const segmentLength = cumulativeDistances[end] - cumulativeDistances[startIdx] + + // Skip very short segments + if (segmentLength < 50) return + + // Compute midpoint by averaging first and last coordinates + const midpoint = [ + (firstCoord[0] + lastCoord[0]) / 2, + (firstCoord[1] + lastCoord[1]) / 2 + ] + + // Format distance label + let distanceLabel + if (segmentLength >= 1000) { + distanceLabel = `${(segmentLength / 1000).toFixed(1)} km` + } else { + distanceLabel = `${Math.round(segmentLength)} m` + } + + // Type-specific label formatting + let label, priority + if (extrasType === 'steepness') { + const rangeLabel = STEEPNESS_RANGES[Math.abs(value)] + const arrow = value > 0 ? '▲' : '▼' + label = `${arrow} ${rangeLabel}\n${distanceLabel}` + priority = Math.abs(value) + } else if (extrasType === 'surface') { + const surfaceName = EXTRAS_COLOR_CONFIGS.surface.labels[String(value)] || 'Unknown' + label = `${surfaceName}\n${distanceLabel}` + priority = segmentLength + } else { + return // Unknown type + } + + labelFeatures.push({ + type: 'Feature', + geometry: { type: 'Point', coordinates: midpoint }, + properties: { + 'route-extras-label': label, + 'route-extras-color': resolveExtrasColor(extrasType, value), + 'route-extras-priority': priority + } + }) + }) + + return labelFeatures +} + // Show/hide the map legend for route extras export function showExtrasLegend (extrasType, activeValues) { let container = document.getElementById('route-extras-legend') @@ -160,6 +241,12 @@ export function renderRouteExtras (features, sourceId) { if (!extrasData?.values) return const coords = feature.geometry.coordinates + + // Precompute cumulative distances once for O(1) segment length lookups in label creation + const cumulativeDistances = (extrasType === 'steepness' || extrasType === 'surface') + ? computeCumulativeDistances(coords) + : null + extrasData.values.forEach(([startIdx, endIdx, value]) => { if (endIdx <= startIdx || startIdx >= coords.length) return const segment = coords.slice(startIdx, Math.min(endIdx + 1, coords.length)) @@ -177,6 +264,12 @@ export function renderRouteExtras (features, sourceId) { } }) }) + + // Add labels for steepness or surface segments + if (extrasType === 'steepness' || extrasType === 'surface') { + const labelFeatures = createExtrasLabelFeatures(coords, extrasData.values, extrasType, cumulativeDistances) + extrasFeatures.push(...labelFeatures) + } }) // Filter activeValues for discrete legends: remove values < 0.5% of total distance @@ -225,3 +318,32 @@ export function renderRouteExtras (features, sourceId) { features: extrasFeatures.concat(extrusionFeatures) }) } + +export function initializeExtrasLabelStyles (sourceId) { + const layerId = `route-extras-labels_${sourceId}` + if (map.getLayer(layerId)) return + + map.addLayer({ + id: layerId, + source: sourceId, + type: 'symbol', + filter: ['has', 'route-extras-label'], + layout: { + 'text-field': ['get', 'route-extras-label'], + 'text-font': labelFont, + 'text-size': 11, + 'text-allow-overlap': false, + 'text-ignore-placement': false, + 'text-anchor': 'center', + 'text-justify': 'center', + 'text-padding': 0, + // on collision, show the highest priority (steepest grade or longest surface segment) + 'symbol-sort-key': ['-', 10, ['get', 'route-extras-priority']] + }, + paint: { + 'text-color': '#ffffff', + 'text-halo-color': ['get', 'route-extras-color'], + 'text-halo-width': 2 + } + }) +} diff --git a/app/javascript/maplibre/map.js b/app/javascript/maplibre/map.js index 1aa7e9c3..76605a38 100644 --- a/app/javascript/maplibre/map.js +++ b/app/javascript/maplibre/map.js @@ -526,7 +526,9 @@ export function sortLayers () { // console.log('Sorting layers', layers) const userExtrusions = functions.reduceArray(layers, (e) => e.paint && e.paint['fill-extrusion-height'] && e.id.startsWith('polygon-layer-extrusion')) const flatLayers = functions.reduceArray(layers, (e) => (e.id.includes('-flat'))) // keep flat layers behin houses - const routeExtras = functions.reduceArray(layers, (e) => (e.id.includes('route-extras-source'))) + const routeExtras = functions.reduceArray(layers, (e) => (e.id.includes('route-extras-source') && !e.id.startsWith('route-extras-labels'))) + const extrasLabels = functions.reduceArray(layers, (e) => (e.id.startsWith('route-extras-labels'))) + const kmEndMarkers = functions.reduceArray(layers, (e) => (e.id.startsWith('km-marker-end'))) const kmMarkers = functions.reduceArray(layers, (e) => (e.id.startsWith('km-marker'))) const editLayer = functions.reduceArray(layers, (e) => (e.id.startsWith('gl-draw-'))) const userSymbols = functions.reduceArray(layers, (e) => (e.id.startsWith('symbols-layer') || e.id.startsWith('symbols-border-layer'))) @@ -541,7 +543,7 @@ export function sortLayers () { layers = layers.concat(flatLayers).concat(lineLayers).concat(routeExtras).concat(userExtrusions).concat(mapExtrusions).concat(directions) .concat(mapSymbols).concat(points).concat(heatmap).concat(editLayer) - .concat(kmMarkers).concat(userSymbols).concat(userLabels).concat(lineLayerHits).concat(pointsLayerHits) + .concat(kmMarkers).concat(extrasLabels).concat(kmEndMarkers).concat(userSymbols).concat(userLabels).concat(lineLayerHits).concat(pointsLayerHits) const newStyle = { ...currentStyle, layers } map.setStyle(newStyle, { diff: true }) diff --git a/app/javascript/maplibre/styles/styles.js b/app/javascript/maplibre/styles/styles.js index d1d42f41..81cf623a 100644 --- a/app/javascript/maplibre/styles/styles.js +++ b/app/javascript/maplibre/styles/styles.js @@ -526,6 +526,7 @@ export function styles () { ['==', ['get', 'flat'], true], ["!", ["has", "point_count"]], ["!", ["has", "marker-image-url"]], + ["!", ["has", "route-extras-label"]], minZoomFilter], paint: { "circle-pitch-alignment": "map", @@ -578,6 +579,7 @@ export function styles () { ['!=', ['get', 'flat'], true], ["!", ["has", "point_count"]], ["!", ["has", "marker-image-url"]], + ["!", ["has", "route-extras-label"]], minZoomFilter ], paint: { @@ -626,6 +628,7 @@ export function styles () { filter: [ "all", ["==", ["geometry-type"], "Point"], + ["!", ["has", "route-extras-label"]], minZoomFilter ], paint: {