Update map to do filtering

This commit is contained in:
Andras Schmelczer 2026-01-30 18:34:12 +00:00
parent 6122ee44da
commit d4fe881ef4
8 changed files with 349 additions and 372 deletions

View file

@ -1,9 +1,9 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import Map from './components/Map';
import Filters from './components/Filters';
import { DEFAULT_FILTERS } from './lib/constants';
import type {
Filters as FiltersType,
FeatureMeta,
FeatureFilters,
Bounds,
HexagonData,
ViewChangeParams,
@ -11,7 +11,6 @@ import type {
POI,
POIResponse,
POICategoriesMap,
ColorMode,
} from './types';
const DEBOUNCE_MS = 150;
@ -42,8 +41,10 @@ function getApiBaseUrl(): string {
}
export default function App() {
const [filters, setFilters] = useState<FiltersType>(DEFAULT_FILTERS);
const [data, setData] = useState<HexagonData[]>([]);
const [features, setFeatures] = useState<FeatureMeta[]>([]);
const [filters, setFilters] = useState<FeatureFilters>({});
const [activeFeature, setActiveFeature] = useState<string | null>(null);
const [rawData, setRawData] = useState<HexagonData[]>([]);
const [resolution, setResolution] = useState<number>(8);
const [bounds, setBounds] = useState<Bounds | null>(null);
const [loading, setLoading] = useState<boolean>(false);
@ -51,8 +52,6 @@ export default function App() {
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const [colorMode, setColorMode] = useState<ColorMode>('price');
// POI state
const [pois, setPois] = useState<POI[]>([]);
const [poiCategories, setPOICategories] = useState<POICategoriesMap>({});
@ -60,8 +59,21 @@ export default function App() {
const poiDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const poiAbortControllerRef = useRef<AbortController | null>(null);
// Fetch POI category definitions from server on mount
// 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);
})
.catch((err) => console.error('Failed to fetch features:', err));
fetch(`${getApiBaseUrl()}/api/poi-categories`)
.then((res) => res.json())
.then((json: { categories: POICategoriesMap }) => {
@ -70,7 +82,7 @@ export default function App() {
.catch((err) => console.error('Failed to fetch POI categories:', err));
}, []);
// Debounced fetch when dependencies change
// Debounced fetch when resolution/bounds change (no filter params sent)
useEffect(() => {
if (!bounds) return;
@ -89,17 +101,13 @@ export default function App() {
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const params = new URLSearchParams({
resolution: resolution.toString(),
min_year: filters.minYear.toString(),
max_year: filters.maxYear.toString(),
min_price: filters.minPrice.toString(),
max_price: filters.maxPrice.toString(),
bounds: boundsStr,
});
const res = await fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
signal: abortControllerRef.current.signal,
});
const json: ApiResponse = await res.json();
setData(json.features || []);
setRawData(json.features || []);
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
console.error('Failed to fetch data:', err);
@ -114,7 +122,36 @@ export default function App() {
clearTimeout(debounceRef.current);
}
};
}, [filters, resolution, bounds]);
}, [resolution, bounds]);
// Client-side filtering
const data = useMemo(() => {
if (features.length === 0) 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;
});
}, [rawData, filters, activeFeature, features]);
// Fetch POIs when bounds or selected categories change
useEffect(() => {
@ -171,17 +208,24 @@ export default function App() {
return (
<div className="h-screen flex">
<Filters
features={features}
filters={filters}
onChange={setFilters}
activeFeature={activeFeature}
onFiltersChange={setFilters}
onActiveFeatureChange={setActiveFeature}
zoom={zoom}
poiCategories={poiCategories}
selectedPOICategories={selectedPOICategories}
onPOICategoriesChange={setSelectedPOICategories}
colorMode={colorMode}
onColorModeChange={setColorMode}
/>
<div className="flex-1 relative">
<Map data={data} pois={pois} onViewChange={handleViewChange} colorMode={colorMode} />
<Map
data={data}
pois={pois}
onViewChange={handleViewChange}
activeFeature={activeFeature}
features={features}
/>
{loading && (
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">Loading...</div>
)}