From 1a95157b3a067db39174cb46d2d34b5f2d88f5b3 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 13 Sep 2026 16:49:20 +0200 Subject: [PATCH] feat(collections): say what a place is without having to click it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asked for in the feedback: a wall of round photos, and no way to tell which is which. "Name and displaying any custom rating would be very beneficial to quickly seeing what is around you that is of interest." The collections map had hover switched off. The trip map has told you what a marker is all along, so it is the same card, now shared instead of written a third time — it already existed twice, once per renderer, and the two had drifted in how they resolved the category icon. The card gains a rating, and so does the marker itself: an amber disc on the corner where a trip's day numbers sit. The two never collide, because a numbered stop is one already planned into a day and this answers the question asked before that. Nothing is drawn for an unrated place, which is most of them. Also from this round: the place panel's labels move up beside the name, where a label belongs — read three rows down they looked like a property of the rating above them — and the rating gets a ground of its own so a line of stars stops reading as another row of text. --- .../components/Collections/CollectionMap.tsx | 5 +- .../Collections/CollectionPlaceDetail.tsx | 31 +++++--- client/src/components/Map/MapView.tsx | 52 +++++--------- client/src/components/Map/MapViewGL.tsx | 47 ++++-------- .../components/Map/PlaceHoverCard.test.tsx | 27 +++++++ client/src/components/Map/PlaceHoverCard.tsx | 71 +++++++++++++++++++ client/src/components/Map/mapHover.ts | 3 + client/src/components/Map/ratingBadge.test.ts | 23 ++++++ client/src/components/Map/ratingBadge.ts | 27 +++++++ client/src/styles/collections.css | 7 ++ 10 files changed, 215 insertions(+), 78 deletions(-) create mode 100644 client/src/components/Map/PlaceHoverCard.test.tsx create mode 100644 client/src/components/Map/PlaceHoverCard.tsx create mode 100644 client/src/components/Map/ratingBadge.test.ts create mode 100644 client/src/components/Map/ratingBadge.ts 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; }