perfect-postcode/frontend/src/App.tsx
2026-02-01 11:07:58 +00:00

803 lines
26 KiB
TypeScript

import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import Map from './components/Map';
import Filters from './components/Filters';
import POIPane from './components/POIPane';
import { PropertiesPane } from './components/PropertiesPane';
import DataSources from './components/DataSources';
import DataSourcesPage from './components/DataSourcesPage';
import HomePage from './components/HomePage';
import type {
FeatureMeta,
FeatureGroup,
FeatureFilters,
Bounds,
HexagonData,
ViewChangeParams,
ApiResponse,
POI,
POIResponse,
POICategoriesResponse,
POICategoryGroup,
ViewState,
Property,
HexagonPropertiesResponse,
} from './types';
const DEBOUNCE_MS = 150;
const URL_DEBOUNCE_MS = 300;
// Detect if running through VS Code web proxy and construct API base URL
function getApiBaseUrl(): string {
const { hostname, pathname, href } = window.location;
// Check pathname for /proxy/PORT pattern
const pathMatch = pathname.match(/^(\/proxy\/)(\d+)/);
if (pathMatch) {
return `${pathMatch[1]}8001`;
}
// Check full href in case proxy rewrites pathname
const hrefMatch = href.match(/(\/proxy\/)\d+/);
if (hrefMatch) {
return `${hrefMatch[1]}8001`;
}
// If not localhost, assume we're behind a proxy and need explicit backend port
if (hostname !== 'localhost' && hostname !== '127.0.0.1') {
return '/proxy/8001';
}
// Local development - webpack proxies /api to :8001
return '';
}
const DEFAULT_VIEW: ViewState = {
longitude: -1.5,
latitude: 53.5,
zoom: 6,
pitch: 0,
};
// --- URL State helpers ---
function parseUrlState(): {
viewState?: ViewState;
filters?: FeatureFilters;
poiCategories?: Set<string>;
tab?: 'pois' | 'properties';
} {
const params = new URLSearchParams(window.location.search);
const result: ReturnType<typeof parseUrlState> = {};
// Parse view: v=lat,lng,zoom
const v = params.get('v');
if (v) {
const parts = v.split(',').map(Number);
if (parts.length === 3 && parts.every((n) => !isNaN(n))) {
result.viewState = {
latitude: parts[0],
longitude: parts[1],
zoom: parts[2],
pitch: 0,
};
}
}
// Parse filters: f=name:min:max,name:val1|val2
const f = params.get('f');
if (f) {
const filters: FeatureFilters = {};
for (const segment of f.split(',')) {
const colonIdx = segment.indexOf(':');
if (colonIdx === -1) continue;
const name = segment.substring(0, colonIdx);
const rest = segment.substring(colonIdx + 1);
if (rest.includes(':')) {
// Numeric: name:min:max
const [minStr, maxStr] = rest.split(':');
const min = Number(minStr);
const max = Number(maxStr);
if (!isNaN(min) && !isNaN(max)) {
filters[name] = [min, max];
}
} else if (rest.includes('|')) {
// Enum: name:val1|val2
filters[name] = rest.split('|');
} else {
// Single enum value
filters[name] = [rest];
}
}
if (Object.keys(filters).length > 0) {
result.filters = filters;
}
}
// Parse POI categories: poi=Cafe,Pub,School
const poi = params.get('poi');
if (poi) {
result.poiCategories = new Set(poi.split(',').filter(Boolean));
}
// Parse tab: tab=p or tab=o
const tab = params.get('tab');
if (tab === 'p') result.tab = 'properties';
else if (tab === 'o') result.tab = 'pois';
return result;
}
function stateToParams(
viewState: { latitude: number; longitude: number; zoom: number } | null,
filters: FeatureFilters,
features: FeatureMeta[],
selectedPOICategories: Set<string>,
rightPaneTab: 'pois' | 'properties'
): URLSearchParams {
const params = new URLSearchParams();
// View
if (viewState) {
params.set(
'v',
`${viewState.latitude.toFixed(4)},${viewState.longitude.toFixed(4)},${viewState.zoom.toFixed(1)}`
);
}
// Filters
const filterEntries = Object.entries(filters);
if (filterEntries.length > 0) {
const filtersStr = filterEntries
.map(([name, value]) => {
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') {
return `${name}:${(value as string[]).join('|')}`;
}
const [min, max] = value as [number, number];
return `${name}:${min}:${max}`;
})
.join(',');
params.set('f', filtersStr);
}
// POI categories
if (selectedPOICategories.size > 0) {
params.set('poi', Array.from(selectedPOICategories).join(','));
}
// Tab (only if non-default)
if (rightPaneTab === 'properties') {
params.set('tab', 'p');
}
return params;
}
// --- Header ---
type Page = 'home' | 'dashboard' | 'data-sources';
function Header({
activePage,
onPageChange,
}: {
activePage: Page;
onPageChange: (page: Page) => void;
}) {
const [copied, setCopied] = useState(false);
const handleShare = useCallback(() => {
navigator.clipboard.writeText(window.location.href).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}, []);
const tabClass = (page: Page) =>
`px-3 py-1.5 rounded text-sm transition-colors ${
activePage === page
? 'bg-navy-700 font-semibold'
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
}`;
return (
<header className="h-12 bg-navy-900 text-white flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
onClick={() => onPageChange('home')}
>
<svg
className="w-5 h-5 text-teal-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<span className="font-semibold text-lg">Narrowit</span>
</button>
<nav className="flex items-center gap-1">
<button className={tabClass('home')} onClick={() => onPageChange('home')}>
Home
</button>
<button className={tabClass('dashboard')} onClick={() => onPageChange('dashboard')}>
Dashboard
</button>
<button className={tabClass('data-sources')} onClick={() => onPageChange('data-sources')}>
Data Sources
</button>
</nav>
</div>
{activePage === 'dashboard' && (
<button
onClick={handleShare}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
>
{copied ? (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
Copied!
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
/>
</svg>
Share
</>
)}
</button>
)}
</header>
);
}
// --- App ---
export default function App() {
// Parse URL state once on mount
const urlState = useMemo(() => parseUrlState(), []);
const [features, setFeatures] = useState<FeatureMeta[]>([]);
const [filters, setFilters] = useState<FeatureFilters>(urlState.filters || {});
const [activeFeature, setActiveFeature] = useState<string | null>(null);
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
const [rawData, setRawData] = useState<HexagonData[]>([]);
const [dragData, setDragData] = useState<HexagonData[] | null>(null);
const [resolution, setResolution] = useState<number>(8);
const [bounds, setBounds] = useState<Bounds | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [zoom, setZoom] = useState<number>(urlState.viewState?.zoom || DEFAULT_VIEW.zoom);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const dragAbortRef = useRef<AbortController | null>(null);
// View state for URL serialization
const [currentView, setCurrentView] = useState<{
latitude: number;
longitude: number;
zoom: number;
} | null>(
urlState.viewState
? {
latitude: urlState.viewState.latitude,
longitude: urlState.viewState.longitude,
zoom: urlState.viewState.zoom,
}
: null
);
// Initial view state for Map
const initialViewState = useMemo(() => urlState.viewState || DEFAULT_VIEW, []);
// POI state
const [pois, setPois] = useState<POI[]>([]);
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(
urlState.poiCategories || new Set()
);
const poiDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const poiAbortControllerRef = useRef<AbortController | null>(null);
// Hexagon properties state
const [selectedHexagon, setSelectedHexagon] = useState<{ h3: string; resolution: number } | null>(
null
);
const [properties, setProperties] = useState<Property[]>([]);
const [propertiesTotal, setPropertiesTotal] = useState(0);
const [propertiesOffset, setPropertiesOffset] = useState(0);
const [loadingProperties, setLoadingProperties] = useState(false);
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties'>(urlState.tab || 'pois');
const [activePage, setActivePage] = useState<Page>('home');
// Derive enabled features from filter keys
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
// Derive view feature: active drag takes priority over pinned
const viewFeature = activeFeature || pinnedFeature;
const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null;
// Color range: always the feature's full slider range from metadata
const colorRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
const meta = features.find((f) => f.name === viewFeature);
if (meta?.min != null && meta?.max != null) return [meta.min, meta.max];
return null;
}, [viewFeature, features]);
// Filter range: current drag or committed filter values, used for gray-out
const filterRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
if (activeFeature && dragValue) return dragValue;
const filterVal = filters[viewFeature];
if (filterVal && typeof filterVal[0] === 'number') return filterVal as [number, number];
return null;
}, [viewFeature, activeFeature, dragValue, filters]);
// --- URL sync ---
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (urlDebounceRef.current) {
clearTimeout(urlDebounceRef.current);
}
urlDebounceRef.current = setTimeout(() => {
const params = stateToParams(
currentView,
filters,
features,
selectedPOICategories,
rightPaneTab
);
const search = params.toString();
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
window.history.replaceState(null, '', newUrl);
}, URL_DEBOUNCE_MS);
return () => {
if (urlDebounceRef.current) clearTimeout(urlDebounceRef.current);
};
}, [currentView, filters, features, selectedPOICategories, rightPaneTab]);
// Fetch feature metadata + POI categories on mount
useEffect(() => {
fetch(`${getApiBaseUrl()}/api/features`)
.then((res) => res.json())
.then((json: { groups: FeatureGroup[] }) => {
// Flatten grouped response into a flat feature list with group annotation
const flat: FeatureMeta[] = json.groups.flatMap((g) =>
g.features.map((f) => ({ ...f, group: g.name }))
);
setFeatures(flat);
})
.catch((err) => console.error('Failed to fetch features:', err));
fetch(`${getApiBaseUrl()}/api/poi-categories`)
.then((res) => res.json())
.then((json: POICategoriesResponse) => {
setPOICategoryGroups(json.groups);
})
.catch((err) => console.error('Failed to fetch POI categories:', err));
}, []);
// Build filter query string helper
const buildFilterParam = useCallback((): string => {
const filterEntries = Object.entries(filters);
if (filterEntries.length === 0) return '';
return filterEntries
.map(([name, value]) => {
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') {
return `${name}:${(value as string[]).join('|')}`;
}
const [min, max] = value as [number, number];
return `${name}:${min}:${max}`;
})
.join(',');
}, [filters, features]);
// Debounced fetch when resolution/bounds/filters change — always fetch hexagons
useEffect(() => {
if (!bounds) return;
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(async () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
setLoading(true);
try {
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const filtersStr = buildFilterParam();
const params = new URLSearchParams({
resolution: resolution.toString(),
bounds: boundsStr,
});
if (filtersStr) params.set('filters', filtersStr);
const res = await fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
signal: abortControllerRef.current.signal,
});
const json: ApiResponse = await res.json();
setRawData(json.features || []);
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
console.error('Failed to fetch data:', err);
}
} finally {
setLoading(false);
}
}, DEBOUNCE_MS);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [resolution, bounds, filters, buildFilterParam]);
// During slider drag, use the expanded dataset (without active feature filter)
// so both narrowing and expanding are visible. Otherwise use server-filtered data.
const data = dragData ?? rawData;
// Fetch POIs when bounds or selected categories change
useEffect(() => {
if (!bounds || selectedPOICategories.size === 0) {
setPois([]);
return;
}
if (poiDebounceRef.current) {
clearTimeout(poiDebounceRef.current);
}
poiDebounceRef.current = setTimeout(async () => {
if (poiAbortControllerRef.current) {
poiAbortControllerRef.current.abort();
}
poiAbortControllerRef.current = new AbortController();
try {
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const categoriesStr = Array.from(selectedPOICategories).join(',');
const params = new URLSearchParams({
categories: categoriesStr,
bounds: boundsStr,
});
const res = await fetch(`${getApiBaseUrl()}/api/pois?${params}`, {
signal: poiAbortControllerRef.current.signal,
});
const json: POIResponse = await res.json();
setPois(json.pois || []);
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
console.error('Failed to fetch POIs:', err);
}
}
}, DEBOUNCE_MS);
return () => {
if (poiDebounceRef.current) {
clearTimeout(poiDebounceRef.current);
}
};
}, [bounds, selectedPOICategories]);
const prevBoundsRef = useRef<string>('');
const handleViewChange = useCallback(
({
resolution: newRes,
bounds: newBounds,
zoom: newZoom,
latitude,
longitude,
}: ViewChangeParams) => {
// Only update bounds/resolution when quantized values actually change
const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
if (boundsKey !== prevBoundsRef.current) {
prevBoundsRef.current = boundsKey;
setResolution(newRes);
setBounds(newBounds);
}
setZoom(newZoom);
setCurrentView({ latitude, longitude, zoom: newZoom });
},
[]
);
const handleAddFilter = useCallback(
(name: string) => {
const meta = features.find((f) => f.name === name);
if (!meta) return;
if (meta.type === 'enum' && meta.values) {
setFilters((prev) => ({ ...prev, [name]: [...meta.values!] }));
} else if (meta.min != null && meta.max != null) {
setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] }));
}
},
[features]
);
const handleFilterChange = useCallback((name: string, value: [number, number] | string[]) => {
setFilters((prev) => ({ ...prev, [name]: value }));
}, []);
const handleRemoveFilter = useCallback((name: string) => {
setFilters((prev) => {
const next = { ...prev };
delete next[name];
return next;
});
setPinnedFeature((prev) => (prev === name ? null : prev));
}, []);
const handleDragStart = useCallback(
(name: string) => {
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') return; // No drag interaction for enum features
setActiveFeature(name);
const fval = filters[name];
setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null);
// Fetch hexagons without this feature's filter so we can expand the range
if (!bounds) return;
if (dragAbortRef.current) dragAbortRef.current.abort();
dragAbortRef.current = new AbortController();
const otherFilters = Object.entries(filters).filter(([k]) => k !== name);
let filtersStr = '';
if (otherFilters.length > 0) {
filtersStr = otherFilters
.map(([n, value]) => {
const m = features.find((f) => f.name === n);
if (m?.type === 'enum') return `${n}:${(value as string[]).join('|')}`;
const [min, max] = value as [number, number];
return `${n}:${min}:${max}`;
})
.join(',');
}
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const params = new URLSearchParams({ resolution: resolution.toString(), bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr);
fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
signal: dragAbortRef.current.signal,
})
.then((res) => res.json())
.then((json: ApiResponse) => setDragData(json.features || []))
.catch((err) => {
if (err instanceof Error && err.name !== 'AbortError') {
console.error('Failed to fetch drag data:', err);
}
});
},
[filters, features, bounds, resolution]
);
const handleDragChange = useCallback((value: [number, number]) => {
setDragValue(value);
}, []);
const handleDragEnd = useCallback(() => {
if (activeFeature && dragValue) {
setFilters((prev) => ({ ...prev, [activeFeature]: dragValue }));
}
setActiveFeature(null);
setDragValue(null);
setDragData(null);
if (dragAbortRef.current) {
dragAbortRef.current.abort();
dragAbortRef.current = null;
}
}, [activeFeature, dragValue]);
const handleTogglePin = useCallback((name: string) => {
setPinnedFeature((prev) => (prev === name ? null : name));
}, []);
const handleCancelPin = useCallback(() => {
setPinnedFeature(null);
}, []);
const fetchHexagonProperties = useCallback(
async (h3: string, res: number, offset = 0) => {
setLoadingProperties(true);
try {
const params = new URLSearchParams({
h3,
resolution: res.toString(),
limit: '100',
offset: offset.toString(),
});
// Add current filters
const filterEntries = Object.entries(filters);
if (filterEntries.length > 0) {
const filterStr = filterEntries
.map(([name, value]) => {
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') {
return `${name}:${(value as string[]).join('|')}`;
}
const [min, max] = value as [number, number];
return `${name}:${min}:${max}`;
})
.join(',');
params.append('filters', filterStr);
}
const response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`);
const data: HexagonPropertiesResponse = await response.json();
if (offset === 0) {
setProperties(data.properties);
} else {
setProperties((prev) => [...prev, ...data.properties]);
}
setPropertiesTotal(data.total);
setPropertiesOffset(offset + data.properties.length);
} catch (err) {
console.error('Failed to fetch properties:', err);
} finally {
setLoadingProperties(false);
}
},
[filters, features]
);
const handleHexagonClick = useCallback(
(h3: string) => {
if (selectedHexagon?.h3 === h3) {
// Deselect if clicking same hexagon
setSelectedHexagon(null);
setProperties([]);
} else {
setSelectedHexagon({ h3, resolution });
setPropertiesOffset(0);
setRightPaneTab('properties'); // Auto-switch to properties tab
fetchHexagonProperties(h3, resolution, 0);
}
},
[selectedHexagon, resolution, fetchHexagonProperties]
);
const handleLoadMoreProperties = useCallback(() => {
if (selectedHexagon) {
fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, propertiesOffset);
}
}, [selectedHexagon, propertiesOffset, fetchHexagonProperties]);
const handleCloseProperties = useCallback(() => {
setSelectedHexagon(null);
setProperties([]);
}, []);
return (
<div className="h-screen flex flex-col">
<Header activePage={activePage} onPageChange={setActivePage} />
{activePage === 'home' ? (
<HomePage onOpenDashboard={() => setActivePage('dashboard')} />
) : activePage === 'data-sources' ? (
<DataSourcesPage />
) : (
<div className="flex-1 flex overflow-hidden">
<Filters
features={features}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
enabledFeatures={enabledFeatures}
onAddFilter={handleAddFilter}
onRemoveFilter={handleRemoveFilter}
onFilterChange={handleFilterChange}
onDragStart={handleDragStart}
onDragChange={handleDragChange}
onDragEnd={handleDragEnd}
zoom={zoom}
pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin}
onCancelPin={handleCancelPin}
/>
<div className="flex-1 relative">
<Map
data={data}
pois={pois}
onViewChange={handleViewChange}
viewFeature={viewFeature}
colorRange={colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selectedHexagon?.h3 || null}
onHexagonClick={handleHexagonClick}
initialViewState={initialViewState}
/>
{loading && (
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">
Loading...
</div>
)}
<DataSources onNavigate={() => setActivePage('data-sources')} />
</div>
<div className="w-72 bg-white shadow-lg z-10 flex flex-col">
{/* Tab headers */}
<div className="flex border-b border-warm-200">
<button
className={`flex-1 p-3 ${
rightPaneTab === 'pois'
? 'border-b-2 border-teal-500 font-semibold'
: 'text-warm-600'
}`}
onClick={() => setRightPaneTab('pois')}
>
POIs {pois.length > 0 && `(${pois.length})`}
</button>
<button
className={`flex-1 p-3 ${
rightPaneTab === 'properties'
? 'border-b-2 border-teal-500 font-semibold'
: 'text-warm-600'
}`}
onClick={() => setRightPaneTab('properties')}
>
Properties {propertiesTotal > 0 && `(${propertiesTotal})`}
</button>
</div>
{/* Tab content */}
<div className="flex-1 overflow-hidden">
{rightPaneTab === 'pois' ? (
<POIPane
groups={poiCategoryGroups}
selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length}
/>
) : (
<PropertiesPane
properties={properties}
total={propertiesTotal}
loading={loadingProperties}
hexagonId={selectedHexagon?.h3 || null}
onLoadMore={handleLoadMoreProperties}
onClose={handleCloseProperties}
/>
)}
</div>
</div>
</div>
)}
</div>
);
}