Update UI

This commit is contained in:
Andras Schmelczer 2026-02-01 11:07:58 +00:00
parent 2ac37ece97
commit 5f311233e4
10 changed files with 663 additions and 408 deletions

View file

@ -4,8 +4,11 @@ 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,
@ -14,6 +17,7 @@ import type {
POI,
POIResponse,
POICategoriesResponse,
POICategoryGroup,
ViewState,
Property,
HexagonPropertiesResponse,
@ -171,7 +175,15 @@ function stateToParams(
// --- Header ---
function 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(() => {
@ -181,60 +193,85 @@ function Header() {
});
}, []);
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-slate-800 text-white flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-2">
<svg
className="w-5 h-5 text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<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')}
>
<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">Property Map</span>
<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>
<button
onClick={handleShare}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-slate-700 hover:bg-slate-600 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>
{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>
);
}
@ -249,13 +286,16 @@ export default function App() {
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<{
@ -277,7 +317,7 @@ export default function App() {
// POI state
const [pois, setPois] = useState<POI[]>([]);
const [poiCategories, setPOICategories] = useState<string[]>([]);
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(
urlState.poiCategories || new Set()
);
@ -293,10 +333,31 @@ export default function App() {
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);
@ -326,20 +387,40 @@ export default function App() {
useEffect(() => {
fetch(`${getApiBaseUrl()}/api/features`)
.then((res) => res.json())
.then((json: { features: FeatureMeta[] }) => {
setFeatures(json.features);
.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) => {
setPOICategories(json.categories);
setPOICategoryGroups(json.groups);
})
.catch((err) => console.error('Failed to fetch POI categories:', err));
}, []);
// Debounced fetch when resolution/bounds/filters change
// 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;
@ -356,25 +437,13 @@ export default function App() {
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,
});
// Build filters param: numeric=name:min:max, enum=name:val1|val2
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('filters', filtersStr);
}
if (filtersStr) params.set('filters', filtersStr);
const res = await fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
signal: abortControllerRef.current.signal,
});
@ -394,10 +463,11 @@ export default function App() {
clearTimeout(debounceRef.current);
}
};
}, [resolution, bounds, filters]);
}, [resolution, bounds, filters, buildFilterParam]);
// Data passed directly to Map — visual filtering is done via color/opacity in the layer
const data = rawData;
// 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(() => {
@ -444,7 +514,13 @@ export default function App() {
const prevBoundsRef = useRef<string>('');
const handleViewChange = useCallback(
({ resolution: newRes, bounds: newBounds, zoom: newZoom, latitude, longitude }: ViewChangeParams) => {
({
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) {
@ -471,12 +547,9 @@ export default function App() {
[features]
);
const handleFilterChange = useCallback(
(name: string, value: [number, number] | string[]) => {
setFilters((prev) => ({ ...prev, [name]: value }));
},
[]
);
const handleFilterChange = useCallback((name: string, value: [number, number] | string[]) => {
setFilters((prev) => ({ ...prev, [name]: value }));
}, []);
const handleRemoveFilter = useCallback((name: string) => {
setFilters((prev) => {
@ -484,6 +557,7 @@ export default function App() {
delete next[name];
return next;
});
setPinnedFeature((prev) => (prev === name ? null : prev));
}, []);
const handleDragStart = useCallback(
@ -493,8 +567,41 @@ export default function App() {
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]
[filters, features, bounds, resolution]
);
const handleDragChange = useCallback((value: [number, number]) => {
@ -507,8 +614,21 @@ export default function App() {
}
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);
@ -584,84 +704,100 @@ export default function App() {
return (
<div className="h-screen flex flex-col">
<Header />
<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}
/>
<div className="flex-1 relative">
<Map
data={data}
pois={pois}
onViewChange={handleViewChange}
<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}
features={features}
selectedHexagonId={selectedHexagon?.h3 || null}
onHexagonClick={handleHexagonClick}
initialViewState={initialViewState}
enabledFeatures={enabledFeatures}
onAddFilter={handleAddFilter}
onRemoveFilter={handleRemoveFilter}
onFilterChange={handleFilterChange}
onDragStart={handleDragStart}
onDragChange={handleDragChange}
onDragEnd={handleDragEnd}
zoom={zoom}
pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin}
onCancelPin={handleCancelPin}
/>
{loading && (
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">Loading...</div>
)}
<DataSources />
</div>
<div className="w-72 bg-white shadow-lg z-10 flex flex-col">
{/* Tab headers */}
<div className="flex border-b border-gray-200">
<button
className={`flex-1 p-3 ${
rightPaneTab === 'pois' ? 'border-b-2 border-blue-500 font-semibold' : 'text-gray-600'
}`}
onClick={() => setRightPaneTab('pois')}
>
POIs {pois.length > 0 && `(${pois.length})`}
</button>
<button
className={`flex-1 p-3 ${
rightPaneTab === 'properties'
? 'border-b-2 border-blue-500 font-semibold'
: 'text-gray-600'
}`}
onClick={() => setRightPaneTab('properties')}
>
Properties {propertiesTotal > 0 && `(${propertiesTotal})`}
</button>
</div>
{/* Tab content */}
<div className="flex-1 overflow-hidden">
{rightPaneTab === 'pois' ? (
<POIPane
categories={poiCategories}
selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length}
/>
) : (
<PropertiesPane
properties={properties}
total={propertiesTotal}
loading={loadingProperties}
hexagonId={selectedHexagon?.h3 || null}
onLoadMore={handleLoadMoreProperties}
onClose={handleCloseProperties}
/>
<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>
)}
</div>
);
}