Last night

This commit is contained in:
Andras Schmelczer 2026-02-08 10:21:37 +00:00
parent 2906b01734
commit 42ee2d4c51
47 changed files with 848 additions and 478 deletions

View file

@ -13,6 +13,7 @@ import { fetchWithRetry, apiUrl } from './lib/api';
import { parseUrlState } from './lib/url-state';
import { INITIAL_VIEW_STATE } from './lib/consts';
import { useTheme } from './hooks/useTheme';
import { useIsMobile } from './hooks/useIsMobile';
import { useAuth } from './hooks/useAuth';
import { useSavedSearches } from './hooks/useSavedSearches';
@ -87,6 +88,7 @@ export default function App() {
});
const { theme, toggleTheme } = useTheme();
const isMobile = useIsMobile();
const {
user,
loading: authLoading,
@ -207,7 +209,7 @@ export default function App() {
}
return (
<div className="h-screen flex flex-col">
<div className="h-full flex flex-col">
<Header
activePage={activePage}
onPageChange={navigateTo}
@ -227,6 +229,7 @@ export default function App() {
setShowAuthModal(true);
}}
onLogout={logout}
isMobile={isMobile}
/>
{activePage === 'home' ? (
<HomePage onOpenDashboard={() => navigateTo('dashboard')} theme={theme} />
@ -257,6 +260,7 @@ export default function App() {
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
onNavigateTo={navigateTo}
onExportStateChange={setExportState}
isMobile={isMobile}
/>
)}
{showAuthModal && (

View file

@ -65,7 +65,7 @@ const FAQ_ITEMS: FAQItem[] = [
{
question: 'Does this work on mobile?',
answer:
'The app is designed for desktop browsers where you have enough screen space for the map, filter panel, and POI/properties panel side by side. It will load on mobile but the experience is best on a larger screen.',
'Yes. On mobile, the dashboard uses a vertical split layout with the map on top and a tabbed panel below for filters, area stats, properties, and POIs. Tapping a hexagon opens a full-screen drawer with the details. The full desktop experience with side-by-side panels is available on screens 768px and wider.',
},
];

View file

@ -40,7 +40,7 @@ export default function HomePage({
<div className="relative" style={{ zIndex: 1 }}>
{/* Hero */}
<div className="max-w-3xl mx-auto px-6 pt-20 pb-24">
<div className="max-w-3xl mx-auto px-6 pt-12 pb-16 md:pt-20 md:pb-24">
<div
ref={heroRef}
className="fade-in-section backdrop-blur-sm bg-warm-50/60 dark:bg-navy-950/60 rounded-2xl p-8 -mx-2"
@ -48,7 +48,7 @@ export default function HomePage({
<p className="text-teal-600 font-semibold tracking-wide uppercase text-sm mb-4">
Find where to live, not just what&apos;s for sale
</p>
<h1 className="text-5xl font-extrabold text-navy-950 dark:text-warm-100 mb-6 leading-[1.1] tracking-tight">
<h1 className="text-3xl md:text-5xl font-extrabold text-navy-950 dark:text-warm-100 mb-6 leading-[1.1] tracking-tight">
Every neighbourhood
<br />
in England &amp; Wales.
@ -158,7 +158,7 @@ export default function HomePage({
<div className="grid grid-cols-3 gap-6 text-center">
{STATS.map((s) => (
<div key={s.label}>
<div className="text-3xl font-extrabold text-teal-600">{s.value}</div>
<div className="text-2xl md:text-3xl font-extrabold text-teal-600">{s.value}</div>
<div className="text-sm text-warm-500 dark:text-warm-400 mt-1">{s.label}</div>
</div>
))}

View file

@ -10,7 +10,7 @@ import StackedBarChart from './StackedBarChart';
import StackedEnumChart from './StackedEnumChart';
import PriceHistoryChart from './PriceHistoryChart';
import ExternalSearchLinks from './ExternalSearchLinks';
import { InfoIcon, CloseIcon } from '../ui/icons';
import { InfoIcon, CloseIcon, ChevronIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { LightbulbIcon } from '../ui/icons/LightbulbIcon';
import { IconButton } from '../ui/IconButton';
@ -58,6 +58,7 @@ export default function AreaPane({
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const [aiSummaryExpanded, setAiSummaryExpanded] = useState(true);
const toggleGroup = (name: string) =>
setCollapsedGroups((prev) => {
@ -133,41 +134,53 @@ export default function AreaPane({
<ExternalSearchLinks location={hexagonLocation} filters={filters} />
)}
{/* AI Summary Card */}
{(aiSummary || aiSummaryLoading || aiSummaryError) && (
<div className="px-3 pt-3 pb-1">
<div className="bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5">
<div className="flex items-center gap-1.5 mb-1.5">
<LightbulbIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400" />
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">AI Summary</span>
</div>
{aiSummaryError ? (
<div className="text-xs text-warm-600 dark:text-warm-400">
<span>Failed to generate summary. </span>
{onRetryAiSummary && (
<button
onClick={onRetryAiSummary}
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 underline"
>
Retry
</button>
)}
</div>
) : aiSummaryLoading ? (
<div className="space-y-1.5">
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-full" />
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-4/5" />
</div>
) : (
<p className="text-xs text-warm-700 dark:text-warm-300 leading-relaxed">
{aiSummary}
</p>
)}
</div>
</div>
)}
<div className="flex-1 overflow-y-auto">
{/* AI Summary Card */}
{(aiSummary || aiSummaryLoading || aiSummaryError) && (
<div className="px-3 pt-3 pb-1">
<div className="bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5">
<button
onClick={() => setAiSummaryExpanded(!aiSummaryExpanded)}
className="w-full flex items-center justify-between gap-1.5 mb-1.5"
>
<div className="flex items-center gap-1.5">
<LightbulbIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400" />
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">AI Summary</span>
</div>
<ChevronIcon
direction={aiSummaryExpanded ? 'down' : 'right'}
className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400"
/>
</button>
{aiSummaryExpanded && (
<>
{aiSummaryError ? (
<div className="text-xs text-warm-600 dark:text-warm-400">
<span>Failed to generate summary. </span>
{onRetryAiSummary && (
<button
onClick={onRetryAiSummary}
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 underline"
>
Retry
</button>
)}
</div>
) : aiSummaryLoading ? (
<div className="space-y-1.5">
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-full" />
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-4/5" />
</div>
) : (
<p className="text-xs text-warm-700 dark:text-warm-300 leading-relaxed">
{aiSummary}
</p>
)}
</>
)}
</div>
</div>
)}
{loading && !stats ? (
<LoadingSkeleton />
) : stats ? (

View file

@ -88,10 +88,10 @@ function FeatureBrowser({
return (
<>
<div className="p-2 border-b border-warm-200 dark:border-navy-700">
<div className="shrink-0 p-2 border-b border-warm-200 dark:border-navy-700">
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
</div>
<div className="min-h-0 flex-1 overflow-y-auto flex flex-col">
<div className="md:min-h-0 md:flex-1 md:overflow-y-auto flex flex-col">
{grouped.map((group) => {
const isExpanded = isSearching || expandedGroups.has(group.name);
return (
@ -187,7 +187,7 @@ export default memo(function Filters({
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
return (
<div className="flex flex-col bg-white dark:bg-navy-950 overflow-hidden h-full">
<div className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<button
onClick={() => setShowPhilosophy(true)}
@ -197,7 +197,7 @@ export default memo(function Filters({
Finding the Perfect Postcode
</button>
</div>
<div className="min-h-0 flex flex-col max-h-[65%]">
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:max-h-[65%]">
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
@ -215,7 +215,7 @@ export default memo(function Filters({
</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-3">
<div className="md:flex-1 md:overflow-y-auto p-3 space-y-3">
{enabledFeatureList.length === 0 && (
<EmptyState
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
@ -308,11 +308,11 @@ export default memo(function Filters({
</div>
</div>
<div className="min-h-0 flex-1 flex flex-col border-t border-warm-200 dark:border-warm-700">
<div className="shrink-0 md:shrink md:min-h-0 md:flex-1 flex flex-col border-t border-warm-200 dark:border-warm-700">
<div className="shrink-0 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">Add Filter</span>
</div>
<div className="min-h-0 flex-1 flex flex-col">
<div className="md:min-h-0 md:flex-1 flex flex-col">
<FeatureBrowser
availableFeatures={availableFeatures}
allFeatures={features}

View file

@ -27,9 +27,9 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }:
// Show stats for active filters (up to 4)
for (const name of activeFilterNames.slice(0, 4)) {
const minVal = data[`min_${name}`];
if (minVal != null && typeof minVal === 'number') {
results.push({ name, value: formatValue(minVal) });
const val = data[`avg_${name}`] ?? data[`min_${name}`];
if (val != null && typeof val === 'number') {
results.push({ name, value: formatValue(val) });
}
}

View file

@ -14,7 +14,9 @@ import type {
ViewChangeParams,
POI,
FeatureMeta,
Bounds,
} from '../../types';
import { cellToLatLng } from 'h3-js';
import {
GRADIENT,
normalizedToColor,
@ -63,6 +65,7 @@ interface MapProps {
filters?: FeatureFilters;
searchedPostcode?: SearchedPostcode | null;
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
bounds?: Bounds | null;
}
@ -114,6 +117,7 @@ export default memo(function Map({
filters = {},
searchedPostcode,
onPostcodeSearched,
bounds: viewportBounds,
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
@ -199,13 +203,18 @@ export default memo(function Map({
let min = Infinity;
let max = -Infinity;
for (const d of data) {
if (viewportBounds) {
const [lat, lng] = cellToLatLng(d.h3);
if (lat < viewportBounds.south || lat > viewportBounds.north || lng < viewportBounds.west || lng > viewportBounds.east) continue;
}
const c = d.count as number;
if (c < min) min = c;
if (c > max) max = c;
}
if (min === Infinity) return { min: 0, max: 1 };
if (min === max) return { min, max: min + 1 };
return { min, max };
}, [data]);
}, [data, viewportBounds]);
const colorFeatureMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
@ -259,13 +268,18 @@ export default memo(function Map({
let min = Infinity;
let max = -Infinity;
for (const d of postcodeData) {
if (viewportBounds) {
const [lng, lat] = d.properties.centroid as [number, number];
if (lat < viewportBounds.south || lat > viewportBounds.north || lng < viewportBounds.west || lng > viewportBounds.east) continue;
}
const c = d.properties.count;
if (c < min) min = c;
if (c > max) max = c;
}
if (min === Infinity) return { min: 0, max: 1 };
if (min === max) return { min, max: min + 1 };
return { min, max };
}, [postcodeData]);
}, [postcodeData, viewportBounds]);
const postcodeCountRangeRef = useRef(postcodeCountRange);
postcodeCountRangeRef.current = postcodeCountRange;
@ -324,7 +338,7 @@ export default memo(function Map({
const cfm = colorFeatureMetaRef.current;
const dark = isDarkRef.current;
if (vf && clr && cfm) {
const val = d[`min_${vf}`];
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
if (val == null) return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
if (fr) {
const minVal = d[`min_${vf}`] as number;
@ -392,7 +406,7 @@ export default memo(function Map({
const cfm = colorFeatureMetaRef.current;
const dark = isDarkRef.current;
if (vf && clr && cfm) {
const val = d[`min_${vf}`];
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
if (val == null) return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
if (fr) {
const minVal = d[`min_${vf}`] as number;
@ -402,15 +416,15 @@ export default memo(function Map({
}
}
const range = clr[1] - clr[0];
if (range === 0) return [...GRADIENT[0].color, 255] as [number, number, number, number];
if (range === 0) return [...GRADIENT[0].color, 180] as [number, number, number, number];
const t = ((val as number) - clr[0]) / range;
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
return [...rgb, 255] as [number, number, number, number];
return [...rgb, 180] as [number, number, number, number];
}
const cr = postcodeCountRangeRef.current;
const c = d.count;
const t = (c - cr.min) / (cr.max - cr.min);
return [...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current), 255] as [
return [...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current), 180] as [
number,
number,
number,
@ -442,6 +456,8 @@ export default memo(function Map({
pickable: true,
onClick: handlePostcodeClick,
onHover: handlePostcodeHoverCallback,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: 'landuse_park',
}),
[postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]
);
@ -572,22 +588,9 @@ export default memo(function Map({
) : (
<>
<PostcodeSearch onFlyTo={handleFlyTo} onPostcodeSearched={onPostcodeSearched} />
{viewSource === 'eye' && viewFeature && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-white dark:bg-warm-800 rounded-lg shadow-lg px-5 py-3">
<span className="text-lg font-semibold text-navy-950 dark:text-white">
Previewing &ldquo;{viewFeature}&rdquo;
</span>
<button
onClick={onCancelPin}
className="px-4 py-1.5 rounded-md bg-warm-200 dark:bg-warm-700 text-warm-700 dark:text-white hover:bg-warm-300 dark:hover:bg-warm-600 font-medium text-sm"
>
Cancel
</button>
</div>
)}
{viewFeature && colorRange && colorFeatureMeta ? (
<MapLegend
featureLabel={colorFeatureMeta.name}
featureLabel={viewSource === 'eye' ? `Previewing \u201c${colorFeatureMeta.name}\u201d` : colorFeatureMeta.name}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
@ -598,7 +601,7 @@ export default memo(function Map({
) : (
<MapLegend
featureLabel="Property density"
range={[0, 0]}
range={usePostcodeView ? [postcodeCountRange.min, postcodeCountRange.max] : [countRange.min, countRange.max]}
showCancel={false}
onCancel={onCancelPin}
mode="density"

View file

@ -41,8 +41,8 @@ export default function MapLegend({
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-200">
{mode === 'density' ? (
<>
<span>Few</span>
<span>Many</span>
<span>{formatValue(range[0])}</span>
<span>{formatValue(range[1])}</span>
</>
) : enumValues && enumValues.length > 0 ? (
<>

View file

@ -7,6 +7,7 @@ import Filters from './Filters';
import POIPane from './POIPane';
import { PropertiesPane } from './PropertiesPane';
import AreaPane from './AreaPane';
import MobileDrawer from './MobileDrawer';
import DataSources from '../data-sources/DataSources';
import { TabButton } from '../ui/TabButton';
import { useMapData } from '../../hooks/useMapData';
@ -24,6 +25,8 @@ export interface ExportState {
exporting: boolean;
}
type MobileBottomTab = 'filters' | 'pois';
interface MapPageProps {
features: FeatureMeta[];
poiCategoryGroups: POICategoryGroup[];
@ -39,6 +42,7 @@ interface MapPageProps {
onExportStateChange?: (state: ExportState) => void;
screenshotMode?: boolean;
ogMode?: boolean;
isMobile?: boolean;
}
export default function MapPage({
@ -56,6 +60,7 @@ export default function MapPage({
onExportStateChange,
screenshotMode,
ogMode,
isMobile = false,
}: MapPageProps) {
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null);
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(initialPOICategories);
@ -63,6 +68,10 @@ export default function MapPage({
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(288, 200, 500, 'right');
// Mobile state
const [mobileBottomTab, setMobileBottomTab] = useState<MobileBottomTab>('filters');
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
// Initialize filters first
const {
filters,
@ -123,6 +132,15 @@ export default function MapPage({
selection.setRightPaneTab(initialTab);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// On mobile, open drawer and switch tab when hexagon is clicked
const handleMobileHexagonClick = useCallback((id: string, isPostcode?: boolean) => {
selection.handleHexagonClick(id, isPostcode);
if (id) {
setMobileDrawerOpen(true);
setMobileBottomTab('area');
}
}, [selection.handleHexagonClick]); // eslint-disable-line react-hooks/exhaustive-deps
// Compute hexagon location for external links
const hexagonLocation = useMemo(() => {
const hexId = selection.selectedHexagon?.id;
@ -194,7 +212,7 @@ export default function MapPage({
if (screenshotMode) {
return (
<div className="h-screen w-screen">
<div className="h-full w-full">
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
@ -215,11 +233,164 @@ export default function MapPage({
theme={theme}
screenshotMode
ogMode={ogMode}
bounds={mapData.bounds}
/>
</div>
);
}
// Shared pane content renderers
const renderAreaPane = () => (
<AreaPane
stats={selection.areaStats}
globalFeatures={features}
loading={selection.loadingAreaStats}
hexagonId={selection.selectedHexagon?.id || null}
isPostcode={selection.selectedHexagon?.type === 'postcode'}
postcodeData={
selection.selectedHexagon?.type === 'postcode'
? mapData.postcodeData.find((f) => f.properties.postcode === selection.selectedHexagon?.id) || null
: null
}
onViewProperties={selection.handleViewPropertiesFromArea}
onClose={selection.handleCloseSelection}
hexagonLocation={hexagonLocation}
filters={filters}
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
aiSummary={aiSummary.summary}
aiSummaryLoading={aiSummary.loading}
aiSummaryError={aiSummary.error}
onRetryAiSummary={aiSummary.retry}
/>
);
const renderPropertiesPane = () => (
<PropertiesPane
properties={selection.properties}
total={selection.propertiesTotal}
loading={selection.loadingProperties}
hexagonId={selection.selectedHexagon?.id || null}
onLoadMore={selection.handleLoadMoreProperties}
onClose={selection.handleCloseSelection}
onNavigateToSource={(slug) => onNavigateTo('data-sources', slug)}
/>
);
const renderPOIPane = () => (
<POIPane
groups={poiCategoryGroups}
selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length}
onNavigateToSource={(slug) => onNavigateTo('data-sources', slug)}
/>
);
const renderFilters = () => (
<Filters
features={features}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
enabledFeatures={enabledFeatures}
onAddFilter={handleAddFilter}
onRemoveFilter={handleRemoveFilter}
onFilterChange={handleFilterChange}
onDragStart={handleDragStart}
onDragChange={handleDragChange}
onDragEnd={handleDragEnd}
zoom={mapData.zoom}
itemCount={mapData.usePostcodeView ? mapData.postcodeData.length : mapData.data.length}
usePostcodeView={mapData.usePostcodeView}
pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin}
onCancelPin={handleCancelPin}
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={onClearPendingInfoFeature}
/>
);
// Mobile layout
if (isMobile) {
return (
<div className="flex-1 flex flex-col 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">
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">Connecting to server...</p>
</div>
</div>
)}
{/* Map — 45% */}
<div className="relative overflow-hidden" style={{ flex: '45 0 0' }}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={viewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selection.selectedHexagon?.id || null}
hoveredHexagonId={selection.hoveredHexagon}
onHexagonClick={handleMobileHexagonClick}
onHexagonHover={selection.handleHexagonHover}
initialViewState={initialViewState}
theme={theme}
filters={filters}
searchedPostcode={searchedPostcode}
onPostcodeSearched={setSearchedPostcode}
bounds={mapData.bounds}
/>
{mapData.loading && (
<div className="absolute bottom-2 left-2 bg-white dark:bg-navy-800 dark:text-warm-200 px-2 py-1 rounded shadow text-xs">
Loading...
</div>
)}
<DataSources onNavigate={() => onNavigateTo('data-sources')} />
</div>
{/* Bottom panel — 55% */}
<div className="bg-white dark:bg-navy-950 border-t border-warm-200 dark:border-navy-700 overflow-hidden flex flex-col" style={{ flex: '55 0 0' }}>
{/* Tab bar */}
<div className="flex shrink-0 border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton label="Filters" isActive={mobileBottomTab === 'filters'} onClick={() => setMobileBottomTab('filters')} />
<TabButton label="POIs" isActive={mobileBottomTab === 'pois'} onClick={() => setMobileBottomTab('pois')} />
</div>
{/* Tab content */}
<div className="flex-1 min-h-0">
{mobileBottomTab === 'pois' ? (
<div className="h-full overflow-y-auto">
{renderPOIPane()}
</div>
) : (
renderFilters()
)}
</div>
</div>
{/* Mobile drawer for full-screen hexagon details */}
{mobileDrawerOpen && selection.selectedHexagon && (
<MobileDrawer
onClose={() => setMobileDrawerOpen(false)}
renderArea={renderAreaPane}
renderProperties={renderPropertiesPane}
renderPOIs={renderPOIPane}
/>
)}
</div>
);
}
// Desktop layout (unchanged)
return (
<div className="flex-1 flex overflow-hidden relative">
{initialLoading && (
@ -234,28 +405,7 @@ export default function MapPage({
{/* Left Pane */}
<div className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden" style={{ width: leftPaneWidth }}>
<div className="flex-1 flex flex-col 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={mapData.zoom}
itemCount={mapData.usePostcodeView ? mapData.postcodeData.length : mapData.data.length}
usePostcodeView={mapData.usePostcodeView}
pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin}
onCancelPin={handleCancelPin}
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={onClearPendingInfoFeature}
/>
{renderFilters()}
</div>
<div
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
@ -288,6 +438,7 @@ export default function MapPage({
filters={filters}
searchedPostcode={searchedPostcode}
onPostcodeSearched={setSearchedPostcode}
bounds={mapData.bounds}
/>
{mapData.loading && (
<div className="absolute bottom-4 left-4 bg-white dark:bg-navy-800 dark:text-warm-200 px-3 py-1 rounded shadow">
@ -314,45 +465,11 @@ export default function MapPage({
<div className="flex-1 overflow-hidden">
{selection.rightPaneTab === 'area' ? (
<AreaPane
stats={selection.areaStats}
globalFeatures={features}
loading={selection.loadingAreaStats}
hexagonId={selection.selectedHexagon?.id || null}
isPostcode={selection.selectedHexagon?.type === 'postcode'}
postcodeData={
selection.selectedHexagon?.type === 'postcode'
? mapData.postcodeData.find((f) => f.properties.postcode === selection.selectedHexagon?.id) || null
: null
}
onViewProperties={selection.handleViewPropertiesFromArea}
onClose={selection.handleCloseSelection}
hexagonLocation={hexagonLocation}
filters={filters}
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
aiSummary={aiSummary.summary}
aiSummaryLoading={aiSummary.loading}
aiSummaryError={aiSummary.error}
onRetryAiSummary={aiSummary.retry}
/>
renderAreaPane()
) : selection.rightPaneTab === 'properties' ? (
<PropertiesPane
properties={selection.properties}
total={selection.propertiesTotal}
loading={selection.loadingProperties}
hexagonId={selection.selectedHexagon?.id || null}
onLoadMore={selection.handleLoadMoreProperties}
onClose={selection.handleCloseSelection}
onNavigateToSource={(slug) => onNavigateTo('data-sources', slug)}
/>
renderPropertiesPane()
) : (
<POIPane
groups={poiCategoryGroups}
selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length}
onNavigateToSource={(slug) => onNavigateTo('data-sources', slug)}
/>
renderPOIPane()
)}
</div>
</div>

View file

@ -0,0 +1,59 @@
import { useState, useEffect } from 'react';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { TabButton } from '../ui/TabButton';
type DrawerTab = 'area' | 'properties' | 'pois';
interface MobileDrawerProps {
onClose: () => void;
renderArea: () => React.ReactNode;
renderProperties: () => React.ReactNode;
renderPOIs: () => React.ReactNode;
}
export default function MobileDrawer({
onClose,
renderArea,
renderProperties,
renderPOIs,
}: MobileDrawerProps) {
const [tab, setTab] = useState<DrawerTab>('area');
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex flex-col">
{/* Backdrop — top 10% */}
<div className="h-[10%] bg-black/50" onClick={onClose} />
{/* Panel — bottom 90% */}
<div className="h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col shadow-xl overflow-hidden">
{/* Tab bar + close */}
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm shrink-0">
<TabButton label="Area" isActive={tab === 'area'} onClick={() => setTab('area')} />
<TabButton label="Properties" isActive={tab === 'properties'} onClick={() => setTab('properties')} />
<TabButton label="POIs" isActive={tab === 'pois'} onClick={() => setTab('pois')} />
<button
onClick={onClose}
className="ml-auto flex items-center justify-center w-10 h-10 rounded-lg hover:bg-warm-100 dark:hover:bg-navy-800"
aria-label="Close drawer"
>
<CloseIcon className="w-5 h-5 text-warm-500 dark:text-warm-400" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden">
{tab === 'area' ? renderArea() : tab === 'properties' ? renderProperties() : renderPOIs()}
</div>
</div>
</div>
);
}

View file

@ -15,7 +15,7 @@ export default function AuthModal({
}: {
onClose: () => void;
onLogin: (email: string, password: string) => Promise<void>;
onRegister: (email: string, password: string, name?: string) => Promise<void>;
onRegister: (email: string, password: string) => Promise<void>;
onForgotPassword: (email: string) => Promise<void>;
loading: boolean;
error: string | null;
@ -25,7 +25,6 @@ export default function AuthModal({
const [view, setView] = useState<View>(initialTab);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [resetSent, setResetSent] = useState(false);
const switchView = useCallback(
@ -45,7 +44,7 @@ export default function AuthModal({
await onLogin(email, password);
onClose();
} else if (view === 'register') {
await onRegister(email, password, name || undefined);
await onRegister(email, password);
onClose();
} else {
await onForgotPassword(email);
@ -55,7 +54,7 @@ export default function AuthModal({
// Error is handled by the hook
}
},
[view, email, password, name, onLogin, onRegister, onForgotPassword, onClose]
[view, email, password, onLogin, onRegister, onForgotPassword, onClose]
);
const title =
@ -107,21 +106,6 @@ export default function AuthModal({
{/* Form */}
<form onSubmit={handleSubmit} className="p-5 space-y-4">
{view === 'register' && (
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="Your name (optional)"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Email

View file

@ -1,10 +1,12 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import { DownloadIcon } from './icons/DownloadIcon';
import { BookmarkIcon } from './icons/BookmarkIcon';
import { MapPinIcon } from './icons/MapPinIcon';
import { CheckIcon } from './icons/CheckIcon';
import { ClipboardIcon } from './icons/ClipboardIcon';
import { CloseIcon } from './icons/CloseIcon';
import { MenuIcon } from './icons/MenuIcon';
import { SunIcon } from './icons/SunIcon';
import { MoonIcon } from './icons/MoonIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
@ -25,6 +27,7 @@ export default function Header({
onLoginClick,
onRegisterClick,
onLogout,
isMobile,
}: {
activePage: Page;
onPageChange: (page: Page) => void;
@ -38,8 +41,25 @@ export default function Header({
onLoginClick: () => void;
onRegisterClick: () => void;
onLogout: () => void;
isMobile: boolean;
}) {
const [copied, setCopied] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
// Close menu on Escape
useEffect(() => {
if (!menuOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenuOpen(false);
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [menuOpen]);
// Close menu when switching away from mobile
useEffect(() => {
if (!isMobile) setMenuOpen(false);
}, [isMobile]);
const handleShare = useCallback(() => {
const url = window.location.href;
@ -69,8 +89,24 @@ export default function Header({
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
}`;
const mobileNavItem = (page: Page, label: string) => (
<button
key={page}
className={`w-full text-left px-4 py-3 text-base font-medium rounded ${
activePage === page ? 'bg-navy-700 text-white' : 'text-warm-300 hover:bg-navy-800 hover:text-white'
}`}
onClick={() => {
onPageChange(page);
setMenuOpen(false);
}}
>
{label}
</button>
);
return (
<header className="h-12 bg-navy-900 text-white flex items-center justify-between px-4 shrink-0">
<header className="h-12 bg-navy-900 text-white flex items-center px-4 shrink-0">
{/* Left: Logo + nav */}
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
@ -79,25 +115,32 @@ export default function Header({
<MapPinIcon className="w-5 h-5 text-teal-400" />
<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>
{user && (
<button className={tabClass('saved-searches')} onClick={() => onPageChange('saved-searches')}>
Saved
{/* Desktop nav */}
{!isMobile && (
<nav className="flex items-center gap-2">
<button className={tabClass('dashboard')} onClick={() => onPageChange('dashboard')}>
Dashboard
</button>
)}
</nav>
{user && (
<button className={tabClass('saved-searches')} onClick={() => onPageChange('saved-searches')}>
Saved
</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">
{activePage === 'dashboard' && (
{/* Right side */}
<div className="flex items-center gap-2 ml-auto">
{/* Desktop-only dashboard actions */}
{!isMobile && activePage === 'dashboard' && (
<>
{onSaveSearch && (
<button
@ -140,32 +183,166 @@ export default function Header({
</button>
</>
)}
{user ? (
<UserMenu user={user} onLogout={onLogout} />
) : (
{/* Desktop-only auth */}
{!isMobile && (
<>
<button
onClick={onLoginClick}
className="px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
>
Log in
</button>
<button
onClick={onRegisterClick}
className="px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium"
>
Register
</button>
{user ? (
<UserMenu user={user} onLogout={onLogout} />
) : (
<>
<button
onClick={onLoginClick}
className="px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
>
Log in
</button>
<button
onClick={onRegisterClick}
className="px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium"
>
Register
</button>
</>
)}
</>
)}
<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' ? <SunIcon className="w-4 h-4" /> : <MoonIcon className="w-4 h-4" />}
</button>
{/* Mobile auth CTA (logged out only) */}
{isMobile && !user && (
<button
onClick={onRegisterClick}
className="px-4 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-semibold"
>
Sign up
</button>
)}
{/* Theme toggle (desktop only) */}
{!isMobile && (
<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' ? <SunIcon className="w-4 h-4" /> : <MoonIcon className="w-4 h-4" />}
</button>
)}
{/* Mobile hamburger */}
{isMobile && (
<button
onClick={() => setMenuOpen(true)}
className="flex items-center justify-center w-10 h-10 -mr-2 rounded hover:bg-navy-800 transition-colors"
aria-label="Open menu"
>
<MenuIcon className="w-6 h-6" />
</button>
)}
</div>
{/* Mobile slide-in menu */}
{isMobile && menuOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setMenuOpen(false)}
/>
{/* Menu panel */}
<div className="fixed top-0 right-0 bottom-0 w-64 bg-navy-900 z-50 flex flex-col shadow-xl">
<div className="flex items-center justify-between px-4 h-12 border-b border-navy-700">
<span className="font-semibold">Menu</span>
<button
onClick={() => setMenuOpen(false)}
className="flex items-center justify-center w-10 h-10 -mr-2 rounded hover:bg-navy-800 transition-colors"
aria-label="Close menu"
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
<nav className="flex-1 flex flex-col gap-1 p-3 overflow-y-auto">
{mobileNavItem('dashboard', 'Dashboard')}
{user && mobileNavItem('saved-searches', 'Saved')}
{mobileNavItem('data-sources', 'Data Sources')}
{mobileNavItem('faq', 'FAQ')}
{/* Dashboard actions */}
{activePage === 'dashboard' && (
<div className="mt-3 pt-3 border-t border-navy-700 flex flex-col gap-1">
{onSaveSearch && (
<button
onClick={() => { onSaveSearch(); setMenuOpen(false); }}
disabled={savingSearch}
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded disabled:opacity-50"
>
{savingSearch ? <SpinnerIcon className="w-5 h-5 animate-spin" /> : <BookmarkIcon className="w-5 h-5" />}
Save
</button>
)}
<button
onClick={() => { handleShare(); setMenuOpen(false); }}
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded"
>
{copied ? <CheckIcon className="w-5 h-5" /> : <ClipboardIcon className="w-5 h-5" />}
{copied ? 'Copied!' : 'Share'}
</button>
<button
onClick={() => { onExport?.(); setMenuOpen(false); }}
disabled={!onExport || exporting}
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded disabled:opacity-50"
>
<DownloadIcon className="w-5 h-5" />
{exporting ? 'Exporting...' : 'Export'}
</button>
</div>
)}
</nav>
{/* Theme toggle + Auth section at bottom */}
<div className="p-3 border-t border-navy-700 flex flex-col gap-3">
{/* Theme toggle */}
<button
onClick={() => { onToggleTheme(); }}
className="w-full flex items-center gap-3 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded transition-colors"
>
{theme === 'light' ? <SunIcon className="w-5 h-5" /> : <MoonIcon className="w-5 h-5" />}
<span>Theme: {theme === 'light' ? 'Light' : 'Dark'}</span>
</button>
{/* Auth buttons */}
<div>
{user ? (
<div className="flex items-center justify-between px-4 py-2">
<span className="text-sm text-warm-300 truncate">{user.email}</span>
<button
onClick={() => { onLogout(); setMenuOpen(false); }}
className="text-sm text-warm-400 hover:text-white"
>
Log out
</button>
</div>
) : (
<div className="flex gap-2">
<button
onClick={() => { onLoginClick(); setMenuOpen(false); }}
className="flex-1 px-3 py-2.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm text-center"
>
Log in
</button>
<button
onClick={() => { onRegisterClick(); setMenuOpen(false); }}
className="flex-1 px-3 py-2.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium text-center"
>
Register
</button>
</div>
)}
</div>
</div>
</div>
</>
)}
</header>
);
}

View file

@ -17,14 +17,14 @@ export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout:
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
const initial = (user.name || user.email)[0].toUpperCase();
const initial = user.email[0].toUpperCase();
return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setOpen((prev) => !prev)}
className="flex items-center justify-center w-8 h-8 rounded-full bg-teal-600 text-white text-sm font-medium hover:bg-teal-700"
title={user.name || user.email}
title={user.email}
>
{initial}
</button>
@ -32,12 +32,7 @@ export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout:
{open && (
<div className="absolute right-0 top-10 w-56 bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-lg z-50">
<div className="px-4 py-3 border-b border-warm-200 dark:border-warm-700">
{user.name && (
<p className="text-sm font-medium text-navy-950 dark:text-warm-100 truncate">
{user.name}
</p>
)}
<p className="text-xs text-warm-500 dark:text-warm-400 truncate">{user.email}</p>
<p className="text-sm font-medium text-navy-950 dark:text-warm-100 truncate">{user.email}</p>
</div>
<div className="p-1">
<button

View file

@ -0,0 +1,17 @@
interface IconProps {
className?: string;
}
export function MenuIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
);
}

View file

@ -5,3 +5,4 @@ export { PlusIcon } from './PlusIcon';
export { ChevronIcon } from './ChevronIcon';
export { FilterIcon } from './FilterIcon';
export { LightbulbIcon } from './LightbulbIcon';
export { MenuIcon } from './MenuIcon';

View file

@ -65,7 +65,7 @@ export function useAreaSummary({
name: f.name,
mean: f.mean,
})),
enum_stats: stats.enum_features.map((f) => ({
enum_stats: stats.enum_features.filter(f => !FORBIDDEN_FEATURES.includes(f.name)).map((f) => ({
name: f.name,
counts: f.counts,
})),

View file

@ -4,8 +4,6 @@ import pb from '../lib/pocketbase';
export interface AuthUser {
id: string;
email: string;
name: string;
avatar: string;
verified: boolean;
}
@ -15,8 +13,6 @@ function recordToUser(record: any): AuthUser {
return {
id: record.id || '',
email: record.email || '',
name: record.name || '',
avatar: record.avatar || '',
verified: record.verified || false,
};
}
@ -58,7 +54,7 @@ export function useAuth() {
}
}, []);
const register = useCallback(async (email: string, password: string, name?: string) => {
const register = useCallback(async (email: string, password: string) => {
setLoading(true);
setError(null);
try {
@ -66,7 +62,6 @@ export function useAuth() {
email,
password,
passwordConfirm: password,
name: name || '',
});
// Auto-login after registration
const result = await pb.collection('users').authWithPassword(email, password);

View file

@ -68,12 +68,13 @@ export function useHexagonSelection({
const minVal = props[`min_${f.name}`];
const maxVal = props[`max_${f.name}`];
if (typeof minVal !== 'number' || typeof maxVal !== 'number') continue;
const avgVal = props[`avg_${f.name}`];
numeric_features.push({
name: f.name,
count: props.count,
min: minVal,
max: maxVal,
mean: (minVal + maxVal) / 2,
mean: typeof avgVal === 'number' ? avgVal : (minVal + maxVal) / 2,
});
}

View file

@ -0,0 +1,16 @@
import { useState, useEffect } from 'react';
const MOBILE_QUERY = '(max-width: 767px)';
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState(() => window.matchMedia(MOBILE_QUERY).matches);
useEffect(() => {
const mql = window.matchMedia(MOBILE_QUERY);
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
return isMobile;
}

View file

@ -10,6 +10,19 @@ import type {
} from '../types';
import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/map-utils';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
import { cellToLatLng } from 'h3-js';
/** Return the p-th percentile (0100) from a sorted array via linear interpolation. */
function percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0;
if (sorted.length === 1) return sorted[0];
const idx = (p / 100) * (sorted.length - 1);
const lo = Math.floor(idx);
const hi = Math.ceil(idx);
if (lo === hi) return sorted[lo];
return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
}
const DEBOUNCE_MS = 150;
@ -118,7 +131,8 @@ export function useMapData({
const data = dragData ?? rawData;
// Compute actual min/max from visible data for the viewed feature
// Compute p5/p95 from visible data for the viewed feature
// Only considers hexagons/postcodes whose center falls within the viewport bounds
const dataRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
const meta = features.find((f) => f.name === viewFeature);
@ -126,32 +140,34 @@ export function useMapData({
if (activeFeature && !dragData) return null;
let min = Infinity;
let max = -Infinity;
const vals: number[] = [];
if (usePostcodeView) {
if (postcodeData.length === 0) return null;
for (const feat of postcodeData) {
const val = feat.properties[`min_${viewFeature}`];
if (typeof val === 'number' && !isNaN(val)) {
min = Math.min(min, val);
max = Math.max(max, val);
if (bounds) {
const [lng, lat] = feat.properties.centroid as [number, number];
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east) continue;
}
const val = feat.properties[`avg_${viewFeature}`] ?? feat.properties[`min_${viewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
} else {
if (data.length === 0) return null;
for (const item of data) {
const val = item[`min_${viewFeature}`];
if (typeof val === 'number' && !isNaN(val)) {
min = Math.min(min, val);
max = Math.max(max, val);
if (bounds) {
const [lat, lng] = cellToLatLng(item.h3);
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east) continue;
}
const val = item[`avg_${viewFeature}`] ?? item[`min_${viewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
}
if (min === Infinity || max === -Infinity) return null;
return [min, max];
}, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature]);
if (vals.length === 0) return null;
vals.sort((a, b) => a - b);
return [percentile(vals, COLOR_RANGE_LOW_PERCENTILE), percentile(vals, COLOR_RANGE_HIGH_PERCENTILE)];
}, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature, bounds]);
// Color range for the legend and hex coloring
const colorRange = useMemo((): [number, number] | null => {

View file

@ -1,5 +1,5 @@
const DOMAIN = 'narrowit.schmelczer.dev';
const ENDPOINT = '/status';
const ENDPOINT = 'https://stats.schmelczer.dev/status';
const IS_DEV = process.env.NODE_ENV !== 'production';
type EventOptions = {
@ -32,8 +32,9 @@ function sendEvent(name: string, options?: EventOptions) {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'omit',
keepalive: true,
}).catch(() => {});
}).catch(() => { });
}
}

View file

@ -6,8 +6,10 @@ html,
body,
#root {
height: 100%;
height: 100dvh;
margin: 0;
padding: 0;
overflow: hidden;
overscroll-behavior: none;
}

View file

@ -5,6 +5,11 @@ export const INITIAL_RETRY_MS = 1000;
export const MAX_RETRY_MS = 10000;
/** Lower percentile for color-range clipping (0100) */
export const COLOR_RANGE_LOW_PERCENTILE = 5;
/** Upper percentile for color-range clipping (0100) */
export const COLOR_RANGE_HIGH_PERCENTILE = 95;
export const MAP_BOUNDS: [number, number, number, number] = [-9.5, 49, 5, 57];
export const MAP_MIN_ZOOM = 5.5;

View file

@ -58,7 +58,7 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
protomaps: {
type: 'vector',
tiles: [tileUrl],
maxzoom: 17,
maxzoom: 15,
},
},
layers: modifiedLayers,