Checkpoint all changes

This commit is contained in:
Andras Schmelczer 2026-02-01 19:30:33 +00:00
parent 65877acf95
commit 66c2a25457
28 changed files with 3035 additions and 621 deletions

View file

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