This commit is contained in:
Andras Schmelczer 2026-02-02 21:56:35 +00:00
parent 2c613dc0d1
commit a677b9331f
28 changed files with 1647 additions and 1498 deletions

View file

@ -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}
/>