Improve map
This commit is contained in:
parent
400f733956
commit
51967fa880
7 changed files with 794 additions and 353 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue