Update chars

This commit is contained in:
Andras Schmelczer 2026-02-07 10:00:13 +00:00
parent 609dd5278c
commit 46585a4b2b
8 changed files with 522 additions and 285 deletions

View file

@ -11,7 +11,6 @@ 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,
@ -29,8 +28,9 @@ import type {
Property,
HexagonPropertiesResponse,
HexagonStatsResponse,
NumericFeatureStats,
} from './types';
import { fetchWithRetry, getApiBaseUrl, buildFilterString, apiUrl, logNonAbortError } from './lib/api';
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';
@ -108,8 +108,10 @@ 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 [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);
@ -503,6 +505,32 @@ export default function App() {
[filters, features]
);
/** Build stats from already-loaded PostcodeData (min/max per feature). */
const buildPostcodeStats = useCallback(
(postcode: string): HexagonStatsResponse | null => {
const pc = postcodeData.find((d) => d.postcode === postcode);
if (!pc) return null;
const numeric_features: NumericFeatureStats[] = [];
for (const f of features) {
if (f.type !== 'numeric') continue;
const minVal = pc[`min_${f.name}`];
const maxVal = pc[`max_${f.name}`];
if (typeof minVal !== 'number' || typeof maxVal !== 'number') continue;
numeric_features.push({
name: f.name,
count: pc.count,
min: minVal,
max: maxVal,
mean: (minVal + maxVal) / 2,
});
}
return { count: pc.count, numeric_features, enum_features: [] };
},
[postcodeData, features]
);
const fetchHexagonProperties = useCallback(
async (h3: string, res: number, offset = 0) => {
setLoadingProperties(true);
@ -545,12 +573,13 @@ export default function App() {
} else {
const type = isPostcode ? 'postcode' : 'hexagon';
setSelectedHexagon({ id, type, resolution });
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setRightPaneTab('area');
if (isPostcode) {
// For postcodes, we don't have a stats API yet, so skip
setAreaStats(null);
setAreaStats(buildPostcodeStats(id));
setLoadingAreaStats(false);
} else {
setLoadingAreaStats(true);
@ -561,7 +590,7 @@ export default function App() {
}
}
},
[selectedHexagon, resolution, fetchHexagonStats]
[selectedHexagon, resolution, fetchHexagonStats, buildPostcodeStats]
);
const handleHexagonHover = useCallback((h3: string | null) => {
@ -576,6 +605,14 @@ export default function App() {
}
}, [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);
@ -588,6 +625,40 @@ export default function App() {
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) {
return (
<div className="h-screen w-screen">
@ -660,46 +731,43 @@ export default function App() {
</div>
)}
<div
className={`flex ${leftPaneCollapsed ? 'w-10' : 'w-96'} bg-white dark:bg-navy-950 shadow-lg overflow-hidden`}
className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
style={{ width: leftPaneWidth }}
>
{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 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
@ -732,106 +800,91 @@ export default function App() {
<DataSources onNavigate={() => navigateTo('data-sources')} />
</div>
<div
className={`${rightPaneCollapsed ? 'w-10' : 'w-72'} bg-white dark:bg-navy-950 shadow-lg z-10 flex flex-col`}
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width: rightPaneWidth }}
>
{rightPaneCollapsed ? (
<button
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"
>
<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>
) : (
<>
<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="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"
count={areaStats?.count}
isActive={rightPaneTab === 'area'}
onClick={() => setRightPaneTab('area')}
/>
<TabButton
label="Properties"
count={propertiesTotal > 0 ? propertiesTotal : undefined}
isActive={rightPaneTab === 'properties'}
onClick={handlePropertiesTabClick}
/>
<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={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 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>
</div>
)}