diff --git a/frontend/src/components/Map.tsx b/frontend/src/components/Map.tsx index 71796aa..a988f90 100644 --- a/frontend/src/components/Map.tsx +++ b/frontend/src/components/Map.tsx @@ -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([]); 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)[metricField]; + if (typeof raw !== 'number') return undefined; + return interpolateMetricColor(raw, scale.min, scale.max, colorScheme) ?? undefined; + }; return (
@@ -421,6 +437,7 @@ export function Map(props: MapProps) { variant="full" avgPricePerSqm={avgPricePerSqm} allPOIs={props.pois} + metricColor={colorFor(property.properties)} /> ))}
diff --git a/frontend/src/components/PropertyCard.tsx b/frontend/src/components/PropertyCard.tsx index 5e1d539..dd1b31b 100644 --- a/frontend/src/components/PropertyCard.tsx +++ b/frontend/src/components/PropertyCard.tsx @@ -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 ( -
e.stopPropagation()}> +
e.stopPropagation()}>
+ {/* Prev / next click targets — visible on hover, always available for keyboard via tabbable buttons. */} + +
{photos.map((_, i) => (
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 (
{/* Image section with 16:10 aspect ratio */}
diff --git a/frontend/src/components/__tests__/PropertyCard.test.tsx b/frontend/src/components/__tests__/PropertyCard.test.tsx index 9bf3b8f..4d7bcc5 100644 --- a/frontend/src/components/__tests__/PropertyCard.test.tsx +++ b/frontend/src/components/__tests__/PropertyCard.test.tsx @@ -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( + , + ); + 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(); + 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(); + 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(); + expect(container.querySelector('button[aria-label="Previous photo"]')).not.toBeInTheDocument(); + expect(container.querySelector('button[aria-label="Next photo"]')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/constants/__tests__/index.test.ts b/frontend/src/constants/__tests__/index.test.ts index ac93bef..41eef39 100644 --- a/frontend/src/constants/__tests__/index.test.ts +++ b/frontend/src/constants/__tests__/index.test.ts @@ -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)'); + }); +}); diff --git a/frontend/src/constants/colorSchemes.ts b/frontend/src/constants/colorSchemes.ts index ff198d3..dafcd47 100644 --- a/frontend/src/constants/colorSchemes.ts +++ b/frontend/src/constants/colorSchemes.ts @@ -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})`; +}