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/layers": "^9.0.0",
|
||||||
"@deck.gl/mapbox": "^9.2.6",
|
"@deck.gl/mapbox": "^9.2.6",
|
||||||
"@deck.gl/react": "^9.0.0",
|
"@deck.gl/react": "^9.0.0",
|
||||||
|
"@protomaps/basemaps": "^5.7.0",
|
||||||
"@radix-ui/react-select": "^2.0.0",
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
"@radix-ui/react-slider": "^1.1.0",
|
"@radix-ui/react-slider": "^1.1.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
|
|
@ -1714,6 +1715,14 @@
|
||||||
"integrity": "sha512-EI413MkWKBDVNIfLdqbeNSJTs7ToBz/KVGkwi3D+dQrSIkRI2IYbWGAU3xX+D6+CI4ls8ehxMhNpUVMaZggDvQ==",
|
"integrity": "sha512-EI413MkWKBDVNIfLdqbeNSJTs7ToBz/KVGkwi3D+dQrSIkRI2IYbWGAU3xX+D6+CI4ls8ehxMhNpUVMaZggDvQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@puppeteer/browsers": {
|
||||||
"version": "2.11.2",
|
"version": "2.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.11.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"name": "property-map-frontend",
|
"name": "property-map-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "webpack serve --mode development --port 3030",
|
"dev": "webpack serve --mode development --port 3000",
|
||||||
"build": "webpack --mode production && node scripts/prerender.mjs",
|
"build": "webpack --mode production && node scripts/prerender.mjs",
|
||||||
"build:no-prerender": "webpack --mode production",
|
"build:no-prerender": "webpack --mode production",
|
||||||
"prerender": "node scripts/prerender.mjs",
|
"prerender": "node scripts/prerender.mjs",
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"@deck.gl/layers": "^9.0.0",
|
"@deck.gl/layers": "^9.0.0",
|
||||||
"@deck.gl/mapbox": "^9.2.6",
|
"@deck.gl/mapbox": "^9.2.6",
|
||||||
"@deck.gl/react": "^9.0.0",
|
"@deck.gl/react": "^9.0.0",
|
||||||
|
"@protomaps/basemaps": "^5.7.0",
|
||||||
"@radix-ui/react-select": "^2.0.0",
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
"@radix-ui/react-slider": "^1.1.0",
|
"@radix-ui/react-slider": "^1.1.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
import { trackPageview } from './usePlausible';
|
import { trackPageview } from './usePlausible';
|
||||||
import Map from './components/Map';
|
import Map from './components/Map';
|
||||||
|
import type { SearchedPostcode } from './components/PostcodeSearch';
|
||||||
import Filters from './components/Filters';
|
import Filters from './components/Filters';
|
||||||
import POIPane from './components/POIPane';
|
import POIPane from './components/POIPane';
|
||||||
import { PropertiesPane } from './components/PropertiesPane';
|
import { PropertiesPane } from './components/PropertiesPane';
|
||||||
|
|
@ -10,12 +11,15 @@ import DataSourcesPage from './components/DataSourcesPage';
|
||||||
import FAQPage from './components/FAQPage';
|
import FAQPage from './components/FAQPage';
|
||||||
import HomePage from './components/HomePage';
|
import HomePage from './components/HomePage';
|
||||||
import Header, { type Page } from './components/Header';
|
import Header, { type Page } from './components/Header';
|
||||||
|
import { ChevronIcon } from './components/ui/Icons';
|
||||||
|
import { TabButton } from './components/ui/TabButton';
|
||||||
import type {
|
import type {
|
||||||
FeatureMeta,
|
FeatureMeta,
|
||||||
FeatureGroup,
|
FeatureGroup,
|
||||||
FeatureFilters,
|
FeatureFilters,
|
||||||
Bounds,
|
Bounds,
|
||||||
HexagonData,
|
HexagonData,
|
||||||
|
PostcodeData,
|
||||||
ViewChangeParams,
|
ViewChangeParams,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
POI,
|
POI,
|
||||||
|
|
@ -26,8 +30,9 @@ import type {
|
||||||
HexagonPropertiesResponse,
|
HexagonPropertiesResponse,
|
||||||
HexagonStatsResponse,
|
HexagonStatsResponse,
|
||||||
} from './types';
|
} 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 { parseUrlState, DEFAULT_VIEW } from './lib/url-state';
|
||||||
|
import { POSTCODE_ZOOM_THRESHOLD } from './lib/map-utils';
|
||||||
import { useTheme } from './hooks/useTheme';
|
import { useTheme } from './hooks/useTheme';
|
||||||
import { useUrlSync } from './hooks/useUrlSync';
|
import { useUrlSync } from './hooks/useUrlSync';
|
||||||
|
|
||||||
|
|
@ -53,6 +58,7 @@ export default function App() {
|
||||||
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
||||||
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
|
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
|
||||||
const [rawData, setRawData] = useState<HexagonData[]>([]);
|
const [rawData, setRawData] = useState<HexagonData[]>([]);
|
||||||
|
const [postcodeData, setPostcodeData] = useState<PostcodeData[]>([]);
|
||||||
const [dragData, setDragData] = useState<HexagonData[] | null>(null);
|
const [dragData, setDragData] = useState<HexagonData[] | null>(null);
|
||||||
const [resolution, setResolution] = useState<number>(8);
|
const [resolution, setResolution] = useState<number>(8);
|
||||||
const [bounds, setBounds] = useState<Bounds | null>(null);
|
const [bounds, setBounds] = useState<Bounds | null>(null);
|
||||||
|
|
@ -86,9 +92,11 @@ export default function App() {
|
||||||
const poiDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const poiDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const poiAbortControllerRef = useRef<AbortController | null>(null);
|
const poiAbortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const [selectedHexagon, setSelectedHexagon] = useState<{ h3: string; resolution: number } | null>(
|
const [selectedHexagon, setSelectedHexagon] = useState<{
|
||||||
null
|
id: string;
|
||||||
);
|
type: 'hexagon' | 'postcode';
|
||||||
|
resolution: number;
|
||||||
|
} | null>(null);
|
||||||
const [properties, setProperties] = useState<Property[]>([]);
|
const [properties, setProperties] = useState<Property[]>([]);
|
||||||
const [propertiesTotal, setPropertiesTotal] = useState(0);
|
const [propertiesTotal, setPropertiesTotal] = useState(0);
|
||||||
const [propertiesOffset, setPropertiesOffset] = useState(0);
|
const [propertiesOffset, setPropertiesOffset] = useState(0);
|
||||||
|
|
@ -100,14 +108,11 @@ export default function App() {
|
||||||
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
|
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
|
||||||
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
|
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
|
||||||
|
|
||||||
|
const [leftPaneCollapsed, setLeftPaneCollapsed] = useState(false);
|
||||||
|
const [rightPaneCollapsed, setRightPaneCollapsed] = useState(false);
|
||||||
|
|
||||||
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
|
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
|
||||||
const [hoveredAreaStats, setHoveredAreaStats] = useState<HexagonStatsResponse | null>(null);
|
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | 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 [initialLoading, setInitialLoading] = useState(true);
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
|
|
||||||
const [activePage, setActivePage] = useState<Page>(() => {
|
const [activePage, setActivePage] = useState<Page>(() => {
|
||||||
|
|
@ -162,20 +167,6 @@ export default function App() {
|
||||||
const viewFeature = activeFeature || pinnedFeature;
|
const viewFeature = activeFeature || pinnedFeature;
|
||||||
const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null;
|
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 => {
|
const filterRange = useMemo((): [number, number] | null => {
|
||||||
if (!viewFeature) return null;
|
if (!viewFeature) return null;
|
||||||
if (activeFeature && dragValue) return dragValue;
|
if (activeFeature && dragValue) return dragValue;
|
||||||
|
|
@ -196,7 +187,7 @@ export default function App() {
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchWithRetry<{ groups: FeatureGroup[] }>(
|
fetchWithRetry<{ groups: FeatureGroup[] }>(
|
||||||
`${getApiBaseUrl()}/api/features`,
|
apiUrl('features'),
|
||||||
(json) => {
|
(json) => {
|
||||||
const flat: FeatureMeta[] = json.groups.flatMap((g) =>
|
const flat: FeatureMeta[] = json.groups.flatMap((g) =>
|
||||||
g.features.map((f) => ({ ...f, group: g.name }))
|
g.features.map((f) => ({ ...f, group: g.name }))
|
||||||
|
|
@ -209,7 +200,7 @@ export default function App() {
|
||||||
);
|
);
|
||||||
|
|
||||||
fetchWithRetry<POICategoriesResponse>(
|
fetchWithRetry<POICategoriesResponse>(
|
||||||
`${getApiBaseUrl()}/api/poi-categories`,
|
apiUrl('poi-categories'),
|
||||||
(json) => {
|
(json) => {
|
||||||
setPOICategoryGroups(json.groups);
|
setPOICategoryGroups(json.groups);
|
||||||
poisLoaded = true;
|
poisLoaded = true;
|
||||||
|
|
@ -226,6 +217,8 @@ export default function App() {
|
||||||
[filters, features]
|
[filters, features]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bounds) return;
|
if (!bounds) return;
|
||||||
|
|
||||||
|
|
@ -244,25 +237,42 @@ export default function App() {
|
||||||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||||
const filtersStr = buildFilterParam();
|
const filtersStr = buildFilterParam();
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
if (usePostcodeView) {
|
||||||
resolution: resolution.toString(),
|
// Fetch postcode polygons for high zoom levels
|
||||||
bounds: boundsStr,
|
const params = new URLSearchParams({ bounds: boundsStr });
|
||||||
});
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
if (filtersStr) params.set('filters', filtersStr);
|
if (viewFeature) {
|
||||||
if (viewFeature) {
|
params.set('fields', 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 {
|
} 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) {
|
} catch (err) {
|
||||||
if (err instanceof Error && err.name !== 'AbortError') {
|
logNonAbortError('Failed to fetch data', err);
|
||||||
console.error('Failed to fetch data:', err);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -273,10 +283,57 @@ export default function App() {
|
||||||
clearTimeout(debounceRef.current);
|
clearTimeout(debounceRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [resolution, bounds, filters, buildFilterParam, viewFeature]);
|
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView]);
|
||||||
|
|
||||||
const data = dragData ?? rawData;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!bounds || selectedPOICategories.size === 0) {
|
if (!bounds || selectedPOICategories.size === 0) {
|
||||||
setPois([]);
|
setPois([]);
|
||||||
|
|
@ -300,15 +357,13 @@ export default function App() {
|
||||||
categories: categoriesStr,
|
categories: categoriesStr,
|
||||||
bounds: boundsStr,
|
bounds: boundsStr,
|
||||||
});
|
});
|
||||||
const res = await fetch(`${getApiBaseUrl()}/api/pois?${params}`, {
|
const res = await fetch(apiUrl('pois', params), {
|
||||||
signal: poiAbortControllerRef.current.signal,
|
signal: poiAbortControllerRef.current.signal,
|
||||||
});
|
});
|
||||||
const json: POIResponse = await res.json();
|
const json: POIResponse = await res.json();
|
||||||
setPois(json.pois || []);
|
setPois(json.pois || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error && err.name !== 'AbortError') {
|
logNonAbortError('Failed to fetch POIs', err);
|
||||||
console.error('Failed to fetch POIs:', err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, DEBOUNCE_MS);
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
|
@ -396,16 +451,12 @@ export default function App() {
|
||||||
if (filtersStr) params.set('filters', filtersStr);
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
params.set('fields', name);
|
params.set('fields', name);
|
||||||
|
|
||||||
fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
|
fetch(apiUrl('hexagons', params), {
|
||||||
signal: dragAbortRef.current.signal,
|
signal: dragAbortRef.current.signal,
|
||||||
})
|
})
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((json: ApiResponse) => setDragData(json.features || []))
|
.then((json: ApiResponse) => setDragData(json.features || []))
|
||||||
.catch((err) => {
|
.catch((err) => logNonAbortError('Failed to fetch drag data', err));
|
||||||
if (err instanceof Error && err.name !== 'AbortError') {
|
|
||||||
console.error('Failed to fetch drag data:', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[filters, features, bounds, resolution]
|
[filters, features, bounds, resolution]
|
||||||
);
|
);
|
||||||
|
|
@ -446,7 +497,7 @@ export default function App() {
|
||||||
if (fields) {
|
if (fields) {
|
||||||
params.set('fields', fields.join(','));
|
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;
|
return (await response.json()) as HexagonStatsResponse;
|
||||||
},
|
},
|
||||||
[filters, features]
|
[filters, features]
|
||||||
|
|
@ -466,7 +517,7 @@ export default function App() {
|
||||||
const filterStr = buildFilterString(filters, features);
|
const filterStr = buildFilterString(filters, features);
|
||||||
if (filterStr) params.append('filters', filterStr);
|
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();
|
const data: HexagonPropertiesResponse = await response.json();
|
||||||
|
|
||||||
if (offset === 0) {
|
if (offset === 0) {
|
||||||
|
|
@ -486,99 +537,48 @@ export default function App() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleHexagonClick = useCallback(
|
const handleHexagonClick = useCallback(
|
||||||
(h3: string) => {
|
(id: string, isPostcode = false) => {
|
||||||
if (selectedHexagon?.h3 === h3) {
|
if (selectedHexagon?.id === id) {
|
||||||
setSelectedHexagon(null);
|
setSelectedHexagon(null);
|
||||||
setProperties([]);
|
setProperties([]);
|
||||||
setAreaStats(null);
|
setAreaStats(null);
|
||||||
} else {
|
} else {
|
||||||
setSelectedHexagon({ h3, resolution });
|
const type = isPostcode ? 'postcode' : 'hexagon';
|
||||||
|
setSelectedHexagon({ id, type, resolution });
|
||||||
setPropertiesOffset(0);
|
setPropertiesOffset(0);
|
||||||
setRightPaneTab('area');
|
setRightPaneTab('area');
|
||||||
setLoadingAreaStats(true);
|
|
||||||
fetchHexagonStats(h3, resolution)
|
if (isPostcode) {
|
||||||
.then((stats) => setAreaStats(stats))
|
// For postcodes, we don't have a stats API yet, so skip
|
||||||
.catch((error) => {
|
setAreaStats(null);
|
||||||
if (error instanceof Error && error.name !== 'AbortError') {
|
setLoadingAreaStats(false);
|
||||||
console.error('Failed to fetch area stats:', error);
|
} else {
|
||||||
}
|
setLoadingAreaStats(true);
|
||||||
})
|
fetchHexagonStats(id, resolution)
|
||||||
.finally(() => setLoadingAreaStats(false));
|
.then((stats) => setAreaStats(stats))
|
||||||
|
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
|
||||||
|
.finally(() => setLoadingAreaStats(false));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectedHexagon, resolution, fetchHexagonStats]
|
[selectedHexagon, resolution, fetchHexagonStats]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleHexagonHover = useCallback(
|
const handleHexagonHover = useCallback((h3: string | null) => {
|
||||||
(h3: string | null) => {
|
setHoveredHexagon(h3);
|
||||||
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 handleViewPropertiesFromArea = useCallback(() => {
|
const handleViewPropertiesFromArea = useCallback(() => {
|
||||||
if (selectedHexagon) {
|
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
|
||||||
setRightPaneTab('properties');
|
setRightPaneTab('properties');
|
||||||
setPropertiesOffset(0);
|
setPropertiesOffset(0);
|
||||||
fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, 0);
|
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
||||||
}
|
}
|
||||||
}, [selectedHexagon, fetchHexagonProperties]);
|
}, [selectedHexagon, fetchHexagonProperties]);
|
||||||
|
|
||||||
const handleLoadMoreProperties = useCallback(() => {
|
const handleLoadMoreProperties = useCallback(() => {
|
||||||
if (selectedHexagon) {
|
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
|
||||||
fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, propertiesOffset);
|
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, propertiesOffset);
|
||||||
}
|
}
|
||||||
}, [selectedHexagon, propertiesOffset, fetchHexagonProperties]);
|
}, [selectedHexagon, propertiesOffset, fetchHexagonProperties]);
|
||||||
|
|
||||||
|
|
@ -593,6 +593,8 @@ export default function App() {
|
||||||
<div className="h-screen w-screen">
|
<div className="h-screen w-screen">
|
||||||
<Map
|
<Map
|
||||||
data={data}
|
data={data}
|
||||||
|
postcodeData={postcodeData}
|
||||||
|
usePostcodeView={usePostcodeView}
|
||||||
pois={pois}
|
pois={pois}
|
||||||
onViewChange={handleViewChange}
|
onViewChange={handleViewChange}
|
||||||
viewFeature={viewFeature}
|
viewFeature={viewFeature}
|
||||||
|
|
@ -657,31 +659,53 @@ export default function App() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Filters
|
<div
|
||||||
features={features}
|
className={`flex ${leftPaneCollapsed ? 'w-10' : 'w-96'} bg-white dark:bg-navy-950 shadow-lg overflow-hidden`}
|
||||||
filters={filters}
|
>
|
||||||
activeFeature={activeFeature}
|
{leftPaneCollapsed ? (
|
||||||
dragValue={dragValue}
|
<button
|
||||||
enabledFeatures={enabledFeatures}
|
onClick={() => setLeftPaneCollapsed(false)}
|
||||||
onAddFilter={handleAddFilter}
|
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"
|
||||||
onRemoveFilter={handleRemoveFilter}
|
title="Expand filters"
|
||||||
onFilterChange={handleFilterChange}
|
>
|
||||||
onDragStart={handleDragStart}
|
<ChevronIcon direction="right" className="w-5 h-5" />
|
||||||
onDragChange={handleDragChange}
|
<span className="text-xs font-medium writing-mode-vertical">Filters</span>
|
||||||
onDragEnd={handleDragEnd}
|
</button>
|
||||||
zoom={zoom}
|
) : (
|
||||||
pinnedFeature={pinnedFeature}
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
onTogglePin={handleTogglePin}
|
<Filters
|
||||||
onCancelPin={handleCancelPin}
|
features={features}
|
||||||
onNavigateToSource={(slug, featureName) => {
|
filters={filters}
|
||||||
navigateTo('data-sources', slug, featureName);
|
activeFeature={activeFeature}
|
||||||
}}
|
dragValue={dragValue}
|
||||||
openInfoFeature={pendingInfoFeature}
|
enabledFeatures={enabledFeatures}
|
||||||
onClearOpenInfoFeature={() => setPendingInfoFeature(null)}
|
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">
|
<div className="flex-1 relative">
|
||||||
<Map
|
<Map
|
||||||
data={data}
|
data={data}
|
||||||
|
postcodeData={postcodeData}
|
||||||
|
usePostcodeView={usePostcodeView}
|
||||||
pois={pois}
|
pois={pois}
|
||||||
onViewChange={handleViewChange}
|
onViewChange={handleViewChange}
|
||||||
viewFeature={viewFeature}
|
viewFeature={viewFeature}
|
||||||
|
|
@ -690,12 +714,15 @@ export default function App() {
|
||||||
viewSource={viewSource}
|
viewSource={viewSource}
|
||||||
onCancelPin={handleCancelPin}
|
onCancelPin={handleCancelPin}
|
||||||
features={features}
|
features={features}
|
||||||
selectedHexagonId={selectedHexagon?.h3 || null}
|
selectedHexagonId={selectedHexagon?.id || null}
|
||||||
hoveredHexagonId={hoveredHexagon}
|
hoveredHexagonId={hoveredHexagon}
|
||||||
onHexagonClick={handleHexagonClick}
|
onHexagonClick={handleHexagonClick}
|
||||||
onHexagonHover={handleHexagonHover}
|
onHexagonHover={handleHexagonHover}
|
||||||
initialViewState={initialViewState}
|
initialViewState={initialViewState}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
filters={filters}
|
||||||
|
searchedPostcode={searchedPostcode}
|
||||||
|
onPostcodeSearched={setSearchedPostcode}
|
||||||
/>
|
/>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="absolute bottom-4 left-4 bg-white dark:bg-navy-800 dark:text-warm-200 px-3 py-1 rounded shadow">
|
<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')} />
|
<DataSources onNavigate={() => navigateTo('data-sources')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-72 bg-white dark:bg-navy-950 shadow-lg z-10 flex flex-col">
|
<div
|
||||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
className={`${rightPaneCollapsed ? 'w-10' : 'w-72'} bg-white dark:bg-navy-950 shadow-lg z-10 flex flex-col`}
|
||||||
|
>
|
||||||
|
{rightPaneCollapsed ? (
|
||||||
<button
|
<button
|
||||||
className={`flex-1 p-3 ${
|
onClick={() => setRightPaneCollapsed(false)}
|
||||||
rightPaneTab === 'area'
|
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"
|
||||||
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
|
title="Expand panel"
|
||||||
: 'text-warm-600 dark:text-warm-400'
|
|
||||||
}`}
|
|
||||||
onClick={() => setRightPaneTab('area')}
|
|
||||||
>
|
>
|
||||||
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>
|
||||||
<button
|
) : (
|
||||||
className={`flex-1 p-3 ${
|
<>
|
||||||
rightPaneTab === 'properties'
|
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||||
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
|
<button
|
||||||
: 'text-warm-600 dark:text-warm-400'
|
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"
|
||||||
onClick={() => setRightPaneTab('properties')}
|
title="Collapse panel"
|
||||||
>
|
>
|
||||||
Properties {propertiesTotal > 0 && `(${propertiesTotal})`}
|
<ChevronIcon direction="right" className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<TabButton
|
||||||
className={`flex-1 p-3 ${
|
label="Area"
|
||||||
rightPaneTab === 'pois'
|
count={areaStats?.count}
|
||||||
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
|
isActive={rightPaneTab === 'area'}
|
||||||
: 'text-warm-600 dark:text-warm-400'
|
onClick={() => setRightPaneTab('area')}
|
||||||
}`}
|
/>
|
||||||
onClick={() => setRightPaneTab('pois')}
|
<TabButton
|
||||||
>
|
label="Properties"
|
||||||
POIs {pois.length > 0 && `(${pois.length})`}
|
count={propertiesTotal > 0 ? propertiesTotal : undefined}
|
||||||
</button>
|
isActive={rightPaneTab === 'properties'}
|
||||||
</div>
|
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">
|
<div className="flex-1 overflow-hidden">
|
||||||
{rightPaneTab === 'area' ? (
|
{rightPaneTab === 'area' ? (
|
||||||
<AreaPane
|
<AreaPane
|
||||||
stats={hoverMode && hoveredAreaStats ? hoveredAreaStats : areaStats}
|
stats={areaStats}
|
||||||
globalFeatures={features}
|
globalFeatures={features}
|
||||||
loading={
|
loading={loadingAreaStats}
|
||||||
hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
|
hexagonId={selectedHexagon?.id || null}
|
||||||
? loadingHoveredAreaStats
|
isPostcode={selectedHexagon?.type === 'postcode'}
|
||||||
: loadingAreaStats
|
postcodeData={
|
||||||
}
|
selectedHexagon?.type === 'postcode'
|
||||||
hexagonId={
|
? postcodeData.find((d) => d.postcode === selectedHexagon.id) || null
|
||||||
hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
|
: null
|
||||||
? hoveredHexagon
|
}
|
||||||
: selectedHexagon?.h3 || null
|
onViewProperties={handleViewPropertiesFromArea}
|
||||||
}
|
onClose={handleCloseProperties}
|
||||||
isHoveredPreview={
|
hexagonLocation={(() => {
|
||||||
!!(
|
const hexId = selectedHexagon?.id;
|
||||||
hoverMode &&
|
const hex = hexId ? data.find((d) => d.h3 === hexId) : null;
|
||||||
hoveredAreaStats &&
|
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number')
|
||||||
hoveredHexagon &&
|
return null;
|
||||||
hoveredHexagon !== selectedHexagon?.h3
|
return {
|
||||||
)
|
lat: hex.lat as number,
|
||||||
}
|
lon: hex.lon as number,
|
||||||
hoverMode={hoverMode}
|
resolution,
|
||||||
onHoverModeChange={setHoverMode}
|
};
|
||||||
onViewProperties={handleViewPropertiesFromArea}
|
})()}
|
||||||
onClose={handleCloseProperties}
|
filters={filters}
|
||||||
hexagonLocation={(() => {
|
onNavigateToSource={(slug, featureName) => {
|
||||||
const hexId =
|
navigateTo('data-sources', slug, featureName);
|
||||||
hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
|
}}
|
||||||
? hoveredHexagon
|
/>
|
||||||
: selectedHexagon?.h3;
|
) : rightPaneTab === 'properties' ? (
|
||||||
const hex = hexId ? data.find((d) => d.h3 === hexId) : null;
|
<PropertiesPane
|
||||||
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number')
|
properties={properties}
|
||||||
return null;
|
total={propertiesTotal}
|
||||||
return {
|
loading={loadingProperties}
|
||||||
lat: hex.lat as number,
|
hexagonId={selectedHexagon?.id || null}
|
||||||
lon: hex.lon as number,
|
onLoadMore={handleLoadMoreProperties}
|
||||||
resolution,
|
onClose={handleCloseProperties}
|
||||||
};
|
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
|
||||||
})()}
|
/>
|
||||||
filters={filters}
|
) : (
|
||||||
/>
|
<POIPane
|
||||||
) : rightPaneTab === 'properties' ? (
|
groups={poiCategoryGroups}
|
||||||
<PropertiesPane
|
selectedCategories={selectedPOICategories}
|
||||||
properties={hoverMode && hoveredProperties ? hoveredProperties : properties}
|
onCategoriesChange={setSelectedPOICategories}
|
||||||
total={hoverMode && hoveredProperties ? hoveredPropertiesTotal : propertiesTotal}
|
poiCount={pois.length}
|
||||||
loading={loadingProperties}
|
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
|
||||||
hexagonId={
|
/>
|
||||||
hoverMode && hoveredProperties ? hoveredHexagon : selectedHexagon?.h3 || null
|
)}
|
||||||
}
|
</div>
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,28 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse } from '../types';
|
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeData } from '../types';
|
||||||
import type { HexagonLocation } from '../lib/external-search';
|
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 { DualHistogram, LoadingSkeleton } from './DualHistogram';
|
||||||
import EnumBarChart from './EnumBarChart';
|
import EnumBarChart from './EnumBarChart';
|
||||||
import ExternalSearchLinks from './ExternalSearchLinks';
|
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 {
|
interface AreaPaneProps {
|
||||||
stats: HexagonStatsResponse | null;
|
stats: HexagonStatsResponse | null;
|
||||||
globalFeatures: FeatureMeta[];
|
globalFeatures: FeatureMeta[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
hexagonId: string | null;
|
hexagonId: string | null;
|
||||||
isHoveredPreview: boolean;
|
isPostcode?: boolean;
|
||||||
hoverMode: boolean;
|
postcodeData?: PostcodeData | null;
|
||||||
onHoverModeChange: (enabled: boolean) => void;
|
|
||||||
onViewProperties: () => void;
|
onViewProperties: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
hexagonLocation: HexagonLocation | null;
|
hexagonLocation: HexagonLocation | null;
|
||||||
filters: FeatureFilters;
|
filters: FeatureFilters;
|
||||||
}
|
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AreaPane({
|
export default function AreaPane({
|
||||||
|
|
@ -39,15 +30,18 @@ export default function AreaPane({
|
||||||
globalFeatures,
|
globalFeatures,
|
||||||
loading,
|
loading,
|
||||||
hexagonId,
|
hexagonId,
|
||||||
isHoveredPreview,
|
isPostcode = false,
|
||||||
hoverMode,
|
postcodeData,
|
||||||
onHoverModeChange,
|
|
||||||
onViewProperties,
|
onViewProperties,
|
||||||
onClose,
|
onClose,
|
||||||
hexagonLocation,
|
hexagonLocation,
|
||||||
filters,
|
filters,
|
||||||
|
onNavigateToSource,
|
||||||
}: AreaPaneProps) {
|
}: 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(() => {
|
const numericByName = useMemo(() => {
|
||||||
if (!stats) return new Map();
|
if (!stats) return new Map();
|
||||||
|
|
@ -65,78 +59,31 @@ export default function AreaPane({
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hexagonId) {
|
if (!hexagonId) {
|
||||||
return (
|
return <PaneEmptyState message="Click a hexagon or postcode to view area statistics" />;
|
||||||
<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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
|
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="flex items-center gap-2">
|
<div>
|
||||||
<h2 className="text-sm font-semibold dark:text-warm-100">Area Statistics</h2>
|
<h2 className="text-sm font-semibold dark:text-warm-100">
|
||||||
{isHoveredPreview && (
|
{isPostcode ? hexagonId : 'Area Statistics'}
|
||||||
<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">
|
</h2>
|
||||||
Preview
|
{isPostcode && (
|
||||||
</span>
|
<span className="text-xs text-warm-500 dark:text-warm-400">Postcode</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<IconButton onClick={onClose} title="Close">
|
||||||
<button
|
<CloseIcon />
|
||||||
onClick={() => onHoverModeChange(!hoverMode)}
|
</IconButton>
|
||||||
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>
|
</div>
|
||||||
{stats && (
|
{propertyCount != null && (
|
||||||
<p className="text-sm text-warm-600 dark:text-warm-400 mt-1">
|
<p className="text-sm text-warm-600 dark:text-warm-400 mt-1">
|
||||||
{stats.count.toLocaleString()} properties
|
{propertyCount.toLocaleString()} properties
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{stats && (
|
{!isPostcode && stats && (
|
||||||
<button
|
<button
|
||||||
onClick={onViewProperties}
|
onClick={onViewProperties}
|
||||||
className="mt-2 w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
|
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) {
|
if (numericStats) {
|
||||||
const globalFeature = globalFeatureByName.get(feature.name);
|
const globalFeature = globalFeatureByName.get(feature.name);
|
||||||
const globalHistogram = globalFeature?.histogram;
|
const globalHistogram = globalFeature?.histogram;
|
||||||
let globalMean: number | undefined;
|
const globalMean = globalHistogram
|
||||||
if (globalHistogram && globalHistogram.counts.length > 0) {
|
? calculateHistogramMean(globalHistogram)
|
||||||
const totalCount = globalHistogram.counts.reduce((a, b) => a + b, 0);
|
: undefined;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -194,9 +131,20 @@ export default function AreaPane({
|
||||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-baseline">
|
<div className="flex justify-between items-baseline">
|
||||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
<div className="flex items-center gap-1 min-w-0 mr-2">
|
||||||
{feature.name}
|
<span className="text-xs text-warm-700 dark:text-warm-300 truncate">
|
||||||
</span>
|
{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">
|
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||||
{formatValue(numericStats.mean)}
|
{formatValue(numericStats.mean)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -231,9 +179,20 @@ export default function AreaPane({
|
||||||
key={feature.name}
|
key={feature.name}
|
||||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||||
>
|
>
|
||||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
<div className="flex items-center gap-1">
|
||||||
{feature.name}
|
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||||
</span>
|
{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} />
|
<EnumBarChart counts={enumStats.counts} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -248,6 +207,14 @@ export default function AreaPane({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{infoFeature && (
|
||||||
|
<FeatureInfoPopup
|
||||||
|
feature={infoFeature}
|
||||||
|
onClose={() => setInfoFeature(null)}
|
||||||
|
onNavigateToSource={onNavigateToSource}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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 { memo, useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
||||||
import { Slider } from './ui/slider';
|
import { Slider } from './ui/slider';
|
||||||
import { Label } from './ui/label';
|
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 type { FeatureMeta, FeatureFilters } from '../types';
|
||||||
import { formatFilterValue } from '../lib/format';
|
import { formatFilterValue } from '../lib/format';
|
||||||
|
import { groupFeaturesByCategory } from '../lib/features';
|
||||||
import InfoPopup from './InfoPopup';
|
import InfoPopup from './InfoPopup';
|
||||||
|
import { FeatureInfoPopup } from './FeatureInfoPopup';
|
||||||
|
import { FeatureActions } from './FeatureIcons';
|
||||||
|
|
||||||
interface FiltersProps {
|
interface FiltersProps {
|
||||||
features: FeatureMeta[];
|
features: FeatureMeta[];
|
||||||
|
|
@ -18,27 +26,15 @@ interface FiltersProps {
|
||||||
onDragChange: (value: [number, number]) => void;
|
onDragChange: (value: [number, number]) => void;
|
||||||
onDragEnd: () => void;
|
onDragEnd: () => void;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
|
itemCount: number;
|
||||||
|
usePostcodeView: boolean;
|
||||||
pinnedFeature: string | null;
|
pinnedFeature: string | null;
|
||||||
onTogglePin: (name: string) => void;
|
onTogglePin: (name: string) => void;
|
||||||
onCancelPin: () => void;
|
onCancelPin: () => void;
|
||||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||||
openInfoFeature?: string | null;
|
openInfoFeature?: string | null;
|
||||||
onClearOpenInfoFeature?: () => void;
|
onClearOpenInfoFeature?: () => void;
|
||||||
}
|
onCollapse?: () => 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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeatureBrowser({
|
function FeatureBrowser({
|
||||||
|
|
@ -77,32 +73,12 @@ function FeatureBrowser({
|
||||||
return availableFeatures.filter((f) => f.name.toLowerCase().includes(lower));
|
return availableFeatures.filter((f) => f.name.toLowerCase().includes(lower));
|
||||||
}, [availableFeatures, search]);
|
}, [availableFeatures, search]);
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]);
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="p-2 border-b border-warm-200 dark:border-navy-700">
|
<div className="p-2 border-b border-warm-200 dark:border-navy-700">
|
||||||
<input
|
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{grouped.map((group) => (
|
{grouped.map((group) => (
|
||||||
|
|
@ -125,86 +101,31 @@ function FeatureBrowser({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0 mt-0.5">
|
<FeatureActions
|
||||||
{f.detail && (
|
feature={f}
|
||||||
<button
|
isPinned={isPinned}
|
||||||
onClick={() => setInfoFeature(f)}
|
onTogglePin={onTogglePin}
|
||||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
|
onShowInfo={setInfoFeature}
|
||||||
title="Feature info"
|
onAdd={onAddFilter}
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{grouped.length === 0 && (
|
{grouped.length === 0 && (
|
||||||
<div className="px-3 py-4 text-sm text-warm-400 dark:text-warm-500 text-center">
|
<EmptyState
|
||||||
{search ? 'No matching features' : 'All features are active'}
|
title={search ? 'No matching features' : 'All features are active'}
|
||||||
</div>
|
className="px-3 py-4"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{infoFeature && (
|
{infoFeature && (
|
||||||
<InfoPopup
|
<FeatureInfoPopup
|
||||||
title={infoFeature.name}
|
feature={infoFeature}
|
||||||
onClose={() => setInfoFeature(null)}
|
onClose={() => setInfoFeature(null)}
|
||||||
sourceLink={
|
onNavigateToSource={onNavigateToSource}
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -223,12 +144,15 @@ export default memo(function Filters({
|
||||||
onDragChange,
|
onDragChange,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
zoom,
|
zoom,
|
||||||
|
itemCount,
|
||||||
|
usePostcodeView,
|
||||||
pinnedFeature,
|
pinnedFeature,
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
onCancelPin: _onCancelPin,
|
onCancelPin: _onCancelPin,
|
||||||
onNavigateToSource,
|
onNavigateToSource,
|
||||||
openInfoFeature,
|
openInfoFeature,
|
||||||
onClearOpenInfoFeature,
|
onClearOpenInfoFeature,
|
||||||
|
onCollapse,
|
||||||
}: FiltersProps) {
|
}: FiltersProps) {
|
||||||
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
|
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
|
||||||
const enabledFeatureList = 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 containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [splitFraction, setSplitFraction] = useState(0.65);
|
const [splitFraction, setSplitFraction] = useState(0.65);
|
||||||
const draggingRef = useRef(false);
|
const draggingRef = useRef(false);
|
||||||
|
const [showPhilosophy, setShowPhilosophy] = useState(false);
|
||||||
|
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
|
||||||
|
|
||||||
const handleSeparatorPointerDown = useCallback((e: React.PointerEvent) => {
|
const handleSeparatorPointerDown = useCallback((e: React.PointerEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -258,8 +184,22 @@ export default memo(function Filters({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
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="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="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">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -272,32 +212,19 @@ export default memo(function Filters({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||||
{enabledFeatureList.length === 0 && (
|
{enabledFeatureList.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
<EmptyState
|
||||||
<svg
|
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
|
||||||
className="w-8 h-8 text-warm-300 dark:text-warm-600 mb-2"
|
title="No active filters"
|
||||||
viewBox="0 0 24 24"
|
description="Browse features below and click + to add a filter"
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{enabledFeatureList.map((feature) => {
|
{enabledFeatureList.map((feature) => {
|
||||||
|
|
@ -311,41 +238,19 @@ export default memo(function Filters({
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>{feature.name}</Label>
|
<Label>{feature.name}</Label>
|
||||||
<div className="flex items-center gap-0.5">
|
<FeatureActions
|
||||||
<button
|
feature={feature}
|
||||||
onClick={() => onTogglePin(feature.name)}
|
isPinned={pinnedFeature === 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'}`}
|
onTogglePin={onTogglePin}
|
||||||
title={
|
onShowInfo={setActiveInfoFeature}
|
||||||
pinnedFeature === feature.name
|
onRemove={onRemoveFilter}
|
||||||
? '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>
|
|
||||||
</div>
|
</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">
|
<div className="space-y-0.5 max-h-40 overflow-y-auto">
|
||||||
{allValues.map((val) => (
|
{allValues.map((val) => (
|
||||||
<label
|
<label
|
||||||
|
|
@ -389,22 +294,13 @@ export default memo(function Filters({
|
||||||
{feature.name}: {formatFilterValue(displayValue[0])} -{' '}
|
{feature.name}: {formatFilterValue(displayValue[0])} -{' '}
|
||||||
{formatFilterValue(displayValue[1])}
|
{formatFilterValue(displayValue[1])}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex items-center gap-0.5">
|
<FeatureActions
|
||||||
<button
|
feature={feature}
|
||||||
onClick={() => onTogglePin(feature.name)}
|
isPinned={isPinned}
|
||||||
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'}`}
|
onTogglePin={onTogglePin}
|
||||||
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
|
onShowInfo={setActiveInfoFeature}
|
||||||
>
|
onRemove={onRemoveFilter}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
min={feature.min!}
|
min={feature.min!}
|
||||||
|
|
@ -447,6 +343,73 @@ export default memo(function Filters({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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 { useRef, useCallback, type ReactNode } from 'react';
|
||||||
import { useClickOutside } from '../hooks/useClickOutside';
|
import { useClickOutside } from '../hooks/useClickOutside';
|
||||||
|
import { CloseIcon } from './ui/Icons';
|
||||||
|
import { IconButton } from './ui/IconButton';
|
||||||
|
|
||||||
interface InfoPopupProps {
|
interface InfoPopupProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -25,20 +27,9 @@ export default function InfoPopup({ title, children, onClose, sourceLink }: Info
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-3">
|
<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>
|
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">{title}</h3>
|
||||||
<button
|
<IconButton onClick={onClose} className="shrink-0">
|
||||||
onClick={onClose}
|
<CloseIcon />
|
||||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
</IconButton>
|
||||||
>
|
|
||||||
<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>
|
||||||
{children}
|
{children}
|
||||||
{sourceLink && (
|
{sourceLink && (
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,18 @@ import { Map as MapGL, useControl } from 'react-map-gl/maplibre';
|
||||||
import type { MapRef } from 'react-map-gl/maplibre';
|
import type { MapRef } from 'react-map-gl/maplibre';
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
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 type { PickingInfo } from '@deck.gl/core';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
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 {
|
import {
|
||||||
GRADIENT,
|
GRADIENT,
|
||||||
normalizedToColor,
|
normalizedToColor,
|
||||||
|
|
@ -14,11 +22,14 @@ import {
|
||||||
zoomToResolution,
|
zoomToResolution,
|
||||||
getBoundsFromViewState,
|
getBoundsFromViewState,
|
||||||
emojiToTwemojiUrl,
|
emojiToTwemojiUrl,
|
||||||
MAP_STYLE_LIGHT,
|
getMapStyle,
|
||||||
MAP_STYLE_DARK,
|
POSTCODE_ZOOM_THRESHOLD,
|
||||||
} from '../lib/map-utils';
|
} 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 MapLegend from './MapLegend';
|
||||||
|
import HoverCard from './HoverCard';
|
||||||
|
import type { FeatureFilters } from '../types';
|
||||||
|
|
||||||
/** Convert POI id (e.g. "n12345") to OpenStreetMap URL */
|
/** Convert POI id (e.g. "n12345") to OpenStreetMap URL */
|
||||||
function osmIdToUrl(id: string): string | null {
|
function osmIdToUrl(id: string): string | null {
|
||||||
|
|
@ -30,6 +41,8 @@ function osmIdToUrl(id: string): string | null {
|
||||||
|
|
||||||
interface MapProps {
|
interface MapProps {
|
||||||
data: HexagonData[];
|
data: HexagonData[];
|
||||||
|
postcodeData: PostcodeData[];
|
||||||
|
usePostcodeView: boolean;
|
||||||
pois: POI[];
|
pois: POI[];
|
||||||
onViewChange: (params: ViewChangeParams) => void;
|
onViewChange: (params: ViewChangeParams) => void;
|
||||||
viewFeature: string | null;
|
viewFeature: string | null;
|
||||||
|
|
@ -40,19 +53,16 @@ interface MapProps {
|
||||||
features: FeatureMeta[];
|
features: FeatureMeta[];
|
||||||
selectedHexagonId: string | null;
|
selectedHexagonId: string | null;
|
||||||
hoveredHexagonId: string | null;
|
hoveredHexagonId: string | null;
|
||||||
onHexagonClick: (h3: string) => void;
|
onHexagonClick: (id: string, isPostcode?: boolean) => void;
|
||||||
onHexagonHover: (h3: string | null) => void;
|
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
|
||||||
initialViewState?: ViewState;
|
initialViewState?: ViewState;
|
||||||
theme?: 'light' | 'dark';
|
theme?: 'light' | 'dark';
|
||||||
screenshotMode?: boolean;
|
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 {
|
interface Dimensions {
|
||||||
width: number;
|
width: number;
|
||||||
|
|
@ -81,6 +91,8 @@ function DeckOverlay({
|
||||||
|
|
||||||
export default memo(function Map({
|
export default memo(function Map({
|
||||||
data,
|
data,
|
||||||
|
postcodeData,
|
||||||
|
usePostcodeView,
|
||||||
pois,
|
pois,
|
||||||
onViewChange,
|
onViewChange,
|
||||||
viewFeature,
|
viewFeature,
|
||||||
|
|
@ -96,10 +108,14 @@ export default memo(function Map({
|
||||||
initialViewState,
|
initialViewState,
|
||||||
theme = 'light',
|
theme = 'light',
|
||||||
screenshotMode = false,
|
screenshotMode = false,
|
||||||
|
filters = {},
|
||||||
|
searchedPostcode,
|
||||||
|
onPostcodeSearched,
|
||||||
}: MapProps) {
|
}: MapProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
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 [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
||||||
|
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
|
|
@ -119,17 +135,11 @@ export default memo(function Map({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dimensions.width === 0 || dimensions.height === 0) return;
|
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 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({
|
onViewChange({
|
||||||
resolution,
|
resolution,
|
||||||
bounds,
|
bounds,
|
||||||
|
|
@ -153,30 +163,17 @@ export default memo(function Map({
|
||||||
const handleMapLoad = useCallback(
|
const handleMapLoad = useCallback(
|
||||||
(evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
|
(evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
|
||||||
const map = evt.target;
|
const map = evt.target;
|
||||||
if (themeRef.current === 'light') {
|
// Hide buildings to reduce visual clutter over hexagons
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
map.setLayoutProperty('building', 'visibility', 'none');
|
map.setLayoutProperty('buildings', 'visibility', 'none');
|
||||||
map.setLayoutProperty('building-top', 'visibility', 'none');
|
|
||||||
} catch {
|
} 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<{
|
const [popupInfo, setPopupInfo] = useState<{
|
||||||
x: number;
|
x: number;
|
||||||
|
|
@ -244,9 +241,11 @@ export default memo(function Map({
|
||||||
const onHexagonHoverRef = useRef(onHexagonHover);
|
const onHexagonHoverRef = useRef(onHexagonHover);
|
||||||
onHexagonHoverRef.current = onHexagonHover;
|
onHexagonHoverRef.current = onHexagonHover;
|
||||||
const handleHexagonHover = useCallback((info: PickingInfo<HexagonData>) => {
|
const handleHexagonHover = useCallback((info: PickingInfo<HexagonData>) => {
|
||||||
if (info.object && 'h3' in info.object) {
|
if (info.object && 'h3' in info.object && info.x !== undefined && info.y !== undefined) {
|
||||||
onHexagonHoverRef.current(info.object.h3);
|
setHoverPosition({ x: info.x, y: info.y });
|
||||||
|
onHexagonHoverRef.current(info.object.h3, info.x, info.y);
|
||||||
} else {
|
} else {
|
||||||
|
setHoverPosition(null);
|
||||||
onHexagonHoverRef.current(null);
|
onHexagonHoverRef.current(null);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -257,7 +256,54 @@ export default memo(function Map({
|
||||||
handlePoiHoverRef.current(info);
|
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 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(
|
const hexLayer = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -321,11 +367,76 @@ export default memo(function Map({
|
||||||
onClick: handleHexagonClick,
|
onClick: handleHexagonClick,
|
||||||
onHover: handleHexagonHover,
|
onHover: handleHexagonHover,
|
||||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||||
beforeId: 'waterway_label',
|
beforeId: 'water_waterway_label',
|
||||||
}),
|
}),
|
||||||
[data, colorTrigger, handleHexagonClick, handleHexagonHover]
|
[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(
|
const poiLayer = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new IconLayer<POI>({
|
new IconLayer<POI>({
|
||||||
|
|
@ -346,7 +457,43 @@ export default memo(function Map({
|
||||||
[pois, stablePoiHover]
|
[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 (
|
return (
|
||||||
<div className="flex-1 h-full relative" ref={containerRef}>
|
<div className="flex-1 h-full relative" ref={containerRef}>
|
||||||
|
|
@ -362,8 +509,8 @@ export default memo(function Map({
|
||||||
touchPitch={false}
|
touchPitch={false}
|
||||||
keyboard={true}
|
keyboard={true}
|
||||||
pitchWithRotate={false}
|
pitchWithRotate={false}
|
||||||
minZoom={5}
|
minZoom={MAP_MIN_ZOOM}
|
||||||
maxBounds={[-12, 49, 4, 62]}
|
maxBounds={MAP_BOUNDS}
|
||||||
>
|
>
|
||||||
<DeckOverlay layers={layers} getTooltip={null} />
|
<DeckOverlay layers={layers} getTooltip={null} />
|
||||||
</MapGL>
|
</MapGL>
|
||||||
|
|
@ -378,7 +525,7 @@ export default memo(function Map({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<PostcodeSearch onFlyTo={handleFlyTo} />
|
<PostcodeSearch onFlyTo={handleFlyTo} onPostcodeSearched={onPostcodeSearched} />
|
||||||
{viewSource === 'eye' && viewFeature && (
|
{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">
|
<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">
|
<span className="text-lg font-semibold text-navy-950 dark:text-warm-100">
|
||||||
|
|
@ -434,6 +581,20 @@ export default memo(function Map({
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@ import { useState, useRef, useCallback } from 'react';
|
||||||
import type { POICategoryGroup } from '../types';
|
import type { POICategoryGroup } from '../types';
|
||||||
import { useClickOutside } from '../hooks/useClickOutside';
|
import { useClickOutside } from '../hooks/useClickOutside';
|
||||||
import InfoPopup from './InfoPopup';
|
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 {
|
interface POIPaneProps {
|
||||||
groups: POICategoryGroup[];
|
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="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">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2>
|
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2>
|
||||||
<button
|
<IconButton onClick={() => setShowInfo(true)} title="Data source info">
|
||||||
onClick={() => setShowInfo(true)}
|
<InfoIcon />
|
||||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
|
</IconButton>
|
||||||
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>
|
||||||
|
|
||||||
{showInfo && (
|
{showInfo && (
|
||||||
|
|
@ -148,40 +139,22 @@ export default function POIPane({
|
||||||
? 'All categories'
|
? 'All categories'
|
||||||
: `${selectedCount} selected`}
|
: `${selectedCount} selected`}
|
||||||
</span>
|
</span>
|
||||||
<svg
|
<ChevronIcon
|
||||||
className={`w-4 h-4 ml-2 flex-shrink-0 transition-transform ${dropdownOpen ? 'rotate-180' : ''}`}
|
direction={dropdownOpen ? 'up' : 'down'}
|
||||||
fill="none"
|
className="w-4 h-4 ml-2 flex-shrink-0"
|
||||||
stroke="currentColor"
|
/>
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{dropdownOpen && (
|
{dropdownOpen && (
|
||||||
<div className="border border-warm-300 dark:border-navy-700 rounded shadow-lg bg-white dark:bg-navy-800">
|
<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">
|
<div className="px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||||
<button
|
<SelectionButtons onSelectAll={selectAll} onSelectNone={selectNone} className="text-xs" />
|
||||||
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>
|
</div>
|
||||||
<div className="px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
<div className="px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||||
<input
|
<SearchInput
|
||||||
type="text"
|
|
||||||
placeholder="Search categories..."
|
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={setSearchTerm}
|
||||||
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"
|
placeholder="Search categories..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-96 overflow-y-auto py-1">
|
<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">
|
<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
|
<button
|
||||||
onClick={() => toggleCollapse(group.name)}
|
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
|
<ChevronIcon direction="right" className="w-3 h-3" />
|
||||||
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>
|
|
||||||
</button>
|
</button>
|
||||||
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export interface SearchedPostcode {
|
||||||
|
postcode: string;
|
||||||
|
vertices: [number, number][];
|
||||||
|
}
|
||||||
|
|
||||||
export default function PostcodeSearch({
|
export default function PostcodeSearch({
|
||||||
onFlyTo,
|
onFlyTo,
|
||||||
|
onPostcodeSearched,
|
||||||
}: {
|
}: {
|
||||||
onFlyTo: (lat: number, lng: number, zoom: number) => void;
|
onFlyTo: (lat: number, lng: number, zoom: number) => void;
|
||||||
|
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -18,27 +25,27 @@ export default function PostcodeSearch({
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(`/api/postcode/${encodeURIComponent(trimmed)}`);
|
||||||
`https://api.postcodes.io/postcodes/${encodeURIComponent(trimmed)}`
|
|
||||||
);
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError('Postcode not found');
|
setError('Postcode not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const json = await res.json();
|
const json: {
|
||||||
if (json.status === 200 && json.result) {
|
postcode: string;
|
||||||
onFlyTo(json.result.latitude, json.result.longitude, 14);
|
latitude: number;
|
||||||
setQuery('');
|
longitude: number;
|
||||||
} else {
|
vertices: [number, number][];
|
||||||
setError('Postcode not found');
|
} = await res.json();
|
||||||
}
|
onFlyTo(json.latitude, json.longitude, 16);
|
||||||
|
onPostcodeSearched?.({ postcode: json.postcode, vertices: json.vertices });
|
||||||
|
setQuery('');
|
||||||
} catch {
|
} catch {
|
||||||
setError('Lookup failed');
|
setError('Lookup failed');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[query, onFlyTo]
|
[query, onFlyTo, onPostcodeSearched]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { Property } from '../types';
|
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 InfoPopup from './InfoPopup';
|
||||||
|
import { SearchInput } from './ui/SearchInput';
|
||||||
|
import { PaneHeader } from './ui/PaneHeader';
|
||||||
|
import { PaneEmptyState } from './ui/EmptyState';
|
||||||
|
|
||||||
interface PropertiesPaneProps {
|
interface PropertiesPaneProps {
|
||||||
properties: Property[];
|
properties: Property[];
|
||||||
|
|
@ -11,9 +15,6 @@ interface PropertiesPaneProps {
|
||||||
onLoadMore: () => void;
|
onLoadMore: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onNavigateToSource?: (slug: string) => void;
|
onNavigateToSource?: (slug: string) => void;
|
||||||
isHoveredPreview?: boolean;
|
|
||||||
hoverMode?: boolean;
|
|
||||||
onHoverModeChange?: (enabled: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortBy = 'price' | 'size' | 'energy';
|
type SortBy = 'price' | 'size' | 'energy';
|
||||||
|
|
@ -26,9 +27,6 @@ export function PropertiesPane({
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
onClose,
|
onClose,
|
||||||
onNavigateToSource,
|
onNavigateToSource,
|
||||||
isHoveredPreview,
|
|
||||||
hoverMode,
|
|
||||||
onHoverModeChange,
|
|
||||||
}: PropertiesPaneProps) {
|
}: PropertiesPaneProps) {
|
||||||
const [sortBy, setSortBy] = useState<SortBy>('price');
|
const [sortBy, setSortBy] = useState<SortBy>('price');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|
@ -56,130 +54,52 @@ export function PropertiesPane({
|
||||||
}, [properties, sortBy, search]);
|
}, [properties, sortBy, search]);
|
||||||
|
|
||||||
if (!hexagonId) {
|
if (!hexagonId) {
|
||||||
return (
|
return <PaneEmptyState message="Click a hexagon to view properties" />;
|
||||||
<div className="flex items-center justify-center h-full text-warm-500 dark:text-warm-400">
|
|
||||||
Click a hexagon to view properties
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="p-4 border-b border-warm-200 dark:border-navy-700">
|
<PaneHeader
|
||||||
<div className="flex justify-between items-center">
|
title="Properties"
|
||||||
<div className="flex items-center gap-2">
|
subtitle={
|
||||||
<h2 className="text-lg font-semibold dark:text-warm-100">Properties</h2>
|
search.trim()
|
||||||
{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()
|
|
||||||
? `${filteredAndSorted.length} match${filteredAndSorted.length !== 1 ? 'es' : ''} in ${properties.length} loaded`
|
? `${filteredAndSorted.length} match${filteredAndSorted.length !== 1 ? 'es' : ''} in ${properties.length} loaded`
|
||||||
: `Showing ${properties.length} of ${total} properties`}
|
: `Showing ${properties.length} of ${total} properties`
|
||||||
</p>
|
}
|
||||||
{showInfo && (
|
onClose={onClose}
|
||||||
<InfoPopup
|
onInfoClick={() => setShowInfo(true)}
|
||||||
title="Property Data"
|
/>
|
||||||
onClose={() => setShowInfo(false)}
|
{showInfo && (
|
||||||
sourceLink={
|
<InfoPopup
|
||||||
onNavigateToSource
|
title="Property Data"
|
||||||
? {
|
onClose={() => setShowInfo(false)}
|
||||||
label: 'View data source',
|
sourceLink={
|
||||||
onClick: () => {
|
onNavigateToSource
|
||||||
onNavigateToSource('epc');
|
? {
|
||||||
setShowInfo(false);
|
label: 'View data source',
|
||||||
},
|
onClick: () => {
|
||||||
}
|
onNavigateToSource('epc');
|
||||||
: undefined
|
setShowInfo(false);
|
||||||
}
|
},
|
||||||
>
|
}
|
||||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
: undefined
|
||||||
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
|
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||||
recent sale price from the Land Registry.
|
Property data combines Energy Performance Certificates (EPC) with HM Land Registry
|
||||||
</p>
|
Price Paid records, fuzzy-matched by address within each postcode. Includes floor
|
||||||
</InfoPopup>
|
area, energy ratings, construction age, and tenure from EPC surveys, plus the most
|
||||||
)}
|
recent sale price from the Land Registry.
|
||||||
</div>
|
</p>
|
||||||
|
</InfoPopup>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="p-2 border-b border-warm-200 dark:border-navy-700 space-y-2">
|
<div className="p-2 border-b border-warm-200 dark:border-navy-700 space-y-2">
|
||||||
<input
|
<SearchInput
|
||||||
type="text"
|
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={setSearch}
|
||||||
placeholder="Search by address or postcode..."
|
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
|
<select
|
||||||
value={sortBy}
|
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 }) {
|
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 price = getNum(property, 'Last known price', 'latest_price');
|
||||||
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
|
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
|
||||||
const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area');
|
const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area');
|
||||||
|
|
@ -251,11 +158,11 @@ function PropertyCard({ property }: { property: Property }) {
|
||||||
|
|
||||||
{price !== undefined && (
|
{price !== undefined && (
|
||||||
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
|
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
|
||||||
£{fmt(price)}
|
£{formatNumber(price)}
|
||||||
{pricePerSqm !== undefined && (
|
{pricePerSqm !== undefined && (
|
||||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||||
{' '}
|
{' '}
|
||||||
(£{fmt(pricePerSqm)}/m²)
|
(£{formatNumber(pricePerSqm)}/m²)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -281,12 +188,12 @@ function PropertyCard({ property }: { property: Property }) {
|
||||||
)}
|
)}
|
||||||
{floorArea !== undefined && (
|
{floorArea !== undefined && (
|
||||||
<div>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{rooms !== undefined && (
|
{rooms !== undefined && (
|
||||||
<div>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{age !== undefined && (
|
{age !== undefined && (
|
||||||
|
|
@ -310,12 +217,12 @@ function PropertyCard({ property }: { property: Property }) {
|
||||||
{councilTax !== undefined ? (
|
{councilTax !== undefined ? (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-warm-500 dark:text-warm-400">Council tax:</span> £
|
<span className="text-warm-500 dark:text-warm-400">Council tax:</span> £
|
||||||
{fmt(councilTax)}/yr
|
{formatNumber(councilTax)}/yr
|
||||||
</div>
|
</div>
|
||||||
) : councilTaxD !== undefined ? (
|
) : councilTaxD !== undefined ? (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-warm-500 dark:text-warm-400">Council tax (D):</span> £
|
<span className="text-warm-500 dark:text-warm-400">Council tax (D):</span> £
|
||||||
{fmt(councilTaxD)}/yr
|
{formatNumber(councilTaxD)}/yr
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
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 INITIAL_RETRY_MS = 1000;
|
||||||
const MAX_RETRY_MS = 10000;
|
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>(
|
export async function fetchWithRetry<T>(
|
||||||
url: string,
|
url: string,
|
||||||
onSuccess: (data: T) => void,
|
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)}`;
|
if (value >= 1000) return approximate ? `~${Math.round(value)}` : `${Math.round(value)}`;
|
||||||
return Math.round(value).toString();
|
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 { 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
|
// Re-export constants for backwards compatibility
|
||||||
export const MAP_STYLE_LIGHT = '/api/tiles/style.json?theme=light';
|
export { FEATURE_GRADIENT as GRADIENT, DENSITY_GRADIENT, POSTCODE_ZOOM_THRESHOLD } from './consts';
|
||||||
export const MAP_STYLE_DARK = '/api/tiles/style.json?theme=dark';
|
|
||||||
|
|
||||||
export const GRADIENT: { t: number; color: [number, number, number] }[] = [
|
export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
||||||
{ t: 0, color: [46, 204, 113] },
|
const flavor = namedFlavor(theme);
|
||||||
{ t: 0.33, color: [241, 196, 15] },
|
// Use absolute URL for tiles - required by MapLibre
|
||||||
{ t: 0.66, color: [231, 76, 60] },
|
const tileUrl = `${window.location.origin}/api/tiles/{z}/{x}/{y}`;
|
||||||
{ t: 1, color: [142, 68, 173] },
|
return {
|
||||||
];
|
version: 8,
|
||||||
|
glyphs: GLYPHS_URL,
|
||||||
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
sprite: `${SPRITE_URL_BASE}/${theme}`,
|
||||||
{ t: 0, color: [130, 234, 220] },
|
sources: {
|
||||||
{ t: 0.5, color: [20, 140, 180] },
|
protomaps: {
|
||||||
{ t: 1, color: [88, 28, 140] },
|
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] {
|
export function normalizedToColor(t: number): [number, number, number] {
|
||||||
if (t <= 0) return GRADIENT[0].color;
|
if (t <= 0) return FEATURE_GRADIENT[0].color;
|
||||||
if (t >= 1) return GRADIENT[GRADIENT.length - 1].color;
|
if (t >= 1) return FEATURE_GRADIENT[FEATURE_GRADIENT.length - 1].color;
|
||||||
|
|
||||||
for (let i = 0; i < GRADIENT.length - 1; i++) {
|
for (let i = 0; i < FEATURE_GRADIENT.length - 1; i++) {
|
||||||
const lo = GRADIENT[i];
|
const lo = FEATURE_GRADIENT[i];
|
||||||
const hi = GRADIENT[i + 1];
|
const hi = FEATURE_GRADIENT[i + 1];
|
||||||
if (t >= lo.t && t <= hi.t) {
|
if (t >= lo.t && t <= hi.t) {
|
||||||
const frac = (t - lo.t) / (hi.t - lo.t);
|
const frac = (t - lo.t) / (hi.t - lo.t);
|
||||||
return [
|
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] {
|
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;
|
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 {
|
export function zoomToResolution(zoom: number): number {
|
||||||
if (zoom < 6) return 5;
|
for (const { maxZoom, resolution } of ZOOM_TO_RESOLUTION_THRESHOLDS) {
|
||||||
if (zoom < 7) return 6;
|
if (zoom < maxZoom) return resolution;
|
||||||
if (zoom < 9.5) return 8;
|
}
|
||||||
if (zoom < 11) return 9;
|
return ZOOM_TO_RESOLUTION_THRESHOLDS[ZOOM_TO_RESOLUTION_THRESHOLDS.length - 1].resolution;
|
||||||
if (zoom < 13) return 10;
|
|
||||||
if (zoom < 15) return 11;
|
|
||||||
return 12;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBoundsFromViewState(
|
export function getBoundsFromViewState(
|
||||||
|
|
@ -103,8 +115,6 @@ export function getBoundsFromViewState(
|
||||||
return { south, west, north, east };
|
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 {
|
export function emojiToTwemojiUrl(emoji: string): string {
|
||||||
const codePoint = emoji.codePointAt(0);
|
const codePoint = emoji.codePointAt(0);
|
||||||
if (!codePoint) return `${TWEMOJI_BASE}1f4cd.png`;
|
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;
|
[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 {
|
export interface Bounds {
|
||||||
south: number;
|
south: number;
|
||||||
west: number;
|
west: number;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue