Checkpoint all changes
This commit is contained in:
parent
65877acf95
commit
66c2a25457
28 changed files with 3035 additions and 621 deletions
|
|
@ -3,8 +3,10 @@ import Map from './components/Map';
|
|||
import Filters from './components/Filters';
|
||||
import POIPane from './components/POIPane';
|
||||
import { PropertiesPane } from './components/PropertiesPane';
|
||||
import AreaPane from './components/AreaPane';
|
||||
import DataSources from './components/DataSources';
|
||||
import DataSourcesPage from './components/DataSourcesPage';
|
||||
import FAQPage from './components/FAQPage';
|
||||
import HomePage from './components/HomePage';
|
||||
import type {
|
||||
FeatureMeta,
|
||||
|
|
@ -21,18 +23,43 @@ import type {
|
|||
ViewState,
|
||||
Property,
|
||||
HexagonPropertiesResponse,
|
||||
HexagonStatsResponse,
|
||||
} from './types';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
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 {
|
||||
const { hostname, pathname, href } = window.location;
|
||||
const { pathname, href } = window.location;
|
||||
|
||||
// Check pathname for /proxy/PORT pattern
|
||||
// Check pathname for /proxy/PORT pattern (VS Code web proxy)
|
||||
const pathMatch = pathname.match(/^(\/proxy\/)(\d+)/);
|
||||
if (pathMatch) {
|
||||
return `${pathMatch[1]}8001`;
|
||||
|
|
@ -44,12 +71,7 @@ function getApiBaseUrl(): string {
|
|||
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
|
||||
// Default: same origin (works for both local dev with webpack proxy and production)
|
||||
return '';
|
||||
}
|
||||
|
||||
|
|
@ -66,7 +88,7 @@ function parseUrlState(): {
|
|||
viewState?: ViewState;
|
||||
filters?: FeatureFilters;
|
||||
poiCategories?: Set<string>;
|
||||
tab?: 'pois' | 'properties';
|
||||
tab?: 'pois' | 'properties' | 'area';
|
||||
} {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const result: ReturnType<typeof parseUrlState> = {};
|
||||
|
|
@ -121,10 +143,11 @@ function parseUrlState(): {
|
|||
result.poiCategories = new Set(poi.split(',').filter(Boolean));
|
||||
}
|
||||
|
||||
// Parse tab: tab=p or tab=o
|
||||
// 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;
|
||||
}
|
||||
|
|
@ -134,7 +157,7 @@ function stateToParams(
|
|||
filters: FeatureFilters,
|
||||
features: FeatureMeta[],
|
||||
selectedPOICategories: Set<string>,
|
||||
rightPaneTab: 'pois' | 'properties'
|
||||
rightPaneTab: 'pois' | 'properties' | 'area'
|
||||
): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
|
|
@ -170,6 +193,8 @@ function stateToParams(
|
|||
// Tab (only if non-default)
|
||||
if (rightPaneTab === 'properties') {
|
||||
params.set('tab', 'p');
|
||||
} else if (rightPaneTab === 'area') {
|
||||
params.set('tab', 'a');
|
||||
}
|
||||
|
||||
return params;
|
||||
|
|
@ -177,7 +202,7 @@ function stateToParams(
|
|||
|
||||
// --- Header ---
|
||||
|
||||
type Page = 'home' | 'dashboard' | 'data-sources';
|
||||
type Page = 'home' | 'dashboard' | 'data-sources' | 'faq';
|
||||
|
||||
function Header({
|
||||
activePage,
|
||||
|
|
@ -200,9 +225,9 @@ function Header({
|
|||
}, []);
|
||||
|
||||
const tabClass = (page: Page) =>
|
||||
`px-3 py-1.5 rounded text-sm transition-colors ${
|
||||
`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||
activePage === page
|
||||
? 'bg-navy-700 font-semibold'
|
||||
? 'bg-navy-700 text-white'
|
||||
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
|
||||
}`;
|
||||
|
||||
|
|
@ -234,16 +259,16 @@ function Header({
|
|||
</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>
|
||||
<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">
|
||||
|
|
@ -355,8 +380,62 @@ 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'>(urlState.tab || 'pois');
|
||||
const [activePage, setActivePage] = useState<Page>('home');
|
||||
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);
|
||||
const [hoveredPropertiesTotal, setHoveredPropertiesTotal] = useState(0);
|
||||
const [loadingHoveredAreaStats, setLoadingHoveredAreaStats] = useState(false);
|
||||
const [hoverMode, setHoverMode] = useState(true);
|
||||
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 (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')
|
||||
? 'dashboard'
|
||||
: '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}`;
|
||||
window.history.pushState({ page }, '', url);
|
||||
setActivePage(page);
|
||||
}, []);
|
||||
|
||||
// Handle browser back/forward
|
||||
useEffect(() => {
|
||||
// Tag the initial state so popstate can restore it
|
||||
if (!window.history.state?.page) {
|
||||
window.history.replaceState({ page: activePage }, '');
|
||||
}
|
||||
const handlePopState = (e: PopStateEvent) => {
|
||||
if (e.state?.page) {
|
||||
setActivePage(e.state.page);
|
||||
if (e.state.infoFeature) {
|
||||
setPendingInfoFeature(e.state.infoFeature);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
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>(() => {
|
||||
|
|
@ -387,10 +466,15 @@ export default function App() {
|
|||
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
|
||||
// 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);
|
||||
if (meta?.min != null && meta?.max != null) return [meta.min, meta.max];
|
||||
if (!meta) return null;
|
||||
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
|
||||
return [0, meta.values.length - 1];
|
||||
}
|
||||
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
|
||||
return null;
|
||||
}, [viewFeature, features]);
|
||||
|
||||
|
|
@ -420,7 +504,7 @@ export default function App() {
|
|||
);
|
||||
const search = params.toString();
|
||||
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
|
||||
window.history.replaceState(null, '', newUrl);
|
||||
window.history.replaceState({ ...window.history.state }, '', newUrl);
|
||||
}, URL_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
|
|
@ -428,25 +512,40 @@ export default function App() {
|
|||
};
|
||||
}, [currentView, filters, features, selectedPOICategories, rightPaneTab]);
|
||||
|
||||
// Fetch feature metadata + POI categories on mount
|
||||
// Fetch feature metadata + POI categories on mount with exponential backoff
|
||||
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 controller = new AbortController();
|
||||
let featuresLoaded = false;
|
||||
let poisLoaded = false;
|
||||
|
||||
const checkDone = () => {
|
||||
if (featuresLoaded && poisLoaded) setInitialLoading(false);
|
||||
};
|
||||
|
||||
fetchWithRetry<{ groups: FeatureGroup[] }>(
|
||||
`${getApiBaseUrl()}/api/features`,
|
||||
(json) => {
|
||||
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));
|
||||
featuresLoaded = true;
|
||||
checkDone();
|
||||
},
|
||||
controller.signal
|
||||
);
|
||||
|
||||
fetch(`${getApiBaseUrl()}/api/poi-categories`)
|
||||
.then((res) => res.json())
|
||||
.then((json: POICategoriesResponse) => {
|
||||
fetchWithRetry<POICategoriesResponse>(
|
||||
`${getApiBaseUrl()}/api/poi-categories`,
|
||||
(json) => {
|
||||
setPOICategoryGroups(json.groups);
|
||||
})
|
||||
.catch((err) => console.error('Failed to fetch POI categories:', err));
|
||||
poisLoaded = true;
|
||||
checkDone();
|
||||
},
|
||||
controller.signal
|
||||
);
|
||||
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
// Build filter query string helper
|
||||
|
|
@ -674,6 +773,32 @@ export default function App() {
|
|||
setPinnedFeature(null);
|
||||
}, []);
|
||||
|
||||
const fetchHexagonStats = useCallback(
|
||||
async (h3: string, res: number, signal?: AbortSignal) => {
|
||||
const params = new URLSearchParams({
|
||||
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 response = await fetch(`${getApiBaseUrl()}/api/hexagon-stats?${params}`, { signal });
|
||||
return (await response.json()) as HexagonStatsResponse;
|
||||
},
|
||||
[filters, features]
|
||||
);
|
||||
|
||||
const fetchHexagonProperties = useCallback(
|
||||
async (h3: string, res: number, offset = 0) => {
|
||||
setLoadingProperties(true);
|
||||
|
|
@ -726,16 +851,96 @@ export default function App() {
|
|||
// Deselect if clicking same hexagon
|
||||
setSelectedHexagon(null);
|
||||
setProperties([]);
|
||||
setAreaStats(null);
|
||||
} else {
|
||||
setSelectedHexagon({ h3, resolution });
|
||||
setPropertiesOffset(0);
|
||||
setRightPaneTab('properties'); // Auto-switch to properties tab
|
||||
fetchHexagonProperties(h3, resolution, 0);
|
||||
setRightPaneTab('area'); // Auto-switch to area tab
|
||||
setLoadingAreaStats(true);
|
||||
fetchHexagonStats(h3, resolution)
|
||||
.then((stats) => setAreaStats(stats))
|
||||
.catch((error) => {
|
||||
if (error instanceof Error && error.name !== 'AbortError') {
|
||||
console.error('Failed to fetch area stats:', error);
|
||||
}
|
||||
})
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
}
|
||||
},
|
||||
[selectedHexagon, resolution, fetchHexagonProperties]
|
||||
[selectedHexagon, resolution, fetchHexagonStats]
|
||||
);
|
||||
|
||||
const handleHexagonHover = useCallback(
|
||||
(h3: string | null) => {
|
||||
setHoveredHexagon(h3);
|
||||
if (!hoverMode || !h3 || h3 === selectedHexagon?.h3) {
|
||||
if (hoverDebounceRef.current) clearTimeout(hoverDebounceRef.current);
|
||||
if (hoverAbortRef.current) hoverAbortRef.current.abort();
|
||||
setHoveredAreaStats(null);
|
||||
setHoveredProperties(null);
|
||||
setHoveredPropertiesTotal(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hoverDebounceRef.current) clearTimeout(hoverDebounceRef.current);
|
||||
hoverDebounceRef.current = setTimeout(async () => {
|
||||
if (hoverAbortRef.current) hoverAbortRef.current.abort();
|
||||
hoverAbortRef.current = new AbortController();
|
||||
const signal = hoverAbortRef.current.signal;
|
||||
|
||||
try {
|
||||
if (rightPaneTab === 'area') {
|
||||
setLoadingHoveredAreaStats(true);
|
||||
const stats = await fetchHexagonStats(h3, resolution, signal);
|
||||
if (!signal.aborted) setHoveredAreaStats(stats);
|
||||
} else if (rightPaneTab === 'properties') {
|
||||
const params = new URLSearchParams({
|
||||
h3,
|
||||
resolution: resolution.toString(),
|
||||
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 response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`, {
|
||||
signal,
|
||||
});
|
||||
const data: HexagonPropertiesResponse = await response.json();
|
||||
if (!signal.aborted) {
|
||||
setHoveredProperties(data.properties);
|
||||
setHoveredPropertiesTotal(data.total);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name !== 'AbortError') {
|
||||
console.error('Failed to fetch hover data:', error);
|
||||
}
|
||||
} finally {
|
||||
if (!signal.aborted) setLoadingHoveredAreaStats(false);
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
},
|
||||
[hoverMode, selectedHexagon, rightPaneTab, resolution, filters, features, fetchHexagonStats]
|
||||
);
|
||||
|
||||
const handleViewPropertiesFromArea = useCallback(() => {
|
||||
if (selectedHexagon) {
|
||||
setRightPaneTab('properties');
|
||||
setPropertiesOffset(0);
|
||||
fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, 0);
|
||||
}
|
||||
}, [selectedHexagon, fetchHexagonProperties]);
|
||||
|
||||
const handleLoadMoreProperties = useCallback(() => {
|
||||
if (selectedHexagon) {
|
||||
fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, propertiesOffset);
|
||||
|
|
@ -745,17 +950,48 @@ export default function App() {
|
|||
const handleCloseProperties = useCallback(() => {
|
||||
setSelectedHexagon(null);
|
||||
setProperties([]);
|
||||
setAreaStats(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<Header activePage={activePage} onPageChange={setActivePage} theme={theme} onToggleTheme={toggleTheme} />
|
||||
<Header activePage={activePage} onPageChange={navigateTo} theme={theme} onToggleTheme={toggleTheme} />
|
||||
{activePage === 'home' ? (
|
||||
<HomePage onOpenDashboard={() => setActivePage('dashboard')} theme={theme} />
|
||||
<HomePage onOpenDashboard={() => navigateTo('dashboard')} theme={theme} />
|
||||
) : activePage === 'data-sources' ? (
|
||||
<DataSourcesPage />
|
||||
) : activePage === 'faq' ? (
|
||||
<FAQPage />
|
||||
) : (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<div className="flex-1 flex overflow-hidden relative">
|
||||
{initialLoading && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<svg
|
||||
className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
|
||||
Connecting to server...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Filters
|
||||
features={features}
|
||||
filters={filters}
|
||||
|
|
@ -772,6 +1008,11 @@ export default function App() {
|
|||
pinnedFeature={pinnedFeature}
|
||||
onTogglePin={handleTogglePin}
|
||||
onCancelPin={handleCancelPin}
|
||||
onNavigateToSource={(slug, featureName) => {
|
||||
navigateTo('data-sources', slug, featureName);
|
||||
}}
|
||||
openInfoFeature={pendingInfoFeature}
|
||||
onClearOpenInfoFeature={() => setPendingInfoFeature(null)}
|
||||
/>
|
||||
<div className="flex-1 relative">
|
||||
<Map
|
||||
|
|
@ -785,29 +1026,31 @@ export default function App() {
|
|||
onCancelPin={handleCancelPin}
|
||||
features={features}
|
||||
selectedHexagonId={selectedHexagon?.h3 || null}
|
||||
hoveredHexagonId={hoveredHexagon}
|
||||
onHexagonClick={handleHexagonClick}
|
||||
onHexagonHover={handleHexagonHover}
|
||||
initialViewState={initialViewState}
|
||||
theme={theme}
|
||||
/>
|
||||
{loading && (
|
||||
<div className="absolute top-4 right-4 bg-white dark:bg-warm-800 dark:text-warm-200 px-3 py-1 rounded shadow">
|
||||
<div className="absolute bottom-4 left-4 bg-white dark:bg-navy-800 dark:text-warm-200 px-3 py-1 rounded shadow">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
<DataSources onNavigate={() => setActivePage('data-sources')} />
|
||||
<DataSources onNavigate={() => navigateTo('data-sources')} />
|
||||
</div>
|
||||
<div className="w-72 bg-white dark:bg-warm-900 shadow-lg z-10 flex flex-col">
|
||||
<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-warm-700">
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||
<button
|
||||
className={`flex-1 p-3 ${
|
||||
rightPaneTab === 'pois'
|
||||
rightPaneTab === 'area'
|
||||
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
|
||||
: 'text-warm-600 dark:text-warm-400'
|
||||
}`}
|
||||
onClick={() => setRightPaneTab('pois')}
|
||||
onClick={() => setRightPaneTab('area')}
|
||||
>
|
||||
POIs {pois.length > 0 && `(${pois.length})`}
|
||||
Area {areaStats ? `(${areaStats.count})` : ''}
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 p-3 ${
|
||||
|
|
@ -819,25 +1062,52 @@ export default function App() {
|
|||
>
|
||||
Properties {propertiesTotal > 0 && `(${propertiesTotal})`}
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 p-3 ${
|
||||
rightPaneTab === 'pois'
|
||||
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
|
||||
: 'text-warm-600 dark:text-warm-400'
|
||||
}`}
|
||||
onClick={() => setRightPaneTab('pois')}
|
||||
>
|
||||
POIs {pois.length > 0 && `(${pois.length})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{rightPaneTab === 'pois' ? (
|
||||
{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)}
|
||||
hoverMode={hoverMode}
|
||||
onHoverModeChange={setHoverMode}
|
||||
onViewProperties={handleViewPropertiesFromArea}
|
||||
onClose={handleCloseProperties}
|
||||
/>
|
||||
) : rightPaneTab === 'properties' ? (
|
||||
<PropertiesPane
|
||||
properties={hoverMode && hoveredProperties ? hoveredProperties : properties}
|
||||
total={hoverMode && hoveredProperties ? hoveredPropertiesTotal : propertiesTotal}
|
||||
loading={loadingProperties}
|
||||
hexagonId={hoverMode && hoveredProperties ? hoveredHexagon : selectedHexagon?.h3 || null}
|
||||
onLoadMore={handleLoadMoreProperties}
|
||||
onClose={handleCloseProperties}
|
||||
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
|
||||
isHoveredPreview={!!(hoverMode && hoveredProperties && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3)}
|
||||
hoverMode={hoverMode}
|
||||
onHoverModeChange={setHoverMode}
|
||||
/>
|
||||
) : (
|
||||
<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}
|
||||
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue