Format and lint
This commit is contained in:
parent
42ee2d4c51
commit
04a78e7bfe
75 changed files with 1290 additions and 719 deletions
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'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.
|
||||
</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'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'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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
Know what's possible
|
||||
Know what'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'd rather tell you upfront if your expectations are unrealistic than have you
|
||||
spend months searching for something that doesn't exist.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue