diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e333140..c749be6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "@deck.gl/layers": "^9.0.0", "@deck.gl/mapbox": "^9.2.6", "@deck.gl/react": "^9.0.0", + "@plausible-analytics/tracker": "^0.4.4", "@protomaps/basemaps": "^5.7.0", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slider": "^1.1.0", @@ -3325,6 +3326,11 @@ "node": ">=20.0.0" } }, + "node_modules/@plausible-analytics/tracker": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.4.tgz", + "integrity": "sha512-fz0NOYUEYXtg1TBaPEEvtcBq3FfmLFuTe1VZw4M8sTWX129br5dguu3M15+plOQnc181ShYe67RfwhKgK89VnA==" + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.6.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index f093cd4..8af6cd0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@deck.gl/layers": "^9.0.0", "@deck.gl/mapbox": "^9.2.6", "@deck.gl/react": "^9.0.0", + "@plausible-analytics/tracker": "^0.4.4", "@protomaps/basemaps": "^5.7.0", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slider": "^1.1.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 86e9a6d..cff85e1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,4 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; -import { trackPageview } from './hooks/usePlausible'; import MapPage, { type ExportState } from './components/map/MapPage'; import DataSourcesPage from './components/data-sources/DataSourcesPage'; import FAQPage from './components/faq/FAQPage'; @@ -19,17 +18,22 @@ import { useSavedSearches } from './hooks/useSavedSearches'; declare global { interface Window { - __og_ready?: boolean; + __screenshot_ready?: boolean; } } function pageToPath(page: Page): string { switch (page) { - case 'dashboard': return '/dashboard'; - case 'data-sources': return '/data-sources'; - case 'faq': return '/faq'; - case 'saved-searches': return '/saved'; - default: return '/'; + case 'dashboard': + return '/dashboard'; + case 'data-sources': + return '/data-sources'; + case 'faq': + return '/faq'; + case 'saved-searches': + return '/saved'; + default: + return '/'; } } @@ -44,7 +48,10 @@ function pathToPage(pathname: string): Page | null { export default function App() { const urlState = useMemo(() => parseUrlState(), []); - const initialViewState = useMemo(() => urlState.viewState || INITIAL_VIEW_STATE, []); + const initialViewState = useMemo( + () => urlState.viewState || INITIAL_VIEW_STATE, + [urlState.viewState] + ); const isScreenshotMode = useMemo(() => { const params = new URLSearchParams(window.location.search); @@ -76,11 +83,7 @@ export default function App() { const params = new URLSearchParams(window.location.search); if (params.has('v') || params.has('f') || params.has('poi') || params.has('tab')) { // Rewrite URL to /dashboard keeping query params - window.history.replaceState( - { page: 'dashboard' }, - '', - `/dashboard${window.location.search}` - ); + window.history.replaceState({ page: 'dashboard' }, '', `/dashboard${window.location.search}`); return 'dashboard'; } @@ -141,7 +144,7 @@ export default function App() { return () => controller.abort(); }, []); - // Screenshot mode ready signal — MapPage sets __og_ready once map data loads + // Screenshot mode ready signal — MapPage sets __screenshot_ready once map data loads // Navigation const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => { @@ -152,7 +155,6 @@ export default function App() { const url = hash ? `${path}#${hash}` : path; window.history.pushState({ page }, '', url); setActivePage(page); - trackPageview(); }, []); useEffect(() => { @@ -180,11 +182,12 @@ export default function App() { }, []); // eslint-disable-line react-hooks/exhaustive-deps // Fetch saved searches when page becomes active + const { fetchSearches } = savedSearches; useEffect(() => { if (activePage === 'saved-searches') { - savedSearches.fetchSearches(); + fetchSearches(); } - }, [activePage, savedSearches.fetchSearches]); + }, [activePage, fetchSearches]); const [exportState, setExportState] = useState(null); diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx index 49aa1db..78225ac 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -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({ >
- AI Summary + + AI Summary +
{aiSummaryError ? (
- Failed to generate summary. - {onRetryAiSummary && ( - - )} + Failed to generate summary.
) : aiSummaryLoading ? (
@@ -191,19 +188,24 @@ export default function AreaPane({
- Teal bars show the distribution in this selected area + Teal bars{' '} + show the distribution in this selected area
- Gray bars show the overall distribution across all areas + Gray bars{' '} + show the overall distribution across all areas
- Dashed line indicates the global average + + Dashed line + {' '} + indicates the global average
@@ -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 &&
- {/* 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; - })() && ( -
- Price History - -
- )} - {stackedCharts - ? // Render stacked charts for this group - stackedCharts.map((chart) => { - const segments = chart.components - .map((name) => ({ - name, - value: numericByName.get(name)?.mean ?? 0, - })) + {isExpanded && ( +
+ {/* 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; + })() && ( +
+ + Price History + + +
+ )} + {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 ( +
+
+ {featureMeta ? ( + + ) : ( + + {chart.label} + + )} + + {formatValue(total)} + {chart.unit ? ` ${chart.unit}` : ''} + +
+ +
+ ); + }) + : // 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 ( +
+
+ + + {formatValue(numericStats.mean, feature)} + +
+ {numericStats.histogram && + (globalHistogram ? ( + + ) : ( + + ))} +
+ ); + } + + if (enumStats) { + return ( +
+ + +
+ ); + } + + 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({
{featureMeta ? ( @@ -288,166 +406,57 @@ export default function AreaPane({ )} - {formatValue(total)} - {chart.unit ? ` ${chart.unit}` : ''} + {total.toLocaleString()}
- + [v, chart.valueColors[i]]) + )} + />
); - }) - : // 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 => c !== null); - return ( -
-
- - - {formatValue(numericStats.mean, feature)} - -
- {numericStats.histogram && ( - globalHistogram ? ( - - ) : ( - - ) - )} -
- ); - } - - if (enumStats) { - return ( -
- - -
- ); - } - - 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 (
-
+
{featureMeta ? ( ) : ( - + {chart.label} )} - - {total.toLocaleString()} -
- [v, chart.valueColors[i]]) - )} +
); - } - - // 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 => c !== null); - - if (components.length === 0) return null; - - return ( -
-
- {featureMeta ? ( - - ) : ( - - {chart.label} - - )} -
- -
- ); - })} -
} + })} +
+ )}
); })} diff --git a/frontend/src/components/map/DualHistogram.tsx b/frontend/src/components/map/DualHistogram.tsx index 3638d30..c1813fe 100644 --- a/frontend/src/components/map/DualHistogram.tsx +++ b/frontend/src/components/map/DualHistogram.tsx @@ -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 (
diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index c4768ec..284e2d1 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -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({ } 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

- 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.

@@ -347,7 +348,7 @@ export default memo(function Filters({

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.

@@ -367,18 +368,19 @@ export default memo(function Filters({ Find the right place, not just the right listing

- 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.

- Know what's possible + Know what's possible

- 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.

diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index 684b259..95a6209 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -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(null); const [viewState, setViewState] = useState(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({ ) : ( <> - {viewFeature && colorRange && colorFeatureMeta ? ( - - ) : ( - - )} + {!hideLegend && + (viewFeature && colorRange && colorFeatureMeta ? ( + + ) : ( + + ))} {popupInfo && (
+
{featureLabel} {showCancel && ( diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 59e8193..39c39a5 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -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(null); - const [selectedPOICategories, setSelectedPOICategories] = useState>(initialPOICategories); + const [selectedPOICategories, setSelectedPOICategories] = + useState>(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({
-

Connecting to server...

+

+ Connecting to server... +

)} @@ -348,6 +385,7 @@ export default function MapPage({ searchedPostcode={searchedPostcode} onPostcodeSearched={setSearchedPostcode} bounds={mapData.bounds} + hideLegend /> {mapData.loading && (
@@ -358,19 +396,55 @@ export default function MapPage({
{/* Bottom panel — 55% */} -
+
+ {/* Legend */} + {viewFeature && mapData.colorRange && mobileLegendMeta ? ( + + ) : ( + + )} {/* Tab bar */} -
- setMobileBottomTab('filters')} /> - setMobileBottomTab('pois')} /> +
+ setMobileBottomTab('filters')} + /> + setMobileBottomTab('pois')} + />
{/* Tab content */}
{mobileBottomTab === 'pois' ? ( -
- {renderPOIPane()} -
+
{renderPOIPane()}
) : ( renderFilters() )} @@ -397,16 +471,19 @@ export default function MapPage({
-

Connecting to server...

+

+ Connecting to server... +

)} {/* Left Pane */} -
-
- {renderFilters()} -
+
+
{renderFilters()}
{/* Right Pane */} -
+
- selection.setRightPaneTab('area')} /> - - selection.setRightPaneTab('pois')} /> + selection.setRightPaneTab('area')} + /> + + selection.setRightPaneTab('pois')} + />
- {selection.rightPaneTab === 'area' ? ( - renderAreaPane() - ) : selection.rightPaneTab === 'properties' ? ( - renderPropertiesPane() - ) : ( - renderPOIPane() - )} + {selection.rightPaneTab === 'area' + ? renderAreaPane() + : selection.rightPaneTab === 'properties' + ? renderPropertiesPane() + : renderPOIPane()}
diff --git a/frontend/src/components/map/MobileDrawer.tsx b/frontend/src/components/map/MobileDrawer.tsx index 5e5f5cc..20ec451 100644 --- a/frontend/src/components/map/MobileDrawer.tsx +++ b/frontend/src/components/map/MobileDrawer.tsx @@ -38,7 +38,11 @@ export default function MobileDrawer({ {/* Tab bar + close */}
setTab('area')} /> - setTab('properties')} /> + setTab('properties')} + /> setTab('pois')} /> {user && ( - )} - )} {/* Auth buttons */}
- {user ? ( -
- {user.email} - -
- ) : ( -
- - -
- )} + {user ? ( +
+ {user.email} + +
+ ) : ( +
+ + +
+ )}
diff --git a/frontend/src/components/ui/IconButton.tsx b/frontend/src/components/ui/IconButton.tsx index b42400a..af291b2 100644 --- a/frontend/src/components/ui/IconButton.tsx +++ b/frontend/src/components/ui/IconButton.tsx @@ -6,16 +6,28 @@ interface IconButtonProps { children: ReactNode; active?: boolean; className?: string; + size?: 'sm' | 'md'; } -export function IconButton({ onClick, title, children, active, className }: IconButtonProps) { - const baseClasses = 'p-0.5 rounded'; +export function IconButton({ + onClick, + title, + children, + active, + className, + size = 'sm', +}: IconButtonProps) { + const padClasses = size === 'md' ? 'p-1' : 'p-0.5'; const colorClasses = active ? 'text-teal-600 dark:text-teal-400' : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'; return ( - ); diff --git a/frontend/src/components/ui/SaveSearchModal.tsx b/frontend/src/components/ui/SaveSearchModal.tsx index 4968c1b..c8da9e7 100644 --- a/frontend/src/components/ui/SaveSearchModal.tsx +++ b/frontend/src/components/ui/SaveSearchModal.tsx @@ -41,7 +41,7 @@ export default function SaveSearchModal({
e.stopPropagation()} >
@@ -63,7 +63,7 @@ export default function SaveSearchModal({ type="text" value={name} onChange={(e) => setName(e.target.value)} - className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500" + className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500" placeholder="My search" autoFocus /> diff --git a/frontend/src/components/ui/UserMenu.tsx b/frontend/src/components/ui/UserMenu.tsx index e2e0739..03c6e11 100644 --- a/frontend/src/components/ui/UserMenu.tsx +++ b/frontend/src/components/ui/UserMenu.tsx @@ -32,7 +32,9 @@ export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout: {open && (
-

{user.email}

+

+ {user.email} +