Refactor UI
This commit is contained in:
parent
ce4c0cc08c
commit
34a4d0ba86
32 changed files with 1726 additions and 845 deletions
9
frontend/package-lock.json
generated
9
frontend/package-lock.json
generated
|
|
@ -13,6 +13,7 @@
|
|||
"@deck.gl/layers": "^9.0.0",
|
||||
"@deck.gl/mapbox": "^9.2.6",
|
||||
"@deck.gl/react": "^9.0.0",
|
||||
"@protomaps/basemaps": "^5.7.0",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slider": "^1.1.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
|
|
@ -1714,6 +1715,14 @@
|
|||
"integrity": "sha512-EI413MkWKBDVNIfLdqbeNSJTs7ToBz/KVGkwi3D+dQrSIkRI2IYbWGAU3xX+D6+CI4ls8ehxMhNpUVMaZggDvQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@protomaps/basemaps": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@protomaps/basemaps/-/basemaps-5.7.0.tgz",
|
||||
"integrity": "sha512-vIInnzVSxHuOcvj1BFGkCjlFxG/9a1GV23t98kGEVcPUM7aEqTnf6loUHTRJYX5eCz+WCO16N0aibr1SLg830Q==",
|
||||
"bin": {
|
||||
"generate_style": "src/cli.ts"
|
||||
}
|
||||
},
|
||||
"node_modules/@puppeteer/browsers": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.11.2.tgz",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"name": "property-map-frontend",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "webpack serve --mode development --port 3030",
|
||||
"dev": "webpack serve --mode development --port 3000",
|
||||
"build": "webpack --mode production && node scripts/prerender.mjs",
|
||||
"build:no-prerender": "webpack --mode production",
|
||||
"prerender": "node scripts/prerender.mjs",
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
"@deck.gl/layers": "^9.0.0",
|
||||
"@deck.gl/mapbox": "^9.2.6",
|
||||
"@deck.gl/react": "^9.0.0",
|
||||
"@protomaps/basemaps": "^5.7.0",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slider": "^1.1.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { trackPageview } from './usePlausible';
|
||||
import Map from './components/Map';
|
||||
import type { SearchedPostcode } from './components/PostcodeSearch';
|
||||
import Filters from './components/Filters';
|
||||
import POIPane from './components/POIPane';
|
||||
import { PropertiesPane } from './components/PropertiesPane';
|
||||
|
|
@ -10,12 +11,15 @@ 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,
|
||||
FeatureGroup,
|
||||
FeatureFilters,
|
||||
Bounds,
|
||||
HexagonData,
|
||||
PostcodeData,
|
||||
ViewChangeParams,
|
||||
ApiResponse,
|
||||
POI,
|
||||
|
|
@ -26,8 +30,9 @@ import type {
|
|||
HexagonPropertiesResponse,
|
||||
HexagonStatsResponse,
|
||||
} from './types';
|
||||
import { fetchWithRetry, getApiBaseUrl, buildFilterString } from './lib/api';
|
||||
import { fetchWithRetry, getApiBaseUrl, 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';
|
||||
import { useUrlSync } from './hooks/useUrlSync';
|
||||
|
||||
|
|
@ -53,6 +58,7 @@ export default function App() {
|
|||
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
||||
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
|
||||
const [rawData, setRawData] = useState<HexagonData[]>([]);
|
||||
const [postcodeData, setPostcodeData] = useState<PostcodeData[]>([]);
|
||||
const [dragData, setDragData] = useState<HexagonData[] | null>(null);
|
||||
const [resolution, setResolution] = useState<number>(8);
|
||||
const [bounds, setBounds] = useState<Bounds | null>(null);
|
||||
|
|
@ -86,9 +92,11 @@ export default function App() {
|
|||
const poiDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const poiAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const [selectedHexagon, setSelectedHexagon] = useState<{ h3: string; resolution: number } | null>(
|
||||
null
|
||||
);
|
||||
const [selectedHexagon, setSelectedHexagon] = useState<{
|
||||
id: string;
|
||||
type: 'hexagon' | 'postcode';
|
||||
resolution: number;
|
||||
} | null>(null);
|
||||
const [properties, setProperties] = useState<Property[]>([]);
|
||||
const [propertiesTotal, setPropertiesTotal] = useState(0);
|
||||
const [propertiesOffset, setPropertiesOffset] = useState(0);
|
||||
|
|
@ -100,14 +108,11 @@ export default function App() {
|
|||
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
|
||||
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
|
||||
|
||||
const [leftPaneCollapsed, setLeftPaneCollapsed] = useState(false);
|
||||
const [rightPaneCollapsed, setRightPaneCollapsed] = useState(false);
|
||||
|
||||
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
|
||||
const [hoveredAreaStats, setHoveredAreaStats] = useState<HexagonStatsResponse | null>(null);
|
||||
const [hoveredProperties, setHoveredProperties] = useState<Property[] | null>(null);
|
||||
const [hoveredPropertiesTotal, setHoveredPropertiesTotal] = useState(0);
|
||||
const [loadingHoveredAreaStats, setLoadingHoveredAreaStats] = useState(false);
|
||||
const [hoverMode, setHoverMode] = useState(true);
|
||||
const hoverAbortRef = useRef<AbortController | null>(null);
|
||||
const hoverDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
|
||||
const [activePage, setActivePage] = useState<Page>(() => {
|
||||
|
|
@ -162,20 +167,6 @@ export default function App() {
|
|||
const viewFeature = activeFeature || pinnedFeature;
|
||||
const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null;
|
||||
|
||||
const colorRange = useMemo((): [number, number] | null => {
|
||||
if (!viewFeature) return null;
|
||||
const meta = features.find((f) => f.name === viewFeature);
|
||||
if (!meta) return null;
|
||||
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
|
||||
return [0, meta.values.length - 1];
|
||||
}
|
||||
if (activeFeature === viewFeature && dragValue) return dragValue;
|
||||
const filterVal = filters[viewFeature];
|
||||
if (filterVal && typeof filterVal[0] === 'number') return filterVal as [number, number];
|
||||
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
|
||||
return null;
|
||||
}, [viewFeature, features, activeFeature, dragValue, filters]);
|
||||
|
||||
const filterRange = useMemo((): [number, number] | null => {
|
||||
if (!viewFeature) return null;
|
||||
if (activeFeature && dragValue) return dragValue;
|
||||
|
|
@ -196,7 +187,7 @@ export default function App() {
|
|||
};
|
||||
|
||||
fetchWithRetry<{ groups: FeatureGroup[] }>(
|
||||
`${getApiBaseUrl()}/api/features`,
|
||||
apiUrl('features'),
|
||||
(json) => {
|
||||
const flat: FeatureMeta[] = json.groups.flatMap((g) =>
|
||||
g.features.map((f) => ({ ...f, group: g.name }))
|
||||
|
|
@ -209,7 +200,7 @@ export default function App() {
|
|||
);
|
||||
|
||||
fetchWithRetry<POICategoriesResponse>(
|
||||
`${getApiBaseUrl()}/api/poi-categories`,
|
||||
apiUrl('poi-categories'),
|
||||
(json) => {
|
||||
setPOICategoryGroups(json.groups);
|
||||
poisLoaded = true;
|
||||
|
|
@ -226,6 +217,8 @@ export default function App() {
|
|||
[filters, features]
|
||||
);
|
||||
|
||||
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
|
||||
|
||||
useEffect(() => {
|
||||
if (!bounds) return;
|
||||
|
||||
|
|
@ -244,25 +237,42 @@ export default function App() {
|
|||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||
const filtersStr = buildFilterParam();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
resolution: resolution.toString(),
|
||||
bounds: boundsStr,
|
||||
});
|
||||
if (filtersStr) params.set('filters', filtersStr);
|
||||
if (viewFeature) {
|
||||
params.set('fields', viewFeature);
|
||||
if (usePostcodeView) {
|
||||
// Fetch postcode polygons for high zoom levels
|
||||
const params = new URLSearchParams({ bounds: boundsStr });
|
||||
if (filtersStr) params.set('filters', filtersStr);
|
||||
if (viewFeature) {
|
||||
params.set('fields', viewFeature);
|
||||
} else {
|
||||
params.set('fields', '');
|
||||
}
|
||||
const res = await fetch(apiUrl('postcodes', params), {
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
const json: { features: PostcodeData[] } = await res.json();
|
||||
setPostcodeData(json.features || []);
|
||||
setRawData([]); // Clear hexagon data
|
||||
} else {
|
||||
params.set('fields', '');
|
||||
// Fetch hexagons for lower zoom levels
|
||||
const params = new URLSearchParams({
|
||||
resolution: resolution.toString(),
|
||||
bounds: boundsStr,
|
||||
});
|
||||
if (filtersStr) params.set('filters', filtersStr);
|
||||
if (viewFeature) {
|
||||
params.set('fields', viewFeature);
|
||||
} else {
|
||||
params.set('fields', '');
|
||||
}
|
||||
const res = await fetch(apiUrl('hexagons', params), {
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
const json: ApiResponse = await res.json();
|
||||
setRawData(json.features || []);
|
||||
setPostcodeData([]); // Clear postcode data
|
||||
}
|
||||
const res = await fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
const json: ApiResponse = await res.json();
|
||||
setRawData(json.features || []);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name !== 'AbortError') {
|
||||
console.error('Failed to fetch data:', err);
|
||||
}
|
||||
logNonAbortError('Failed to fetch data', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -273,10 +283,57 @@ export default function App() {
|
|||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [resolution, bounds, filters, buildFilterParam, viewFeature]);
|
||||
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView]);
|
||||
|
||||
const data = dragData ?? rawData;
|
||||
|
||||
// Compute actual min/max from visible data for the viewed feature
|
||||
// Uses postcodeData when in postcode view, otherwise hexagon/drag data
|
||||
const dataRange = useMemo((): [number, number] | null => {
|
||||
if (!viewFeature) return null;
|
||||
const meta = features.find((f) => f.name === viewFeature);
|
||||
if (!meta || meta.type === 'enum') return null;
|
||||
|
||||
// When actively dragging, only use dragData (not rawData which has old filters)
|
||||
// If dragData hasn't loaded yet, return null to trigger fallback
|
||||
if (activeFeature && !dragData) return null;
|
||||
|
||||
// Choose the appropriate data source based on zoom level
|
||||
const sourceData = usePostcodeView ? postcodeData : data;
|
||||
if (sourceData.length === 0) return null;
|
||||
|
||||
// Only use min_<feature> values since that's what hexagon coloring uses
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const item of sourceData) {
|
||||
const val = item[`min_${viewFeature}`];
|
||||
if (typeof val === 'number' && !isNaN(val)) {
|
||||
min = Math.min(min, val);
|
||||
max = Math.max(max, val);
|
||||
}
|
||||
}
|
||||
if (min === Infinity || max === -Infinity) return null;
|
||||
return [min, max];
|
||||
}, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature]);
|
||||
|
||||
// Color range for the legend and hex coloring - uses actual data range when available
|
||||
const colorRange = useMemo((): [number, number] | null => {
|
||||
if (!viewFeature) return null;
|
||||
const meta = features.find((f) => f.name === viewFeature);
|
||||
if (!meta) return null;
|
||||
// For enum features: use [0, numValues-1]
|
||||
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
|
||||
return [0, meta.values.length - 1];
|
||||
}
|
||||
// Use actual data range when available (shows actual min/max on the map)
|
||||
if (dataRange) return dataRange;
|
||||
// During drag when data hasn't loaded yet, use dragValue as preview
|
||||
if (activeFeature && dragValue) return dragValue;
|
||||
// Fallback to full feature range
|
||||
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
|
||||
return null;
|
||||
}, [viewFeature, features, dataRange, activeFeature, dragValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bounds || selectedPOICategories.size === 0) {
|
||||
setPois([]);
|
||||
|
|
@ -300,15 +357,13 @@ export default function App() {
|
|||
categories: categoriesStr,
|
||||
bounds: boundsStr,
|
||||
});
|
||||
const res = await fetch(`${getApiBaseUrl()}/api/pois?${params}`, {
|
||||
const res = await fetch(apiUrl('pois', params), {
|
||||
signal: poiAbortControllerRef.current.signal,
|
||||
});
|
||||
const json: POIResponse = await res.json();
|
||||
setPois(json.pois || []);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name !== 'AbortError') {
|
||||
console.error('Failed to fetch POIs:', err);
|
||||
}
|
||||
logNonAbortError('Failed to fetch POIs', err);
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
|
|
@ -396,16 +451,12 @@ export default function App() {
|
|||
if (filtersStr) params.set('filters', filtersStr);
|
||||
params.set('fields', name);
|
||||
|
||||
fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
|
||||
fetch(apiUrl('hexagons', params), {
|
||||
signal: dragAbortRef.current.signal,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((json: ApiResponse) => setDragData(json.features || []))
|
||||
.catch((err) => {
|
||||
if (err instanceof Error && err.name !== 'AbortError') {
|
||||
console.error('Failed to fetch drag data:', err);
|
||||
}
|
||||
});
|
||||
.catch((err) => logNonAbortError('Failed to fetch drag data', err));
|
||||
},
|
||||
[filters, features, bounds, resolution]
|
||||
);
|
||||
|
|
@ -446,7 +497,7 @@ export default function App() {
|
|||
if (fields) {
|
||||
params.set('fields', fields.join(','));
|
||||
}
|
||||
const response = await fetch(`${getApiBaseUrl()}/api/hexagon-stats?${params}`, { signal });
|
||||
const response = await fetch(apiUrl('hexagon-stats', params), { signal });
|
||||
return (await response.json()) as HexagonStatsResponse;
|
||||
},
|
||||
[filters, features]
|
||||
|
|
@ -466,7 +517,7 @@ export default function App() {
|
|||
const filterStr = buildFilterString(filters, features);
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
|
||||
const response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`);
|
||||
const response = await fetch(apiUrl('hexagon-properties', params));
|
||||
const data: HexagonPropertiesResponse = await response.json();
|
||||
|
||||
if (offset === 0) {
|
||||
|
|
@ -486,99 +537,48 @@ export default function App() {
|
|||
);
|
||||
|
||||
const handleHexagonClick = useCallback(
|
||||
(h3: string) => {
|
||||
if (selectedHexagon?.h3 === h3) {
|
||||
(id: string, isPostcode = false) => {
|
||||
if (selectedHexagon?.id === id) {
|
||||
setSelectedHexagon(null);
|
||||
setProperties([]);
|
||||
setAreaStats(null);
|
||||
} else {
|
||||
setSelectedHexagon({ h3, resolution });
|
||||
const type = isPostcode ? 'postcode' : 'hexagon';
|
||||
setSelectedHexagon({ id, type, resolution });
|
||||
setPropertiesOffset(0);
|
||||
setRightPaneTab('area');
|
||||
setLoadingAreaStats(true);
|
||||
fetchHexagonStats(h3, resolution)
|
||||
.then((stats) => setAreaStats(stats))
|
||||
.catch((error) => {
|
||||
if (error instanceof Error && error.name !== 'AbortError') {
|
||||
console.error('Failed to fetch area stats:', error);
|
||||
}
|
||||
})
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
|
||||
if (isPostcode) {
|
||||
// For postcodes, we don't have a stats API yet, so skip
|
||||
setAreaStats(null);
|
||||
setLoadingAreaStats(false);
|
||||
} else {
|
||||
setLoadingAreaStats(true);
|
||||
fetchHexagonStats(id, resolution)
|
||||
.then((stats) => setAreaStats(stats))
|
||||
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectedHexagon, resolution, fetchHexagonStats]
|
||||
);
|
||||
|
||||
const handleHexagonHover = useCallback(
|
||||
(h3: string | null) => {
|
||||
setHoveredHexagon(h3);
|
||||
if (!hoverMode || !h3 || h3 === selectedHexagon?.h3) {
|
||||
if (hoverDebounceRef.current) clearTimeout(hoverDebounceRef.current);
|
||||
if (hoverAbortRef.current) hoverAbortRef.current.abort();
|
||||
setHoveredAreaStats(null);
|
||||
setHoveredProperties(null);
|
||||
setHoveredPropertiesTotal(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hoverDebounceRef.current) clearTimeout(hoverDebounceRef.current);
|
||||
hoverDebounceRef.current = setTimeout(async () => {
|
||||
if (hoverAbortRef.current) hoverAbortRef.current.abort();
|
||||
hoverAbortRef.current = new AbortController();
|
||||
const signal = hoverAbortRef.current.signal;
|
||||
|
||||
try {
|
||||
if (rightPaneTab === 'area') {
|
||||
setLoadingHoveredAreaStats(true);
|
||||
const hoverFields = Object.keys(filters);
|
||||
const stats = await fetchHexagonStats(
|
||||
h3,
|
||||
resolution,
|
||||
signal,
|
||||
hoverFields.length > 0 ? hoverFields : undefined
|
||||
);
|
||||
if (!signal.aborted) setHoveredAreaStats(stats);
|
||||
} else if (rightPaneTab === 'properties') {
|
||||
const params = new URLSearchParams({
|
||||
h3,
|
||||
resolution: resolution.toString(),
|
||||
limit: '3',
|
||||
offset: '0',
|
||||
});
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
const response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`, {
|
||||
signal,
|
||||
});
|
||||
const data: HexagonPropertiesResponse = await response.json();
|
||||
if (!signal.aborted) {
|
||||
setHoveredProperties(data.properties);
|
||||
setHoveredPropertiesTotal(data.total);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name !== 'AbortError') {
|
||||
console.error('Failed to fetch hover data:', error);
|
||||
}
|
||||
} finally {
|
||||
if (!signal.aborted) setLoadingHoveredAreaStats(false);
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
},
|
||||
[hoverMode, selectedHexagon, rightPaneTab, resolution, filters, features, fetchHexagonStats]
|
||||
);
|
||||
const handleHexagonHover = useCallback((h3: string | null) => {
|
||||
setHoveredHexagon(h3);
|
||||
}, []);
|
||||
|
||||
const handleViewPropertiesFromArea = useCallback(() => {
|
||||
if (selectedHexagon) {
|
||||
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
|
||||
setRightPaneTab('properties');
|
||||
setPropertiesOffset(0);
|
||||
fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, 0);
|
||||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
||||
}
|
||||
}, [selectedHexagon, fetchHexagonProperties]);
|
||||
|
||||
const handleLoadMoreProperties = useCallback(() => {
|
||||
if (selectedHexagon) {
|
||||
fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, propertiesOffset);
|
||||
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
|
||||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, propertiesOffset);
|
||||
}
|
||||
}, [selectedHexagon, propertiesOffset, fetchHexagonProperties]);
|
||||
|
||||
|
|
@ -593,6 +593,8 @@ export default function App() {
|
|||
<div className="h-screen w-screen">
|
||||
<Map
|
||||
data={data}
|
||||
postcodeData={postcodeData}
|
||||
usePostcodeView={usePostcodeView}
|
||||
pois={pois}
|
||||
onViewChange={handleViewChange}
|
||||
viewFeature={viewFeature}
|
||||
|
|
@ -657,31 +659,53 @@ export default function App() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Filters
|
||||
features={features}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
enabledFeatures={enabledFeatures}
|
||||
onAddFilter={handleAddFilter}
|
||||
onRemoveFilter={handleRemoveFilter}
|
||||
onFilterChange={handleFilterChange}
|
||||
onDragStart={handleDragStart}
|
||||
onDragChange={handleDragChange}
|
||||
onDragEnd={handleDragEnd}
|
||||
zoom={zoom}
|
||||
pinnedFeature={pinnedFeature}
|
||||
onTogglePin={handleTogglePin}
|
||||
onCancelPin={handleCancelPin}
|
||||
onNavigateToSource={(slug, featureName) => {
|
||||
navigateTo('data-sources', slug, featureName);
|
||||
}}
|
||||
openInfoFeature={pendingInfoFeature}
|
||||
onClearOpenInfoFeature={() => setPendingInfoFeature(null)}
|
||||
/>
|
||||
<div
|
||||
className={`flex ${leftPaneCollapsed ? 'w-10' : 'w-96'} bg-white dark:bg-navy-950 shadow-lg overflow-hidden`}
|
||||
>
|
||||
{leftPaneCollapsed ? (
|
||||
<button
|
||||
onClick={() => setLeftPaneCollapsed(false)}
|
||||
className="w-full h-full flex flex-col items-center justify-center gap-2 text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400 hover:bg-warm-50 dark:hover:bg-navy-800"
|
||||
title="Expand filters"
|
||||
>
|
||||
<ChevronIcon direction="right" className="w-5 h-5" />
|
||||
<span className="text-xs font-medium writing-mode-vertical">Filters</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<Filters
|
||||
features={features}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
enabledFeatures={enabledFeatures}
|
||||
onAddFilter={handleAddFilter}
|
||||
onRemoveFilter={handleRemoveFilter}
|
||||
onFilterChange={handleFilterChange}
|
||||
onDragStart={handleDragStart}
|
||||
onDragChange={handleDragChange}
|
||||
onDragEnd={handleDragEnd}
|
||||
zoom={zoom}
|
||||
itemCount={usePostcodeView ? postcodeData.length : data.length}
|
||||
usePostcodeView={usePostcodeView}
|
||||
pinnedFeature={pinnedFeature}
|
||||
onTogglePin={handleTogglePin}
|
||||
onCancelPin={handleCancelPin}
|
||||
onNavigateToSource={(slug, featureName) => {
|
||||
navigateTo('data-sources', slug, featureName);
|
||||
}}
|
||||
openInfoFeature={pendingInfoFeature}
|
||||
onClearOpenInfoFeature={() => setPendingInfoFeature(null)}
|
||||
onCollapse={() => setLeftPaneCollapsed(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 relative">
|
||||
<Map
|
||||
data={data}
|
||||
postcodeData={postcodeData}
|
||||
usePostcodeView={usePostcodeView}
|
||||
pois={pois}
|
||||
onViewChange={handleViewChange}
|
||||
viewFeature={viewFeature}
|
||||
|
|
@ -690,12 +714,15 @@ export default function App() {
|
|||
viewSource={viewSource}
|
||||
onCancelPin={handleCancelPin}
|
||||
features={features}
|
||||
selectedHexagonId={selectedHexagon?.h3 || null}
|
||||
selectedHexagonId={selectedHexagon?.id || null}
|
||||
hoveredHexagonId={hoveredHexagon}
|
||||
onHexagonClick={handleHexagonClick}
|
||||
onHexagonHover={handleHexagonHover}
|
||||
initialViewState={initialViewState}
|
||||
theme={theme}
|
||||
filters={filters}
|
||||
searchedPostcode={searchedPostcode}
|
||||
onPostcodeSearched={setSearchedPostcode}
|
||||
/>
|
||||
{loading && (
|
||||
<div className="absolute bottom-4 left-4 bg-white dark:bg-navy-800 dark:text-warm-200 px-3 py-1 rounded shadow">
|
||||
|
|
@ -704,115 +731,107 @@ export default function App() {
|
|||
)}
|
||||
<DataSources onNavigate={() => navigateTo('data-sources')} />
|
||||
</div>
|
||||
<div className="w-72 bg-white dark:bg-navy-950 shadow-lg z-10 flex flex-col">
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||
<div
|
||||
className={`${rightPaneCollapsed ? 'w-10' : 'w-72'} bg-white dark:bg-navy-950 shadow-lg z-10 flex flex-col`}
|
||||
>
|
||||
{rightPaneCollapsed ? (
|
||||
<button
|
||||
className={`flex-1 p-3 ${
|
||||
rightPaneTab === 'area'
|
||||
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
|
||||
: 'text-warm-600 dark:text-warm-400'
|
||||
}`}
|
||||
onClick={() => setRightPaneTab('area')}
|
||||
onClick={() => setRightPaneCollapsed(false)}
|
||||
className="w-full h-full flex flex-col items-center justify-center gap-2 text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400 hover:bg-warm-50 dark:hover:bg-navy-800"
|
||||
title="Expand panel"
|
||||
>
|
||||
Area {areaStats ? `(${areaStats.count})` : ''}
|
||||
<ChevronIcon direction="left" className="w-5 h-5" />
|
||||
<span className="text-xs font-medium writing-mode-vertical">
|
||||
{rightPaneTab === 'area'
|
||||
? 'Area'
|
||||
: rightPaneTab === 'properties'
|
||||
? 'Properties'
|
||||
: 'POIs'}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 p-3 ${
|
||||
rightPaneTab === 'properties'
|
||||
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
|
||||
: 'text-warm-600 dark:text-warm-400'
|
||||
}`}
|
||||
onClick={() => setRightPaneTab('properties')}
|
||||
>
|
||||
Properties {propertiesTotal > 0 && `(${propertiesTotal})`}
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 p-3 ${
|
||||
rightPaneTab === 'pois'
|
||||
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
|
||||
: 'text-warm-600 dark:text-warm-400'
|
||||
}`}
|
||||
onClick={() => setRightPaneTab('pois')}
|
||||
>
|
||||
POIs {pois.length > 0 && `(${pois.length})`}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||
<button
|
||||
onClick={() => setRightPaneCollapsed(true)}
|
||||
className="px-2 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 border-r border-warm-200 dark:border-navy-700"
|
||||
title="Collapse panel"
|
||||
>
|
||||
<ChevronIcon direction="right" className="w-4 h-4" />
|
||||
</button>
|
||||
<TabButton
|
||||
label="Area"
|
||||
count={areaStats?.count}
|
||||
isActive={rightPaneTab === 'area'}
|
||||
onClick={() => setRightPaneTab('area')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Properties"
|
||||
count={propertiesTotal > 0 ? propertiesTotal : undefined}
|
||||
isActive={rightPaneTab === 'properties'}
|
||||
onClick={() => setRightPaneTab('properties')}
|
||||
/>
|
||||
<TabButton
|
||||
label="POIs"
|
||||
count={pois.length > 0 ? pois.length : undefined}
|
||||
isActive={rightPaneTab === 'pois'}
|
||||
onClick={() => setRightPaneTab('pois')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{rightPaneTab === 'area' ? (
|
||||
<AreaPane
|
||||
stats={hoverMode && hoveredAreaStats ? hoveredAreaStats : areaStats}
|
||||
globalFeatures={features}
|
||||
loading={
|
||||
hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
|
||||
? loadingHoveredAreaStats
|
||||
: loadingAreaStats
|
||||
}
|
||||
hexagonId={
|
||||
hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
|
||||
? hoveredHexagon
|
||||
: selectedHexagon?.h3 || null
|
||||
}
|
||||
isHoveredPreview={
|
||||
!!(
|
||||
hoverMode &&
|
||||
hoveredAreaStats &&
|
||||
hoveredHexagon &&
|
||||
hoveredHexagon !== selectedHexagon?.h3
|
||||
)
|
||||
}
|
||||
hoverMode={hoverMode}
|
||||
onHoverModeChange={setHoverMode}
|
||||
onViewProperties={handleViewPropertiesFromArea}
|
||||
onClose={handleCloseProperties}
|
||||
hexagonLocation={(() => {
|
||||
const hexId =
|
||||
hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
|
||||
? hoveredHexagon
|
||||
: selectedHexagon?.h3;
|
||||
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}
|
||||
/>
|
||||
) : rightPaneTab === 'properties' ? (
|
||||
<PropertiesPane
|
||||
properties={hoverMode && hoveredProperties ? hoveredProperties : properties}
|
||||
total={hoverMode && hoveredProperties ? hoveredPropertiesTotal : propertiesTotal}
|
||||
loading={loadingProperties}
|
||||
hexagonId={
|
||||
hoverMode && hoveredProperties ? hoveredHexagon : selectedHexagon?.h3 || null
|
||||
}
|
||||
onLoadMore={handleLoadMoreProperties}
|
||||
onClose={handleCloseProperties}
|
||||
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
|
||||
isHoveredPreview={
|
||||
!!(
|
||||
hoverMode &&
|
||||
hoveredProperties &&
|
||||
hoveredHexagon &&
|
||||
hoveredHexagon !== selectedHexagon?.h3
|
||||
)
|
||||
}
|
||||
hoverMode={hoverMode}
|
||||
onHoverModeChange={setHoverMode}
|
||||
/>
|
||||
) : (
|
||||
<POIPane
|
||||
groups={poiCategoryGroups}
|
||||
selectedCategories={selectedPOICategories}
|
||||
onCategoriesChange={setSelectedPOICategories}
|
||||
poiCount={pois.length}
|
||||
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{rightPaneTab === 'area' ? (
|
||||
<AreaPane
|
||||
stats={areaStats}
|
||||
globalFeatures={features}
|
||||
loading={loadingAreaStats}
|
||||
hexagonId={selectedHexagon?.id || null}
|
||||
isPostcode={selectedHexagon?.type === 'postcode'}
|
||||
postcodeData={
|
||||
selectedHexagon?.type === 'postcode'
|
||||
? postcodeData.find((d) => 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' ? (
|
||||
<PropertiesPane
|
||||
properties={properties}
|
||||
total={propertiesTotal}
|
||||
loading={loadingProperties}
|
||||
hexagonId={selectedHexagon?.id || null}
|
||||
onLoadMore={handleLoadMoreProperties}
|
||||
onClose={handleCloseProperties}
|
||||
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
|
||||
/>
|
||||
) : (
|
||||
<POIPane
|
||||
groups={poiCategoryGroups}
|
||||
selectedCategories={selectedPOICategories}
|
||||
onCategoriesChange={setSelectedPOICategories}
|
||||
poiCount={pois.length}
|
||||
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,28 @@
|
|||
import { useMemo } from 'react';
|
||||
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse } from '../types';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeData } from '../types';
|
||||
import type { HexagonLocation } from '../lib/external-search';
|
||||
import { formatValue } from '../lib/format';
|
||||
import { formatValue, calculateHistogramMean } from '../lib/format';
|
||||
import { groupFeaturesByCategory } from '../lib/features';
|
||||
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
|
||||
import EnumBarChart from './EnumBarChart';
|
||||
import ExternalSearchLinks from './ExternalSearchLinks';
|
||||
import { InfoIcon, CloseIcon } from './ui/Icons';
|
||||
import { IconButton } from './ui/IconButton';
|
||||
import { FeatureInfoPopup } from './FeatureInfoPopup';
|
||||
import { PaneEmptyState } from './ui/EmptyState';
|
||||
|
||||
interface AreaPaneProps {
|
||||
stats: HexagonStatsResponse | null;
|
||||
globalFeatures: FeatureMeta[];
|
||||
loading: boolean;
|
||||
hexagonId: string | null;
|
||||
isHoveredPreview: boolean;
|
||||
hoverMode: boolean;
|
||||
onHoverModeChange: (enabled: boolean) => void;
|
||||
isPostcode?: boolean;
|
||||
postcodeData?: PostcodeData | null;
|
||||
onViewProperties: () => void;
|
||||
onClose: () => void;
|
||||
hexagonLocation: HexagonLocation | null;
|
||||
filters: FeatureFilters;
|
||||
}
|
||||
|
||||
function groupFeatures(globalFeatures: FeatureMeta[]): { name: string; features: FeatureMeta[] }[] {
|
||||
const groups: { name: string; features: FeatureMeta[] }[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const feature of globalFeatures) {
|
||||
const groupName = feature.group || 'Other';
|
||||
if (!seen.has(groupName)) {
|
||||
seen.add(groupName);
|
||||
groups.push({ name: groupName, features: [] });
|
||||
}
|
||||
groups.find((group) => group.name === groupName)!.features.push(feature);
|
||||
}
|
||||
return groups;
|
||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
}
|
||||
|
||||
export default function AreaPane({
|
||||
|
|
@ -39,15 +30,18 @@ export default function AreaPane({
|
|||
globalFeatures,
|
||||
loading,
|
||||
hexagonId,
|
||||
isHoveredPreview,
|
||||
hoverMode,
|
||||
onHoverModeChange,
|
||||
isPostcode = false,
|
||||
postcodeData,
|
||||
onViewProperties,
|
||||
onClose,
|
||||
hexagonLocation,
|
||||
filters,
|
||||
onNavigateToSource,
|
||||
}: AreaPaneProps) {
|
||||
const featureGroups = useMemo(() => groupFeatures(globalFeatures), [globalFeatures]);
|
||||
// For postcodes, use local data for count
|
||||
const propertyCount = isPostcode && postcodeData ? postcodeData.count : stats?.count;
|
||||
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
|
||||
const numericByName = useMemo(() => {
|
||||
if (!stats) return new Map();
|
||||
|
|
@ -65,78 +59,31 @@ export default function AreaPane({
|
|||
);
|
||||
|
||||
if (!hexagonId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-warm-500 dark:text-warm-400 px-4 text-center text-sm">
|
||||
Click a hexagon to view area statistics
|
||||
</div>
|
||||
);
|
||||
return <PaneEmptyState message="Click a hexagon or postcode to view area statistics" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold dark:text-warm-100">Area Statistics</h2>
|
||||
{isHoveredPreview && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
|
||||
Preview
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold dark:text-warm-100">
|
||||
{isPostcode ? hexagonId : 'Area Statistics'}
|
||||
</h2>
|
||||
{isPostcode && (
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">Postcode</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onHoverModeChange(!hoverMode)}
|
||||
className={`p-1 rounded ${
|
||||
hoverMode
|
||||
? 'text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
}`}
|
||||
title={
|
||||
hoverMode ? 'Live preview on (click to lock)' : 'Live preview off (click to enable)'
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-1"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<IconButton onClick={onClose} title="Close">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
{stats && (
|
||||
{propertyCount != null && (
|
||||
<p className="text-sm text-warm-600 dark:text-warm-400 mt-1">
|
||||
{stats.count.toLocaleString()} properties
|
||||
{propertyCount.toLocaleString()} properties
|
||||
</p>
|
||||
)}
|
||||
{stats && (
|
||||
{!isPostcode && stats && (
|
||||
<button
|
||||
onClick={onViewProperties}
|
||||
className="mt-2 w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
|
||||
|
|
@ -174,19 +121,9 @@ export default function AreaPane({
|
|||
if (numericStats) {
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
const globalHistogram = globalFeature?.histogram;
|
||||
let globalMean: number | undefined;
|
||||
if (globalHistogram && globalHistogram.counts.length > 0) {
|
||||
const totalCount = globalHistogram.counts.reduce((a, b) => a + b, 0);
|
||||
if (totalCount > 0) {
|
||||
let weightedSum = 0;
|
||||
for (let i = 0; i < globalHistogram.counts.length; i++) {
|
||||
const binCenter =
|
||||
globalHistogram.min + (i + 0.5) * globalHistogram.bin_width;
|
||||
weightedSum += binCenter * globalHistogram.counts[i];
|
||||
}
|
||||
globalMean = weightedSum / totalCount;
|
||||
}
|
||||
}
|
||||
const globalMean = globalHistogram
|
||||
? calculateHistogramMean(globalHistogram)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -194,9 +131,20 @@ export default function AreaPane({
|
|||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||
{feature.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 min-w-0 mr-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate">
|
||||
{feature.name}
|
||||
</span>
|
||||
{feature.detail && (
|
||||
<button
|
||||
onClick={() => setInfoFeature(feature)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
title="Feature info"
|
||||
>
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(numericStats.mean)}
|
||||
</span>
|
||||
|
|
@ -231,9 +179,20 @@ export default function AreaPane({
|
|||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{feature.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{feature.name}
|
||||
</span>
|
||||
{feature.detail && (
|
||||
<button
|
||||
onClick={() => setInfoFeature(feature)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
title="Feature info"
|
||||
>
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<EnumBarChart counts={enumStats.counts} />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -248,6 +207,14 @@ export default function AreaPane({
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{infoFeature && (
|
||||
<FeatureInfoPopup
|
||||
feature={infoFeature}
|
||||
onClose={() => setInfoFeature(null)}
|
||||
onNavigateToSource={onNavigateToSource}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
47
frontend/src/components/FeatureIcons.tsx
Normal file
47
frontend/src/components/FeatureIcons.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import type { FeatureMeta } from '../types';
|
||||
import { EyeIcon, InfoIcon, PlusIcon, CloseIcon } from './ui/Icons';
|
||||
import { IconButton } from './ui/IconButton';
|
||||
|
||||
// Re-export icons for backwards compatibility
|
||||
export { EyeIcon, InfoIcon, CloseIcon as RemoveIcon } from './ui/Icons';
|
||||
|
||||
interface FeatureActionsProps {
|
||||
feature: FeatureMeta;
|
||||
isPinned: boolean;
|
||||
onTogglePin: (name: string) => void;
|
||||
onShowInfo?: (feature: FeatureMeta) => void;
|
||||
onRemove?: (name: string) => void;
|
||||
onAdd?: (name: string) => void;
|
||||
}
|
||||
|
||||
export function FeatureActions({
|
||||
feature,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
onShowInfo,
|
||||
onRemove,
|
||||
onAdd,
|
||||
}: FeatureActionsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{feature.detail && onShowInfo && (
|
||||
<IconButton onClick={() => onShowInfo(feature)} title="Feature info">
|
||||
<InfoIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton onClick={() => onTogglePin(feature.name)} title={isPinned ? 'Unpin color view' : 'Color map by this feature'} active={isPinned}>
|
||||
<EyeIcon filled={isPinned} />
|
||||
</IconButton>
|
||||
{onAdd && (
|
||||
<IconButton onClick={() => onAdd(feature.name)} title="Add filter">
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
{onRemove && (
|
||||
<IconButton onClick={() => onRemove(feature.name)} title="Remove filter">
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
frontend/src/components/FeatureInfoPopup.tsx
Normal file
37
frontend/src/components/FeatureInfoPopup.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { FeatureMeta } from '../types';
|
||||
import InfoPopup from './InfoPopup';
|
||||
|
||||
interface FeatureInfoPopupProps {
|
||||
feature: FeatureMeta;
|
||||
onClose: () => void;
|
||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
}
|
||||
|
||||
export function FeatureInfoPopup({ feature, onClose, onNavigateToSource }: FeatureInfoPopupProps) {
|
||||
return (
|
||||
<InfoPopup
|
||||
title={feature.name}
|
||||
onClose={onClose}
|
||||
sourceLink={
|
||||
feature.source && onNavigateToSource
|
||||
? {
|
||||
label: 'View data source',
|
||||
onClick: () => {
|
||||
onNavigateToSource(feature.source!, feature.name);
|
||||
onClose();
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{feature.description && (
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 mb-2">{feature.description}</p>
|
||||
)}
|
||||
{feature.detail && (
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
{feature.detail}
|
||||
</p>
|
||||
)}
|
||||
</InfoPopup>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +1,17 @@
|
|||
import { memo, useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
||||
import { Slider } from './ui/slider';
|
||||
import { Label } from './ui/label';
|
||||
import { SearchInput } from './ui/SearchInput';
|
||||
import { SelectionButtons } from './ui/SelectionButtons';
|
||||
import { ChevronIcon, FilterIcon, LightbulbIcon } from './ui/Icons';
|
||||
import { IconButton } from './ui/IconButton';
|
||||
import { EmptyState } from './ui/EmptyState';
|
||||
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||
import { formatFilterValue } from '../lib/format';
|
||||
import { groupFeaturesByCategory } from '../lib/features';
|
||||
import InfoPopup from './InfoPopup';
|
||||
import { FeatureInfoPopup } from './FeatureInfoPopup';
|
||||
import { FeatureActions } from './FeatureIcons';
|
||||
|
||||
interface FiltersProps {
|
||||
features: FeatureMeta[];
|
||||
|
|
@ -18,27 +26,15 @@ interface FiltersProps {
|
|||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
zoom: number;
|
||||
itemCount: number;
|
||||
usePostcodeView: boolean;
|
||||
pinnedFeature: string | null;
|
||||
onTogglePin: (name: string) => void;
|
||||
onCancelPin: () => void;
|
||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
openInfoFeature?: string | null;
|
||||
onClearOpenInfoFeature?: () => void;
|
||||
}
|
||||
|
||||
function EyeIcon({ filled, className }: { filled: boolean; className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className || 'w-3.5 h-3.5'}
|
||||
viewBox="0 0 24 24"
|
||||
fill={filled ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
onCollapse?: () => void;
|
||||
}
|
||||
|
||||
function FeatureBrowser({
|
||||
|
|
@ -77,32 +73,12 @@ function FeatureBrowser({
|
|||
return availableFeatures.filter((f) => f.name.toLowerCase().includes(lower));
|
||||
}, [availableFeatures, search]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const groups: { name: string; features: FeatureMeta[] }[] = [];
|
||||
const seen = new Map<string, FeatureMeta[]>();
|
||||
for (const f of filtered) {
|
||||
const g = f.group || 'Other';
|
||||
let arr = seen.get(g);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
seen.set(g, arr);
|
||||
groups.push({ name: g, features: arr });
|
||||
}
|
||||
arr.push(f);
|
||||
}
|
||||
return groups;
|
||||
}, [filtered]);
|
||||
const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search features..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm border rounded bg-white dark:bg-navy-800 dark:text-warm-200 border-warm-200 dark:border-navy-700 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-1 focus:ring-teal-400"
|
||||
/>
|
||||
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{grouped.map((group) => (
|
||||
|
|
@ -125,86 +101,31 @@ function FeatureBrowser({
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0 mt-0.5">
|
||||
{f.detail && (
|
||||
<button
|
||||
onClick={() => setInfoFeature(f)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
|
||||
title="Feature info"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onTogglePin(f.name)}
|
||||
className={`p-0.5 rounded ${isPinned ? 'text-teal-600 dark:text-teal-400' : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'}`}
|
||||
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
|
||||
>
|
||||
<EyeIcon filled={isPinned} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAddFilter(f.name)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
|
||||
title="Add filter"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m-7-7h14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<FeatureActions
|
||||
feature={f}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={setInfoFeature}
|
||||
onAdd={onAddFilter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{grouped.length === 0 && (
|
||||
<div className="px-3 py-4 text-sm text-warm-400 dark:text-warm-500 text-center">
|
||||
{search ? 'No matching features' : 'All features are active'}
|
||||
</div>
|
||||
<EmptyState
|
||||
title={search ? 'No matching features' : 'All features are active'}
|
||||
className="px-3 py-4"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{infoFeature && (
|
||||
<InfoPopup
|
||||
title={infoFeature.name}
|
||||
<FeatureInfoPopup
|
||||
feature={infoFeature}
|
||||
onClose={() => setInfoFeature(null)}
|
||||
sourceLink={
|
||||
infoFeature.source && onNavigateToSource
|
||||
? {
|
||||
label: 'View data source',
|
||||
onClick: () => {
|
||||
onNavigateToSource(infoFeature.source!, infoFeature.name);
|
||||
setInfoFeature(null);
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{infoFeature.description && (
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 mb-2">
|
||||
{infoFeature.description}
|
||||
</p>
|
||||
)}
|
||||
{infoFeature.detail && (
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
{infoFeature.detail}
|
||||
</p>
|
||||
)}
|
||||
</InfoPopup>
|
||||
onNavigateToSource={onNavigateToSource}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
@ -223,12 +144,15 @@ export default memo(function Filters({
|
|||
onDragChange,
|
||||
onDragEnd,
|
||||
zoom,
|
||||
itemCount,
|
||||
usePostcodeView,
|
||||
pinnedFeature,
|
||||
onTogglePin,
|
||||
onCancelPin: _onCancelPin,
|
||||
onNavigateToSource,
|
||||
openInfoFeature,
|
||||
onClearOpenInfoFeature,
|
||||
onCollapse,
|
||||
}: FiltersProps) {
|
||||
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
|
||||
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
|
||||
|
|
@ -236,6 +160,8 @@ export default memo(function Filters({
|
|||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [splitFraction, setSplitFraction] = useState(0.65);
|
||||
const draggingRef = useRef(false);
|
||||
const [showPhilosophy, setShowPhilosophy] = useState(false);
|
||||
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
|
||||
const handleSeparatorPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -258,8 +184,22 @@ export default memo(function Filters({
|
|||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-80 flex flex-col bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
|
||||
className="flex flex-col bg-white dark:bg-navy-950 overflow-hidden h-full"
|
||||
>
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
{onCollapse && (
|
||||
<IconButton onClick={onCollapse} title="Collapse filters">
|
||||
<ChevronIcon direction="left" />
|
||||
</IconButton>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowPhilosophy(true)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md flex items-center justify-center gap-2"
|
||||
>
|
||||
<LightbulbIcon />
|
||||
Finding the Perfect Postcode
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex flex-col" style={{ height: `${splitFraction * 100}%` }}>
|
||||
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -272,32 +212,19 @@ export default memo(function Filters({
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">Zoom {zoom.toFixed(1)}</span>
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">
|
||||
{itemCount.toLocaleString()} {usePostcodeView ? 'postcodes' : 'hexagons'} · z
|
||||
{zoom.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
{enabledFeatureList.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-warm-300 dark:text-warm-600 mb-2"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-warm-400 dark:text-warm-500">
|
||||
No active filters
|
||||
</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 mt-1">
|
||||
Browse features below and click + to add a filter
|
||||
</span>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
|
||||
title="No active filters"
|
||||
description="Browse features below and click + to add a filter"
|
||||
/>
|
||||
)}
|
||||
|
||||
{enabledFeatureList.map((feature) => {
|
||||
|
|
@ -311,41 +238,19 @@ export default memo(function Filters({
|
|||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{feature.name}</Label>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => onTogglePin(feature.name)}
|
||||
className={`p-0.5 rounded ${pinnedFeature === feature.name ? 'text-teal-600 dark:text-teal-400' : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'}`}
|
||||
title={
|
||||
pinnedFeature === feature.name
|
||||
? 'Unpin color view'
|
||||
: 'Color map by this feature'
|
||||
}
|
||||
>
|
||||
<EyeIcon filled={pinnedFeature === feature.name} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemoveFilter(feature.name)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 text-sm px-1"
|
||||
title="Remove filter"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 text-sm mb-1">
|
||||
<button
|
||||
className="text-teal-600 dark:text-teal-400 hover:underline"
|
||||
onClick={() => onFilterChange(feature.name, [...allValues])}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
className="text-teal-600 dark:text-teal-400 hover:underline"
|
||||
onClick={() => onFilterChange(feature.name, [])}
|
||||
>
|
||||
None
|
||||
</button>
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={pinnedFeature === feature.name}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={setActiveInfoFeature}
|
||||
onRemove={onRemoveFilter}
|
||||
/>
|
||||
</div>
|
||||
<SelectionButtons
|
||||
onSelectAll={() => onFilterChange(feature.name, [...allValues])}
|
||||
onSelectNone={() => onFilterChange(feature.name, [])}
|
||||
className="mb-1"
|
||||
/>
|
||||
<div className="space-y-0.5 max-h-40 overflow-y-auto">
|
||||
{allValues.map((val) => (
|
||||
<label
|
||||
|
|
@ -389,22 +294,13 @@ export default memo(function Filters({
|
|||
{feature.name}: {formatFilterValue(displayValue[0])} -{' '}
|
||||
{formatFilterValue(displayValue[1])}
|
||||
</Label>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => onTogglePin(feature.name)}
|
||||
className={`p-0.5 rounded ${isPinned ? 'text-teal-600 dark:text-teal-400' : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'}`}
|
||||
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
|
||||
>
|
||||
<EyeIcon filled={isPinned} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemoveFilter(feature.name)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 text-sm px-1"
|
||||
title="Remove filter"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={setActiveInfoFeature}
|
||||
onRemove={onRemoveFilter}
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
min={feature.min!}
|
||||
|
|
@ -447,6 +343,73 @@ export default memo(function Filters({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPhilosophy && (
|
||||
<InfoPopup title="Finding the Perfect Postcode" onClose={() => setShowPhilosophy(false)}>
|
||||
<div className="space-y-4 text-sm">
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
See the full picture
|
||||
</h4>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
Your priorities, your filters
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
We all care about different things. Some want peace and quiet; others want to be
|
||||
near the action. Use our filters to define exactly what matters to you and discover
|
||||
postcodes that match.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
||||
{activeInfoFeature && (
|
||||
<FeatureInfoPopup
|
||||
feature={activeInfoFeature}
|
||||
onClose={() => setActiveInfoFeature(null)}
|
||||
onNavigateToSource={onNavigateToSource}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
94
frontend/src/components/HoverCard.tsx
Normal file
94
frontend/src/components/HoverCard.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { memo } from 'react';
|
||||
import type { HexagonData, PostcodeData, FeatureFilters } from '../types';
|
||||
import { formatValue } from '../lib/format';
|
||||
|
||||
interface HoverCardProps {
|
||||
x: number;
|
||||
y: number;
|
||||
id: string;
|
||||
isPostcode: boolean;
|
||||
data: HexagonData | PostcodeData | null;
|
||||
filters: FeatureFilters;
|
||||
}
|
||||
|
||||
export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }: HoverCardProps) {
|
||||
const activeFilterNames = Object.keys(filters);
|
||||
|
||||
// Get key stats to show from local data (min_<feature> values)
|
||||
const getDisplayStats = () => {
|
||||
if (!data) return [];
|
||||
|
||||
const results: { name: string; value: string }[] = [];
|
||||
|
||||
// Show stats for active filters (up to 4)
|
||||
for (const name of activeFilterNames.slice(0, 4)) {
|
||||
const minVal = data[`min_${name}`];
|
||||
if (minVal != null && typeof minVal === 'number') {
|
||||
results.push({ name, value: formatValue(minVal) });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const displayStats = getDisplayStats();
|
||||
const count = data?.count;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg p-3 text-sm dark:text-warm-200 pointer-events-none z-50 min-w-[180px] max-w-[260px]"
|
||||
style={{
|
||||
left: x,
|
||||
top: y - 12,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}}
|
||||
>
|
||||
{/* Arrow */}
|
||||
<div
|
||||
className="absolute w-3 h-3 bg-white dark:bg-warm-800 rotate-45"
|
||||
style={{
|
||||
left: '50%',
|
||||
bottom: -6,
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="font-semibold text-navy-950 dark:text-warm-100 truncate">
|
||||
{isPostcode ? id : 'Area'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Property count */}
|
||||
{count != null && (
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-2">
|
||||
{count.toLocaleString()} {count === 1 ? 'property' : 'properties'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick stats */}
|
||||
{displayStats.length > 0 && (
|
||||
<div className="space-y-1 border-t border-warm-200 dark:border-warm-700 pt-2">
|
||||
{displayStats.map((stat) => (
|
||||
<div key={stat.name} className="flex justify-between gap-2 text-xs">
|
||||
<span className="text-warm-500 dark:text-warm-400 truncate">{stat.name}</span>
|
||||
<span className="font-medium text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{stat.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hint */}
|
||||
{data && (
|
||||
<div className="text-[10px] text-warm-400 dark:text-warm-500 mt-2 text-center">
|
||||
Click for details
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { useRef, useCallback, type ReactNode } from 'react';
|
||||
import { useClickOutside } from '../hooks/useClickOutside';
|
||||
import { CloseIcon } from './ui/Icons';
|
||||
import { IconButton } from './ui/IconButton';
|
||||
|
||||
interface InfoPopupProps {
|
||||
title: string;
|
||||
|
|
@ -25,20 +27,9 @@ export default function InfoPopup({ title, children, onClose, sourceLink }: Info
|
|||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<IconButton onClick={onClose} className="shrink-0">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
{children}
|
||||
{sourceLink && (
|
||||
|
|
|
|||
|
|
@ -3,10 +3,18 @@ import { Map as MapGL, useControl } from 'react-map-gl/maplibre';
|
|||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
||||
import { IconLayer } from '@deck.gl/layers';
|
||||
import { IconLayer, PolygonLayer } from '@deck.gl/layers';
|
||||
import type { PickingInfo } from '@deck.gl/core';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI, FeatureMeta } from '../types';
|
||||
import type {
|
||||
HexagonData,
|
||||
PostcodeData,
|
||||
ViewState,
|
||||
ViewChangeParams,
|
||||
Bounds,
|
||||
POI,
|
||||
FeatureMeta,
|
||||
} from '../types';
|
||||
import {
|
||||
GRADIENT,
|
||||
normalizedToColor,
|
||||
|
|
@ -14,11 +22,14 @@ import {
|
|||
zoomToResolution,
|
||||
getBoundsFromViewState,
|
||||
emojiToTwemojiUrl,
|
||||
MAP_STYLE_LIGHT,
|
||||
MAP_STYLE_DARK,
|
||||
getMapStyle,
|
||||
POSTCODE_ZOOM_THRESHOLD,
|
||||
} from '../lib/map-utils';
|
||||
import PostcodeSearch from './PostcodeSearch';
|
||||
import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../lib/consts';
|
||||
import PostcodeSearch, { type SearchedPostcode } from './PostcodeSearch';
|
||||
import MapLegend from './MapLegend';
|
||||
import HoverCard from './HoverCard';
|
||||
import type { FeatureFilters } from '../types';
|
||||
|
||||
/** Convert POI id (e.g. "n12345") to OpenStreetMap URL */
|
||||
function osmIdToUrl(id: string): string | null {
|
||||
|
|
@ -30,6 +41,8 @@ function osmIdToUrl(id: string): string | null {
|
|||
|
||||
interface MapProps {
|
||||
data: HexagonData[];
|
||||
postcodeData: PostcodeData[];
|
||||
usePostcodeView: boolean;
|
||||
pois: POI[];
|
||||
onViewChange: (params: ViewChangeParams) => void;
|
||||
viewFeature: string | null;
|
||||
|
|
@ -40,19 +53,16 @@ interface MapProps {
|
|||
features: FeatureMeta[];
|
||||
selectedHexagonId: string | null;
|
||||
hoveredHexagonId: string | null;
|
||||
onHexagonClick: (h3: string) => void;
|
||||
onHexagonHover: (h3: string | null) => void;
|
||||
onHexagonClick: (id: string, isPostcode?: boolean) => void;
|
||||
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
|
||||
initialViewState?: ViewState;
|
||||
theme?: 'light' | 'dark';
|
||||
screenshotMode?: boolean;
|
||||
filters?: FeatureFilters;
|
||||
searchedPostcode?: SearchedPostcode | null;
|
||||
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
|
||||
}
|
||||
|
||||
const INITIAL_VIEW: ViewState = {
|
||||
longitude: -1.5,
|
||||
latitude: 53.5,
|
||||
zoom: 6,
|
||||
pitch: 0,
|
||||
};
|
||||
|
||||
interface Dimensions {
|
||||
width: number;
|
||||
|
|
@ -81,6 +91,8 @@ function DeckOverlay({
|
|||
|
||||
export default memo(function Map({
|
||||
data,
|
||||
postcodeData,
|
||||
usePostcodeView,
|
||||
pois,
|
||||
onViewChange,
|
||||
viewFeature,
|
||||
|
|
@ -96,10 +108,14 @@ export default memo(function Map({
|
|||
initialViewState,
|
||||
theme = 'light',
|
||||
screenshotMode = false,
|
||||
filters = {},
|
||||
searchedPostcode,
|
||||
onPostcodeSearched,
|
||||
}: MapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW);
|
||||
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
||||
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
|
|
@ -119,17 +135,11 @@ export default memo(function Map({
|
|||
useEffect(() => {
|
||||
if (dimensions.width === 0 || dimensions.height === 0) return;
|
||||
|
||||
const raw = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
|
||||
// Send exact viewport bounds - server will filter to only return
|
||||
// hexagons/postcodes that intersect this precise AABB
|
||||
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
|
||||
const resolution = zoomToResolution(viewState.zoom);
|
||||
|
||||
const QUANT = 0.01;
|
||||
const bounds: Bounds = {
|
||||
south: Math.floor(raw.south / QUANT) * QUANT,
|
||||
west: Math.floor(raw.west / QUANT) * QUANT,
|
||||
north: Math.ceil(raw.north / QUANT) * QUANT,
|
||||
east: Math.ceil(raw.east / QUANT) * QUANT,
|
||||
};
|
||||
|
||||
onViewChange({
|
||||
resolution,
|
||||
bounds,
|
||||
|
|
@ -153,30 +163,17 @@ export default memo(function Map({
|
|||
const handleMapLoad = useCallback(
|
||||
(evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
|
||||
const map = evt.target;
|
||||
if (themeRef.current === 'light') {
|
||||
for (const layer of map.getStyle().layers || []) {
|
||||
if (layer.type !== 'symbol') continue;
|
||||
map.setPaintProperty(layer.id, 'text-halo-color', 'rgba(255,255,255,1)');
|
||||
map.setPaintProperty(layer.id, 'text-halo-width', 2);
|
||||
map.setPaintProperty(layer.id, 'text-color', '#222');
|
||||
}
|
||||
for (const layer of map.getStyle().layers || []) {
|
||||
if (layer.id === 'water' || layer.id.startsWith('water')) {
|
||||
map.setPaintProperty(layer.id, 'fill-color', '#6baed6');
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hide buildings to reduce visual clutter over hexagons
|
||||
try {
|
||||
map.setLayoutProperty('building', 'visibility', 'none');
|
||||
map.setLayoutProperty('building-top', 'visibility', 'none');
|
||||
map.setLayoutProperty('buildings', 'visibility', 'none');
|
||||
} catch {
|
||||
// layers may not exist in dark style
|
||||
// layer may not exist
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const mapStyle = theme === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT;
|
||||
const mapStyle = useMemo(() => getMapStyle(theme), [theme]);
|
||||
|
||||
const [popupInfo, setPopupInfo] = useState<{
|
||||
x: number;
|
||||
|
|
@ -244,9 +241,11 @@ export default memo(function Map({
|
|||
const onHexagonHoverRef = useRef(onHexagonHover);
|
||||
onHexagonHoverRef.current = onHexagonHover;
|
||||
const handleHexagonHover = useCallback((info: PickingInfo<HexagonData>) => {
|
||||
if (info.object && 'h3' in info.object) {
|
||||
onHexagonHoverRef.current(info.object.h3);
|
||||
if (info.object && 'h3' in info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setHoverPosition({ x: info.x, y: info.y });
|
||||
onHexagonHoverRef.current(info.object.h3, info.x, info.y);
|
||||
} else {
|
||||
setHoverPosition(null);
|
||||
onHexagonHoverRef.current(null);
|
||||
}
|
||||
}, []);
|
||||
|
|
@ -257,7 +256,54 @@ export default memo(function Map({
|
|||
handlePoiHoverRef.current(info);
|
||||
}, []);
|
||||
|
||||
// Compute count range for postcodes (similar to hexagons)
|
||||
const postcodeCountRange = useMemo(() => {
|
||||
if (postcodeData.length === 0) return { min: 0, max: 1 };
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const d of postcodeData) {
|
||||
const c = d.count as number;
|
||||
if (c < min) min = c;
|
||||
if (c > max) max = c;
|
||||
}
|
||||
if (min === max) return { min, max: min + 1 };
|
||||
return { min, max };
|
||||
}, [postcodeData]);
|
||||
|
||||
const postcodeCountRangeRef = useRef(postcodeCountRange);
|
||||
postcodeCountRangeRef.current = postcodeCountRange;
|
||||
|
||||
// Track selected/hovered postcode for styling
|
||||
const [selectedPostcode, setSelectedPostcode] = useState<string | null>(null);
|
||||
const [hoveredPostcode, setHoveredPostcode] = useState<string | null>(null);
|
||||
const selectedPostcodeRef = useRef(selectedPostcode);
|
||||
selectedPostcodeRef.current = selectedPostcode;
|
||||
const hoveredPostcodeRef = useRef(hoveredPostcode);
|
||||
hoveredPostcodeRef.current = hoveredPostcode;
|
||||
|
||||
const handlePostcodeClick = useCallback((info: PickingInfo<PostcodeData>) => {
|
||||
if (info.object && 'postcode' in info.object) {
|
||||
const pc = info.object.postcode;
|
||||
setSelectedPostcode((prev) => (prev === pc ? null : pc));
|
||||
// Also trigger the hexagon click handler with the postcode as identifier
|
||||
onHexagonClickRef.current(pc, true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePostcodeHoverCallback = useCallback((info: PickingInfo<PostcodeData>) => {
|
||||
if (info.object && 'postcode' in info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setHoveredPostcode(info.object.postcode);
|
||||
setHoverPosition({ x: info.x, y: info.y });
|
||||
onHexagonHoverRef.current(info.object.postcode, info.x, info.y);
|
||||
} else {
|
||||
setHoveredPostcode(null);
|
||||
setHoverPosition(null);
|
||||
onHexagonHoverRef.current(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}`;
|
||||
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}`;
|
||||
|
||||
const hexLayer = useMemo(
|
||||
() =>
|
||||
|
|
@ -321,11 +367,76 @@ export default memo(function Map({
|
|||
onClick: handleHexagonClick,
|
||||
onHover: handleHexagonHover,
|
||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||
beforeId: 'waterway_label',
|
||||
beforeId: 'water_waterway_label',
|
||||
}),
|
||||
[data, colorTrigger, handleHexagonClick, handleHexagonHover]
|
||||
);
|
||||
|
||||
const postcodeLayer = useMemo(
|
||||
() =>
|
||||
new PolygonLayer<PostcodeData>({
|
||||
id: 'postcode-polygons',
|
||||
data: postcodeData,
|
||||
getPolygon: (d) => d.vertices,
|
||||
getFillColor: (d) => {
|
||||
const vf = viewFeatureRef.current;
|
||||
const clr = colorRangeRef.current;
|
||||
const fr = filterRangeRef.current;
|
||||
const cfm = colorFeatureMetaRef.current;
|
||||
if (vf && clr && cfm) {
|
||||
const val = d[`min_${vf}`];
|
||||
if (val == null) return [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 [180, 180, 180, 60] as [number, number, number, number];
|
||||
}
|
||||
}
|
||||
const range = clr[1] - clr[0];
|
||||
if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number];
|
||||
const t = ((val as number) - clr[0]) / range;
|
||||
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
|
||||
return [...rgb, 200] as [number, number, number, number];
|
||||
}
|
||||
const cr = postcodeCountRangeRef.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))), 200] as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
},
|
||||
getLineColor: (d) => {
|
||||
if (d.postcode === selectedPostcodeRef.current)
|
||||
return [255, 255, 255, 255] as [number, number, number, number];
|
||||
if (d.postcode === hoveredPostcodeRef.current)
|
||||
return [29, 228, 195, 200] as [number, number, number, number];
|
||||
return [100, 100, 100, 150] as [number, number, number, number];
|
||||
},
|
||||
getLineWidth: (d) => {
|
||||
if (d.postcode === selectedPostcodeRef.current) return 3;
|
||||
if (d.postcode === hoveredPostcodeRef.current) return 2;
|
||||
return 1;
|
||||
},
|
||||
lineWidthUnits: 'pixels',
|
||||
updateTriggers: {
|
||||
getFillColor: [postcodeColorTrigger],
|
||||
getLineColor: [postcodeColorTrigger],
|
||||
getLineWidth: [postcodeColorTrigger],
|
||||
},
|
||||
extruded: false,
|
||||
pickable: true,
|
||||
onClick: handlePostcodeClick,
|
||||
onHover: handlePostcodeHoverCallback,
|
||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||
beforeId: 'water_waterway_label',
|
||||
}),
|
||||
[postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]
|
||||
);
|
||||
|
||||
const poiLayer = useMemo(
|
||||
() =>
|
||||
new IconLayer<POI>({
|
||||
|
|
@ -346,7 +457,43 @@ export default memo(function Map({
|
|||
[pois, stablePoiHover]
|
||||
);
|
||||
|
||||
const layers = useMemo(() => [hexLayer, poiLayer], [hexLayer, poiLayer]);
|
||||
// Check if the searched postcode has data (passes current filters)
|
||||
const searchedPostcodeHasData = useMemo(() => {
|
||||
if (!searchedPostcode) return false;
|
||||
return postcodeData.some((d) => d.postcode === searchedPostcode.postcode);
|
||||
}, [searchedPostcode, postcodeData]);
|
||||
|
||||
// Highlight layer for searched postcode
|
||||
const searchedPostcodeHighlightLayer = useMemo(() => {
|
||||
if (!searchedPostcode) return null;
|
||||
const hasData = searchedPostcodeHasData;
|
||||
// Use different layers for dashed vs solid lines
|
||||
return new PolygonLayer<{ vertices: [number, number][] }>({
|
||||
id: 'searched-postcode-highlight',
|
||||
data: [{ vertices: searchedPostcode.vertices }],
|
||||
getPolygon: (d) => d.vertices,
|
||||
// Transparent fill - just show outline
|
||||
getFillColor: hasData
|
||||
? [29, 228, 195, 40] // teal tint when has data
|
||||
: [255, 180, 0, 30], // orange tint when filtered out
|
||||
getLineColor: hasData
|
||||
? [29, 228, 195, 255] // solid teal when has data
|
||||
: [255, 180, 0, 200], // orange when filtered out (no matching properties)
|
||||
getLineWidth: hasData ? 4 : 3,
|
||||
lineWidthUnits: 'pixels',
|
||||
stroked: true,
|
||||
filled: true,
|
||||
pickable: false,
|
||||
});
|
||||
}, [searchedPostcode, searchedPostcodeHasData]);
|
||||
|
||||
const layers = useMemo(() => {
|
||||
const baseLayers = usePostcodeView ? [postcodeLayer, poiLayer] : [hexLayer, poiLayer];
|
||||
if (searchedPostcodeHighlightLayer) {
|
||||
return [...baseLayers, searchedPostcodeHighlightLayer];
|
||||
}
|
||||
return baseLayers;
|
||||
}, [usePostcodeView, hexLayer, postcodeLayer, poiLayer, searchedPostcodeHighlightLayer]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 h-full relative" ref={containerRef}>
|
||||
|
|
@ -362,8 +509,8 @@ export default memo(function Map({
|
|||
touchPitch={false}
|
||||
keyboard={true}
|
||||
pitchWithRotate={false}
|
||||
minZoom={5}
|
||||
maxBounds={[-12, 49, 4, 62]}
|
||||
minZoom={MAP_MIN_ZOOM}
|
||||
maxBounds={MAP_BOUNDS}
|
||||
>
|
||||
<DeckOverlay layers={layers} getTooltip={null} />
|
||||
</MapGL>
|
||||
|
|
@ -378,7 +525,7 @@ export default memo(function Map({
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
<PostcodeSearch onFlyTo={handleFlyTo} />
|
||||
<PostcodeSearch onFlyTo={handleFlyTo} onPostcodeSearched={onPostcodeSearched} />
|
||||
{viewSource === 'eye' && viewFeature && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-white dark:bg-warm-800 rounded-lg shadow-lg px-5 py-3">
|
||||
<span className="text-lg font-semibold text-navy-950 dark:text-warm-100">
|
||||
|
|
@ -434,6 +581,20 @@ export default memo(function Map({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && (
|
||||
<HoverCard
|
||||
x={hoverPosition.x}
|
||||
y={hoverPosition.y}
|
||||
id={hoveredHexagonId}
|
||||
isPostcode={usePostcodeView}
|
||||
data={
|
||||
usePostcodeView
|
||||
? postcodeData.find((d) => d.postcode === hoveredHexagonId) || null
|
||||
: data.find((d) => d.h3 === hoveredHexagonId) || null
|
||||
}
|
||||
filters={filters}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { useState, useRef, useCallback } from 'react';
|
|||
import type { POICategoryGroup } from '../types';
|
||||
import { useClickOutside } from '../hooks/useClickOutside';
|
||||
import InfoPopup from './InfoPopup';
|
||||
import { SearchInput } from './ui/SearchInput';
|
||||
import { SelectionButtons } from './ui/SelectionButtons';
|
||||
import { InfoIcon, ChevronIcon } from './ui/Icons';
|
||||
import { IconButton } from './ui/IconButton';
|
||||
|
||||
interface POIPaneProps {
|
||||
groups: POICategoryGroup[];
|
||||
|
|
@ -93,22 +97,9 @@ export default function POIPane({
|
|||
<div className="w-72 p-4 bg-white dark:bg-navy-950 shadow-lg space-y-4 overflow-y-auto max-h-screen">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2>
|
||||
<button
|
||||
onClick={() => setShowInfo(true)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
|
||||
title="Data source info"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
|
||||
</svg>
|
||||
</button>
|
||||
<IconButton onClick={() => setShowInfo(true)} title="Data source info">
|
||||
<InfoIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{showInfo && (
|
||||
|
|
@ -148,40 +139,22 @@ export default function POIPane({
|
|||
? 'All categories'
|
||||
: `${selectedCount} selected`}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 ml-2 flex-shrink-0 transition-transform ${dropdownOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<ChevronIcon
|
||||
direction={dropdownOpen ? 'up' : 'down'}
|
||||
className="w-4 h-4 ml-2 flex-shrink-0"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div className="border border-warm-300 dark:border-navy-700 rounded shadow-lg bg-white dark:bg-navy-800">
|
||||
<div className="flex gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<span className="text-xs text-warm-300 dark:text-warm-600">|</span>
|
||||
<button
|
||||
onClick={selectNone}
|
||||
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
None
|
||||
</button>
|
||||
<div className="px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<SelectionButtons onSelectAll={selectAll} onSelectNone={selectNone} className="text-xs" />
|
||||
</div>
|
||||
<div className="px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search categories..."
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm border border-warm-300 dark:border-navy-700 rounded bg-white dark:bg-navy-950 dark:text-warm-200 dark:placeholder-warm-500"
|
||||
onChange={setSearchTerm}
|
||||
placeholder="Search categories..."
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto py-1">
|
||||
|
|
@ -198,21 +171,9 @@ export default function POIPane({
|
|||
<div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 dark:bg-navy-950 border-y border-warm-100 dark:border-navy-700">
|
||||
<button
|
||||
onClick={() => toggleCollapse(group.name)}
|
||||
className="p-0.5 text-warm-400 hover:text-warm-600"
|
||||
className={`p-0.5 text-warm-400 hover:text-warm-600 transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<ChevronIcon direction="right" className="w-3 h-3" />
|
||||
</button>
|
||||
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface SearchedPostcode {
|
||||
postcode: string;
|
||||
vertices: [number, number][];
|
||||
}
|
||||
|
||||
export default function PostcodeSearch({
|
||||
onFlyTo,
|
||||
onPostcodeSearched,
|
||||
}: {
|
||||
onFlyTo: (lat: number, lng: number, zoom: number) => void;
|
||||
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -18,27 +25,27 @@ export default function PostcodeSearch({
|
|||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://api.postcodes.io/postcodes/${encodeURIComponent(trimmed)}`
|
||||
);
|
||||
const res = await fetch(`/api/postcode/${encodeURIComponent(trimmed)}`);
|
||||
if (!res.ok) {
|
||||
setError('Postcode not found');
|
||||
return;
|
||||
}
|
||||
const json = await res.json();
|
||||
if (json.status === 200 && json.result) {
|
||||
onFlyTo(json.result.latitude, json.result.longitude, 14);
|
||||
setQuery('');
|
||||
} else {
|
||||
setError('Postcode not found');
|
||||
}
|
||||
const json: {
|
||||
postcode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
vertices: [number, number][];
|
||||
} = await res.json();
|
||||
onFlyTo(json.latitude, json.longitude, 16);
|
||||
onPostcodeSearched?.({ postcode: json.postcode, vertices: json.vertices });
|
||||
setQuery('');
|
||||
} catch {
|
||||
setError('Lookup failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[query, onFlyTo]
|
||||
[query, onFlyTo, onPostcodeSearched]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import { Property } from '../types';
|
||||
import { formatDuration, formatAge } from '../lib/format';
|
||||
import { formatDuration, formatAge, formatNumber } from '../lib/format';
|
||||
import { getNum } from '../lib/property-fields';
|
||||
import InfoPopup from './InfoPopup';
|
||||
import { SearchInput } from './ui/SearchInput';
|
||||
import { PaneHeader } from './ui/PaneHeader';
|
||||
import { PaneEmptyState } from './ui/EmptyState';
|
||||
|
||||
interface PropertiesPaneProps {
|
||||
properties: Property[];
|
||||
|
|
@ -11,9 +15,6 @@ interface PropertiesPaneProps {
|
|||
onLoadMore: () => void;
|
||||
onClose: () => void;
|
||||
onNavigateToSource?: (slug: string) => void;
|
||||
isHoveredPreview?: boolean;
|
||||
hoverMode?: boolean;
|
||||
onHoverModeChange?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
type SortBy = 'price' | 'size' | 'energy';
|
||||
|
|
@ -26,9 +27,6 @@ export function PropertiesPane({
|
|||
onLoadMore,
|
||||
onClose,
|
||||
onNavigateToSource,
|
||||
isHoveredPreview,
|
||||
hoverMode,
|
||||
onHoverModeChange,
|
||||
}: PropertiesPaneProps) {
|
||||
const [sortBy, setSortBy] = useState<SortBy>('price');
|
||||
const [search, setSearch] = useState('');
|
||||
|
|
@ -56,130 +54,52 @@ export function PropertiesPane({
|
|||
}, [properties, sortBy, search]);
|
||||
|
||||
if (!hexagonId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-warm-500 dark:text-warm-400">
|
||||
Click a hexagon to view properties
|
||||
</div>
|
||||
);
|
||||
return <PaneEmptyState message="Click a hexagon to view properties" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-4 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold dark:text-warm-100">Properties</h2>
|
||||
{isHoveredPreview && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
|
||||
Preview
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowInfo(true)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
|
||||
title="Data source info"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{onHoverModeChange && (
|
||||
<button
|
||||
onClick={() => onHoverModeChange(!hoverMode)}
|
||||
className={`p-1 rounded ${
|
||||
hoverMode
|
||||
? 'text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
}`}
|
||||
title={
|
||||
hoverMode
|
||||
? 'Live preview on (click to lock)'
|
||||
: 'Live preview off (click to enable)'
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-1"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-warm-600 dark:text-warm-400">
|
||||
{search.trim()
|
||||
<PaneHeader
|
||||
title="Properties"
|
||||
subtitle={
|
||||
search.trim()
|
||||
? `${filteredAndSorted.length} match${filteredAndSorted.length !== 1 ? 'es' : ''} in ${properties.length} loaded`
|
||||
: `Showing ${properties.length} of ${total} properties`}
|
||||
</p>
|
||||
{showInfo && (
|
||||
<InfoPopup
|
||||
title="Property Data"
|
||||
onClose={() => setShowInfo(false)}
|
||||
sourceLink={
|
||||
onNavigateToSource
|
||||
? {
|
||||
label: 'View data source',
|
||||
onClick: () => {
|
||||
onNavigateToSource('epc');
|
||||
setShowInfo(false);
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<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.
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
</div>
|
||||
: `Showing ${properties.length} of ${total} properties`
|
||||
}
|
||||
onClose={onClose}
|
||||
onInfoClick={() => setShowInfo(true)}
|
||||
/>
|
||||
{showInfo && (
|
||||
<InfoPopup
|
||||
title="Property Data"
|
||||
onClose={() => setShowInfo(false)}
|
||||
sourceLink={
|
||||
onNavigateToSource
|
||||
? {
|
||||
label: 'View data source',
|
||||
onClick: () => {
|
||||
onNavigateToSource('epc');
|
||||
setShowInfo(false);
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<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.
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
||||
<div className="p-2 border-b border-warm-200 dark:border-navy-700 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by address or postcode..."
|
||||
className="w-full p-2 border border-warm-300 dark:border-navy-700 rounded text-sm bg-white dark:bg-navy-800 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500"
|
||||
className="p-2"
|
||||
/>
|
||||
<select
|
||||
value={sortBy}
|
||||
|
|
@ -216,20 +136,7 @@ export function PropertiesPane({
|
|||
);
|
||||
}
|
||||
|
||||
function getNum(property: Property, ...keys: string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const v = property[key];
|
||||
if (v !== undefined && v !== null && typeof v === 'number') return v;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function PropertyCard({ property }: { property: Property }) {
|
||||
const fmt = (value: number | undefined, decimals = 0): string => {
|
||||
if (value === undefined) return '';
|
||||
return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString();
|
||||
};
|
||||
|
||||
const price = getNum(property, 'Last known price', 'latest_price');
|
||||
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
|
||||
const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area');
|
||||
|
|
@ -251,11 +158,11 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
|
||||
{price !== undefined && (
|
||||
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
|
||||
£{fmt(price)}
|
||||
£{formatNumber(price)}
|
||||
{pricePerSqm !== undefined && (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{' '}
|
||||
(£{fmt(pricePerSqm)}/m²)
|
||||
(£{formatNumber(pricePerSqm)}/m²)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -281,12 +188,12 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
)}
|
||||
{floorArea !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Floor area:</span> {fmt(floorArea)}m²
|
||||
<span className="text-warm-500 dark:text-warm-400">Floor area:</span> {formatNumber(floorArea)}m²
|
||||
</div>
|
||||
)}
|
||||
{rooms !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Rooms:</span> {fmt(rooms)}
|
||||
<span className="text-warm-500 dark:text-warm-400">Rooms:</span> {formatNumber(rooms)}
|
||||
</div>
|
||||
)}
|
||||
{age !== undefined && (
|
||||
|
|
@ -310,12 +217,12 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
{councilTax !== undefined ? (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Council tax:</span> £
|
||||
{fmt(councilTax)}/yr
|
||||
{formatNumber(councilTax)}/yr
|
||||
</div>
|
||||
) : councilTaxD !== undefined ? (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Council tax (D):</span> £
|
||||
{fmt(councilTaxD)}/yr
|
||||
{formatNumber(councilTaxD)}/yr
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
89
frontend/src/components/ui/CheckboxList.tsx
Normal file
89
frontend/src/components/ui/CheckboxList.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
interface CheckboxListProps {
|
||||
items: string[];
|
||||
selected: string[] | Set<string>;
|
||||
onChange: (selected: string[]) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CheckboxList({ items, selected, onChange, className = '' }: CheckboxListProps) {
|
||||
const selectedSet = selected instanceof Set ? selected : new Set(selected);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(item: string) => {
|
||||
const newSelected = selectedSet.has(item)
|
||||
? [...selectedSet].filter((v) => v !== item)
|
||||
: [...selectedSet, item];
|
||||
onChange(newSelected);
|
||||
},
|
||||
[selectedSet, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`space-y-0.5 ${className}`}>
|
||||
{items.map((item) => (
|
||||
<label
|
||||
key={item}
|
||||
className="flex items-center gap-1.5 text-sm cursor-pointer dark:text-warm-300"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSet.has(item)}
|
||||
onChange={() => handleToggle(item)}
|
||||
className="rounded accent-teal-600"
|
||||
/>
|
||||
{item}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CheckboxListWithSetProps {
|
||||
items: string[];
|
||||
selected: Set<string>;
|
||||
onChange: (selected: Set<string>) => void;
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
}
|
||||
|
||||
export function CheckboxListWithSet({
|
||||
items,
|
||||
selected,
|
||||
onChange,
|
||||
className = '',
|
||||
itemClassName = '',
|
||||
}: CheckboxListWithSetProps) {
|
||||
const handleToggle = useCallback(
|
||||
(item: string) => {
|
||||
const newSet = new Set(selected);
|
||||
if (newSet.has(item)) {
|
||||
newSet.delete(item);
|
||||
} else {
|
||||
newSet.add(item);
|
||||
}
|
||||
onChange(newSet);
|
||||
},
|
||||
[selected, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{items.map((item) => (
|
||||
<label
|
||||
key={item}
|
||||
className={`flex items-center gap-2 cursor-pointer dark:text-warm-300 ${itemClassName}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(item)}
|
||||
onChange={() => handleToggle(item)}
|
||||
className="rounded accent-teal-600"
|
||||
/>
|
||||
<span className="text-sm flex-1">{item}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
frontend/src/components/ui/EmptyState.tsx
Normal file
31
frontend/src/components/ui/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { ReactNode } from 'react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, description, className = '' }: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center py-8 text-center ${className}`}
|
||||
>
|
||||
{icon && <div className="mb-2">{icon}</div>}
|
||||
<span className="text-sm font-medium text-warm-400 dark:text-warm-500">{title}</span>
|
||||
{description && (
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 mt-1">{description}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Centered message variant for panes
|
||||
export function PaneEmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-warm-500 dark:text-warm-400 px-4 text-center text-sm">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/ui/IconButton.tsx
Normal file
22
frontend/src/components/ui/IconButton.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { ReactNode, MouseEvent } from 'react';
|
||||
|
||||
interface IconButtonProps {
|
||||
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function IconButton({ onClick, title, children, active, className }: IconButtonProps) {
|
||||
const baseClasses = 'p-0.5 rounded';
|
||||
const colorClasses = active
|
||||
? 'text-teal-600 dark:text-teal-400'
|
||||
: 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300';
|
||||
|
||||
return (
|
||||
<button onClick={onClick} title={title} className={`${baseClasses} ${colorClasses} ${className || ''}`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
92
frontend/src/components/ui/Icons.tsx
Normal file
92
frontend/src/components/ui/Icons.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// Shared icon components with consistent sizing and styling
|
||||
|
||||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CloseIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EyeIcon({ filled, className = 'w-3.5 h-3.5' }: IconProps & { filled: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill={filled ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlusIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m-7-7h14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronIcon({
|
||||
direction,
|
||||
className = 'w-4 h-4',
|
||||
}: IconProps & { direction: 'left' | 'right' | 'up' | 'down' }) {
|
||||
const paths: Record<string, string> = {
|
||||
left: 'M15 19l-7-7 7-7',
|
||||
right: 'M9 5l7 7-7 7',
|
||||
up: 'M18 15l-6-6-6 6',
|
||||
down: 'M6 9l6 6 6-6',
|
||||
};
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={paths[direction]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterIcon({ className = 'w-8 h-8' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LightbulbIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/ui/PaneHeader.tsx
Normal file
35
frontend/src/components/ui/PaneHeader.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import { CloseIcon, InfoIcon } from './Icons';
|
||||
import { IconButton } from './IconButton';
|
||||
|
||||
interface PaneHeaderProps {
|
||||
title: string;
|
||||
subtitle?: ReactNode;
|
||||
onClose?: () => void;
|
||||
onInfoClick?: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function PaneHeader({ title, subtitle, onClose, onInfoClick, children }: PaneHeaderProps) {
|
||||
return (
|
||||
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold dark:text-warm-100">{title}</h2>
|
||||
{onInfoClick && (
|
||||
<IconButton onClick={onInfoClick} title="Data source info">
|
||||
<InfoIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
{onClose && (
|
||||
<IconButton onClick={onClose} title="Close">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && <div className="text-sm text-warm-600 dark:text-warm-400 mt-1">{subtitle}</div>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/ui/SearchInput.tsx
Normal file
23
frontend/src/components/ui/SearchInput.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
interface SearchInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search...',
|
||||
className = '',
|
||||
}: SearchInputProps) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={`w-full px-2 py-1 text-sm border rounded bg-white dark:bg-navy-800 dark:text-warm-200 border-warm-200 dark:border-navy-700 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-1 focus:ring-teal-400 ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/ui/SelectionButtons.tsx
Normal file
24
frontend/src/components/ui/SelectionButtons.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
interface SelectionButtonsProps {
|
||||
onSelectAll: () => void;
|
||||
onSelectNone: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SelectionButtons({ onSelectAll, onSelectNone, className = '' }: SelectionButtonsProps) {
|
||||
return (
|
||||
<div className={`flex gap-2 text-sm ${className}`}>
|
||||
<button
|
||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
|
||||
onClick={onSelectAll}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
|
||||
onClick={onSelectNone}
|
||||
>
|
||||
None
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/ui/TabButton.tsx
Normal file
22
frontend/src/components/ui/TabButton.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
interface TabButtonProps {
|
||||
label: string;
|
||||
count?: number;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function TabButton({ label, count, isActive, onClick }: TabButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`flex-1 p-3 ${
|
||||
isActive
|
||||
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
|
||||
: 'text-warm-600 dark:text-warm-400'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
{count !== undefined && count > 0 && ` (${count})`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
81
frontend/src/hooks/useDebouncedFetch.ts
Normal file
81
frontend/src/hooks/useDebouncedFetch.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import { isAbortError, logNonAbortError } from '../lib/api';
|
||||
|
||||
const DEFAULT_DEBOUNCE_MS = 150;
|
||||
|
||||
interface UseDebouncedFetchOptions {
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
interface UseDebouncedFetchResult {
|
||||
fetch: (url: string, onSuccess: (data: unknown) => void) => void;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export function useDebouncedFetch(
|
||||
options: UseDebouncedFetchOptions = {}
|
||||
): UseDebouncedFetchResult {
|
||||
const { debounceMs = DEFAULT_DEBOUNCE_MS } = options;
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
}
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchFn = useCallback(
|
||||
(url: string, onSuccess: (data: unknown) => void) => {
|
||||
// Clear any pending debounce
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
// Abort any in-flight request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { signal: abortControllerRef.current.signal });
|
||||
const json = await res.json();
|
||||
onSuccess(json);
|
||||
} catch (err) {
|
||||
logNonAbortError(`Failed to fetch ${url}`, err);
|
||||
}
|
||||
}, debounceMs);
|
||||
},
|
||||
[debounceMs]
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancel();
|
||||
};
|
||||
}, [cancel]);
|
||||
|
||||
return { fetch: fetchFn, cancel };
|
||||
}
|
||||
|
||||
// Typed version with generic
|
||||
export function useTypedDebouncedFetch<T>(
|
||||
options: UseDebouncedFetchOptions = {}
|
||||
): {
|
||||
fetch: (url: string, onSuccess: (data: T) => void) => void;
|
||||
cancel: () => void;
|
||||
} {
|
||||
const result = useDebouncedFetch(options);
|
||||
return {
|
||||
fetch: result.fetch as (url: string, onSuccess: (data: T) => void) => void,
|
||||
cancel: result.cancel,
|
||||
};
|
||||
}
|
||||
27
frontend/src/hooks/useInfoPopup.ts
Normal file
27
frontend/src/hooks/useInfoPopup.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface UseInfoPopupResult<T> {
|
||||
item: T | null;
|
||||
isOpen: boolean;
|
||||
open: (item: T) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export function useInfoPopup<T>(): UseInfoPopupResult<T> {
|
||||
const [item, setItem] = useState<T | null>(null);
|
||||
|
||||
const open = useCallback((newItem: T) => {
|
||||
setItem(newItem);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setItem(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
item,
|
||||
isOpen: item !== null,
|
||||
open,
|
||||
close,
|
||||
};
|
||||
}
|
||||
55
frontend/src/hooks/useSearch.ts
Normal file
55
frontend/src/hooks/useSearch.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
|
||||
interface UseSearchResult<T> {
|
||||
query: string;
|
||||
setQuery: (query: string) => void;
|
||||
filtered: T[];
|
||||
}
|
||||
|
||||
export function useSearch<T>(
|
||||
items: T[],
|
||||
getSearchableText: (item: T) => string
|
||||
): UseSearchResult<T> {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return items;
|
||||
const lower = query.toLowerCase();
|
||||
return items.filter((item) => getSearchableText(item).toLowerCase().includes(lower));
|
||||
}, [items, query, getSearchableText]);
|
||||
|
||||
return { query, setQuery, filtered };
|
||||
}
|
||||
|
||||
// Variant for searching groups with nested items
|
||||
export function useGroupSearch<G extends { name: string }, T>(
|
||||
groups: G[],
|
||||
getItems: (group: G) => T[],
|
||||
getSearchableText: (item: T) => string,
|
||||
createGroup: (group: G, filteredItems: T[]) => G
|
||||
): { query: string; setQuery: (query: string) => void; filtered: G[] } {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return groups;
|
||||
const lower = query.toLowerCase();
|
||||
|
||||
return groups
|
||||
.map((group) => {
|
||||
// If group name matches, return whole group
|
||||
if (group.name.toLowerCase().includes(lower)) return group;
|
||||
|
||||
// Otherwise filter items
|
||||
const items = getItems(group);
|
||||
const matchingItems = items.filter((item) =>
|
||||
getSearchableText(item).toLowerCase().includes(lower)
|
||||
);
|
||||
|
||||
if (matchingItems.length === 0) return null;
|
||||
return createGroup(group, matchingItems);
|
||||
})
|
||||
.filter((g): g is G => g !== null);
|
||||
}, [groups, query, getItems, getSearchableText, createGroup]);
|
||||
|
||||
return { query, setQuery, filtered };
|
||||
}
|
||||
|
|
@ -50,3 +50,9 @@ h3 {
|
|||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Vertical text for collapsed pane labels */
|
||||
.writing-mode-vertical {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,25 @@ import type { FeatureMeta, FeatureFilters } from '../types';
|
|||
const INITIAL_RETRY_MS = 1000;
|
||||
const MAX_RETRY_MS = 10000;
|
||||
|
||||
// Error handling utilities
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError';
|
||||
}
|
||||
|
||||
export function logNonAbortError(label: string, error: unknown): void {
|
||||
if (!isAbortError(error)) {
|
||||
console.error(`${label}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// API URL helper
|
||||
export function apiUrl(endpoint: string, params?: URLSearchParams): string {
|
||||
const base = getApiBaseUrl();
|
||||
const path = endpoint.startsWith('/') ? endpoint : `/api/${endpoint}`;
|
||||
const query = params?.toString();
|
||||
return query ? `${base}${path}?${query}` : `${base}${path}`;
|
||||
}
|
||||
|
||||
export async function fetchWithRetry<T>(
|
||||
url: string,
|
||||
onSuccess: (data: T) => void,
|
||||
|
|
|
|||
76
frontend/src/lib/consts.ts
Normal file
76
frontend/src/lib/consts.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import type { ViewState } from '../types';
|
||||
|
||||
// =============================================================================
|
||||
// Map Bounds & Zoom
|
||||
// =============================================================================
|
||||
|
||||
/** Geographic bounds constraining map panning [west, south, east, north] */
|
||||
export const MAP_BOUNDS: [number, number, number, number] = [-12, 49, 4, 62];
|
||||
|
||||
/** Minimum zoom level (can't zoom out further) */
|
||||
export const MAP_MIN_ZOOM = 5;
|
||||
|
||||
/** Maximum zoom level for tile fetching (map extrapolates beyond this) */
|
||||
export const TILE_MAX_ZOOM = 15;
|
||||
|
||||
/** Initial map view state */
|
||||
export const INITIAL_VIEW_STATE: ViewState = {
|
||||
longitude: -1.5,
|
||||
latitude: 53.5,
|
||||
zoom: 6,
|
||||
pitch: 0,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Zoom Thresholds
|
||||
// =============================================================================
|
||||
|
||||
/** Zoom level at which we switch from H3 hexagons to postcode polygons */
|
||||
export const POSTCODE_ZOOM_THRESHOLD = 15;
|
||||
|
||||
/**
|
||||
* Zoom to H3 resolution mapping thresholds.
|
||||
* Returns the H3 resolution to use for a given zoom level.
|
||||
*/
|
||||
export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
|
||||
{ maxZoom: 7.5, resolution: 5 },
|
||||
{ maxZoom: 9.5, resolution: 6 },
|
||||
{ maxZoom: 10.5, resolution: 8 },
|
||||
{ maxZoom: 12, resolution: 9 },
|
||||
{ maxZoom: Infinity, resolution: 10 },
|
||||
] as const;
|
||||
|
||||
// =============================================================================
|
||||
// Color Gradients
|
||||
// =============================================================================
|
||||
|
||||
/** Feature value gradient (green → yellow → red → purple) */
|
||||
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [46, 204, 113] },
|
||||
{ t: 0.33, color: [241, 196, 15] },
|
||||
{ t: 0.66, color: [231, 76, 60] },
|
||||
{ t: 1, color: [142, 68, 173] },
|
||||
];
|
||||
|
||||
/** Property density gradient (teal → blue → purple) */
|
||||
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [130, 234, 220] },
|
||||
{ t: 0.5, color: [20, 140, 180] },
|
||||
{ t: 1, color: [88, 28, 140] },
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// External URLs
|
||||
// =============================================================================
|
||||
|
||||
/** Protomaps font glyphs URL */
|
||||
export const GLYPHS_URL = 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf';
|
||||
|
||||
/** Protomaps sprite base URL */
|
||||
export const SPRITE_URL_BASE = 'https://protomaps.github.io/basemaps-assets/sprites/v4';
|
||||
|
||||
/** Twemoji CDN base URL */
|
||||
export const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/';
|
||||
|
||||
/** OpenStreetMap attribution HTML */
|
||||
export const OSM_ATTRIBUTION = '© <a href="https://openstreetmap.org">OpenStreetMap</a>';
|
||||
36
frontend/src/lib/features.ts
Normal file
36
frontend/src/lib/features.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { FeatureMeta } from '../types';
|
||||
|
||||
export interface FeatureGroup {
|
||||
name: string;
|
||||
features: FeatureMeta[];
|
||||
}
|
||||
|
||||
export function groupFeaturesByCategory(features: FeatureMeta[]): FeatureGroup[] {
|
||||
const groups: FeatureGroup[] = [];
|
||||
const seen = new Map<string, FeatureMeta[]>();
|
||||
|
||||
for (const feature of features) {
|
||||
const groupName = feature.group || 'Other';
|
||||
let arr = seen.get(groupName);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
seen.set(groupName, arr);
|
||||
groups.push({ name: groupName, features: arr });
|
||||
}
|
||||
arr.push(feature);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
// Feature lookup utilities
|
||||
export function getFeatureByName(
|
||||
name: string,
|
||||
features: FeatureMeta[]
|
||||
): FeatureMeta | undefined {
|
||||
return features.find((f) => f.name === name);
|
||||
}
|
||||
|
||||
export function createFeatureMap(features: FeatureMeta[]): Map<string, FeatureMeta> {
|
||||
return new Map(features.map((f) => [f.name, f]));
|
||||
}
|
||||
|
|
@ -22,3 +22,27 @@ export function formatAge(value: number, approximate = true): string {
|
|||
if (value >= 1000) return approximate ? `~${Math.round(value)}` : `${Math.round(value)}`;
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
|
||||
// Format number with optional decimals, used in PropertyCard
|
||||
export function formatNumber(value: number | undefined, decimals = 0): string {
|
||||
if (value === undefined) return '';
|
||||
return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString();
|
||||
}
|
||||
|
||||
// Calculate weighted mean from histogram
|
||||
export function calculateHistogramMean(histogram: {
|
||||
min: number;
|
||||
bin_width: number;
|
||||
counts: number[];
|
||||
}): number | undefined {
|
||||
if (!histogram.counts.length) return undefined;
|
||||
const totalCount = histogram.counts.reduce((a, b) => a + b, 0);
|
||||
if (totalCount === 0) return undefined;
|
||||
|
||||
let weightedSum = 0;
|
||||
for (let i = 0; i < histogram.counts.length; i++) {
|
||||
const binCenter = histogram.min + (i + 0.5) * histogram.bin_width;
|
||||
weightedSum += binCenter * histogram.counts[i];
|
||||
}
|
||||
return weightedSum / totalCount;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,47 @@
|
|||
import type { ViewState, Bounds } from '../types';
|
||||
import type { StyleSpecification } from 'maplibre-gl';
|
||||
import { layers, namedFlavor } from '@protomaps/basemaps';
|
||||
import {
|
||||
GLYPHS_URL,
|
||||
SPRITE_URL_BASE,
|
||||
TILE_MAX_ZOOM,
|
||||
OSM_ATTRIBUTION,
|
||||
FEATURE_GRADIENT,
|
||||
DENSITY_GRADIENT,
|
||||
ZOOM_TO_RESOLUTION_THRESHOLDS,
|
||||
TWEMOJI_BASE,
|
||||
} from './consts';
|
||||
|
||||
// Self-hosted tile styles from server
|
||||
export const MAP_STYLE_LIGHT = '/api/tiles/style.json?theme=light';
|
||||
export const MAP_STYLE_DARK = '/api/tiles/style.json?theme=dark';
|
||||
// Re-export constants for backwards compatibility
|
||||
export { FEATURE_GRADIENT as GRADIENT, DENSITY_GRADIENT, POSTCODE_ZOOM_THRESHOLD } from './consts';
|
||||
|
||||
export const GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [46, 204, 113] },
|
||||
{ t: 0.33, color: [241, 196, 15] },
|
||||
{ t: 0.66, color: [231, 76, 60] },
|
||||
{ t: 1, color: [142, 68, 173] },
|
||||
];
|
||||
|
||||
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [130, 234, 220] },
|
||||
{ t: 0.5, color: [20, 140, 180] },
|
||||
{ t: 1, color: [88, 28, 140] },
|
||||
];
|
||||
export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
||||
const flavor = namedFlavor(theme);
|
||||
// Use absolute URL for tiles - required by MapLibre
|
||||
const tileUrl = `${window.location.origin}/api/tiles/{z}/{x}/{y}`;
|
||||
return {
|
||||
version: 8,
|
||||
glyphs: GLYPHS_URL,
|
||||
sprite: `${SPRITE_URL_BASE}/${theme}`,
|
||||
sources: {
|
||||
protomaps: {
|
||||
type: 'vector',
|
||||
tiles: [tileUrl],
|
||||
maxzoom: TILE_MAX_ZOOM,
|
||||
attribution: OSM_ATTRIBUTION,
|
||||
},
|
||||
},
|
||||
layers: layers('protomaps', flavor, { lang: 'en' }),
|
||||
} as StyleSpecification;
|
||||
}
|
||||
|
||||
export function normalizedToColor(t: number): [number, number, number] {
|
||||
if (t <= 0) return GRADIENT[0].color;
|
||||
if (t >= 1) return GRADIENT[GRADIENT.length - 1].color;
|
||||
if (t <= 0) return FEATURE_GRADIENT[0].color;
|
||||
if (t >= 1) return FEATURE_GRADIENT[FEATURE_GRADIENT.length - 1].color;
|
||||
|
||||
for (let i = 0; i < GRADIENT.length - 1; i++) {
|
||||
const lo = GRADIENT[i];
|
||||
const hi = GRADIENT[i + 1];
|
||||
for (let i = 0; i < FEATURE_GRADIENT.length - 1; i++) {
|
||||
const lo = FEATURE_GRADIENT[i];
|
||||
const hi = FEATURE_GRADIENT[i + 1];
|
||||
if (t >= lo.t && t <= hi.t) {
|
||||
const frac = (t - lo.t) / (hi.t - lo.t);
|
||||
return [
|
||||
|
|
@ -33,7 +51,7 @@ export function normalizedToColor(t: number): [number, number, number] {
|
|||
];
|
||||
}
|
||||
}
|
||||
return GRADIENT[GRADIENT.length - 1].color;
|
||||
return FEATURE_GRADIENT[FEATURE_GRADIENT.length - 1].color;
|
||||
}
|
||||
|
||||
export function countToColor(t: number): [number, number, number] {
|
||||
|
|
@ -55,17 +73,11 @@ export function countToColor(t: number): [number, number, number] {
|
|||
return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color;
|
||||
}
|
||||
|
||||
/** Zoom threshold at which we switch from hexagons to postcode polygons */
|
||||
export const POSTCODE_ZOOM_THRESHOLD = 15;
|
||||
|
||||
export function zoomToResolution(zoom: number): number {
|
||||
if (zoom < 6) return 5;
|
||||
if (zoom < 7) return 6;
|
||||
if (zoom < 9.5) return 8;
|
||||
if (zoom < 11) return 9;
|
||||
if (zoom < 13) return 10;
|
||||
if (zoom < 15) return 11;
|
||||
return 12;
|
||||
for (const { maxZoom, resolution } of ZOOM_TO_RESOLUTION_THRESHOLDS) {
|
||||
if (zoom < maxZoom) return resolution;
|
||||
}
|
||||
return ZOOM_TO_RESOLUTION_THRESHOLDS[ZOOM_TO_RESOLUTION_THRESHOLDS.length - 1].resolution;
|
||||
}
|
||||
|
||||
export function getBoundsFromViewState(
|
||||
|
|
@ -103,8 +115,6 @@ export function getBoundsFromViewState(
|
|||
return { south, west, north, east };
|
||||
}
|
||||
|
||||
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/';
|
||||
|
||||
export function emojiToTwemojiUrl(emoji: string): string {
|
||||
const codePoint = emoji.codePointAt(0);
|
||||
if (!codePoint) return `${TWEMOJI_BASE}1f4cd.png`;
|
||||
|
|
|
|||
38
frontend/src/lib/property-fields.ts
Normal file
38
frontend/src/lib/property-fields.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import type { Property } from '../types';
|
||||
|
||||
// Field aliases: maps human-readable names to snake_case names
|
||||
// The server may return either depending on source
|
||||
const FIELD_ALIASES: Record<string, string[]> = {
|
||||
price: ['Last known price', 'latest_price'],
|
||||
pricePerSqm: ['Price per sqm', 'price_per_sqm'],
|
||||
floorArea: ['Total floor area (sqm)', 'total_floor_area'],
|
||||
rooms: ['Rooms (including bedrooms & bathrooms)', 'number_habitable_rooms'],
|
||||
constructionAge: ['Approximate construction age', 'construction_age_band'],
|
||||
councilTax: ['Council tax (£/yr)'],
|
||||
councilTaxD: ['Council tax Band D (£/yr)'],
|
||||
};
|
||||
|
||||
export function getPropertyNumber(
|
||||
property: Property,
|
||||
field: keyof typeof FIELD_ALIASES
|
||||
): number | undefined {
|
||||
const keys = FIELD_ALIASES[field];
|
||||
if (!keys) return undefined;
|
||||
|
||||
for (const key of keys) {
|
||||
const v = property[key];
|
||||
if (v !== undefined && v !== null && typeof v === 'number') {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Generic getter for any field names (for dynamic lookups)
|
||||
export function getNum(property: Property, ...keys: string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const v = property[key];
|
||||
if (v !== undefined && v !== null && typeof v === 'number') return v;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -29,6 +29,13 @@ export interface HexagonData {
|
|||
[key: string]: string | number | null;
|
||||
}
|
||||
|
||||
export interface PostcodeData {
|
||||
postcode: string;
|
||||
vertices: [number, number][];
|
||||
count: number;
|
||||
[key: string]: string | number | [number, number][] | null;
|
||||
}
|
||||
|
||||
export interface Bounds {
|
||||
south: number;
|
||||
west: number;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue