diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7413aa8..37864af 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,7 +11,6 @@ import DataSourcesPage from './components/DataSourcesPage'; import FAQPage from './components/FAQPage'; import HomePage from './components/HomePage'; import Header, { type Page } from './components/Header'; -import { ChevronIcon } from './components/ui/Icons'; import { TabButton } from './components/ui/TabButton'; import type { FeatureMeta, @@ -29,8 +28,9 @@ import type { Property, HexagonPropertiesResponse, HexagonStatsResponse, + NumericFeatureStats, } from './types'; -import { fetchWithRetry, getApiBaseUrl, buildFilterString, apiUrl, logNonAbortError } from './lib/api'; +import { fetchWithRetry, buildFilterString, apiUrl, logNonAbortError } from './lib/api'; import { parseUrlState, DEFAULT_VIEW } from './lib/url-state'; import { POSTCODE_ZOOM_THRESHOLD } from './lib/map-utils'; import { useTheme } from './hooks/useTheme'; @@ -108,8 +108,10 @@ export default function App() { const [areaStats, setAreaStats] = useState(null); const [loadingAreaStats, setLoadingAreaStats] = useState(false); - const [leftPaneCollapsed, setLeftPaneCollapsed] = useState(false); - const [rightPaneCollapsed, setRightPaneCollapsed] = useState(false); + const [leftPaneWidth, setLeftPaneWidth] = useState(384); // 24rem = 384px + const [rightPaneWidth, setRightPaneWidth] = useState(288); // 18rem = 288px + const leftDraggingRef = useRef(false); + const rightDraggingRef = useRef(false); const [hoveredHexagon, setHoveredHexagon] = useState(null); const [searchedPostcode, setSearchedPostcode] = useState(null); @@ -503,6 +505,32 @@ export default function App() { [filters, features] ); + /** Build stats from already-loaded PostcodeData (min/max per feature). */ + const buildPostcodeStats = useCallback( + (postcode: string): HexagonStatsResponse | null => { + const pc = postcodeData.find((d) => d.postcode === postcode); + if (!pc) return null; + + const numeric_features: NumericFeatureStats[] = []; + for (const f of features) { + if (f.type !== 'numeric') continue; + const minVal = pc[`min_${f.name}`]; + const maxVal = pc[`max_${f.name}`]; + if (typeof minVal !== 'number' || typeof maxVal !== 'number') continue; + numeric_features.push({ + name: f.name, + count: pc.count, + min: minVal, + max: maxVal, + mean: (minVal + maxVal) / 2, + }); + } + + return { count: pc.count, numeric_features, enum_features: [] }; + }, + [postcodeData, features] + ); + const fetchHexagonProperties = useCallback( async (h3: string, res: number, offset = 0) => { setLoadingProperties(true); @@ -545,12 +573,13 @@ export default function App() { } else { const type = isPostcode ? 'postcode' : 'hexagon'; setSelectedHexagon({ id, type, resolution }); + setProperties([]); + setPropertiesTotal(0); setPropertiesOffset(0); setRightPaneTab('area'); if (isPostcode) { - // For postcodes, we don't have a stats API yet, so skip - setAreaStats(null); + setAreaStats(buildPostcodeStats(id)); setLoadingAreaStats(false); } else { setLoadingAreaStats(true); @@ -561,7 +590,7 @@ export default function App() { } } }, - [selectedHexagon, resolution, fetchHexagonStats] + [selectedHexagon, resolution, fetchHexagonStats, buildPostcodeStats] ); const handleHexagonHover = useCallback((h3: string | null) => { @@ -576,6 +605,14 @@ export default function App() { } }, [selectedHexagon, fetchHexagonProperties]); + const handlePropertiesTabClick = useCallback(() => { + setRightPaneTab('properties'); + if (selectedHexagon?.type === 'hexagon' && properties.length === 0 && !loadingProperties) { + setPropertiesOffset(0); + fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0); + } + }, [selectedHexagon, properties.length, loadingProperties, fetchHexagonProperties]); + const handleLoadMoreProperties = useCallback(() => { if (selectedHexagon && selectedHexagon.type === 'hexagon') { fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, propertiesOffset); @@ -588,6 +625,40 @@ export default function App() { setAreaStats(null); }, []); + // Left pane resize handlers + const handleLeftSeparatorPointerDown = useCallback((e: React.PointerEvent) => { + e.preventDefault(); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + leftDraggingRef.current = true; + }, []); + + const handleLeftSeparatorPointerMove = useCallback((e: React.PointerEvent) => { + if (!leftDraggingRef.current) return; + const newWidth = Math.min(600, Math.max(200, e.clientX)); + setLeftPaneWidth(newWidth); + }, []); + + const handleLeftSeparatorPointerUp = useCallback(() => { + leftDraggingRef.current = false; + }, []); + + // Right pane resize handlers + const handleRightSeparatorPointerDown = useCallback((e: React.PointerEvent) => { + e.preventDefault(); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + rightDraggingRef.current = true; + }, []); + + const handleRightSeparatorPointerMove = useCallback((e: React.PointerEvent) => { + if (!rightDraggingRef.current) return; + const newWidth = Math.min(500, Math.max(200, window.innerWidth - e.clientX)); + setRightPaneWidth(newWidth); + }, []); + + const handleRightSeparatorPointerUp = useCallback(() => { + rightDraggingRef.current = false; + }, []); + if (isScreenshotMode) { return (
@@ -660,46 +731,43 @@ export default function App() {
)}
- {leftPaneCollapsed ? ( - - ) : ( -
- { - navigateTo('data-sources', slug, featureName); - }} - openInfoFeature={pendingInfoFeature} - onClearOpenInfoFeature={() => setPendingInfoFeature(null)} - onCollapse={() => setLeftPaneCollapsed(true)} - /> -
- )} +
+ { + navigateTo('data-sources', slug, featureName); + }} + openInfoFeature={pendingInfoFeature} + onClearOpenInfoFeature={() => setPendingInfoFeature(null)} + /> +
+
+
+
navigateTo('data-sources')} />
- {rightPaneCollapsed ? ( - - ) : ( - <> -
- - setRightPaneTab('area')} - /> - 0 ? propertiesTotal : undefined} - isActive={rightPaneTab === 'properties'} - onClick={() => setRightPaneTab('properties')} - /> - 0 ? pois.length : undefined} - isActive={rightPaneTab === 'pois'} - onClick={() => setRightPaneTab('pois')} - /> -
+
+
+
+
+
+ setRightPaneTab('area')} + /> + 0 ? propertiesTotal : undefined} + isActive={rightPaneTab === 'properties'} + onClick={handlePropertiesTabClick} + /> + 0 ? pois.length : undefined} + isActive={rightPaneTab === 'pois'} + onClick={() => setRightPaneTab('pois')} + /> +
-
- {rightPaneTab === 'area' ? ( - d.postcode === selectedHexagon.id) || null - : null - } - onViewProperties={handleViewPropertiesFromArea} - onClose={handleCloseProperties} - hexagonLocation={(() => { - const hexId = selectedHexagon?.id; - const hex = hexId ? data.find((d) => d.h3 === hexId) : null; - if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') - return null; - return { - lat: hex.lat as number, - lon: hex.lon as number, - resolution, - }; - })()} - filters={filters} - onNavigateToSource={(slug, featureName) => { - navigateTo('data-sources', slug, featureName); - }} - /> - ) : rightPaneTab === 'properties' ? ( - navigateTo('data-sources', slug)} - /> - ) : ( - navigateTo('data-sources', slug)} - /> - )} -
- - )} +
+ {rightPaneTab === 'area' ? ( + d.postcode === selectedHexagon.id) || null + : null + } + onViewProperties={handleViewPropertiesFromArea} + onClose={handleCloseProperties} + hexagonLocation={(() => { + const hexId = selectedHexagon?.id; + const hex = hexId ? data.find((d) => d.h3 === hexId) : null; + if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') + return null; + return { + lat: hex.lat as number, + lon: hex.lon as number, + resolution, + }; + })()} + filters={filters} + onNavigateToSource={(slug, featureName) => { + navigateTo('data-sources', slug, featureName); + }} + /> + ) : rightPaneTab === 'properties' ? ( + navigateTo('data-sources', slug)} + /> + ) : ( + navigateTo('data-sources', slug)} + /> + )} +
+
)} diff --git a/frontend/src/components/AreaPane.tsx b/frontend/src/components/AreaPane.tsx index b1adc5e..1fcd4a0 100644 --- a/frontend/src/components/AreaPane.tsx +++ b/frontend/src/components/AreaPane.tsx @@ -3,10 +3,11 @@ import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeData } import type { HexagonLocation } from '../lib/external-search'; import { formatValue, calculateHistogramMean } from '../lib/format'; import { groupFeaturesByCategory } from '../lib/features'; -import { CRIME_BREAKDOWNS } from '../lib/consts'; +import { STACKED_GROUPS } from '../lib/consts'; import { DualHistogram, LoadingSkeleton } from './DualHistogram'; import EnumBarChart from './EnumBarChart'; import StackedBarChart from './StackedBarChart'; +import PriceHistoryChart from './PriceHistoryChart'; import ExternalSearchLinks from './ExternalSearchLinks'; import { InfoIcon, CloseIcon } from './ui/Icons'; import { IconButton } from './ui/IconButton'; @@ -104,17 +105,19 @@ export default function AreaPane({ ) : stats ? (
+ {stats.price_history && stats.price_history.length > 0 && ( +
+ Price History + +
+ )} {featureGroups.map((group) => { const hasData = group.features.some( (feature) => numericByName.has(feature.name) || enumByName.has(feature.name) ); if (!hasData) return null; - // For Crime group, only show aggregate features with stacked breakdown - const isCrimeGroup = group.name === 'Crime'; - const featuresToRender = isCrimeGroup - ? group.features.filter((f) => f.name in CRIME_BREAKDOWNS) - : group.features; + const stackedCharts = STACKED_GROUPS[group.name]; return (
@@ -122,38 +125,43 @@ export default function AreaPane({ {group.name}
- {featuresToRender.map((feature) => { - const numericStats = numericByName.get(feature.name); - const enumStats = enumByName.get(feature.name); - - if (numericStats) { - // Check if this is a crime aggregate that should show breakdown - const breakdown = CRIME_BREAKDOWNS[feature.name]; - if (breakdown) { - // Build segments from component crime means - const segments = breakdown - .map((componentName) => { - const componentStats = numericByName.get(componentName); - return { - name: componentName, - value: componentStats?.mean ?? 0, - }; - }) + {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 (
- {feature.name} + {chart.label} - {feature.detail && ( + {featureMeta?.detail && (
- {formatValue(numericStats.mean)} avg/yr + {formatValue(total)} + {chart.unit ? ` ${chart.unit}` : ''}
- +
); - } + }) + : // Default: render each feature individually + group.features.map((feature) => { + const numericStats = numericByName.get(feature.name); + const enumStats = enumByName.get(feature.name); - // Regular numeric feature with histogram - const globalFeature = globalFeatureByName.get(feature.name); - const globalHistogram = globalFeature?.histogram; - const globalMean = globalHistogram - ? calculateHistogramMean(globalHistogram) - : undefined; + if (numericStats) { + const globalFeature = globalFeatureByName.get(feature.name); + const globalHistogram = globalFeature?.histogram; + const globalMean = globalHistogram + ? calculateHistogramMean(globalHistogram) + : undefined; - return ( -
-
-
- - {feature.name} - - {feature.detail && ( - + return ( +
+
+
+ + {feature.name} + + {feature.detail && ( + + )} +
+ + {formatValue(numericStats.mean)} + +
+ {numericStats.histogram && ( + <> +
+ {formatValue(numericStats.histogram.min)} + {formatValue(numericStats.histogram.max)} +
+ {globalHistogram ? ( + + ) : ( + + )} + )}
- - {formatValue(numericStats.mean)} - -
-
- {formatValue(numericStats.histogram.min)} - {formatValue(numericStats.histogram.max)} -
- {globalHistogram ? ( - - ) : ( - - )} -
- ); - } + ); + } - if (enumStats) { - return ( -
-
- - {feature.name} - - {feature.detail && ( - - )} -
- -
- ); - } + if (enumStats) { + return ( +
+
+ + {feature.name} + + {feature.detail && ( + + )} +
+ +
+ ); + } - return null; - })} + return null; + })}
); diff --git a/frontend/src/components/Filters.tsx b/frontend/src/components/Filters.tsx index 1d5d7aa..2c458b2 100644 --- a/frontend/src/components/Filters.tsx +++ b/frontend/src/components/Filters.tsx @@ -155,6 +155,7 @@ export default memo(function Filters({ const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name)); const containerRef = useRef(null); + const headerRef = useRef(null); const [splitFraction, setSplitFraction] = useState(0.65); const draggingRef = useRef(false); const [showPhilosophy, setShowPhilosophy] = useState(false); @@ -169,8 +170,9 @@ export default memo(function Filters({ const handleSeparatorPointerMove = useCallback((e: React.PointerEvent) => { if (!draggingRef.current || !containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); + const headerHeight = headerRef.current?.offsetHeight ?? 0; const y = e.clientY - rect.top; - const fraction = Math.min(0.8, Math.max(0.15, y / rect.height)); + const fraction = Math.min(0.8, Math.max(0.15, (y - headerHeight) / rect.height)); setSplitFraction(fraction); }, []); @@ -183,7 +185,7 @@ export default memo(function Filters({ ref={containerRef} className="flex flex-col bg-white dark:bg-navy-950 overflow-hidden h-full" > -
+