Improve map

This commit is contained in:
Andras Schmelczer 2026-01-31 12:49:56 +00:00
parent 400f733956
commit 51967fa880
7 changed files with 794 additions and 353 deletions

View file

@ -1,6 +1,9 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import Map from './components/Map';
import Filters from './components/Filters';
import POIPane from './components/POIPane';
import { PropertiesPane } from './components/PropertiesPane';
import DataSources from './components/DataSources';
import type {
FeatureMeta,
FeatureFilters,
@ -10,7 +13,9 @@ import type {
ApiResponse,
POI,
POIResponse,
POICategoriesMap,
POICategoriesResponse,
Property,
HexagonPropertiesResponse,
} from './types';
const DEBOUNCE_MS = 150;
@ -44,6 +49,7 @@ export default function App() {
const [features, setFeatures] = useState<FeatureMeta[]>([]);
const [filters, setFilters] = useState<FeatureFilters>({});
const [activeFeature, setActiveFeature] = useState<string | null>(null);
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
const [rawData, setRawData] = useState<HexagonData[]>([]);
const [resolution, setResolution] = useState<number>(8);
const [bounds, setBounds] = useState<Bounds | null>(null);
@ -54,35 +60,41 @@ export default function App() {
// POI state
const [pois, setPois] = useState<POI[]>([]);
const [poiCategories, setPOICategories] = useState<POICategoriesMap>({});
const [poiCategories, setPOICategories] = useState<string[]>([]);
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(new Set());
const poiDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const poiAbortControllerRef = useRef<AbortController | null>(null);
// Hexagon properties state
const [selectedHexagon, setSelectedHexagon] = useState<{ h3: string; 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'>('pois');
// Derive enabled features from filter keys
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
// Fetch feature metadata + POI categories on mount
useEffect(() => {
fetch(`${getApiBaseUrl()}/api/features`)
.then((res) => res.json())
.then((json: { features: FeatureMeta[] }) => {
setFeatures(json.features);
// Initialize filters with full range for each feature
const initial: FeatureFilters = {};
for (const f of json.features) {
initial[f.name] = [f.min, f.max];
}
setFilters(initial);
// Start with no filters (empty object)
})
.catch((err) => console.error('Failed to fetch features:', err));
fetch(`${getApiBaseUrl()}/api/poi-categories`)
.then((res) => res.json())
.then((json: { categories: POICategoriesMap }) => {
.then((json: POICategoriesResponse) => {
setPOICategories(json.categories);
})
.catch((err) => console.error('Failed to fetch POI categories:', err));
}, []);
// Debounced fetch when resolution/bounds change (no filter params sent)
// Debounced fetch when resolution/bounds/filters change
useEffect(() => {
if (!bounds) return;
@ -103,6 +115,14 @@ export default function App() {
resolution: resolution.toString(),
bounds: boundsStr,
});
// Build filters param: name:min:max,...
const filterEntries = Object.entries(filters);
if (filterEntries.length > 0) {
const filtersStr = filterEntries
.map(([name, [min, max]]) => `${name}:${min}:${max}`)
.join(',');
params.set('filters', filtersStr);
}
const res = await fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
signal: abortControllerRef.current.signal,
});
@ -122,36 +142,19 @@ export default function App() {
clearTimeout(debounceRef.current);
}
};
}, [resolution, bounds]);
}, [resolution, bounds, filters]);
// Client-side filtering
// Client-side filtering: only during drag preview
const data = useMemo(() => {
if (features.length === 0) return rawData;
if (!activeFeature || !dragValue) return rawData;
return rawData.filter((hex) => {
if (activeFeature) {
// Only apply the active feature's filter
const range = filters[activeFeature];
if (!range) return true;
const minVal = hex[`min_${activeFeature}`];
const maxVal = hex[`max_${activeFeature}`];
if (minVal == null || maxVal == null) return true;
return (minVal as number) <= range[1] && (maxVal as number) >= range[0];
}
// Apply ALL filters as intersection
for (const f of features) {
const range = filters[f.name];
if (!range) continue;
// Skip features where filter is at full range
if (range[0] === f.min && range[1] === f.max) continue;
const minVal = hex[`min_${f.name}`];
const maxVal = hex[`max_${f.name}`];
if (minVal == null || maxVal == null) continue;
if ((minVal as number) > range[1] || (maxVal as number) < range[0]) return false;
}
return true;
const minVal = hex[`min_${activeFeature}`];
const maxVal = hex[`max_${activeFeature}`];
if (minVal == null || maxVal == null) return false;
return (minVal as number) <= dragValue[1] && (maxVal as number) >= dragValue[0];
});
}, [rawData, filters, activeFeature, features]);
}, [rawData, activeFeature, dragValue]);
// Fetch POIs when bounds or selected categories change
useEffect(() => {
@ -181,7 +184,7 @@ export default function App() {
signal: poiAbortControllerRef.current.signal,
});
const json: POIResponse = await res.json();
setPois(json.features || []);
setPois(json.pois || []);
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
console.error('Failed to fetch POIs:', err);
@ -205,18 +208,123 @@ export default function App() {
[]
);
const handleAddFilter = useCallback(
(name: string) => {
const meta = features.find((f) => f.name === name);
if (!meta) return;
setFilters((prev) => ({ ...prev, [name]: [meta.min, meta.max] }));
},
[features]
);
const handleRemoveFilter = useCallback((name: string) => {
setFilters((prev) => {
const next = { ...prev };
delete next[name];
return next;
});
}, []);
const handleDragStart = useCallback(
(name: string) => {
setActiveFeature(name);
setDragValue(filters[name] || null);
},
[filters]
);
const handleDragChange = useCallback((value: [number, number]) => {
setDragValue(value);
}, []);
const handleDragEnd = useCallback(() => {
if (activeFeature && dragValue) {
setFilters((prev) => ({ ...prev, [activeFeature]: dragValue }));
}
setActiveFeature(null);
setDragValue(null);
}, [activeFeature, dragValue]);
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(),
});
// Add current filters
const filterEntries = Object.entries(filters);
if (filterEntries.length > 0) {
const filterStr = filterEntries
.map(([name, [min, max]]) => `${name}:${min}:${max}`)
.join(',');
params.append('filters', filterStr);
}
const response = await fetch(`${getApiBaseUrl()}/api/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]
);
const handleHexagonClick = useCallback(
(h3: string) => {
if (selectedHexagon?.h3 === h3) {
// Deselect if clicking same hexagon
setSelectedHexagon(null);
setProperties([]);
} else {
setSelectedHexagon({ h3, resolution });
setPropertiesOffset(0);
setRightPaneTab('properties'); // Auto-switch to properties tab
fetchHexagonProperties(h3, resolution, 0);
}
},
[selectedHexagon, resolution, fetchHexagonProperties]
);
const handleLoadMoreProperties = useCallback(() => {
if (selectedHexagon) {
fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, propertiesOffset);
}
}, [selectedHexagon, propertiesOffset, fetchHexagonProperties]);
const handleCloseProperties = useCallback(() => {
setSelectedHexagon(null);
setProperties([]);
}, []);
return (
<div className="h-screen flex">
<Filters
features={features}
filters={filters}
activeFeature={activeFeature}
onFiltersChange={setFilters}
onActiveFeatureChange={setActiveFeature}
dragValue={dragValue}
enabledFeatures={enabledFeatures}
onAddFilter={handleAddFilter}
onRemoveFilter={handleRemoveFilter}
onDragStart={handleDragStart}
onDragChange={handleDragChange}
onDragEnd={handleDragEnd}
zoom={zoom}
poiCategories={poiCategories}
selectedPOICategories={selectedPOICategories}
onPOICategoriesChange={setSelectedPOICategories}
/>
<div className="flex-1 relative">
<Map
@ -224,11 +332,61 @@ export default function App() {
pois={pois}
onViewChange={handleViewChange}
activeFeature={activeFeature}
dragValue={dragValue}
features={features}
selectedHexagonId={selectedHexagon?.h3 || null}
onHexagonClick={handleHexagonClick}
/>
{loading && (
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">Loading...</div>
)}
<DataSources />
</div>
<div className="w-72 bg-white shadow-lg z-10 flex flex-col">
{/* Tab headers */}
<div className="flex border-b border-gray-200">
<button
className={`flex-1 p-3 ${
rightPaneTab === 'pois'
? 'border-b-2 border-blue-500 font-semibold'
: 'text-gray-600'
}`}
onClick={() => setRightPaneTab('pois')}
>
POIs {pois.length > 0 && `(${pois.length})`}
</button>
<button
className={`flex-1 p-3 ${
rightPaneTab === 'properties'
? 'border-b-2 border-blue-500 font-semibold'
: 'text-gray-600'
}`}
onClick={() => setRightPaneTab('properties')}
>
Properties {propertiesTotal > 0 && `(${propertiesTotal})`}
</button>
</div>
{/* Tab content */}
<div className="flex-1 overflow-hidden">
{rightPaneTab === 'pois' ? (
<POIPane
categories={poiCategories}
selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length}
/>
) : (
<PropertiesPane
properties={properties}
total={propertiesTotal}
loading={loadingProperties}
hexagonId={selectedHexagon?.h3 || null}
onLoadMore={handleLoadMoreProperties}
onClose={handleCloseProperties}
/>
)}
</div>
</div>
</div>
);