Refactor UI
This commit is contained in:
parent
ce4c0cc08c
commit
34a4d0ba86
32 changed files with 1726 additions and 845 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { trackPageview } from './usePlausible';
|
||||
import Map from './components/Map';
|
||||
import type { SearchedPostcode } from './components/PostcodeSearch';
|
||||
import Filters from './components/Filters';
|
||||
import POIPane from './components/POIPane';
|
||||
import { PropertiesPane } from './components/PropertiesPane';
|
||||
|
|
@ -10,12 +11,15 @@ import DataSourcesPage from './components/DataSourcesPage';
|
|||
import FAQPage from './components/FAQPage';
|
||||
import HomePage from './components/HomePage';
|
||||
import Header, { type Page } from './components/Header';
|
||||
import { ChevronIcon } from './components/ui/Icons';
|
||||
import { TabButton } from './components/ui/TabButton';
|
||||
import type {
|
||||
FeatureMeta,
|
||||
FeatureGroup,
|
||||
FeatureFilters,
|
||||
Bounds,
|
||||
HexagonData,
|
||||
PostcodeData,
|
||||
ViewChangeParams,
|
||||
ApiResponse,
|
||||
POI,
|
||||
|
|
@ -26,8 +30,9 @@ import type {
|
|||
HexagonPropertiesResponse,
|
||||
HexagonStatsResponse,
|
||||
} from './types';
|
||||
import { fetchWithRetry, getApiBaseUrl, buildFilterString } from './lib/api';
|
||||
import { fetchWithRetry, getApiBaseUrl, buildFilterString, apiUrl, logNonAbortError } from './lib/api';
|
||||
import { parseUrlState, DEFAULT_VIEW } from './lib/url-state';
|
||||
import { POSTCODE_ZOOM_THRESHOLD } from './lib/map-utils';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { useUrlSync } from './hooks/useUrlSync';
|
||||
|
||||
|
|
@ -53,6 +58,7 @@ export default function App() {
|
|||
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
||||
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
|
||||
const [rawData, setRawData] = useState<HexagonData[]>([]);
|
||||
const [postcodeData, setPostcodeData] = useState<PostcodeData[]>([]);
|
||||
const [dragData, setDragData] = useState<HexagonData[] | null>(null);
|
||||
const [resolution, setResolution] = useState<number>(8);
|
||||
const [bounds, setBounds] = useState<Bounds | null>(null);
|
||||
|
|
@ -86,9 +92,11 @@ export default function App() {
|
|||
const poiDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const poiAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const [selectedHexagon, setSelectedHexagon] = useState<{ h3: string; resolution: number } | null>(
|
||||
null
|
||||
);
|
||||
const [selectedHexagon, setSelectedHexagon] = useState<{
|
||||
id: string;
|
||||
type: 'hexagon' | 'postcode';
|
||||
resolution: number;
|
||||
} | null>(null);
|
||||
const [properties, setProperties] = useState<Property[]>([]);
|
||||
const [propertiesTotal, setPropertiesTotal] = useState(0);
|
||||
const [propertiesOffset, setPropertiesOffset] = useState(0);
|
||||
|
|
@ -100,14 +108,11 @@ export default function App() {
|
|||
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
|
||||
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
|
||||
|
||||
const [leftPaneCollapsed, setLeftPaneCollapsed] = useState(false);
|
||||
const [rightPaneCollapsed, setRightPaneCollapsed] = useState(false);
|
||||
|
||||
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
|
||||
const [hoveredAreaStats, setHoveredAreaStats] = useState<HexagonStatsResponse | null>(null);
|
||||
const [hoveredProperties, setHoveredProperties] = useState<Property[] | null>(null);
|
||||
const [hoveredPropertiesTotal, setHoveredPropertiesTotal] = useState(0);
|
||||
const [loadingHoveredAreaStats, setLoadingHoveredAreaStats] = useState(false);
|
||||
const [hoverMode, setHoverMode] = useState(true);
|
||||
const hoverAbortRef = useRef<AbortController | null>(null);
|
||||
const hoverDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
|
||||
const [activePage, setActivePage] = useState<Page>(() => {
|
||||
|
|
@ -162,20 +167,6 @@ export default function App() {
|
|||
const viewFeature = activeFeature || pinnedFeature;
|
||||
const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null;
|
||||
|
||||
const colorRange = useMemo((): [number, number] | null => {
|
||||
if (!viewFeature) return null;
|
||||
const meta = features.find((f) => f.name === viewFeature);
|
||||
if (!meta) return null;
|
||||
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
|
||||
return [0, meta.values.length - 1];
|
||||
}
|
||||
if (activeFeature === viewFeature && dragValue) return dragValue;
|
||||
const filterVal = filters[viewFeature];
|
||||
if (filterVal && typeof filterVal[0] === 'number') return filterVal as [number, number];
|
||||
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
|
||||
return null;
|
||||
}, [viewFeature, features, activeFeature, dragValue, filters]);
|
||||
|
||||
const filterRange = useMemo((): [number, number] | null => {
|
||||
if (!viewFeature) return null;
|
||||
if (activeFeature && dragValue) return dragValue;
|
||||
|
|
@ -196,7 +187,7 @@ export default function App() {
|
|||
};
|
||||
|
||||
fetchWithRetry<{ groups: FeatureGroup[] }>(
|
||||
`${getApiBaseUrl()}/api/features`,
|
||||
apiUrl('features'),
|
||||
(json) => {
|
||||
const flat: FeatureMeta[] = json.groups.flatMap((g) =>
|
||||
g.features.map((f) => ({ ...f, group: g.name }))
|
||||
|
|
@ -209,7 +200,7 @@ export default function App() {
|
|||
);
|
||||
|
||||
fetchWithRetry<POICategoriesResponse>(
|
||||
`${getApiBaseUrl()}/api/poi-categories`,
|
||||
apiUrl('poi-categories'),
|
||||
(json) => {
|
||||
setPOICategoryGroups(json.groups);
|
||||
poisLoaded = true;
|
||||
|
|
@ -226,6 +217,8 @@ export default function App() {
|
|||
[filters, features]
|
||||
);
|
||||
|
||||
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
|
||||
|
||||
useEffect(() => {
|
||||
if (!bounds) return;
|
||||
|
||||
|
|
@ -244,25 +237,42 @@ export default function App() {
|
|||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||
const filtersStr = buildFilterParam();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
resolution: resolution.toString(),
|
||||
bounds: boundsStr,
|
||||
});
|
||||
if (filtersStr) params.set('filters', filtersStr);
|
||||
if (viewFeature) {
|
||||
params.set('fields', viewFeature);
|
||||
if (usePostcodeView) {
|
||||
// Fetch postcode polygons for high zoom levels
|
||||
const params = new URLSearchParams({ bounds: boundsStr });
|
||||
if (filtersStr) params.set('filters', filtersStr);
|
||||
if (viewFeature) {
|
||||
params.set('fields', viewFeature);
|
||||
} else {
|
||||
params.set('fields', '');
|
||||
}
|
||||
const res = await fetch(apiUrl('postcodes', params), {
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
const json: { features: PostcodeData[] } = await res.json();
|
||||
setPostcodeData(json.features || []);
|
||||
setRawData([]); // Clear hexagon data
|
||||
} else {
|
||||
params.set('fields', '');
|
||||
// Fetch hexagons for lower zoom levels
|
||||
const params = new URLSearchParams({
|
||||
resolution: resolution.toString(),
|
||||
bounds: boundsStr,
|
||||
});
|
||||
if (filtersStr) params.set('filters', filtersStr);
|
||||
if (viewFeature) {
|
||||
params.set('fields', viewFeature);
|
||||
} else {
|
||||
params.set('fields', '');
|
||||
}
|
||||
const res = await fetch(apiUrl('hexagons', params), {
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
const json: ApiResponse = await res.json();
|
||||
setRawData(json.features || []);
|
||||
setPostcodeData([]); // Clear postcode data
|
||||
}
|
||||
const res = await fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
const json: ApiResponse = await res.json();
|
||||
setRawData(json.features || []);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name !== 'AbortError') {
|
||||
console.error('Failed to fetch data:', err);
|
||||
}
|
||||
logNonAbortError('Failed to fetch data', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -273,10 +283,57 @@ export default function App() {
|
|||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [resolution, bounds, filters, buildFilterParam, viewFeature]);
|
||||
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView]);
|
||||
|
||||
const data = dragData ?? rawData;
|
||||
|
||||
// Compute actual min/max from visible data for the viewed feature
|
||||
// Uses postcodeData when in postcode view, otherwise hexagon/drag data
|
||||
const dataRange = useMemo((): [number, number] | null => {
|
||||
if (!viewFeature) return null;
|
||||
const meta = features.find((f) => f.name === viewFeature);
|
||||
if (!meta || meta.type === 'enum') return null;
|
||||
|
||||
// When actively dragging, only use dragData (not rawData which has old filters)
|
||||
// If dragData hasn't loaded yet, return null to trigger fallback
|
||||
if (activeFeature && !dragData) return null;
|
||||
|
||||
// Choose the appropriate data source based on zoom level
|
||||
const sourceData = usePostcodeView ? postcodeData : data;
|
||||
if (sourceData.length === 0) return null;
|
||||
|
||||
// Only use min_<feature> values since that's what hexagon coloring uses
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const item of sourceData) {
|
||||
const val = item[`min_${viewFeature}`];
|
||||
if (typeof val === 'number' && !isNaN(val)) {
|
||||
min = Math.min(min, val);
|
||||
max = Math.max(max, val);
|
||||
}
|
||||
}
|
||||
if (min === Infinity || max === -Infinity) return null;
|
||||
return [min, max];
|
||||
}, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature]);
|
||||
|
||||
// Color range for the legend and hex coloring - uses actual data range when available
|
||||
const colorRange = useMemo((): [number, number] | null => {
|
||||
if (!viewFeature) return null;
|
||||
const meta = features.find((f) => f.name === viewFeature);
|
||||
if (!meta) return null;
|
||||
// For enum features: use [0, numValues-1]
|
||||
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
|
||||
return [0, meta.values.length - 1];
|
||||
}
|
||||
// Use actual data range when available (shows actual min/max on the map)
|
||||
if (dataRange) return dataRange;
|
||||
// During drag when data hasn't loaded yet, use dragValue as preview
|
||||
if (activeFeature && dragValue) return dragValue;
|
||||
// Fallback to full feature range
|
||||
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
|
||||
return null;
|
||||
}, [viewFeature, features, dataRange, activeFeature, dragValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bounds || selectedPOICategories.size === 0) {
|
||||
setPois([]);
|
||||
|
|
@ -300,15 +357,13 @@ export default function App() {
|
|||
categories: categoriesStr,
|
||||
bounds: boundsStr,
|
||||
});
|
||||
const res = await fetch(`${getApiBaseUrl()}/api/pois?${params}`, {
|
||||
const res = await fetch(apiUrl('pois', params), {
|
||||
signal: poiAbortControllerRef.current.signal,
|
||||
});
|
||||
const json: POIResponse = await res.json();
|
||||
setPois(json.pois || []);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name !== 'AbortError') {
|
||||
console.error('Failed to fetch POIs:', err);
|
||||
}
|
||||
logNonAbortError('Failed to fetch POIs', err);
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
|
|
@ -396,16 +451,12 @@ export default function App() {
|
|||
if (filtersStr) params.set('filters', filtersStr);
|
||||
params.set('fields', name);
|
||||
|
||||
fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
|
||||
fetch(apiUrl('hexagons', params), {
|
||||
signal: dragAbortRef.current.signal,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((json: ApiResponse) => setDragData(json.features || []))
|
||||
.catch((err) => {
|
||||
if (err instanceof Error && err.name !== 'AbortError') {
|
||||
console.error('Failed to fetch drag data:', err);
|
||||
}
|
||||
});
|
||||
.catch((err) => logNonAbortError('Failed to fetch drag data', err));
|
||||
},
|
||||
[filters, features, bounds, resolution]
|
||||
);
|
||||
|
|
@ -446,7 +497,7 @@ export default function App() {
|
|||
if (fields) {
|
||||
params.set('fields', fields.join(','));
|
||||
}
|
||||
const response = await fetch(`${getApiBaseUrl()}/api/hexagon-stats?${params}`, { signal });
|
||||
const response = await fetch(apiUrl('hexagon-stats', params), { signal });
|
||||
return (await response.json()) as HexagonStatsResponse;
|
||||
},
|
||||
[filters, features]
|
||||
|
|
@ -466,7 +517,7 @@ export default function App() {
|
|||
const filterStr = buildFilterString(filters, features);
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
|
||||
const response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`);
|
||||
const response = await fetch(apiUrl('hexagon-properties', params));
|
||||
const data: HexagonPropertiesResponse = await response.json();
|
||||
|
||||
if (offset === 0) {
|
||||
|
|
@ -486,99 +537,48 @@ export default function App() {
|
|||
);
|
||||
|
||||
const handleHexagonClick = useCallback(
|
||||
(h3: string) => {
|
||||
if (selectedHexagon?.h3 === h3) {
|
||||
(id: string, isPostcode = false) => {
|
||||
if (selectedHexagon?.id === id) {
|
||||
setSelectedHexagon(null);
|
||||
setProperties([]);
|
||||
setAreaStats(null);
|
||||
} else {
|
||||
setSelectedHexagon({ h3, resolution });
|
||||
const type = isPostcode ? 'postcode' : 'hexagon';
|
||||
setSelectedHexagon({ id, type, resolution });
|
||||
setPropertiesOffset(0);
|
||||
setRightPaneTab('area');
|
||||
setLoadingAreaStats(true);
|
||||
fetchHexagonStats(h3, resolution)
|
||||
.then((stats) => setAreaStats(stats))
|
||||
.catch((error) => {
|
||||
if (error instanceof Error && error.name !== 'AbortError') {
|
||||
console.error('Failed to fetch area stats:', error);
|
||||
}
|
||||
})
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
|
||||
if (isPostcode) {
|
||||
// For postcodes, we don't have a stats API yet, so skip
|
||||
setAreaStats(null);
|
||||
setLoadingAreaStats(false);
|
||||
} else {
|
||||
setLoadingAreaStats(true);
|
||||
fetchHexagonStats(id, resolution)
|
||||
.then((stats) => setAreaStats(stats))
|
||||
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectedHexagon, resolution, fetchHexagonStats]
|
||||
);
|
||||
|
||||
const handleHexagonHover = useCallback(
|
||||
(h3: string | null) => {
|
||||
setHoveredHexagon(h3);
|
||||
if (!hoverMode || !h3 || h3 === selectedHexagon?.h3) {
|
||||
if (hoverDebounceRef.current) clearTimeout(hoverDebounceRef.current);
|
||||
if (hoverAbortRef.current) hoverAbortRef.current.abort();
|
||||
setHoveredAreaStats(null);
|
||||
setHoveredProperties(null);
|
||||
setHoveredPropertiesTotal(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hoverDebounceRef.current) clearTimeout(hoverDebounceRef.current);
|
||||
hoverDebounceRef.current = setTimeout(async () => {
|
||||
if (hoverAbortRef.current) hoverAbortRef.current.abort();
|
||||
hoverAbortRef.current = new AbortController();
|
||||
const signal = hoverAbortRef.current.signal;
|
||||
|
||||
try {
|
||||
if (rightPaneTab === 'area') {
|
||||
setLoadingHoveredAreaStats(true);
|
||||
const hoverFields = Object.keys(filters);
|
||||
const stats = await fetchHexagonStats(
|
||||
h3,
|
||||
resolution,
|
||||
signal,
|
||||
hoverFields.length > 0 ? hoverFields : undefined
|
||||
);
|
||||
if (!signal.aborted) setHoveredAreaStats(stats);
|
||||
} else if (rightPaneTab === 'properties') {
|
||||
const params = new URLSearchParams({
|
||||
h3,
|
||||
resolution: resolution.toString(),
|
||||
limit: '3',
|
||||
offset: '0',
|
||||
});
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
const response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`, {
|
||||
signal,
|
||||
});
|
||||
const data: HexagonPropertiesResponse = await response.json();
|
||||
if (!signal.aborted) {
|
||||
setHoveredProperties(data.properties);
|
||||
setHoveredPropertiesTotal(data.total);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name !== 'AbortError') {
|
||||
console.error('Failed to fetch hover data:', error);
|
||||
}
|
||||
} finally {
|
||||
if (!signal.aborted) setLoadingHoveredAreaStats(false);
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
},
|
||||
[hoverMode, selectedHexagon, rightPaneTab, resolution, filters, features, fetchHexagonStats]
|
||||
);
|
||||
const handleHexagonHover = useCallback((h3: string | null) => {
|
||||
setHoveredHexagon(h3);
|
||||
}, []);
|
||||
|
||||
const handleViewPropertiesFromArea = useCallback(() => {
|
||||
if (selectedHexagon) {
|
||||
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
|
||||
setRightPaneTab('properties');
|
||||
setPropertiesOffset(0);
|
||||
fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, 0);
|
||||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
||||
}
|
||||
}, [selectedHexagon, fetchHexagonProperties]);
|
||||
|
||||
const handleLoadMoreProperties = useCallback(() => {
|
||||
if (selectedHexagon) {
|
||||
fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, propertiesOffset);
|
||||
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
|
||||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, propertiesOffset);
|
||||
}
|
||||
}, [selectedHexagon, propertiesOffset, fetchHexagonProperties]);
|
||||
|
||||
|
|
@ -593,6 +593,8 @@ export default function App() {
|
|||
<div className="h-screen w-screen">
|
||||
<Map
|
||||
data={data}
|
||||
postcodeData={postcodeData}
|
||||
usePostcodeView={usePostcodeView}
|
||||
pois={pois}
|
||||
onViewChange={handleViewChange}
|
||||
viewFeature={viewFeature}
|
||||
|
|
@ -657,31 +659,53 @@ export default function App() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Filters
|
||||
features={features}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
enabledFeatures={enabledFeatures}
|
||||
onAddFilter={handleAddFilter}
|
||||
onRemoveFilter={handleRemoveFilter}
|
||||
onFilterChange={handleFilterChange}
|
||||
onDragStart={handleDragStart}
|
||||
onDragChange={handleDragChange}
|
||||
onDragEnd={handleDragEnd}
|
||||
zoom={zoom}
|
||||
pinnedFeature={pinnedFeature}
|
||||
onTogglePin={handleTogglePin}
|
||||
onCancelPin={handleCancelPin}
|
||||
onNavigateToSource={(slug, featureName) => {
|
||||
navigateTo('data-sources', slug, featureName);
|
||||
}}
|
||||
openInfoFeature={pendingInfoFeature}
|
||||
onClearOpenInfoFeature={() => setPendingInfoFeature(null)}
|
||||
/>
|
||||
<div
|
||||
className={`flex ${leftPaneCollapsed ? 'w-10' : 'w-96'} bg-white dark:bg-navy-950 shadow-lg overflow-hidden`}
|
||||
>
|
||||
{leftPaneCollapsed ? (
|
||||
<button
|
||||
onClick={() => setLeftPaneCollapsed(false)}
|
||||
className="w-full h-full flex flex-col items-center justify-center gap-2 text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400 hover:bg-warm-50 dark:hover:bg-navy-800"
|
||||
title="Expand filters"
|
||||
>
|
||||
<ChevronIcon direction="right" className="w-5 h-5" />
|
||||
<span className="text-xs font-medium writing-mode-vertical">Filters</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<Filters
|
||||
features={features}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
enabledFeatures={enabledFeatures}
|
||||
onAddFilter={handleAddFilter}
|
||||
onRemoveFilter={handleRemoveFilter}
|
||||
onFilterChange={handleFilterChange}
|
||||
onDragStart={handleDragStart}
|
||||
onDragChange={handleDragChange}
|
||||
onDragEnd={handleDragEnd}
|
||||
zoom={zoom}
|
||||
itemCount={usePostcodeView ? postcodeData.length : data.length}
|
||||
usePostcodeView={usePostcodeView}
|
||||
pinnedFeature={pinnedFeature}
|
||||
onTogglePin={handleTogglePin}
|
||||
onCancelPin={handleCancelPin}
|
||||
onNavigateToSource={(slug, featureName) => {
|
||||
navigateTo('data-sources', slug, featureName);
|
||||
}}
|
||||
openInfoFeature={pendingInfoFeature}
|
||||
onClearOpenInfoFeature={() => setPendingInfoFeature(null)}
|
||||
onCollapse={() => setLeftPaneCollapsed(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 relative">
|
||||
<Map
|
||||
data={data}
|
||||
postcodeData={postcodeData}
|
||||
usePostcodeView={usePostcodeView}
|
||||
pois={pois}
|
||||
onViewChange={handleViewChange}
|
||||
viewFeature={viewFeature}
|
||||
|
|
@ -690,12 +714,15 @@ export default function App() {
|
|||
viewSource={viewSource}
|
||||
onCancelPin={handleCancelPin}
|
||||
features={features}
|
||||
selectedHexagonId={selectedHexagon?.h3 || null}
|
||||
selectedHexagonId={selectedHexagon?.id || null}
|
||||
hoveredHexagonId={hoveredHexagon}
|
||||
onHexagonClick={handleHexagonClick}
|
||||
onHexagonHover={handleHexagonHover}
|
||||
initialViewState={initialViewState}
|
||||
theme={theme}
|
||||
filters={filters}
|
||||
searchedPostcode={searchedPostcode}
|
||||
onPostcodeSearched={setSearchedPostcode}
|
||||
/>
|
||||
{loading && (
|
||||
<div className="absolute bottom-4 left-4 bg-white dark:bg-navy-800 dark:text-warm-200 px-3 py-1 rounded shadow">
|
||||
|
|
@ -704,115 +731,107 @@ export default function App() {
|
|||
)}
|
||||
<DataSources onNavigate={() => navigateTo('data-sources')} />
|
||||
</div>
|
||||
<div className="w-72 bg-white dark:bg-navy-950 shadow-lg z-10 flex flex-col">
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||
<div
|
||||
className={`${rightPaneCollapsed ? 'w-10' : 'w-72'} bg-white dark:bg-navy-950 shadow-lg z-10 flex flex-col`}
|
||||
>
|
||||
{rightPaneCollapsed ? (
|
||||
<button
|
||||
className={`flex-1 p-3 ${
|
||||
rightPaneTab === 'area'
|
||||
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
|
||||
: 'text-warm-600 dark:text-warm-400'
|
||||
}`}
|
||||
onClick={() => setRightPaneTab('area')}
|
||||
onClick={() => setRightPaneCollapsed(false)}
|
||||
className="w-full h-full flex flex-col items-center justify-center gap-2 text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400 hover:bg-warm-50 dark:hover:bg-navy-800"
|
||||
title="Expand panel"
|
||||
>
|
||||
Area {areaStats ? `(${areaStats.count})` : ''}
|
||||
<ChevronIcon direction="left" className="w-5 h-5" />
|
||||
<span className="text-xs font-medium writing-mode-vertical">
|
||||
{rightPaneTab === 'area'
|
||||
? 'Area'
|
||||
: rightPaneTab === 'properties'
|
||||
? 'Properties'
|
||||
: 'POIs'}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 p-3 ${
|
||||
rightPaneTab === 'properties'
|
||||
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
|
||||
: 'text-warm-600 dark:text-warm-400'
|
||||
}`}
|
||||
onClick={() => setRightPaneTab('properties')}
|
||||
>
|
||||
Properties {propertiesTotal > 0 && `(${propertiesTotal})`}
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 p-3 ${
|
||||
rightPaneTab === 'pois'
|
||||
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
|
||||
: 'text-warm-600 dark:text-warm-400'
|
||||
}`}
|
||||
onClick={() => setRightPaneTab('pois')}
|
||||
>
|
||||
POIs {pois.length > 0 && `(${pois.length})`}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||
<button
|
||||
onClick={() => setRightPaneCollapsed(true)}
|
||||
className="px-2 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 border-r border-warm-200 dark:border-navy-700"
|
||||
title="Collapse panel"
|
||||
>
|
||||
<ChevronIcon direction="right" className="w-4 h-4" />
|
||||
</button>
|
||||
<TabButton
|
||||
label="Area"
|
||||
count={areaStats?.count}
|
||||
isActive={rightPaneTab === 'area'}
|
||||
onClick={() => setRightPaneTab('area')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Properties"
|
||||
count={propertiesTotal > 0 ? propertiesTotal : undefined}
|
||||
isActive={rightPaneTab === 'properties'}
|
||||
onClick={() => setRightPaneTab('properties')}
|
||||
/>
|
||||
<TabButton
|
||||
label="POIs"
|
||||
count={pois.length > 0 ? pois.length : undefined}
|
||||
isActive={rightPaneTab === 'pois'}
|
||||
onClick={() => setRightPaneTab('pois')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{rightPaneTab === 'area' ? (
|
||||
<AreaPane
|
||||
stats={hoverMode && hoveredAreaStats ? hoveredAreaStats : areaStats}
|
||||
globalFeatures={features}
|
||||
loading={
|
||||
hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
|
||||
? loadingHoveredAreaStats
|
||||
: loadingAreaStats
|
||||
}
|
||||
hexagonId={
|
||||
hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
|
||||
? hoveredHexagon
|
||||
: selectedHexagon?.h3 || null
|
||||
}
|
||||
isHoveredPreview={
|
||||
!!(
|
||||
hoverMode &&
|
||||
hoveredAreaStats &&
|
||||
hoveredHexagon &&
|
||||
hoveredHexagon !== selectedHexagon?.h3
|
||||
)
|
||||
}
|
||||
hoverMode={hoverMode}
|
||||
onHoverModeChange={setHoverMode}
|
||||
onViewProperties={handleViewPropertiesFromArea}
|
||||
onClose={handleCloseProperties}
|
||||
hexagonLocation={(() => {
|
||||
const hexId =
|
||||
hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
|
||||
? hoveredHexagon
|
||||
: selectedHexagon?.h3;
|
||||
const hex = hexId ? data.find((d) => d.h3 === hexId) : null;
|
||||
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number')
|
||||
return null;
|
||||
return {
|
||||
lat: hex.lat as number,
|
||||
lon: hex.lon as number,
|
||||
resolution,
|
||||
};
|
||||
})()}
|
||||
filters={filters}
|
||||
/>
|
||||
) : rightPaneTab === 'properties' ? (
|
||||
<PropertiesPane
|
||||
properties={hoverMode && hoveredProperties ? hoveredProperties : properties}
|
||||
total={hoverMode && hoveredProperties ? hoveredPropertiesTotal : propertiesTotal}
|
||||
loading={loadingProperties}
|
||||
hexagonId={
|
||||
hoverMode && hoveredProperties ? hoveredHexagon : selectedHexagon?.h3 || null
|
||||
}
|
||||
onLoadMore={handleLoadMoreProperties}
|
||||
onClose={handleCloseProperties}
|
||||
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
|
||||
isHoveredPreview={
|
||||
!!(
|
||||
hoverMode &&
|
||||
hoveredProperties &&
|
||||
hoveredHexagon &&
|
||||
hoveredHexagon !== selectedHexagon?.h3
|
||||
)
|
||||
}
|
||||
hoverMode={hoverMode}
|
||||
onHoverModeChange={setHoverMode}
|
||||
/>
|
||||
) : (
|
||||
<POIPane
|
||||
groups={poiCategoryGroups}
|
||||
selectedCategories={selectedPOICategories}
|
||||
onCategoriesChange={setSelectedPOICategories}
|
||||
poiCount={pois.length}
|
||||
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{rightPaneTab === 'area' ? (
|
||||
<AreaPane
|
||||
stats={areaStats}
|
||||
globalFeatures={features}
|
||||
loading={loadingAreaStats}
|
||||
hexagonId={selectedHexagon?.id || null}
|
||||
isPostcode={selectedHexagon?.type === 'postcode'}
|
||||
postcodeData={
|
||||
selectedHexagon?.type === 'postcode'
|
||||
? postcodeData.find((d) => d.postcode === selectedHexagon.id) || null
|
||||
: null
|
||||
}
|
||||
onViewProperties={handleViewPropertiesFromArea}
|
||||
onClose={handleCloseProperties}
|
||||
hexagonLocation={(() => {
|
||||
const hexId = selectedHexagon?.id;
|
||||
const hex = hexId ? data.find((d) => d.h3 === hexId) : null;
|
||||
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number')
|
||||
return null;
|
||||
return {
|
||||
lat: hex.lat as number,
|
||||
lon: hex.lon as number,
|
||||
resolution,
|
||||
};
|
||||
})()}
|
||||
filters={filters}
|
||||
onNavigateToSource={(slug, featureName) => {
|
||||
navigateTo('data-sources', slug, featureName);
|
||||
}}
|
||||
/>
|
||||
) : rightPaneTab === 'properties' ? (
|
||||
<PropertiesPane
|
||||
properties={properties}
|
||||
total={propertiesTotal}
|
||||
loading={loadingProperties}
|
||||
hexagonId={selectedHexagon?.id || null}
|
||||
onLoadMore={handleLoadMoreProperties}
|
||||
onClose={handleCloseProperties}
|
||||
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
|
||||
/>
|
||||
) : (
|
||||
<POIPane
|
||||
groups={poiCategoryGroups}
|
||||
selectedCategories={selectedPOICategories}
|
||||
onCategoriesChange={setSelectedPOICategories}
|
||||
poiCount={pois.length}
|
||||
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue