Refactor
This commit is contained in:
parent
2c613dc0d1
commit
a677b9331f
28 changed files with 1647 additions and 1498 deletions
|
|
@ -9,6 +9,7 @@ import DataSources from './components/DataSources';
|
|||
import DataSourcesPage from './components/DataSourcesPage';
|
||||
import FAQPage from './components/FAQPage';
|
||||
import HomePage from './components/HomePage';
|
||||
import Header, { type Page } from './components/Header';
|
||||
import type {
|
||||
FeatureMeta,
|
||||
FeatureGroup,
|
||||
|
|
@ -21,321 +22,31 @@ import type {
|
|||
POIResponse,
|
||||
POICategoriesResponse,
|
||||
POICategoryGroup,
|
||||
ViewState,
|
||||
Property,
|
||||
HexagonPropertiesResponse,
|
||||
HexagonStatsResponse,
|
||||
} from './types';
|
||||
import { fetchWithRetry, getApiBaseUrl, buildFilterString } from './lib/api';
|
||||
import { parseUrlState, DEFAULT_VIEW } from './lib/url-state';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { useUrlSync } from './hooks/useUrlSync';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
declare global {
|
||||
interface Window {
|
||||
__og_ready?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 150;
|
||||
const URL_DEBOUNCE_MS = 300;
|
||||
const INITIAL_RETRY_MS = 1000;
|
||||
const MAX_RETRY_MS = 10000;
|
||||
|
||||
async function fetchWithRetry<T>(
|
||||
url: string,
|
||||
onSuccess: (data: T) => void,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
let delay = INITIAL_RETRY_MS;
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
const res = await fetch(url, { signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
onSuccess(json);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (signal.aborted) return;
|
||||
console.error(`Failed to fetch ${url}, retrying in ${delay}ms:`, err);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
delay = Math.min(delay * 2, MAX_RETRY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect if running through VS Code web proxy and construct API base URL
|
||||
function getApiBaseUrl(): string {
|
||||
// In production builds, always use same-origin (Rust server serves both API and frontend)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const { pathname, href } = window.location;
|
||||
|
||||
// Check pathname for /proxy/PORT pattern (VS Code web proxy)
|
||||
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`;
|
||||
}
|
||||
|
||||
// Default: same origin (works for local dev with webpack proxy)
|
||||
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' | 'area';
|
||||
} {
|
||||
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 or tab=a
|
||||
const tab = params.get('tab');
|
||||
if (tab === 'p') result.tab = 'properties';
|
||||
else if (tab === 'o') result.tab = 'pois';
|
||||
else if (tab === 'a') result.tab = 'area';
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function stateToParams(
|
||||
viewState: { latitude: number; longitude: number; zoom: number } | null,
|
||||
filters: FeatureFilters,
|
||||
features: FeatureMeta[],
|
||||
selectedPOICategories: Set<string>,
|
||||
rightPaneTab: 'pois' | 'properties' | 'area'
|
||||
): 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');
|
||||
} else if (rightPaneTab === 'area') {
|
||||
params.set('tab', 'a');
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
// --- Header ---
|
||||
|
||||
type Page = 'home' | 'dashboard' | 'data-sources' | 'faq';
|
||||
|
||||
function Header({
|
||||
activePage,
|
||||
onPageChange,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
}: {
|
||||
activePage: Page;
|
||||
onPageChange: (page: Page) => void;
|
||||
theme: Theme;
|
||||
onToggleTheme: () => 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 font-medium transition-colors ${
|
||||
activePage === page
|
||||
? 'bg-navy-700 text-white'
|
||||
: '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-2">
|
||||
<button className={tabClass('dashboard')} onClick={() => onPageChange('dashboard')}>
|
||||
Dashboard
|
||||
</button>
|
||||
<button className={tabClass('data-sources')} onClick={() => onPageChange('data-sources')}>
|
||||
Data Sources
|
||||
</button>
|
||||
<button className={tabClass('faq')} onClick={() => onPageChange('faq')}>
|
||||
FAQ
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onToggleTheme}
|
||||
className="flex items-center justify-center w-8 h-8 rounded bg-navy-800 hover:bg-navy-700 transition-colors"
|
||||
title={`Theme: ${theme}`}
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// --- App ---
|
||||
|
||||
export default function App() {
|
||||
// Parse URL state once on mount
|
||||
const urlState = useMemo(() => parseUrlState(), []);
|
||||
|
||||
const isScreenshotMode = useMemo(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('screenshot') === '1';
|
||||
}, []);
|
||||
|
||||
const [features, setFeatures] = useState<FeatureMeta[]>([]);
|
||||
const [filters, setFilters] = useState<FeatureFilters>(urlState.filters || {});
|
||||
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
||||
|
|
@ -351,7 +62,6 @@ export default function App() {
|
|||
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;
|
||||
|
|
@ -366,10 +76,8 @@ export default function App() {
|
|||
: 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>>(
|
||||
|
|
@ -378,7 +86,6 @@ export default function App() {
|
|||
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
|
||||
);
|
||||
|
|
@ -386,13 +93,13 @@ 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' | 'area'>(urlState.tab || 'pois');
|
||||
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties' | 'area'>(
|
||||
urlState.tab || 'pois'
|
||||
);
|
||||
|
||||
// Area stats state
|
||||
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
|
||||
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
|
||||
|
||||
// Hover state
|
||||
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
|
||||
const [hoveredAreaStats, setHoveredAreaStats] = useState<HexagonStatsResponse | null>(null);
|
||||
const [hoveredProperties, setHoveredProperties] = useState<Property[] | null>(null);
|
||||
|
|
@ -402,8 +109,9 @@ export default function App() {
|
|||
const hoverAbortRef = useRef<AbortController | null>(null);
|
||||
const hoverDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
|
||||
const [activePage, setActivePage] = useState<Page>(() => {
|
||||
// Restore from history state if available (e.g. back/forward navigation)
|
||||
if (isScreenshotMode) return 'dashboard';
|
||||
if (window.history.state?.page) return window.history.state.page;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.has('v') || params.has('f') || params.has('poi') || params.has('tab')
|
||||
|
|
@ -411,24 +119,21 @@ export default function App() {
|
|||
: 'home';
|
||||
});
|
||||
|
||||
// Feature name to auto-open in the info popup after back navigation
|
||||
const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(null);
|
||||
|
||||
// Navigate between pages with history support
|
||||
const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => {
|
||||
// Before pushing, tag the current state with the info feature so back restores it
|
||||
if (infoFeature) {
|
||||
window.history.replaceState({ ...window.history.state, infoFeature }, '');
|
||||
}
|
||||
const url = hash ? `${window.location.pathname}${window.location.search}#${hash}` : `${window.location.pathname}${window.location.search}`;
|
||||
const url = hash
|
||||
? `${window.location.pathname}${window.location.search}#${hash}`
|
||||
: `${window.location.pathname}${window.location.search}`;
|
||||
window.history.pushState({ page }, '', url);
|
||||
setActivePage(page);
|
||||
trackPageview();
|
||||
}, []);
|
||||
|
||||
// Handle browser back/forward
|
||||
useEffect(() => {
|
||||
// Tag the initial state so popstate can restore it
|
||||
if (!window.history.state?.page) {
|
||||
window.history.replaceState({ page: activePage }, '');
|
||||
}
|
||||
|
|
@ -444,37 +149,19 @@ export default function App() {
|
|||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Theme state — defaults to system preference on first visit
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const stored = localStorage.getItem('theme');
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
return 'light';
|
||||
});
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
// Sync dark class on <html> and persist to localStorage
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
if (isScreenshotMode && !initialLoading && rawData.length > 0) {
|
||||
window.__og_ready = true;
|
||||
}
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
}, [isScreenshotMode, initialLoading, rawData]);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
|
||||
}, []);
|
||||
|
||||
// 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: use the filter slider range when a numeric filter is active,
|
||||
// otherwise fall back to the feature's full range from metadata.
|
||||
// For enum features, use ordinal index range [0, values.length - 1].
|
||||
|
||||
const colorRange = useMemo((): [number, number] | null => {
|
||||
if (!viewFeature) return null;
|
||||
const meta = features.find((f) => f.name === viewFeature);
|
||||
|
|
@ -482,7 +169,6 @@ export default function App() {
|
|||
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
|
||||
return [0, meta.values.length - 1];
|
||||
}
|
||||
// Use live drag values or committed filter range if available
|
||||
if (activeFeature === viewFeature && dragValue) return dragValue;
|
||||
const filterVal = filters[viewFeature];
|
||||
if (filterVal && typeof filterVal[0] === 'number') return filterVal as [number, number];
|
||||
|
|
@ -490,7 +176,6 @@ export default function App() {
|
|||
return null;
|
||||
}, [viewFeature, features, activeFeature, dragValue, filters]);
|
||||
|
||||
// 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;
|
||||
|
|
@ -499,32 +184,8 @@ export default function App() {
|
|||
return null;
|
||||
}, [viewFeature, activeFeature, dragValue, filters]);
|
||||
|
||||
// --- URL sync ---
|
||||
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useUrlSync(currentView, filters, features, selectedPOICategories, rightPaneTab);
|
||||
|
||||
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({ ...window.history.state }, '', newUrl);
|
||||
}, URL_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (urlDebounceRef.current) clearTimeout(urlDebounceRef.current);
|
||||
};
|
||||
}, [currentView, filters, features, selectedPOICategories, rightPaneTab]);
|
||||
|
||||
// Fetch feature metadata + POI categories on mount with exponential backoff
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let featuresLoaded = false;
|
||||
|
|
@ -560,23 +221,11 @@ export default function App() {
|
|||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
// 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]);
|
||||
const buildFilterParam = useCallback(
|
||||
(): string => buildFilterString(filters, features),
|
||||
[filters, features]
|
||||
);
|
||||
|
||||
// Debounced fetch when resolution/bounds/filters change — always fetch hexagons
|
||||
useEffect(() => {
|
||||
if (!bounds) return;
|
||||
|
||||
|
|
@ -600,7 +249,6 @@ export default function App() {
|
|||
bounds: boundsStr,
|
||||
});
|
||||
if (filtersStr) params.set('filters', filtersStr);
|
||||
// Only request data for the actively viewed feature (reduces bandwidth)
|
||||
if (viewFeature) {
|
||||
params.set('fields', viewFeature);
|
||||
} else {
|
||||
|
|
@ -627,11 +275,8 @@ export default function App() {
|
|||
};
|
||||
}, [resolution, bounds, filters, buildFilterParam, viewFeature]);
|
||||
|
||||
// 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([]);
|
||||
|
|
@ -683,7 +328,6 @@ export default function App() {
|
|||
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;
|
||||
|
|
@ -725,12 +369,11 @@ 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
|
||||
if (meta?.type === 'enum') return;
|
||||
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();
|
||||
|
|
@ -751,7 +394,6 @@ export default function App() {
|
|||
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);
|
||||
// Only request the dragged feature's data
|
||||
params.set('fields', name);
|
||||
|
||||
fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
|
||||
|
|
@ -799,20 +441,8 @@ export default function App() {
|
|||
h3,
|
||||
resolution: res.toString(),
|
||||
});
|
||||
const filterEntries = Object.entries(filters);
|
||||
if (filterEntries.length > 0) {
|
||||
const filterStr = filterEntries
|
||||
.map(([name, value]) => {
|
||||
const meta = features.find((feature) => feature.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 filterStr = buildFilterString(filters, features);
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
if (fields) {
|
||||
params.set('fields', fields.join(','));
|
||||
}
|
||||
|
|
@ -833,21 +463,8 @@ export default function App() {
|
|||
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 filterStr = buildFilterString(filters, features);
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
|
||||
const response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`);
|
||||
const data: HexagonPropertiesResponse = await response.json();
|
||||
|
|
@ -871,14 +488,13 @@ export default function App() {
|
|||
const handleHexagonClick = useCallback(
|
||||
(h3: string) => {
|
||||
if (selectedHexagon?.h3 === h3) {
|
||||
// Deselect if clicking same hexagon
|
||||
setSelectedHexagon(null);
|
||||
setProperties([]);
|
||||
setAreaStats(null);
|
||||
} else {
|
||||
setSelectedHexagon({ h3, resolution });
|
||||
setPropertiesOffset(0);
|
||||
setRightPaneTab('area'); // Auto-switch to area tab
|
||||
setRightPaneTab('area');
|
||||
setLoadingAreaStats(true);
|
||||
fetchHexagonStats(h3, resolution)
|
||||
.then((stats) => setAreaStats(stats))
|
||||
|
|
@ -914,9 +530,13 @@ export default function App() {
|
|||
try {
|
||||
if (rightPaneTab === 'area') {
|
||||
setLoadingHoveredAreaStats(true);
|
||||
// On hover, only fetch stats for features that have active filters
|
||||
const hoverFields = Object.keys(filters);
|
||||
const stats = await fetchHexagonStats(h3, resolution, signal, hoverFields.length > 0 ? hoverFields : undefined);
|
||||
const stats = await fetchHexagonStats(
|
||||
h3,
|
||||
resolution,
|
||||
signal,
|
||||
hoverFields.length > 0 ? hoverFields : undefined
|
||||
);
|
||||
if (!signal.aborted) setHoveredAreaStats(stats);
|
||||
} else if (rightPaneTab === 'properties') {
|
||||
const params = new URLSearchParams({
|
||||
|
|
@ -925,18 +545,8 @@ export default function App() {
|
|||
limit: '3',
|
||||
offset: '0',
|
||||
});
|
||||
const filterEntries = Object.entries(filters);
|
||||
if (filterEntries.length > 0) {
|
||||
const filterStr = filterEntries
|
||||
.map(([name, value]) => {
|
||||
const meta = features.find((feature) => feature.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 filterStr = buildFilterString(filters, features);
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
const response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`, {
|
||||
signal,
|
||||
});
|
||||
|
|
@ -978,9 +588,38 @@ export default function App() {
|
|||
setAreaStats(null);
|
||||
}, []);
|
||||
|
||||
if (isScreenshotMode) {
|
||||
return (
|
||||
<div className="h-screen w-screen">
|
||||
<Map
|
||||
data={data}
|
||||
pois={pois}
|
||||
onViewChange={handleViewChange}
|
||||
viewFeature={viewFeature}
|
||||
colorRange={colorRange}
|
||||
filterRange={filterRange}
|
||||
viewSource={viewSource}
|
||||
onCancelPin={handleCancelPin}
|
||||
features={features}
|
||||
selectedHexagonId={null}
|
||||
hoveredHexagonId={null}
|
||||
onHexagonClick={() => {}}
|
||||
onHexagonHover={() => {}}
|
||||
initialViewState={initialViewState}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<Header activePage={activePage} onPageChange={navigateTo} theme={theme} onToggleTheme={toggleTheme} />
|
||||
<Header
|
||||
activePage={activePage}
|
||||
onPageChange={navigateTo}
|
||||
theme={theme}
|
||||
onToggleTheme={toggleTheme}
|
||||
/>
|
||||
{activePage === 'home' ? (
|
||||
<HomePage onOpenDashboard={() => navigateTo('dashboard')} theme={theme} />
|
||||
) : activePage === 'data-sources' ? (
|
||||
|
|
@ -1065,7 +704,6 @@ export default function App() {
|
|||
<DataSources onNavigate={() => navigateTo('data-sources')} />
|
||||
</div>
|
||||
<div className="w-72 bg-white dark:bg-navy-950 shadow-lg z-10 flex flex-col">
|
||||
{/* Tab headers */}
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||
<button
|
||||
className={`flex-1 p-3 ${
|
||||
|
|
@ -1099,34 +737,48 @@ export default function App() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{rightPaneTab === 'area' ? (
|
||||
<AreaPane
|
||||
stats={hoverMode && hoveredAreaStats ? hoveredAreaStats : areaStats}
|
||||
globalFeatures={features}
|
||||
loading={hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3 ? loadingHoveredAreaStats : loadingAreaStats}
|
||||
hexagonId={hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3 ? hoveredHexagon : selectedHexagon?.h3 || null}
|
||||
isHoveredPreview={!!(hoverMode && hoveredAreaStats && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3)}
|
||||
loading={
|
||||
hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
|
||||
? loadingHoveredAreaStats
|
||||
: loadingAreaStats
|
||||
}
|
||||
hexagonId={
|
||||
hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
|
||||
? hoveredHexagon
|
||||
: selectedHexagon?.h3 || null
|
||||
}
|
||||
isHoveredPreview={
|
||||
!!(
|
||||
hoverMode &&
|
||||
hoveredAreaStats &&
|
||||
hoveredHexagon &&
|
||||
hoveredHexagon !== selectedHexagon?.h3
|
||||
)
|
||||
}
|
||||
hoverMode={hoverMode}
|
||||
onHoverModeChange={setHoverMode}
|
||||
onViewProperties={handleViewPropertiesFromArea}
|
||||
onClose={handleCloseProperties}
|
||||
hexagonLocation={
|
||||
(() => {
|
||||
const hexId = hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
|
||||
hexagonLocation={(() => {
|
||||
const hexId =
|
||||
hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
|
||||
? hoveredHexagon
|
||||
: selectedHexagon?.h3;
|
||||
const hex = hexId ? data.find((d) => d.h3 === hexId) : null;
|
||||
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
|
||||
return {
|
||||
lat: hex.lat as number,
|
||||
lon: hex.lon as number,
|
||||
postcode: (hex.postcode as string | undefined) ?? null,
|
||||
resolution,
|
||||
};
|
||||
})()
|
||||
}
|
||||
const hex = hexId ? data.find((d) => d.h3 === hexId) : null;
|
||||
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number')
|
||||
return null;
|
||||
return {
|
||||
lat: hex.lat as number,
|
||||
lon: hex.lon as number,
|
||||
postcode: (hex.postcode as string | undefined) ?? null,
|
||||
resolution,
|
||||
};
|
||||
})()}
|
||||
filters={filters}
|
||||
/>
|
||||
) : rightPaneTab === 'properties' ? (
|
||||
|
|
@ -1134,11 +786,20 @@ export default function App() {
|
|||
properties={hoverMode && hoveredProperties ? hoveredProperties : properties}
|
||||
total={hoverMode && hoveredProperties ? hoveredPropertiesTotal : propertiesTotal}
|
||||
loading={loadingProperties}
|
||||
hexagonId={hoverMode && hoveredProperties ? hoveredHexagon : selectedHexagon?.h3 || null}
|
||||
hexagonId={
|
||||
hoverMode && hoveredProperties ? hoveredHexagon : selectedHexagon?.h3 || null
|
||||
}
|
||||
onLoadMore={handleLoadMoreProperties}
|
||||
onClose={handleCloseProperties}
|
||||
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
|
||||
isHoveredPreview={!!(hoverMode && hoveredProperties && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3)}
|
||||
isHoveredPreview={
|
||||
!!(
|
||||
hoverMode &&
|
||||
hoveredProperties &&
|
||||
hoveredHexagon &&
|
||||
hoveredHexagon !== selectedHexagon?.h3
|
||||
)
|
||||
}
|
||||
hoverMode={hoverMode}
|
||||
onHoverModeChange={setHoverMode}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue