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
|
|
@ -10,7 +10,7 @@ import { PropertyCard } from "./PropertyCard";
|
||||||
import { ScrollArea } from "./ui/scroll-area";
|
import { ScrollArea } from "./ui/scroll-area";
|
||||||
import type { GeoJSONFeatureCollection, PropertyProperties, POI } from "@/types";
|
import type { GeoJSONFeatureCollection, PropertyProperties, POI } from "@/types";
|
||||||
import { MAP_CONFIG, HEATMAP_CONFIG, PERCENTILE_CONFIG } from "@/constants";
|
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 { calculateColorStops } from "@/utils/mapUtils";
|
||||||
import { HexgridHeatmapClient } from "@/workers/HexgridHeatmapClient";
|
import { HexgridHeatmapClient } from "@/workers/HexgridHeatmapClient";
|
||||||
|
|
||||||
|
|
@ -46,6 +46,10 @@ export function Map(props: MapProps) {
|
||||||
const poiMarkersRef = useRef<mapboxgl.Marker[]>([]);
|
const poiMarkersRef = useRef<mapboxgl.Marker[]>([]);
|
||||||
const isPickingPOIRef = useRef(props.isPickingPOI ?? false);
|
const isPickingPOIRef = useRef(props.isPickingPOI ?? false);
|
||||||
const onPoiLocationPickRef = useRef(props.onPoiLocationPick);
|
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;
|
const metricMode = props.effectiveMetric ?? props.queryParameters?.metric ?? Metric.qmprice;
|
||||||
|
|
||||||
|
|
@ -102,11 +106,13 @@ export function Map(props: MapProps) {
|
||||||
makeLegend(colorScheme, colorResult.min, colorResult.max);
|
makeLegend(colorScheme, colorResult.min, colorResult.max);
|
||||||
const colorStopsValue = calculateColorStops(colorScheme, colorResult.min, colorResult.max);
|
const colorStopsValue = calculateColorStops(colorScheme, colorResult.min, colorResult.max);
|
||||||
heatmap.setColorStops(colorStopsValue);
|
heatmap.setColorStops(colorStopsValue);
|
||||||
|
colorScaleRef.current = { min: colorResult.min, max: colorResult.max };
|
||||||
} else {
|
} else {
|
||||||
// Set safe default stops so stale stops from a previous metric don't cause
|
// 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
|
// Mapbox expression errors when the hexgrid produces cells with different value ranges
|
||||||
const colorStopsValue = calculateColorStops(colorScheme, 0, 1);
|
const colorStopsValue = calculateColorStops(colorScheme, 0, 1);
|
||||||
heatmap.setColorStops(colorStopsValue);
|
heatmap.setColorStops(colorStopsValue);
|
||||||
|
colorScaleRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
heatmap.update();
|
heatmap.update();
|
||||||
|
|
@ -407,6 +413,16 @@ export function Map(props: MapProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getListingDialog(properties: PropertyWithCoords[]) {
|
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 (
|
return (
|
||||||
<ScrollArea className="rounded-md">
|
<ScrollArea className="rounded-md">
|
||||||
<div className="overflow-y-auto max-h-[500px] w-[420px]">
|
<div className="overflow-y-auto max-h-[500px] w-[420px]">
|
||||||
|
|
@ -421,6 +437,7 @@ export function Map(props: MapProps) {
|
||||||
variant="full"
|
variant="full"
|
||||||
avgPricePerSqm={avgPricePerSqm}
|
avgPricePerSqm={avgPricePerSqm}
|
||||||
allPOIs={props.pois}
|
allPOIs={props.pois}
|
||||||
|
metricColor={colorFor(property.properties)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import useEmblaCarousel from 'embla-carousel-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 type { PropertyProperties, POIDistanceInfo, POI } from '@/types';
|
||||||
import { formatDuration, formatPrice, formatInteger, formatPricePerSqmShort, isFiniteNumber, EM_DASH } from '@/utils/format';
|
import { formatDuration, formatPrice, formatInteger, formatPricePerSqmShort, isFiniteNumber, EM_DASH } from '@/utils/format';
|
||||||
|
|
||||||
|
|
@ -126,7 +126,7 @@ function CardCarousel({ photos, altText }: { photos: string[]; altText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
className="overflow-hidden h-full focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
className="overflow-hidden h-full focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
ref={emblaRef}
|
ref={emblaRef}
|
||||||
|
|
@ -146,6 +146,23 @@ function CardCarousel({ photos, altText }: { photos: string[]; altText?: string
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="absolute bottom-1 left-0 right-0 flex justify-center gap-1">
|
||||||
{photos.map((_, i) => (
|
{photos.map((_, i) => (
|
||||||
<div
|
<div
|
||||||
|
|
@ -167,6 +184,10 @@ interface PropertyCardProps {
|
||||||
avgPricePerSqm?: number;
|
avgPricePerSqm?: number;
|
||||||
allPOIs?: POI[];
|
allPOIs?: POI[];
|
||||||
onClick?: () => void;
|
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({
|
export function PropertyCard({
|
||||||
|
|
@ -176,6 +197,7 @@ export function PropertyCard({
|
||||||
avgPricePerSqm,
|
avgPricePerSqm,
|
||||||
allPOIs,
|
allPOIs,
|
||||||
onClick,
|
onClick,
|
||||||
|
metricColor,
|
||||||
}: PropertyCardProps) {
|
}: PropertyCardProps) {
|
||||||
// BUY listings may have null numeric / date fields; render "—" at the JSX leaf
|
// 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
|
// 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
|
// Full variant
|
||||||
return (
|
return (
|
||||||
<div
|
<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' : ''
|
isHighlighted ? 'ring-2 ring-primary' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={handleClick}
|
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 */}
|
{/* Image section with 16:10 aspect ratio */}
|
||||||
<div className="relative aspect-[16/10] bg-muted">
|
<div className="relative aspect-[16/10] bg-muted">
|
||||||
|
|
|
||||||
|
|
@ -187,4 +187,40 @@ describe('PropertyCard', () => {
|
||||||
expect(container.textContent).toContain('4m');
|
expect(container.textContent).toContain('4m');
|
||||||
expect(container.textContent).toContain('15m');
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { MAP_CONFIG } from '@/constants';
|
import { MAP_CONFIG } from '@/constants';
|
||||||
|
import {
|
||||||
|
LOW_IS_GOOD_COLOR_STOPS,
|
||||||
|
HIGH_IS_GOOD_COLOR_STOPS,
|
||||||
|
interpolateMetricColor,
|
||||||
|
} from '@/constants/colorSchemes';
|
||||||
|
|
||||||
describe('MAP_CONFIG', () => {
|
describe('MAP_CONFIG', () => {
|
||||||
describe('B18 — default map center is London', () => {
|
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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -91,3 +91,50 @@ export const COLOR_SCHEME_NAMES = {
|
||||||
HIGH_IS_GOOD: 'Red → Green (high is good)',
|
HIGH_IS_GOOD: 'Red → Green (high is good)',
|
||||||
LEGACY: 'Classic (blue → orange)',
|
LEGACY: 'Classic (blue → orange)',
|
||||||
} as const;
|
} 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})`;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue