wrongmove: preserve heatmap color on multi-property popup + arrow nav on card carousel
When the user clicks a heatmap hex that contains multiple properties,
each property card in the resulting popup now renders a left-edge
color stripe matching the heatmap gradient for that property's
individual value of the active metric (Price/m², Total Price, Size,
Bedrooms, …). The "color code" carries from the map into the popup
instead of dying at the hex boundary.
Plumbing:
- `colorSchemes.ts` gains `interpolateMetricColor(value, min, max, stops)`
that walks the color-stop ramp and returns `rgb(R, G, B)`.
- `Map.tsx` stashes the latest `{min, max}` from `computeColorScale` in
a ref so `getListingDialog` can compute per-property colors without
re-running the worker.
- `PropertyCard` accepts an optional `metricColor` prop and applies it
as a 4px `border-left`. Compact variant unchanged (no stripe).
Also resolves the Round-3 Fix-4 follow-up: `CardCarousel` (inside
PropertyCard.tsx) now has clickable prev/next chevron buttons in
addition to drag + keyboard navigation. Buttons fade in on hover
(group-hover) and are always focus-visible for keyboard users; clicks
stop propagation so the parent card click handler doesn't fire.
Tests: 9 new (4 covering interpolateMetricColor edge cases —
null/NaN/clamp — and 4 covering metricColor stripe + carousel
buttons present/absent). Full suite 210/210.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
9bb5320e2b
commit
9a5ad7878c
5 changed files with 163 additions and 4 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import useEmblaCarousel from 'embla-carousel-react';
|
||||
import { ExternalLink, Footprints, Bike, Train } from 'lucide-react';
|
||||
import { ExternalLink, Footprints, Bike, Train, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import type { PropertyProperties, POIDistanceInfo, POI } from '@/types';
|
||||
import { formatDuration, formatPrice, formatInteger, formatPricePerSqmShort, isFiniteNumber, EM_DASH } from '@/utils/format';
|
||||
|
||||
|
|
@ -126,7 +126,7 @@ function CardCarousel({ photos, altText }: { photos: string[]; altText?: string
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full" onClick={e => e.stopPropagation()}>
|
||||
<div className="relative w-full h-full group" onClick={e => e.stopPropagation()}>
|
||||
<div
|
||||
className="overflow-hidden h-full focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
ref={emblaRef}
|
||||
|
|
@ -146,6 +146,23 @@ function CardCarousel({ photos, altText }: { photos: string[]; altText?: string
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Prev / next click targets — visible on hover, always available for keyboard via tabbable buttons. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous photo"
|
||||
className="absolute left-0.5 top-1/2 -translate-y-1/2 flex items-center justify-center w-5 h-5 rounded-full bg-black/40 text-white opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
|
||||
onClick={e => { e.stopPropagation(); emblaApi?.scrollPrev(); }}
|
||||
>
|
||||
<ChevronLeft className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next photo"
|
||||
className="absolute right-0.5 top-1/2 -translate-y-1/2 flex items-center justify-center w-5 h-5 rounded-full bg-black/40 text-white opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
|
||||
onClick={e => { e.stopPropagation(); emblaApi?.scrollNext(); }}
|
||||
>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</button>
|
||||
<div className="absolute bottom-1 left-0 right-0 flex justify-center gap-1">
|
||||
{photos.map((_, i) => (
|
||||
<div
|
||||
|
|
@ -167,6 +184,10 @@ interface PropertyCardProps {
|
|||
avgPricePerSqm?: number;
|
||||
allPOIs?: POI[];
|
||||
onClick?: () => void;
|
||||
// Optional per-card color matching the heatmap gradient for the active map
|
||||
// metric (preserves the "color code" when multiple properties are visible
|
||||
// at the same place). Rendered as a left-edge stripe on the full variant.
|
||||
metricColor?: string;
|
||||
}
|
||||
|
||||
export function PropertyCard({
|
||||
|
|
@ -176,6 +197,7 @@ export function PropertyCard({
|
|||
avgPricePerSqm,
|
||||
allPOIs,
|
||||
onClick,
|
||||
metricColor,
|
||||
}: PropertyCardProps) {
|
||||
// BUY listings may have null numeric / date fields; render "—" at the JSX leaf
|
||||
// when the source is null/undefined/non-finite so the user can't mistake a missing
|
||||
|
|
@ -276,10 +298,12 @@ export function PropertyCard({
|
|||
// Full variant
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border bg-card shadow-sm hover:shadow-md transition-shadow overflow-hidden ${
|
||||
className={`relative rounded-lg border bg-card shadow-sm hover:shadow-md transition-shadow overflow-hidden ${
|
||||
isHighlighted ? 'ring-2 ring-primary' : ''
|
||||
}`}
|
||||
onClick={handleClick}
|
||||
style={metricColor ? { borderLeft: `4px solid ${metricColor}` } : undefined}
|
||||
title={metricColor ? 'Color matches the map heatmap value for this property' : undefined}
|
||||
>
|
||||
{/* Image section with 16:10 aspect ratio */}
|
||||
<div className="relative aspect-[16/10] bg-muted">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue