Update chars
This commit is contained in:
parent
609dd5278c
commit
46585a4b2b
8 changed files with 522 additions and 285 deletions
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue