mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
3c87d37cd9
A saved transit route is now its own reservation type instead of piggybacking on train/bus: it gets a tram icon and its own color everywhere, and the day timeline renders the itinerary inline — line badges in their official colors with walk segments, plus the transfer count and walking time — instead of a generic transport row. Clicking the row opens the itinerary view (journey summary, stop-by-stop legs with times, platforms, headsigns and operators) rather than the edit form; editing stays reachable from an Edit action inside that view. The transit type is registered across the timeline merge, transport modal, reservations panel, file manager, map overlays and detail panels, with a translated type label in every locale.
456 lines
18 KiB
TypeScript
456 lines
18 KiB
TypeScript
import { createElement, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
|
|
import L from 'leaflet'
|
|
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
|
|
import { escapeHtml } from '@trek/shared'
|
|
import { useSettingsStore } from '../../store/settingsStore'
|
|
import type { Reservation, ReservationEndpoint } from '../../types'
|
|
|
|
const ENDPOINT_PANE = 'reservation-endpoints'
|
|
const AIRPORT_BADGE_HALF_PX = 16
|
|
const BADGE_GAP_PX = 5
|
|
|
|
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transit' | 'transport_other'
|
|
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transit', 'transport_other']
|
|
|
|
const TRANSPORT_COLOR = '#3b82f6'
|
|
|
|
const TYPE_META: Record<TransportType, { color: string; icon: typeof Plane; geodesic: boolean }> = {
|
|
flight: { color: TRANSPORT_COLOR, icon: Plane, geodesic: true },
|
|
train: { color: TRANSPORT_COLOR, icon: Train, geodesic: false },
|
|
cruise: { color: TRANSPORT_COLOR, icon: Ship, geodesic: true },
|
|
car: { color: TRANSPORT_COLOR, icon: Car, geodesic: false },
|
|
bus: { color: TRANSPORT_COLOR, icon: Bus, geodesic: false },
|
|
taxi: { color: TRANSPORT_COLOR, icon: CarTaxiFront, geodesic: false },
|
|
bicycle: { color: TRANSPORT_COLOR, icon: Bike, geodesic: false },
|
|
ferry: { color: TRANSPORT_COLOR, icon: Sailboat, geodesic: true },
|
|
transit: { color: TRANSPORT_COLOR, icon: TramFront, geodesic: false },
|
|
transport_other: { color: TRANSPORT_COLOR, icon: Route, geodesic: false },
|
|
}
|
|
|
|
function useEndpointPane() {
|
|
const map = useMap()
|
|
useMemo(() => {
|
|
if (typeof map?.getPane !== 'function' || typeof map?.createPane !== 'function') return
|
|
if (!map.getPane(ENDPOINT_PANE)) {
|
|
const pane = map.createPane(ENDPOINT_PANE)
|
|
pane.style.zIndex = '650'
|
|
pane.style.pointerEvents = 'auto'
|
|
}
|
|
}, [map])
|
|
}
|
|
|
|
function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
|
|
const { icon: IconCmp, color } = TYPE_META[type]
|
|
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
|
|
const labelHtml = label ? `<span>${escapeHtml(label)}</span>` : ''
|
|
const estWidth = label ? Math.max(40, label.length * 6 + 28) : 26
|
|
return L.divIcon({
|
|
className: 'trek-endpoint-marker',
|
|
html: `<div style="
|
|
display:inline-flex;align-items:center;justify-content:center;gap:4px;
|
|
padding:0 8px;border-radius:999px;
|
|
background:${color};box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
|
border:1.5px solid #fff;color:#fff;
|
|
font-family:var(--font-system);font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
|
|
box-sizing:border-box;height:22px;white-space:nowrap;
|
|
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml ? `<span style="display:inline-flex;align-items:center;line-height:1">${escapeHtml(label)}</span>` : ''}</div>`,
|
|
iconSize: [estWidth, 22],
|
|
iconAnchor: [estWidth / 2, 11],
|
|
popupAnchor: [0, -11],
|
|
})
|
|
}
|
|
|
|
function toRad(d: number) { return d * Math.PI / 180 }
|
|
function toDeg(r: number) { return r * 180 / Math.PI }
|
|
|
|
function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
|
|
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
|
|
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
|
|
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
|
|
if (d === 0) return [a, b]
|
|
const pts: [number, number][] = []
|
|
for (let i = 0; i <= steps; i++) {
|
|
const f = i / steps
|
|
const A = Math.sin((1 - f) * d) / Math.sin(d)
|
|
const B = Math.sin(f * d) / Math.sin(d)
|
|
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
|
|
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
|
|
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
|
|
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
|
|
const lng = Math.atan2(y, x)
|
|
pts.push([toDeg(lat), toDeg(lng)])
|
|
}
|
|
return pts
|
|
}
|
|
|
|
function splitAntimeridian(points: [number, number][]): [number, number][][] {
|
|
const segments: [number, number][][] = []
|
|
let cur: [number, number][] = []
|
|
for (let i = 0; i < points.length; i++) {
|
|
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
|
|
if (cur.length > 1) segments.push(cur)
|
|
cur = []
|
|
}
|
|
cur.push(points[i])
|
|
}
|
|
if (cur.length > 1) segments.push(cur)
|
|
return segments
|
|
}
|
|
|
|
function cleanName(name: string): string {
|
|
return name.replace(/\s*\([^)]*\)/g, '').trim()
|
|
}
|
|
|
|
function haversineKm(a: [number, number], b: [number, number]): number {
|
|
const R = 6371
|
|
const dLat = toRad(b[0] - a[0])
|
|
const dLng = toRad(b[1] - a[1])
|
|
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLng / 2) ** 2
|
|
return 2 * R * Math.asin(Math.sqrt(h))
|
|
}
|
|
|
|
function parseInTz(isoLocal: string, tz: string): number {
|
|
const [datePart, timePart] = isoLocal.split('T')
|
|
const [y, mo, d] = datePart.split('-').map(Number)
|
|
const [h, mi] = (timePart || '00:00').split(':').map(Number)
|
|
const guess = Date.UTC(y, mo - 1, d, h, mi)
|
|
const fmt = new Intl.DateTimeFormat('en-US', {
|
|
timeZone: tz, hour12: false,
|
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
})
|
|
const parts = Object.fromEntries(fmt.formatToParts(new Date(guess)).filter(p => p.type !== 'literal').map(p => [p.type, p.value]))
|
|
const asUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) % 24, Number(parts.minute), Number(parts.second))
|
|
return guess - (asUtc - guess)
|
|
}
|
|
|
|
function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fallbackStart: string | null, fallbackEnd: string | null): string | null {
|
|
let start = from.local_date && from.local_time ? `${from.local_date}T${from.local_time}` : fallbackStart
|
|
let end = to.local_date && to.local_time ? `${to.local_date}T${to.local_time}` : fallbackEnd
|
|
if (!start || !end) return null
|
|
|
|
if (!start.includes('T') && end.includes('T')) start = `${end.split('T')[0]}T${start}`
|
|
if (!end.includes('T') && start.includes('T')) end = `${start.split('T')[0]}T${end}`
|
|
if (!start.includes('T') || !end.includes('T')) return null
|
|
|
|
const fromTz = from.timezone || to.timezone
|
|
const toTz = to.timezone || fromTz
|
|
|
|
let startMs: number, endMs: number
|
|
if (fromTz && toTz) {
|
|
startMs = parseInTz(start, fromTz)
|
|
endMs = parseInTz(end, toTz)
|
|
} else {
|
|
startMs = new Date(start).getTime()
|
|
endMs = new Date(end).getTime()
|
|
}
|
|
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null
|
|
if (endMs <= startMs) endMs += 24 * 60 * 60000
|
|
const minutes = Math.round((endMs - startMs) / 60000)
|
|
if (minutes <= 0 || minutes > 48 * 60) return null
|
|
const h = Math.floor(minutes / 60)
|
|
const m = minutes % 60
|
|
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
|
}
|
|
|
|
interface TransportItem {
|
|
res: Reservation
|
|
from: ReservationEndpoint
|
|
to: ReservationEndpoint
|
|
waypoints: ReservationEndpoint[]
|
|
type: TransportType
|
|
arcs: [number, number][][]
|
|
primaryArc: [number, number][]
|
|
fallback: [number, number]
|
|
mainLabel: string | null
|
|
subLabel: string | null
|
|
}
|
|
|
|
function buildStatsHtml(color: string, mainLabel: string | null, subLabel: string | null): { html: string; width: number; height: number } {
|
|
const estWidth = Math.max(
|
|
mainLabel ? mainLabel.length * 6.5 : 0,
|
|
subLabel ? subLabel.length * 5.5 : 0,
|
|
) + 22
|
|
const hasBoth = !!mainLabel && !!subLabel
|
|
const height = hasBoth ? 36 : 22
|
|
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${escapeHtml(mainLabel)}</span>` : ''
|
|
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${escapeHtml(subLabel)}</span>` : ''
|
|
const html = `<div class="trek-stats-inner" style="
|
|
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
|
width:100%;height:100%;
|
|
padding:0 11px;border-radius:999px;
|
|
background:rgba(17,24,39,0.92);color:#fff;
|
|
box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
|
border:1px solid ${color}aa;
|
|
font-family:var(--font-system);
|
|
white-space:nowrap;box-sizing:border-box;
|
|
transform-origin:center;
|
|
will-change:transform;
|
|
">${main}${sub}</div>`
|
|
return { html, width: estWidth, height }
|
|
}
|
|
|
|
function StatsLabel({ item }: { item: TransportItem }) {
|
|
const map = useMap()
|
|
const markerRef = useRef<L.Marker | null>(null)
|
|
const innerRef = useRef<HTMLElement | null>(null)
|
|
|
|
const arc = item.primaryArc
|
|
const color = TYPE_META[item.type].color
|
|
|
|
const { html, width, height } = useMemo(() => buildStatsHtml(color, item.mainLabel, item.subLabel), [color, item.mainLabel, item.subLabel])
|
|
const buffer = AIRPORT_BADGE_HALF_PX + width / 2 + BADGE_GAP_PX
|
|
|
|
const compute = () => {
|
|
if (arc.length < 2) return null
|
|
const size = map.getSize()
|
|
const pts = arc.map(p => map.latLngToContainerPoint(p as L.LatLngTuple))
|
|
const cum: number[] = [0]
|
|
let total = 0
|
|
for (let i = 1; i < pts.length; i++) {
|
|
total += pts[i].distanceTo(pts[i - 1])
|
|
cum.push(total)
|
|
}
|
|
if (total <= 0) return null
|
|
|
|
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
|
|
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
|
|
|
|
const isIn = (p: L.Point) => {
|
|
if (p.x < -40 || p.x > size.x + 40 || p.y < -40 || p.y > size.y + 40) return false
|
|
if (p.distanceTo(fromPx) < buffer) return false
|
|
if (p.distanceTo(toPx) < buffer) return false
|
|
return true
|
|
}
|
|
|
|
let firstIdx = -1
|
|
let lastIdx = -1
|
|
for (let i = 0; i < pts.length; i++) {
|
|
if (isIn(pts[i])) {
|
|
if (firstIdx < 0) firstIdx = i
|
|
lastIdx = i
|
|
}
|
|
}
|
|
if (firstIdx < 0) {
|
|
const target = total / 2
|
|
let sIdx = 0
|
|
while (sIdx < cum.length - 2 && cum[sIdx + 1] < target) sIdx++
|
|
const span = cum[sIdx + 1] - cum[sIdx]
|
|
const tm = span > 0 ? (target - cum[sIdx]) / span : 0
|
|
const pA = pts[sIdx]
|
|
const pB = pts[sIdx + 1]
|
|
const mx = pA.x + (pB.x - pA.x) * tm
|
|
const my = pA.y + (pB.y - pA.y) * tm
|
|
const latlng = map.containerPointToLatLng([mx, my])
|
|
let angle = Math.atan2(pB.y - pA.y, pB.x - pA.x) * 180 / Math.PI
|
|
if (angle > 90) angle -= 180
|
|
if (angle < -90) angle += 180
|
|
return { point: [latlng.lat, latlng.lng] as [number, number], angle }
|
|
}
|
|
|
|
const bisectFraction = (a: L.Point, b: L.Point) => {
|
|
let lo = 0, hi = 1
|
|
for (let k = 0; k < 10; k++) {
|
|
const mid = (lo + hi) / 2
|
|
const mp = L.point(a.x + (b.x - a.x) * mid, a.y + (b.y - a.y) * mid)
|
|
if (isIn(mp)) hi = mid
|
|
else lo = mid
|
|
}
|
|
return (lo + hi) / 2
|
|
}
|
|
|
|
let lowCum = cum[firstIdx]
|
|
if (firstIdx > 0) {
|
|
const t = bisectFraction(pts[firstIdx - 1], pts[firstIdx])
|
|
lowCum = cum[firstIdx - 1] + (cum[firstIdx] - cum[firstIdx - 1]) * t
|
|
}
|
|
let highCum = cum[lastIdx]
|
|
if (lastIdx < pts.length - 1) {
|
|
const t = bisectFraction(pts[lastIdx + 1], pts[lastIdx])
|
|
highCum = cum[lastIdx] + (cum[lastIdx + 1] - cum[lastIdx]) * (1 - t)
|
|
}
|
|
|
|
const targetLen = (lowCum + highCum) / 2
|
|
|
|
let segIdx = 0
|
|
while (segIdx < cum.length - 2 && cum[segIdx + 1] < targetLen) segIdx++
|
|
const segSpan = cum[segIdx + 1] - cum[segIdx]
|
|
const t = segSpan > 0 ? (targetLen - cum[segIdx]) / segSpan : 0
|
|
const pA = pts[segIdx]
|
|
const pB = pts[segIdx + 1]
|
|
const px = pA.x + (pB.x - pA.x) * t
|
|
const py = pA.y + (pB.y - pA.y) * t
|
|
const latlng = map.containerPointToLatLng([px, py])
|
|
|
|
let angle = Math.atan2(pB.y - pA.y, pB.x - pA.x) * 180 / Math.PI
|
|
if (angle > 90) angle -= 180
|
|
if (angle < -90) angle += 180
|
|
|
|
return { point: [latlng.lat, latlng.lng] as [number, number], angle }
|
|
}
|
|
|
|
const apply = () => {
|
|
const pose = compute()
|
|
const marker = markerRef.current
|
|
if (!marker) return
|
|
const el = marker.getElement() as HTMLElement | null
|
|
if (!pose) {
|
|
if (el) el.style.display = 'none'
|
|
return
|
|
}
|
|
if (el) el.style.display = ''
|
|
marker.setLatLng(pose.point as L.LatLngTuple)
|
|
if (!innerRef.current && el) innerRef.current = el.querySelector('.trek-stats-inner') as HTMLElement | null
|
|
if (innerRef.current) innerRef.current.style.transform = `rotate(${pose.angle}deg)`
|
|
}
|
|
|
|
useEffect(() => {
|
|
const icon = L.divIcon({
|
|
className: 'trek-endpoint-stats',
|
|
html,
|
|
iconSize: [width, height],
|
|
iconAnchor: [width / 2, height / 2],
|
|
})
|
|
const marker = L.marker([0, 0], { icon, pane: ENDPOINT_PANE, interactive: false, keyboard: false })
|
|
marker.addTo(map)
|
|
markerRef.current = marker
|
|
innerRef.current = null
|
|
apply()
|
|
return () => {
|
|
marker.remove()
|
|
markerRef.current = null
|
|
innerRef.current = null
|
|
}
|
|
}, [map, html, width, height])
|
|
|
|
useMapEvents({
|
|
move: apply,
|
|
zoom: apply,
|
|
viewreset: apply,
|
|
resize: apply,
|
|
})
|
|
|
|
return null
|
|
}
|
|
|
|
interface Props {
|
|
reservations: Reservation[]
|
|
showConnections: boolean
|
|
showStats: boolean
|
|
onEndpointClick?: (reservationId: number) => void
|
|
}
|
|
|
|
export default function ReservationOverlay({ reservations, showConnections, showStats, onEndpointClick }: Props) {
|
|
useEndpointPane()
|
|
const map = useMap()
|
|
const [zoom, setZoom] = useState(() => map.getZoom())
|
|
useMapEvents({
|
|
zoomend: () => setZoom(map.getZoom()),
|
|
})
|
|
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
|
|
|
|
const items = useMemo<TransportItem[]>(() => {
|
|
const out: TransportItem[] = []
|
|
for (const r of reservations) {
|
|
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
|
|
// Ordered waypoints (from · stops · to). A single-leg booking has exactly two,
|
|
// so the arc + markers below are byte-identical to before for it.
|
|
const waypoints = (r.endpoints || [])
|
|
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop')
|
|
.slice()
|
|
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
|
if (waypoints.length < 2) continue
|
|
const from = waypoints[0]
|
|
const to = waypoints[waypoints.length - 1]
|
|
const type = r.type as TransportType
|
|
const isGeo = TYPE_META[type].geodesic
|
|
// One arc per leg (between consecutive waypoints), concatenated.
|
|
const arcs: [number, number][][] = []
|
|
let distanceKm = 0
|
|
for (let i = 0; i < waypoints.length - 1; i++) {
|
|
const a = waypoints[i]
|
|
const b = waypoints[i + 1]
|
|
const segArcs = isGeo
|
|
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
|
|
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
|
|
arcs.push(...segArcs)
|
|
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
|
|
}
|
|
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
|
|
const primaryArc = arcs[primaryIdx] ?? []
|
|
const fallback: [number, number] = primaryArc.length > 0
|
|
? (primaryArc[Math.floor(primaryArc.length / 2)] ?? [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2])
|
|
: [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2]
|
|
|
|
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
|
|
const distance = `${Math.round(distanceKm)} km`
|
|
// Show the full route (FRA → BER → HND) when every waypoint has a code.
|
|
const mainLabel = waypoints.every(w => w.code)
|
|
? waypoints.map(w => w.code).join(' → ')
|
|
: (from.code && to.code ? `${from.code} → ${to.code}` : null)
|
|
const subParts = [duration, distance].filter(Boolean) as string[]
|
|
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
|
|
|
|
out.push({ res: r, from, to, waypoints, type, arcs, primaryArc, fallback, mainLabel, subLabel })
|
|
}
|
|
return out
|
|
}, [reservations])
|
|
|
|
const visibleItems = useMemo(() => {
|
|
return items.filter(item => {
|
|
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
|
|
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
|
|
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
|
|
return fromPx.distanceTo(toPx) >= minPx
|
|
})
|
|
}, [items, zoom, map])
|
|
|
|
const labelVisibleIds = useMemo(() => {
|
|
const set = new Set<number>()
|
|
for (const item of visibleItems) {
|
|
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
|
|
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
|
|
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : 400
|
|
if (fromPx.distanceTo(toPx) >= minPx) set.add(item.res.id)
|
|
}
|
|
return set
|
|
}, [visibleItems, zoom, map])
|
|
|
|
if (!showConnections) return null
|
|
|
|
return (
|
|
<>
|
|
{visibleItems.map(item => item.arcs.map((seg, segIdx) => (
|
|
<Polyline
|
|
key={`line-${item.res.id}-${segIdx}`}
|
|
positions={seg}
|
|
pathOptions={{
|
|
color: TYPE_META[item.type].color,
|
|
weight: 2.5,
|
|
opacity: item.res.status === 'confirmed' ? 0.75 : 0.55,
|
|
dashArray: item.res.status === 'confirmed' ? undefined : '6, 6',
|
|
}}
|
|
/>
|
|
)))}
|
|
|
|
{visibleItems.flatMap(item => item.waypoints.map((wp, wi) => (
|
|
<Marker
|
|
key={`wp-${item.res.id}-${wi}`}
|
|
position={[wp.lat, wp.lng]}
|
|
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (wp.code || cleanName(wp.name)) : null)}
|
|
pane={ENDPOINT_PANE}
|
|
zIndexOffset={1000}
|
|
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
|
|
>
|
|
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
|
<div style={{ fontWeight: 600, fontSize: 12 }}>{wp.name}</div>
|
|
{item.res.title && <div className="text-content-muted" style={{ fontSize: 11 }}>{item.res.title}</div>}
|
|
</Tooltip>
|
|
</Marker>
|
|
)))}
|
|
</>
|
|
)
|
|
}
|