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:
Viktor Barzin 2026-05-15 23:10:20 +00:00
parent 9bb5320e2b
commit 9a5ad7878c
5 changed files with 163 additions and 4 deletions

View file

@ -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">