mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 14:44:51 +02:00
fix: measure tool — multi-point with segment distances and running total
This commit is contained in:
parent
30bfd72642
commit
a1f929e10a
1 changed files with 1970 additions and 1791 deletions
|
|
@ -7,7 +7,7 @@ import { useStore } from '../store'
|
||||||
import { decodePolyline } from '../utils/decode'
|
import { decodePolyline } from '../utils/decode'
|
||||||
import { fetchReverse } from '../api'
|
import { fetchReverse } from '../api'
|
||||||
import { getConfig, hasFeature } from '../config'
|
import { getConfig, hasFeature } from '../config'
|
||||||
import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Plus, Star, Ruler } from 'lucide-react'
|
import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Plus, Star, Ruler, X } from 'lucide-react'
|
||||||
import RadialMenu from './RadialMenu'
|
import RadialMenu from './RadialMenu'
|
||||||
import useContextMenu from '../hooks/useContextMenu'
|
import useContextMenu from '../hooks/useContextMenu'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
@ -653,6 +653,9 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
// Flag to suppress map-click when a stop pin was clicked
|
// Flag to suppress map-click when a stop pin was clicked
|
||||||
const pinClickedRef = useRef(false)
|
const pinClickedRef = useRef(false)
|
||||||
const highlightedFeatureRef = useRef(null) // { source, sourceLayer, id } for setFeatureState
|
const highlightedFeatureRef = useRef(null) // { source, sourceLayer, id } for setFeatureState
|
||||||
|
// Refs for measurement state (accessible in click handlers)
|
||||||
|
const measuringRef = useRef({ active: false, points: [] })
|
||||||
|
const measureLabelsRef = useRef([]) // HTML label elements
|
||||||
|
|
||||||
const stops = useStore((s) => s.stops)
|
const stops = useStore((s) => s.stops)
|
||||||
const route = useStore((s) => s.route)
|
const route = useStore((s) => s.route)
|
||||||
|
|
@ -681,9 +684,14 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
lon: 0,
|
lon: 0,
|
||||||
centerLabel: null,
|
centerLabel: null,
|
||||||
})
|
})
|
||||||
// Measurement mode state
|
// Measurement mode state (for UI rendering)
|
||||||
const [measuring, setMeasuring] = useState({ active: false, points: [] })
|
const [measuring, setMeasuring] = useState({ active: false, points: [], totalMeters: 0 })
|
||||||
|
|
||||||
|
// Sync state to ref for click handler access
|
||||||
|
const updateMeasuringState = (newState) => {
|
||||||
|
measuringRef.current = newState
|
||||||
|
setMeasuring(newState)
|
||||||
|
}
|
||||||
|
|
||||||
// Update measurement layer with current points
|
// Update measurement layer with current points
|
||||||
const updateMeasureLayer = (points) => {
|
const updateMeasureLayer = (points) => {
|
||||||
|
|
@ -716,18 +724,169 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear measurement mode
|
// Update segment labels (HTML overlays)
|
||||||
|
const updateMeasureLabels = (points) => {
|
||||||
|
const map = mapInstance.current
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
// Remove old labels
|
||||||
|
measureLabelsRef.current.forEach(el => el.remove())
|
||||||
|
measureLabelsRef.current = []
|
||||||
|
|
||||||
|
if (points.length < 2) return
|
||||||
|
|
||||||
|
const container = mapRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
// Create label for each segment
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
const p1 = points[i - 1]
|
||||||
|
const p2 = points[i]
|
||||||
|
const midLat = (p1.lat + p2.lat) / 2
|
||||||
|
const midLon = (p1.lon + p2.lon) / 2
|
||||||
|
const dist = haversineDistance(p1.lat, p1.lon, p2.lat, p2.lon)
|
||||||
|
|
||||||
|
const label = document.createElement('div')
|
||||||
|
label.className = 'measure-label'
|
||||||
|
label.textContent = formatDistance(dist)
|
||||||
|
label.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
color: white;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 100;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
`
|
||||||
|
|
||||||
|
const pos = map.project([midLon, midLat])
|
||||||
|
label.style.left = pos.x + 'px'
|
||||||
|
label.style.top = pos.y + 'px'
|
||||||
|
|
||||||
|
container.appendChild(label)
|
||||||
|
measureLabelsRef.current.push(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reposition labels on map move/zoom
|
||||||
|
const repositionLabels = () => {
|
||||||
|
const map = mapInstance.current
|
||||||
|
const points = measuringRef.current.points
|
||||||
|
if (!map || points.length < 2) return
|
||||||
|
|
||||||
|
measureLabelsRef.current.forEach((label, i) => {
|
||||||
|
if (i >= points.length - 1) return
|
||||||
|
const p1 = points[i]
|
||||||
|
const p2 = points[i + 1]
|
||||||
|
const midLat = (p1.lat + p2.lat) / 2
|
||||||
|
const midLon = (p1.lon + p2.lon) / 2
|
||||||
|
const pos = map.project([midLon, midLat])
|
||||||
|
label.style.left = pos.x + 'px'
|
||||||
|
label.style.top = pos.y + 'px'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear measurement mode completely
|
||||||
const clearMeasuring = () => {
|
const clearMeasuring = () => {
|
||||||
const map = mapInstance.current
|
const map = mapInstance.current
|
||||||
setMeasuring({ active: false, points: [] })
|
updateMeasuringState({ active: false, points: [], totalMeters: 0 })
|
||||||
|
|
||||||
|
// Remove labels
|
||||||
|
measureLabelsRef.current.forEach(el => el.remove())
|
||||||
|
measureLabelsRef.current = []
|
||||||
|
|
||||||
if (map) {
|
if (map) {
|
||||||
map.getCanvas().style.cursor = ""
|
map.getCanvas().style.cursor = ""
|
||||||
|
map.doubleClickZoom.enable()
|
||||||
if (map.getLayer(MEASURE_LINE_LAYER)) map.removeLayer(MEASURE_LINE_LAYER)
|
if (map.getLayer(MEASURE_LINE_LAYER)) map.removeLayer(MEASURE_LINE_LAYER)
|
||||||
if (map.getLayer(MEASURE_POINT_LAYER)) map.removeLayer(MEASURE_POINT_LAYER)
|
if (map.getLayer(MEASURE_POINT_LAYER)) map.removeLayer(MEASURE_POINT_LAYER)
|
||||||
if (map.getSource(MEASURE_SOURCE)) map.removeSource(MEASURE_SOURCE)
|
if (map.getSource(MEASURE_SOURCE)) map.removeSource(MEASURE_SOURCE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// End measurement (keep line visible, exit active mode)
|
||||||
|
const endMeasuring = () => {
|
||||||
|
const map = mapInstance.current
|
||||||
|
if (map) {
|
||||||
|
map.getCanvas().style.cursor = ""
|
||||||
|
map.doubleClickZoom.enable()
|
||||||
|
}
|
||||||
|
updateMeasuringState({ ...measuringRef.current, active: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new measurement
|
||||||
|
const startMeasuring = (lat, lon) => {
|
||||||
|
const map = mapInstance.current
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
// Clear any existing measurement first
|
||||||
|
measureLabelsRef.current.forEach(el => el.remove())
|
||||||
|
measureLabelsRef.current = []
|
||||||
|
if (map.getLayer(MEASURE_LINE_LAYER)) map.removeLayer(MEASURE_LINE_LAYER)
|
||||||
|
if (map.getLayer(MEASURE_POINT_LAYER)) map.removeLayer(MEASURE_POINT_LAYER)
|
||||||
|
if (map.getSource(MEASURE_SOURCE)) map.removeSource(MEASURE_SOURCE)
|
||||||
|
|
||||||
|
// Set up new measurement
|
||||||
|
updateMeasuringState({ active: true, points: [{ lat, lon }], totalMeters: 0 })
|
||||||
|
map.getCanvas().style.cursor = "crosshair"
|
||||||
|
map.doubleClickZoom.disable()
|
||||||
|
|
||||||
|
// Add source and layers
|
||||||
|
map.addSource(MEASURE_SOURCE, {
|
||||||
|
type: "geojson",
|
||||||
|
data: { type: "FeatureCollection", features: [] },
|
||||||
|
})
|
||||||
|
const accentColor = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#7a9a6b"
|
||||||
|
map.addLayer({
|
||||||
|
id: MEASURE_LINE_LAYER,
|
||||||
|
type: "line",
|
||||||
|
source: MEASURE_SOURCE,
|
||||||
|
paint: {
|
||||||
|
"line-color": accentColor,
|
||||||
|
"line-width": 2,
|
||||||
|
"line-dasharray": [8, 4],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
map.addLayer({
|
||||||
|
id: MEASURE_POINT_LAYER,
|
||||||
|
type: "circle",
|
||||||
|
source: MEASURE_SOURCE,
|
||||||
|
filter: ["==", "$type", "Point"],
|
||||||
|
paint: {
|
||||||
|
"circle-radius": 5,
|
||||||
|
"circle-color": accentColor,
|
||||||
|
"circle-stroke-width": 2,
|
||||||
|
"circle-stroke-color": "#1a1a1a",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
updateMeasureLayer([{ lat, lon }])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a point to the measurement
|
||||||
|
const addMeasurePoint = (lat, lon) => {
|
||||||
|
const current = measuringRef.current
|
||||||
|
if (!current.active) return
|
||||||
|
|
||||||
|
const newPoints = [...current.points, { lat, lon }]
|
||||||
|
|
||||||
|
// Calculate total distance
|
||||||
|
let totalMeters = 0
|
||||||
|
for (let i = 1; i < newPoints.length; i++) {
|
||||||
|
totalMeters += haversineDistance(
|
||||||
|
newPoints[i - 1].lat, newPoints[i - 1].lon,
|
||||||
|
newPoints[i].lat, newPoints[i].lon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMeasuringState({ active: true, points: newPoints, totalMeters })
|
||||||
|
updateMeasureLayer(newPoints)
|
||||||
|
updateMeasureLabels(newPoints)
|
||||||
|
}
|
||||||
|
|
||||||
const radialWedges = [
|
const radialWedges = [
|
||||||
{
|
{
|
||||||
id: "directions-to",
|
id: "directions-to",
|
||||||
|
|
@ -814,40 +973,7 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
icon: Ruler,
|
icon: Ruler,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
setRadialMenu((m) => ({ ...m, open: false }))
|
setRadialMenu((m) => ({ ...m, open: false }))
|
||||||
const map = mapInstance.current
|
startMeasuring(radialMenu.lat, radialMenu.lon)
|
||||||
if (!map) return
|
|
||||||
setMeasuring({ active: true, points: [{ lat: radialMenu.lat, lon: radialMenu.lon }] })
|
|
||||||
map.getCanvas().style.cursor = "crosshair"
|
|
||||||
if (!map.getSource(MEASURE_SOURCE)) {
|
|
||||||
map.addSource(MEASURE_SOURCE, {
|
|
||||||
type: "geojson",
|
|
||||||
data: { type: "FeatureCollection", features: [] },
|
|
||||||
})
|
|
||||||
const accentColor = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#7a9a6b"
|
|
||||||
map.addLayer({
|
|
||||||
id: MEASURE_LINE_LAYER,
|
|
||||||
type: "line",
|
|
||||||
source: MEASURE_SOURCE,
|
|
||||||
paint: {
|
|
||||||
"line-color": accentColor,
|
|
||||||
"line-width": 2,
|
|
||||||
"line-dasharray": [8, 4],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
map.addLayer({
|
|
||||||
id: MEASURE_POINT_LAYER,
|
|
||||||
type: "circle",
|
|
||||||
source: MEASURE_SOURCE,
|
|
||||||
filter: ["==", "$type", "Point"],
|
|
||||||
paint: {
|
|
||||||
"circle-radius": 4,
|
|
||||||
"circle-color": accentColor,
|
|
||||||
"circle-stroke-width": 1,
|
|
||||||
"circle-stroke-color": "#fff",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
updateMeasureLayer([{ lat: radialMenu.lat, lon: radialMenu.lon }])
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
@ -856,6 +982,9 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
const map = mapInstance.current
|
const map = mapInstance.current
|
||||||
if (!map || !mapRef.current) return
|
if (!map || !mapRef.current) return
|
||||||
|
|
||||||
|
// Suppress context menu during measurement mode
|
||||||
|
if (measuringRef.current.active) return
|
||||||
|
|
||||||
// Convert screen coords to lat/lon
|
// Convert screen coords to lat/lon
|
||||||
const rect = mapRef.current.getBoundingClientRect()
|
const rect = mapRef.current.getBoundingClientRect()
|
||||||
const lngLat = map.unproject([x - rect.left, y - rect.top])
|
const lngLat = map.unproject([x - rect.left, y - rect.top])
|
||||||
|
|
@ -1003,24 +1132,10 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle measuring mode
|
// CRITICAL: Check measuring mode FIRST using ref (not stale closure)
|
||||||
const measureState = measuring
|
if (measuringRef.current.active) {
|
||||||
if (measureState.active) {
|
|
||||||
const { lng, lat } = e.lngLat
|
const { lng, lat } = e.lngLat
|
||||||
const newPoints = [...measureState.points, { lat, lon: lng }]
|
addMeasurePoint(lat, lng)
|
||||||
setMeasuring({ ...measureState, points: newPoints })
|
|
||||||
updateMeasureLayer(newPoints)
|
|
||||||
// Calculate and show total distance
|
|
||||||
if (newPoints.length > 1) {
|
|
||||||
let totalMeters = 0
|
|
||||||
for (let i = 1; i < newPoints.length; i++) {
|
|
||||||
totalMeters += haversineDistance(
|
|
||||||
newPoints[i - 1].lat, newPoints[i - 1].lon,
|
|
||||||
newPoints[i].lat, newPoints[i].lon
|
|
||||||
)
|
|
||||||
}
|
|
||||||
toast(formatDistance(totalMeters), { icon: "📏", duration: 2000 })
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1203,16 +1318,20 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Double-click ends measurement mode
|
// Double-click ends measurement mode (and prevents zoom)
|
||||||
map.on('dblclick', (e) => {
|
map.on('dblclick', (e) => {
|
||||||
if (measuring.active) {
|
if (measuringRef.current.active) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
// Keep the measurement visible but exit measuring mode
|
// Add final point and end
|
||||||
setMeasuring((m) => ({ ...m, active: false }))
|
const { lng, lat } = e.lngLat
|
||||||
map.getCanvas().style.cursor = ''
|
addMeasurePoint(lat, lng)
|
||||||
|
endMeasuring()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Reposition measure labels on map move
|
||||||
|
map.on('move', repositionLabels)
|
||||||
|
|
||||||
// Initialize mapCenter immediately when map loads (Fix 1: search viewport)
|
// Initialize mapCenter immediately when map loads (Fix 1: search viewport)
|
||||||
map.once('load', () => {
|
map.once('load', () => {
|
||||||
const center = map.getCenter()
|
const center = map.getCenter()
|
||||||
|
|
@ -1262,11 +1381,15 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
|
|
||||||
interactiveLayers.forEach(layerId => {
|
interactiveLayers.forEach(layerId => {
|
||||||
map.on('mouseenter', layerId, () => {
|
map.on('mouseenter', layerId, () => {
|
||||||
map.getCanvas().style.cursor = 'pointer'
|
if (!measuringRef.current.active) {
|
||||||
|
map.getCanvas().style.cursor = 'pointer'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
map.on('mouseleave', layerId, () => {
|
map.on('mouseleave', layerId, () => {
|
||||||
map.getCanvas().style.cursor = ''
|
if (!measuringRef.current.active) {
|
||||||
|
map.getCanvas().style.cursor = ''
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -1283,6 +1406,9 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
ro.disconnect()
|
ro.disconnect()
|
||||||
if (watchIdRef.current != null) navigator.geolocation.clearWatch(watchIdRef.current)
|
if (watchIdRef.current != null) navigator.geolocation.clearWatch(watchIdRef.current)
|
||||||
if (gpsMarkerRef.current) gpsMarkerRef.current.remove()
|
if (gpsMarkerRef.current) gpsMarkerRef.current.remove()
|
||||||
|
// Clean up measure labels
|
||||||
|
measureLabelsRef.current.forEach(el => el.remove())
|
||||||
|
measureLabelsRef.current = []
|
||||||
maplibregl.removeProtocol('pmtiles')
|
maplibregl.removeProtocol('pmtiles')
|
||||||
map.remove()
|
map.remove()
|
||||||
}
|
}
|
||||||
|
|
@ -1670,13 +1796,13 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
// ESC key handler for measurement mode
|
// ESC key handler for measurement mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
if (e.key === "Escape" && measuring.active) {
|
if (e.key === "Escape" && measuringRef.current.active) {
|
||||||
clearMeasuring()
|
endMeasuring()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener("keydown", handleKeyDown)
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
}, [measuring.active])
|
}, [])
|
||||||
|
|
||||||
// Handle location pick mode for contacts
|
// Handle location pick mode for contacts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1686,11 +1812,11 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
map.getCanvas().style.cursor = 'crosshair'
|
map.getCanvas().style.cursor = 'crosshair'
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
if (map && !measuring.active) {
|
if (map && !measuringRef.current.active) {
|
||||||
map.getCanvas().style.cursor = ''
|
map.getCanvas().style.cursor = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [pickingLocationFor, measuring.active])
|
}, [pickingLocationFor])
|
||||||
|
|
||||||
// ESC key handler for location pick mode
|
// ESC key handler for location pick mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1773,6 +1899,59 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
>
|
>
|
||||||
Z {zoomLevel.toFixed(1)}
|
Z {zoomLevel.toFixed(1)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Measurement info bar */}
|
||||||
|
{(measuring.active || measuring.points.length > 1) && (
|
||||||
|
<div
|
||||||
|
className="absolute top-4 left-1/2 transform -translate-x-1/2 z-50 flex items-center gap-3 px-4 py-2 rounded-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||||
|
color: "white",
|
||||||
|
fontSize: "13px",
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ruler size={16} style={{ opacity: 0.8 }} />
|
||||||
|
<span>
|
||||||
|
<strong>{formatDistance(measuring.totalMeters)}</strong>
|
||||||
|
<span style={{ opacity: 0.7, marginLeft: "6px" }}>
|
||||||
|
({measuring.points.length} {measuring.points.length === 1 ? "point" : "points"})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{measuring.active && (
|
||||||
|
<span style={{ opacity: 0.6, fontSize: "11px" }}>
|
||||||
|
Click to add points
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={endMeasuring}
|
||||||
|
className="px-2 py-1 rounded text-xs font-medium"
|
||||||
|
style={{
|
||||||
|
background: "var(--accent)",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={clearMeasuring}
|
||||||
|
className="p-1 rounded"
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
title="Clear measurement"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Radial context menu */}
|
{/* Radial context menu */}
|
||||||
<RadialMenu
|
<RadialMenu
|
||||||
open={radialMenu.open}
|
open={radialMenu.open}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue