Performance on trip planner (Maybe ?)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, useMemo, useCallback } from 'react'
|
||||
import { useEffect, useRef, useState, useMemo, useCallback, memo } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
@@ -65,7 +65,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
cursor:pointer;flex-shrink:0;position:relative;
|
||||
">
|
||||
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
|
||||
<img src="${escAttr(place.image_url)}" loading="lazy" style="width:100%;height:100%;object-fit:cover;" />
|
||||
<img src="${escAttr(place.image_url)}" loading="lazy" decoding="async" style="width:100%;height:100%;object-fit:cover;" />
|
||||
</div>
|
||||
${badgeHtml}
|
||||
</div>`,
|
||||
@@ -330,7 +330,7 @@ function LocationTracker() {
|
||||
)
|
||||
}
|
||||
|
||||
export function MapView({
|
||||
export const MapView = memo(function MapView({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
route = null,
|
||||
@@ -404,6 +404,63 @@ export function MapView({
|
||||
fetchNext()
|
||||
}, [places])
|
||||
|
||||
const clusterIconCreateFunction = useCallback((cluster) => {
|
||||
const count = cluster.getChildCount()
|
||||
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
||||
return L.divIcon({
|
||||
html: `<div class="marker-cluster-custom" style="width:${size}px;height:${size}px;"><span>${count}</span></div>`,
|
||||
className: 'marker-cluster-wrapper',
|
||||
iconSize: L.point(size, size),
|
||||
})
|
||||
}, [])
|
||||
|
||||
const markers = useMemo(() => places.map((place) => {
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
const resolvedPhotoUrl = place.image_url || (cacheKey && photoUrls[cacheKey]) || null
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, isSelected)
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={place.id}
|
||||
position={[place.lat, place.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{
|
||||
click: () => onMarkerClick && onMarkerClick(place.id),
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
>
|
||||
<Tooltip
|
||||
direction="right"
|
||||
offset={[0, 0]}
|
||||
opacity={1}
|
||||
className="map-tooltip"
|
||||
>
|
||||
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
|
||||
{place.name}
|
||||
</div>
|
||||
{place.category_name && (() => {
|
||||
const CatIcon = getCategoryIcon(place.category_icon)
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{place.address && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{place.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
)
|
||||
}), [places, selectedPlaceId, dayOrderMap, photoUrls, onMarkerClick])
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={center}
|
||||
@@ -416,6 +473,7 @@ export function MapView({
|
||||
url={tileUrl}
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
maxZoom={19}
|
||||
keepBuffer={4}
|
||||
/>
|
||||
|
||||
<MapController center={center} zoom={zoom} />
|
||||
@@ -433,65 +491,9 @@ export function MapView({
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
singleMarkerMode
|
||||
iconCreateFunction={(cluster) => {
|
||||
const count = cluster.getChildCount()
|
||||
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
||||
return L.divIcon({
|
||||
html: `<div class="marker-cluster-custom"
|
||||
style="width:${size}px;height:${size}px;">
|
||||
<span>${count}</span>
|
||||
</div>`,
|
||||
className: 'marker-cluster-wrapper',
|
||||
iconSize: L.point(size, size),
|
||||
})
|
||||
}}
|
||||
iconCreateFunction={clusterIconCreateFunction}
|
||||
>
|
||||
{places.map((place) => {
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
const resolvedPhotoUrl = place.image_url || (cacheKey && photoUrls[cacheKey]) || null
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, isSelected)
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={place.id}
|
||||
position={[place.lat, place.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{
|
||||
click: () => onMarkerClick && onMarkerClick(place.id),
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
>
|
||||
<Tooltip
|
||||
direction="right"
|
||||
offset={[0, 0]}
|
||||
opacity={1}
|
||||
className="map-tooltip"
|
||||
>
|
||||
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
|
||||
{place.name}
|
||||
</div>
|
||||
{place.category_name && (() => {
|
||||
const CatIcon = getCategoryIcon(place.category_icon)
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{place.address && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{place.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
)
|
||||
})}
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{route && route.length > 1 && (
|
||||
@@ -510,4 +512,4 @@ export function MapView({
|
||||
)}
|
||||
</MapContainer>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string }
|
||||
declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users } from 'lucide-react'
|
||||
|
||||
@@ -78,7 +78,7 @@ interface DayPlanSidebarProps {
|
||||
onNavigateToFiles?: () => void
|
||||
}
|
||||
|
||||
export default function DayPlanSidebar({
|
||||
const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
tripId,
|
||||
trip, days, places, categories, assignments,
|
||||
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
||||
@@ -323,6 +323,16 @@ export default function DayPlanSidebar({
|
||||
return result.sort((a, b) => a.sortKey - b.sortKey)
|
||||
}
|
||||
|
||||
// Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const mergedItemsMap = useMemo(() => {
|
||||
const map: Record<number, ReturnType<typeof getMergedItems>> = {}
|
||||
days.forEach(day => { map[day.id] = getMergedItems(day.id) })
|
||||
return map
|
||||
// getMergedItems is redefined each render but captures assignments/dayNotes/reservations/days via closure
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [days, assignments, dayNotes, reservations])
|
||||
|
||||
const openAddNote = (dayId, e) => {
|
||||
e?.stopPropagation()
|
||||
_openAddNote(dayId, getMergedItems, (id) => {
|
||||
@@ -669,10 +679,10 @@ export default function DayPlanSidebar({
|
||||
setDraggingId(null)
|
||||
}
|
||||
|
||||
const totalCost = days.reduce((s, d) => {
|
||||
const totalCost = useMemo(() => days.reduce((s, d) => {
|
||||
const da = assignments[String(d.id)] || []
|
||||
return s + da.reduce((s2, a) => s2 + (parseFloat(a.place?.price) || 0), 0)
|
||||
}, 0)
|
||||
}, 0), [days, assignments])
|
||||
|
||||
// Bester verfügbarer Standort für Wetter: zugewiesene Orte zuerst, dann beliebiger Reiseort
|
||||
const anyGeoAssignment = Object.values(assignments).flatMap(da => da).find(a => a.place?.lat && a.place?.lng)
|
||||
@@ -756,12 +766,12 @@ export default function DayPlanSidebar({
|
||||
const formattedDate = formatDate(day.date, locale)
|
||||
const loc = da.find(a => a.place?.lat && a.place?.lng)
|
||||
const isDragTarget = dragOverDayId === day.id
|
||||
const merged = getMergedItems(day.id)
|
||||
const merged = mergedItemsMap[day.id] || []
|
||||
const dayNoteUi = noteUi[day.id]
|
||||
const placeItems = merged.filter(i => i.type === 'place')
|
||||
|
||||
return (
|
||||
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)', contentVisibility: 'auto', containIntrinsicSize: '0 64px' }}>
|
||||
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
||||
<div
|
||||
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
|
||||
@@ -1663,4 +1673,6 @@ export default function DayPlanSidebar({
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default DayPlanSidebar
|
||||
|
||||
@@ -30,7 +30,7 @@ interface PlacesSidebarProps {
|
||||
onCategoryFilterChange?: (categoryId: string) => void
|
||||
}
|
||||
|
||||
export default function PlacesSidebar({
|
||||
const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange,
|
||||
}: PlacesSidebarProps) {
|
||||
@@ -69,9 +69,9 @@ export default function PlacesSidebar({
|
||||
const [catDropOpen, setCatDropOpen] = useState(false)
|
||||
|
||||
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
||||
const plannedIds = new Set(
|
||||
const plannedIds = useMemo(() => new Set(
|
||||
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
|
||||
)
|
||||
), [assignments])
|
||||
|
||||
const filtered = useMemo(() => places.filter(p => {
|
||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||
@@ -79,7 +79,7 @@ export default function PlacesSidebar({
|
||||
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
||||
return true
|
||||
}), [places, filter, categoryFilters, search, plannedIds.size])
|
||||
}), [places, filter, categoryFilters, search, plannedIds])
|
||||
|
||||
const isAssignedToSelectedDay = (placeId) =>
|
||||
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
||||
@@ -363,4 +363,6 @@ export default function PlacesSidebar({
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default PlacesSidebar
|
||||
|
||||
@@ -86,6 +86,7 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
|
||||
src={photoSrc}
|
||||
alt={place.name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={() => setPhotoSrc(null)}
|
||||
/>
|
||||
|
||||
@@ -122,18 +122,19 @@ async function fetchWikimediaPhoto(lat: number, lng: number, name?: string): Pro
|
||||
action: 'query', format: 'json',
|
||||
titles: name,
|
||||
prop: 'pageimages',
|
||||
piprop: 'original',
|
||||
piprop: 'thumbnail',
|
||||
pithumbsize: '400',
|
||||
pilimit: '1',
|
||||
redirects: '1',
|
||||
});
|
||||
const res = await fetch(`https://en.wikipedia.org/w/api.php?${searchParams}`, { headers: { 'User-Agent': UA } });
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { query?: { pages?: Record<string, { original?: { source?: string } }> } };
|
||||
const data = await res.json() as { query?: { pages?: Record<string, { thumbnail?: { source?: string } }> } };
|
||||
const pages = data.query?.pages;
|
||||
if (pages) {
|
||||
for (const page of Object.values(pages)) {
|
||||
if (page.original?.source) {
|
||||
return { photoUrl: page.original.source, attribution: 'Wikipedia' };
|
||||
if (page.thumbnail?.source) {
|
||||
return { photoUrl: page.thumbnail.source, attribution: 'Wikipedia' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,7 +203,7 @@ function getMapsKey(userId: number): string | null {
|
||||
return admin?.maps_api_key || null;
|
||||
}
|
||||
|
||||
const photoCache = new Map<string, { photoUrl: string; attribution: string | null; fetchedAt: number }>();
|
||||
const photoCache = new Map<string, { photoUrl: string; attribution: string | null; fetchedAt: number; error?: boolean }>();
|
||||
const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours
|
||||
const CACHE_MAX_ENTRIES = 1000;
|
||||
const CACHE_PRUNE_TARGET = 500;
|
||||
@@ -378,6 +379,9 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp
|
||||
|
||||
const cached = photoCache.get(placeId);
|
||||
if (cached && Date.now() - cached.fetchedAt < PHOTO_TTL) {
|
||||
if (cached.error) {
|
||||
return res.status(404).json({ error: `(Cache) No photo available` });
|
||||
}
|
||||
return res.json({ photoUrl: cached.photoUrl, attribution: cached.attribution });
|
||||
}
|
||||
|
||||
@@ -396,10 +400,12 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp
|
||||
if (wiki) {
|
||||
photoCache.set(placeId, { ...wiki, fetchedAt: Date.now() });
|
||||
return res.json(wiki);
|
||||
} else {
|
||||
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
return res.status(404).json({ error: 'No photo available' });
|
||||
return res.status(404).json({ error: '(Wikimedia) No photo available' });
|
||||
}
|
||||
|
||||
// Google Photos
|
||||
@@ -414,11 +420,13 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp
|
||||
|
||||
if (!detailsRes.ok) {
|
||||
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
|
||||
return res.status(404).json({ error: 'Photo could not be retrieved' });
|
||||
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
|
||||
return res.status(404).json({ error: '(Google Places) Photo could not be retrieved' });
|
||||
}
|
||||
|
||||
if (!details.photos?.length) {
|
||||
return res.status(404).json({ error: 'No photo available' });
|
||||
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
|
||||
return res.status(404).json({ error: '(Google Places) No photo available' });
|
||||
}
|
||||
|
||||
const photo = details.photos[0];
|
||||
@@ -432,7 +440,8 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp
|
||||
const photoUrl = mediaData.photoUri;
|
||||
|
||||
if (!photoUrl) {
|
||||
return res.status(404).json({ error: 'Photo URL not available' });
|
||||
photoCache.set(placeId, { photoUrl: '', attribution, fetchedAt: Date.now(), error: true });
|
||||
return res.status(404).json({ error: '(Google Places) Photo URL not available' });
|
||||
}
|
||||
|
||||
photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() });
|
||||
@@ -448,6 +457,7 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp
|
||||
res.json({ photoUrl, attribution });
|
||||
} catch (err: unknown) {
|
||||
console.error('Place photo error:', err);
|
||||
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
|
||||
res.status(500).json({ error: 'Error fetching photo' });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user