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
7 changes: 4 additions & 3 deletions app/assets/stylesheets/feature.css
Original file line number Diff line number Diff line change
Expand Up @@ -547,13 +547,13 @@ body:not(.map-mode-rw) .feature-section-card:has(#map-description-view:empty) {
}

/* the shape buttons hold an icon instead of a word, the width matches an icon-only content button */
#marker-shape-ui .segmented .btn {
:is(#marker-shape-ui, #desc-shape-ui) .segmented .btn {
width: 2.6rem;
padding: 0;
}

/* a solid glyph in the full button color weighs as much as the active cell */
#marker-shape-ui .segmented .btn:not(.active) {
:is(#marker-shape-ui, #desc-shape-ui) .segmented .btn:not(.active) {
color: color-mix(in srgb, var(--ctrl-button-color) 70%, #FFF);
}

Expand Down Expand Up @@ -630,7 +630,8 @@ body:not(.map-mode-rw) .feature-section-card:has(#map-description-view:empty) {
}

.edit-ui .left-label {
width: 5rem;
min-width: 5rem;
max-width: 6rem;
}

#feature-show-title-ui {
Expand Down
72 changes: 72 additions & 0 deletions app/assets/stylesheets/map.css
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,78 @@ body:has(#map-mode-badge) .layer-popover {
opacity: 0.9 !important;
}

/* --scale follows marker-scaling, set in desc_banners.js. Maplibre positions the outer
.maplibregl-popup with its own transform, so content and tip scale on their own, both around
the anchor: the point of the tip, which sits below the content. */
.desc-banner .maplibregl-popup-content {
transform: scale(var(--scale, 1));
transform-origin: 50% calc(100% + 10px);
}

.desc-banner .maplibregl-popup-tip {
transform: scale(var(--scale, 1));
transform-origin: bottom center;
}

.desc-banner.shape-square .maplibregl-popup-content {
border-radius: 0;
transform-origin: bottom center;
}

/* A comic bubble: an ellipse with a straight tail from its lower left to the bottom-left anchor.
The tail is the tip of maplibre, an svg stretched over up to half the bubble width
(vector-effect keeps the stroke width). It lies below the ellipse, which hides the part of its base that
reaches inside, and ::after repeats its fill above the outline, so the outline stops at the
tail edges. Both boxes share the tail size and the --overlap into the ellipse. */
.desc-banner.shape-bubble {
--tail-h: 3.5rem;
--tail-w: min(50%, 10rem);
--overlap: 1.6rem;
}

.desc-banner.shape-bubble .maplibregl-popup-content {
z-index: 1;
border: 2px solid #000;
border-radius: 50%;

/* narrower than the 40rem of the popup, a wide ellipse goes flat and its tail with it.
ponytail: fixed padding, a text of more than five lines pokes its corners out of the ellipse */
max-width: 26rem;
padding: 2rem 3rem;
transform-origin: 0 calc(100% + var(--tail-h) - var(--overlap));
}

.desc-banner.shape-bubble .maplibregl-popup-content::after {
content: "";
position: absolute;
left: -2px;
bottom: calc(var(--overlap) - var(--tail-h) - 2px);
width: var(--tail-w);
height: var(--tail-h);
background: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-3 0 106 103' preserveAspectRatio='none'><path d='M50 0L0 100L76 0Z' fill='%23fff'/></svg>") 0 0 / 100% 100% no-repeat;
}

.desc-banner.shape-bubble .maplibregl-popup-tip {
z-index: 0;
width: var(--tail-w);
height: var(--tail-h);
margin-top: calc(-1 * var(--overlap));
border: 0;
background: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-3 0 106 103' preserveAspectRatio='none'><path d='M50 0L0 100L76 0Z' fill='%23fff'/><path d='M50 0L0 100L76 0' fill='none' stroke='%23000' stroke-width='2' vector-effect='non-scaling-stroke'/></svg>") 0 0 / 100% 100% no-repeat;
transform-origin: bottom left;
}

/* an invisible marker has nothing for the tip to point at, a square carries none. The tail of
a comic bubble is part of its drawing, so it stays. */
.desc-banner:is(.no-tip:not(.shape-bubble), .shape-square) .maplibregl-popup-tip {
display: none;
}

/* marked wraps the text in <p>, its bootstrap margin would leave an empty strip at the bottom */
.desc-banner .maplibregl-popup-content > :last-child {
margin-bottom: 0;
}

.elevation-marker {
border-radius: 50%;
width: 20px;
Expand Down
13 changes: 11 additions & 2 deletions app/javascript/controllers/feature/edit_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { flyToFeature } from 'maplibre/animations'
import { draw, handleDelete } from 'maplibre/edit'
import {
backgroundMode, confirmImageLocation, featureIcon, getFeatureTypeName, markerContentMode,
markerMemory, resetHighlightedFeature, syncBackground, syncMarkerContent, syncShapeButtons,
uploadImage, uploadImageToFeature
markerMemory, resetHighlightedFeature, syncBackground, syncDescShapeButtons, syncMarkerContent,
syncShapeButtons, uploadImage, uploadImageToFeature
} from 'maplibre/feature'
import { hasKmMarkers } from 'maplibre/layers/geojson/km_markers'
import { applyFeatureUpdate, getFeature, getLayer, renderLayer } from 'maplibre/layers/layers'
Expand Down Expand Up @@ -254,6 +254,15 @@ export default class extends Controller {
this.renderFeature({ refreshKmMarkers: true })
}

updateDescShape (e) {
const feature = this.getEditFeature()
const shape = e.currentTarget.dataset.descShape
if (shape === 'none') { delete feature.properties['show-desc'] } else { feature.properties['show-desc'] = shape }
syncDescShapeButtons(feature)
this.renderFeature()
this.saveFeature()
}

updateShape (e) {
const feature = this.getEditFeature()
const shape = e.currentTarget.dataset.shape
Expand Down
6 changes: 4 additions & 2 deletions app/javascript/controllers/feature/modal_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import { AnimateLineAnimation, AnimatePolygonAnimation, animateViewFromPropertie
import { draw, select, unselect } from 'maplibre/edit'
import {
getFeatureTypeName, highlightedFeatureId, refreshFeatureMeta, showFeatureDetails,
syncBackground, syncMarkerContent, syncShapeButtons
syncBackground, syncDescShapeButtons, syncMarkerContent, syncShapeButtons
} from 'maplibre/feature'
import { EXTRAS_COLOR_CONFIGS } from 'maplibre/layers/geojson/route_extras'
import { getFeature, layers } from 'maplibre/layers/layers'
import { applyFeatureUpdate, getFeature, layers } from 'maplibre/layers/layers'
import { convertToRoute } from 'maplibre/routing/gpx_to_route'
import { routingEnabled } from 'maplibre/routing/openrouteservice'
import { defaultLineWidth, defaultPointSize, defaults } from 'maplibre/styles/defaults'
Expand Down Expand Up @@ -104,6 +104,7 @@ export default class extends Controller {
// init ui input elements
document.querySelector('#feature-title-input input').value = feature.properties.title || null
document.querySelector('#feature-show-title-on-map').checked = !!feature.properties.label
syncDescShapeButtons(feature)

dom.hideElements(['.edit-point', '.edit-line', '.edit-polygon'])

Expand Down Expand Up @@ -319,6 +320,7 @@ export default class extends Controller {
try {
if (easyMDE && feature.properties.desc !== easyMDE.value()) {
feature.properties.desc = easyMDE.value()
if (feature.properties['show-desc']) { applyFeatureUpdate(feature) }
functions.debounce(() => { this.saveFeature() }, 'desc', 2000)
}
} catch (error) {
Expand Down
5 changes: 5 additions & 0 deletions app/javascript/helpers/functions.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import DOMPurify from 'dompurify'

const debounceList = []
const throttleList = []

Expand Down Expand Up @@ -159,7 +161,10 @@ export function symbolUrl (symbol) {
return symbol.includes('/') ? symbol : '/icon-sets/noto/' + symbol + '.png'
}

// marked passes raw html through, so without this a description could run a script for every
// viewer of the map. 'target' is dropped by default, the OSM link of overpassDescription has one.
export function sanitizeMarkdown (desc) {
desc = DOMPurify.sanitize(desc, { ADD_ATTR: ['target'] })
// open external and image links in new tab
desc = desc.replace(/<a(\s+)(href=['"]https?:\/\/|href=['"]\/image)/gi, '<a$1target="_blank" $2')

Expand Down
8 changes: 8 additions & 0 deletions app/javascript/maplibre/feature.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { status } from 'helpers/status'
import { resetControls } from 'maplibre/controls/shared'
import { showElevationChart } from 'maplibre/feature/elevation'
import { showExtrasTotals } from 'maplibre/feature/extras_totals'
import { descShape } from 'maplibre/layers/geojson/desc_banners'
import { canPinImage } from 'maplibre/layers/geojson/image_overlays'
import { getFeature, getFeatureSource, getLayer, layers } from "maplibre/layers/layers"
import { wikipediaFeatureDescription } from 'maplibre/layers/wikipedia'
Expand Down Expand Up @@ -273,6 +274,13 @@ export function syncShapeButtons (feature) {
})
}

export function syncDescShapeButtons (feature) {
const shape = descShape(feature)
document.querySelectorAll('#desc-shape-ui [data-desc-shape]').forEach(button => {
button.classList.toggle('active', button.dataset.descShape === shape)
})
}

// An image covers a symbol, so a point that carries both reads as an image.
export function markerContentMode (feature) {
if (feature.properties['marker-image-url']) { return 'image' }
Expand Down
7 changes: 7 additions & 0 deletions app/javascript/maplibre/layers/geojson.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { draw, select } from 'maplibre/edit'
import { removeDescBanner, renderDescBanners, syncDescBanner } from 'maplibre/layers/geojson/desc_banners'
import { buildLineExtrusion } from 'maplibre/layers/geojson/extrusion'
import {
removeFeatureImageOverlay,
Expand Down Expand Up @@ -84,6 +85,7 @@ export class GeoJSONLayer extends Layer {
removeGeoJSONSource(this.routeExtrasSourceId)
removeGeoJSONSource(this.extrusionSourceId)
renderImageOverlays([], this.id)
renderDescBanners([], this)
}

initialize() {
Expand Down Expand Up @@ -137,6 +139,7 @@ export class GeoJSONLayer extends Layer {
applyKmMarkerLevelFilter(this.kmMarkerSourceId)
applyRouteExtrasLevelFilter(this.routeExtrasSourceId)
renderImageOverlays(this.layer.geojson?.features || [], this.id, this.show !== false)
renderDescBanners(this.layer.geojson?.features || [], this)
}

// setData(url) lets MapLibre fetch AND parse the features in its web worker (off the main
Expand Down Expand Up @@ -202,6 +205,7 @@ export class GeoJSONLayer extends Layer {
renderRouteExtras(features, this.routeExtrasSourceId)
this.renderExtrusionLines(features)
renderImageOverlays(features, this.id, this.show !== false)
renderDescBanners(features, this)

if (sourceLoaded) {
// MapLibre's URL load already holds exactly this set; don't re-parse it.
Expand Down Expand Up @@ -280,6 +284,7 @@ export class GeoJSONLayer extends Layer {
}

syncImageOverlay(feature, this.id, this.show !== false)
syncDescBanner(feature, this)

// Keep the MapboxDraw overlay in sync for geometry edits (no-op when nothing is in draw).
if (resetDraw) { this.resetDrawFeatures(true) }
Expand Down Expand Up @@ -309,6 +314,7 @@ export class GeoJSONLayer extends Layer {
}

syncImageOverlay(feature, this.id, this.show !== false)
syncDescBanner(feature, this)
}

// Surgically remove a feature from this layer's source without a full render(). See
Expand All @@ -332,6 +338,7 @@ export class GeoJSONLayer extends Layer {
}

removeFeatureImageOverlay(feature, this.id)
removeDescBanner(feature, this)

// Cheap regardless of draw's contents, so always keep it in sync (e.g. a feature deleted
// remotely while selected locally).
Expand Down
127 changes: 127 additions & 0 deletions app/javascript/maplibre/layers/geojson/desc_banners.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { sanitizeMarkdown } from 'helpers/functions'
import { hideContextMenu } from 'maplibre/controls/context_menu'
import { featureOnLevel } from 'maplibre/controls/levels'
import { draw } from 'maplibre/edit'
import { highlightFeature } from 'maplibre/feature'
import { clusterMaxZoom, frontFeature, map } from 'maplibre/map'
import { defaultPointSize } from 'maplibre/styles/defaults'
import { marked } from 'marked'

// A point with 'show-desc' carries its markdown description as an HTML popup pinned above the
// marker. Popups are DOM elements, not style layers, so this module keeps them by hand, keyed
// by the geojson layer id like the image overlays.
const banners = new Map()
const prefix = layer => `${layer.id}/`
const key = (layer, feature) => prefix(layer) + feature.id

export const hasDescBanner = feature => feature.geometry?.type === 'Point' &&
!!feature.properties?.['show-desc'] && !!feature.properties?.desc

export function renderDescBanners (features, layer, visible = layer.show !== false) {
const wanted = new Set()
features.filter(hasDescBanner).forEach(feature => {
wanted.add(key(layer, feature))
upsertDescBanner(feature, layer, visible)
})
Array.from(banners.keys())
.filter(id => id.startsWith(prefix(layer)) && !wanted.has(id))
.forEach(removeBanner)
}

export function syncDescBanner (feature, layer, visible = layer.show !== false) {
if (hasDescBanner(feature)) {
upsertDescBanner(feature, layer, visible)
} else {
removeBanner(key(layer, feature))
}
}

export function removeDescBanner (feature, layer) {
removeBanner(key(layer, feature))
}

// on zoom: the scale follows the marker, and a clustered layer folds its points into clusters
export function refreshDescBanners () {
banners.forEach(entry => {
scaleBanner(entry.popup, entry.feature)
showBanner(entry)
})
}

export const descShape = feature => feature.properties['show-desc'] || 'none'

function upsertDescBanner (feature, layer, visible) {
marked.use({ gfm: true, breaks: true })
const html = sanitizeMarkdown(marked(feature.properties.desc))
Comment thread
digitaltom marked this conversation as resolved.
const shape = descShape(feature)
// the tail of a bubble sits at its corner, and maplibre puts the tip where the anchor is
const anchor = shape === 'bubble' ? 'bottom-left' : 'bottom'
let popup = banners.get(key(layer, feature))?.popup
if (popup && popup.options.anchor !== anchor) {
removeBanner(key(layer, feature))
popup = null
}
if (!popup) {
// focusAfterOpen would pull the focus out of the description editor on every keystroke
popup = new window.maplibregl.Popup({
closeButton: false, closeOnClick: false, focusAfterOpen: false, anchor, className: 'desc-banner', maxWidth: '40rem'
}).setLngLat(feature.geometry.coordinates).addTo(map)
}
const created = !banners.has(key(layer, feature))
const entry = { popup, feature, layer, visible }
banners.set(key(layer, feature), entry)
// the element of the popup exists only after its first content
popup.setLngLat(feature.geometry.coordinates).setHTML(html)
if (created) { bindBannerEvents(popup.getElement(), key(layer, feature)) }
scaleBanner(popup, feature)
const p = feature.properties
const el = popup.getElement()
;['banner', 'square', 'bubble'].forEach(s => el.classList.toggle(`shape-${s}`, s === shape))
el.classList.toggle('no-tip', p['marker-color'] === 'transparent' && p.stroke === 'transparent')
showBanner(entry)
}

// ponytail: a clustered layer hides every banner up to clusterMaxZoom, a point that stands
// alone down there loses its banner too. querySourceFeatures per point if that matters.
function showBanner ({ popup, feature, layer, visible }) {
const clustered = layer.clustered && map.getZoom() < clusterMaxZoom + 1
popup.getElement().classList.toggle('hidden', !(visible && featureOnLevel(feature)) || clustered)
}

// The popup sits beside the canvas container, so the map sees none of its events: the wheel
// is passed on for zooming, a click selects the feature like a click on the marker does.
function bindBannerEvents (el, id) {
el.addEventListener('wheel', e => {
e.preventDefault()
map.getCanvasContainer().dispatchEvent(new WheelEvent('wheel', e))
})
// a drag across the text ends in a click as well, but that one selects text for a copy.
// The selection itself is no clue, it still stands during a click on the selected text.
let downAt = [0, 0]
el.addEventListener('mousedown', e => { downAt = [e.clientX, e.clientY] })
el.addEventListener('click', e => {
if (Math.hypot(e.clientX - downAt[0], e.clientY - downAt[1]) > 3) { return }
if (window.gon.map_mode === 'static' || (draw && draw.getMode() !== 'simple_select')) { return }
const feature = banners.get(id)?.feature
if (!feature) { return }
hideContextMenu()
highlightFeature(feature, true)
requestAnimationFrame(() => frontFeature(feature))
})
}

// Same curve as shapeIconSize in styles.js: 1x at zoom 16, doubling with each zoom level.
function scaleBanner (popup, feature) {
const p = feature.properties
const radius = Number(p['marker-size'] || defaultPointSize(feature))
const zoomFactor = p['marker-scaling'] ? 2 ** (map.getZoom() - 16) : 1
// marker-size is the radius, so the arrow tip sits on the top edge of the marker
popup.setOffset(radius * zoomFactor)
// a marker at its default size gets the banner at 1x, twice the size doubles the banner
popup.getElement().style.setProperty('--scale', radius / defaultPointSize(feature) * zoomFactor)
}

function removeBanner (id) {
banners.get(id)?.popup.remove()
banners.delete(id)
}
7 changes: 5 additions & 2 deletions app/javascript/maplibre/layers/layer.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,11 @@ export class Layer {
* Called once during initialization; visibility toggles reuse the source.
*/
createSource() {
const cluster = !!this.layer.cluster && !this.layer.heatmap
addGeoJSONSource(this.sourceId, cluster, this.attribution)
addGeoJSONSource(this.sourceId, this.clustered, this.attribution)
}

get clustered() {
return !!this.layer.cluster && !this.layer.heatmap
}

/**
Expand Down
Loading
Loading