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 { trackPageview } from './usePlausible';
|
||||
import Map from './components/map/Map';
|
||||
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 { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { trackPageview } from './hooks/usePlausible';
|
||||
import MapPage from './components/map/MapPage';
|
||||
import DataSourcesPage from './components/data-sources/DataSourcesPage';
|
||||
import FAQPage from './components/faq/FAQPage';
|
||||
import HomePage from './components/home/HomePage';
|
||||
import Header, { type Page } from './components/ui/Header';
|
||||
import { TabButton } from './components/ui/TabButton';
|
||||
import type {
|
||||
FeatureMeta,
|
||||
FeatureGroup,
|
||||
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 type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
|
||||
import { fetchWithRetry, apiUrl } from './lib/api';
|
||||
import { parseUrlState } from './lib/url-state';
|
||||
import { INITIAL_VIEW_STATE } from './lib/consts';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { useUrlSync } from './hooks/useUrlSync';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
|
@ -42,81 +17,22 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
||||
export default function App() {
|
||||
const urlState = useMemo(() => parseUrlState(), []);
|
||||
const initialViewState = useMemo(() => urlState.viewState || INITIAL_VIEW_STATE, []);
|
||||
|
||||
const isScreenshotMode = useMemo(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('screenshot') === '1';
|
||||
}, []);
|
||||
|
||||
// Core data
|
||||
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 [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);
|
||||
|
||||
// UI state
|
||||
const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(null);
|
||||
const [activePage, setActivePage] = useState<Page>(() => {
|
||||
if (isScreenshotMode) return 'dashboard';
|
||||
if (window.history.state?.page) return window.history.state.page;
|
||||
|
|
@ -126,59 +42,9 @@ export default function App() {
|
|||
: '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();
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
|
||||
// Load features and POI categories on mount
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let featuresLoaded = false;
|
||||
|
|
@ -214,486 +80,58 @@ export default function App() {
|
|||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const buildFilterParam = useCallback(
|
||||
(): string => buildFilterString(filters, features),
|
||||
[filters, features]
|
||||
);
|
||||
// Screenshot mode ready signal
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
if (!bounds) return;
|
||||
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
if (!window.history.state?.page) {
|
||||
window.history.replaceState({ page: activePage }, '');
|
||||
}
|
||||
|
||||
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) {
|
||||
// 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
|
||||
const handlePopState = (e: PopStateEvent) => {
|
||||
if (e.state?.page) {
|
||||
setActivePage(e.state.page);
|
||||
if (e.state.infoFeature) {
|
||||
setPendingInfoFeature(e.state.infoFeature);
|
||||
}
|
||||
} 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
|
||||
// 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;
|
||||
}, []);
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (isScreenshotMode) {
|
||||
return (
|
||||
<div className="h-screen w-screen">
|
||||
<Map
|
||||
data={data}
|
||||
postcodeData={postcodeData}
|
||||
usePostcodeView={usePostcodeView}
|
||||
pois={pois}
|
||||
onViewChange={handleViewChange}
|
||||
viewFeature={viewFeature}
|
||||
colorRange={colorRange}
|
||||
filterRange={filterRange}
|
||||
viewSource={viewSource}
|
||||
onCancelPin={handleCancelPin}
|
||||
features={features}
|
||||
selectedHexagonId={null}
|
||||
hoveredHexagonId={null}
|
||||
onHexagonClick={() => {}}
|
||||
onHexagonHover={() => {}}
|
||||
initialViewState={initialViewState}
|
||||
theme={theme}
|
||||
screenshotMode
|
||||
/>
|
||||
</div>
|
||||
<MapPage
|
||||
features={features}
|
||||
poiCategoryGroups={poiCategoryGroups}
|
||||
initialFilters={urlState.filters || {}}
|
||||
initialViewState={initialViewState}
|
||||
initialPOICategories={urlState.poiCategories || new Set()}
|
||||
initialTab={urlState.tab || 'pois'}
|
||||
initialLoading={initialLoading}
|
||||
theme={theme}
|
||||
pendingInfoFeature={null}
|
||||
onClearPendingInfoFeature={() => {}}
|
||||
onNavigateTo={() => {}}
|
||||
screenshotMode
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -712,189 +150,19 @@ export default function App() {
|
|||
) : activePage === 'faq' ? (
|
||||
<FAQPage />
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
<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={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}
|
||||
theme={theme}
|
||||
filters={filters}
|
||||
searchedPostcode={searchedPostcode}
|
||||
onPostcodeSearched={setSearchedPostcode}
|
||||
/>
|
||||
{loading && (
|
||||
<div className="absolute bottom-4 left-4 bg-white dark:bg-navy-800 dark:text-warm-200 px-3 py-1 rounded shadow">
|
||||
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>
|
||||
<MapPage
|
||||
features={features}
|
||||
poiCategoryGroups={poiCategoryGroups}
|
||||
initialFilters={urlState.filters || {}}
|
||||
initialViewState={initialViewState}
|
||||
initialPOICategories={urlState.poiCategories || new Set()}
|
||||
initialTab={urlState.tab || 'pois'}
|
||||
initialLoading={initialLoading}
|
||||
theme={theme}
|
||||
pendingInfoFeature={pendingInfoFeature}
|
||||
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
|
||||
onNavigateTo={navigateTo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue