Add filter info
This commit is contained in:
parent
01ec17ff04
commit
f7d586a1e9
5 changed files with 682 additions and 234 deletions
|
|
@ -14,11 +14,13 @@ import type {
|
|||
POI,
|
||||
POIResponse,
|
||||
POICategoriesResponse,
|
||||
ViewState,
|
||||
Property,
|
||||
HexagonPropertiesResponse,
|
||||
} from './types';
|
||||
|
||||
const DEBOUNCE_MS = 150;
|
||||
const URL_DEBOUNCE_MS = 300;
|
||||
|
||||
// Detect if running through VS Code web proxy and construct API base URL
|
||||
function getApiBaseUrl(): string {
|
||||
|
|
@ -45,23 +47,240 @@ function getApiBaseUrl(): string {
|
|||
return '';
|
||||
}
|
||||
|
||||
const DEFAULT_VIEW: ViewState = {
|
||||
longitude: -1.5,
|
||||
latitude: 53.5,
|
||||
zoom: 6,
|
||||
pitch: 0,
|
||||
};
|
||||
|
||||
// --- URL State helpers ---
|
||||
|
||||
function parseUrlState(): {
|
||||
viewState?: ViewState;
|
||||
filters?: FeatureFilters;
|
||||
poiCategories?: Set<string>;
|
||||
tab?: 'pois' | 'properties';
|
||||
} {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const result: ReturnType<typeof parseUrlState> = {};
|
||||
|
||||
// Parse view: v=lat,lng,zoom
|
||||
const v = params.get('v');
|
||||
if (v) {
|
||||
const parts = v.split(',').map(Number);
|
||||
if (parts.length === 3 && parts.every((n) => !isNaN(n))) {
|
||||
result.viewState = {
|
||||
latitude: parts[0],
|
||||
longitude: parts[1],
|
||||
zoom: parts[2],
|
||||
pitch: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Parse filters: f=name:min:max,name:val1|val2
|
||||
const f = params.get('f');
|
||||
if (f) {
|
||||
const filters: FeatureFilters = {};
|
||||
for (const segment of f.split(',')) {
|
||||
const colonIdx = segment.indexOf(':');
|
||||
if (colonIdx === -1) continue;
|
||||
const name = segment.substring(0, colonIdx);
|
||||
const rest = segment.substring(colonIdx + 1);
|
||||
if (rest.includes(':')) {
|
||||
// Numeric: name:min:max
|
||||
const [minStr, maxStr] = rest.split(':');
|
||||
const min = Number(minStr);
|
||||
const max = Number(maxStr);
|
||||
if (!isNaN(min) && !isNaN(max)) {
|
||||
filters[name] = [min, max];
|
||||
}
|
||||
} else if (rest.includes('|')) {
|
||||
// Enum: name:val1|val2
|
||||
filters[name] = rest.split('|');
|
||||
} else {
|
||||
// Single enum value
|
||||
filters[name] = [rest];
|
||||
}
|
||||
}
|
||||
if (Object.keys(filters).length > 0) {
|
||||
result.filters = filters;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse POI categories: poi=Cafe,Pub,School
|
||||
const poi = params.get('poi');
|
||||
if (poi) {
|
||||
result.poiCategories = new Set(poi.split(',').filter(Boolean));
|
||||
}
|
||||
|
||||
// Parse tab: tab=p or tab=o
|
||||
const tab = params.get('tab');
|
||||
if (tab === 'p') result.tab = 'properties';
|
||||
else if (tab === 'o') result.tab = 'pois';
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function stateToParams(
|
||||
viewState: { latitude: number; longitude: number; zoom: number } | null,
|
||||
filters: FeatureFilters,
|
||||
features: FeatureMeta[],
|
||||
selectedPOICategories: Set<string>,
|
||||
rightPaneTab: 'pois' | 'properties'
|
||||
): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// View
|
||||
if (viewState) {
|
||||
params.set(
|
||||
'v',
|
||||
`${viewState.latitude.toFixed(4)},${viewState.longitude.toFixed(4)},${viewState.zoom.toFixed(1)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Filters
|
||||
const filterEntries = Object.entries(filters);
|
||||
if (filterEntries.length > 0) {
|
||||
const filtersStr = filterEntries
|
||||
.map(([name, value]) => {
|
||||
const meta = features.find((f) => f.name === name);
|
||||
if (meta?.type === 'enum') {
|
||||
return `${name}:${(value as string[]).join('|')}`;
|
||||
}
|
||||
const [min, max] = value as [number, number];
|
||||
return `${name}:${min}:${max}`;
|
||||
})
|
||||
.join(',');
|
||||
params.set('f', filtersStr);
|
||||
}
|
||||
|
||||
// POI categories
|
||||
if (selectedPOICategories.size > 0) {
|
||||
params.set('poi', Array.from(selectedPOICategories).join(','));
|
||||
}
|
||||
|
||||
// Tab (only if non-default)
|
||||
if (rightPaneTab === 'properties') {
|
||||
params.set('tab', 'p');
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
// --- Header ---
|
||||
|
||||
function Header() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleShare = useCallback(() => {
|
||||
navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="h-12 bg-slate-800 text-white flex items-center justify-between px-4 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-5 h-5 text-blue-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-semibold text-lg">Property Map</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-slate-700 hover:bg-slate-600 transition-colors text-sm"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
|
||||
/>
|
||||
</svg>
|
||||
Share
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// --- App ---
|
||||
|
||||
export default function App() {
|
||||
// Parse URL state once on mount
|
||||
const urlState = useMemo(() => parseUrlState(), []);
|
||||
|
||||
const [features, setFeatures] = useState<FeatureMeta[]>([]);
|
||||
const [filters, setFilters] = useState<FeatureFilters>({});
|
||||
const [filters, setFilters] = useState<FeatureFilters>(urlState.filters || {});
|
||||
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);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [zoom, setZoom] = useState<number>(6);
|
||||
const [zoom, setZoom] = useState<number>(urlState.viewState?.zoom || DEFAULT_VIEW.zoom);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// View state for URL serialization
|
||||
const [currentView, setCurrentView] = useState<{
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zoom: number;
|
||||
} | null>(
|
||||
urlState.viewState
|
||||
? {
|
||||
latitude: urlState.viewState.latitude,
|
||||
longitude: urlState.viewState.longitude,
|
||||
zoom: urlState.viewState.zoom,
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
// Initial view state for Map
|
||||
const initialViewState = useMemo(() => urlState.viewState || DEFAULT_VIEW, []);
|
||||
|
||||
// POI state
|
||||
const [pois, setPois] = useState<POI[]>([]);
|
||||
const [poiCategories, setPOICategories] = useState<string[]>([]);
|
||||
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(new Set());
|
||||
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(
|
||||
urlState.poiCategories || new Set()
|
||||
);
|
||||
const poiDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const poiAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
|
|
@ -73,18 +292,42 @@ export default function App() {
|
|||
const [propertiesTotal, setPropertiesTotal] = useState(0);
|
||||
const [propertiesOffset, setPropertiesOffset] = useState(0);
|
||||
const [loadingProperties, setLoadingProperties] = useState(false);
|
||||
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties'>('pois');
|
||||
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties'>(urlState.tab || 'pois');
|
||||
|
||||
// Derive enabled features from filter keys
|
||||
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
|
||||
|
||||
// --- URL sync ---
|
||||
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (urlDebounceRef.current) {
|
||||
clearTimeout(urlDebounceRef.current);
|
||||
}
|
||||
urlDebounceRef.current = setTimeout(() => {
|
||||
const params = stateToParams(
|
||||
currentView,
|
||||
filters,
|
||||
features,
|
||||
selectedPOICategories,
|
||||
rightPaneTab
|
||||
);
|
||||
const search = params.toString();
|
||||
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
|
||||
window.history.replaceState(null, '', newUrl);
|
||||
}, URL_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (urlDebounceRef.current) clearTimeout(urlDebounceRef.current);
|
||||
};
|
||||
}, [currentView, filters, features, selectedPOICategories, rightPaneTab]);
|
||||
|
||||
// Fetch feature metadata + POI categories on mount
|
||||
useEffect(() => {
|
||||
fetch(`${getApiBaseUrl()}/api/features`)
|
||||
.then((res) => res.json())
|
||||
.then((json: { features: FeatureMeta[] }) => {
|
||||
setFeatures(json.features);
|
||||
// Start with no filters (empty object)
|
||||
})
|
||||
.catch((err) => console.error('Failed to fetch features:', err));
|
||||
|
||||
|
|
@ -117,11 +360,18 @@ export default function App() {
|
|||
resolution: resolution.toString(),
|
||||
bounds: boundsStr,
|
||||
});
|
||||
// Build filters param: name:min:max,...
|
||||
// Build filters param: numeric=name:min:max, enum=name:val1|val2
|
||||
const filterEntries = Object.entries(filters);
|
||||
if (filterEntries.length > 0) {
|
||||
const filtersStr = filterEntries
|
||||
.map(([name, [min, max]]) => `${name}:${min}:${max}`)
|
||||
.map(([name, value]) => {
|
||||
const meta = features.find((f) => f.name === name);
|
||||
if (meta?.type === 'enum') {
|
||||
return `${name}:${(value as string[]).join('|')}`;
|
||||
}
|
||||
const [min, max] = value as [number, number];
|
||||
return `${name}:${min}:${max}`;
|
||||
})
|
||||
.join(',');
|
||||
params.set('filters', filtersStr);
|
||||
}
|
||||
|
|
@ -146,17 +396,8 @@ export default function App() {
|
|||
};
|
||||
}, [resolution, bounds, filters]);
|
||||
|
||||
// Client-side filtering: only during drag preview
|
||||
const data = useMemo(() => {
|
||||
if (!activeFeature || !dragValue) return rawData;
|
||||
|
||||
return rawData.filter((hex) => {
|
||||
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, activeFeature, dragValue]);
|
||||
// Data passed directly to Map — visual filtering is done via color/opacity in the layer
|
||||
const data = rawData;
|
||||
|
||||
// Fetch POIs when bounds or selected categories change
|
||||
useEffect(() => {
|
||||
|
|
@ -201,11 +442,18 @@ export default function App() {
|
|||
};
|
||||
}, [bounds, selectedPOICategories]);
|
||||
|
||||
const prevBoundsRef = useRef<string>('');
|
||||
const handleViewChange = useCallback(
|
||||
({ resolution: newRes, bounds: newBounds, zoom: newZoom }: ViewChangeParams) => {
|
||||
setResolution(newRes);
|
||||
setBounds(newBounds);
|
||||
({ resolution: newRes, bounds: newBounds, zoom: newZoom, latitude, longitude }: ViewChangeParams) => {
|
||||
// Only update bounds/resolution when quantized values actually change
|
||||
const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
|
||||
if (boundsKey !== prevBoundsRef.current) {
|
||||
prevBoundsRef.current = boundsKey;
|
||||
setResolution(newRes);
|
||||
setBounds(newBounds);
|
||||
}
|
||||
setZoom(newZoom);
|
||||
setCurrentView({ latitude, longitude, zoom: newZoom });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
|
@ -214,11 +462,22 @@ export default function App() {
|
|||
(name: string) => {
|
||||
const meta = features.find((f) => f.name === name);
|
||||
if (!meta) return;
|
||||
setFilters((prev) => ({ ...prev, [name]: [meta.min, meta.max] }));
|
||||
if (meta.type === 'enum' && meta.values) {
|
||||
setFilters((prev) => ({ ...prev, [name]: [...meta.values!] }));
|
||||
} else if (meta.min != null && meta.max != null) {
|
||||
setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] }));
|
||||
}
|
||||
},
|
||||
[features]
|
||||
);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(name: string, value: [number, number] | string[]) => {
|
||||
setFilters((prev) => ({ ...prev, [name]: value }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRemoveFilter = useCallback((name: string) => {
|
||||
setFilters((prev) => {
|
||||
const next = { ...prev };
|
||||
|
|
@ -229,10 +488,13 @@ export default function App() {
|
|||
|
||||
const handleDragStart = useCallback(
|
||||
(name: string) => {
|
||||
const meta = features.find((f) => f.name === name);
|
||||
if (meta?.type === 'enum') return; // No drag interaction for enum features
|
||||
setActiveFeature(name);
|
||||
setDragValue(filters[name] || null);
|
||||
const fval = filters[name];
|
||||
setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null);
|
||||
},
|
||||
[filters]
|
||||
[filters, features]
|
||||
);
|
||||
|
||||
const handleDragChange = useCallback((value: [number, number]) => {
|
||||
|
|
@ -262,7 +524,14 @@ export default function App() {
|
|||
const filterEntries = Object.entries(filters);
|
||||
if (filterEntries.length > 0) {
|
||||
const filterStr = filterEntries
|
||||
.map(([name, [min, max]]) => `${name}:${min}:${max}`)
|
||||
.map(([name, value]) => {
|
||||
const meta = features.find((f) => f.name === name);
|
||||
if (meta?.type === 'enum') {
|
||||
return `${name}:${(value as string[]).join('|')}`;
|
||||
}
|
||||
const [min, max] = value as [number, number];
|
||||
return `${name}:${min}:${max}`;
|
||||
})
|
||||
.join(',');
|
||||
params.append('filters', filterStr);
|
||||
}
|
||||
|
|
@ -283,7 +552,7 @@ export default function App() {
|
|||
setLoadingProperties(false);
|
||||
}
|
||||
},
|
||||
[filters]
|
||||
[filters, features]
|
||||
);
|
||||
|
||||
const handleHexagonClick = useCallback(
|
||||
|
|
@ -314,78 +583,83 @@ export default function App() {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex">
|
||||
<Filters
|
||||
features={features}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
enabledFeatures={enabledFeatures}
|
||||
onAddFilter={handleAddFilter}
|
||||
onRemoveFilter={handleRemoveFilter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragChange={handleDragChange}
|
||||
onDragEnd={handleDragEnd}
|
||||
zoom={zoom}
|
||||
/>
|
||||
<div className="flex-1 relative">
|
||||
<Map
|
||||
data={data}
|
||||
pois={pois}
|
||||
onViewChange={handleViewChange}
|
||||
<div className="h-screen flex flex-col">
|
||||
<Header />
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<Filters
|
||||
features={features}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
features={features}
|
||||
selectedHexagonId={selectedHexagon?.h3 || null}
|
||||
onHexagonClick={handleHexagonClick}
|
||||
enabledFeatures={enabledFeatures}
|
||||
onAddFilter={handleAddFilter}
|
||||
onRemoveFilter={handleRemoveFilter}
|
||||
onFilterChange={handleFilterChange}
|
||||
onDragStart={handleDragStart}
|
||||
onDragChange={handleDragChange}
|
||||
onDragEnd={handleDragEnd}
|
||||
zoom={zoom}
|
||||
/>
|
||||
{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 className="flex-1 relative">
|
||||
<Map
|
||||
data={data}
|
||||
pois={pois}
|
||||
onViewChange={handleViewChange}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
features={features}
|
||||
selectedHexagonId={selectedHexagon?.h3 || null}
|
||||
onHexagonClick={handleHexagonClick}
|
||||
initialViewState={initialViewState}
|
||||
/>
|
||||
{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>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue