diff --git a/client/src/components/Collections/CollectionMap.tsx b/client/src/components/Collections/CollectionMap.tsx index cd7bb03b5..3dd3cc742 100644 --- a/client/src/components/Collections/CollectionMap.tsx +++ b/client/src/components/Collections/CollectionMap.tsx @@ -29,7 +29,10 @@ export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, on onDeselect() : undefined} // No center/zoom: the map frames itself on the collection's places at mount, and diff --git a/client/src/components/Collections/CollectionPlaceDetail.tsx b/client/src/components/Collections/CollectionPlaceDetail.tsx index 48ad1ba89..e232e2dec 100644 --- a/client/src/components/Collections/CollectionPlaceDetail.tsx +++ b/client/src/components/Collections/CollectionPlaceDetail.tsx @@ -216,7 +216,23 @@ export default function CollectionPlaceDetail({
{editing ? setName(e.target.value)} className="col-detail-name-input" autoFocus aria-label={t('collections.listName')} /> - :

{place.name}

} + : ( + /* Beside the name, not three rows below it: a label is part of what this + place IS, the same as its name, and read on its own further down it + looked like a property of the rating above it. */ +
+

{place.name}

+ {assignedLabels.length > 0 && ( +
+ {assignedLabels.map(l => ( + + {l.name} + + ))} +
+ )} +
+ )}
@@ -236,7 +252,9 @@ export default function CollectionPlaceDetail({ {/* Collaborative rating (#1435) — every member votes; the average shows. */} {onRate && ( -
+ /* In a panel of full-width rows, a bare line of stars read as another row of + text. Set on its own ground it reads as the one thing here you can answer. */ +
)} @@ -311,15 +329,6 @@ export default function CollectionPlaceDetail({
) : ( <> - {assignedLabels.length > 0 && ( -
- {assignedLabels.map(l => ( - - {l.name} - - ))} -
- )} {place.description && (
{place.description} diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 94159480e..5b9e38ece 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -36,7 +36,9 @@ import L from 'leaflet' import 'leaflet.markercluster/dist/MarkerCluster.css' import 'leaflet.markercluster/dist/MarkerCluster.Default.css' import { mapsApi } from '../../api/client' -import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons' +import { CATEGORY_ICON_MAP } from '../shared/categoryIcons' +import PlaceHoverCard from './PlaceHoverCard' +import { ratingBadgeHtml } from './ratingBadge' import ReservationOverlay from './ReservationOverlay' import { PluginMapMarkers } from './MapPluginMarkers' import { PluginMapLayers } from './MapPluginLayers' @@ -132,7 +134,7 @@ function RouteViaMarker({ via, controls, eventHandlers, children }: { * 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 || ''}:${place.stop_type || ''}:${orderNumbers?.join(',') || ''}` + const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${place.stop_type || ''}:${orderNumbers?.join(',') || ''}:${(place as { rating_avg?: number | null }).rating_avg ?? ''}` const cached = iconCache.get(cacheKey) if (cached) return cached @@ -157,8 +159,10 @@ function createPlaceIcon(place, orderNumbers, isSelected) { : '0 2px 8px rgba(0,0,0,0.22)' const bgColor = safeHexColor(place.category_color, '#6b7280') - // Number badges (bottom-right) - let badgeHtml = '' + // Number badges (bottom-right), or the rating where there are none: a numbered stop + // is one already planned into a day, and the rating answers the question asked before + // that. The two never want the same corner at the same time. + let badgeHtml = ratingBadgeHtml((place as { rating_avg?: number | null }).rating_avg) if (orderNumbers && orderNumbers.length > 0) { const label = orderNumbers.join(' · ') badgeHtml = ` -
- {hoveredPlace.name} -
- {hoveredPlace.category_name && CatIcon && ( -
- - {hoveredPlace.category_name} -
- )} - {hoveredPlace.address && ( -
- {hoveredPlace.address} -
- )} -
+ )} ) diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 16ea7daea..5f57fdb90 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -33,6 +33,8 @@ import { useHazardLayerGL } from './useHazardLayerGL' import { useDawarichTrailGL } from './useDawarichTrailGL' import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag' import NightPauseTooltip from './NightPauseTooltip' +import PlaceHoverCard from './PlaceHoverCard' +import { ratingBadgeHtml } from './ratingBadge' import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories' import { resolveTrackColor, hasManualTrackColor } from './trackColors' import { buildPoiPopupHtml } from './placePopup' @@ -336,7 +338,9 @@ function createMarkerElement(place: Place & { category_color?: string; category_ // bleed outside it and the route lines would appear slightly off. const outer = size + borderWidth * 2 - let badgeHtml = '' + // Same corner, same rule as the Leaflet map: numbers when the place is planned into a + // day, the rating when it is not. + let badgeHtml = ratingBadgeHtml((place as { rating_avg?: number | null }).rating_avg) if (orderNumbers && orderNumbers.length > 0) { const label = orderNumbers.join(' · ') badgeHtml = `
@@ -2322,35 +2324,16 @@ export function MapViewGL({ )} {!hoverDisabled && hoverPlace && !hoverPlace.routeVia?.nightPause && hoverPos && !isMobile && (!hoverPlace.routeVia || routeVias.includes(hoverPlace.routeVia)) && ( -
-
- {hoverPlace.name} -
- {hoverPlace.category_name && ( -
- - {hoverPlace.category_name} -
- )} - {hoverPlace.address && ( -
- {hoverPlace.address} -
- )} -
+ )}
) diff --git a/client/src/components/Map/PlaceHoverCard.test.tsx b/client/src/components/Map/PlaceHoverCard.test.tsx new file mode 100644 index 000000000..c6d02995c --- /dev/null +++ b/client/src/components/Map/PlaceHoverCard.test.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import PlaceHoverCard from './PlaceHoverCard' + +describe('PlaceHoverCard', () => { + it('FE-MAP-HOVERCARD-001: says what the place is, which a round photo cannot', () => { + render() + + expect(screen.getByText('Museumsinsel')).toBeInTheDocument() + expect(screen.getByText('Sights')).toBeInTheDocument() + expect(screen.getByText('Bodestraße 1')).toBeInTheDocument() + }) + + it('FE-MAP-HOVERCARD-002: shows a rating beside the name, and only when there is one', () => { + const { rerender } = render() + expect(screen.getByText('4')).toBeInTheDocument() + + rerender() + expect(screen.queryByText('4')).toBeNull() + }) + + it('FE-MAP-HOVERCARD-003: never takes the pointer — it is a label, not a target', () => { + render() + expect(screen.getByTestId('tooltip')).toHaveStyle({ pointerEvents: 'none' }) + }) +}) diff --git a/client/src/components/Map/PlaceHoverCard.tsx b/client/src/components/Map/PlaceHoverCard.tsx new file mode 100644 index 000000000..c08862219 --- /dev/null +++ b/client/src/components/Map/PlaceHoverCard.tsx @@ -0,0 +1,71 @@ +import React from 'react' +import { Star } from 'lucide-react' +import { getCategoryIcon } from '../shared/categoryIcons' + +/** + * What a place is, shown at the cursor while the pointer rests on its marker. + * + * One card for every map. It was written twice, once in each renderer, and the two + * had already drifted in how they resolved the category icon; a third copy for the + * collections map was not going to end better. Collections is also why it carries a + * rating: on a wall of round photos, "which of these did I like" is the question the + * picture cannot answer. + * + * Follows the cursor rather than anchoring to the marker, and never takes the + * pointer: it is a label on the map, not something to aim at. + */ +export interface PlaceHoverCardProps { + x: number + y: number + name: string | null | undefined + categoryName?: string | null + categoryIcon?: string | null + categoryColor?: string | null + address?: string | null + /** Average across everyone who rated it, when the surface tracks ratings. */ + rating?: number | null +} + +export default function PlaceHoverCard({ x, y, name, categoryName, categoryIcon, categoryColor, address, rating }: PlaceHoverCardProps): React.ReactElement { + const CatIcon = categoryName ? getCategoryIcon(categoryIcon) : null + return ( +
+
+
+ {name} +
+ {typeof rating === 'number' && rating > 0 && ( + + + {/* One decimal only when it earns it: "4" reads faster than "4.0". */} + {Number.isInteger(rating) ? rating : rating.toFixed(1)} + + )} +
+ {categoryName && CatIcon && ( +
+ + {categoryName} +
+ )} + {address && ( +
+ {address} +
+ )} +
+ ) +} diff --git a/client/src/components/Map/mapHover.ts b/client/src/components/Map/mapHover.ts index 5f58ae245..5d283c627 100644 --- a/client/src/components/Map/mapHover.ts +++ b/client/src/components/Map/mapHover.ts @@ -6,5 +6,8 @@ export interface MapHoverInfo { category_name?: string | null category_icon?: string | null category_color?: string | null + /** Everyone's average, where the surface keeps ratings. On the collections map it + * is the one thing a round photo cannot tell you: which of these you liked. */ + rating_avg?: number | null routeVia?: RouteVia } diff --git a/client/src/components/Map/ratingBadge.test.ts b/client/src/components/Map/ratingBadge.test.ts new file mode 100644 index 000000000..10ef7ad16 --- /dev/null +++ b/client/src/components/Map/ratingBadge.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest' +import { ratingBadgeHtml } from './ratingBadge' + +describe('ratingBadgeHtml', () => { + it('FE-MAP-RATINGBADGE-001: shows a whole rating without a decimal', () => { + // "4" reads faster than "4.0", and at this size every character costs. + expect(ratingBadgeHtml(4)).toContain('>4<') + expect(ratingBadgeHtml(4)).not.toContain('4.0') + }) + + it('FE-MAP-RATINGBADGE-002: keeps one decimal where an average has one', () => { + expect(ratingBadgeHtml(4.5)).toContain('>4.5<') + expect(ratingBadgeHtml(4.25)).toContain('>4.3<') + }) + + it('FE-MAP-RATINGBADGE-003: draws nothing without a rating', () => { + // An unrated place is most of them. A badge on every marker would say nothing and + // cover the photo it sits on. + expect(ratingBadgeHtml(null)).toBe('') + expect(ratingBadgeHtml(undefined)).toBe('') + expect(ratingBadgeHtml(0)).toBe('') + }) +}) diff --git a/client/src/components/Map/ratingBadge.ts b/client/src/components/Map/ratingBadge.ts new file mode 100644 index 000000000..35d763e67 --- /dev/null +++ b/client/src/components/Map/ratingBadge.ts @@ -0,0 +1,27 @@ +/** + * The rating a place carries, as a disc on the corner of its marker. + * + * Bottom right, where a trip's day numbers sit — the two never appear together, since + * a numbered stop is one you have already planned and this answers the question you + * ask before that: of all these photos, which did I like. Amber rather than the number + * badge's white, so a glance tells them apart without reading either. + * + * A rounded value and no star: at eighteen pixels a star is a smudge, and "4.5" is + * already the whole sentence. + */ +export function ratingBadgeHtml(rating: number | null | undefined): string { + if (typeof rating !== 'number' || !(rating > 0)) return ''; + const label = Number.isInteger(rating) ? String(rating) : rating.toFixed(1); + return `${label}`; +} diff --git a/client/src/styles/collections.css b/client/src/styles/collections.css index 1586e6b2d..aaedd671b 100644 --- a/client/src/styles/collections.css +++ b/client/src/styles/collections.css @@ -318,6 +318,7 @@ .trek-dash .col-labelchip.on { color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 16%, transparent); border-color: color-mix(in oklch, var(--label, var(--accent)) 42%, transparent); } .trek-dash .col-labelchip.on .col-labelchip-dot { background: var(--label, var(--accent)); } .trek-dash .col-labelchip.static { cursor: default; color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 14%, transparent); border-color: transparent; } +.trek-dash .col-detail-rating { display: inline-flex; align-items: center; align-self: flex-start; gap: 8px; padding: 6px 12px; margin: 2px 0 4px; border-radius: 999px; background: var(--surface-2); border: 1px solid var(--line); } .trek-dash .col-detail-labels { display: flex; flex-wrap: wrap; gap: 7px; margin: 2px 0 10px; } /* ----------------- filter bar (status + category dropdowns) ----------------- */ @@ -433,6 +434,12 @@ } .trek-dash .col-detail-close:hover { background: rgba(0,0,0,.6); } .trek-dash .col-detail-head { position: absolute; left: 20px; right: 20px; bottom: 12px; z-index: 1; color: #fff; } +/* Name and labels on one line, wrapping together. The labels sit on the cover photo + now, so they carry the same shadow the name does or they vanish into a bright sky. */ +.trek-dash .col-detail-titlerow { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; min-width: 0; } +.trek-dash .col-detail-titlerow .col-detail-name { flex: 0 1 auto; } +.trek-dash .col-detail-labels.on-cover { margin: 0; flex: 0 1 auto; } +.trek-dash .col-detail-labels.on-cover .col-labelchip { box-shadow: 0 1px 6px rgba(0,0,0,.45); } .trek-dash .col-detail-name { font-size: 22px; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; margin: 0; text-shadow: 0 1px 6px rgba(0,0,0,.55); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .trek-dash .col-detail-name-input { width: 100%; font-size: 20px; font-weight: 700; background: rgba(255,255,255,.18); border: 1px solid rgba(255,255,255,.32); border-radius: 8px; padding: 3px 8px; color: #fff; outline: none; } .trek-dash .col-detail-body { padding: 16px 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; }