Format and lint

This commit is contained in:
Andras Schmelczer 2026-02-08 12:37:07 +00:00
parent 42ee2d4c51
commit 04a78e7bfe
75 changed files with 1290 additions and 719 deletions

View file

@ -1,5 +1,10 @@
import { useMemo, useState } from 'react';
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../types';
import type {
FeatureFilters,
FeatureMeta,
HexagonStatsResponse,
PostcodeFeature,
} from '../../types';
import type { HexagonLocation } from '../../lib/external-search';
import { formatValue, formatFilterValue, calculateHistogramMean } from '../../lib/format';
import { groupFeaturesByCategory } from '../../lib/features';
@ -33,7 +38,6 @@ interface AreaPaneProps {
aiSummary?: string;
aiSummaryLoading?: boolean;
aiSummaryError?: string | null;
onRetryAiSummary?: () => void;
}
export default function AreaPane({
@ -51,7 +55,6 @@ export default function AreaPane({
aiSummary,
aiSummaryLoading,
aiSummaryError,
onRetryAiSummary,
}: AreaPaneProps) {
// For postcodes, use local data for count
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
@ -145,7 +148,9 @@ export default function AreaPane({
>
<div className="flex items-center gap-1.5">
<LightbulbIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400" />
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">AI Summary</span>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
AI Summary
</span>
</div>
<ChevronIcon
direction={aiSummaryExpanded ? 'down' : 'right'}
@ -156,15 +161,7 @@ export default function AreaPane({
<>
{aiSummaryError ? (
<div className="text-xs text-warm-600 dark:text-warm-400">
<span>Failed to generate summary. </span>
{onRetryAiSummary && (
<button
onClick={onRetryAiSummary}
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 underline"
>
Retry
</button>
)}
Failed to generate summary.
</div>
) : aiSummaryLoading ? (
<div className="space-y-1.5">
@ -191,19 +188,24 @@ export default function AreaPane({
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-teal-500 dark:bg-teal-400 rounded" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">Teal bars</span> show the distribution in this selected area
<span className="font-medium text-warm-900 dark:text-warm-100">Teal bars</span>{' '}
show the distribution in this selected area
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-warm-300/60 dark:bg-warm-600/60 rounded" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">Gray bars</span> show the overall distribution across all areas
<span className="font-medium text-warm-900 dark:text-warm-100">Gray bars</span>{' '}
show the overall distribution across all areas
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-px border-t border-dashed border-warm-500 dark:border-warm-400" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">Dashed line</span> indicates the global average
<span className="font-medium text-warm-900 dark:text-warm-100">
Dashed line
</span>{' '}
indicates the global average
</span>
</div>
</div>
@ -219,9 +221,9 @@ export default function AreaPane({
// Features that are part of a stacked enum config (rendered as compact charts)
const stackedEnumFeatureNames = new Set(
stackedEnumCharts?.flatMap((c) =>
(stackedEnumCharts?.flatMap((c) =>
[c.feature, ...c.components].filter(Boolean)
) as string[] ?? []
) as string[]) ?? []
);
const isExpanded = !collapsedGroups.has(group.name);
@ -234,40 +236,156 @@ export default function AreaPane({
onToggle={() => toggleGroup(group.name)}
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
/>
{isExpanded && <div className="px-3 py-2 space-y-3">
{/* Price History in Property group */}
{group.name === 'Property' && stats.price_history && (() => {
// Only show chart if there are at least 2 unique years
const uniqueYears = new Set(stats.price_history.map(p => Math.floor(p.year)));
return uniqueYears.size > 1;
})() && (
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2">
<span className="text-xs text-warm-700 dark:text-warm-300">Price History</span>
<PriceHistoryChart points={stats.price_history} />
</div>
)}
{stackedCharts
? // Render stacked charts for this group
stackedCharts.map((chart) => {
const segments = chart.components
.map((name) => ({
name,
value: numericByName.get(name)?.mean ?? 0,
}))
{isExpanded && (
<div className="px-3 py-2 space-y-3">
{/* Price History in Property group */}
{group.name === 'Property' &&
stats.price_history &&
(() => {
// Only show chart if there are at least 2 unique years
const uniqueYears = new Set(
stats.price_history.map((p) => Math.floor(p.year))
);
return uniqueYears.size > 1;
})() && (
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2">
<span className="text-xs text-warm-700 dark:text-warm-300">
Price History
</span>
<PriceHistoryChart points={stats.price_history} />
</div>
)}
{stackedCharts
? // Render stacked charts for this group
stackedCharts.map((chart) => {
const segments = chart.components
.map((name) => ({
name,
value: numericByName.get(name)?.mean ?? 0,
}))
.filter((s) => s.value > 0);
// Use aggregate feature stats if available, otherwise sum components
const aggregateStats = chart.feature
? numericByName.get(chart.feature)
: undefined;
const total = aggregateStats
? aggregateStats.mean
: segments.reduce((sum, s) => sum + s.value, 0);
const featureMeta = chart.feature
? globalFeatureByName.get(chart.feature)
: undefined;
if (total === 0) return null;
return (
<div
key={chart.label}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={{ ...featureMeta, name: chart.label }}
onShowInfo={setInfoFeature}
className="mr-2"
/>
) : (
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
{chart.label}
</span>
)}
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(total)}
{chart.unit ? ` ${chart.unit}` : ''}
</span>
</div>
<StackedBarChart segments={segments} total={total} />
</div>
);
})
: // Default: render each feature individually (skip stacked enum features)
group.features
.filter((f) => !stackedEnumFeatureNames.has(f.name))
.map((feature) => {
const numericStats = numericByName.get(feature.name);
const enumStats = enumByName.get(feature.name);
if (numericStats) {
const globalFeature = globalFeatureByName.get(feature.name);
const globalHistogram = globalFeature?.histogram;
const globalMean = globalHistogram
? calculateHistogramMean(globalHistogram)
: undefined;
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline">
<FeatureLabel
feature={feature}
onShowInfo={setInfoFeature}
className="mr-2"
/>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean, feature)}
</span>
</div>
{numericStats.histogram &&
(globalHistogram ? (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={globalHistogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
globalMean={globalMean}
formatLabel={formatFilterValue}
/>
) : (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={numericStats.histogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
formatLabel={formatFilterValue}
/>
))}
</div>
);
}
if (enumStats) {
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
<EnumBarChart counts={enumStats.counts} />
</div>
);
}
return null;
})}
{/* Stacked enum charts */}
{stackedEnumCharts?.map((chart) => {
const featureMeta = chart.feature
? globalFeatureByName.get(chart.feature)
: undefined;
// Single component: render as a stacked bar (like crime charts)
if (chart.components.length === 1) {
const stats = enumByName.get(chart.components[0]);
if (!stats) return null;
const segments = chart.valueOrder
.map((value) => ({ name: value, value: stats.counts[value] ?? 0 }))
.filter((s) => s.value > 0);
// Use aggregate feature stats if available, otherwise sum components
const aggregateStats = chart.feature
? numericByName.get(chart.feature)
: undefined;
const total = aggregateStats
? aggregateStats.mean
: segments.reduce((sum, s) => sum + s.value, 0);
const featureMeta = chart.feature
? globalFeatureByName.get(chart.feature)
: undefined;
const total = segments.reduce((sum, s) => sum + s.value, 0);
if (total === 0) return null;
return (
@ -278,7 +396,7 @@ export default function AreaPane({
<div className="flex justify-between items-baseline mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={{ ...featureMeta, name: chart.label }}
feature={featureMeta}
onShowInfo={setInfoFeature}
className="mr-2"
/>
@ -288,166 +406,57 @@ export default function AreaPane({
</span>
)}
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(total)}
{chart.unit ? ` ${chart.unit}` : ''}
{total.toLocaleString()}
</span>
</div>
<StackedBarChart segments={segments} total={total} />
<StackedBarChart
segments={segments}
total={total}
colorMap={Object.fromEntries(
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
)}
/>
</div>
);
})
: // Default: render each feature individually (skip stacked enum features)
group.features
.filter((f) => !stackedEnumFeatureNames.has(f.name))
.map((feature) => {
const numericStats = numericByName.get(feature.name);
const enumStats = enumByName.get(feature.name);
}
if (numericStats) {
const globalFeature = globalFeatureByName.get(feature.name);
const globalHistogram = globalFeature?.histogram;
const globalMean = globalHistogram
? calculateHistogramMean(globalHistogram)
: undefined;
// Multi-component: render as compact multi-row chart (like risk features)
const components = chart.components
.map((name) => {
const stats = enumByName.get(name);
return stats ? { label: name, stats } : null;
})
.filter((c): c is NonNullable<typeof c> => c !== null);
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline">
<FeatureLabel
feature={feature}
onShowInfo={setInfoFeature}
className="mr-2"
/>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean, feature)}
</span>
</div>
{numericStats.histogram && (
globalHistogram ? (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={globalHistogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
globalMean={globalMean}
formatLabel={formatFilterValue}
/>
) : (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={numericStats.histogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
formatLabel={formatFilterValue}
/>
)
)}
</div>
);
}
if (enumStats) {
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
<EnumBarChart counts={enumStats.counts} />
</div>
);
}
return null;
})}
{/* Stacked enum charts */}
{stackedEnumCharts?.map((chart) => {
const featureMeta = chart.feature
? globalFeatureByName.get(chart.feature)
: undefined;
// Single component: render as a stacked bar (like crime charts)
if (chart.components.length === 1) {
const stats = enumByName.get(chart.components[0]);
if (!stats) return null;
const segments = chart.valueOrder
.map((value) => ({ name: value, value: stats.counts[value] ?? 0 }))
.filter((s) => s.value > 0);
const total = segments.reduce((sum, s) => sum + s.value, 0);
if (total === 0) return null;
if (components.length === 0) return null;
return (
<div
key={chart.label}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline mb-1.5">
<div className="mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={featureMeta}
feature={{ ...featureMeta, name: chart.label }}
onShowInfo={setInfoFeature}
className="mr-2"
/>
) : (
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
<span className="text-xs text-warm-700 dark:text-warm-300">
{chart.label}
</span>
)}
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{total.toLocaleString()}
</span>
</div>
<StackedBarChart
segments={segments}
total={total}
colorMap={Object.fromEntries(
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
)}
<StackedEnumChart
components={components}
valueOrder={chart.valueOrder}
valueColors={chart.valueColors}
/>
</div>
);
}
// Multi-component: render as compact multi-row chart (like risk features)
const components = chart.components
.map((name) => {
const stats = enumByName.get(name);
return stats ? { label: name, stats } : null;
})
.filter((c): c is NonNullable<typeof c> => c !== null);
if (components.length === 0) return null;
return (
<div
key={chart.label}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={{ ...featureMeta, name: chart.label }}
onShowInfo={setInfoFeature}
/>
) : (
<span className="text-xs text-warm-700 dark:text-warm-300">
{chart.label}
</span>
)}
</div>
<StackedEnumChart
components={components}
valueOrder={chart.valueOrder}
valueColors={chart.valueColors}
/>
</div>
);
})}
</div>}
})}
</div>
)}
</div>
);
})}

View file

@ -51,15 +51,16 @@ export function DualHistogram({
const localMax = Math.max(...localBars, 1);
const globalMax = Math.max(...globalBars, 1);
const fmt = formatLabel ?? ((v: number) => (Number.isInteger(v) ? v.toLocaleString() : v.toFixed(1)));
const fmt =
formatLabel ?? ((v: number) => (Number.isInteger(v) ? v.toLocaleString() : v.toFixed(1)));
// Compute center value for each bar.
// Bar 0 = low outlier, bars 1..n-2 = middle (p1 to p99), bar n-1 = high outlier.
const middleBins = Math.max(barCount - 2, 0);
const middleWidth = middleBins > 0 && p99 > p1 ? (p99 - p1) / middleBins : 0;
const barCenters: number[] = Array.from({ length: barCount }, (_, i) => {
if (i === 0) return p1; // outlier bin, label as p1
if (i === barCount - 1) return p99; // outlier bin, label as p99
if (i === 0) return p1; // outlier bin, label as p1
if (i === barCount - 1) return p99; // outlier bin, label as p99
return p1 + (i - 1 + 0.5) * middleWidth;
});
@ -71,7 +72,10 @@ export function DualHistogram({
let bestDist = Infinity;
for (let i = 1; i < barCount - 1; i++) {
const dist = Math.abs(barCenters[i] - v);
if (dist < bestDist) { bestDist = dist; bestBar = i; }
if (dist < bestDist) {
bestDist = dist;
bestBar = i;
}
}
if (!tickBars.has(bestBar)) tickBars.set(bestBar, fmt(v));
}
@ -79,9 +83,7 @@ export function DualHistogram({
// Mean line: position as fraction across the bar area
const meanFrac = globalMean != null && p99 > p1 ? (globalMean - p1) / (p99 - p1) : null;
// Account for outlier bins: middle region spans bars 1..n-2
const meanPct = meanFrac != null
? ((1 + meanFrac * middleBins) / barCount) * 100
: null;
const meanPct = meanFrac != null ? ((1 + meanFrac * middleBins) / barCount) * 100 : null;
return (
<div className="mt-1">

View file

@ -1,6 +1,5 @@
import { memo, useState, useMemo, useEffect } from 'react';
import { Slider } from '../ui/Slider';
import { Label } from '../ui/Label';
import { SearchInput } from '../ui/SearchInput';
import { FilterIcon, LightbulbIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
@ -138,7 +137,9 @@ function FeatureBrowser({
<EmptyState
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
title={search ? 'No matching features' : 'All features are active'}
description={search ? 'Try a different search term' : 'Remove a filter to see available features'}
description={
search ? 'Try a different search term' : 'Remove a filter to see available features'
}
className="px-3 py-4"
/>
) : (
@ -334,9 +335,9 @@ export default memo(function Filters({
Be intentional, not reactive
</h4>
<p className="text-warm-600 dark:text-warm-300">
Your future home isn't a box of cereal you grab because it's on sale. Don't let a
seemingly good deal turn into lifelong regret. Instead of waiting for listings to
appear, define what you actually want and go find it.
Your future home isn&apos;t a box of cereal you grab because it&apos;s on sale.
Don&apos;t let a seemingly good deal turn into lifelong regret. Instead of waiting
for listings to appear, define what you actually want and go find it.
</p>
</div>
@ -347,7 +348,7 @@ export default memo(function Filters({
<p className="text-warm-600 dark:text-warm-300">
Current listings show only a fraction of the market. There are too few to give you a
complete picture, yet too many to evaluate one by one. We aggregate millions of
historical sales so you can understand what's truly available in any area.
historical sales so you can understand what&apos;s truly available in any area.
</p>
</div>
@ -367,18 +368,19 @@ export default memo(function Filters({
Find the right place, not just the right listing
</h4>
<p className="text-warm-600 dark:text-warm-300">
The best areas to live don't always have properties listed right now. We help you
identify where you should be looking, so when something does come up, you're ready.
The best areas to live don&apos;t always have properties listed right now. We help
you identify where you should be looking, so when something does come up,
you&apos;re ready.
</p>
</div>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
Know what's possible
Know what&apos;s possible
</h4>
<p className="text-warm-600 dark:text-warm-300">
We'd rather tell you upfront if your expectations are unrealistic than have you
spend months searching for something that doesn't exist.
We&apos;d rather tell you upfront if your expectations are unrealistic than have you
spend months searching for something that doesn&apos;t exist.
</p>
</div>
</div>

View file

@ -16,7 +16,7 @@ import type {
FeatureMeta,
Bounds,
} from '../../types';
import { cellToLatLng } from 'h3-js';
import {
GRADIENT,
normalizedToColor,
@ -66,9 +66,9 @@ interface MapProps {
searchedPostcode?: SearchedPostcode | null;
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
bounds?: Bounds | null;
hideLegend?: boolean;
}
interface Dimensions {
width: number;
height: number;
@ -118,6 +118,7 @@ export default memo(function Map({
searchedPostcode,
onPostcodeSearched,
bounds: viewportBounds,
hideLegend = false,
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
@ -204,8 +205,13 @@ export default memo(function Map({
let max = -Infinity;
for (const d of data) {
if (viewportBounds) {
const [lat, lng] = cellToLatLng(d.h3);
if (lat < viewportBounds.south || lat > viewportBounds.north || lng < viewportBounds.west || lng > viewportBounds.east) continue;
if (
d.lat < viewportBounds.south ||
d.lat > viewportBounds.north ||
d.lon < viewportBounds.west ||
d.lon > viewportBounds.east
)
continue;
}
const c = d.count as number;
if (c < min) min = c;
@ -270,7 +276,13 @@ export default memo(function Map({
for (const d of postcodeData) {
if (viewportBounds) {
const [lng, lat] = d.properties.centroid as [number, number];
if (lat < viewportBounds.south || lat > viewportBounds.north || lng < viewportBounds.west || lng > viewportBounds.east) continue;
if (
lat < viewportBounds.south ||
lat > viewportBounds.north ||
lng < viewportBounds.west ||
lng > viewportBounds.east
)
continue;
}
const c = d.properties.count;
if (c < min) min = c;
@ -339,12 +351,23 @@ export default memo(function Map({
const dark = isDarkRef.current;
if (vf && clr && cfm) {
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
if (val == null) return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
if (val == null)
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
number,
number,
number,
number,
];
if (fr) {
const minVal = d[`min_${vf}`] as number;
const maxVal = d[`max_${vf}`] as number;
if (maxVal < fr[0] || minVal > fr[1]) {
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [
number,
number,
number,
number,
];
}
}
const range = clr[1] - clr[0];
@ -356,12 +379,10 @@ export default memo(function Map({
const cr = countRangeRef.current;
const c = d.count as number;
const t = (c - cr.min) / (cr.max - cr.min);
return [...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current), 255] as [
number,
number,
number,
number,
];
return [
...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current),
255,
] as [number, number, number, number];
},
getLineColor: (d) => {
if (d.h3 === selectedHexagonIdRef.current)
@ -407,12 +428,23 @@ export default memo(function Map({
const dark = isDarkRef.current;
if (vf && clr && cfm) {
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
if (val == null) return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
if (val == null)
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
number,
number,
number,
number,
];
if (fr) {
const minVal = d[`min_${vf}`] as number;
const maxVal = d[`max_${vf}`] as number;
if (maxVal < fr[0] || minVal > fr[1]) {
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [
number,
number,
number,
number,
];
}
}
const range = clr[1] - clr[0];
@ -424,12 +456,10 @@ export default memo(function Map({
const cr = postcodeCountRangeRef.current;
const c = d.count;
const t = (c - cr.min) / (cr.max - cr.min);
return [...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current), 180] as [
number,
number,
number,
number,
];
return [
...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current),
180,
] as [number, number, number, number];
},
getLineColor: (f) => {
const pc = f.properties.postcode;
@ -438,7 +468,12 @@ export default memo(function Map({
return [255, 255, 255, 255] as [number, number, number, number];
if (pc === hoveredPostcodeRef.current)
return [29, 228, 195, 200] as [number, number, number, number];
return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [number, number, number, number];
return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [
number,
number,
number,
number,
];
},
getLineWidth: (f) => {
const pc = f.properties.postcode;
@ -546,7 +581,14 @@ export default memo(function Map({
return [...baseLayers, searchedPostcodeHighlightLayer];
}
return baseLayers;
}, [usePostcodeView, hexLayer, postcodeLayer, postcodeLabelsLayer, poiLayer, searchedPostcodeHighlightLayer]);
}, [
usePostcodeView,
hexLayer,
postcodeLayer,
postcodeLabelsLayer,
poiLayer,
searchedPostcodeHighlightLayer,
]);
const handleMouseLeave = useCallback(() => {
setHoverPosition(null);
@ -588,26 +630,35 @@ export default memo(function Map({
) : (
<>
<PostcodeSearch onFlyTo={handleFlyTo} onPostcodeSearched={onPostcodeSearched} />
{viewFeature && colorRange && colorFeatureMeta ? (
<MapLegend
featureLabel={viewSource === 'eye' ? `Previewing \u201c${colorFeatureMeta.name}\u201d` : colorFeatureMeta.name}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
mode="feature"
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
theme={theme}
/>
) : (
<MapLegend
featureLabel="Property density"
range={usePostcodeView ? [postcodeCountRange.min, postcodeCountRange.max] : [countRange.min, countRange.max]}
showCancel={false}
onCancel={onCancelPin}
mode="density"
theme={theme}
/>
)}
{!hideLegend &&
(viewFeature && colorRange && colorFeatureMeta ? (
<MapLegend
featureLabel={
viewSource === 'eye'
? `Previewing \u201c${colorFeatureMeta.name}\u201d`
: colorFeatureMeta.name
}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
mode="feature"
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
theme={theme}
/>
) : (
<MapLegend
featureLabel="Property density"
range={
usePostcodeView
? [postcodeCountRange.min, postcodeCountRange.max]
: [countRange.min, countRange.max]
}
showCancel={false}
onCancel={onCancelPin}
mode="density"
theme={theme}
/>
))}
{popupInfo && (
<div
className="absolute bg-white dark:bg-warm-800 rounded shadow-lg p-2 text-sm dark:text-white"

View file

@ -11,6 +11,7 @@ export default function MapLegend({
mode,
enumValues,
theme = 'light',
inline = false,
}: {
featureLabel: string;
range: [number, number];
@ -19,12 +20,20 @@ export default function MapLegend({
mode: 'feature' | 'density';
enumValues?: string[];
theme?: 'light' | 'dark';
inline?: boolean;
}) {
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const gradientStyle = mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
const gradientStyle =
mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
return (
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[160px]">
<div
className={
inline
? 'bg-white dark:bg-warm-800 dark:text-white p-3 text-xs border-b border-warm-200 dark:border-warm-700'
: 'absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[160px]'
}
>
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-sm dark:text-white">{featureLabel}</span>
{showCancel && (

View file

@ -9,6 +9,7 @@ import { PropertiesPane } from './PropertiesPane';
import AreaPane from './AreaPane';
import MobileDrawer from './MobileDrawer';
import DataSources from '../data-sources/DataSources';
import MapLegend from './MapLegend';
import { TabButton } from '../ui/TabButton';
import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData';
@ -25,7 +26,7 @@ export interface ExportState {
exporting: boolean;
}
type MobileBottomTab = 'filters' | 'pois';
type MobileBottomTab = 'filters' | 'pois' | 'area';
interface MapPageProps {
features: FeatureMeta[];
@ -63,7 +64,8 @@ export default function MapPage({
isMobile = false,
}: MapPageProps) {
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null);
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(initialPOICategories);
const [selectedPOICategories, setSelectedPOICategories] =
useState<Set<string>>(initialPOICategories);
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(288, 200, 500, 'right');
@ -116,7 +118,6 @@ export default function MapPage({
const selection = useHexagonSelection({
filters,
features,
postcodeData: mapData.postcodeData,
resolution: mapData.resolution,
});
@ -133,13 +134,17 @@ export default function MapPage({
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// On mobile, open drawer and switch tab when hexagon is clicked
const handleMobileHexagonClick = useCallback((id: string, isPostcode?: boolean) => {
selection.handleHexagonClick(id, isPostcode);
if (id) {
setMobileDrawerOpen(true);
setMobileBottomTab('area');
}
}, [selection.handleHexagonClick]); // eslint-disable-line react-hooks/exhaustive-deps
const { handleHexagonClick } = selection;
const handleMobileHexagonClick = useCallback(
(id: string, isPostcode?: boolean) => {
handleHexagonClick(id, isPostcode);
if (id) {
setMobileDrawerOpen(true);
setMobileBottomTab('area');
}
},
[handleHexagonClick]
);
// Compute hexagon location for external links
const hexagonLocation = useMemo(() => {
@ -158,7 +163,13 @@ export default function MapPage({
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution };
}
}, [selection.selectedHexagon?.id, selection.selectedHexagon?.type, mapData.data, mapData.postcodeData, mapData.resolution]);
}, [
selection.selectedHexagon?.id,
selection.selectedHexagon?.type,
mapData.data,
mapData.postcodeData,
mapData.resolution,
]);
// AI area summary
const aiSummary = useAreaSummary({
@ -203,10 +214,33 @@ export default function MapPage({
onExportStateChange?.({ onExport: handleExport, exporting });
}, [handleExport, exporting, onExportStateChange]);
// Mobile legend data (computed from API-fetched data, which is already viewport-scoped)
const mobileLegendMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
[viewFeature, features]
);
const mobileDensityRange = useMemo((): [number, number] => {
const items = mapData.usePostcodeView ? mapData.postcodeData : mapData.data;
if (items.length === 0) return [0, 1];
let min = Infinity;
let max = -Infinity;
for (const d of items) {
const c =
'count' in d
? (d as { count: number }).count
: (d as { properties: { count: number } }).properties.count;
if (c < min) min = c;
if (c > max) max = c;
}
if (min === Infinity) return [0, 1];
if (min === max) return [min, min + 1];
return [min, max];
}, [mapData.data, mapData.postcodeData, mapData.usePostcodeView]);
// Signal screenshot readiness once map data has loaded
useEffect(() => {
if (screenshotMode && !mapData.loading && mapData.data.length > 0) {
window.__og_ready = true;
window.__screenshot_ready = true;
}
}, [screenshotMode, mapData.loading, mapData.data.length]);
@ -249,7 +283,9 @@ export default function MapPage({
isPostcode={selection.selectedHexagon?.type === 'postcode'}
postcodeData={
selection.selectedHexagon?.type === 'postcode'
? mapData.postcodeData.find((f) => f.properties.postcode === selection.selectedHexagon?.id) || null
? mapData.postcodeData.find(
(f) => f.properties.postcode === selection.selectedHexagon?.id
) || null
: null
}
onViewProperties={selection.handleViewPropertiesFromArea}
@ -260,7 +296,6 @@ export default function MapPage({
aiSummary={aiSummary.summary}
aiSummaryLoading={aiSummary.loading}
aiSummaryError={aiSummary.error}
onRetryAiSummary={aiSummary.retry}
/>
);
@ -319,7 +354,9 @@ export default function MapPage({
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4">
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">Connecting to server...</p>
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
Connecting to server...
</p>
</div>
</div>
)}
@ -348,6 +385,7 @@ export default function MapPage({
searchedPostcode={searchedPostcode}
onPostcodeSearched={setSearchedPostcode}
bounds={mapData.bounds}
hideLegend
/>
{mapData.loading && (
<div className="absolute bottom-2 left-2 bg-white dark:bg-navy-800 dark:text-warm-200 px-2 py-1 rounded shadow text-xs">
@ -358,19 +396,55 @@ export default function MapPage({
</div>
{/* Bottom panel — 55% */}
<div className="bg-white dark:bg-navy-950 border-t border-warm-200 dark:border-navy-700 overflow-hidden flex flex-col" style={{ flex: '55 0 0' }}>
<div
className="bg-white dark:bg-warm-900 border-t border-warm-200 dark:border-warm-700 overflow-hidden flex flex-col"
style={{ flex: '55 0 0' }}
>
{/* Legend */}
{viewFeature && mapData.colorRange && mobileLegendMeta ? (
<MapLegend
featureLabel={
viewSource === 'eye'
? `Previewing \u201c${mobileLegendMeta.name}\u201d`
: mobileLegendMeta.name
}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
mode="feature"
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
theme={theme}
inline
/>
) : (
<MapLegend
featureLabel="Property density"
range={mobileDensityRange}
showCancel={false}
onCancel={handleCancelPin}
mode="density"
theme={theme}
inline
/>
)}
{/* Tab bar */}
<div className="flex shrink-0 border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton label="Filters" isActive={mobileBottomTab === 'filters'} onClick={() => setMobileBottomTab('filters')} />
<TabButton label="POIs" isActive={mobileBottomTab === 'pois'} onClick={() => setMobileBottomTab('pois')} />
<div className="flex shrink-0 border-b border-warm-200 dark:border-warm-700 text-sm">
<TabButton
label="Filters"
isActive={mobileBottomTab === 'filters'}
onClick={() => setMobileBottomTab('filters')}
/>
<TabButton
label="POIs"
isActive={mobileBottomTab === 'pois'}
onClick={() => setMobileBottomTab('pois')}
/>
</div>
{/* Tab content */}
<div className="flex-1 min-h-0">
{mobileBottomTab === 'pois' ? (
<div className="h-full overflow-y-auto">
{renderPOIPane()}
</div>
<div className="h-full overflow-y-auto">{renderPOIPane()}</div>
) : (
renderFilters()
)}
@ -397,16 +471,19 @@ export default function MapPage({
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4">
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">Connecting to server...</p>
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
Connecting to server...
</p>
</div>
</div>
)}
{/* Left Pane */}
<div className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden" style={{ width: leftPaneWidth }}>
<div className="flex-1 flex flex-col overflow-hidden">
{renderFilters()}
</div>
<div
className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
style={{ width: leftPaneWidth }}
>
<div className="flex-1 flex flex-col overflow-hidden">{renderFilters()}</div>
<div
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...leftPaneHandlers}
@ -449,7 +526,10 @@ export default function MapPage({
</div>
{/* Right Pane */}
<div className="flex bg-white dark:bg-navy-950 shadow-lg z-10" style={{ width: rightPaneWidth }}>
<div
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width: rightPaneWidth }}
>
<div
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...rightPaneHandlers}
@ -458,19 +538,29 @@ export default function MapPage({
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton label="Area" isActive={selection.rightPaneTab === 'area'} onClick={() => selection.setRightPaneTab('area')} />
<TabButton label="Properties" isActive={selection.rightPaneTab === 'properties'} onClick={selection.handlePropertiesTabClick} />
<TabButton label="POIs" isActive={selection.rightPaneTab === 'pois'} onClick={() => selection.setRightPaneTab('pois')} />
<TabButton
label="Area"
isActive={selection.rightPaneTab === 'area'}
onClick={() => selection.setRightPaneTab('area')}
/>
<TabButton
label="Properties"
isActive={selection.rightPaneTab === 'properties'}
onClick={selection.handlePropertiesTabClick}
/>
<TabButton
label="POIs"
isActive={selection.rightPaneTab === 'pois'}
onClick={() => selection.setRightPaneTab('pois')}
/>
</div>
<div className="flex-1 overflow-hidden">
{selection.rightPaneTab === 'area' ? (
renderAreaPane()
) : selection.rightPaneTab === 'properties' ? (
renderPropertiesPane()
) : (
renderPOIPane()
)}
{selection.rightPaneTab === 'area'
? renderAreaPane()
: selection.rightPaneTab === 'properties'
? renderPropertiesPane()
: renderPOIPane()}
</div>
</div>
</div>

View file

@ -38,7 +38,11 @@ export default function MobileDrawer({
{/* Tab bar + close */}
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm shrink-0">
<TabButton label="Area" isActive={tab === 'area'} onClick={() => setTab('area')} />
<TabButton label="Properties" isActive={tab === 'properties'} onClick={() => setTab('properties')} />
<TabButton
label="Properties"
isActive={tab === 'properties'}
onClick={() => setTab('properties')}
/>
<TabButton label="POIs" isActive={tab === 'pois'} onClick={() => setTab('pois')} />
<button
onClick={onClose}

View file

@ -159,9 +159,7 @@ export default function POIPane({
<div className="flex-1 overflow-y-auto border-t border-warm-200 dark:border-warm-700">
{filteredGroups.map((group) => {
const groupSelected = group.categories.filter((c) =>
selectedCategories.has(c)
).length;
const groupSelected = group.categories.filter((c) => selectedCategories.has(c)).length;
const allInGroupSelected = groupSelected === group.categories.length;
const someInGroupSelected = groupSelected > 0 && !allInGroupSelected;
const isCollapsed = collapsedGroups.has(group.name) && !searchTerm;

View file

@ -53,8 +53,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
.map(([yr, prices]) => {
prices.sort((a, b) => a - b);
const mid = Math.floor(prices.length / 2);
const median =
prices.length % 2 ? prices[mid] : (prices[mid - 1] + prices[mid]) / 2;
const median = prices.length % 2 ? prices[mid] : (prices[mid - 1] + prices[mid]) / 2;
return { year: yr + 0.5, price: median };
})
.sort((a, b) => a.year - b.year);
@ -86,9 +85,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
const yearLabels: number[] = [];
for (let y = yearStart; y <= yearMax; y += 5) yearLabels.push(y);
const medianPolyline = medians
.map((a) => `${scaleX(a.year)},${scaleY(a.price)}`)
.join(' ');
const medianPolyline = medians.map((a) => `${scaleX(a.year)},${scaleY(a.price)}`).join(' ');
return (
<div ref={containerRef} style={{ height: HEIGHT }}>

View file

@ -23,7 +23,7 @@ export function PropertiesPane({
loading,
hexagonId,
onLoadMore,
onClose,
onClose: _onClose,
onNavigateToSource,
}: PropertiesPaneProps) {
const [search, setSearch] = useState('');
@ -70,10 +70,10 @@ export function PropertiesPane({
}
>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
Property data combines Energy Performance Certificates (EPC) with HM Land Registry
Price Paid records, fuzzy-matched by address within each postcode. Includes floor
area, energy ratings, construction age, and tenure from EPC surveys, plus the most
recent sale price from the Land Registry.
Property data combines Energy Performance Certificates (EPC) with HM Land Registry Price
Paid records, fuzzy-matched by address within each postcode. Includes floor area, energy
ratings, construction age, and tenure from EPC surveys, plus the most recent sale price
from the Land Registry.
</p>
</InfoPopup>
)}
@ -122,10 +122,7 @@ function PropertyLoadingSkeleton() {
return (
<div className="space-y-0">
{Array.from({ length: 5 }).map((_, idx) => (
<div
key={idx}
className="p-4 border-b border-warm-100 dark:border-navy-800 animate-pulse"
>
<div key={idx} className="p-4 border-b border-warm-100 dark:border-navy-800 animate-pulse">
{/* Address */}
<div className="h-5 w-3/4 bg-warm-200 dark:bg-warm-700 rounded mb-2" />
{/* Postcode */}
@ -196,7 +193,8 @@ function PropertyCard({ property }: { property: Property }) {
)}
{floorArea !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Floor area:</span> {formatNumber(floorArea)}m²
<span className="text-warm-500 dark:text-warm-400">Floor area:</span>{' '}
{formatNumber(floorArea)}m²
</div>
)}
{rooms !== undefined && (

View file

@ -29,15 +29,10 @@ function shortenLabel(name: string): string {
}
export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) {
const sortedSegments = useMemo(
() => [...segments].sort((a, b) => b.value - a.value),
[segments]
);
const sortedSegments = useMemo(() => [...segments].sort((a, b) => b.value - a.value), [segments]);
if (total === 0) {
return (
<div className="text-xs text-warm-400 dark:text-warm-500 italic">No data</div>
);
return <div className="text-xs text-warm-400 dark:text-warm-500 italic">No data</div>;
}
return (
@ -53,7 +48,8 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
className="h-full"
style={{
width: `${pct}%`,
backgroundColor: colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
backgroundColor:
colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
}}
title={`${shortenLabel(segment.name)}: ${formatValue(segment.value)} (${pct.toFixed(1)}%)`}
/>
@ -68,7 +64,8 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
<span
className="w-2 h-2 rounded-sm shrink-0"
style={{
backgroundColor: colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
backgroundColor:
colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
}}
/>
<span className="text-[10px] text-warm-600 dark:text-warm-400">

View file

@ -24,9 +24,7 @@ export default function StackedEnumChart({
});
if (visibleRows.length === 0) {
return (
<div className="text-xs text-warm-400 dark:text-warm-500 italic mt-1">All low</div>
);
return <div className="text-xs text-warm-400 dark:text-warm-500 italic mt-1">All low</div>;
}
return (