803 lines
26 KiB
TypeScript
803 lines
26 KiB
TypeScript
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 DataSourcesPage from './components/DataSourcesPage';
|
|
import HomePage from './components/HomePage';
|
|
import type {
|
|
FeatureMeta,
|
|
FeatureGroup,
|
|
FeatureFilters,
|
|
Bounds,
|
|
HexagonData,
|
|
ViewChangeParams,
|
|
ApiResponse,
|
|
POI,
|
|
POIResponse,
|
|
POICategoriesResponse,
|
|
POICategoryGroup,
|
|
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 {
|
|
const { hostname, pathname, href } = window.location;
|
|
|
|
// Check pathname for /proxy/PORT pattern
|
|
const pathMatch = pathname.match(/^(\/proxy\/)(\d+)/);
|
|
if (pathMatch) {
|
|
return `${pathMatch[1]}8001`;
|
|
}
|
|
|
|
// Check full href in case proxy rewrites pathname
|
|
const hrefMatch = href.match(/(\/proxy\/)\d+/);
|
|
if (hrefMatch) {
|
|
return `${hrefMatch[1]}8001`;
|
|
}
|
|
|
|
// If not localhost, assume we're behind a proxy and need explicit backend port
|
|
if (hostname !== 'localhost' && hostname !== '127.0.0.1') {
|
|
return '/proxy/8001';
|
|
}
|
|
|
|
// Local development - webpack proxies /api to :8001
|
|
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 ---
|
|
|
|
type Page = 'home' | 'dashboard' | 'data-sources';
|
|
|
|
function Header({
|
|
activePage,
|
|
onPageChange,
|
|
}: {
|
|
activePage: Page;
|
|
onPageChange: (page: Page) => void;
|
|
}) {
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const handleShare = useCallback(() => {
|
|
navigator.clipboard.writeText(window.location.href).then(() => {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
});
|
|
}, []);
|
|
|
|
const tabClass = (page: Page) =>
|
|
`px-3 py-1.5 rounded text-sm transition-colors ${
|
|
activePage === page
|
|
? 'bg-navy-700 font-semibold'
|
|
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
|
|
}`;
|
|
|
|
return (
|
|
<header className="h-12 bg-navy-900 text-white flex items-center justify-between px-4 shrink-0">
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
|
onClick={() => onPageChange('home')}
|
|
>
|
|
<svg
|
|
className="w-5 h-5 text-teal-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">Narrowit</span>
|
|
</button>
|
|
<nav className="flex items-center gap-1">
|
|
<button className={tabClass('home')} onClick={() => onPageChange('home')}>
|
|
Home
|
|
</button>
|
|
<button className={tabClass('dashboard')} onClick={() => onPageChange('dashboard')}>
|
|
Dashboard
|
|
</button>
|
|
<button className={tabClass('data-sources')} onClick={() => onPageChange('data-sources')}>
|
|
Data Sources
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
{activePage === 'dashboard' && (
|
|
<button
|
|
onClick={handleShare}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 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>(urlState.filters || {});
|
|
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
|
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
|
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
|
|
const [rawData, setRawData] = useState<HexagonData[]>([]);
|
|
const [dragData, setDragData] = useState<HexagonData[] | null>(null);
|
|
const [resolution, setResolution] = useState<number>(8);
|
|
const [bounds, setBounds] = useState<Bounds | null>(null);
|
|
const [loading, setLoading] = useState<boolean>(false);
|
|
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);
|
|
const dragAbortRef = 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 [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
|
|
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(
|
|
urlState.poiCategories || 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'>(urlState.tab || 'pois');
|
|
const [activePage, setActivePage] = useState<Page>('home');
|
|
|
|
// Derive enabled features from filter keys
|
|
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
|
|
|
|
// Derive view feature: active drag takes priority over pinned
|
|
const viewFeature = activeFeature || pinnedFeature;
|
|
const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null;
|
|
// Color range: always the feature's full slider range from metadata
|
|
const colorRange = useMemo((): [number, number] | null => {
|
|
if (!viewFeature) return null;
|
|
const meta = features.find((f) => f.name === viewFeature);
|
|
if (meta?.min != null && meta?.max != null) return [meta.min, meta.max];
|
|
return null;
|
|
}, [viewFeature, features]);
|
|
|
|
// Filter range: current drag or committed filter values, used for gray-out
|
|
const filterRange = useMemo((): [number, number] | null => {
|
|
if (!viewFeature) return null;
|
|
if (activeFeature && dragValue) return dragValue;
|
|
const filterVal = filters[viewFeature];
|
|
if (filterVal && typeof filterVal[0] === 'number') return filterVal as [number, number];
|
|
return null;
|
|
}, [viewFeature, activeFeature, dragValue, 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: { groups: FeatureGroup[] }) => {
|
|
// Flatten grouped response into a flat feature list with group annotation
|
|
const flat: FeatureMeta[] = json.groups.flatMap((g) =>
|
|
g.features.map((f) => ({ ...f, group: g.name }))
|
|
);
|
|
setFeatures(flat);
|
|
})
|
|
.catch((err) => console.error('Failed to fetch features:', err));
|
|
|
|
fetch(`${getApiBaseUrl()}/api/poi-categories`)
|
|
.then((res) => res.json())
|
|
.then((json: POICategoriesResponse) => {
|
|
setPOICategoryGroups(json.groups);
|
|
})
|
|
.catch((err) => console.error('Failed to fetch POI categories:', err));
|
|
}, []);
|
|
|
|
// Build filter query string helper
|
|
const buildFilterParam = useCallback((): string => {
|
|
const filterEntries = Object.entries(filters);
|
|
if (filterEntries.length === 0) return '';
|
|
return 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(',');
|
|
}, [filters, features]);
|
|
|
|
// Debounced fetch when resolution/bounds/filters change — always fetch hexagons
|
|
useEffect(() => {
|
|
if (!bounds) return;
|
|
|
|
if (debounceRef.current) {
|
|
clearTimeout(debounceRef.current);
|
|
}
|
|
|
|
debounceRef.current = setTimeout(async () => {
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
abortControllerRef.current = new AbortController();
|
|
|
|
setLoading(true);
|
|
try {
|
|
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
|
const filtersStr = buildFilterParam();
|
|
|
|
const params = new URLSearchParams({
|
|
resolution: resolution.toString(),
|
|
bounds: boundsStr,
|
|
});
|
|
if (filtersStr) params.set('filters', filtersStr);
|
|
const res = await fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
|
|
signal: abortControllerRef.current.signal,
|
|
});
|
|
const json: ApiResponse = await res.json();
|
|
setRawData(json.features || []);
|
|
} catch (err) {
|
|
if (err instanceof Error && err.name !== 'AbortError') {
|
|
console.error('Failed to fetch data:', err);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, DEBOUNCE_MS);
|
|
|
|
return () => {
|
|
if (debounceRef.current) {
|
|
clearTimeout(debounceRef.current);
|
|
}
|
|
};
|
|
}, [resolution, bounds, filters, buildFilterParam]);
|
|
|
|
// During slider drag, use the expanded dataset (without active feature filter)
|
|
// so both narrowing and expanding are visible. Otherwise use server-filtered data.
|
|
const data = dragData ?? rawData;
|
|
|
|
// Fetch POIs when bounds or selected categories change
|
|
useEffect(() => {
|
|
if (!bounds || selectedPOICategories.size === 0) {
|
|
setPois([]);
|
|
return;
|
|
}
|
|
|
|
if (poiDebounceRef.current) {
|
|
clearTimeout(poiDebounceRef.current);
|
|
}
|
|
|
|
poiDebounceRef.current = setTimeout(async () => {
|
|
if (poiAbortControllerRef.current) {
|
|
poiAbortControllerRef.current.abort();
|
|
}
|
|
poiAbortControllerRef.current = new AbortController();
|
|
|
|
try {
|
|
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
|
const categoriesStr = Array.from(selectedPOICategories).join(',');
|
|
const params = new URLSearchParams({
|
|
categories: categoriesStr,
|
|
bounds: boundsStr,
|
|
});
|
|
const res = await fetch(`${getApiBaseUrl()}/api/pois?${params}`, {
|
|
signal: poiAbortControllerRef.current.signal,
|
|
});
|
|
const json: POIResponse = await res.json();
|
|
setPois(json.pois || []);
|
|
} catch (err) {
|
|
if (err instanceof Error && err.name !== 'AbortError') {
|
|
console.error('Failed to fetch POIs:', err);
|
|
}
|
|
}
|
|
}, DEBOUNCE_MS);
|
|
|
|
return () => {
|
|
if (poiDebounceRef.current) {
|
|
clearTimeout(poiDebounceRef.current);
|
|
}
|
|
};
|
|
}, [bounds, selectedPOICategories]);
|
|
|
|
const prevBoundsRef = useRef<string>('');
|
|
const handleViewChange = useCallback(
|
|
({
|
|
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 });
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleAddFilter = useCallback(
|
|
(name: string) => {
|
|
const meta = features.find((f) => f.name === name);
|
|
if (!meta) return;
|
|
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 };
|
|
delete next[name];
|
|
return next;
|
|
});
|
|
setPinnedFeature((prev) => (prev === name ? null : prev));
|
|
}, []);
|
|
|
|
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);
|
|
const fval = filters[name];
|
|
setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null);
|
|
|
|
// Fetch hexagons without this feature's filter so we can expand the range
|
|
if (!bounds) return;
|
|
if (dragAbortRef.current) dragAbortRef.current.abort();
|
|
dragAbortRef.current = new AbortController();
|
|
|
|
const otherFilters = Object.entries(filters).filter(([k]) => k !== name);
|
|
let filtersStr = '';
|
|
if (otherFilters.length > 0) {
|
|
filtersStr = otherFilters
|
|
.map(([n, value]) => {
|
|
const m = features.find((f) => f.name === n);
|
|
if (m?.type === 'enum') return `${n}:${(value as string[]).join('|')}`;
|
|
const [min, max] = value as [number, number];
|
|
return `${n}:${min}:${max}`;
|
|
})
|
|
.join(',');
|
|
}
|
|
|
|
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
|
const params = new URLSearchParams({ resolution: resolution.toString(), bounds: boundsStr });
|
|
if (filtersStr) params.set('filters', filtersStr);
|
|
|
|
fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
|
|
signal: dragAbortRef.current.signal,
|
|
})
|
|
.then((res) => res.json())
|
|
.then((json: ApiResponse) => setDragData(json.features || []))
|
|
.catch((err) => {
|
|
if (err instanceof Error && err.name !== 'AbortError') {
|
|
console.error('Failed to fetch drag data:', err);
|
|
}
|
|
});
|
|
},
|
|
[filters, features, bounds, resolution]
|
|
);
|
|
|
|
const handleDragChange = useCallback((value: [number, number]) => {
|
|
setDragValue(value);
|
|
}, []);
|
|
|
|
const handleDragEnd = useCallback(() => {
|
|
if (activeFeature && dragValue) {
|
|
setFilters((prev) => ({ ...prev, [activeFeature]: dragValue }));
|
|
}
|
|
setActiveFeature(null);
|
|
setDragValue(null);
|
|
setDragData(null);
|
|
if (dragAbortRef.current) {
|
|
dragAbortRef.current.abort();
|
|
dragAbortRef.current = null;
|
|
}
|
|
}, [activeFeature, dragValue]);
|
|
|
|
const handleTogglePin = useCallback((name: string) => {
|
|
setPinnedFeature((prev) => (prev === name ? null : name));
|
|
}, []);
|
|
|
|
const handleCancelPin = useCallback(() => {
|
|
setPinnedFeature(null);
|
|
}, []);
|
|
|
|
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, 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);
|
|
}
|
|
|
|
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, features]
|
|
);
|
|
|
|
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 flex-col">
|
|
<Header activePage={activePage} onPageChange={setActivePage} />
|
|
{activePage === 'home' ? (
|
|
<HomePage onOpenDashboard={() => setActivePage('dashboard')} />
|
|
) : activePage === 'data-sources' ? (
|
|
<DataSourcesPage />
|
|
) : (
|
|
<div className="flex-1 flex 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}
|
|
pinnedFeature={pinnedFeature}
|
|
onTogglePin={handleTogglePin}
|
|
onCancelPin={handleCancelPin}
|
|
/>
|
|
<div className="flex-1 relative">
|
|
<Map
|
|
data={data}
|
|
pois={pois}
|
|
onViewChange={handleViewChange}
|
|
viewFeature={viewFeature}
|
|
colorRange={colorRange}
|
|
filterRange={filterRange}
|
|
viewSource={viewSource}
|
|
onCancelPin={handleCancelPin}
|
|
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 onNavigate={() => setActivePage('data-sources')} />
|
|
</div>
|
|
<div className="w-72 bg-white shadow-lg z-10 flex flex-col">
|
|
{/* Tab headers */}
|
|
<div className="flex border-b border-warm-200">
|
|
<button
|
|
className={`flex-1 p-3 ${
|
|
rightPaneTab === 'pois'
|
|
? 'border-b-2 border-teal-500 font-semibold'
|
|
: 'text-warm-600'
|
|
}`}
|
|
onClick={() => setRightPaneTab('pois')}
|
|
>
|
|
POIs {pois.length > 0 && `(${pois.length})`}
|
|
</button>
|
|
<button
|
|
className={`flex-1 p-3 ${
|
|
rightPaneTab === 'properties'
|
|
? 'border-b-2 border-teal-500 font-semibold'
|
|
: 'text-warm-600'
|
|
}`}
|
|
onClick={() => setRightPaneTab('properties')}
|
|
>
|
|
Properties {propertiesTotal > 0 && `(${propertiesTotal})`}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tab content */}
|
|
<div className="flex-1 overflow-hidden">
|
|
{rightPaneTab === 'pois' ? (
|
|
<POIPane
|
|
groups={poiCategoryGroups}
|
|
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>
|
|
);
|
|
}
|