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

@ -10,7 +10,7 @@ import { PropertyCard } from "./PropertyCard";
import { ScrollArea } from "./ui/scroll-area";
import type { GeoJSONFeatureCollection, PropertyProperties, POI } from "@/types";
import { MAP_CONFIG, HEATMAP_CONFIG, PERCENTILE_CONFIG } from "@/constants";
import { getColorSchemeForMetric, getMetricInterpretation } from "@/constants/colorSchemes";
import { getColorSchemeForMetric, getMetricInterpretation, interpolateMetricColor } from "@/constants/colorSchemes";
import { calculateColorStops } from "@/utils/mapUtils";
import { HexgridHeatmapClient } from "@/workers/HexgridHeatmapClient";
@ -46,6 +46,10 @@ export function Map(props: MapProps) {
const poiMarkersRef = useRef<mapboxgl.Marker[]>([]);
const isPickingPOIRef = useRef(props.isPickingPOI ?? false);
const onPoiLocationPickRef = useRef(props.onPoiLocationPick);
// Latest color-scale bounds from the heatmap worker. Used by the
// multi-property popup to color each card by its individual metric value
// (so the per-card "color code" matches the heatmap gradient).
const colorScaleRef = useRef<{ min: number; max: number } | null>(null);
const metricMode = props.effectiveMetric ?? props.queryParameters?.metric ?? Metric.qmprice;
@ -102,11 +106,13 @@ export function Map(props: MapProps) {
makeLegend(colorScheme, colorResult.min, colorResult.max);
const colorStopsValue = calculateColorStops(colorScheme, colorResult.min, colorResult.max);
heatmap.setColorStops(colorStopsValue);
colorScaleRef.current = { min: colorResult.min, max: colorResult.max };
} else {
// Set safe default stops so stale stops from a previous metric don't cause
// Mapbox expression errors when the hexgrid produces cells with different value ranges
const colorStopsValue = calculateColorStops(colorScheme, 0, 1);
heatmap.setColorStops(colorStopsValue);
colorScaleRef.current = null;
}
heatmap.update();
@ -407,6 +413,16 @@ export function Map(props: MapProps) {
}
function getListingDialog(properties: PropertyWithCoords[]) {
const scale = colorScaleRef.current;
// Read each property's value for the active metric; Metric.price aliases
// to property.total_price.
const metricField = metricMode === Metric.price ? 'total_price' : metricMode;
const colorFor = (p: PropertyProperties): string | undefined => {
if (!scale) return undefined;
const raw = (p as unknown as Record<string, unknown>)[metricField];
if (typeof raw !== 'number') return undefined;
return interpolateMetricColor(raw, scale.min, scale.max, colorScheme) ?? undefined;
};
return (
<ScrollArea className="rounded-md">
<div className="overflow-y-auto max-h-[500px] w-[420px]">
@ -421,6 +437,7 @@ export function Map(props: MapProps) {
variant="full"
avgPricePerSqm={avgPricePerSqm}
allPOIs={props.pois}
metricColor={colorFor(property.properties)}
/>
))}
</div>

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

View file

@ -187,4 +187,40 @@ describe('PropertyCard', () => {
expect(container.textContent).toContain('4m');
expect(container.textContent).toContain('15m');
});
// metricColor regression: full-variant card in multi-property popup
// shows a left-border stripe matching the heatmap gradient.
it('renders metricColor as a left-edge stripe on the full variant', () => {
const property = createMockProperty({ qmprice: 50 });
const { container } = render(
<PropertyCard property={property} variant="full" metricColor="rgb(34, 197, 94)" />,
);
const root = container.firstElementChild as HTMLElement;
expect(root.style.borderLeft).toContain('rgb(34, 197, 94)');
expect(root.title).toMatch(/color matches the map heatmap/i);
});
it('omits the left-edge stripe when metricColor is not provided', () => {
const property = createMockProperty();
const { container } = render(<PropertyCard property={property} variant="full" />);
const root = container.firstElementChild as HTMLElement;
expect(root.style.borderLeft).toBe('');
});
// CardCarousel arrow regression: multi-photo cards expose prev/next buttons.
it('renders prev/next photo buttons when there are multiple photos', () => {
const property = createMockProperty({
photos: ['https://example.com/a.jpg', 'https://example.com/b.jpg', 'https://example.com/c.jpg'],
});
const { container } = render(<PropertyCard property={property} />);
expect(container.querySelector('button[aria-label="Previous photo"]')).toBeInTheDocument();
expect(container.querySelector('button[aria-label="Next photo"]')).toBeInTheDocument();
});
it('does not render carousel buttons for single-photo cards', () => {
const property = createMockProperty({ photos: [], photo_thumbnail: 'https://example.com/one.jpg' });
const { container } = render(<PropertyCard property={property} />);
expect(container.querySelector('button[aria-label="Previous photo"]')).not.toBeInTheDocument();
expect(container.querySelector('button[aria-label="Next photo"]')).not.toBeInTheDocument();
});
});

View file

@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest';
import { MAP_CONFIG } from '@/constants';
import {
LOW_IS_GOOD_COLOR_STOPS,
HIGH_IS_GOOD_COLOR_STOPS,
interpolateMetricColor,
} from '@/constants/colorSchemes';
describe('MAP_CONFIG', () => {
describe('B18 — default map center is London', () => {
@ -34,3 +39,33 @@ describe('MAP_CONFIG', () => {
});
});
});
describe('interpolateMetricColor', () => {
it('returns null for invalid inputs (NaN value, max <= min)', () => {
expect(interpolateMetricColor(NaN, 0, 100, LOW_IS_GOOD_COLOR_STOPS)).toBeNull();
expect(interpolateMetricColor(50, 100, 100, LOW_IS_GOOD_COLOR_STOPS)).toBeNull();
expect(interpolateMetricColor(50, 50, 0, LOW_IS_GOOD_COLOR_STOPS)).toBeNull();
});
it('returns the LOW_IS_GOOD start (green) for value at min', () => {
const c = interpolateMetricColor(0, 0, 100, LOW_IS_GOOD_COLOR_STOPS);
expect(c).toBe('rgb(34, 197, 94)');
});
it('returns the LOW_IS_GOOD end (red) for value at max', () => {
const c = interpolateMetricColor(100, 0, 100, LOW_IS_GOOD_COLOR_STOPS);
expect(c).toBe('rgb(239, 68, 68)');
});
it('inverts for HIGH_IS_GOOD (small → red, large → green)', () => {
expect(interpolateMetricColor(0, 0, 100, HIGH_IS_GOOD_COLOR_STOPS)).toBe('rgb(239, 68, 68)');
expect(interpolateMetricColor(100, 0, 100, HIGH_IS_GOOD_COLOR_STOPS)).toBe('rgb(34, 197, 94)');
});
it('clamps out-of-range values to the gradient endpoints', () => {
const below = interpolateMetricColor(-9999, 0, 100, LOW_IS_GOOD_COLOR_STOPS);
const above = interpolateMetricColor(9999, 0, 100, LOW_IS_GOOD_COLOR_STOPS);
expect(below).toBe('rgb(34, 197, 94)');
expect(above).toBe('rgb(239, 68, 68)');
});
});

View file

@ -91,3 +91,50 @@ export const COLOR_SCHEME_NAMES = {
HIGH_IS_GOOD: 'Red → Green (high is good)',
LEGACY: 'Classic (blue → orange)',
} as const;
// Parse an "rgba(R, G, B, A)" or "rgb(R, G, B)" string into numeric components.
function parseRgba(s: string): { r: number; g: number; b: number; a: number } | null {
const m = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)/);
if (!m) return null;
return {
r: Number(m[1]),
g: Number(m[2]),
b: Number(m[3]),
a: m[4] !== undefined ? Number(m[4]) : 1,
};
}
// Interpolate a metric value through the color-stop ramp scaled to [min, max].
// Used to "preserve the color code" on per-property cards in the multi-property
// popup so each card visually reflects the same heatmap gradient.
export function interpolateMetricColor(
value: number,
min: number,
max: number,
stops: [number, string][],
): string | null {
if (!Number.isFinite(value) || !Number.isFinite(min) || !Number.isFinite(max) || max <= min) {
return null;
}
const pct = Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100));
// Find the two stops bracketing pct.
let lo = stops[0];
let hi = stops[stops.length - 1];
for (let i = 0; i < stops.length - 1; i++) {
if (pct >= stops[i][0] && pct <= stops[i + 1][0]) {
lo = stops[i];
hi = stops[i + 1];
break;
}
}
const a = parseRgba(lo[1]);
const b = parseRgba(hi[1]);
if (!a || !b) return null;
const t = hi[0] === lo[0] ? 0 : (pct - lo[0]) / (hi[0] - lo[0]);
const r = Math.round(a.r + (b.r - a.r) * t);
const g = Math.round(a.g + (b.g - a.g) * t);
const bl = Math.round(a.b + (b.b - a.b) * t);
// Opaque output — the heatmap stops use 0.7 alpha for overlay over basemap,
// but a card stripe should be fully visible.
return `rgb(${r}, ${g}, ${bl})`;
}