Refactor map page
This commit is contained in:
parent
29d048ffd4
commit
d4d79f0d99
17 changed files with 1014 additions and 878 deletions
|
|
@ -1,40 +1,15 @@
|
||||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { trackPageview } from './usePlausible';
|
import { trackPageview } from './hooks/usePlausible';
|
||||||
import Map from './components/map/Map';
|
import MapPage from './components/map/MapPage';
|
||||||
import type { SearchedPostcode } from './components/map/PostcodeSearch';
|
|
||||||
import Filters from './components/map/Filters';
|
|
||||||
import POIPane from './components/map/POIPane';
|
|
||||||
import { PropertiesPane } from './components/map/PropertiesPane';
|
|
||||||
import AreaPane from './components/map/AreaPane';
|
|
||||||
import DataSources from './components/data-sources/DataSources';
|
|
||||||
import DataSourcesPage from './components/data-sources/DataSourcesPage';
|
import DataSourcesPage from './components/data-sources/DataSourcesPage';
|
||||||
import FAQPage from './components/faq/FAQPage';
|
import FAQPage from './components/faq/FAQPage';
|
||||||
import HomePage from './components/home/HomePage';
|
import HomePage from './components/home/HomePage';
|
||||||
import Header, { type Page } from './components/ui/Header';
|
import Header, { type Page } from './components/ui/Header';
|
||||||
import { TabButton } from './components/ui/TabButton';
|
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
|
||||||
import type {
|
import { fetchWithRetry, apiUrl } from './lib/api';
|
||||||
FeatureMeta,
|
import { parseUrlState } from './lib/url-state';
|
||||||
FeatureGroup,
|
import { INITIAL_VIEW_STATE } from './lib/consts';
|
||||||
FeatureFilters,
|
|
||||||
Bounds,
|
|
||||||
HexagonData,
|
|
||||||
PostcodeFeature,
|
|
||||||
ViewChangeParams,
|
|
||||||
ApiResponse,
|
|
||||||
POI,
|
|
||||||
POIResponse,
|
|
||||||
POICategoriesResponse,
|
|
||||||
POICategoryGroup,
|
|
||||||
Property,
|
|
||||||
HexagonPropertiesResponse,
|
|
||||||
HexagonStatsResponse,
|
|
||||||
NumericFeatureStats,
|
|
||||||
} from './types';
|
|
||||||
import { fetchWithRetry, buildFilterString, apiUrl, logNonAbortError } from './lib/api';
|
|
||||||
import { parseUrlState, DEFAULT_VIEW } from './lib/url-state';
|
|
||||||
import { POSTCODE_ZOOM_THRESHOLD } from './lib/map-utils';
|
|
||||||
import { useTheme } from './hooks/useTheme';
|
import { useTheme } from './hooks/useTheme';
|
||||||
import { useUrlSync } from './hooks/useUrlSync';
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|
@ -42,81 +17,22 @@ declare global {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEBOUNCE_MS = 150;
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const urlState = useMemo(() => parseUrlState(), []);
|
const urlState = useMemo(() => parseUrlState(), []);
|
||||||
|
const initialViewState = useMemo(() => urlState.viewState || INITIAL_VIEW_STATE, []);
|
||||||
|
|
||||||
const isScreenshotMode = useMemo(() => {
|
const isScreenshotMode = useMemo(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
return params.get('screenshot') === '1';
|
return params.get('screenshot') === '1';
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Core data
|
||||||
const [features, setFeatures] = useState<FeatureMeta[]>([]);
|
const [features, setFeatures] = useState<FeatureMeta[]>([]);
|
||||||
const [filters, setFilters] = useState<FeatureFilters>(urlState.filters || {});
|
|
||||||
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
|
||||||
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
|
||||||
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
|
|
||||||
const [rawData, setRawData] = useState<HexagonData[]>([]);
|
|
||||||
const [postcodeData, setPostcodeData] = useState<PostcodeFeature[]>([]);
|
|
||||||
const [dragData, setDragData] = useState<HexagonData[] | null>(null);
|
|
||||||
const [resolution, setResolution] = useState<number>(8);
|
|
||||||
const [bounds, setBounds] = useState<Bounds | null>(null);
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
const [zoom, setZoom] = useState<number>(urlState.viewState?.zoom || DEFAULT_VIEW.zoom);
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
|
||||||
const dragAbortRef = useRef<AbortController | null>(null);
|
|
||||||
|
|
||||||
const [currentView, setCurrentView] = useState<{
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
zoom: number;
|
|
||||||
} | null>(
|
|
||||||
urlState.viewState
|
|
||||||
? {
|
|
||||||
latitude: urlState.viewState.latitude,
|
|
||||||
longitude: urlState.viewState.longitude,
|
|
||||||
zoom: urlState.viewState.zoom,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
const initialViewState = useMemo(() => urlState.viewState || DEFAULT_VIEW, []);
|
|
||||||
|
|
||||||
const [pois, setPois] = useState<POI[]>([]);
|
|
||||||
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
|
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
|
||||||
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(
|
|
||||||
urlState.poiCategories || new Set()
|
|
||||||
);
|
|
||||||
const poiDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const poiAbortControllerRef = useRef<AbortController | null>(null);
|
|
||||||
|
|
||||||
const [selectedHexagon, setSelectedHexagon] = useState<{
|
|
||||||
id: string;
|
|
||||||
type: 'hexagon' | 'postcode';
|
|
||||||
resolution: number;
|
|
||||||
} | null>(null);
|
|
||||||
const [properties, setProperties] = useState<Property[]>([]);
|
|
||||||
const [propertiesTotal, setPropertiesTotal] = useState(0);
|
|
||||||
const [propertiesOffset, setPropertiesOffset] = useState(0);
|
|
||||||
const [loadingProperties, setLoadingProperties] = useState(false);
|
|
||||||
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties' | 'area'>(
|
|
||||||
urlState.tab || 'pois'
|
|
||||||
);
|
|
||||||
|
|
||||||
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
|
|
||||||
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
|
|
||||||
|
|
||||||
const [leftPaneWidth, setLeftPaneWidth] = useState(384); // 24rem = 384px
|
|
||||||
const [rightPaneWidth, setRightPaneWidth] = useState(288); // 18rem = 288px
|
|
||||||
const leftDraggingRef = useRef(false);
|
|
||||||
const rightDraggingRef = useRef(false);
|
|
||||||
|
|
||||||
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
|
|
||||||
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null);
|
|
||||||
const [initialLoading, setInitialLoading] = useState(true);
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(null);
|
||||||
const [activePage, setActivePage] = useState<Page>(() => {
|
const [activePage, setActivePage] = useState<Page>(() => {
|
||||||
if (isScreenshotMode) return 'dashboard';
|
if (isScreenshotMode) return 'dashboard';
|
||||||
if (window.history.state?.page) return window.history.state.page;
|
if (window.history.state?.page) return window.history.state.page;
|
||||||
|
|
@ -126,59 +42,9 @@ export default function App() {
|
||||||
: 'home';
|
: 'home';
|
||||||
});
|
});
|
||||||
|
|
||||||
const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => {
|
|
||||||
if (infoFeature) {
|
|
||||||
window.history.replaceState({ ...window.history.state, infoFeature }, '');
|
|
||||||
}
|
|
||||||
const url = hash
|
|
||||||
? `${window.location.pathname}${window.location.search}#${hash}`
|
|
||||||
: `${window.location.pathname}${window.location.search}`;
|
|
||||||
window.history.pushState({ page }, '', url);
|
|
||||||
setActivePage(page);
|
|
||||||
trackPageview();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!window.history.state?.page) {
|
|
||||||
window.history.replaceState({ page: activePage }, '');
|
|
||||||
}
|
|
||||||
const handlePopState = (e: PopStateEvent) => {
|
|
||||||
if (e.state?.page) {
|
|
||||||
setActivePage(e.state.page);
|
|
||||||
if (e.state.infoFeature) {
|
|
||||||
setPendingInfoFeature(e.state.infoFeature);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('popstate', handlePopState);
|
|
||||||
return () => window.removeEventListener('popstate', handlePopState);
|
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
useEffect(() => {
|
// Load features and POI categories on mount
|
||||||
if (isScreenshotMode && !initialLoading && rawData.length > 0) {
|
|
||||||
window.__og_ready = true;
|
|
||||||
}
|
|
||||||
}, [isScreenshotMode, initialLoading, rawData]);
|
|
||||||
|
|
||||||
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
|
|
||||||
|
|
||||||
const viewFeature = activeFeature || pinnedFeature;
|
|
||||||
const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null;
|
|
||||||
|
|
||||||
const filterRange = useMemo((): [number, number] | null => {
|
|
||||||
if (!viewFeature) return null;
|
|
||||||
if (activeFeature && dragValue) return dragValue;
|
|
||||||
const filterVal = filters[viewFeature];
|
|
||||||
if (filterVal && typeof filterVal[0] === 'number') return filterVal as [number, number];
|
|
||||||
return null;
|
|
||||||
}, [viewFeature, activeFeature, dragValue, filters]);
|
|
||||||
|
|
||||||
useUrlSync(currentView, filters, features, selectedPOICategories, rightPaneTab);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let featuresLoaded = false;
|
let featuresLoaded = false;
|
||||||
|
|
@ -214,486 +80,58 @@ export default function App() {
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const buildFilterParam = useCallback(
|
// Screenshot mode ready signal
|
||||||
(): string => buildFilterString(filters, features),
|
useEffect(() => {
|
||||||
[filters, features]
|
if (isScreenshotMode && !initialLoading && features.length > 0) {
|
||||||
);
|
window.__og_ready = true;
|
||||||
|
}
|
||||||
|
}, [isScreenshotMode, initialLoading, features]);
|
||||||
|
|
||||||
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
|
// Navigation
|
||||||
|
const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => {
|
||||||
|
if (infoFeature) {
|
||||||
|
window.history.replaceState({ ...window.history.state, infoFeature }, '');
|
||||||
|
}
|
||||||
|
const url = hash
|
||||||
|
? `${window.location.pathname}${window.location.search}#${hash}`
|
||||||
|
: `${window.location.pathname}${window.location.search}`;
|
||||||
|
window.history.pushState({ page }, '', url);
|
||||||
|
setActivePage(page);
|
||||||
|
trackPageview();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bounds) return;
|
if (!window.history.state?.page) {
|
||||||
|
window.history.replaceState({ page: activePage }, '');
|
||||||
if (debounceRef.current) {
|
|
||||||
clearTimeout(debounceRef.current);
|
|
||||||
}
|
}
|
||||||
|
const handlePopState = (e: PopStateEvent) => {
|
||||||
debounceRef.current = setTimeout(async () => {
|
if (e.state?.page) {
|
||||||
if (abortControllerRef.current) {
|
setActivePage(e.state.page);
|
||||||
abortControllerRef.current.abort();
|
if (e.state.infoFeature) {
|
||||||
|
setPendingInfoFeature(e.state.infoFeature);
|
||||||
}
|
}
|
||||||
abortControllerRef.current = new AbortController();
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
|
||||||
const filtersStr = buildFilterParam();
|
|
||||||
|
|
||||||
if (usePostcodeView) {
|
|
||||||
// Fetch postcode polygons for high zoom levels
|
|
||||||
const params = new URLSearchParams({ bounds: boundsStr });
|
|
||||||
if (filtersStr) params.set('filters', filtersStr);
|
|
||||||
if (viewFeature) {
|
|
||||||
params.set('fields', viewFeature);
|
|
||||||
} else {
|
|
||||||
params.set('fields', '');
|
|
||||||
}
|
|
||||||
const res = await fetch(apiUrl('postcodes', params), {
|
|
||||||
signal: abortControllerRef.current.signal,
|
|
||||||
});
|
|
||||||
const json: { features: PostcodeFeature[] } = await res.json();
|
|
||||||
setPostcodeData(json.features || []);
|
|
||||||
setRawData([]); // Clear hexagon data
|
|
||||||
} else {
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logNonAbortError('Failed to fetch data', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, DEBOUNCE_MS);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (debounceRef.current) {
|
|
||||||
clearTimeout(debounceRef.current);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView]);
|
window.addEventListener('popstate', handlePopState);
|
||||||
|
return () => window.removeEventListener('popstate', handlePopState);
|
||||||
const data = dragData ?? rawData;
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Only use min_<feature> values since that's what hexagon coloring uses
|
|
||||||
let min = Infinity;
|
|
||||||
let max = -Infinity;
|
|
||||||
|
|
||||||
if (usePostcodeView) {
|
|
||||||
if (postcodeData.length === 0) return null;
|
|
||||||
for (const feat of postcodeData) {
|
|
||||||
const val = feat.properties[`min_${viewFeature}`];
|
|
||||||
if (typeof val === 'number' && !isNaN(val)) {
|
|
||||||
min = Math.min(min, val);
|
|
||||||
max = Math.max(max, val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (data.length === 0) return null;
|
|
||||||
for (const item of data) {
|
|
||||||
const val = item[`min_${viewFeature}`];
|
|
||||||
if (typeof val === 'number' && !isNaN(val)) {
|
|
||||||
min = Math.min(min, val);
|
|
||||||
max = Math.max(max, val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (min === Infinity || max === -Infinity) return null;
|
|
||||||
return [min, max];
|
|
||||||
}, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature]);
|
|
||||||
|
|
||||||
// Color range for the legend and hex coloring - uses actual data range when available
|
|
||||||
const colorRange = useMemo((): [number, number] | null => {
|
|
||||||
if (!viewFeature) return null;
|
|
||||||
const meta = features.find((f) => f.name === viewFeature);
|
|
||||||
if (!meta) return null;
|
|
||||||
// For enum features: use [0, numValues-1]
|
|
||||||
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
|
|
||||||
return [0, meta.values.length - 1];
|
|
||||||
}
|
|
||||||
// Use actual data range when available (shows actual min/max on the map)
|
|
||||||
if (dataRange) return dataRange;
|
|
||||||
// During drag when data hasn't loaded yet, use dragValue as preview
|
|
||||||
if (activeFeature && dragValue) return dragValue;
|
|
||||||
// Fallback to full feature range
|
|
||||||
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
|
|
||||||
return null;
|
|
||||||
}, [viewFeature, features, dataRange, activeFeature, dragValue]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!bounds || selectedPOICategories.size === 0) {
|
|
||||||
setPois([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (poiDebounceRef.current) {
|
|
||||||
clearTimeout(poiDebounceRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
poiDebounceRef.current = setTimeout(async () => {
|
|
||||||
if (poiAbortControllerRef.current) {
|
|
||||||
poiAbortControllerRef.current.abort();
|
|
||||||
}
|
|
||||||
poiAbortControllerRef.current = new AbortController();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
|
||||||
const categoriesStr = Array.from(selectedPOICategories).join(',');
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
categories: categoriesStr,
|
|
||||||
bounds: boundsStr,
|
|
||||||
});
|
|
||||||
const res = await fetch(apiUrl('pois', params), {
|
|
||||||
signal: poiAbortControllerRef.current.signal,
|
|
||||||
});
|
|
||||||
const json: POIResponse = await res.json();
|
|
||||||
setPois(json.pois || []);
|
|
||||||
} catch (err) {
|
|
||||||
logNonAbortError('Failed to fetch POIs', err);
|
|
||||||
}
|
|
||||||
}, DEBOUNCE_MS);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (poiDebounceRef.current) {
|
|
||||||
clearTimeout(poiDebounceRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [bounds, selectedPOICategories]);
|
|
||||||
|
|
||||||
const prevBoundsRef = useRef<string>('');
|
|
||||||
const handleViewChange = useCallback(
|
|
||||||
({
|
|
||||||
resolution: newRes,
|
|
||||||
bounds: newBounds,
|
|
||||||
zoom: newZoom,
|
|
||||||
latitude,
|
|
||||||
longitude,
|
|
||||||
}: ViewChangeParams) => {
|
|
||||||
const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
|
|
||||||
if (boundsKey !== prevBoundsRef.current) {
|
|
||||||
prevBoundsRef.current = boundsKey;
|
|
||||||
setResolution(newRes);
|
|
||||||
setBounds(newBounds);
|
|
||||||
}
|
|
||||||
setZoom(newZoom);
|
|
||||||
setCurrentView({ latitude, longitude, zoom: newZoom });
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAddFilter = useCallback(
|
|
||||||
(name: string) => {
|
|
||||||
const meta = features.find((f) => f.name === name);
|
|
||||||
if (!meta) return;
|
|
||||||
if (meta.type === 'enum' && meta.values) {
|
|
||||||
setFilters((prev) => ({ ...prev, [name]: [...meta.values!] }));
|
|
||||||
} else if (meta.min != null && meta.max != null) {
|
|
||||||
setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] }));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[features]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFilterChange = useCallback((name: string, value: [number, number] | string[]) => {
|
|
||||||
setFilters((prev) => ({ ...prev, [name]: value }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRemoveFilter = useCallback((name: string) => {
|
|
||||||
setFilters((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[name];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setPinnedFeature((prev) => (prev === name ? null : prev));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDragStart = useCallback(
|
|
||||||
(name: string) => {
|
|
||||||
const meta = features.find((f) => f.name === name);
|
|
||||||
if (meta?.type === 'enum') return;
|
|
||||||
setActiveFeature(name);
|
|
||||||
const fval = filters[name];
|
|
||||||
setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null);
|
|
||||||
|
|
||||||
if (!bounds) return;
|
|
||||||
if (dragAbortRef.current) dragAbortRef.current.abort();
|
|
||||||
dragAbortRef.current = new AbortController();
|
|
||||||
|
|
||||||
const otherFilters = Object.entries(filters).filter(([k]) => k !== name);
|
|
||||||
let filtersStr = '';
|
|
||||||
if (otherFilters.length > 0) {
|
|
||||||
filtersStr = otherFilters
|
|
||||||
.map(([n, value]) => {
|
|
||||||
const m = features.find((f) => f.name === n);
|
|
||||||
if (m?.type === 'enum') return `${n}:${(value as string[]).join('|')}`;
|
|
||||||
const [min, max] = value as [number, number];
|
|
||||||
return `${n}:${min}:${max}`;
|
|
||||||
})
|
|
||||||
.join(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
|
||||||
const params = new URLSearchParams({ resolution: resolution.toString(), bounds: boundsStr });
|
|
||||||
if (filtersStr) params.set('filters', filtersStr);
|
|
||||||
params.set('fields', name);
|
|
||||||
|
|
||||||
fetch(apiUrl('hexagons', params), {
|
|
||||||
signal: dragAbortRef.current.signal,
|
|
||||||
})
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((json: ApiResponse) => setDragData(json.features || []))
|
|
||||||
.catch((err) => logNonAbortError('Failed to fetch drag data', err));
|
|
||||||
},
|
|
||||||
[filters, features, bounds, resolution]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDragChange = useCallback((value: [number, number]) => {
|
|
||||||
setDragValue(value);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(() => {
|
|
||||||
if (activeFeature && dragValue) {
|
|
||||||
setFilters((prev) => ({ ...prev, [activeFeature]: dragValue }));
|
|
||||||
}
|
|
||||||
setActiveFeature(null);
|
|
||||||
setDragValue(null);
|
|
||||||
setDragData(null);
|
|
||||||
if (dragAbortRef.current) {
|
|
||||||
dragAbortRef.current.abort();
|
|
||||||
dragAbortRef.current = null;
|
|
||||||
}
|
|
||||||
}, [activeFeature, dragValue]);
|
|
||||||
|
|
||||||
const handleTogglePin = useCallback((name: string) => {
|
|
||||||
setPinnedFeature((prev) => (prev === name ? null : name));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCancelPin = useCallback(() => {
|
|
||||||
setPinnedFeature(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchHexagonStats = useCallback(
|
|
||||||
async (h3: string, res: number, signal?: AbortSignal, fields?: string[]) => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
h3,
|
|
||||||
resolution: res.toString(),
|
|
||||||
});
|
|
||||||
const filterStr = buildFilterString(filters, features);
|
|
||||||
if (filterStr) params.append('filters', filterStr);
|
|
||||||
if (fields) {
|
|
||||||
params.set('fields', fields.join(','));
|
|
||||||
}
|
|
||||||
const response = await fetch(apiUrl('hexagon-stats', params), { signal });
|
|
||||||
return (await response.json()) as HexagonStatsResponse;
|
|
||||||
},
|
|
||||||
[filters, features]
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Build stats from already-loaded PostcodeFeature (min/max per feature). */
|
|
||||||
const buildPostcodeStats = useCallback(
|
|
||||||
(postcode: string): HexagonStatsResponse | null => {
|
|
||||||
const feat = postcodeData.find((f) => f.properties.postcode === postcode);
|
|
||||||
if (!feat) return null;
|
|
||||||
const props = feat.properties;
|
|
||||||
|
|
||||||
const numeric_features: NumericFeatureStats[] = [];
|
|
||||||
for (const f of features) {
|
|
||||||
if (f.type !== 'numeric') continue;
|
|
||||||
const minVal = props[`min_${f.name}`];
|
|
||||||
const maxVal = props[`max_${f.name}`];
|
|
||||||
if (typeof minVal !== 'number' || typeof maxVal !== 'number') continue;
|
|
||||||
numeric_features.push({
|
|
||||||
name: f.name,
|
|
||||||
count: props.count,
|
|
||||||
min: minVal,
|
|
||||||
max: maxVal,
|
|
||||||
mean: (minVal + maxVal) / 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { count: props.count, numeric_features, enum_features: [] };
|
|
||||||
},
|
|
||||||
[postcodeData, features]
|
|
||||||
);
|
|
||||||
|
|
||||||
const fetchHexagonProperties = useCallback(
|
|
||||||
async (h3: string, res: number, offset = 0) => {
|
|
||||||
setLoadingProperties(true);
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
h3,
|
|
||||||
resolution: res.toString(),
|
|
||||||
limit: '100',
|
|
||||||
offset: offset.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const filterStr = buildFilterString(filters, features);
|
|
||||||
if (filterStr) params.append('filters', filterStr);
|
|
||||||
|
|
||||||
const response = await fetch(apiUrl('hexagon-properties', params));
|
|
||||||
const data: HexagonPropertiesResponse = await response.json();
|
|
||||||
|
|
||||||
if (offset === 0) {
|
|
||||||
setProperties(data.properties);
|
|
||||||
} else {
|
|
||||||
setProperties((prev) => [...prev, ...data.properties]);
|
|
||||||
}
|
|
||||||
setPropertiesTotal(data.total);
|
|
||||||
setPropertiesOffset(offset + data.properties.length);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch properties:', err);
|
|
||||||
} finally {
|
|
||||||
setLoadingProperties(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[filters, features]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleHexagonClick = useCallback(
|
|
||||||
(id: string, isPostcode = false) => {
|
|
||||||
if (selectedHexagon?.id === id) {
|
|
||||||
setSelectedHexagon(null);
|
|
||||||
setProperties([]);
|
|
||||||
setAreaStats(null);
|
|
||||||
} else {
|
|
||||||
const type = isPostcode ? 'postcode' : 'hexagon';
|
|
||||||
setSelectedHexagon({ id, type, resolution });
|
|
||||||
setProperties([]);
|
|
||||||
setPropertiesTotal(0);
|
|
||||||
setPropertiesOffset(0);
|
|
||||||
setRightPaneTab('area');
|
|
||||||
|
|
||||||
if (isPostcode) {
|
|
||||||
setAreaStats(buildPostcodeStats(id));
|
|
||||||
setLoadingAreaStats(false);
|
|
||||||
} else {
|
|
||||||
setLoadingAreaStats(true);
|
|
||||||
fetchHexagonStats(id, resolution)
|
|
||||||
.then((stats) => setAreaStats(stats))
|
|
||||||
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
|
|
||||||
.finally(() => setLoadingAreaStats(false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[selectedHexagon, resolution, fetchHexagonStats, buildPostcodeStats]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleHexagonHover = useCallback((h3: string | null) => {
|
|
||||||
setHoveredHexagon(h3);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleViewPropertiesFromArea = useCallback(() => {
|
|
||||||
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
|
|
||||||
setRightPaneTab('properties');
|
|
||||||
setPropertiesOffset(0);
|
|
||||||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
|
||||||
}
|
|
||||||
}, [selectedHexagon, fetchHexagonProperties]);
|
|
||||||
|
|
||||||
const handlePropertiesTabClick = useCallback(() => {
|
|
||||||
setRightPaneTab('properties');
|
|
||||||
if (selectedHexagon?.type === 'hexagon' && properties.length === 0 && !loadingProperties) {
|
|
||||||
setPropertiesOffset(0);
|
|
||||||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
|
||||||
}
|
|
||||||
}, [selectedHexagon, properties.length, loadingProperties, fetchHexagonProperties]);
|
|
||||||
|
|
||||||
const handleLoadMoreProperties = useCallback(() => {
|
|
||||||
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
|
|
||||||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, propertiesOffset);
|
|
||||||
}
|
|
||||||
}, [selectedHexagon, propertiesOffset, fetchHexagonProperties]);
|
|
||||||
|
|
||||||
const handleCloseProperties = useCallback(() => {
|
|
||||||
setSelectedHexagon(null);
|
|
||||||
setProperties([]);
|
|
||||||
setAreaStats(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Left pane resize handlers
|
|
||||||
const handleLeftSeparatorPointerDown = useCallback((e: React.PointerEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
||||||
leftDraggingRef.current = true;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLeftSeparatorPointerMove = useCallback((e: React.PointerEvent) => {
|
|
||||||
if (!leftDraggingRef.current) return;
|
|
||||||
const newWidth = Math.min(600, Math.max(200, e.clientX));
|
|
||||||
setLeftPaneWidth(newWidth);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLeftSeparatorPointerUp = useCallback(() => {
|
|
||||||
leftDraggingRef.current = false;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Right pane resize handlers
|
|
||||||
const handleRightSeparatorPointerDown = useCallback((e: React.PointerEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
||||||
rightDraggingRef.current = true;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRightSeparatorPointerMove = useCallback((e: React.PointerEvent) => {
|
|
||||||
if (!rightDraggingRef.current) return;
|
|
||||||
const newWidth = Math.min(500, Math.max(200, window.innerWidth - e.clientX));
|
|
||||||
setRightPaneWidth(newWidth);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRightSeparatorPointerUp = useCallback(() => {
|
|
||||||
rightDraggingRef.current = false;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isScreenshotMode) {
|
if (isScreenshotMode) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen">
|
<MapPage
|
||||||
<Map
|
|
||||||
data={data}
|
|
||||||
postcodeData={postcodeData}
|
|
||||||
usePostcodeView={usePostcodeView}
|
|
||||||
pois={pois}
|
|
||||||
onViewChange={handleViewChange}
|
|
||||||
viewFeature={viewFeature}
|
|
||||||
colorRange={colorRange}
|
|
||||||
filterRange={filterRange}
|
|
||||||
viewSource={viewSource}
|
|
||||||
onCancelPin={handleCancelPin}
|
|
||||||
features={features}
|
features={features}
|
||||||
selectedHexagonId={null}
|
poiCategoryGroups={poiCategoryGroups}
|
||||||
hoveredHexagonId={null}
|
initialFilters={urlState.filters || {}}
|
||||||
onHexagonClick={() => {}}
|
|
||||||
onHexagonHover={() => {}}
|
|
||||||
initialViewState={initialViewState}
|
initialViewState={initialViewState}
|
||||||
|
initialPOICategories={urlState.poiCategories || new Set()}
|
||||||
|
initialTab={urlState.tab || 'pois'}
|
||||||
|
initialLoading={initialLoading}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
pendingInfoFeature={null}
|
||||||
|
onClearPendingInfoFeature={() => {}}
|
||||||
|
onNavigateTo={() => {}}
|
||||||
screenshotMode
|
screenshotMode
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -712,189 +150,19 @@ export default function App() {
|
||||||
) : activePage === 'faq' ? (
|
) : activePage === 'faq' ? (
|
||||||
<FAQPage />
|
<FAQPage />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex overflow-hidden relative">
|
<MapPage
|
||||||
{initialLoading && (
|
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<svg
|
|
||||||
className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
|
|
||||||
Connecting to server...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
|
|
||||||
style={{ width: leftPaneWidth }}
|
|
||||||
>
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
|
||||||
<Filters
|
|
||||||
features={features}
|
features={features}
|
||||||
filters={filters}
|
poiCategoryGroups={poiCategoryGroups}
|
||||||
activeFeature={activeFeature}
|
initialFilters={urlState.filters || {}}
|
||||||
dragValue={dragValue}
|
|
||||||
enabledFeatures={enabledFeatures}
|
|
||||||
onAddFilter={handleAddFilter}
|
|
||||||
onRemoveFilter={handleRemoveFilter}
|
|
||||||
onFilterChange={handleFilterChange}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragChange={handleDragChange}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
zoom={zoom}
|
|
||||||
itemCount={usePostcodeView ? postcodeData.length : data.length}
|
|
||||||
usePostcodeView={usePostcodeView}
|
|
||||||
pinnedFeature={pinnedFeature}
|
|
||||||
onTogglePin={handleTogglePin}
|
|
||||||
onCancelPin={handleCancelPin}
|
|
||||||
onNavigateToSource={(slug, featureName) => {
|
|
||||||
navigateTo('data-sources', slug, featureName);
|
|
||||||
}}
|
|
||||||
openInfoFeature={pendingInfoFeature}
|
|
||||||
onClearOpenInfoFeature={() => setPendingInfoFeature(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
|
||||||
onPointerDown={handleLeftSeparatorPointerDown}
|
|
||||||
onPointerMove={handleLeftSeparatorPointerMove}
|
|
||||||
onPointerUp={handleLeftSeparatorPointerUp}
|
|
||||||
>
|
|
||||||
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 relative">
|
|
||||||
<Map
|
|
||||||
data={data}
|
|
||||||
postcodeData={postcodeData}
|
|
||||||
usePostcodeView={usePostcodeView}
|
|
||||||
pois={pois}
|
|
||||||
onViewChange={handleViewChange}
|
|
||||||
viewFeature={viewFeature}
|
|
||||||
colorRange={colorRange}
|
|
||||||
filterRange={filterRange}
|
|
||||||
viewSource={viewSource}
|
|
||||||
onCancelPin={handleCancelPin}
|
|
||||||
features={features}
|
|
||||||
selectedHexagonId={selectedHexagon?.id || null}
|
|
||||||
hoveredHexagonId={hoveredHexagon}
|
|
||||||
onHexagonClick={handleHexagonClick}
|
|
||||||
onHexagonHover={handleHexagonHover}
|
|
||||||
initialViewState={initialViewState}
|
initialViewState={initialViewState}
|
||||||
|
initialPOICategories={urlState.poiCategories || new Set()}
|
||||||
|
initialTab={urlState.tab || 'pois'}
|
||||||
|
initialLoading={initialLoading}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
filters={filters}
|
pendingInfoFeature={pendingInfoFeature}
|
||||||
searchedPostcode={searchedPostcode}
|
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
|
||||||
onPostcodeSearched={setSearchedPostcode}
|
onNavigateTo={navigateTo}
|
||||||
/>
|
/>
|
||||||
{loading && (
|
|
||||||
<div className="absolute bottom-4 left-4 bg-white dark:bg-navy-800 dark:text-warm-200 px-3 py-1 rounded shadow">
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<DataSources onNavigate={() => navigateTo('data-sources')} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
|
|
||||||
style={{ width: rightPaneWidth }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
|
||||||
onPointerDown={handleRightSeparatorPointerDown}
|
|
||||||
onPointerMove={handleRightSeparatorPointerMove}
|
|
||||||
onPointerUp={handleRightSeparatorPointerUp}
|
|
||||||
>
|
|
||||||
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
|
||||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
|
||||||
<TabButton
|
|
||||||
label="Area"
|
|
||||||
isActive={rightPaneTab === 'area'}
|
|
||||||
onClick={() => setRightPaneTab('area')}
|
|
||||||
/>
|
|
||||||
<TabButton
|
|
||||||
label="Properties"
|
|
||||||
isActive={rightPaneTab === 'properties'}
|
|
||||||
onClick={handlePropertiesTabClick}
|
|
||||||
/>
|
|
||||||
<TabButton
|
|
||||||
label="POIs"
|
|
||||||
isActive={rightPaneTab === 'pois'}
|
|
||||||
onClick={() => setRightPaneTab('pois')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
{rightPaneTab === 'area' ? (
|
|
||||||
<AreaPane
|
|
||||||
stats={areaStats}
|
|
||||||
globalFeatures={features}
|
|
||||||
loading={loadingAreaStats}
|
|
||||||
hexagonId={selectedHexagon?.id || null}
|
|
||||||
isPostcode={selectedHexagon?.type === 'postcode'}
|
|
||||||
postcodeData={
|
|
||||||
selectedHexagon?.type === 'postcode'
|
|
||||||
? postcodeData.find((f) => f.properties.postcode === selectedHexagon.id) || null
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
onViewProperties={handleViewPropertiesFromArea}
|
|
||||||
onClose={handleCloseProperties}
|
|
||||||
hexagonLocation={(() => {
|
|
||||||
const hexId = selectedHexagon?.id;
|
|
||||||
const hex = hexId ? data.find((d) => d.h3 === hexId) : null;
|
|
||||||
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number')
|
|
||||||
return null;
|
|
||||||
return {
|
|
||||||
lat: hex.lat as number,
|
|
||||||
lon: hex.lon as number,
|
|
||||||
resolution,
|
|
||||||
};
|
|
||||||
})()}
|
|
||||||
filters={filters}
|
|
||||||
onNavigateToSource={(slug, featureName) => {
|
|
||||||
navigateTo('data-sources', slug, featureName);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : rightPaneTab === 'properties' ? (
|
|
||||||
<PropertiesPane
|
|
||||||
properties={properties}
|
|
||||||
total={propertiesTotal}
|
|
||||||
loading={loadingProperties}
|
|
||||||
hexagonId={selectedHexagon?.id || null}
|
|
||||||
onLoadMore={handleLoadMoreProperties}
|
|
||||||
onClose={handleCloseProperties}
|
|
||||||
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<POIPane
|
|
||||||
groups={poiCategoryGroups}
|
|
||||||
selectedCategories={selectedPOICategories}
|
|
||||||
onCategoriesChange={setSelectedPOICategories}
|
|
||||||
poiCount={pois.length}
|
|
||||||
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
283
frontend/src/components/map/MapPage.tsx
Normal file
283
frontend/src/components/map/MapPage.tsx
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState } from '../../types';
|
||||||
|
import type { SearchedPostcode } from './PostcodeSearch';
|
||||||
|
import type { Page } from '../ui/Header';
|
||||||
|
import Map from './Map';
|
||||||
|
import Filters from './Filters';
|
||||||
|
import POIPane from './POIPane';
|
||||||
|
import { PropertiesPane } from './PropertiesPane';
|
||||||
|
import AreaPane from './AreaPane';
|
||||||
|
import DataSources from '../data-sources/DataSources';
|
||||||
|
import { TabButton } from '../ui/TabButton';
|
||||||
|
import { useMapData } from '../../hooks/useMapData';
|
||||||
|
import { usePOIData } from '../../hooks/usePOIData';
|
||||||
|
import { useFilters } from '../../hooks/useFilters';
|
||||||
|
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
|
||||||
|
import { usePaneResize } from '../../hooks/usePaneResize';
|
||||||
|
|
||||||
|
interface MapPageProps {
|
||||||
|
features: FeatureMeta[];
|
||||||
|
poiCategoryGroups: POICategoryGroup[];
|
||||||
|
initialFilters: FeatureFilters;
|
||||||
|
initialViewState: ViewState;
|
||||||
|
initialPOICategories: Set<string>;
|
||||||
|
initialTab: 'pois' | 'properties' | 'area';
|
||||||
|
initialLoading: boolean;
|
||||||
|
theme: 'light' | 'dark';
|
||||||
|
pendingInfoFeature: string | null;
|
||||||
|
onClearPendingInfoFeature: () => void;
|
||||||
|
onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void;
|
||||||
|
screenshotMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MapPage({
|
||||||
|
features,
|
||||||
|
poiCategoryGroups,
|
||||||
|
initialFilters,
|
||||||
|
initialViewState,
|
||||||
|
initialPOICategories,
|
||||||
|
initialTab,
|
||||||
|
initialLoading,
|
||||||
|
theme,
|
||||||
|
pendingInfoFeature,
|
||||||
|
onClearPendingInfoFeature,
|
||||||
|
onNavigateTo,
|
||||||
|
screenshotMode,
|
||||||
|
}: MapPageProps) {
|
||||||
|
if (screenshotMode) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen">
|
||||||
|
<Map
|
||||||
|
data={[]}
|
||||||
|
postcodeData={[]}
|
||||||
|
usePostcodeView={false}
|
||||||
|
pois={[]}
|
||||||
|
onViewChange={() => {}}
|
||||||
|
viewFeature={null}
|
||||||
|
colorRange={null}
|
||||||
|
filterRange={null}
|
||||||
|
viewSource={null}
|
||||||
|
onCancelPin={() => {}}
|
||||||
|
features={features}
|
||||||
|
selectedHexagonId={null}
|
||||||
|
hoveredHexagonId={null}
|
||||||
|
onHexagonClick={() => {}}
|
||||||
|
onHexagonHover={() => {}}
|
||||||
|
initialViewState={initialViewState}
|
||||||
|
theme={theme}
|
||||||
|
screenshotMode
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null);
|
||||||
|
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(initialPOICategories);
|
||||||
|
|
||||||
|
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left');
|
||||||
|
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(288, 200, 500, 'right');
|
||||||
|
|
||||||
|
// Initialize filters first
|
||||||
|
const {
|
||||||
|
filters,
|
||||||
|
activeFeature,
|
||||||
|
dragValue,
|
||||||
|
dragData,
|
||||||
|
pinnedFeature,
|
||||||
|
enabledFeatures,
|
||||||
|
viewFeature,
|
||||||
|
viewSource,
|
||||||
|
filterRange,
|
||||||
|
handleAddFilter,
|
||||||
|
handleFilterChange,
|
||||||
|
handleRemoveFilter,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragChange,
|
||||||
|
handleDragEnd,
|
||||||
|
handleTogglePin,
|
||||||
|
handleCancelPin,
|
||||||
|
updateBoundsInfo,
|
||||||
|
} = useFilters({
|
||||||
|
initialFilters,
|
||||||
|
features,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map data hook
|
||||||
|
const mapData = useMapData({
|
||||||
|
filters,
|
||||||
|
features,
|
||||||
|
viewFeature,
|
||||||
|
activeFeature,
|
||||||
|
dragValue,
|
||||||
|
dragData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep filter bounds in sync with map data
|
||||||
|
useEffect(() => {
|
||||||
|
updateBoundsInfo(mapData.bounds, mapData.resolution);
|
||||||
|
}, [mapData.bounds, mapData.resolution, updateBoundsInfo]);
|
||||||
|
|
||||||
|
// Hexagon selection hook
|
||||||
|
const selection = useHexagonSelection({
|
||||||
|
filters,
|
||||||
|
features,
|
||||||
|
postcodeData: mapData.postcodeData,
|
||||||
|
resolution: mapData.resolution,
|
||||||
|
});
|
||||||
|
|
||||||
|
// POI data
|
||||||
|
const pois = usePOIData(mapData.bounds, selectedPOICategories);
|
||||||
|
|
||||||
|
// Set initial view and tab from URL state
|
||||||
|
useEffect(() => {
|
||||||
|
mapData.setInitialView(initialViewState);
|
||||||
|
selection.setRightPaneTab(initialTab);
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Compute hexagon location for external links
|
||||||
|
const hexagonLocation = useMemo(() => {
|
||||||
|
const hexId = selection.selectedHexagon?.id;
|
||||||
|
const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null;
|
||||||
|
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
|
||||||
|
return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution };
|
||||||
|
}, [selection.selectedHexagon?.id, mapData.data, mapData.resolution]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex overflow-hidden relative">
|
||||||
|
{initialLoading && (
|
||||||
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<svg className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">Connecting to server...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Left Pane */}
|
||||||
|
<div className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden" style={{ width: leftPaneWidth }}>
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<Filters
|
||||||
|
features={features}
|
||||||
|
filters={filters}
|
||||||
|
activeFeature={activeFeature}
|
||||||
|
dragValue={dragValue}
|
||||||
|
enabledFeatures={enabledFeatures}
|
||||||
|
onAddFilter={handleAddFilter}
|
||||||
|
onRemoveFilter={handleRemoveFilter}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragChange={handleDragChange}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
zoom={mapData.zoom}
|
||||||
|
itemCount={mapData.usePostcodeView ? mapData.postcodeData.length : mapData.data.length}
|
||||||
|
usePostcodeView={mapData.usePostcodeView}
|
||||||
|
pinnedFeature={pinnedFeature}
|
||||||
|
onTogglePin={handleTogglePin}
|
||||||
|
onCancelPin={handleCancelPin}
|
||||||
|
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
|
||||||
|
openInfoFeature={pendingInfoFeature}
|
||||||
|
onClearOpenInfoFeature={onClearPendingInfoFeature}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||||
|
{...leftPaneHandlers}
|
||||||
|
>
|
||||||
|
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Map
|
||||||
|
data={mapData.data}
|
||||||
|
postcodeData={mapData.postcodeData}
|
||||||
|
usePostcodeView={mapData.usePostcodeView}
|
||||||
|
pois={pois}
|
||||||
|
onViewChange={mapData.handleViewChange}
|
||||||
|
viewFeature={viewFeature}
|
||||||
|
colorRange={mapData.colorRange}
|
||||||
|
filterRange={filterRange}
|
||||||
|
viewSource={viewSource}
|
||||||
|
onCancelPin={handleCancelPin}
|
||||||
|
features={features}
|
||||||
|
selectedHexagonId={selection.selectedHexagon?.id || null}
|
||||||
|
hoveredHexagonId={selection.hoveredHexagon}
|
||||||
|
onHexagonClick={selection.handleHexagonClick}
|
||||||
|
onHexagonHover={selection.handleHexagonHover}
|
||||||
|
initialViewState={initialViewState}
|
||||||
|
theme={theme}
|
||||||
|
filters={filters}
|
||||||
|
searchedPostcode={searchedPostcode}
|
||||||
|
onPostcodeSearched={setSearchedPostcode}
|
||||||
|
/>
|
||||||
|
{mapData.loading && (
|
||||||
|
<div className="absolute bottom-4 left-4 bg-white dark:bg-navy-800 dark:text-warm-200 px-3 py-1 rounded shadow">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DataSources onNavigate={() => onNavigateTo('data-sources')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Pane */}
|
||||||
|
<div className="flex bg-white dark:bg-navy-950 shadow-lg z-10" style={{ width: rightPaneWidth }}>
|
||||||
|
<div
|
||||||
|
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||||
|
{...rightPaneHandlers}
|
||||||
|
>
|
||||||
|
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||||
|
<TabButton label="Area" isActive={selection.rightPaneTab === 'area'} onClick={() => selection.setRightPaneTab('area')} />
|
||||||
|
<TabButton label="Properties" isActive={selection.rightPaneTab === 'properties'} onClick={selection.handlePropertiesTabClick} />
|
||||||
|
<TabButton label="POIs" isActive={selection.rightPaneTab === 'pois'} onClick={() => selection.setRightPaneTab('pois')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{selection.rightPaneTab === 'area' ? (
|
||||||
|
<AreaPane
|
||||||
|
stats={selection.areaStats}
|
||||||
|
globalFeatures={features}
|
||||||
|
loading={selection.loadingAreaStats}
|
||||||
|
hexagonId={selection.selectedHexagon?.id || null}
|
||||||
|
isPostcode={selection.selectedHexagon?.type === 'postcode'}
|
||||||
|
postcodeData={
|
||||||
|
selection.selectedHexagon?.type === 'postcode'
|
||||||
|
? mapData.postcodeData.find((f) => f.properties.postcode === selection.selectedHexagon?.id) || null
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onViewProperties={selection.handleViewPropertiesFromArea}
|
||||||
|
onClose={selection.handleCloseSelection}
|
||||||
|
hexagonLocation={hexagonLocation}
|
||||||
|
filters={filters}
|
||||||
|
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
|
||||||
|
/>
|
||||||
|
) : selection.rightPaneTab === 'properties' ? (
|
||||||
|
<PropertiesPane
|
||||||
|
properties={selection.properties}
|
||||||
|
total={selection.propertiesTotal}
|
||||||
|
loading={selection.loadingProperties}
|
||||||
|
hexagonId={selection.selectedHexagon?.id || null}
|
||||||
|
onLoadMore={selection.handleLoadMoreProperties}
|
||||||
|
onClose={selection.handleCloseSelection}
|
||||||
|
onNavigateToSource={(slug) => onNavigateTo('data-sources', slug)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<POIPane
|
||||||
|
groups={poiCategoryGroups}
|
||||||
|
selectedCategories={selectedPOICategories}
|
||||||
|
onCategoriesChange={setSelectedPOICategories}
|
||||||
|
poiCount={pois.length}
|
||||||
|
onNavigateToSource={(slug) => onNavigateTo('data-sources', slug)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
interface TabButtonProps {
|
interface TabButtonProps {
|
||||||
label: string;
|
label: string;
|
||||||
count?: number;
|
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
interface IconProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LocationIcon({ className = 'w-5 h-5' }: IconProps) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
className={className}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -5,4 +5,3 @@ export { PlusIcon } from './PlusIcon';
|
||||||
export { ChevronIcon } from './ChevronIcon';
|
export { ChevronIcon } from './ChevronIcon';
|
||||||
export { FilterIcon } from './FilterIcon';
|
export { FilterIcon } from './FilterIcon';
|
||||||
export { LightbulbIcon } from './LightbulbIcon';
|
export { LightbulbIcon } from './LightbulbIcon';
|
||||||
export { LocationIcon } from './LocationIcon';
|
|
||||||
|
|
|
||||||
151
frontend/src/hooks/useFilters.ts
Normal file
151
frontend/src/hooks/useFilters.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { useState, useCallback, useRef, useMemo } from 'react';
|
||||||
|
import type { FeatureMeta, FeatureFilters, Bounds, HexagonData, ApiResponse } from '../types';
|
||||||
|
import { apiUrl, logNonAbortError } from '../lib/api';
|
||||||
|
|
||||||
|
interface UseFiltersOptions {
|
||||||
|
initialFilters: FeatureFilters;
|
||||||
|
features: FeatureMeta[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
|
// Use refs for bounds/resolution so handleDragStart always has latest values
|
||||||
|
const boundsRef = useRef<Bounds | null>(null);
|
||||||
|
const resolutionRef = useRef<number>(8);
|
||||||
|
const [filters, setFilters] = useState<FeatureFilters>(initialFilters);
|
||||||
|
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
||||||
|
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
||||||
|
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
|
||||||
|
const [dragData, setDragData] = useState<HexagonData[] | null>(null);
|
||||||
|
const dragAbortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
|
||||||
|
|
||||||
|
const viewFeature = activeFeature || pinnedFeature;
|
||||||
|
const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null;
|
||||||
|
|
||||||
|
const filterRange = useMemo((): [number, number] | null => {
|
||||||
|
if (!viewFeature) return null;
|
||||||
|
if (activeFeature && dragValue) return dragValue;
|
||||||
|
const filterVal = filters[viewFeature];
|
||||||
|
if (filterVal && typeof filterVal[0] === 'number') return filterVal as [number, number];
|
||||||
|
return null;
|
||||||
|
}, [viewFeature, activeFeature, dragValue, filters]);
|
||||||
|
|
||||||
|
const handleAddFilter = useCallback(
|
||||||
|
(name: string) => {
|
||||||
|
const meta = features.find((f) => f.name === name);
|
||||||
|
if (!meta) return;
|
||||||
|
if (meta.type === 'enum' && meta.values) {
|
||||||
|
setFilters((prev) => ({ ...prev, [name]: [...meta.values!] }));
|
||||||
|
} else if (meta.min != null && meta.max != null) {
|
||||||
|
setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[features]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFilterChange = useCallback((name: string, value: [number, number] | string[]) => {
|
||||||
|
setFilters((prev) => ({ ...prev, [name]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRemoveFilter = useCallback((name: string) => {
|
||||||
|
setFilters((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[name];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setPinnedFeature((prev) => (prev === name ? null : prev));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback(
|
||||||
|
(name: string) => {
|
||||||
|
const meta = features.find((f) => f.name === name);
|
||||||
|
if (meta?.type === 'enum') return;
|
||||||
|
setActiveFeature(name);
|
||||||
|
const fval = filters[name];
|
||||||
|
setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null);
|
||||||
|
|
||||||
|
const currentBounds = boundsRef.current;
|
||||||
|
if (!currentBounds) return;
|
||||||
|
if (dragAbortRef.current) dragAbortRef.current.abort();
|
||||||
|
dragAbortRef.current = new AbortController();
|
||||||
|
|
||||||
|
const otherFilters = Object.entries(filters).filter(([k]) => k !== name);
|
||||||
|
let filtersStr = '';
|
||||||
|
if (otherFilters.length > 0) {
|
||||||
|
filtersStr = otherFilters
|
||||||
|
.map(([n, value]) => {
|
||||||
|
const m = features.find((f) => f.name === n);
|
||||||
|
if (m?.type === 'enum') return `${n}:${(value as string[]).join('|')}`;
|
||||||
|
const [min, max] = value as [number, number];
|
||||||
|
return `${n}:${min}:${max}`;
|
||||||
|
})
|
||||||
|
.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundsStr = `${currentBounds.south},${currentBounds.west},${currentBounds.north},${currentBounds.east}`;
|
||||||
|
const params = new URLSearchParams({ resolution: resolutionRef.current.toString(), bounds: boundsStr });
|
||||||
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
|
params.set('fields', name);
|
||||||
|
|
||||||
|
fetch(apiUrl('hexagons', params), {
|
||||||
|
signal: dragAbortRef.current.signal,
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((json: ApiResponse) => setDragData(json.features || []))
|
||||||
|
.catch((err) => logNonAbortError('Failed to fetch drag data', err));
|
||||||
|
},
|
||||||
|
[filters, features]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragChange = useCallback((value: [number, number]) => {
|
||||||
|
setDragValue(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
if (activeFeature && dragValue) {
|
||||||
|
setFilters((prev) => ({ ...prev, [activeFeature]: dragValue }));
|
||||||
|
}
|
||||||
|
setActiveFeature(null);
|
||||||
|
setDragValue(null);
|
||||||
|
setDragData(null);
|
||||||
|
if (dragAbortRef.current) {
|
||||||
|
dragAbortRef.current.abort();
|
||||||
|
dragAbortRef.current = null;
|
||||||
|
}
|
||||||
|
}, [activeFeature, dragValue]);
|
||||||
|
|
||||||
|
const handleTogglePin = useCallback((name: string) => {
|
||||||
|
setPinnedFeature((prev) => (prev === name ? null : name));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCancelPin = useCallback(() => {
|
||||||
|
setPinnedFeature(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateBoundsInfo = useCallback((newBounds: Bounds | null, newResolution: number) => {
|
||||||
|
boundsRef.current = newBounds;
|
||||||
|
resolutionRef.current = newResolution;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filters,
|
||||||
|
activeFeature,
|
||||||
|
dragValue,
|
||||||
|
dragData,
|
||||||
|
pinnedFeature,
|
||||||
|
enabledFeatures,
|
||||||
|
viewFeature,
|
||||||
|
viewSource,
|
||||||
|
filterRange,
|
||||||
|
handleAddFilter,
|
||||||
|
handleFilterChange,
|
||||||
|
handleRemoveFilter,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragChange,
|
||||||
|
handleDragEnd,
|
||||||
|
handleTogglePin,
|
||||||
|
handleCancelPin,
|
||||||
|
updateBoundsInfo,
|
||||||
|
};
|
||||||
|
}
|
||||||
196
frontend/src/hooks/useHexagonSelection.ts
Normal file
196
frontend/src/hooks/useHexagonSelection.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import type {
|
||||||
|
FeatureMeta,
|
||||||
|
FeatureFilters,
|
||||||
|
PostcodeFeature,
|
||||||
|
Property,
|
||||||
|
HexagonPropertiesResponse,
|
||||||
|
HexagonStatsResponse,
|
||||||
|
NumericFeatureStats,
|
||||||
|
} from '../types';
|
||||||
|
import { buildFilterString, apiUrl, logNonAbortError } from '../lib/api';
|
||||||
|
|
||||||
|
interface SelectedHexagon {
|
||||||
|
id: string;
|
||||||
|
type: 'hexagon' | 'postcode';
|
||||||
|
resolution: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseHexagonSelectionOptions {
|
||||||
|
filters: FeatureFilters;
|
||||||
|
features: FeatureMeta[];
|
||||||
|
postcodeData: PostcodeFeature[];
|
||||||
|
resolution: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHexagonSelection({
|
||||||
|
filters,
|
||||||
|
features,
|
||||||
|
postcodeData,
|
||||||
|
resolution,
|
||||||
|
}: UseHexagonSelectionOptions) {
|
||||||
|
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
|
||||||
|
const [properties, setProperties] = useState<Property[]>([]);
|
||||||
|
const [propertiesTotal, setPropertiesTotal] = useState(0);
|
||||||
|
const [propertiesOffset, setPropertiesOffset] = useState(0);
|
||||||
|
const [loadingProperties, setLoadingProperties] = useState(false);
|
||||||
|
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
|
||||||
|
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
|
||||||
|
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
|
||||||
|
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties' | 'area'>('pois');
|
||||||
|
|
||||||
|
const fetchHexagonStats = useCallback(
|
||||||
|
async (h3: string, res: number, signal?: AbortSignal, fields?: string[]) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
h3,
|
||||||
|
resolution: res.toString(),
|
||||||
|
});
|
||||||
|
const filterStr = buildFilterString(filters, features);
|
||||||
|
if (filterStr) params.append('filters', filterStr);
|
||||||
|
if (fields) {
|
||||||
|
params.set('fields', fields.join(','));
|
||||||
|
}
|
||||||
|
const response = await fetch(apiUrl('hexagon-stats', params), { signal });
|
||||||
|
return (await response.json()) as HexagonStatsResponse;
|
||||||
|
},
|
||||||
|
[filters, features]
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildPostcodeStats = useCallback(
|
||||||
|
(postcode: string): HexagonStatsResponse | null => {
|
||||||
|
const feat = postcodeData.find((f) => f.properties.postcode === postcode);
|
||||||
|
if (!feat) return null;
|
||||||
|
const props = feat.properties;
|
||||||
|
|
||||||
|
const numeric_features: NumericFeatureStats[] = [];
|
||||||
|
for (const f of features) {
|
||||||
|
if (f.type !== 'numeric') continue;
|
||||||
|
const minVal = props[`min_${f.name}`];
|
||||||
|
const maxVal = props[`max_${f.name}`];
|
||||||
|
if (typeof minVal !== 'number' || typeof maxVal !== 'number') continue;
|
||||||
|
numeric_features.push({
|
||||||
|
name: f.name,
|
||||||
|
count: props.count,
|
||||||
|
min: minVal,
|
||||||
|
max: maxVal,
|
||||||
|
mean: (minVal + maxVal) / 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { count: props.count, numeric_features, enum_features: [] };
|
||||||
|
},
|
||||||
|
[postcodeData, features]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchHexagonProperties = useCallback(
|
||||||
|
async (h3: string, res: number, offset = 0) => {
|
||||||
|
setLoadingProperties(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
h3,
|
||||||
|
resolution: res.toString(),
|
||||||
|
limit: '100',
|
||||||
|
offset: offset.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filterStr = buildFilterString(filters, features);
|
||||||
|
if (filterStr) params.append('filters', filterStr);
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl('hexagon-properties', params));
|
||||||
|
const data: HexagonPropertiesResponse = await response.json();
|
||||||
|
|
||||||
|
if (offset === 0) {
|
||||||
|
setProperties(data.properties);
|
||||||
|
} else {
|
||||||
|
setProperties((prev) => [...prev, ...data.properties]);
|
||||||
|
}
|
||||||
|
setPropertiesTotal(data.total);
|
||||||
|
setPropertiesOffset(offset + data.properties.length);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch properties:', err);
|
||||||
|
} finally {
|
||||||
|
setLoadingProperties(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[filters, features]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHexagonClick = useCallback(
|
||||||
|
(id: string, isPostcode = false) => {
|
||||||
|
if (selectedHexagon?.id === id) {
|
||||||
|
setSelectedHexagon(null);
|
||||||
|
setProperties([]);
|
||||||
|
setAreaStats(null);
|
||||||
|
} else {
|
||||||
|
const type = isPostcode ? 'postcode' : 'hexagon';
|
||||||
|
setSelectedHexagon({ id, type, resolution });
|
||||||
|
setProperties([]);
|
||||||
|
setPropertiesTotal(0);
|
||||||
|
setPropertiesOffset(0);
|
||||||
|
setRightPaneTab('area');
|
||||||
|
|
||||||
|
if (isPostcode) {
|
||||||
|
setAreaStats(buildPostcodeStats(id));
|
||||||
|
setLoadingAreaStats(false);
|
||||||
|
} else {
|
||||||
|
setLoadingAreaStats(true);
|
||||||
|
fetchHexagonStats(id, resolution)
|
||||||
|
.then((stats) => setAreaStats(stats))
|
||||||
|
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
|
||||||
|
.finally(() => setLoadingAreaStats(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedHexagon, resolution, fetchHexagonStats, buildPostcodeStats]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHexagonHover = useCallback((h3: string | null) => {
|
||||||
|
setHoveredHexagon(h3);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleViewPropertiesFromArea = useCallback(() => {
|
||||||
|
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
|
||||||
|
setRightPaneTab('properties');
|
||||||
|
setPropertiesOffset(0);
|
||||||
|
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
||||||
|
}
|
||||||
|
}, [selectedHexagon, fetchHexagonProperties]);
|
||||||
|
|
||||||
|
const handlePropertiesTabClick = useCallback(() => {
|
||||||
|
setRightPaneTab('properties');
|
||||||
|
if (selectedHexagon?.type === 'hexagon' && properties.length === 0 && !loadingProperties) {
|
||||||
|
setPropertiesOffset(0);
|
||||||
|
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
||||||
|
}
|
||||||
|
}, [selectedHexagon, properties.length, loadingProperties, fetchHexagonProperties]);
|
||||||
|
|
||||||
|
const handleLoadMoreProperties = useCallback(() => {
|
||||||
|
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
|
||||||
|
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, propertiesOffset);
|
||||||
|
}
|
||||||
|
}, [selectedHexagon, propertiesOffset, fetchHexagonProperties]);
|
||||||
|
|
||||||
|
const handleCloseSelection = useCallback(() => {
|
||||||
|
setSelectedHexagon(null);
|
||||||
|
setProperties([]);
|
||||||
|
setAreaStats(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedHexagon,
|
||||||
|
properties,
|
||||||
|
propertiesTotal,
|
||||||
|
loadingProperties,
|
||||||
|
areaStats,
|
||||||
|
loadingAreaStats,
|
||||||
|
hoveredHexagon,
|
||||||
|
rightPaneTab,
|
||||||
|
setRightPaneTab,
|
||||||
|
handleHexagonClick,
|
||||||
|
handleHexagonHover,
|
||||||
|
handleViewPropertiesFromArea,
|
||||||
|
handlePropertiesTabClick,
|
||||||
|
handleLoadMoreProperties,
|
||||||
|
handleCloseSelection,
|
||||||
|
};
|
||||||
|
}
|
||||||
197
frontend/src/hooks/useMapData.ts
Normal file
197
frontend/src/hooks/useMapData.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import type {
|
||||||
|
FeatureMeta,
|
||||||
|
FeatureFilters,
|
||||||
|
Bounds,
|
||||||
|
HexagonData,
|
||||||
|
PostcodeFeature,
|
||||||
|
ViewChangeParams,
|
||||||
|
ApiResponse,
|
||||||
|
} from '../types';
|
||||||
|
import { buildFilterString, apiUrl, logNonAbortError } from '../lib/api';
|
||||||
|
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/map-utils';
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 150;
|
||||||
|
|
||||||
|
interface UseMapDataOptions {
|
||||||
|
filters: FeatureFilters;
|
||||||
|
features: FeatureMeta[];
|
||||||
|
viewFeature: string | null;
|
||||||
|
activeFeature: string | null;
|
||||||
|
dragValue: [number, number] | null;
|
||||||
|
dragData: HexagonData[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMapData({
|
||||||
|
filters,
|
||||||
|
features,
|
||||||
|
viewFeature,
|
||||||
|
activeFeature,
|
||||||
|
dragValue,
|
||||||
|
dragData,
|
||||||
|
}: UseMapDataOptions) {
|
||||||
|
const [rawData, setRawData] = useState<HexagonData[]>([]);
|
||||||
|
const [postcodeData, setPostcodeData] = useState<PostcodeFeature[]>([]);
|
||||||
|
const [resolution, setResolution] = useState<number>(8);
|
||||||
|
const [bounds, setBounds] = useState<Bounds | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [zoom, setZoom] = useState<number>(10);
|
||||||
|
const [currentView, setCurrentView] = useState<{
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
zoom: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const prevBoundsRef = useRef<string>('');
|
||||||
|
|
||||||
|
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
|
||||||
|
|
||||||
|
const buildFilterParam = useCallback(
|
||||||
|
(): string => buildFilterString(filters, features),
|
||||||
|
[filters, features]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch hexagons or postcodes when bounds/filters change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bounds) return;
|
||||||
|
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||||
|
const filtersStr = buildFilterParam();
|
||||||
|
|
||||||
|
if (usePostcodeView) {
|
||||||
|
const params = new URLSearchParams({ bounds: boundsStr });
|
||||||
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
|
params.set('fields', viewFeature || '');
|
||||||
|
const res = await fetch(apiUrl('postcodes', params), {
|
||||||
|
signal: abortControllerRef.current.signal,
|
||||||
|
});
|
||||||
|
const json: { features: PostcodeFeature[] } = await res.json();
|
||||||
|
setPostcodeData(json.features || []);
|
||||||
|
setRawData([]);
|
||||||
|
} else {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
resolution: resolution.toString(),
|
||||||
|
bounds: boundsStr,
|
||||||
|
});
|
||||||
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
|
params.set('fields', viewFeature || '');
|
||||||
|
const res = await fetch(apiUrl('hexagons', params), {
|
||||||
|
signal: abortControllerRef.current.signal,
|
||||||
|
});
|
||||||
|
const json: ApiResponse = await res.json();
|
||||||
|
setRawData(json.features || []);
|
||||||
|
setPostcodeData([]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logNonAbortError('Failed to fetch data', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView]);
|
||||||
|
|
||||||
|
const data = dragData ?? rawData;
|
||||||
|
|
||||||
|
// Compute actual min/max from visible data for the viewed feature
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (activeFeature && !dragData) return null;
|
||||||
|
|
||||||
|
let min = Infinity;
|
||||||
|
let max = -Infinity;
|
||||||
|
|
||||||
|
if (usePostcodeView) {
|
||||||
|
if (postcodeData.length === 0) return null;
|
||||||
|
for (const feat of postcodeData) {
|
||||||
|
const val = feat.properties[`min_${viewFeature}`];
|
||||||
|
if (typeof val === 'number' && !isNaN(val)) {
|
||||||
|
min = Math.min(min, val);
|
||||||
|
max = Math.max(max, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (data.length === 0) return null;
|
||||||
|
for (const item of data) {
|
||||||
|
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
|
||||||
|
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 (dataRange) return dataRange;
|
||||||
|
if (activeFeature && dragValue) return dragValue;
|
||||||
|
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
|
||||||
|
return null;
|
||||||
|
}, [viewFeature, features, dataRange, activeFeature, dragValue]);
|
||||||
|
|
||||||
|
const handleViewChange = useCallback(
|
||||||
|
({ resolution: newRes, bounds: newBounds, zoom: newZoom, latitude, longitude }: ViewChangeParams) => {
|
||||||
|
const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
|
||||||
|
if (boundsKey !== prevBoundsRef.current) {
|
||||||
|
prevBoundsRef.current = boundsKey;
|
||||||
|
setResolution(newRes);
|
||||||
|
setBounds(newBounds);
|
||||||
|
}
|
||||||
|
setZoom(newZoom);
|
||||||
|
setCurrentView({ latitude, longitude, zoom: newZoom });
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setInitialView = useCallback((view: { latitude: number; longitude: number; zoom: number }) => {
|
||||||
|
setCurrentView(view);
|
||||||
|
setZoom(view.zoom);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
rawData,
|
||||||
|
postcodeData,
|
||||||
|
resolution,
|
||||||
|
bounds,
|
||||||
|
loading,
|
||||||
|
zoom,
|
||||||
|
currentView,
|
||||||
|
usePostcodeView,
|
||||||
|
colorRange,
|
||||||
|
handleViewChange,
|
||||||
|
setInitialView,
|
||||||
|
};
|
||||||
|
}
|
||||||
53
frontend/src/hooks/usePOIData.ts
Normal file
53
frontend/src/hooks/usePOIData.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import type { Bounds, POI, POIResponse } from '../types';
|
||||||
|
import { apiUrl, logNonAbortError } from '../lib/api';
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 150;
|
||||||
|
|
||||||
|
export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string>) {
|
||||||
|
const [pois, setPois] = useState<POI[]>([]);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bounds || selectedCategories.size === 0) {
|
||||||
|
setPois([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||||
|
const categoriesStr = Array.from(selectedCategories).join(',');
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
categories: categoriesStr,
|
||||||
|
bounds: boundsStr,
|
||||||
|
});
|
||||||
|
const res = await fetch(apiUrl('pois', params), {
|
||||||
|
signal: abortControllerRef.current.signal,
|
||||||
|
});
|
||||||
|
const json: POIResponse = await res.json();
|
||||||
|
setPois(json.pois || []);
|
||||||
|
} catch (err) {
|
||||||
|
logNonAbortError('Failed to fetch POIs', err);
|
||||||
|
}
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [bounds, selectedCategories]);
|
||||||
|
|
||||||
|
return pois;
|
||||||
|
}
|
||||||
48
frontend/src/hooks/usePaneResize.ts
Normal file
48
frontend/src/hooks/usePaneResize.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
interface PaneResizeHandlers {
|
||||||
|
onPointerDown: (e: React.PointerEvent) => void;
|
||||||
|
onPointerMove: (e: React.PointerEvent) => void;
|
||||||
|
onPointerUp: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePaneResize(
|
||||||
|
initialWidth: number,
|
||||||
|
minWidth: number,
|
||||||
|
maxWidth: number,
|
||||||
|
side: 'left' | 'right'
|
||||||
|
): [number, PaneResizeHandlers] {
|
||||||
|
const [width, setWidth] = useState(initialWidth);
|
||||||
|
const draggingRef = useRef(false);
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
draggingRef.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (!draggingRef.current) return;
|
||||||
|
const newWidth =
|
||||||
|
side === 'left'
|
||||||
|
? Math.min(maxWidth, Math.max(minWidth, e.clientX))
|
||||||
|
: Math.min(maxWidth, Math.max(minWidth, window.innerWidth - e.clientX));
|
||||||
|
setWidth(newWidth);
|
||||||
|
},
|
||||||
|
[side, minWidth, maxWidth]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerUp = useCallback(() => {
|
||||||
|
draggingRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [
|
||||||
|
width,
|
||||||
|
{
|
||||||
|
onPointerDown: handlePointerDown,
|
||||||
|
onPointerMove: handlePointerMove,
|
||||||
|
onPointerUp: handlePointerUp,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { createRoot, hydrateRoot } from 'react-dom/client';
|
import { createRoot, hydrateRoot } from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import { initPlausible } from './usePlausible';
|
import { initPlausible } from './hooks/usePlausible';
|
||||||
|
|
||||||
initPlausible();
|
initPlausible();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ const INITIAL_RETRY_MS = 1000;
|
||||||
const MAX_RETRY_MS = 10000;
|
const MAX_RETRY_MS = 10000;
|
||||||
|
|
||||||
// Error handling utilities
|
// Error handling utilities
|
||||||
export function isAbortError(error: unknown): boolean {
|
function isAbortError(error: unknown): boolean {
|
||||||
return error instanceof Error && error.name === 'AbortError';
|
return error instanceof Error && error.name === 'AbortError';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,6 @@
|
||||||
import type { ViewState } from '../types';
|
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] = [-9.5, 49, 5, 57];
|
export const MAP_BOUNDS: [number, number, number, number] = [-9.5, 49, 5, 57];
|
||||||
|
|
||||||
/** Minimum zoom level (can't zoom out further) */
|
|
||||||
export const MAP_MIN_ZOOM = 5.5;
|
export const MAP_MIN_ZOOM = 5.5;
|
||||||
|
|
||||||
/** Maximum zoom level for tile fetching (map extrapolates beyond this) */
|
/** Maximum zoom level for tile fetching (map extrapolates beyond this) */
|
||||||
|
|
@ -21,12 +14,6 @@ export const INITIAL_VIEW_STATE: ViewState = {
|
||||||
pitch: 0,
|
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.
|
* Zoom to H3 resolution mapping thresholds.
|
||||||
|
|
@ -40,6 +27,8 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
|
||||||
{ maxZoom: 13, resolution: 9 },
|
{ maxZoom: 13, resolution: 9 },
|
||||||
{ maxZoom: Infinity, resolution: 10 },
|
{ maxZoom: Infinity, resolution: 10 },
|
||||||
] as const;
|
] as const;
|
||||||
|
export const POSTCODE_ZOOM_THRESHOLD = 15;
|
||||||
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Color Gradients
|
// Color Gradients
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,4 @@
|
||||||
import type { FeatureMeta } from '../types';
|
import type { FeatureMeta, FeatureGroup } from '../types';
|
||||||
|
|
||||||
export interface FeatureGroup {
|
|
||||||
name: string;
|
|
||||||
features: FeatureMeta[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function groupFeaturesByCategory(features: FeatureMeta[]): FeatureGroup[] {
|
export function groupFeaturesByCategory(features: FeatureMeta[]): FeatureGroup[] {
|
||||||
const groups: FeatureGroup[] = [];
|
const groups: FeatureGroup[] = [];
|
||||||
|
|
|
||||||
|
|
@ -35,13 +35,15 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
||||||
} as StyleSpecification;
|
} as StyleSpecification;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizedToColor(t: number): [number, number, number] {
|
type GradientStop = { t: number; color: [number, number, number] };
|
||||||
if (t <= 0) return FEATURE_GRADIENT[0].color;
|
|
||||||
if (t >= 1) return FEATURE_GRADIENT[FEATURE_GRADIENT.length - 1].color;
|
|
||||||
|
|
||||||
for (let i = 0; i < FEATURE_GRADIENT.length - 1; i++) {
|
function interpolateGradient(t: number, gradient: GradientStop[]): [number, number, number] {
|
||||||
const lo = FEATURE_GRADIENT[i];
|
if (t <= 0) return gradient[0].color;
|
||||||
const hi = FEATURE_GRADIENT[i + 1];
|
if (t >= 1) return gradient[gradient.length - 1].color;
|
||||||
|
|
||||||
|
for (let i = 0; i < gradient.length - 1; i++) {
|
||||||
|
const lo = gradient[i];
|
||||||
|
const hi = 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 [
|
||||||
|
|
@ -51,26 +53,15 @@ export function normalizedToColor(t: number): [number, number, number] {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return FEATURE_GRADIENT[FEATURE_GRADIENT.length - 1].color;
|
return gradient[gradient.length - 1].color;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizedToColor(t: number): [number, number, number] {
|
||||||
|
return interpolateGradient(t, FEATURE_GRADIENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function countToColor(t: number): [number, number, number] {
|
export function countToColor(t: number): [number, number, number] {
|
||||||
if (t <= 0) return DENSITY_GRADIENT[0].color;
|
return interpolateGradient(t, DENSITY_GRADIENT);
|
||||||
if (t >= 1) return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color;
|
|
||||||
|
|
||||||
for (let i = 0; i < DENSITY_GRADIENT.length - 1; i++) {
|
|
||||||
const lo = DENSITY_GRADIENT[i];
|
|
||||||
const hi = DENSITY_GRADIENT[i + 1];
|
|
||||||
if (t >= lo.t && t <= hi.t) {
|
|
||||||
const frac = (t - lo.t) / (hi.t - lo.t);
|
|
||||||
return [
|
|
||||||
Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac),
|
|
||||||
Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac),
|
|
||||||
Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function zoomToResolution(zoom: number): number {
|
export function zoomToResolution(zoom: number): number {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
import type { FeatureMeta, FeatureFilters, ViewState } from '../types';
|
import type { FeatureMeta, FeatureFilters, ViewState } from '../types';
|
||||||
|
|
||||||
export const DEFAULT_VIEW: ViewState = {
|
|
||||||
longitude: -1.5,
|
|
||||||
latitude: 53.5,
|
|
||||||
zoom: 6,
|
|
||||||
pitch: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function parseUrlState(): {
|
export function parseUrlState(): {
|
||||||
viewState?: ViewState;
|
viewState?: ViewState;
|
||||||
filters?: FeatureFilters;
|
filters?: FeatureFilters;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue