Add filter info
This commit is contained in:
parent
01ec17ff04
commit
f7d586a1e9
5 changed files with 682 additions and 234 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import { memo } from 'react';
|
||||
import { Slider } from './ui/slider';
|
||||
import { Label } from './ui/label';
|
||||
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||
|
|
@ -10,6 +11,7 @@ interface FiltersProps {
|
|||
enabledFeatures: Set<string>;
|
||||
onAddFilter: (name: string) => void;
|
||||
onRemoveFilter: (name: string) => void;
|
||||
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||
onDragStart: (name: string) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
|
|
@ -23,7 +25,7 @@ function formatValue(value: number): string {
|
|||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
export default function Filters({
|
||||
export default memo(function Filters({
|
||||
features,
|
||||
filters,
|
||||
activeFeature,
|
||||
|
|
@ -31,6 +33,7 @@ export default function Filters({
|
|||
enabledFeatures,
|
||||
onAddFilter,
|
||||
onRemoveFilter,
|
||||
onFilterChange,
|
||||
onDragStart,
|
||||
onDragChange,
|
||||
onDragEnd,
|
||||
|
|
@ -40,9 +43,7 @@ export default function Filters({
|
|||
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
|
||||
|
||||
return (
|
||||
<div className="w-72 p-4 bg-white shadow-lg space-y-4 overflow-y-auto max-h-screen">
|
||||
<h1 className="text-xl font-bold">UK Property Prices</h1>
|
||||
|
||||
<div className="w-72 p-4 bg-white shadow-lg space-y-4 overflow-y-auto">
|
||||
<div className="text-sm text-slate-500">Zoom: {zoom.toFixed(1)}</div>
|
||||
|
||||
{/* Add filter dropdown */}
|
||||
|
|
@ -67,10 +68,64 @@ export default function Filters({
|
|||
|
||||
{/* Active filters */}
|
||||
{enabledFeatureList.map((feature) => {
|
||||
if (feature.type === 'enum') {
|
||||
const selectedValues = (filters[feature.name] as string[]) || [];
|
||||
const allValues = feature.values || [];
|
||||
return (
|
||||
<div key={feature.name} className="space-y-1 p-2 rounded">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">{feature.label}</Label>
|
||||
<button
|
||||
onClick={() => onRemoveFilter(feature.name)}
|
||||
className="text-slate-400 hover:text-slate-700 text-sm px-1"
|
||||
title="Remove filter"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs mb-1">
|
||||
<button
|
||||
className="text-blue-600 hover:underline"
|
||||
onClick={() => onFilterChange(feature.name, [...allValues])}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
className="text-blue-600 hover:underline"
|
||||
onClick={() => onFilterChange(feature.name, [])}
|
||||
>
|
||||
None
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-0.5 max-h-40 overflow-y-auto">
|
||||
{allValues.map((val) => (
|
||||
<label key={val} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedValues.includes(val)}
|
||||
onChange={() => {
|
||||
const next = selectedValues.includes(val)
|
||||
? selectedValues.filter((v) => v !== val)
|
||||
: [...selectedValues, val];
|
||||
onFilterChange(feature.name, next);
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
{val}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Numeric feature
|
||||
const isActive = activeFeature === feature.name;
|
||||
const displayValue =
|
||||
isActive && dragValue ? dragValue : filters[feature.name] || [feature.min, feature.max];
|
||||
const step = (feature.max - feature.min) / 100;
|
||||
isActive && dragValue
|
||||
? dragValue
|
||||
: (filters[feature.name] as [number, number]) || [feature.min!, feature.max!];
|
||||
const step = (feature.max! - feature.min!) / 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -90,8 +145,8 @@ export default function Filters({
|
|||
</button>
|
||||
</div>
|
||||
<Slider
|
||||
min={feature.min}
|
||||
max={feature.max}
|
||||
min={feature.min!}
|
||||
max={feature.max!}
|
||||
step={step}
|
||||
value={[displayValue[0], displayValue[1]]}
|
||||
onValueChange={([min, max]) => onDragChange([min, max])}
|
||||
|
|
@ -135,4 +190,4 @@ export default function Filters({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useRef, useEffect, useState, useMemo } from 'react';
|
||||
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
|
||||
import { Map as MapGL, useControl } from 'react-map-gl/maplibre';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
|
|
@ -17,6 +17,7 @@ interface MapProps {
|
|||
features: FeatureMeta[];
|
||||
selectedHexagonId: string | null;
|
||||
onHexagonClick: (h3: string) => void;
|
||||
initialViewState?: ViewState;
|
||||
}
|
||||
|
||||
// Twemoji CDN base URL
|
||||
|
|
@ -118,9 +119,6 @@ interface Dimensions {
|
|||
height: number;
|
||||
}
|
||||
|
||||
// First label layer in the Carto Positron style — hexagons render below this
|
||||
const LABEL_LAYER_ID = 'waterway_label';
|
||||
|
||||
function DeckOverlay({
|
||||
layers,
|
||||
getTooltip,
|
||||
|
|
@ -130,7 +128,13 @@ function DeckOverlay({
|
|||
getTooltip: any;
|
||||
}) {
|
||||
const overlay = useControl(() => new MapboxOverlay({ interleaved: true }));
|
||||
overlay.setProps({ layers, getTooltip });
|
||||
const prevLayersRef = useRef(layers);
|
||||
const prevTooltipRef = useRef(getTooltip);
|
||||
if (layers !== prevLayersRef.current || getTooltip !== prevTooltipRef.current) {
|
||||
prevLayersRef.current = layers;
|
||||
prevTooltipRef.current = getTooltip;
|
||||
overlay.setProps({ layers, getTooltip });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +147,81 @@ function countToColor(t: number): [number, number, number] {
|
|||
return [r, g, b];
|
||||
}
|
||||
|
||||
export default function Map({
|
||||
function PostcodeSearch({
|
||||
onFlyTo,
|
||||
}: {
|
||||
onFlyTo: (lat: number, lng: number, zoom: number) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://api.postcodes.io/postcodes/${encodeURIComponent(trimmed)}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
setError('Postcode not found');
|
||||
return;
|
||||
}
|
||||
const json = await res.json();
|
||||
if (json.status === 200 && json.result) {
|
||||
onFlyTo(json.result.latitude, json.result.longitude, 14);
|
||||
setQuery('');
|
||||
} else {
|
||||
setError('Postcode not found');
|
||||
}
|
||||
} catch {
|
||||
setError('Lookup failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[query, onFlyTo]
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="absolute top-3 left-3 z-10 flex flex-col gap-1"
|
||||
>
|
||||
<div className="flex shadow-lg rounded overflow-hidden">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="Search postcode..."
|
||||
className="px-3 py-2 text-sm w-40 border-none outline-none bg-white"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-3 py-2 bg-blue-500 text-white text-sm hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '...' : 'Go'}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<span className="text-xs text-red-600 bg-white/90 rounded px-2 py-0.5 shadow">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(function Map({
|
||||
data,
|
||||
pois,
|
||||
onViewChange,
|
||||
|
|
@ -152,9 +230,10 @@ export default function Map({
|
|||
features,
|
||||
selectedHexagonId,
|
||||
onHexagonClick,
|
||||
initialViewState,
|
||||
}: MapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [viewState, setViewState] = useState<ViewState>(INITIAL_VIEW);
|
||||
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
||||
|
||||
// Track container dimensions with ResizeObserver
|
||||
|
|
@ -177,16 +256,29 @@ export default function Map({
|
|||
useEffect(() => {
|
||||
if (dimensions.width === 0 || dimensions.height === 0) return;
|
||||
|
||||
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
|
||||
const raw = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
|
||||
const resolution = zoomToResolution(viewState.zoom);
|
||||
|
||||
onViewChange({ resolution, bounds, zoom: viewState.zoom });
|
||||
// Quantize bounds to 0.01° to reduce state churn and improve backend cache hits
|
||||
const QUANT = 0.01;
|
||||
const bounds: Bounds = {
|
||||
south: Math.floor(raw.south / QUANT) * QUANT,
|
||||
west: Math.floor(raw.west / QUANT) * QUANT,
|
||||
north: Math.ceil(raw.north / QUANT) * QUANT,
|
||||
east: Math.ceil(raw.east / QUANT) * QUANT,
|
||||
};
|
||||
|
||||
onViewChange({ resolution, bounds, zoom: viewState.zoom, latitude: viewState.latitude, longitude: viewState.longitude });
|
||||
}, [viewState, dimensions, onViewChange]);
|
||||
|
||||
const handleMove = useCallback((evt: { viewState: ViewState }) => {
|
||||
setViewState(evt.viewState);
|
||||
}, []);
|
||||
|
||||
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
|
||||
setViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
|
||||
}, []);
|
||||
|
||||
// Make place labels more legible over the colored hexagons
|
||||
const handleMapLoad = useCallback(
|
||||
(evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
|
||||
|
|
@ -197,6 +289,8 @@ export default function Map({
|
|||
map.setPaintProperty(layer.id, 'text-halo-width', 2);
|
||||
map.setPaintProperty(layer.id, 'text-color', '#222');
|
||||
}
|
||||
map.setLayoutProperty('building', 'visibility', 'none');
|
||||
map.setLayoutProperty('building-top', 'visibility', 'none');
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
|
@ -236,63 +330,107 @@ export default function Map({
|
|||
return { min, max };
|
||||
}, [data]);
|
||||
|
||||
// Determine color mode
|
||||
const colorFeatureMeta = activeFeature
|
||||
? features.find((f) => f.name === activeFeature) || null
|
||||
: null;
|
||||
// Memoize feature lookup to avoid new reference each render
|
||||
const colorFeatureMeta = useMemo(
|
||||
() => (activeFeature ? features.find((f) => f.name === activeFeature) || null : null),
|
||||
[activeFeature, features]
|
||||
);
|
||||
|
||||
// Use refs for values that change during drag so layers aren't recreated
|
||||
const activeFeatureRef = useRef(activeFeature);
|
||||
activeFeatureRef.current = activeFeature;
|
||||
const dragValueRef = useRef(dragValue);
|
||||
dragValueRef.current = dragValue;
|
||||
const colorFeatureMetaRef = useRef(colorFeatureMeta);
|
||||
colorFeatureMetaRef.current = colorFeatureMeta;
|
||||
const countRangeRef = useRef(countRange);
|
||||
countRangeRef.current = countRange;
|
||||
const selectedHexagonIdRef = useRef(selectedHexagonId);
|
||||
selectedHexagonIdRef.current = selectedHexagonId;
|
||||
|
||||
// Stable click handler using ref
|
||||
const onHexagonClickRef = useRef(onHexagonClick);
|
||||
onHexagonClickRef.current = onHexagonClick;
|
||||
const handleHexagonClick = useCallback(
|
||||
(info: PickingInfo<HexagonData>) => {
|
||||
if (info.object && 'h3' in info.object) {
|
||||
onHexagonClick(info.object.h3);
|
||||
onHexagonClickRef.current(info.object.h3);
|
||||
}
|
||||
},
|
||||
[onHexagonClick]
|
||||
[]
|
||||
);
|
||||
|
||||
const layers = useMemo(
|
||||
() => [
|
||||
// Stable hover handler using ref
|
||||
const handlePoiHoverRef = useRef(handlePoiHover);
|
||||
handlePoiHoverRef.current = handlePoiHover;
|
||||
const stablePoiHover = useCallback((info: PickingInfo<POI>) => {
|
||||
handlePoiHoverRef.current(info);
|
||||
}, []);
|
||||
|
||||
// Derive a trigger value from color-affecting state — avoids useEffect+setState double-render
|
||||
const colorTrigger = `${activeFeature}|${dragValue?.[0]}|${dragValue?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`;
|
||||
|
||||
// Hexagon layer — only recreated when data or color trigger changes
|
||||
const hexLayer = useMemo(
|
||||
() =>
|
||||
new H3HexagonLayer<HexagonData>({
|
||||
id: 'h3-hexagons',
|
||||
data,
|
||||
getHexagon: (d) => d.h3,
|
||||
getFillColor: (d) => {
|
||||
if (activeFeature && dragValue && colorFeatureMeta) {
|
||||
// Drag mode: color by feature value using gradient
|
||||
const val = d[`min_${activeFeature}`];
|
||||
if (val == null) return [128, 128, 128] as [number, number, number];
|
||||
const range = dragValue[1] - dragValue[0];
|
||||
if (range === 0) return GRADIENT[0].color;
|
||||
const t = ((val as number) - dragValue[0]) / range;
|
||||
return normalizedToColor(Math.max(0, Math.min(1, t)));
|
||||
const af = activeFeatureRef.current;
|
||||
const dv = dragValueRef.current;
|
||||
const cfm = colorFeatureMetaRef.current;
|
||||
if (af && dv && cfm) {
|
||||
const val = d[`min_${af}`];
|
||||
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
|
||||
const min = dv[0];
|
||||
const max = dv[1];
|
||||
const minVal = d[`min_${af}`] as number;
|
||||
const maxVal = d[`max_${af}`] as number;
|
||||
// Gray out hexagons outside drag range
|
||||
if (maxVal < min || minVal > max) {
|
||||
return [180, 180, 180, 60] as [number, number, number, number];
|
||||
}
|
||||
const range = max - min;
|
||||
if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number];
|
||||
const t = ((val as number) - min) / range;
|
||||
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
|
||||
return [...rgb, 200] as [number, number, number, number];
|
||||
}
|
||||
// Normal mode: color by count using blue scale
|
||||
const cr = countRangeRef.current;
|
||||
const c = d.count as number;
|
||||
const t = (c - countRange.min) / (countRange.max - countRange.min);
|
||||
return countToColor(Math.max(0, Math.min(1, t)));
|
||||
const t = (c - cr.min) / (cr.max - cr.min);
|
||||
return [...countToColor(Math.max(0, Math.min(1, t))), 200] as [number, number, number, number];
|
||||
},
|
||||
getLineColor: (d) =>
|
||||
(d.h3 === selectedHexagonId ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [
|
||||
(d.h3 === selectedHexagonIdRef.current ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
],
|
||||
getLineWidth: (d) => (d.h3 === selectedHexagonId ? 2 : 0),
|
||||
getLineWidth: (d) => (d.h3 === selectedHexagonIdRef.current ? 2 : 0),
|
||||
lineWidthUnits: 'pixels',
|
||||
updateTriggers: {
|
||||
getFillColor: [activeFeature, dragValue, countRange, colorFeatureMeta],
|
||||
getLineColor: [selectedHexagonId],
|
||||
getLineWidth: [selectedHexagonId],
|
||||
getFillColor: [colorTrigger],
|
||||
getLineColor: [colorTrigger],
|
||||
getLineWidth: [colorTrigger],
|
||||
},
|
||||
extruded: false,
|
||||
pickable: true,
|
||||
opacity: 0.5,
|
||||
opacity: 1,
|
||||
highPrecision: true,
|
||||
onClick: handleHexagonClick,
|
||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||
beforeId: LABEL_LAYER_ID,
|
||||
beforeId: "waterway_label",
|
||||
}),
|
||||
[data, colorTrigger, handleHexagonClick]
|
||||
);
|
||||
|
||||
// POI layer — independent, only recreated when POI data changes
|
||||
const poiLayer = useMemo(
|
||||
() =>
|
||||
new IconLayer<POI>({
|
||||
id: 'poi-icons',
|
||||
data: pois,
|
||||
|
|
@ -306,22 +444,17 @@ export default function Map({
|
|||
sizeMinPixels: 20,
|
||||
sizeMaxPixels: 40,
|
||||
pickable: true,
|
||||
onHover: handlePoiHover,
|
||||
onHover: stablePoiHover,
|
||||
}),
|
||||
],
|
||||
[
|
||||
data,
|
||||
pois,
|
||||
handlePoiHover,
|
||||
handleHexagonClick,
|
||||
activeFeature,
|
||||
dragValue,
|
||||
countRange,
|
||||
colorFeatureMeta,
|
||||
selectedHexagonId,
|
||||
]
|
||||
[pois, stablePoiHover]
|
||||
);
|
||||
|
||||
const layers = useMemo(() => [hexLayer, poiLayer], [hexLayer, poiLayer]);
|
||||
|
||||
// Tooltip uses refs to avoid being a layer dependency
|
||||
const featuresRef = useRef(features);
|
||||
featuresRef.current = features;
|
||||
|
||||
const getTooltip = useCallback(
|
||||
({ object }: { object?: HexagonData }) => {
|
||||
if (!object || !('h3' in object)) return null;
|
||||
|
|
@ -330,7 +463,7 @@ export default function Map({
|
|||
const lines: string[] = [];
|
||||
lines.push(`<strong>${(hex.count as number).toLocaleString()} properties</strong>`);
|
||||
|
||||
for (const f of features) {
|
||||
for (const f of featuresRef.current) {
|
||||
const minVal = hex[`min_${f.name}`];
|
||||
const maxVal = hex[`max_${f.name}`];
|
||||
if (minVal != null && maxVal != null) {
|
||||
|
|
@ -342,7 +475,7 @@ export default function Map({
|
|||
typeof maxVal === 'number'
|
||||
? maxVal.toLocaleString(undefined, { maximumFractionDigits: 1 })
|
||||
: String(maxVal);
|
||||
const highlight = f.name === activeFeature ? 'font-weight: bold;' : '';
|
||||
const highlight = f.name === activeFeatureRef.current ? 'font-weight: bold;' : '';
|
||||
lines.push(`<div style="${highlight}">${f.label}: ${minStr} - ${maxStr}</div>`);
|
||||
}
|
||||
}
|
||||
|
|
@ -356,7 +489,7 @@ export default function Map({
|
|||
},
|
||||
};
|
||||
},
|
||||
[features, activeFeature]
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -371,6 +504,7 @@ export default function Map({
|
|||
>
|
||||
<DeckOverlay layers={layers} getTooltip={getTooltip as never} />
|
||||
</MapGL>
|
||||
<PostcodeSearch onFlyTo={handleFlyTo} />
|
||||
{popupInfo && (
|
||||
<div
|
||||
className="absolute pointer-events-none bg-white rounded shadow-lg p-2 text-sm"
|
||||
|
|
@ -387,4 +521,4 @@ export default function Map({
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -100,122 +100,100 @@ export function PropertiesPane({
|
|||
);
|
||||
}
|
||||
|
||||
function formatDuration(d: string): string {
|
||||
if (d === 'F') return 'Freehold';
|
||||
if (d === 'L') return 'Leasehold';
|
||||
return d;
|
||||
}
|
||||
|
||||
function formatAge(value: number): string {
|
||||
// construction_age_band is a midpoint year, e.g. 1935
|
||||
if (value >= 1000) return `~${Math.round(value)}`;
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
|
||||
// Helper to get a numeric value from a property, trying multiple field names
|
||||
function getNum(property: Property, ...keys: string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const v = property[key];
|
||||
if (v !== undefined && v !== null && typeof v === 'number') return v;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Property card component showing all fields
|
||||
function PropertyCard({ property }: { property: Property }) {
|
||||
const formatNumber = (value: number | undefined, decimals = 0): string => {
|
||||
const fmt = (value: number | undefined, decimals = 0): string => {
|
||||
if (value === undefined) return '';
|
||||
return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString();
|
||||
};
|
||||
|
||||
const price = getNum(property, 'Last known price', 'latest_price');
|
||||
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
|
||||
const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area');
|
||||
const rooms = getNum(property, 'Rooms (including bedrooms & bathrooms)', 'number_habitable_rooms');
|
||||
const age = getNum(property, 'Approximate construction age', 'construction_age_band');
|
||||
|
||||
return (
|
||||
<div className="p-4 border-b border-gray-100 hover:bg-gray-50">
|
||||
{/* Address */}
|
||||
{/* Address & postcode */}
|
||||
<div className="font-semibold">{property.address || 'Unknown Address'}</div>
|
||||
<div className="text-sm text-gray-600">{property.postcode}</div>
|
||||
|
||||
{/* Price */}
|
||||
{property.latest_price && (
|
||||
{price !== undefined && (
|
||||
<div className="mt-2 text-lg font-bold text-green-700">
|
||||
£{formatNumber(property.latest_price as number)}
|
||||
{property.price_per_sqm && (
|
||||
£{fmt(price)}
|
||||
{pricePerSqm !== undefined && (
|
||||
<span className="text-sm font-normal text-gray-600">
|
||||
{' '}
|
||||
(£{formatNumber(property.price_per_sqm as number)}/m²)
|
||||
(£{fmt(pricePerSqm)}/m²)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Property details grid */}
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
{property.property_type && (
|
||||
<div>
|
||||
<span className="text-gray-600">Type:</span> {property.property_type}
|
||||
<span className="text-gray-500">Type:</span> {property.property_type}
|
||||
</div>
|
||||
)}
|
||||
{property.built_form && (
|
||||
<div>
|
||||
<span className="text-gray-600">Form:</span> {property.built_form}
|
||||
<span className="text-gray-500">Built form:</span> {property.built_form}
|
||||
</div>
|
||||
)}
|
||||
{property.total_floor_area && (
|
||||
{property.duration && (
|
||||
<div>
|
||||
<span className="text-gray-600">Area:</span>{' '}
|
||||
{formatNumber(property.total_floor_area as number)}m²
|
||||
<span className="text-gray-500">Tenure:</span> {formatDuration(property.duration)}
|
||||
</div>
|
||||
)}
|
||||
{property.number_habitable_rooms && (
|
||||
{floorArea !== undefined && (
|
||||
<div>
|
||||
<span className="text-gray-600">Rooms:</span>{' '}
|
||||
{formatNumber(property.number_habitable_rooms as number)}
|
||||
<span className="text-gray-500">Floor area:</span> {fmt(floorArea)}m²
|
||||
</div>
|
||||
)}
|
||||
{rooms !== undefined && (
|
||||
<div>
|
||||
<span className="text-gray-500">Rooms:</span> {fmt(rooms)}
|
||||
</div>
|
||||
)}
|
||||
{age !== undefined && (
|
||||
<div>
|
||||
<span className="text-gray-500">Built:</span> {formatAge(age)}
|
||||
</div>
|
||||
)}
|
||||
{property.current_energy_rating && (
|
||||
<div>
|
||||
<span className="text-gray-600">Energy:</span> {property.current_energy_rating}
|
||||
<span className="text-gray-500">EPC rating:</span> {property.current_energy_rating}
|
||||
</div>
|
||||
)}
|
||||
{property.potential_energy_rating && (
|
||||
<div>
|
||||
<span className="text-gray-600">Potential:</span> {property.potential_energy_rating}
|
||||
</div>
|
||||
)}
|
||||
{property.construction_age_band !== undefined && (
|
||||
<div>
|
||||
<span className="text-gray-600">Built (age):</span>{' '}
|
||||
{formatNumber(property.construction_age_band as number)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Journey times */}
|
||||
{property.public_transport_easy_minutes && (
|
||||
<div>
|
||||
<span className="text-gray-600">PT (easy):</span>{' '}
|
||||
{formatNumber(property.public_transport_easy_minutes as number)}min
|
||||
</div>
|
||||
)}
|
||||
{property.public_transport_quick_minutes && (
|
||||
<div>
|
||||
<span className="text-gray-600">PT (quick):</span>{' '}
|
||||
{formatNumber(property.public_transport_quick_minutes as number)}min
|
||||
</div>
|
||||
)}
|
||||
{property.cycling_minutes && (
|
||||
<div>
|
||||
<span className="text-gray-600">Cycling:</span>{' '}
|
||||
{formatNumber(property.cycling_minutes as number)}min
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deprivation scores */}
|
||||
{property.income_score !== undefined && (
|
||||
<div>
|
||||
<span className="text-gray-600">Income:</span>{' '}
|
||||
{formatNumber(property.income_score as number, 1)}
|
||||
</div>
|
||||
)}
|
||||
{property.employment_score !== undefined && (
|
||||
<div>
|
||||
<span className="text-gray-600">Employment:</span>{' '}
|
||||
{formatNumber(property.employment_score as number, 1)}
|
||||
</div>
|
||||
)}
|
||||
{property.education_score !== undefined && (
|
||||
<div>
|
||||
<span className="text-gray-600">Education:</span>{' '}
|
||||
{formatNumber(property.education_score as number, 1)}
|
||||
</div>
|
||||
)}
|
||||
{property.health_score !== undefined && (
|
||||
<div>
|
||||
<span className="text-gray-600">Health:</span>{' '}
|
||||
{formatNumber(property.health_score as number, 1)}
|
||||
</div>
|
||||
)}
|
||||
{property.crime_score !== undefined && (
|
||||
<div>
|
||||
<span className="text-gray-600">Crime:</span>{' '}
|
||||
{formatNumber(property.crime_score as number, 1)}
|
||||
<span className="text-gray-500">EPC potential:</span>{' '}
|
||||
{property.potential_energy_rating}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue