Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/javascript/maplibre/layers/geojson.js
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down
110 changes: 69 additions & 41 deletions app/javascript/maplibre/layers/geojson/km_markers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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'
Expand All @@ -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'
Expand Down
122 changes: 122 additions & 0 deletions app/javascript/maplibre/layers/geojson/route_extras.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand Down Expand Up @@ -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
}
})
}
6 changes: 4 additions & 2 deletions app/javascript/maplibre/map.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')))
Expand All @@ -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 })
Expand Down
3 changes: 3 additions & 0 deletions app/javascript/maplibre/styles/styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -578,6 +579,7 @@ export function styles () {
['!=', ['get', 'flat'], true],
["!", ["has", "point_count"]],
["!", ["has", "marker-image-url"]],
["!", ["has", "route-extras-label"]],
minZoomFilter
],
paint: {
Expand Down Expand Up @@ -626,6 +628,7 @@ export function styles () {
filter: [
"all",
["==", ["geometry-type"], "Point"],
["!", ["has", "route-extras-label"]],
minZoomFilter
],
paint: {
Expand Down
Loading