mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
fix(client): work through the findings from the coverage pass
214 items came out of writing the tests. Real defects are corrected, dead code is removed rather than covered, and the tests that pinned the old behaviour move with them. Also drops two agent coverage scratch files that slipped into the coverage PR and adds the ignore rule that keeps them out.
This commit is contained in:
@@ -1001,6 +1001,15 @@ describe('MapView photo thumbnails', () => {
|
||||
expect(iconHtmlOf(screen.getAllByTestId('marker')[0])).toContain('/uploads/places/mine.jpg')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-076: a place with neither provider id nor coordinates has no cache key and is skipped', () => {
|
||||
render(<MapView places={[
|
||||
buildMapPlace({ id: 25, lat: null, lng: null, image_url: 'https://example.com/a.jpg' }),
|
||||
buildMapPlace({ id: 26, lat: null, lng: null, image_url: 'https://example.com/b.jpg' }),
|
||||
]} />)
|
||||
expect(thumbCallbacks.size).toBe(0)
|
||||
expect(vi.mocked(photoService.fetchPhoto)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-071: photos are not fetched at all when the feature is off', () => {
|
||||
useAuthStore.setState({ placesPhotosEnabled: false })
|
||||
render(<MapView places={[buildMapPlace({ id: 24, lat: 48, lng: 2, google_place_id: 'gp-24' })]} />)
|
||||
|
||||
@@ -36,15 +36,6 @@ L.Icon.Default.mergeOptions({
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
|
||||
})
|
||||
|
||||
/**
|
||||
* Create a round photo-circle marker.
|
||||
* Shows image_url if available, otherwise category icon in colored circle.
|
||||
*/
|
||||
function escAttr(s) {
|
||||
if (!s) return ''
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
const iconCache = new Map<string, L.DivIcon>()
|
||||
|
||||
// Tone dot for a plugin route's via points (charging stops, rest areas) — smaller
|
||||
@@ -73,6 +64,10 @@ function formatViaDwell(seconds: number): string {
|
||||
return h > 0 ? `${h} h ${m} min` : `${m} min`
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a round photo-circle marker.
|
||||
* Shows image_url if available, otherwise category icon in colored circle.
|
||||
*/
|
||||
function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}`
|
||||
const cached = iconCache.get(cacheKey)
|
||||
@@ -329,16 +324,6 @@ interface MapClickHandlerProps {
|
||||
onClick: ((e: L.LeafletMouseEvent) => void) | null
|
||||
}
|
||||
|
||||
function ZoomTracker({ onZoomStart, onZoomEnd }: { onZoomStart: () => void; onZoomEnd: () => void }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
map.on('zoomstart', onZoomStart)
|
||||
map.on('zoomend', onZoomEnd)
|
||||
return () => { map.off('zoomstart', onZoomStart); map.off('zoomend', onZoomEnd) }
|
||||
}, [map, onZoomStart, onZoomEnd])
|
||||
return null
|
||||
}
|
||||
|
||||
const TRACK_CASING_PANE = 'trek-track-casing'
|
||||
|
||||
/**
|
||||
@@ -385,7 +370,7 @@ function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.Leafle
|
||||
|
||||
// Module-level photo cache shared with PlaceAvatar
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
import { isCustomPlaceImage } from './placePhoto'
|
||||
import { isCustomPlaceImage, photoCacheKey } from './placePhoto'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
import LocationButton from './LocationButton'
|
||||
@@ -647,7 +632,7 @@ export const MapView = memo(function MapView({
|
||||
// photo for it (the request would 404 for OSM-only places and the fetched
|
||||
// thumb would shadow the user's own image). (#1136)
|
||||
if (isCustomPlaceImage(place.image_url)) continue
|
||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
const cacheKey = photoCacheKey(place)
|
||||
if (!cacheKey) continue
|
||||
|
||||
const cached = getCached(cacheKey)
|
||||
@@ -693,7 +678,7 @@ export const MapView = memo(function MapView({
|
||||
|
||||
const markers = useMemo(() => places.map((place) => {
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
const pck = photoCacheKey(place)
|
||||
// A custom uploaded image wins over the auto-fetched thumb; otherwise fall back.
|
||||
const photoUrl = isCustomPlaceImage(place.image_url) ? place.image_url! : ((pck && photoUrls[pck]) || place.image_url || null)
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
|
||||
@@ -909,6 +909,29 @@ describe('MapViewGL', () => {
|
||||
expect(queryByTestId('tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-067: hovering does not rebuild the markers under the cursor (#1404)', async () => {
|
||||
loadOnAttach()
|
||||
const places = [
|
||||
buildMapPlace({ id: 41, lat: 48.41, lng: 2.41 }),
|
||||
buildMapPlace({ id: 42, lat: 48.42, lng: 2.42 }),
|
||||
]
|
||||
|
||||
const { queryByTestId } = render(<MapViewGL places={places} fitKey={1} />)
|
||||
await act(async () => {})
|
||||
const before = glMarkers.created.length
|
||||
const el = glMarkers.created[0].element
|
||||
|
||||
act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) })
|
||||
await act(async () => {})
|
||||
|
||||
// The hover card is component state, so it re-renders MapViewGL. The omitted
|
||||
// collection props have to keep their identity across that render — a fresh
|
||||
// `[]`/`{}` default would re-run the reconcile effects and recreate every
|
||||
// marker, and a marker recreated under the pointer never fires mouseleave.
|
||||
expect(queryByTestId('tooltip')).toBeTruthy()
|
||||
expect(glMarkers.created).toHaveLength(before)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-027: clicking a GPX track selects its place unless a marker or cluster is on top', async () => {
|
||||
loadOnAttach()
|
||||
const onMarkerClick = vi.fn()
|
||||
@@ -1588,6 +1611,17 @@ describe('MapViewGL', () => {
|
||||
expect(photoService.fetchPhoto).toHaveBeenCalledWith('osm-5', '/api/maps/place-photo/abc', 48.5, 2.5, 'Museum')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-066: a place with neither provider id nor coordinates has no cache key and is skipped', async () => {
|
||||
render(<MapViewGL places={[
|
||||
buildMapPlace({ id: 98, lat: null, lng: null, image_url: 'https://example.com/a.jpg' }),
|
||||
buildMapPlace({ id: 99, lat: null, lng: null, image_url: 'https://example.com/b.jpg' }),
|
||||
]} fitKey={1} />)
|
||||
await act(async () => {})
|
||||
|
||||
expect(photoService.fetchPhoto).not.toHaveBeenCalled()
|
||||
expect(photoService.onThumbReady).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-057: no photos are fetched when place photos are switched off', async () => {
|
||||
useAuthStore.setState({ placesPhotosEnabled: false })
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
import { isCustomPlaceImage } from './placePhoto'
|
||||
import { isCustomPlaceImage, photoCacheKey } from './placePhoto'
|
||||
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
|
||||
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
|
||||
@@ -73,6 +73,20 @@ interface RouteSegment {
|
||||
drivingText?: string
|
||||
}
|
||||
|
||||
// Stable identities for the omitted collection props. An inline `= []` / `= {}`
|
||||
// default allocates a fresh object on every render, and these props sit in the
|
||||
// dependency arrays of the imperative reconcile effects below — so every render,
|
||||
// including one caused only by the hover tooltip's state, would tear down and
|
||||
// rebuild every marker. That is the "marker recreated under the cursor,
|
||||
// mouseleave never fires" case (#1404).
|
||||
const NO_PLACES: Place[] = []
|
||||
const NO_ROUTE_VIAS: RouteVia[] = []
|
||||
const NO_ROUTE_SEGMENTS: RouteSegment[] = []
|
||||
const NO_DAY_ORDER: Record<number, number[] | null> = {}
|
||||
const NO_RESERVATIONS: Reservation[] = []
|
||||
const NO_CONNECTION_IDS: number[] = []
|
||||
const NO_POIS: Poi[] = []
|
||||
|
||||
interface Props {
|
||||
places: Place[]
|
||||
dayPlaces?: Place[]
|
||||
@@ -315,12 +329,12 @@ function createPoiMarkerElement(category: string): HTMLDivElement {
|
||||
}
|
||||
|
||||
export function MapViewGL({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
places = NO_PLACES,
|
||||
dayPlaces = NO_PLACES,
|
||||
tripId,
|
||||
routeVias = [],
|
||||
routeVias = NO_ROUTE_VIAS,
|
||||
route = null,
|
||||
routeSegments = [],
|
||||
routeSegments = NO_ROUTE_SEGMENTS,
|
||||
selectedPlaceId = null,
|
||||
hoverDisabled = false,
|
||||
onMarkerClick,
|
||||
@@ -329,17 +343,17 @@ export function MapViewGL({
|
||||
center = DEFAULT_MAP_CENTER,
|
||||
zoom = DEFAULT_MAP_ZOOM,
|
||||
fitKey = 0,
|
||||
dayOrderMap = {},
|
||||
dayOrderMap = NO_DAY_ORDER,
|
||||
leftWidth = 0,
|
||||
rightWidth = 0,
|
||||
hasInspector = false,
|
||||
hasDayDetail = false,
|
||||
reservations = [],
|
||||
visibleConnectionIds = [],
|
||||
reservations = NO_RESERVATIONS,
|
||||
visibleConnectionIds = NO_CONNECTION_IDS,
|
||||
showTransitRoutes = true,
|
||||
showReservationStats = false,
|
||||
onReservationClick,
|
||||
pois = [],
|
||||
pois = NO_POIS,
|
||||
onPoiClick,
|
||||
onViewportChange,
|
||||
glProvider = 'mapbox-gl',
|
||||
@@ -816,12 +830,12 @@ export function MapViewGL({
|
||||
if (ev.touches.length !== 1) { cancelLongPress(); return }
|
||||
if ((ev.target as HTMLElement).closest('.mapboxgl-marker, .maplibregl-marker')) return
|
||||
const t = ev.touches[0]
|
||||
lpStart = { x: t.clientX, y: t.clientY }
|
||||
const start = { x: t.clientX, y: t.clientY }
|
||||
lpStart = start
|
||||
lpTimer = window.setTimeout(() => {
|
||||
lpTimer = null
|
||||
if (!lpStart) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const lngLat = map.unproject([lpStart.x - rect.left, lpStart.y - rect.top])
|
||||
const lngLat = map.unproject([start.x - rect.left, start.y - rect.top])
|
||||
lpStart = null
|
||||
// Only suppress the tap when OUR fire opened the form — if the native
|
||||
// contextmenu beat us to it (dedupe), no click needs swallowing.
|
||||
@@ -945,7 +959,7 @@ export function MapViewGL({
|
||||
// photo for it (that request would 404 for OSM-only places and, worse, the
|
||||
// fetched thumb would shadow the user's own image). (#1136)
|
||||
if (isCustomPlaceImage(place.image_url)) continue
|
||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
const cacheKey = photoCacheKey(place)
|
||||
if (!cacheKey) continue
|
||||
const cached = getCached(cacheKey)
|
||||
if (cached?.thumbDataUrl) {
|
||||
@@ -1002,7 +1016,7 @@ export function MapViewGL({
|
||||
|
||||
visiblePlaces.forEach(place => {
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
const pck = photoCacheKey(place)
|
||||
// A custom image wins over the auto-fetched thumb; otherwise fall back to it.
|
||||
const photoUrl = isCustomPlaceImage(place.image_url) ? place.image_url! : ((pck && photoUrls[pck]) || place.image_url || null)
|
||||
const selected = place.id === selectedPlaceId
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// FE-COMP-POIPILL-001 to FE-COMP-POIPILL-006
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
import PoiCategoryPill from './PoiCategoryPill'
|
||||
import { POI_CATEGORIES } from './poiCategories'
|
||||
|
||||
const CAFE = POI_CATEGORIES.find(c => c.key === 'cafe')!
|
||||
const BAR = POI_CATEGORIES.find(c => c.key === 'bar')!
|
||||
|
||||
function pill(props: Partial<React.ComponentProps<typeof PoiCategoryPill>> = {}) {
|
||||
return render(
|
||||
<PoiCategoryPill active={new Set()} onToggle={vi.fn()} {...props} />,
|
||||
)
|
||||
}
|
||||
|
||||
// The spinner replaces the category icon, so "is this segment spinning" reads as
|
||||
// "does its button hold an .animate-spin element".
|
||||
const spinning = () => screen.getAllByRole('button')
|
||||
.filter(b => b.querySelector('.animate-spin'))
|
||||
.map(b => b.getAttribute('aria-label'))
|
||||
|
||||
describe('PoiCategoryPill', () => {
|
||||
it('FE-COMP-POIPILL-001: renders one segment per category, none pressed', () => {
|
||||
pill()
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(POI_CATEGORIES.length)
|
||||
expect(buttons.every(b => b.getAttribute('aria-pressed') === 'false')).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-COMP-POIPILL-002: clicking a segment toggles its category', () => {
|
||||
const onToggle = vi.fn()
|
||||
pill({ onToggle })
|
||||
fireEvent.click(screen.getAllByRole('button')[1])
|
||||
expect(onToggle).toHaveBeenCalledWith(POI_CATEGORIES[1].key)
|
||||
})
|
||||
|
||||
it('FE-COMP-POIPILL-003: the active category spins while its fetch is in flight', () => {
|
||||
pill({ active: new Set([CAFE.key]), loadingKeys: new Set([CAFE.key]) })
|
||||
expect(spinning()).toEqual(['Cafés'])
|
||||
})
|
||||
|
||||
it('FE-COMP-POIPILL-004: a deselected category never spins, even with a lingering loading key', () => {
|
||||
pill({ active: new Set([BAR.key]), loadingKeys: new Set([CAFE.key, BAR.key]) })
|
||||
expect(spinning()).toEqual(['Bars & nightlife'])
|
||||
})
|
||||
|
||||
it('FE-COMP-POIPILL-005: a failed category offers a retry', () => {
|
||||
pill({ active: new Set([CAFE.key]), errorKeys: new Set([CAFE.key]) })
|
||||
expect(screen.getByText('Search this area')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-POIPILL-006: "search this area" reports the moved viewport', () => {
|
||||
const onSearchArea = vi.fn()
|
||||
pill({ active: new Set([CAFE.key]), moved: true, onSearchArea })
|
||||
fireEvent.click(screen.getByText('Search this area'))
|
||||
expect(onSearchArea).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -33,7 +33,9 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, errorKe
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 2, padding: 4, borderRadius: 999, pointerEvents: 'auto', ...frosted }}>
|
||||
{POI_CATEGORIES.map(cat => {
|
||||
const on = active.has(cat.key)
|
||||
const loading = loadingKeys?.has(cat.key)
|
||||
// Only an active category can be loading — a deselected one whose fetch
|
||||
// is still winding down must not keep spinning.
|
||||
const loading = on && !!loadingKeys?.has(cat.key)
|
||||
return (
|
||||
<Tooltip key={cat.key} label={t(cat.labelKey)} placement="bottom">
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, createElement, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Fragment, createElement, useMemo, useState } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
@@ -10,8 +10,6 @@ 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']
|
||||
@@ -46,7 +44,7 @@ function useEndpointPane() {
|
||||
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 labelHtml = label ? `<span style="display:inline-flex;align-items:center;line-height:1">${escapeHtml(label)}</span>` : ''
|
||||
const estWidth = label ? Math.max(40, label.length * 6 + 28) : 26
|
||||
return L.divIcon({
|
||||
className: 'trek-endpoint-marker',
|
||||
@@ -57,7 +55,7 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
|
||||
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>`,
|
||||
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml}</div>`,
|
||||
iconSize: [estWidth, 22],
|
||||
iconAnchor: [estWidth / 2, 11],
|
||||
popupAnchor: [0, -11],
|
||||
@@ -135,182 +133,18 @@ interface TransportItem {
|
||||
type: TransportType
|
||||
arcs: [number, number][][]
|
||||
transitSegs: TransitMapSegment[]
|
||||
primaryArc: [number, number][]
|
||||
fallback: [number, number]
|
||||
// Route ("VIE → LHR") and duration/distance line. Computed on every update but
|
||||
// not drawn since the stats badge was dropped; computeDuration still guards the
|
||||
// non-finite date that used to blank the trip (#1620).
|
||||
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
|
||||
// Accepted for call-site compatibility only: the floating route/duration badge
|
||||
// on the arc is no longer drawn, so nothing is gated on this.
|
||||
showStats: boolean
|
||||
onEndpointClick?: (reservationId: number) => void
|
||||
// Real road-network geometry for car/bus/taxi/bicycle bookings, keyed by
|
||||
@@ -318,7 +152,7 @@ interface Props {
|
||||
roadRoutes?: Map<number, [number, number][]>
|
||||
}
|
||||
|
||||
export default function ReservationOverlay({ reservations, showConnections, showStats, onEndpointClick, roadRoutes }: Props) {
|
||||
export default function ReservationOverlay({ reservations, showConnections, onEndpointClick, roadRoutes }: Props) {
|
||||
useEndpointPane()
|
||||
const map = useMap()
|
||||
const [zoom, setZoom] = useState(() => map.getZoom())
|
||||
@@ -354,12 +188,6 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
||||
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.
|
||||
@@ -369,7 +197,7 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
||||
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, transitSegs: type === 'transit' ? getTransitMapSegments(r) : [], primaryArc, fallback, mainLabel, subLabel })
|
||||
out.push({ res: r, from, to, waypoints, type, arcs, transitSegs: type === 'transit' ? getTransitMapSegments(r) : [], mainLabel, subLabel })
|
||||
}
|
||||
return out
|
||||
}, [reservations])
|
||||
|
||||
@@ -8,3 +8,21 @@
|
||||
export function isCustomPlaceImage(url: string | null | undefined): boolean {
|
||||
return !!url && (url.startsWith('/uploads/') || url.startsWith('data:'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache key under which a place's auto-fetched thumbnail is stored: the provider
|
||||
* id when there is one, otherwise the coordinates. A place with neither has no
|
||||
* identity to cache under, so it gets the empty string — callers skip it rather
|
||||
* than filing every coordinate-less place under one shared entry.
|
||||
*/
|
||||
export function photoCacheKey(place: {
|
||||
google_place_id?: string | null
|
||||
osm_id?: string | null
|
||||
lat?: number | null
|
||||
lng?: number | null
|
||||
}): string {
|
||||
if (place.google_place_id) return place.google_place_id
|
||||
if (place.osm_id) return place.osm_id
|
||||
if (place.lat == null || place.lng == null) return ''
|
||||
return `${place.lat},${place.lng}`
|
||||
}
|
||||
|
||||
@@ -264,7 +264,6 @@ describe('ReservationMapboxOverlay lifecycle', () => {
|
||||
expect(glMarkers.every(m => m.remove.mock.calls.length > 0)).toBe(true)
|
||||
expect(map.listeners('zoomend')).toBe(0)
|
||||
expect(map.listeners('moveend')).toBe(0)
|
||||
expect(map.listeners('render')).toBe(0)
|
||||
expect(map.removeLayer).toHaveBeenCalledWith(RESERVATION_LINE_LAYER_ID)
|
||||
expect(map.removeSource).toHaveBeenCalledWith(RESERVATION_SOURCE_ID)
|
||||
})
|
||||
@@ -287,20 +286,18 @@ describe('ReservationMapboxOverlay lifecycle', () => {
|
||||
expect(features(map)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-RESGL-006: the redraw and rotation handlers are inert once the overlay is destroyed', () => {
|
||||
it('FE-COMP-RESGL-006: the redraw handler is inert once the overlay is destroyed', () => {
|
||||
const map = freshMap()
|
||||
const overlay = attach(map)
|
||||
const handlerFor = (event: string) =>
|
||||
map.on.mock.calls.find(c => c[0] === event)?.[1] as Handler
|
||||
const rerender = handlerFor('moveend')
|
||||
const rotate = handlerFor('render')
|
||||
|
||||
overlay.update([booking()], opts)
|
||||
const drawsBefore = map._sources.get(RESERVATION_SOURCE_ID)!.setData.mock.calls.length
|
||||
overlay.destroy()
|
||||
|
||||
rerender()
|
||||
rotate()
|
||||
expect(map._sources.get(RESERVATION_SOURCE_ID)).toBeUndefined()
|
||||
expect(drawsBefore).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -700,7 +697,7 @@ describe('ReservationMapboxOverlay transit styling', () => {
|
||||
})
|
||||
|
||||
describe('ReservationMapboxOverlay frame handler', () => {
|
||||
it('FE-COMP-RESGL-042: a paint frame rotates only — it does not redraw the overlay', () => {
|
||||
it('FE-COMP-RESGL-042: the overlay does not subscribe to paint frames — only camera events redraw it', () => {
|
||||
const map = freshMap()
|
||||
const overlay = attach(map)
|
||||
overlay.update([booking()], opts)
|
||||
@@ -708,6 +705,7 @@ describe('ReservationMapboxOverlay frame handler', () => {
|
||||
|
||||
map.fire('render')
|
||||
|
||||
expect(map.listeners('render')).toBe(0)
|
||||
expect(map._sources.get(RESERVATION_SOURCE_ID)!.setData.mock.calls).toHaveLength(drawsBefore)
|
||||
expect(glMarkers).toHaveLength(2)
|
||||
expect(glMarkers.every(m => m.remove.mock.calls.length === 0)).toBe(true)
|
||||
|
||||
@@ -102,7 +102,9 @@ interface TransportItem {
|
||||
waypoints: ReservationEndpoint[]
|
||||
type: TransportType
|
||||
arcs: [number, number][][]
|
||||
primaryArc: [number, number][]
|
||||
// Route ("VIE → LHR") and duration/distance line. Computed on every update but
|
||||
// not drawn since the stats badge was dropped; computeDuration still guards the
|
||||
// non-finite date that used to blank the trip (#1620).
|
||||
mainLabel: string | null
|
||||
subLabel: string | null
|
||||
}
|
||||
@@ -136,8 +138,6 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
|
||||
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 duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
|
||||
const distance = `${Math.round(distanceKm)} km`
|
||||
const mainLabel = waypoints.every(w => w.code)
|
||||
@@ -145,7 +145,7 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
|
||||
: (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, mainLabel, subLabel })
|
||||
out.push({ res: r, from, to, waypoints, type, arcs, mainLabel, subLabel })
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -165,32 +165,11 @@ function endpointMarkerHtml(type: TransportType, label: string | null): string {
|
||||
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml}</div>`
|
||||
}
|
||||
|
||||
function buildStatsHtml(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 ${TRANSPORT_COLOR}aa;
|
||||
font-family:var(--font-system);
|
||||
white-space:nowrap;box-sizing:border-box;pointer-events:none;
|
||||
transform-origin:center;will-change:transform;
|
||||
">${main}${sub}</div>`
|
||||
return { html, width: estWidth, height }
|
||||
}
|
||||
|
||||
// ── overlay manager ──────────────────────────────────────────────────────
|
||||
export interface ReservationOverlayOptions {
|
||||
showConnections: boolean
|
||||
// Accepted for call-site compatibility only: the floating route/duration badge
|
||||
// on the arc is no longer drawn, so nothing is gated on this.
|
||||
showStats: boolean
|
||||
showEndpointLabels: boolean
|
||||
onEndpointClick?: (reservationId: number) => void
|
||||
@@ -212,7 +191,6 @@ export class ReservationMapboxOverlay {
|
||||
private opts: ReservationOverlayOptions
|
||||
private MarkerCtor: MarkerConstructor
|
||||
private endpointMarkers: GlMarker[] = []
|
||||
private statsMarkers: { marker: GlMarker; arc: [number, number][] }[] = []
|
||||
private rerender: () => void
|
||||
private destroyed = false
|
||||
|
||||
@@ -224,7 +202,6 @@ export class ReservationMapboxOverlay {
|
||||
this.setupLayer()
|
||||
map.on('zoomend', this.rerender)
|
||||
map.on('moveend', this.rerender)
|
||||
map.on('render', this.updateStatsRotation)
|
||||
}
|
||||
|
||||
update(reservations: Reservation[], opts: ReservationOverlayOptions, roadRoutes?: Map<number, [number, number][]>) {
|
||||
@@ -238,11 +215,8 @@ export class ReservationMapboxOverlay {
|
||||
this.destroyed = true
|
||||
this.map.off('zoomend', this.rerender)
|
||||
this.map.off('moveend', this.rerender)
|
||||
this.map.off('render', this.updateStatsRotation)
|
||||
this.endpointMarkers.forEach(m => m.remove())
|
||||
this.endpointMarkers = []
|
||||
this.statsMarkers.forEach(s => s.marker.remove())
|
||||
this.statsMarkers = []
|
||||
try {
|
||||
if (this.map.getLayer(RESERVATION_LINE_LAYER_ID)) this.map.removeLayer(RESERVATION_LINE_LAYER_ID)
|
||||
if (this.map.getSource(RESERVATION_SOURCE_ID)) this.map.removeSource(RESERVATION_SOURCE_ID)
|
||||
@@ -386,34 +360,7 @@ export class ReservationMapboxOverlay {
|
||||
}
|
||||
}
|
||||
|
||||
// Stats badge removed — the floating route/duration label on the arc is no
|
||||
// longer drawn; only the connection line and the airport markers remain.
|
||||
this.statsMarkers.forEach(s => s.marker.remove())
|
||||
this.statsMarkers = []
|
||||
}
|
||||
|
||||
// Match the Leaflet overlay's "rotate the label along the arc" look.
|
||||
// We pick a short segment straddling the arc midpoint, measure the
|
||||
// screen angle between those two projected points, and clamp it to
|
||||
// [-90°, 90°] so text never renders upside-down.
|
||||
private updateStatsRotation = () => {
|
||||
if (this.destroyed) return
|
||||
for (const entry of this.statsMarkers) {
|
||||
const { marker, arc } = entry
|
||||
if (arc.length < 2) continue
|
||||
const midIdx = Math.floor(arc.length / 2)
|
||||
const a = arc[Math.max(0, midIdx - 2)]!
|
||||
const b = arc[Math.min(arc.length - 1, midIdx + 2)]!
|
||||
try {
|
||||
const pa = this.map.project([a[1], a[0]])
|
||||
const pb = this.map.project([b[1], b[0]])
|
||||
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
|
||||
const el = marker.getElement()
|
||||
const inner = el.querySelector('.trek-stats-inner') as HTMLElement | null
|
||||
if (inner) inner.style.transform = `rotate(${angle}deg)`
|
||||
} catch { /* map not ready / projection failure */ }
|
||||
}
|
||||
// No stats badge: the floating route/duration label on the arc was removed,
|
||||
// so a rendered overlay is the connection lines plus the airport markers.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-POIEXPLORE-001 to FE-COMP-POIEXPLORE-016
|
||||
// FE-COMP-POIEXPLORE-001 to FE-COMP-POIEXPLORE-018
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
@@ -240,4 +240,34 @@ describe('usePoiExplore', () => {
|
||||
expect(result.current.errorKeys.size).toBe(0)
|
||||
expect(result.current.pois[0].osm_id).toBe('node/9')
|
||||
})
|
||||
|
||||
it('FE-COMP-POIEXPLORE-017: the abandoned category stops loading when the selection moves on', async () => {
|
||||
const first = deferred<PoiResponse>()
|
||||
pois.mockReturnValueOnce(first.promise)
|
||||
const { result } = setup()
|
||||
act(() => { result.current.onViewportChange(BBOX) })
|
||||
act(() => { result.current.toggle('cafe') })
|
||||
expect(result.current.loadingKeys.has('cafe')).toBe(true)
|
||||
|
||||
pois.mockResolvedValue(response([poi({ osm_id: 'node/9', category: 'bar' })]))
|
||||
act(() => { result.current.toggle('bar') })
|
||||
|
||||
await act(async () => { first.reject(Object.assign(new Error('canceled'), { name: 'CanceledError' })) })
|
||||
|
||||
await waitFor(() => expect(result.current.loadingKeys.has('cafe')).toBe(false))
|
||||
expect(result.current.loadingKeys.has('bar')).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-COMP-POIEXPLORE-018: turning the category off stops its spinner as well', async () => {
|
||||
const d = deferred<PoiResponse>()
|
||||
pois.mockReturnValue(d.promise)
|
||||
const { result } = setup()
|
||||
act(() => { result.current.onViewportChange(BBOX) })
|
||||
act(() => { result.current.toggle('cafe') })
|
||||
act(() => { result.current.toggle('cafe') })
|
||||
|
||||
await act(async () => { d.reject(Object.assign(new Error('canceled'), { name: 'CanceledError' })) })
|
||||
|
||||
await waitFor(() => expect(result.current.loadingKeys.size).toBe(0))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -74,6 +74,11 @@ export function usePoiExplore() {
|
||||
if (abortRef.current[key] === ctrl) {
|
||||
setLoading(key, false)
|
||||
delete abortRef.current[key]
|
||||
} else if (!abortRef.current[key]) {
|
||||
// Cancelled with nothing taking over (toggle switched the category, or
|
||||
// turned it off) — no later request will clear this key, so do it here
|
||||
// instead of leaving the pill spinning forever.
|
||||
setLoading(key, false)
|
||||
}
|
||||
}
|
||||
}, [setLoading, setError, locale])
|
||||
|
||||
Reference in New Issue
Block a user